mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(cron): isolate cron jobs by workspace (issue #37)
This commit is contained in:
parent
bd36f87cc9
commit
0a5b1989f3
@ -24,55 +24,75 @@ public class CronJobController {
|
||||
|
||||
private final CronJobService cronJobService;
|
||||
|
||||
/**
|
||||
* RFC-083: every endpoint reads {@code X-Workspace-Id} (the frontend
|
||||
* axios interceptor already injects it). Service-layer filtering is
|
||||
* required because {@link vip.mate.config.WorkspaceAccessInterceptor}
|
||||
* skips its membership check entirely for global {@code admin} users —
|
||||
* relying on the interceptor alone leaks cron jobs across workspaces.
|
||||
*/
|
||||
private static final long DEFAULT_WORKSPACE_ID = 1L;
|
||||
|
||||
@Operation(summary = "获取定时任务列表")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<CronJobDTO>> list() {
|
||||
return R.ok(cronJobService.list());
|
||||
public R<List<CronJobDTO>> list(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(cronJobService.list(resolve(workspaceId)));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取定时任务详情")
|
||||
@GetMapping("/{id}")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<CronJobDTO> get(@PathVariable Long id) {
|
||||
return R.ok(cronJobService.getById(id));
|
||||
public R<CronJobDTO> get(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(cronJobService.getById(id, resolve(workspaceId)));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建定时任务")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<CronJobDTO> create(@RequestBody CronJobDTO dto) {
|
||||
return R.ok(cronJobService.create(dto));
|
||||
public R<CronJobDTO> create(@RequestBody CronJobDTO dto,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(cronJobService.create(dto, resolve(workspaceId)));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新定时任务")
|
||||
@PutMapping("/{id}")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<CronJobDTO> update(@PathVariable Long id, @RequestBody CronJobDTO dto) {
|
||||
return R.ok(cronJobService.update(id, dto));
|
||||
public R<CronJobDTO> update(@PathVariable Long id, @RequestBody CronJobDTO dto,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(cronJobService.update(id, dto, resolve(workspaceId)));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除定时任务")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
cronJobService.delete(id);
|
||||
public R<Void> delete(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
cronJobService.delete(id, resolve(workspaceId));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "启用/禁用定时任务")
|
||||
@PutMapping("/{id}/toggle")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
|
||||
cronJobService.toggle(id, enabled);
|
||||
public R<Void> toggle(@PathVariable Long id, @RequestParam boolean enabled,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
cronJobService.toggle(id, enabled, resolve(workspaceId));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "立即执行定时任务")
|
||||
@PostMapping("/{id}/run")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> runNow(@PathVariable Long id) {
|
||||
cronJobService.runNow(id);
|
||||
public R<Void> runNow(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
cronJobService.runNow(id, resolve(workspaceId));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private static long resolve(Long headerWorkspaceId) {
|
||||
return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ import java.time.LocalDateTime;
|
||||
public class CronJobDTO {
|
||||
|
||||
private Long id;
|
||||
/** Out-only: workspace ID stamped by the server from X-Workspace-Id (RFC-083). */
|
||||
private Long workspaceId;
|
||||
private String name;
|
||||
private String cronExpression;
|
||||
private String timezone;
|
||||
@ -54,6 +56,7 @@ public class CronJobDTO {
|
||||
public static CronJobDTO from(CronJobEntity entity) {
|
||||
CronJobDTO dto = new CronJobDTO();
|
||||
dto.setId(entity.getId());
|
||||
dto.setWorkspaceId(entity.getWorkspaceId());
|
||||
dto.setName(entity.getName());
|
||||
dto.setCronExpression(entity.getCronExpression());
|
||||
dto.setTimezone(entity.getTimezone());
|
||||
|
||||
@ -18,6 +18,9 @@ public class CronJobEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** Workspace ID this cron job belongs to (RFC-083 / V62; existing rows default to 1). */
|
||||
private Long workspaceId;
|
||||
|
||||
/** 任务名称 */
|
||||
private String name;
|
||||
|
||||
|
||||
@ -17,13 +17,16 @@ import java.util.List;
|
||||
public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||
|
||||
/**
|
||||
* RFC-063r §2.14: list cron jobs together with their most-recent
|
||||
* delivery status / error (subquery against {@code mate_cron_job_run}).
|
||||
* RFC-063r §2.14 + RFC-083: list cron jobs in the given workspace together
|
||||
* with their most-recent delivery status / error (subquery against
|
||||
* {@code mate_cron_job_run}).
|
||||
*
|
||||
* <p>Both H2 and MySQL accept {@code LIMIT 1} inside a correlated
|
||||
* subquery, so the same SQL is portable across the two profiles. Index
|
||||
* coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created
|
||||
* by V1 baseline migration) makes the subquery cheap.
|
||||
* by V1 baseline migration) makes the subquery cheap; V62 adds
|
||||
* {@code idx_cron_job_workspace(workspace_id, deleted)} for the outer
|
||||
* filter.
|
||||
*
|
||||
* <p>Filters out logically-deleted rows and orders by create_time DESC
|
||||
* to mirror the existing {@code list()} ordering.
|
||||
@ -37,14 +40,16 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||
WHERE r.cron_job_id = j.id
|
||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||
FROM mate_cron_job j
|
||||
WHERE j.deleted = 0
|
||||
WHERE j.deleted = 0 AND j.workspace_id = #{workspaceId}
|
||||
ORDER BY j.create_time DESC
|
||||
""")
|
||||
List<CronJobEntity> selectListWithDeliveryStatus();
|
||||
List<CronJobEntity> selectListWithDeliveryStatus(@Param("workspaceId") Long workspaceId);
|
||||
|
||||
/**
|
||||
* RFC-063r §2.14: per-job variant for the detail page. Same subquery
|
||||
* pattern, restricted to a single id.
|
||||
* RFC-063r §2.14 + RFC-083: per-job variant for the detail page. Same
|
||||
* subquery pattern, restricted to a single id within the given workspace
|
||||
* (cross-workspace access returns null → caller throws not_found, matching
|
||||
* the "deleted" shape so workspace existence isn't enumerable).
|
||||
*/
|
||||
@Select("""
|
||||
SELECT j.*,
|
||||
@ -55,7 +60,17 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||
WHERE r.cron_job_id = j.id
|
||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||
FROM mate_cron_job j
|
||||
WHERE j.id = #{id} AND j.deleted = 0
|
||||
WHERE j.id = #{id} AND j.deleted = 0 AND j.workspace_id = #{workspaceId}
|
||||
""")
|
||||
CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id);
|
||||
CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id,
|
||||
@Param("workspaceId") Long workspaceId);
|
||||
|
||||
/**
|
||||
* RFC-083: workspace-scoped lookup for write paths (update / delete /
|
||||
* toggle / runNow). Skips the delivery-status subquery — those paths
|
||||
* don't need it and pay for the correlated lookup otherwise.
|
||||
*/
|
||||
@Select("SELECT * FROM mate_cron_job WHERE id = #{id} AND deleted = 0 AND workspace_id = #{workspaceId}")
|
||||
CronJobEntity selectByIdAndWorkspace(@Param("id") Long id,
|
||||
@Param("workspaceId") Long workspaceId);
|
||||
}
|
||||
|
||||
@ -93,6 +93,10 @@ public class CronJobService implements ApplicationRunner {
|
||||
scheduler.setThreadNamePrefix("cron-job-");
|
||||
scheduler.initialize();
|
||||
|
||||
// RFC-083: scheduler is process-global by design — load every enabled
|
||||
// job across all workspaces. Do NOT filter by workspace_id here,
|
||||
// otherwise jobs in workspace B stop firing whenever the active UI
|
||||
// workspace is A. Workspace isolation lives in the CRUD paths only.
|
||||
List<CronJobEntity> enabledJobs = cronJobMapper.selectList(
|
||||
new LambdaQueryWrapper<CronJobEntity>()
|
||||
.eq(CronJobEntity::getEnabled, true));
|
||||
@ -114,11 +118,12 @@ public class CronJobService implements ApplicationRunner {
|
||||
|
||||
// ==================== CRUD ====================
|
||||
|
||||
public List<CronJobDTO> list() {
|
||||
public List<CronJobDTO> list(Long workspaceId) {
|
||||
// RFC-063r §2.14: use the variant that aggregates the most-recent
|
||||
// delivery_status from mate_cron_job_run so the list page can
|
||||
// render the "最近投递" badge without a per-row N+1 query.
|
||||
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus();
|
||||
// RFC-083: scoped to the caller's workspace.
|
||||
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus(workspaceId);
|
||||
|
||||
// 批量加载 Agent 名称
|
||||
List<Long> agentIds = entities.stream()
|
||||
@ -152,10 +157,13 @@ public class CronJobService implements ApplicationRunner {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public CronJobDTO getById(Long id) {
|
||||
public CronJobDTO getById(Long id, Long workspaceId) {
|
||||
// RFC-063r §2.14: detail page shows lastDeliveryStatus too — same
|
||||
// subquery shape, restricted to one id.
|
||||
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id);
|
||||
// RFC-083: scoped to the caller's workspace; cross-workspace ID access
|
||||
// surfaces as not_found (same shape as deleted) so workspace existence
|
||||
// is not enumerable.
|
||||
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id, workspaceId);
|
||||
if (entity == null) {
|
||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||
}
|
||||
@ -168,12 +176,15 @@ public class CronJobService implements ApplicationRunner {
|
||||
return dto;
|
||||
}
|
||||
|
||||
public CronJobDTO create(CronJobDTO dto) {
|
||||
public CronJobDTO create(CronJobDTO dto, Long workspaceId) {
|
||||
validateDto(dto);
|
||||
// toSpringCron 校验表达式合法性,结果复用于后续 calcNextRunTime 和 register
|
||||
String springCron = toSpringCron(dto.getCronExpression());
|
||||
|
||||
CronJobEntity entity = dto.toEntity();
|
||||
// RFC-083: workspace stamped server-side from X-Workspace-Id; never
|
||||
// trust a client-supplied value (DTO.toEntity intentionally drops it).
|
||||
entity.setWorkspaceId(workspaceId);
|
||||
if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai");
|
||||
if (entity.getTaskType() == null) entity.setTaskType("text");
|
||||
if (entity.getEnabled() == null) entity.setEnabled(true);
|
||||
@ -186,11 +197,13 @@ public class CronJobService implements ApplicationRunner {
|
||||
register(entity);
|
||||
}
|
||||
|
||||
return getById(entity.getId());
|
||||
return getById(entity.getId(), workspaceId);
|
||||
}
|
||||
|
||||
public CronJobDTO update(Long id, CronJobDTO dto) {
|
||||
CronJobEntity existing = cronJobMapper.selectById(id);
|
||||
public CronJobDTO update(Long id, CronJobDTO dto, Long workspaceId) {
|
||||
// RFC-083: scoped lookup — cross-workspace updates 404 the same as
|
||||
// deleted rows.
|
||||
CronJobEntity existing = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||
if (existing == null) {
|
||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||
}
|
||||
@ -222,11 +235,11 @@ public class CronJobService implements ApplicationRunner {
|
||||
schedulerLock.unlock();
|
||||
}
|
||||
|
||||
return getById(id);
|
||||
return getById(id, workspaceId);
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
||||
public void delete(Long id, Long workspaceId) {
|
||||
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||
if (entity == null) {
|
||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||
}
|
||||
@ -239,8 +252,8 @@ public class CronJobService implements ApplicationRunner {
|
||||
cronJobMapper.deleteById(id);
|
||||
}
|
||||
|
||||
public void toggle(Long id, Boolean enabled) {
|
||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
||||
public void toggle(Long id, Boolean enabled, Long workspaceId) {
|
||||
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||
if (entity == null) {
|
||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||
}
|
||||
@ -267,8 +280,8 @@ public class CronJobService implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
public void runNow(Long id) {
|
||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
||||
public void runNow(Long id, Long workspaceId) {
|
||||
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||
if (entity == null) {
|
||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||
}
|
||||
@ -309,7 +322,8 @@ public class CronJobService implements ApplicationRunner {
|
||||
}
|
||||
}), trigger);
|
||||
scheduledTasks.put(job.getId(), future);
|
||||
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
|
||||
log.info("[CronJob] Registered job {} ({}) ws={}, cron={}, tz={}",
|
||||
job.getId(), job.getName(), job.getWorkspaceId(),
|
||||
job.getCronExpression(), job.getTimezone());
|
||||
} finally {
|
||||
schedulerLock.unlock();
|
||||
|
||||
@ -81,7 +81,11 @@ public class CronJobTool {
|
||||
// reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
|
||||
propagateChannelBinding(dto, origin);
|
||||
|
||||
CronJobDTO created = cronJobService.create(dto);
|
||||
// RFC-083: stamp workspace from the originating ChatOrigin so the
|
||||
// cron job is created in the agent's current workspace; fall back
|
||||
// to the default workspace when origin is unscoped (legacy paths).
|
||||
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
||||
CronJobDTO created = cronJobService.create(dto, workspaceId);
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("success", true);
|
||||
@ -101,9 +105,12 @@ public class CronJobTool {
|
||||
|
||||
@Tool(description = "List all scheduled tasks (cron jobs) for the current agent. "
|
||||
+ "Returns task name, cron expression, next run time, enabled status, and last run time.")
|
||||
public String list_cron_jobs() {
|
||||
public String list_cron_jobs(@Nullable ToolContext ctx) {
|
||||
try {
|
||||
List<CronJobDTO> jobs = cronJobService.list();
|
||||
// RFC-083: scope to the originating workspace so an agent only
|
||||
// sees the cron jobs of the workspace it's running in.
|
||||
Long workspaceId = workspaceFromContext(ctx);
|
||||
List<CronJobDTO> jobs = cronJobService.list(workspaceId);
|
||||
JSONArray arr = new JSONArray();
|
||||
for (CronJobDTO job : jobs) {
|
||||
JSONObject obj = new JSONObject();
|
||||
@ -132,10 +139,13 @@ public class CronJobTool {
|
||||
+ "Use list_cron_jobs first to find the job ID.")
|
||||
public String toggle_cron_job(
|
||||
@ToolParam(description = "Job ID (number)") Long jobId,
|
||||
@ToolParam(description = "true to enable, false to disable") Boolean enabled) {
|
||||
@ToolParam(description = "true to enable, false to disable") Boolean enabled,
|
||||
@Nullable ToolContext ctx) {
|
||||
try {
|
||||
cronJobService.toggle(jobId, enabled);
|
||||
CronJobDTO updated = cronJobService.getById(jobId);
|
||||
// RFC-083: scope toggle to the originating workspace.
|
||||
Long workspaceId = workspaceFromContext(ctx);
|
||||
cronJobService.toggle(jobId, enabled, workspaceId);
|
||||
CronJobDTO updated = cronJobService.getById(jobId, workspaceId);
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("success", true);
|
||||
result.set("jobId", jobId);
|
||||
@ -153,11 +163,14 @@ public class CronJobTool {
|
||||
@Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. "
|
||||
+ "Use list_cron_jobs first to find the job ID.")
|
||||
public String delete_cron_job(
|
||||
@ToolParam(description = "Job ID (number) to delete") Long jobId) {
|
||||
@ToolParam(description = "Job ID (number) to delete") Long jobId,
|
||||
@Nullable ToolContext ctx) {
|
||||
try {
|
||||
CronJobDTO job = cronJobService.getById(jobId);
|
||||
// RFC-083: scope delete to the originating workspace.
|
||||
Long workspaceId = workspaceFromContext(ctx);
|
||||
CronJobDTO job = cronJobService.getById(jobId, workspaceId);
|
||||
String jobName = job.getName();
|
||||
cronJobService.delete(jobId);
|
||||
cronJobService.delete(jobId, workspaceId);
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("success", true);
|
||||
result.set("deleted", jobName);
|
||||
@ -175,6 +188,17 @@ public class CronJobTool {
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-083: resolve the workspace ID from the originating ChatOrigin so
|
||||
* cron-tool reads/writes are scoped to the agent's current workspace.
|
||||
* Falls back to the default workspace (1) when origin is unscoped — same
|
||||
* behaviour as the controller-layer {@code resolve()} helper.
|
||||
*/
|
||||
private Long workspaceFromContext(@Nullable ToolContext ctx) {
|
||||
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.4: propagate the originating channel binding into the cron
|
||||
* job DTO so PR-3's delivery dispatcher can route results back to the
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
-- RFC-083: workspace-isolate cron jobs
|
||||
-- (issue: https://github.com/matevip/mateclaw/issues/37).
|
||||
|
||||
ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_job_workspace ON mate_cron_job(workspace_id, deleted);
|
||||
@ -0,0 +1,20 @@
|
||||
-- RFC-083: workspace-isolate cron jobs
|
||||
-- (issue: https://github.com/matevip/mateclaw/issues/37).
|
||||
|
||||
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_cron_job'
|
||||
AND COLUMN_NAME = 'workspace_id');
|
||||
SET @stmt := IF(@col_exists = 0,
|
||||
'ALTER TABLE mate_cron_job ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_cron_job'
|
||||
AND INDEX_NAME = 'idx_cron_job_workspace');
|
||||
SET @stmt := IF(@idx_exists = 0,
|
||||
'CREATE INDEX idx_cron_job_workspace ON mate_cron_job(workspace_id, deleted)',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
Loading…
Reference in New Issue
Block a user