Эх сурвалжийг харах

fix: 防重复提交整改——补齐唯一约束/统一订单号/修复竞态/批量前端守卫

Xiaogang Liao 3 сар өмнө
parent
commit
9382e6c696

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/common/OrderNoGenerator.java

@@ -0,0 +1,23 @@
+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;
+    }
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -7575,6 +7575,14 @@ private void runMigration100() {
 			// 表已存在,忽略
 		}
 
+		// 迁移158: users.family_id 允许 NULL(新用户微信登录时无家庭)
+		try {
+			jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN family_id BIGINT COMMENT '家庭ID'");
+			log.info("已修改users.family_id允许NULL");
+		} catch (Exception e) {
+			// 忽略错误
+		}
+
 		// 迁移157: 创建 relationship_questionnaire_responses 表(关系问卷回答)
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (" +
@@ -7594,5 +7602,38 @@ private void runMigration100() {
 		} catch (Exception e) {
 			// 表已存在,忽略
 		}
+
+		// 迁移159: 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) {
+			// 约束已存在,忽略
+		}
+
+		// 迁移160: 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) {
+			// 索引已存在或已处理,忽略
+		}
 	}
 }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/subscription/SubscriptionController.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.controller.subscription;
 
+import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.entity.MemberSubscription;
@@ -146,7 +147,7 @@ public class SubscriptionController {
         if (!testMode) {
             return Result.error("仅测试模式可用,正式订阅请走 /create 虚拟支付");
         }
-        String orderNo = "SUB" + System.currentTimeMillis();
+        String orderNo = OrderNoGenerator.generate("SUB");
         subscriptionService.subscribe(familyId, level, amount, paymentType, transactionId, orderNo);
         return Result.success(null);
     }

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.service.api.AssessmentOrderServiceInterface;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.AssessmentAppointment;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
@@ -15,7 +16,6 @@ import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.List;
-import java.util.Random;
 
 import lombok.extern.slf4j.Slf4j;
 
