fix(cron): isolate cron jobs by workspace (issue #37)

This commit is contained in:
matevip 2026-04-29 15:49:04 +08:00
parent bd36f87cc9
commit 0a5b1989f3
8 changed files with 153 additions and 48 deletions

View File

@ -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;
}
}

View File

@ -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());

View File

@ -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;

View File

@ -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);
}

View File

@ -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();

View File

@ -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

View File

@ -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);

View File

@ -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;