Просмотр исходного кода

feat: 测评订单改虚拟支付
payOrder返回VirtualPayParams保持pending,服务端计价,发货推送markPaid+预约确认+佣金

asus 3 месяцев назад
Родитель
Сommit
c02a7ee3c5

+ 31 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java

@@ -1,11 +1,13 @@
 package com.etotem.cfc.controller;
 package com.etotem.cfc.controller;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.service.*;
 import com.etotem.cfc.service.*;
 import com.etotem.cfc.service.DanReportParseService.DanParsedReport;
 import com.etotem.cfc.service.DanReportParseService.DanParsedReport;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
 
 
@@ -44,6 +46,12 @@ public class DanAssessmentController {
     @Resource
     @Resource
     private DanReportUploadService danReportUploadService;
     private DanReportUploadService danReportUploadService;
 
 
+    @Value("${wechat.test-mode:false}")
+    private boolean testMode;
+
+    @Resource
+    private VirtualPayService virtualPayService;
+
     // ===== 测评材料 =====
     // ===== 测评材料 =====
 
 
 @Operation(summary = "获取当前测评材料")
 @Operation(summary = "获取当前测评材料")
@@ -271,11 +279,31 @@ public class DanAssessmentController {
 
 
     @Operation(summary = "支付测评订单")
     @Operation(summary = "支付测评订单")
     @PostMapping("/order/pay")
     @PostMapping("/order/pay")
-    public Result<String> payOrder(@RequestBody Map<String, Object> body) {
+    public Result<VirtualPayParamsDTO> payOrder(@RequestBody Map<String, Object> body,
+                                                @RequestAttribute("userId") Long userId) {
         String orderNo = (String) body.get("orderNo");
         String orderNo = (String) body.get("orderNo");
         String payType = (String) body.get("payType");
         String payType = (String) body.get("payType");
-        boolean ok = orderService.payOrder(orderNo, payType);
-        return ok ? Result.success("支付成功") : Result.error("支付失败");
+        Long appointmentId = body.get("appointmentId") != null
+                ? Long.valueOf(body.get("appointmentId").toString()) : null;
+
+        // testMode 兼容:mock 直接标记已付,跳过虚拟支付(保留现有模拟逻辑)
+        if (testMode) {
+            boolean ok = orderService.payOrder(orderNo, payType);
+            return ok ? Result.success(null) : Result.error("支付失败");
+        }
+
+        // sessionKey 后端从登录态取(users.session_key,@JsonIgnore 防泄露),不由前端传
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+            return Result.error(400, "会话失效,请重新登录");
+        }
+
+        VirtualPayParamsDTO dto = virtualPayService.generateAssessmentPayParams(
+                orderNo, payType, user.getSessionKey(), appointmentId);
+        if (dto == null) {
+            return Result.error(46001, "该商品暂未开放购买");
+        }
+        return Result.success(dto);
     }
     }
 
 
     @Operation(summary = "取消订单")
     @Operation(summary = "取消订单")

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

@@ -174,4 +174,75 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
 
 
         return updated;
         return updated;
     }
     }
