# 防重复提交整改方案 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 修复系统防重复提交机制中的 5 类风险点 —— 补齐数据库唯一约束、统一订单号生成、修复先查后插竞态、补齐前端防重标志、消除弱随机订单号碰撞。 **架构:** 基于现有「前端体验层 + 后端状态机 + 数据库唯一约束」三层防御体系做定向加固,不引入分布式锁/幂等框架等重设施,保持改动最小化: 1. **数据库层:** 为 `guide_orders`、`payment_orders` 补 UNIQUE 约束(最后防线) 2. **业务层:** 新建统一订单号生成器,替换各 Service 的弱随机订单号;修复 `GrowthRecordService` 先查后插竞态 3. **前端层:** 为 DAN 测评上传页补 `submitting` 防重标志 **技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / MySQL 8.0 / uni-app Vue 2 --- ## 背景:风险点清单(来源:2026-08-04 防重复提交机制分析) | 编号 | 风险 | 严重度 | 位置 | |------|------|:------:|------| | 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 测评报告上传 | 修改:补防重标志 | --- ## 任务 1:迁移 158 —— `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** 开始。 - [ ] **步骤 2:在 runMigrations() 末尾追加迁移 158** 在 `DatabaseInitializer.java` 的 `runMigrations()` 方法末尾(`// 迁移20240709` 块之后)添加: ```java // 迁移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) { // 约束已存在,忽略 } ``` - [ ] **步骤 3:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS --- ## 任务 2:迁移 159 —— `payment_orders.order_no` 升级为唯一约束 **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(runMigrations() 末尾,任务 1 之后) - [ ] **步骤 1:在 runMigrations() 末尾追加迁移 159** ```java // 迁移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 保证。 - [ ] **步骤 2:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS --- ## 任务 3:同步 schema.sql 快照 **文件:** - 修改:`cfc-backend/src/main/resources/schema.sql` - [ ] **步骤 1:更新 `guide_orders` 表定义(第 1427 行附近)** 在 `order_no VARCHAR(50) NOT NULL COMMENT '订单号',` 行后追加唯一键声明,并删除结尾处若有冲突的旧索引行。修改后: ```sql 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='成长规划师订单表'; ``` - [ ] **步骤 2:更新 `payment_orders` 表定义(第 3591 行附近)** 将 `INDEX idx_order_no (order_no),` 替换为 `UNIQUE KEY uk_order_no (order_no),`: ```sql 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='会员支付订单'; ``` - [ ] **步骤 3:验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS(确认 schema.sql 改动未影响编译) --- ## 任务 4:创建统一订单号生成器 `OrderNoGenerator` **文件:** - 创建:`cfc-backend/src/main/java/com/etotem/cfc/common/OrderNoGenerator.java` - [ ] **步骤 1:创建工具类** ```java 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; } } ``` **设计说明:** - 同一毫秒内自增序号保证顺序唯一,4 位 UUID 段在进程重启后仍保证随机性,DB 唯一约束兜底最终一致性 - 前缀参数化,保留 `PO`/`ORD`/`SUB`/`PKG`/`A` 等现有前缀,不影响 `PaymentService.handleWechatNotify` 按前缀路由的逻辑 - [ ] **步骤 2:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS --- ## 任务 5:替换各 Service 的 `generateOrderNo()` **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java:791` - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java:699` - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PackagePurchaseService.java:202` - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java:247` - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java:58` - 修改:`cfc-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: ```java // 文件顶部新增 import import com.etotem.cfc.common.OrderNoGenerator; // generateOrderNo() 方法体替换为: private String generateOrderNo() { return OrderNoGenerator.generate("PO"); } ``` - [ ] **步骤 2:替换 MembershipService.generateOrderNo()** ```java // 新增 import com.etotem.cfc.common.OrderNoGenerator; private String generateOrderNo() { return OrderNoGenerator.generate("ORD"); } ``` - [ ] **步骤 3:替换 PackagePurchaseService.generateOrderNo()** ```java // 新增 import com.etotem.cfc.common.OrderNoGenerator; private String generateOrderNo() { return OrderNoGenerator.generate("PKG"); } ``` - [ ] **步骤 4:替换 PackagePaymentService.generateOrderNo()** ```java // 新增 import com.etotem.cfc.common.OrderNoGenerator; private String generateOrderNo() { return OrderNoGenerator.generate("PKG"); } ``` - [ ] **步骤 5:替换 AssessmentOrderService.generateOrderNo()** ```java // 新增 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();` 改为: ```java // 新增 import com.etotem.cfc.common.OrderNoGenerator; String orderNo = OrderNoGenerator.generate("SUB"); ``` - [ ] **步骤 7:替换 MemberSubscriptionService 内联生成(第 100 行)** 原文:`String orderNo = "SUB" + System.currentTimeMillis();` 改为: ```java // 新增 import com.etotem.cfc.common.OrderNoGenerator; String orderNo = OrderNoGenerator.generate("SUB"); ``` - [ ] **步骤 8:全量编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS。若出现未使用 import 警告(Random),清理对应 import 后重新编译。 > **注意:** 不要改动 `PaymentService.handleWechatNotify` 中按前缀分派路由的逻辑(`PO` 前缀 → 商城订单),订单号前缀保持不变。 --- ## 任务 6:修复 `GrowthRecordService` 先查后插竞态 **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecordService.java:286` - [ ] **步骤 1:替换 `checkDuplicateAndInsert()` 方法** 原实现是先 `selectOne` 再 `insert`,并发下两个请求同时通过查询就会双插。改为**直接插入 + 捕获唯一键冲突**,依赖迁移已存在的 `uk_growth_dedup_hash` 唯一索引兜底: ```java // 文件顶部新增 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 --- ## 任务 7:前端 DAN 测评上传页补防重标志 **文件:** - 修改:`cfc-frontend/pages/dan-assessment/report-upload.vue` - [ ] **步骤 1:添加 submitting 数据字段** 在 `data()` 中新增(找到现有 `loading` 字段旁): ```js data() { return { // ...现有字段... submitting: false } } ``` - [ ] **步骤 2:提交入口加防重守卫** 找到提交/上传处理方法(含 `self.loading = true` 的异步提交逻辑),在方法开头加: ```js 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 })`)。 - [ ] **步骤 3:验证前端构建** 运行:`cd cfc-frontend && npm run build:mp-weixin` 预期:构建成功,无编译错误 --- ## 任务 8:整体回归验证 - [ ] **步骤 1:后端全量编译** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS - [ ] **步骤 2:检查路由无冲突(若改动涉及 Controller)** 运行: ```bash grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | wc -l ``` 预期:无新增路由,数量与改动前一致 - [ ] **步骤 3:确认迁移幂等性** 检查 `DatabaseInitializer.java` 中迁移 158/159 的 catch 分支:重复执行时 DROP/ADD 语句均应被 catch 吞掉或自然跳过。确认 DELETE 清理语句在无重复数据时执行 0 行不报错。 - [ ] **步骤 4:git 提交(如需)** ```bash 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 标志位 | --- ## 扩展部分:用户级高频创建页防重(R6) 经用户确认,**仅整改订单类不足以覆盖"按钮连点插入两条"风险** —— 用户级创建页(任务、心愿、家庭成员、保险方案等)同样需要防重。已有 `submitting` 守卫的页面(如 `pages/wishes/create.vue`、`pages/wealth-sub/insurance-add.vue`)无需改动;本任务覆盖**经确认无 submitting 守卫的页面**。 ### 已确认无 submitting 守卫的用户级创建页 | 文件 | 场景 | 按钮位置 | |------|------|----------| | `cfc-frontend/pages/tasks/create-task.vue` | 创建任务(连点多孩会双倍创建) | 第 78 行 ` ``` 改为: ```html ``` > **注意:** 微信小程序 `