目的:基于源码对「方案激活 → 拆任务」做代码级深挖,重点验证「拆任务是否幂等」这一用户关注点,并映射到缺陷与用例。 阅读对象:QA / 研发 / 测试架构。本文件为只读分析,不修改源码。 证据来源:
cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.javacfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java(generateTasksFromTemplate第 195–275 行、applyAndActivate第 284–303 行)cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java(第 115 行调用activate)cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java(幂等单测)
PlanActivationService.activate(planId, operatorId)(@Transactional):
① 取方案 plan = selectById(planId)
② 校验 status == "reviewed" ← 守卫:否则抛「方案状态异常,需要先审核通过」
③ 校验 childId 在 users 或 family_members 存在(任一即可)
④ taskPlanService.generateTasksFromTemplate(plan, operatorId) ← 拆任务(建引导任务+模板任务)
⑤ plan.setStatus("active"),写 activatedAt / totalDays(默认14) / completedDays=0
入口:PlanReviewController 规划师审核通过后调 planActivationService.activate(planId, userId)(第 115 行)。另有一条「家长自助」路径 applyAndActivate(跳过审核,直接 active)。
「拆任务」即 generateTasksFromTemplate:按 packageId 从 task_template_items(按 sort_order)批量生成每日/模板任务,并额外生成 1 条「方案启动」引导任务。
generateTasksFromTemplate 开头:
Long existing = taskMapper.selectCount(
new LambdaQueryWrapper<Task>()
.eq(Task::getSourceType, "plan")
.eq(Task::getSourceId, planId)
.ne(Task::getStatus, "cancelled"));
if (existing != null && existing > 0) return 0; // 已生成过任务则跳过
PlanActivationService.activate 还有 status=="reviewed" 守卫:激活后 status=active,再次调 activate 会因状态非 reviewed 而抛异常。generateTasksFromTemplate_已生成过则跳过 覆盖:selectCount 返回 5 → count=0 且 insert 从未被调用。结论 G1(设计良好,P0 通过项):单线程下「拆任务」幂等,重复激活被双重守卫(任务存在性 + 状态机)拦截。
selectCount → insert 是 非原子 的「检查再插入」(TOCTOU),且 无唯一约束 / 乐观锁 兜底:
reviewed 方案被并发激活时,两个事务都先读到 status=reviewed(守卫放行),都进入 generateTasksFromTemplate,都通过 existing==0 检查,随后都执行 insert → 重复生成任务(双份引导任务 + 双份模板任务)。PlanActivationService.activate 的 status=="reviewed" 守卫挡不住并发:两个事务在 setStatus("active") 提交前都看到了 reviewed,全部提交成功。结论 G2(并发缺陷,中/高):高并发下同一方案可能产出重复任务,破坏「1 方案 → 1 套任务」的不变量。与测评预约模块的 F4(创建无幂等)、F7(额度扣减非原子)属同类「检查-执行非原子」问题。
generateTasksFromTemplate 的计数条件 .ne(Task::getStatus, "cancelled") —— 已 cancelled 的任务不计入。因此:方案激活 → cancelPlan(任务置 cancelled)→ 重新激活时 existing==0 → 重新生成任务。这是有意设计(避免取消后漏生成),但需在文档中明确,否则易被误读为幂等漏洞。
| 编号 | 严重度 | 位置 | 现象 | 期望/建议 |
|---|---|---|---|---|
| G1 | 设计良好 | generateTasksFromTemplate 存在性守卫 + activate 状态守卫 |
单线程幂等、重复激活被拒 | 维持;补并发单测 |
| G2 | 中/高 | generateTasksFromTemplate 检查-插入非原子 |
并发激活同方案 → 重复任务 | 加唯一索引 (source_type, source_id) + 冲突忽略;或对 plan 行加 SELECT ... FOR UPDATE 行锁 |
| G3 | 低 | generateTasksFromTemplate 第 239 行 if (plan.getPackageId() != null) |
packageId==null 时只生成引导任务、无模板任务 |
符合预期但需文档化;若套餐被清空,激活后无日常任务且无告警 |
| G4 | 低(待确认) | buildTodayDeadline() 用于所有任务 deadline |
引导任务与模板任务 deadline 均设为「当天 23:59:59」;模板任务多为 frequency=daily,首条即当天过期 |
确认是否设计意图;若是周期任务,deadline 应为 N 天后,否则「每日任务」首日均显示逾期 |
优先级:P0 主链路/并发 · P1 重要 · P2 一般。
| TC 编号 | 场景 | 模块 | 角色 | 优先级 | 类型 | 前置 | 步骤 | 预期 | 关联发现 |
|---|---|---|---|---|---|---|---|---|---|
| TC-PA-01 | 方案审核通过→激活→拆任务 | 激活/拆任务 | 规划师 | P0 | 功能 | 方案 status=reviewed,套餐含 N 个模板项 | 调 activate |
status→active;生成 1 引导 + N 模板任务;能量/流水一致 | G1 |
| TC-PA-02 | 重复激活应被拒(单线程) | 激活/幂等 | 规划师 | P0 | 异常 | 已激活方案 | 再次 activate |
抛「方案状态异常,需要先审核通过」 | G1 |
| TC-PA-03 | 并发激活同方案不重复拆任务 | 激活/并发幂等 | 规划师×2 | P1 | 并发 | 同一 reviewed 方案 | 两线程同时 activate |
任务总数 = 1+ N(不翻倍);若翻倍→【缺陷 G2】 | G2 |
| TC-PA-04 | 取消后重新激活重新拆任务 | 激活/取消 | 规划师 | P1 | 功能 | 已激活→cancelPlan | 重新 activate |
旧任务 cancelled 不计入,重新生成 1+N 任务 | G3 |
| TC-PA-05 | 套餐为空/packageId 空 | 拆任务/边界 | 规划师 | P2 | 边界 | 方案 packageId=null | activate |
仅生成引导任务,无模板任务(文档化) | G3 |
| TC-PA-06 | 周期任务 deadline 合理性 | 拆任务/数据 | 规划师 | P2 | 数据 | daily 模板项 | activate 后查任务 deadline |
非当天过期(待确认设计) | G4 |
task 表加唯一索引 (source_type, source_id)(或 (source_type, source_id, task_type)),插入冲突时 ON DUPLICATE KEY 忽略 / 捕获 DuplicateKeyException 返回 0;或在 activate 中对方案行加 SELECT ... FOR UPDATE 行锁,使并发激活串行化。目的:保证「1 方案 → 1 套任务」。闭环:TC-PA-03 复测任务不翻倍。packageId==null 时仅生成引导任务的设计意图,避免误判。以上为只读分析结论,修复由研发团队实施;本文件仅供测试与评审使用。