+
+    /**
+     * 按订单号确认关联预约(status 0→1)。失败仅 warn 不阻断。
+     */
+    public boolean confirmAppointmentByOrderNo(String orderNo) {
+        try {
+            AssessmentOrder order = getByOrderNo(orderNo);
+            if (order == null || order.getAppointmentId() == null) {
+                log.warn("确认预约失败,订单无关联预约: orderNo={}", orderNo);
+                return false;
+            }
+            AssessmentAppointment appointment = assessmentAppointmentMapper.selectById(order.getAppointmentId());
+            if (appointment == null) {
+                log.warn("确认预约失败,预约不存在: appointmentId={}", order.getAppointmentId());
+                return false;
+            }
+            if (appointment.getStatus() != null && appointment.getStatus() == 0) {
+                appointment.setStatus(1); // 待确认 → 已确认
+                appointment.setUpdatedAt(new Date());
+                assessmentAppointmentMapper.updateById(appointment);
+                log.info("订单支付成功自动确认预约: orderNo={}, appointmentId={}", orderNo, order.getAppointmentId());
+            }
+            return true;
+        } catch (Exception e) {
+            log.warn("订单支付后确认预约异常: orderNo={}, error={}", orderNo, e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 虚拟支付发货推送解锁(A 前缀订单):幂等标记已付 + 佣金结算 + 预约确认。
+     */
+    public boolean handleVirtualPaySuccess(String orderNo, String transactionId) {
+        AssessmentOrder order = getByOrderNo(orderNo);
+        if (order == null) return false;
+        // 幂等:已 paid/refunded 直接返回(防重复佣金结算,spec §6.2)
+        if ("paid".equals(order.getStatus()) || "refunded".equals(order.getStatus())) {
+            log.info("发货推送重复通知,订单已终态: orderNo={}, status={}", orderNo, order.getStatus());
+            return true;
+        }
+        // 兜底关联:订单无 appointmentId 时按 familyId+childId 找最近待确认预约(spec 计划 Step 3 二选一)
+        if (order.getAppointmentId() == null) {
+            linkLatestPendingAppointment(order);
+        }
+        // 标记已付 + transactionId + 佣金结算(paySuccess 已含 commissionService.settle)
+        boolean ok = paySuccess(orderNo, transactionId, "virtual");
+        if (ok) {
+            confirmAppointmentByOrderNo(orderNo);
+        }
+        return ok;
+    }
+
+    private void linkLatestPendingAppointment(AssessmentOrder order) {
+        try {
+            LambdaQueryWrapper<AssessmentAppointment> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(AssessmentAppointment::getFamilyId, order.getFamilyId())
+                   .eq(AssessmentAppointment::getChildId, order.getChildId())
+                   .eq(AssessmentAppointment::getStatus, 0)
+                   .orderByDesc(AssessmentAppointment::getCreatedAt)
+                   .last("LIMIT 1");
+            List<AssessmentAppointment> list = assessmentAppointmentMapper.selectList(wrapper);
+            if (list != null && !list.isEmpty()) {
+                order.setAppointmentId(list.get(0).getId());
+                order.setUpdatedAt(new Date());
+                this.updateById(order);
+                log.info("发货推送兜底关联预约: orderNo={}, appointmentId={}", order.getOrderNo(), order.getAppointmentId());
+            }
+        } catch (Exception e) {
+            log.warn("发货推送兜底关联预约失败: orderNo={}, error={}", order.getOrderNo(), e.getMessage());
+        }
+    }
 }
 }

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

@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Collections;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
@@ -36,6 +37,12 @@ public class VirtualPayService {
     @Resource
     @Resource
     private AssessmentOrderService assessmentOrderService;
     private AssessmentOrderService assessmentOrderService;
 
 
+    @Resource
+    private VirtualGoodsConfigService virtualGoodsConfigService;
+
+    @Resource
+    private PackagePurchaseService packagePurchaseService;
+
     @Value("${wechat.appid}")
     @Value("${wechat.appid}")
     private String appid;
     private String appid;
 
 
@@ -104,6 +111,54 @@ public class VirtualPayService {
         return dto;
         return dto;
     }
     }
 
 
+    /**
+     * 测评订单虚拟支付下单参数编排(防循环依赖:编排放本类,AssessmentOrderService 单向被依赖)。
+     * 订单保持 pending;查不到道具映射返回 null(调用方转 46001 "该商品暂未开放购买")。
+     *
+     * @param appointmentId 前端可选传入,回填订单修复 appointmentId 链路断点
+     */
+    public VirtualPayParamsDTO generateAssessmentPayParams(String orderNo, String payType,
+                                                           String sessionKey, Long appointmentId) {
+        // 1. 查订单 + 校验 pending
+        AssessmentOrder order = assessmentOrderService.getByOrderNo(orderNo);
+        if (order == null || !"pending".equals(order.getStatus())) {
+            log.warn("生成测评支付参数失败,订单不存在或非pending: orderNo={}", orderNo);
+            return null;
+        }
+
+        // 2. 回填 appointmentId(修复链路断点:createOrder 从不赋值)
+        if (appointmentId != null && order.getAppointmentId() == null) {
+            order.setAppointmentId(appointmentId);
+            order.setUpdatedAt(new Date());
+            assessmentOrderService.updateById(order);
+            log.info("支付下单回填预约关联: orderNo={}, appointmentId={}", orderNo, appointmentId);
+        }
+
+        // 3. 查 virtual_goods_config:goodsType=ASSESSMENT_PACKAGE, bizKey=订单packageId → null → 调用方 46001
+        VirtualGoodsConfig config = virtualGoodsConfigService.getEnabled("ASSESSMENT_PACKAGE",
+                String.valueOf(order.getPackageId()));
+        if (config == null) {
+            log.warn("测评道具未配置或停用: packageId={}", order.getPackageId());
+            return null;
+        }
+
+        // 4. 服务端重算金额(不信任前端传参):total_fee = getPackagePrice(...).totalPrice
+        Map<String, Object> price = packagePurchaseService.getPackagePrice(order.getPackageId(), order.getGuideId(), null);
+        if (price == null || price.get("totalPrice") == null) {
+            log.warn("测评套餐服务端计价失败: packageId={}", order.getPackageId());
+            return null;
+        }
+        Integer totalFee = ((Number) price.get("totalPrice")).intValue();
+        if (totalFee < 100) { // 金额 >= 1元
+            log.warn("测评套餐金额异常(<1元): packageId={}, totalFee={}", order.getPackageId(), totalFee);
+            return null;
+        }
+
+        // 5. 组装 attach + product_info 并生成支付参数(订单保持 pending,不置 paid)
+        String attach = "assessment:" + order.getPackageId();
+        return generatePayParams(config, orderNo, totalFee, order.getPackageName(), attach, sessionKey);
+    }
+
     /**
     /**
      * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
      * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
      * 按 OutTradeNo 前缀路由:A→测评,ORD→会员(TODO Task 7),SUB→订阅(TODO Task 8)。
      * 按 OutTradeNo 前缀路由:A→测评,ORD→会员(TODO Task 7),SUB→订阅(TODO Task 8)。
@@ -128,8 +183,8 @@ public class VirtualPayService {
                     log.info("虚拟支付发货推送重复通知,订单已终态: outTradeNo={}, status={}", outTradeNo, existing.getStatus());
                     log.info("虚拟支付发货推送重复通知,订单已终态: outTradeNo={}, status={}", outTradeNo, existing.getStatus());
                     return successResult();
                     return successResult();
                 }
                 }
-                // 测评订单:标记已付 + 佣金结算
-                assessmentOrderService.paySuccess(outTradeNo, transactionId, "virtual");
+                // 测评订单:标记已付 + 佣金结算 + 预约确认
+                assessmentOrderService.handleVirtualPaySuccess(outTradeNo, transactionId);
             } else if (outTradeNo.startsWith("ORD") || outTradeNo.startsWith("SUB")) {
             } else if (outTradeNo.startsWith("ORD") || outTradeNo.startsWith("SUB")) {
                 // TODO Task 7/8: 会员(ORD) / 订阅(SUB) 标记已付 + 解锁权益,暂未接入,记录日志返回成功
                 // TODO Task 7/8: 会员(ORD) / 订阅(SUB) 标记已付 + 解锁权益,暂未接入,记录日志返回成功
                 log.warn("虚拟支付发货推送暂未接入会员/订阅处理: outTradeNo={}", outTradeNo);
                 log.warn("虚拟支付发货推送暂未接入会员/订阅处理: outTradeNo={}", outTradeNo);