diff --git a/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java b/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java index 52b3a350..43487571 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java @@ -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() { - return R.ok(cronJobService.list()); + public R> 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 get(@PathVariable Long id) { - return R.ok(cronJobService.getById(id)); + public R 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 create(@RequestBody CronJobDTO dto) { - return R.ok(cronJobService.create(dto)); + public R 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 update(@PathVariable Long id, @RequestBody CronJobDTO dto) { - return R.ok(cronJobService.update(id, dto)); + public R 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 delete(@PathVariable Long id) { - cronJobService.delete(id); + public R 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 toggle(@PathVariable Long id, @RequestParam boolean enabled) { - cronJobService.toggle(id, enabled); + public R 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 runNow(@PathVariable Long id) { - cronJobService.runNow(id); + public R 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; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java index 92b8039e..1b6c4bfe 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java @@ -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()); diff --git a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java index 22f3ee9c..61bc1fe2 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java b/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java index 9e7622bf..e2038bd8 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java @@ -17,13 +17,16 @@ import java.util.List; public interface CronJobMapper extends BaseMapper { /** - * 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}). * *

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. * *

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 { 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 selectListWithDeliveryStatus(); + List 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 { 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); } diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java index ce949fed..9d65470f 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java @@ -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 enabledJobs = cronJobMapper.selectList( new LambdaQueryWrapper() .eq(CronJobEntity::getEnabled, true)); @@ -114,11 +118,12 @@ public class CronJobService implements ApplicationRunner { // ==================== CRUD ==================== - public List list() { + public List 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 entities = cronJobMapper.selectListWithDeliveryStatus(); + // RFC-083: scoped to the caller's workspace. + List entities = cronJobMapper.selectListWithDeliveryStatus(workspaceId); // 批量加载 Agent 名称 List 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(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java index 85a71c76..0837f59b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java @@ -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 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 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 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V62__cron_job_workspace_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V62__cron_job_workspace_id.sql new file mode 100644 index 00000000..3756cd01 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V62__cron_job_workspace_id.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V62__cron_job_workspace_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V62__cron_job_workspace_id.sql new file mode 100644 index 00000000..203dc8fe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V62__cron_job_workspace_id.sql @@ -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;