面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 修复系统防重复提交机制中的 5 类风险点 —— 补齐数据库唯一约束、统一订单号生成、修复先查后插竞态、补齐前端防重标志、消除弱随机订单号碰撞。
架构: 基于现有「前端体验层 + 后端状态机 + 数据库唯一约束」三层防御体系做定向加固,不引入分布式锁/幂等框架等重设施,保持改动最小化:
guide_orders、payment_orders 补 UNIQUE 约束(最后防线)GrowthRecordService 先查后插竞态submitting 防重标志技术栈: Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / MySQL 8.0 / uni-app Vue 2
| 编号 | 风险 | 严重度 | 位置 |
|---|---|---|---|
| R1 | guide_orders.order_no 无任何索引,重复下单无兜底 |
🔴 高 | schema.sql:1427 |
| R2 | payment_orders.order_no 仅普通索引,支付回调重复有风险 |
🟠 中 | schema.sql:3606 |
| R3 | 订单号弱随机:PO+毫秒、ORD+毫秒、SUB+毫秒 同毫秒并发必撞 |
🟠 中 | ProductOrderService:791、MembershipService:699、SubscriptionController:149、MemberSubscriptionService:100 |
| R4 | GrowthRecordService 先查后插,并发下双查双插竞态 |
🟠 中 | GrowthRecordService:286 |
| R5 | 前端 DAN 测评上传页无防重标志 | 🟡 低 | cfc-frontend/pages/dan-assessment/report-upload.vue |
范围外: 支付回调状态机(
handlePaymentSuccess的"pending"检查)已覆盖微信回调重复场景,无需整改。WechatService的 synchronized 是 token 缓存保护,与防重复无关,不动。
| 文件 | 职责 | 操作 |
|---|---|---|
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java |
运行时 DDL 迁移唯一入口 | 修改:追加迁移 158、159 |
cfc-backend/src/main/resources/schema.sql |
建表完整快照 | 修改:同步唯一约束 |
cfc-backend/src/main/java/com/etotem/cfc/common/OrderNoGenerator.java |
统一订单号生成工具(新建) | 创建 |
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java |
商城订单 | 修改:generateOrderNo() 改用生成器 |
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java |
会员订单 | 修改:generateOrderNo() 改用生成器 |
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePurchaseService.java |
套餐订单 | 修改:generateOrderNo() 改用生成器 |
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java |
套餐支付订单 | 修改:generateOrderNo() 改用生成器 |
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java |
测评订单 | 修改:generateOrderNo() 改用生成器 |
cfc-backend/src/main/java/com/etotem/cfc/controller/subscription/SubscriptionController.java |
订阅下单(内联生成) | 修改:改用生成器 |
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java |
订阅下单(内联生成) | 修改:改用生成器 |
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecordService.java |
成长记录入库 | 修改:竞态修复 |
cfc-frontend/pages/dan-assessment/report-upload.vue |
DAN 测评报告上传 | 修改:补防重标志 |
guide_orders.order_no 唯一约束文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations() 末尾)
[ ] 步骤 1:确认当前最新迁移编号
运行:grep -n "// 迁移" cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java | tail -5
预期:最新编号为 // 迁移157(另有非编号 // 迁移20240709),新迁移从 158 开始。
在 DatabaseInitializer.java 的 runMigrations() 方法末尾(// 迁移20240709 块之后)添加:
// 迁移158: guide_orders表order_no加唯一约束(防重复下单,整改R1)
try {
// 先清理历史重复数据:每组order_no保留id最小的一行
jdbcTemplate.execute("DELETE g1 FROM guide_orders g1 INNER JOIN guide_orders g2 " +
"ON g1.order_no = g2.order_no AND g1.id > g2.id");
log.info("已清理guide_orders重复order_no");
} catch (Exception e) {
// 忽略:无重复数据时删除0行,不报错
}
try {
jdbcTemplate.execute("ALTER TABLE guide_orders ADD UNIQUE KEY uk_order_no (order_no)");
log.info("已为guide_orders.order_no添加唯一约束");
} catch (Exception e) {
// 约束已存在,忽略
}
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
payment_orders.order_no 升级为唯一约束文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations() 末尾,任务 1 之后)
[ ] 步骤 1:在 runMigrations() 末尾追加迁移 159
// 迁移159: payment_orders表order_no升级为唯一约束(防支付回调重复,整改R2)
try {
jdbcTemplate.execute("DELETE p1 FROM payment_orders p1 INNER JOIN payment_orders p2 " +
"ON p1.order_no = p2.order_no AND p1.id > p2.id");
log.info("已清理payment_orders重复order_no");
} catch (Exception e) {
// 忽略
}
try {
// 先删除原普通索引,再加唯一索引(同名列不可并存)
jdbcTemplate.execute("ALTER TABLE payment_orders DROP INDEX idx_order_no");
jdbcTemplate.execute("ALTER TABLE payment_orders ADD UNIQUE KEY uk_order_no (order_no)");
log.info("已为payment_orders.order_no添加唯一约束");
} catch (Exception e) {
// 索引已存在或已处理,忽略
}
注意: 必须先 DROP 原
idx_order_no普通索引,否则 MySQL 不允许同列同时存在普通索引与唯一索引。若迁移中途失败导致idx_order_no已删而唯一索引未建,重跑时会因uk_order_no存在而忽略,DROP 语句幂等性由 catch 保证。
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
文件:
修改:cfc-backend/src/main/resources/schema.sql
[ ] 步骤 1:更新 guide_orders 表定义(第 1427 行附近)
在 order_no VARCHAR(50) NOT NULL COMMENT '订单号', 行后追加唯一键声明,并删除结尾处若有冲突的旧索引行。修改后:
CREATE TABLE IF NOT EXISTS guide_orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
order_no VARCHAR(50) NOT NULL COMMENT '订单号',
...
UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长规划师订单表';
payment_orders 表定义(第 3591 行附近)将 INDEX idx_order_no (order_no), 替换为 UNIQUE KEY uk_order_no (order_no),:
CREATE TABLE IF NOT EXISTS payment_orders (
...
order_no VARCHAR(64) NOT NULL COMMENT '订单号',
...
UNIQUE KEY uk_order_no (order_no),
INDEX idx_family_id (family_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员支付订单';
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS(确认 schema.sql 改动未影响编译)
OrderNoGenerator文件:
创建:cfc-backend/src/main/java/com/etotem/cfc/common/OrderNoGenerator.java
[ ] 步骤 1:创建工具类
package com.etotem.cfc.common;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
/**
* 统一订单号生成器(整改R3)
* 格式: 前缀 + 毫秒时间戳 + 自增序号(3位) + UUID短段(4位)
* 前缀用于支付回调路由分派(PO=商城/其他=套餐),各业务前缀保持不变
*/
public class OrderNoGenerator {
private static final AtomicLong SEQ = new AtomicLong(0);
private OrderNoGenerator() {
}
public static String generate(String prefix) {
long seq = SEQ.incrementAndGet() % 1000;
String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 4).toUpperCase();
return prefix + System.currentTimeMillis() + String.format("%03d", seq) + uuid;
}
}
设计说明:
前缀参数化,保留 PO/ORD/SUB/PKG/A 等现有前缀,不影响 PaymentService.handleWechatNotify 按前缀路由的逻辑
[ ] 步骤 2:编译验证
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
generateOrderNo()文件:
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java:791cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java:699cfc-backend/src/main/java/com/etotem/cfc/service/PackagePurchaseService.java:202cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java:247cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java:58cfc-backend/src/main/java/com/etotem/cfc/controller/subscription/SubscriptionController.java:149修改:cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java:100
[ ] 步骤 1:替换 ProductOrderService.generateOrderNo()
将方法体替换为一行委托,并新增 import:
// 文件顶部新增 import
import com.etotem.cfc.common.OrderNoGenerator;
// generateOrderNo() 方法体替换为:
private String generateOrderNo() {
return OrderNoGenerator.generate("PO");
}
[ ] 步骤 2:替换 MembershipService.generateOrderNo()
// 新增 import com.etotem.cfc.common.OrderNoGenerator;
private String generateOrderNo() {
return OrderNoGenerator.generate("ORD");
}
[ ] 步骤 3:替换 PackagePurchaseService.generateOrderNo()
// 新增 import com.etotem.cfc.common.OrderNoGenerator;
private String generateOrderNo() {
return OrderNoGenerator.generate("PKG");
}
[ ] 步骤 4:替换 PackagePaymentService.generateOrderNo()
// 新增 import com.etotem.cfc.common.OrderNoGenerator;
private String generateOrderNo() {
return OrderNoGenerator.generate("PKG");
}
[ ] 步骤 5:替换 AssessmentOrderService.generateOrderNo()
// 新增 import com.etotem.cfc.common.OrderNoGenerator;
// 若 generateOrderNo() 变为仅一行,可删除原 java.util.Random import(可选)
private String generateOrderNo() {
return OrderNoGenerator.generate("A");
}
[ ] 步骤 6:替换 SubscriptionController 内联生成(第 149 行)
原文:String orderNo = "SUB" + System.currentTimeMillis();
改为:
// 新增 import com.etotem.cfc.common.OrderNoGenerator;
String orderNo = OrderNoGenerator.generate("SUB");
原文:String orderNo = "SUB" + System.currentTimeMillis();
改为:
// 新增 import com.etotem.cfc.common.OrderNoGenerator;
String orderNo = OrderNoGenerator.generate("SUB");
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS。若出现未使用 import 警告(Random),清理对应 import 后重新编译。
注意: 不要改动
PaymentService.handleWechatNotify中按前缀分派路由的逻辑(PO前缀 → 商城订单),订单号前缀保持不变。
GrowthRecordService 先查后插竞态文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecordService.java:286
[ ] 步骤 1:替换 checkDuplicateAndInsert() 方法
原实现是先 selectOne 再 insert,并发下两个请求同时通过查询就会双插。改为直接插入 + 捕获唯一键冲突,依赖迁移已存在的 uk_growth_dedup_hash 唯一索引兜底:
// 文件顶部新增 import(若未引入)
import org.springframework.dao.DuplicateKeyException;
private void checkDuplicateAndInsert(GrowthRecord record) {
String hash = record.getDedupHash();
if (hash != null && !hash.startsWith("NO_DATE:")) {
try {
growthRecordMapper.insert(record);
} catch (DuplicateKeyException e) {
// 唯一索引 uk_growth_dedup_hash 兜底:并发重复插入被数据库拦截
throw new DuplicateGrowthRecordException("Growth record already exists with hash: " + hash);
}
} else {
growthRecordMapper.insert(record);
}
}
行为说明:
AssessmentService:259 已捕获 DuplicateGrowthRecordException 并静默跳过,调用方无需改动无 hash 或 NO_DATE: 前缀的记录仍走原插入路径(不参与去重,行为不变)
[ ] 步骤 2:编译验证
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
文件:
修改:cfc-frontend/pages/dan-assessment/report-upload.vue
[ ] 步骤 1:添加 submitting 数据字段
在 data() 中新增(找到现有 loading 字段旁):
data() {
return {
// ...现有字段...
submitting: false
}
}
找到提交/上传处理方法(含 self.loading = true 的异步提交逻辑),在方法开头加:
submit: function() {
if (this.submitting) return // 防重复点击
this.submitting = true
// ...现有提交逻辑...
// 在请求回调(成功/失败/完成)的 finally 位置:
this.submitting = false
}
具体模式参照 cfc-frontend/components/feedback-popup.vue:51-71 的既有写法(if (this.submitting) return; this.submitting = true; ... .then(function(){ self.submitting = false }))。
运行:cd cfc-frontend && npm run build:mp-weixin
预期:构建成功,无编译错误
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:
grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | wc -l
预期:无新增路由,数量与改动前一致
检查 DatabaseInitializer.java 中迁移 158/159 的 catch 分支:重复执行时 DROP/ADD 语句均应被 catch 吞掉或自然跳过。确认 DELETE 清理语句在无重复数据时执行 0 行不报错。
[ ] 步骤 4:git 提交(如需)
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
cfc-backend/src/main/resources/schema.sql \
cfc-backend/src/main/java/com/etotem/cfc/common/OrderNoGenerator.java \
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePurchaseService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/subscription/SubscriptionController.java \
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecordService.java \
cfc-frontend/pages/dan-assessment/report-upload.vue
git commit -m "fix: 防重复提交整改——补齐唯一约束/统一订单号/修复竞态"
| 编号 | 风险 | 整改后状态 |
|---|---|---|
| R1 | guide_orders 无唯一键 | ✅ 迁移 158 补齐 UNIQUE KEY |
| R2 | payment_orders 仅普通索引 | ✅ 迁移 159 升级为 UNIQUE KEY |
| R3 | 订单号弱随机碰撞 | ✅ OrderNoGenerator 统一生成(自增序号 + UUID 段) |
| R4 | GrowthRecordService 竞态 | ✅ 直接插入 + DuplicateKeyException 兜底 |
| R5 | 前端 DAN 上传无防重 | ✅ submitting 标志位 |
经用户确认,仅整改订单类不足以覆盖"按钮连点插入两条"风险 —— 用户级创建页(任务、心愿、家庭成员、保险方案等)同样需要防重。已有 submitting 守卫的页面(如 pages/wishes/create.vue、pages/wealth-sub/insurance-add.vue)无需改动;本任务覆盖经确认无 submitting 守卫的页面。
| 文件 | 场景 | 按钮位置 |
|---|---|---|
cfc-frontend/pages/tasks/create-task.vue |
创建任务(连点多孩会双倍创建) | 第 78 行 <button @click="submit"> |
cfc-frontend/pages/family/add-member.vue |
添加家庭成员 | submit 方法 |
cfc-frontend/pages/assessment/apply.vue |
测评申请(创建订单前) | submit 方法 |
cfc-frontend/pages/assessment/purchase.vue |
测评购买页 | submit 方法 |
cfc-frontend/pages/guide/packages/create.vue |
规划师创建套餐 | submit 方法 |
cfc-frontend/pages/guide/register/index.vue |
规划师注册申请 | submit 方法 |
文件:
cfc-frontend/pages/tasks/create-task.vuecfc-frontend/pages/family/add-member.vuecfc-frontend/pages/assessment/apply.vuecfc-frontend/pages/assessment/purchase.vuecfc-frontend/pages/guide/packages/create.vuecfc-frontend/pages/guide/register/index.vue统一改动模板(每个页面):
[ ] 步骤 1:data() 中新增 submitting: false
data() {
return {
// ... 现有字段 ...
submitting: false
}
}
[ ] 步骤 2:submit / submitForm 方法顶部加守卫
在方法最开头(参数校验之后、网络请求之前)插入:
async submit() {
// ... 现有参数校验 ...
if (this.submitting) return // 重复点击直接拦截
this.submitting = true
uni.showLoading({ title: '提交中...' })
try {
// ... 现有请求逻辑 ...
} catch (e) {
// ... 现有错误处理 ...
} finally {
this.submitting = false // 无论成功失败,解锁
uni.hideLoading()
}
}
注意: 若原方法没有
try/finally结构,需将现有请求逻辑包裹进try,并在finally中重置submitting。finally是必须的,否则网络失败时按钮永久禁用。
:loading 与 :disabled将原按钮:
<button @click="submit">创建任务</button>
改为:
<button @click="submit" :loading="submitting" :disabled="submitting">创建任务</button>
注意: 微信小程序
<button>的:loading属性仅在原生<button>上生效(uni-app 中有效),自定义.btn-*类样式不影响;:disabled同步防止视觉/物理双击。
按以下顺序修改,每改一个文件立即 cd cfc-frontend && npm run build:mp-weixin 验证(单个文件改动较小,按文件级验证即可):
| # | 文件 | 重点 |
|---|---|---|
| 1 | pages/tasks/create-task.vue |
submit 在第 259 行,含循环 for (memberId) 多孩创建,守卫至关重要 |
| 2 | pages/family/add-member.vue |
验证 submit 方法名(可能是 submit 或 submitForm) |
| 3 | pages/assessment/apply.vue |
测评申请会创建订单,需配合后端订单号唯一性 |
| 4 | pages/assessment/purchase.vue |
测评购买页(可能涉及支付,与任务 5 联动) |
| 5 | pages/guide/packages/create.vue |
规划师创建套餐 |
| 6 | pages/guide/register/index.vue |
规划师入驻申请 |
运行:cd cfc-frontend && npm run build:mp-weixin
预期:构建成功,6 个文件无编译错误
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:cd cfc-frontend && npm run build:mp-weixin
预期:构建成功
运行:
grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | wc -l
预期:数量与改动前一致(本次不新增 Controller 路由)
[ ] 步骤 4:迁移幂等性
DatabaseInitializer 迁移 158/159 的 catch 分支确保重复执行安全
schema.sql 与建表语句保持同步
[ ] 步骤 5:git 提交
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
cfc-backend/src/main/resources/schema.sql \
cfc-backend/src/main/java/com/etotem/cfc/common/OrderNoGenerator.java \
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePurchaseService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/subscription/SubscriptionController.java \
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecordService.java \
cfc-frontend/pages/dan-assessment/report-upload.vue \
cfc-frontend/pages/tasks/create-task.vue \
cfc-frontend/pages/family/add-member.vue \
cfc-frontend/pages/assessment/apply.vue \
cfc-frontend/pages/assessment/purchase.vue \
cfc-frontend/pages/guide/packages/create.vue \
cfc-frontend/pages/guide/register/index.vue
git commit -m "fix: 防重复提交整改——补齐唯一约束/统一订单号/修复竞态/批量前端守卫"
| 编号 | 风险 | 整改后状态 |
|---|---|---|
| R1 | guide_orders 无唯一键 | ✅ 迁移 158 |
| R2 | payment_orders 仅普通索引 | ✅ 迁移 159 |
| R3 | 订单号弱随机碰撞 | ✅ OrderNoGenerator |
| R4 | GrowthRecordService 竞态 | ✅ DuplicateKeyException 兜底 |
| R5 | DAN 上传页无防重 | ✅ submitting |
| R6 | 用户级创建页连点 | ✅ 6 个高频页 submitting(任务 9) |
遗留建议(本次不改,记录备查):
tasks(creator_id, title, deadline))未强制 —— 若业务确认需要"同家庭同标题同日期任务唯一",另立迁移加 UNIQUE;当前接受业务上重复(同一任务可分发多个孩子)