@@ -56,7 +56,7 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
     }
 
     private String generateOrderNo() {
-        return "A" + System.currentTimeMillis() + String.format("%04d", new Random().nextInt(10000));
+        return OrderNoGenerator.generate("A");
     }
 
     public boolean paySuccess(String orderNo, String transactionId, String payType) {

+ 7 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecordService.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.GrowthRecord;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.GrowthRecordMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -286,14 +287,13 @@ public class GrowthRecordService {
     private void checkDuplicateAndInsert(GrowthRecord record) {
         String hash = record.getDedupHash();
         if (hash != null && !hash.startsWith("NO_DATE:")) {
-            GrowthRecord existing = growthRecordMapper.selectOne(new LambdaQueryWrapper<GrowthRecord>()
-                    .eq(GrowthRecord::getDedupHash, hash)
-                    .last("LIMIT 1"));
-            if (existing != null) {
-                throw new DuplicateGrowthRecordException("Growth record already exists with hash: " + hash
-                        + " (existing id: " + existing.getId() + ")");
+            try {
+                growthRecordMapper.insert(record);
+            } catch (DuplicateKeyException e) {
+                throw new DuplicateGrowthRecordException("Growth record already exists with hash: " + hash);
             }
+        } else {
+            growthRecordMapper.insert(record);
         }
-        growthRecordMapper.insert(record);
     }
 }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.MemberSubscription;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
@@ -97,7 +98,7 @@ public class MemberSubscriptionService {
      * 创建订阅待支付订单(不开通权益,支付成功后由 activateSubscription 开通)
      */
     public MemberSubscriptionOrder createPendingOrder(Long familyId, String level, Integer amount, String paymentType) {
-        String orderNo = "SUB" + System.currentTimeMillis();
+        String orderNo = OrderNoGenerator.generate("SUB");
         Date now = new Date();
         MemberSubscriptionOrder order = new MemberSubscriptionOrder();
         order.setFamilyId(familyId);

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
 import com.etotem.cfc.service.api.MembershipServiceInterface;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.dto.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
@@ -697,7 +698,7 @@ public class MembershipService implements MembershipServiceInterface {
     // ==================== 私有方法 ====================
 
     private String generateOrderNo() {
-        return "ORD" + System.currentTimeMillis();
+        return OrderNoGenerator.generate("ORD");
     }
 
     private Date calculateEndDate(String paymentType, String period) {

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java

@@ -5,6 +5,7 @@ import javax.annotation.Resource;
 
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.TaskTemplatePackage;
 import com.etotem.cfc.mapper.PackageOrderMapper;
@@ -245,7 +246,7 @@ public class PackagePaymentService implements PackagePaymentServiceInterface {
      * 生成订单号
      */
     private String generateOrderNo() {
-        return "PKG" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 4).toUpperCase();
+        return OrderNoGenerator.generate("PKG");
     }
 
     /**

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePurchaseService.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.service.api.PackagePurchaseServiceInterface;
 import javax.annotation.Resource;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.dto.PackagePurchaseDTO;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.GuideInviteCode;
@@ -200,6 +201,6 @@ public class PackagePurchaseService implements PackagePurchaseServiceInterface {
      * 生成订单号
      */
     private String generateOrderNo() {
-        return "PKG" + System.currentTimeMillis() + (int)(Math.random() * 1000);
+        return OrderNoGenerator.generate("PKG");
     }
 }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.CreateProductOrderDTO;
 import com.etotem.cfc.dto.OrderItemVO;
@@ -789,7 +790,7 @@ public class ProductOrderService {
     }
 
     private String generateOrderNo() {
-        return "PO" + System.currentTimeMillis();
+        return OrderNoGenerator.generate("PO");
     }
 
     private String generatePickupCode() {

+ 7 - 3
cfc-backend/src/main/resources/schema.sql

@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS users (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     openid VARCHAR(64) NOT NULL,
     unionid VARCHAR(64),
-    family_id BIGINT NOT NULL,
+    family_id BIGINT,
     role VARCHAR(20) NOT NULL,
     nickname VARCHAR(50),
     avatar VARCHAR(255),
@@ -1434,9 +1434,13 @@ CREATE TABLE IF NOT EXISTS guide_orders (
     status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态: PENDING/PAID/COMPLETED/REFUNDED',
     pay_time DATETIME COMMENT '支付时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_order_no (order_no)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长规划师订单表';
 
+-- 佣金明细表
+CREATE TABLE IF NOT EXISTS guide_commissions (
+
 -- 佣金明细表
 CREATE TABLE IF NOT EXISTS guide_commissions (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
@@ -3603,7 +3607,7 @@ CREATE TABLE IF NOT EXISTS payment_orders (
     paid_at DATETIME COMMENT '支付时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-    INDEX idx_order_no (order_no),
+    UNIQUE KEY uk_order_no (order_no),
     INDEX idx_family_id (family_id),
     INDEX idx_status (status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员支付订单';

+ 10 - 7
cfc-frontend/pages/assessment/apply.vue

@@ -131,7 +131,7 @@
     </view>
 
     <!-- 提交按钮 -->
-    <button class="btn-submit" @click="submit">{{ quotaId ? '消耗额度预约' : '提交申请' }}</button>
+    <button class="btn-submit" :loading="submitting" :disabled="submitting" @click="submit">{{ quotaId ? '消耗额度预约' : '提交申请' }}</button>
   </view>
 </template>
 
@@ -154,7 +154,8 @@ export default {
       inviteCode: '',
       inviteCodeInfo: null,
       totalPrice: 0,
-      discountAmount: 0
+      discountAmount: 0,
+      submitting: false
     }
   },
   onLoad(options) {
@@ -271,6 +272,7 @@ export default {
     },
 
     async submit() {
+      if (this.submitting) return
       if (!this.selectedType) {
         uni.showToast({ title: '请选择测评类型', icon: 'none' })
         return
@@ -288,9 +290,10 @@ export default {
         return
       }
 
-      try {
-        uni.showLoading({ title: '提交中...' })
+      this.submitting = true
+      uni.showLoading({ title: '提交中...' })
 
+      try {
         if (this.quotaId) {
           const res = await createAssessmentAppointmentWithQuota({
             memberId: this.selectedChildId,
@@ -301,7 +304,6 @@ export default {
             appointmentTime: this.appointmentTime,
             notes: ''
           })
-          uni.hideLoading()
           if (res.code === 200) {
             uni.showModal({
               title: '预约成功',
@@ -326,7 +328,6 @@ export default {
             appointmentTime: this.appointmentTime,
             durationMinutes: 60
           })
-          uni.hideLoading()
           if (res.code === 200) {
             uni.showModal({
               title: '申请成功',
@@ -339,8 +340,10 @@ export default {
           }
         }
       } catch (e) {
-        uni.hideLoading()
         uni.showToast({ title: '提交失败', icon: 'none' })
+      } finally {
+        this.submitting = false
+        uni.hideLoading()
       }
     }
   }

+ 13 - 9
cfc-frontend/pages/assessment/purchase.vue

@@ -80,7 +80,7 @@
     </view>
 
     <!-- 提交按钮 -->
-    <button class="btn-submit" @click="submit" :disabled="!selectedPackageId">确认购买</button>
+    <button class="btn-submit" @click="submit" :disabled="!selectedPackageId || submitting" :loading="submitting">确认购买</button>
   </view>
 </template>
 
@@ -101,7 +101,8 @@ export default {
       guidePrice: 0,
       discountAmount: 0,
       inviteCode: '',
-      inviteCodeInfo: null
+      inviteCodeInfo: null,
+      submitting: false
     }
   },
   onLoad(options) {
@@ -242,6 +243,7 @@ export default {
     },
 
     async submit() {
+      if (this.submitting) return
       if (!this.selectedPackageId) {
         uni.showToast({ title: '请选择任务模板', icon: 'none' })
         return
@@ -256,13 +258,14 @@ export default {
         return
       }
 
-      try {
-        uni.showLoading({ title: '创建订单中...' })
+      this.submitting = true
+      uni.showLoading({ title: '创建订单中...' })
 
-        var userId = uni.getStorageSync('userId')
-        var familyId = uni.getStorageSync('familyId')
-        var self = this
+      var userId = uni.getStorageSync('userId')
+      var familyId = uni.getStorageSync('familyId')
+      var self = this
 
+      try {
         var createRes = await createAssessmentOrder({
           familyId: familyId,
           userId: userId,
@@ -274,7 +277,6 @@ export default {
           totalPrice: this.totalPrice,
           discountAmount: this.discountAmount
         })
-        uni.hideLoading()
 
         if (createRes.code !== 200 || !createRes.data || !createRes.data.orderNo) {
           uni.showToast({ title: '创建订单失败', icon: 'none' })
@@ -309,12 +311,14 @@ export default {
           }
         })
       } catch (e) {
-        uni.hideLoading()
         if (e.code === 46001) {
           uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
         } else {
           uni.showToast({ title: e.message || '支付失败', icon: 'none' })
         }
+      } finally {
+        this.submitting = false
+        uni.hideLoading()
       }
     },
     pollOrderStatus: function(orderNo) {

+ 2 - 0
cfc-frontend/pages/dan-assessment/report-upload.vue

@@ -194,6 +194,7 @@ export default {
       })
     },
     startUpload: function() {
+      if (this.loading) return
       if (!this.selectedFile) return
       var self = this
       self.loading = true
@@ -219,6 +220,7 @@ export default {
         })
     },
     doConfirm: function() {
+      if (this.confirming) return
       var self = this
       self.confirming = true
 

+ 2 - 1
cfc-frontend/pages/family/add-member.vue

@@ -175,7 +175,8 @@ this.form.isCesarean = this.cesareanOptions[idx].value
 this.form.cesareanLabel = this.cesareanOptions[idx].label
 },
 async submit() {
-if (!this.form.nickname || !this.form.nickname.trim()) {
+  if (this.submitting) return
+  if (!this.form.nickname || !this.form.nickname.trim()) {
 uni.showToast({ title: '请输入成员昵称', icon: 'none' })
 return
 }

+ 6 - 2
cfc-frontend/pages/guide/packages/create.vue

@@ -44,7 +44,7 @@
         <text class="switch-label">{{ form.isPublic ? '公开到市场' : '仅分享链接' }}</text>
       </view>
 
-      <button class="btn-submit" @click="submit">保存</button>
+      <button class="btn-submit" :loading="submitting" :disabled="submitting" @click="submit">保存</button>
     </view>
   </view>
 </template>
@@ -64,7 +64,8 @@ export default {
         isPublic: false,
         coverImage: ''
       },
-      categories: ['学习', '习惯', '健康', '情绪', '其他']
+      categories: ['学习', '习惯', '健康', '情绪', '其他'],
+      submitting: false
     }
   },
   methods: {
@@ -83,6 +84,7 @@ export default {
       })
     },
     async submit() {
+      if (this.submitting) return
       if (!this.form.name) {
         uni.showToast({ title: '请输入任务模板名称', icon: 'none' })
         return
@@ -92,6 +94,7 @@ export default {
         return
       }
 
+      this.submitting = true
       uni.showLoading({ title: '保存中...' })
       try {
         await createPackage({
@@ -105,6 +108,7 @@ export default {
       } catch (e) {
         uni.showToast({ title: '创建失败', icon: 'none' })
       } finally {
+        this.submitting = false
         uni.hideLoading()
       }
     }

+ 10 - 7
cfc-frontend/pages/guide/register/index.vue

@@ -132,7 +132,7 @@
       </view>
 
       <!-- 提交按钮 -->
-      <button class="btn-submit" @click="submit">提交申请</button>
+      <button class="btn-submit" :loading="submitting" :disabled="submitting" @click="submit">提交申请</button>
     </view>
   </view>
 </template>
@@ -158,7 +158,8 @@ export default {
         teacherPhoto: '',
         certificateImage: ''
       },
-      inviteCodeInfo: null
+      inviteCodeInfo: null,
+      submitting: false
     }
   },
   onLoad(options) {
@@ -227,6 +228,7 @@ export default {
     },
 
     async submit() {
+      if (this.submitting) return
       // 验证表单
       if (!this.formData.realName) {
         uni.showToast({ title: '请输入真实姓名', icon: 'none' })
@@ -243,17 +245,16 @@ export default {
         return
       }
 
+      this.submitting = true
+      uni.showLoading({ title: '提交中...' })
+
       try {
-        uni.showLoading({ title: '提交中...' })
-        
         const userId = uni.getStorageSync('userId')
         const res = await registerGuide({
           userId: userId,
           ...this.formData
         })
 
-        uni.hideLoading()
-
         if (res.code === 200) {
           uni.showModal({
             title: '申请成功',
@@ -270,11 +271,13 @@ export default {
           })
         }
       } catch (e) {
-        uni.hideLoading()
         uni.showToast({
           title: '提交失败',
           icon: 'none'
         })
+      } finally {
+        this.submitting = false
+        uni.hideLoading()
       }
     }
   }

+ 16 - 9
cfc-frontend/pages/tasks/create-task.vue

@@ -75,7 +75,7 @@
 <input class="input" type="number" v-model="form.duration" placeholder="请输入预计完成时长" />
 </view>
 
-<button class="btn-primary" @click="submit">创建任务</button>
+<button class="btn-primary" :loading="submitting" :disabled="submitting" @click="submit">创建任务</button>
 </view>
 </view>
 </template>
@@ -111,7 +111,8 @@ repeatTypes: [
 ],
 dateTimeRange: [[], [], [], []],
 dateTimeIndex: [0, 0, 0, 0],
-deadlineText: ''
+deadlineText: '',
+submitting: false
 }
 },
   onLoad(options) {
@@ -257,6 +258,7 @@ onNeedReviewChange(e) {
 this.form.needReview = e.detail.value
 },
     async submit() {
+      if (this.submitting) return
       if (!this.form.title) {
         uni.showToast({ title: '请输入任务名称', icon: 'none' })
         return
@@ -275,6 +277,7 @@ this.form.needReview = e.detail.value
         return
       }
 
+      this.submitting = true
       uni.showLoading({ title: '创建任务中...' })
       const taskData = {
         title: this.form.title,
@@ -290,15 +293,19 @@ this.form.needReview = e.detail.value
 
       let successCount = 0
       let failCount = 0
-      for (const memberId of this.form.memberIds) {
-        try {
-          await createTask({ ...taskData, memberId })
-          successCount++
-        } catch (e) {
-          failCount++
+      try {
+        for (const memberId of this.form.memberIds) {
+          try {
+            await createTask({ ...taskData, memberId })
+            successCount++
+          } catch (e) {
+            failCount++
+          }
         }
+      } finally {
+        this.submitting = false
+        uni.hideLoading()
       }
-      uni.hideLoading()
 
       if (failCount === 0) {
         uni.showToast({ title: `已为${successCount}个孩子创建任务`, icon: 'success' })