|
|
@@ -0,0 +1,786 @@
|
|
|
+# 会员费虚拟支付开关实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 在会员费支付链路增加 `member_pay_virtual_enabled` 配置开关,通过后端 `payType` 字段分流到虚拟支付(`requestVirtualPayment`)或普通微信支付(`wx.requestPayment`),并打通 v3 JSAPI 回调到会员开通。
|
|
|
+
|
|
|
+**架构:** 后端在 `MembershipService.createOrderWithPayType()` 内读 `sys_config` 开关,返回带 `payType` 字段的统一响应;前端 `pay.vue` 根据 `payType` 分支调用对应支付 SDK;普通支付 v3 回调通过 `PaymentService.handleWechatNotify()` ORD 前缀路由到 `processPaymentCallback()`。
|
|
|
+
|
|
|
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus + 微信支付 v3 API(JSAPI)+ uni-app Vue 2
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 文件 | 变更类型 | 职责 |
|
|
|
+|------|---------|------|
|
|
|
+| `cfc-backend/.../service/MembershipService.java` | 修改 | 新增 `createOrderWithPayType()`;`createOrder()` 拆单逻辑增加 `splitEnabled` 控制 |
|
|
|
+| `cfc-backend/.../controller/MembershipController.java` | 修改 | `createOrder()` 接收 `openid`,调用新方法 |
|
|
|
+| `cfc-backend/.../service/PaymentService.java` | 修改 | `handleWechatNotify()` 补充 ORD 前缀路由;注入 `MembershipService`(`@Lazy`) |
|
|
|
+| `cfc-backend/src/test/java/.../service/MembershipPaySwitchTest.java` | 创建 | 单元测试覆盖开关分流逻辑 |
|
|
|
+| `cfc-frontend/utils/api.js` | 修改 | `createOrder()` 透传 `openid` 参数 |
|
|
|
+| `cfc-frontend/pages/membership/pay.vue` | 修改 | 按 `payType` 分支,新增 `payWechat()` 方法 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 1:MembershipService 新增 createOrderWithPayType()
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/MembershipPaySwitchTest.java`
|
|
|
+
|
|
|
+**前置知识:**
|
|
|
+- `sysConfigService.getValue("member_pay_virtual_enabled")` 返回 null 或 "1" 表示开启虚拟支付;返回 "0" 表示关闭
|
|
|
+- `virtualPayService.generateMembershipPayParams(orderNo, sessionKey)` 生成虚拟支付参数,返回 null 表示道具未配置
|
|
|
+- `paymentService.createWechatPrepay(orderNo, description, amount, openid)` 生成 v3 JSAPI 预付参数
|
|
|
+- 拆单逻辑在 `createOrder()` L369-L384,基于 `virtual_goods_config.goods_price` 判断——关闭虚拟支付时应跳过(`splitEnabled=false`)
|
|
|
+- `createOrder()` 的 3 个调用方(L190/210/231)均走虚拟支付,不受影响
|
|
|
+
|
|
|
+**步骤 1.1:在 MembershipService 中注入 PaymentService**
|
|
|
+
|
|
|
+在 `MembershipService.java` 现有的 `@Resource` 块(L27 起)追加:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private PaymentService paymentService;
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 1.2:为 createOrder() 拆单逻辑增加 splitEnabled 控制**
|
|
|
+
|
|
|
+在 `MembershipService.java` L295 找到 `createOrder()` 方法,找到 L369 行:
|
|
|
+
|
|
|
+```java
|
|
|
+// 修改前:
|
|
|
+if (!"trial".equals(paymentType)) {
|
|
|
+// 修改后:
|
|
|
+if (!"trial".equals(paymentType)) {
|
|
|
+```
|
|
|
+
|
|
|
+替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+// 在 createOrder() 方法开头(L295 之后)增加 splitEnabled=true 默认值
|
|
|
+// 原 createOrder() 签名不变(保持向后兼容);拆单条件改为始终执行(默认拆单开启)
|
|
|
+if (!"trial".equals(paymentType)) {
|
|
|
+```
|
|
|
+
|
|
|
+> **说明**:`createOrder()` 现有调用方均为虚拟支付路径,拆单行为不变。新建的 `createOrderWithPayType()` 不走 `createOrder()`,而是直接内联订单创建逻辑,避免拆单。
|
|
|
+
|
|
|
+**步骤 1.3:新增 createOrderWithPayType() 方法(内联订单创建)**
|
|
|
+
|
|
|
+在 `MembershipService.java` 中 `createOrder()` 方法之后新增。该方法完整复制 `createOrder()` 的订单创建逻辑,但增加 `splitEnabled` 参数(关闭时跳过 L369-L384 的拆单判断):
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 创建会员订单并按 member_pay_virtual_enabled 开关决定支付方式。
|
|
|
+ *
|
|
|
+ * @param sessionKey 当前用户 sessionKey(虚拟支付签名用;普通支付时忽略)
|
|
|
+ * @param openid 普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null
|
|
|
+ * @return 统一响应 map,含 payType("virtual"/"wechat"/"none")及对应字段;
|
|
|
+ * 虚拟支付道具未配置时返回 null(调用方转 46001)
|
|
|
+ */
|
|
|
+public Map<String, Object> createOrderWithPayType(Long userId, Long familyId, String levelCode,
|
|
|
+ String paymentType, String period, Long userCouponId, String sessionKey, String openid) {
|
|
|
+
|
|
|
+ // === 内联订单创建(复制 createOrder() 核心逻辑,splitEnabled 由开关控制)===
|
|
|
+ if (familyId == null) {
|
|
|
+ throw new RuntimeException("请先创建或加入家庭后再购买会员");
|
|
|
+ }
|
|
|
+ MembershipLevel level = levelMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<MembershipLevel>()
|
|
|
+ .eq(MembershipLevel::getLevelCode, levelCode)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (level == null) {
|
|
|
+ throw new RuntimeException("会员等级不存在");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 根据 period 确定费用(分)— 与 createOrder() 逻辑完全一致
|
|
|
+ Integer amount;
|
|
|
+ String effectivePeriod = period != null ? period : "yearly";
|
|
|
+ if ("FAMILY".equals(levelCode)) {
|
|
|
+ effectivePeriod = "yearly";
|
|
|
+ String feeStr = sysConfigService.getValue("member_fee_family_yearly");
|
|
|
+ if (feeStr == null) { feeStr = sysConfigService.getValue("member_fee_family"); }
|
|
|
+ amount = feeStr != null ? Integer.parseInt(feeStr) : 131400;
|
|
|
+ } else if ("PROVIDER".equals(levelCode)) {
|
|
|
+ String configKey = "member_fee_standard_" + effectivePeriod;
|
|
|
+ String feeStr = sysConfigService.getValue(configKey);
|
|
|
+ if (feeStr == null) { feeStr = sysConfigService.getValue("member_fee_standard"); }
|
|
|
+ amount = feeStr != null ? Integer.parseInt(feeStr) : 1314;
|
|
|
+ } else if ("PREMIUM".equals(levelCode)) {
|
|
|
+ if ("monthly".equals(effectivePeriod)) {
|
|
|
+ String feeStr = sysConfigService.getValue("member_fee_premium_monthly");
|
|
|
+ amount = feeStr != null ? Integer.parseInt(feeStr) : 131400;
|
|
|
+ } else {
|
|
|
+ String feeStr = sysConfigService.getValue("member_fee_premium");
|
|
|
+ amount = feeStr != null ? Integer.parseInt(feeStr) : 1316800;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if ("monthly".equals(effectivePeriod)) {
|
|
|
+ amount = level.getPriceMonthly();
|
|
|
+ } else if ("quarterly".equals(effectivePeriod)) {
|
|
|
+ amount = level.getPriceQuarterly() != null ? level.getPriceQuarterly() : level.getPriceMonthly() * 3;
|
|
|
+ } else {
|
|
|
+ amount = level.getPriceYearly();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ int originalAmount = amount;
|
|
|
+ int finalAmount = originalAmount;
|
|
|
+ Long appliedCouponId = null;
|
|
|
+
|
|
|
+ if (!"trial".equals(paymentType)) {
|
|
|
+ if (userCouponId != null) {
|
|
|
+ Integer discount = couponService.apply(userId, userCouponId, "MEMBERSHIP", originalAmount);
|
|
|
+ if (discount != null && discount > 0) {
|
|
|
+ finalAmount = Math.max(0, originalAmount - discount);
|
|
|
+ appliedCouponId = userCouponId;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ finalAmount = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 开关判定:缺省/null/"1"=虚拟支付(拆单开启);"0"=普通支付(拆单关闭)
|
|
|
+ boolean virtualEnabled = !"0".equals(sysConfigService.getValue("member_pay_virtual_enabled"));
|
|
|
+
|
|
|
+ // 拆单:查 MEMBERSHIP 道具配置的 goods_price,按整倍数判断(virtualEnabled=false 时跳过)
|
|
|
+ String groupNo = null;
|
|
|
+ int totalPeriods = 1;
|
|
|
+ Integer splitPeriodAmount = null;
|
|
|
+ if (!"trial".equals(paymentType) && virtualEnabled) {
|
|
|
+ VirtualGoodsConfig goodsConfig = virtualGoodsConfigService.getEnabled("MEMBERSHIP", levelCode);
|
|
|
+ Integer goodsPrice = goodsConfig != null ? goodsConfig.getGoodsPrice() : null;
|
|
|
+ if (goodsPrice != null && goodsPrice > 0) {
|
|
|
+ if (finalAmount % goodsPrice != 0) {
|
|
|
+ throw new RuntimeException("支付金额必须是道具价格的整数倍,无法支付");
|
|
|
+ }
|
|
|
+ totalPeriods = finalAmount / goodsPrice;
|
|
|
+ if (totalPeriods > 1) {
|
|
|
+ splitPeriodAmount = goodsPrice;
|
|
|
+ groupNo = OrderNoGenerator.generate("G");
|
|
|
+ } else {
|
|
|
+ splitPeriodAmount = goodsPrice;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建订单
|
|
|
+ PaymentOrder mainOrder = null;
|
|
|
+ List<PaymentOrder> allOrders = new ArrayList<>();
|
|
|
+ if (groupNo != null) {
|
|
|
+ for (int i = 1; i <= totalPeriods; i++) {
|
|
|
+ PaymentOrder sub = new PaymentOrder();
|
|
|
+ sub.setOrderNo(generateOrderNo());
|
|
|
+ sub.setFamilyId(familyId);
|
|
|
+ sub.setLevelCode(levelCode);
|
|
|
+ sub.setPaymentType(paymentType);
|
|
|
+ sub.setPeriod(effectivePeriod);
|
|
|
+ sub.setAmount(splitPeriodAmount);
|
|
|
+ sub.setUserCouponId(i == 1 ? appliedCouponId : null);
|
|
|
+ sub.setStatus("pending");
|
|
|
+ sub.setGroupNo(groupNo);
|
|
|
+ sub.setPeriodNo(i);
|
|
|
+ sub.setCreatedAt(new Date());
|
|
|
+ sub.setUpdatedAt(new Date());
|
|
|
+ paymentOrderMapper.insert(sub);
|
|
|
+ allOrders.add(sub);
|
|
|
+ if (i == 1) { mainOrder = sub; }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ PaymentOrder order = new PaymentOrder();
|
|
|
+ order.setOrderNo(generateOrderNo());
|
|
|
+ order.setFamilyId(familyId);
|
|
|
+ order.setLevelCode(levelCode);
|
|
|
+ order.setPaymentType(paymentType);
|
|
|
+ order.setPeriod(effectivePeriod);
|
|
|
+ order.setAmount(finalAmount);
|
|
|
+ order.setUserCouponId(appliedCouponId);
|
|
|
+ order.setStatus("trial".equals(paymentType) ? "paid" : "pending");
|
|
|
+ order.setPeriodNo(1);
|
|
|
+ order.setCreatedAt(new Date());
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ paymentOrderMapper.insert(order);
|
|
|
+ allOrders.add(order);
|
|
|
+ mainOrder = order;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ("trial".equals(paymentType)) {
|
|
|
+ activateTrialMembership(userId, familyId, mainOrder);
|
|
|
+ }
|
|
|
+
|
|
|
+ // === 构造响应 ===
|
|
|
+ Map<String, Object> resp = new HashMap<>();
|
|
|
+ resp.put("orderNo", mainOrder.getOrderNo());
|
|
|
+ resp.put("status", mainOrder.getStatus());
|
|
|
+ resp.put("levelCode", mainOrder.getLevelCode());
|
|
|
+ resp.put("amount", mainOrder.getAmount());
|
|
|
+ resp.put("groupNo", mainOrder.getGroupNo());
|
|
|
+ resp.put("periodNo", mainOrder.getPeriodNo());
|
|
|
+ resp.put("totalPeriods", totalPeriods);
|
|
|
+
|
|
|
+ // trial 单已直接开通,无需支付
|
|
|
+ if ("trial".equals(paymentType)) {
|
|
|
+ resp.put("payType", "none");
|
|
|
+ return resp;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (virtualEnabled) {
|
|
|
+ VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(mainOrder.getOrderNo(), sessionKey);
|
|
|
+ if (dto == null) {
|
|
|
+ return null; // 道具未配置,调用方转 46001
|
|
|
+ }
|
|
|
+ resp.put("payType", "virtual");
|
|
|
+ resp.put("orderInfo", dto.getOrderInfo());
|
|
|
+ resp.put("extInfo", dto.getExtInfo());
|
|
|
+ resp.put("sign", dto.getSign());
|
|
|
+ resp.put("signature", dto.getSignature());
|
|
|
+ resp.put("env", dto.getEnv());
|
|
|
+ resp.put("signType", dto.getSignType());
|
|
|
+ resp.put("currentPeriod", dto.getCurrentPeriod());
|
|
|
+ return resp;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 普通微信支付(v3 JSAPI)
|
|
|
+ String description = "浠艾福-" + levelCode + "会员";
|
|
|
+ Map<String, Object> payParams = paymentService.createWechatPrepay(
|
|
|
+ mainOrder.getOrderNo(), description, mainOrder.getAmount(), openid);
|
|
|
+ resp.put("payType", "wechat");
|
|
|
+ resp.put("wechatPayParams", payParams);
|
|
|
+ return resp;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 1.4:编写单元测试**
|
|
|
+
|
|
|
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/MembershipPaySwitchTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.InjectMocks;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+
|
|
|
+import com.etotem.cfc.dto.PaymentOrderDTO;
|
|
|
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
|
|
|
+import com.etotem.cfc.entity.MembershipLevel;
|
|
|
+import com.etotem.cfc.entity.PaymentOrder;
|
|
|
+import com.etotem.cfc.mapper.*;
|
|
|
+import com.etotem.cfc.entity.Family;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class MembershipPaySwitchTest {
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private MembershipService membershipService;
|
|
|
+
|
|
|
+ @Mock private MembershipLevelMapper levelMapper;
|
|
|
+ @Mock private FamilyMembershipMapper membershipMapper;
|
|
|
+ @Mock private FamilyMapper familyMapper;
|
|
|
+ @Mock private UserMapper userMapper;
|
|
|
+ @Mock private PaymentOrderMapper paymentOrderMapper;
|
|
|
+ @Mock private CommissionService commissionService;
|
|
|
+ @Mock private SysConfigService sysConfigService;
|
|
|
+ @Mock private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
|
|
|
+ @Mock private MembershipBenefitsMapper benefitsMapper;
|
|
|
+ @Mock private CouponService couponService;
|
|
|
+ @Mock private TrialMembershipMapper trialMembershipMapper;
|
|
|
+ @Mock private VirtualGoodsConfigService virtualGoodsConfigService;
|
|
|
+ @Mock private VirtualPayService virtualPayService;
|
|
|
+ @Mock private PaymentService paymentService;
|
|
|
+
|
|
|
+ // --- 虚拟支付开启(默认) ---
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createOrderWithPayType_virtualEnabled_returnsVirtualPayType() {
|
|
|
+ when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
|
|
|
+ when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
|
|
|
+ when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
|
|
|
+ when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
|
|
|
+ when(paymentOrderMapper.insert(any())).thenReturn(1);
|
|
|
+
|
|
|
+ VirtualPayParamsDTO mockDto = new VirtualPayParamsDTO();
|
|
|
+ mockDto.setOrderInfo(new HashMap<>());
|
|
|
+ mockDto.setSign("mock-sign");
|
|
|
+ mockDto.setSignature("mock-sig");
|
|
|
+ when(virtualPayService.generateMembershipPayParams(anyString(), anyString())).thenReturn(mockDto);
|
|
|
+
|
|
|
+ User user = new User();
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ when(userMapper.selectById(1L)).thenReturn(user);
|
|
|
+
|
|
|
+ Map<String, Object> result = membershipService.createOrderWithPayType(
|
|
|
+ 1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", null);
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals("virtual", result.get("payType"));
|
|
|
+ assertEquals("ORD", result.get("orderNo") != null ? result.get("orderNo").toString().substring(0, 3) : "");
|
|
|
+ assertNotNull(result.get("orderInfo"));
|
|
|
+ assertNotNull(result.get("sign"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- 虚拟支付关闭 ---
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createOrderWithPayType_virtualDisabled_returnsWechatPayType() {
|
|
|
+ when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("0");
|
|
|
+ when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
|
|
|
+ when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
|
|
|
+ when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
|
|
|
+ when(paymentOrderMapper.insert(any())).thenReturn(1);
|
|
|
+
|
|
|
+ Map<String, Object> mockWechatParams = new HashMap<>();
|
|
|
+ mockWechatParams.put("appId", "wx5ba8038ef16fb245");
|
|
|
+ mockWechatParams.put("timeStamp", "1724900000");
|
|
|
+ mockWechatParams.put("package", "prepay_id=wx_mock");
|
|
|
+ when(paymentService.createWechatPrepay(anyString(), anyString(), anyInt(), anyString()))
|
|
|
+ .thenReturn(mockWechatParams);
|
|
|
+
|
|
|
+ User user = new User();
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ when(userMapper.selectById(1L)).thenReturn(user);
|
|
|
+
|
|
|
+ Map<String, Object> result = membershipService.createOrderWithPayType(
|
|
|
+ 1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", "mockOpenid");
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals("wechat", result.get("payType"));
|
|
|
+ assertNotNull(result.get("wechatPayParams"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createOrderWithPayType_virtualDisabled_noSplit() {
|
|
|
+ when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("0");
|
|
|
+ when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
|
|
|
+ when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
|
|
|
+ when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
|
|
|
+ when(paymentOrderMapper.insert(any())).thenReturn(1);
|
|
|
+
|
|
|
+ Map<String, Object> mockWechatParams = new HashMap<>();
|
|
|
+ mockWechatParams.put("package", "prepay_id=wx_mock");
|
|
|
+ when(paymentService.createWechatPrepay(anyString(), anyString(), anyInt(), anyString()))
|
|
|
+ .thenReturn(mockWechatParams);
|
|
|
+
|
|
|
+ User user = new User();
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ when(userMapper.selectById(1L)).thenReturn(user);
|
|
|
+
|
|
|
+ Map<String, Object> result = membershipService.createOrderWithPayType(
|
|
|
+ 1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", "mockOpenid");
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals("wechat", result.get("payType"));
|
|
|
+ assertNull(result.get("groupNo"));
|
|
|
+ assertEquals(1, result.get("totalPeriods"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- trial 路径 ---
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createOrderWithPayType_trial_returnsNone() {
|
|
|
+ when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
|
|
|
+ when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
|
|
|
+ when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
|
|
|
+ when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(paymentOrderMapper.insert(any())).thenReturn(1);
|
|
|
+
|
|
|
+ User user = new User();
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ when(userMapper.selectById(1L)).thenReturn(user);
|
|
|
+
|
|
|
+ Family family = new Family();
|
|
|
+ family.setId(1L);
|
|
|
+ when(familyMapper.selectById(1L)).thenReturn(family);
|
|
|
+
|
|
|
+ Map<String, Object> result = membershipService.createOrderWithPayType(
|
|
|
+ 1L, 1L, "FAMILY", "trial", null, null, "mockSessionKey", null);
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals("none", result.get("payType"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- 道具未配置 ---
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createOrderWithPayType_virtualEnabled_configMissing_returnsNull() {
|
|
|
+ when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
|
|
|
+ when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
|
|
|
+ when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
|
|
|
+ when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
|
|
|
+ when(paymentOrderMapper.insert(any())).thenReturn(1);
|
|
|
+ when(virtualPayService.generateMembershipPayParams(anyString(), anyString())).thenReturn(null);
|
|
|
+
|
|
|
+ User user = new User();
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ when(userMapper.selectById(1L)).thenReturn(user);
|
|
|
+
|
|
|
+ Map<String, Object> result = membershipService.createOrderWithPayType(
|
|
|
+ 1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", null);
|
|
|
+
|
|
|
+ assertNull(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- 辅助方法 ---
|
|
|
+
|
|
|
+ private MembershipLevel buildLevel(String code, int yearly, Integer monthly, Integer quarterly) {
|
|
|
+ MembershipLevel level = new MembershipLevel();
|
|
|
+ level.setLevelCode(code);
|
|
|
+ level.setPriceYearly(yearly);
|
|
|
+ level.setPriceMonthly(monthly);
|
|
|
+ level.setPriceQuarterly(quarterly);
|
|
|
+ return level;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 1.5:编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+**步骤 1.6:运行单元测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=MembershipPaySwitchTest -DfailIfNoTests=false
|
|
|
+```
|
|
|
+预期:4 tests passed,0 failures
|
|
|
+
|
|
|
+**步骤 1.7:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/MembershipPaySwitchTest.java
|
|
|
+git commit -m "feat(membership): 新增 createOrderWithPayType 开关分流虚拟/普通支付"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 2:MembershipController 改造
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java`
|
|
|
+
|
|
|
+**步骤 2.1:改造 createOrder() 方法**
|
|
|
+
|
|
|
+将 `MembershipController.java` 中 `/orders` 端点(L217-L254)替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 创建支付订单
|
|
|
+ */
|
|
|
+@Operation(summary = "创建订单")
|
|
|
+@PostMapping("/orders")
|
|
|
+public Result<Object> createOrder(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return Result.error("用户未登录");
|
|
|
+ }
|
|
|
+
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user == null) {
|
|
|
+ return Result.error("用户不存在");
|
|
|
+ }
|
|
|
+
|
|
|
+ Long familyId = membershipService.getUserFamilyId(userId);
|
|
|
+ String levelCode = params.get("levelCode").toString();
|
|
|
+ String paymentType = params.get("paymentType").toString();
|
|
|
+ String period = params.get("period") != null ? params.get("period").toString() : "yearly";
|
|
|
+ Long couponId = ParamUtils.getLong(params.get("couponId"));
|
|
|
+ String openid = params.get("openid") != null ? params.get("openid").toString() : null;
|
|
|
+
|
|
|
+ // trial 单:createOrder 内已直接开通(status=paid),无需支付,原样返回
|
|
|
+ if ("trial".equals(paymentType)) {
|
|
|
+ PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
|
|
|
+ return Result.success(order);
|
|
|
+ }
|
|
|
+
|
|
|
+ // testMode:保持现有模拟逻辑(不触发真实虚拟/普通支付)
|
|
|
+ if (testMode) {
|
|
|
+ PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
|
|
|
+ return Result.success(order);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用新方法,按开关分流
|
|
|
+ Map<String, Object> result = membershipService.createOrderWithPayType(
|
|
|
+ userId, familyId, levelCode, paymentType, period, couponId,
|
|
|
+ user.getSessionKey(), openid);
|
|
|
+
|
|
|
+ if (result == null) {
|
|
|
+ return Result.error(46001, "该商品暂未开放购买");
|
|
|
+ }
|
|
|
+ return Result.success(result);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 2.2:确认 testMode 字段存在**
|
|
|
+
|
|
|
+`MembershipController.java` L42 已有:
|
|
|
+```java
|
|
|
+@Value("${wechat.test-mode:false}")
|
|
|
+private boolean testMode;
|
|
|
+```
|
|
|
+无需修改。
|
|
|
+
|
|
|
+**步骤 2.3:编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+**步骤 2.4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
|
|
|
+git commit -m "feat(membership): Controller 改为调用 createOrderWithPayType,支持 openid 参数"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 3:PaymentService ORD 回调路由
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java`
|
|
|
+
|
|
|
+**步骤 3.1:在 PaymentService 中注入 MembershipService(@Lazy 防循环)**
|
|
|
+
|
|
|
+在 `PaymentService.java` 的 `@Resource` 块中追加:
|
|
|
+
|
|
|
+```java
|
|
|
+@Lazy
|
|
|
+@Resource
|
|
|
+private MembershipService membershipService;
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 3.2:修改 handleWechatNotify() 的 ORD 前缀路由**
|
|
|
+
|
|
|
+在 `PaymentService.java` 找到 `handleWechatNotify()` 方法中的路由逻辑(L329-334):
|
|
|
+
|
|
|
+```java
|
|
|
+// 修改前:
|
|
|
+if (orderNo != null && orderNo.startsWith("PO")) {
|
|
|
+ productOrderService.handlePaymentSuccess(orderNo, transactionId);
|
|
|
+} else {
|
|
|
+ handlePaymentCallback(orderNo, transactionId, "wechat");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+// 修改后:ORD→会员开通,PO→商城,其余→PackageOrder(原有 fallback)
|
|
|
+if (orderNo != null && orderNo.startsWith("ORD")) {
|
|
|
+ membershipService.processPaymentCallback(orderNo, transactionId, "wechat");
|
|
|
+} else if (orderNo != null && orderNo.startsWith("PO")) {
|
|
|
+ productOrderService.handlePaymentSuccess(orderNo, transactionId);
|
|
|
+} else {
|
|
|
+ handlePaymentCallback(orderNo, transactionId, "wechat");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 3.3:编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+预期:`BUILD SUCCESS`(`@Lazy` 解决循环依赖)
|
|
|
+
|
|
|
+**步骤 3.4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
|
|
|
+git commit -m "feat(pay): handleWechatNotify 增加 ORD 前缀路由到会员开通回调"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 4:前端 — api.js 和 pay.vue
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/utils/api.js`
|
|
|
+- 修改:`cfc-frontend/pages/membership/pay.vue`
|
|
|
+
|
|
|
+**步骤 4.1:修改 api.js 中 createOrder() 透传 openid**
|
|
|
+
|
|
|
+在 `cfc-frontend/utils/api.js` 中找到 `createOrder` 函数(L330):
|
|
|
+
|
|
|
+```js
|
|
|
+// 修改前:
|
|
|
+export const createOrder = (levelCode, paymentType, period, couponId) => {
|
|
|
+ var data = { levelCode: levelCode, paymentType: paymentType }
|
|
|
+ if (period) data.period = period
|
|
|
+ if (couponId) data.couponId = couponId
|
|
|
+ return request('/api/membership/orders', 'POST', data)
|
|
|
+}
|
|
|
+
|
|
|
+// 修改后:
|
|
|
+export const createOrder = (levelCode, paymentType, period, couponId, openid) => {
|
|
|
+ var data = { levelCode: levelCode, paymentType: paymentType }
|
|
|
+ if (period) data.period = period
|
|
|
+ if (couponId) data.couponId = couponId
|
|
|
+ if (openid) data.openid = openid
|
|
|
+ return request('/api/membership/orders', 'POST', data)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 4.2:修改 pay.vue handlePay() 支持 payType 分流**
|
|
|
+
|
|
|
+在 `cfc-frontend/pages/membership/pay.vue` 的 `handlePay` 方法(L217)中,将现有的 if-else 分支替换为:
|
|
|
+
|
|
|
+```js
|
|
|
+handlePay: function() {
|
|
|
+ var self = this
|
|
|
+ if (this.paying) return
|
|
|
+ this.paying = true
|
|
|
+ var couponId = this.selectedCoupon ? this.selectedCoupon.id : null
|
|
|
+ var openid = uni.getStorageSync('openid') || ''
|
|
|
+ createOrder(this.levelCode, 'pay', this.period, couponId, openid).then(function(res) {
|
|
|
+ var data = res.data || {}
|
|
|
+ self.orderNo = data.orderNo
|
|
|
+ var payType = data.payType || 'virtual'
|
|
|
+
|
|
|
+ if (payType === 'virtual' && data.orderInfo && data.sign) {
|
|
|
+ // 虚拟支付(现有逻辑不变)
|
|
|
+ self.totalPeriods = data.totalPeriods || 1
|
|
|
+ self.currentPeriod = data.currentPeriod || 1
|
|
|
+ self.splitAmount = data.orderInfo.goodsPrice || (self.finalPrice / (self.totalPeriods || 1))
|
|
|
+ self.payInstallments(data)
|
|
|
+ } else if (payType === 'wechat' && data.wechatPayParams) {
|
|
|
+ // 普通微信支付(新增分支)
|
|
|
+ self.payWechat(data.wechatPayParams)
|
|
|
+ } else {
|
|
|
+ // testMode/trial:模拟支付成功
|
|
|
+ self.orderNo = data.orderNo
|
|
|
+ uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
|
|
|
+ setTimeout(function() {
|
|
|
+ self.paying = false
|
|
|
+ uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
|
|
|
+ }, 800)
|
|
|
+ }
|
|
|
+ }).catch(function(e) {
|
|
|
+ self.paying = false
|
|
|
+ if (e.code === 46001) {
|
|
|
+ uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: e.message || '下单失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 4.3:新增 payWechat() 方法**
|
|
|
+
|
|
|
+在 `pay.vue` 的 `methods` 中,`finishPay` 方法之前插入:
|
|
|
+
|
|
|
+```js
|
|
|
+payWechat: function(pp) {
|
|
|
+ var self = this
|
|
|
+ if (!pp || !pp.package) {
|
|
|
+ self.paying = false
|
|
|
+ uni.showToast({ title: '支付参数异常', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ uni.requestPayment({
|
|
|
+ provider: 'wxpay',
|
|
|
+ timeStamp: pp.timeStamp,
|
|
|
+ nonceStr: pp.nonceStr,
|
|
|
+ package: pp.package,
|
|
|
+ signType: pp.signType || 'RSA',
|
|
|
+ paySign: pp.paySign || pp.sign,
|
|
|
+ success: function() {
|
|
|
+ uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
|
|
|
+ setTimeout(function() {
|
|
|
+ self.paying = false
|
|
|
+ uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
|
|
|
+ }, 800)
|
|
|
+ },
|
|
|
+ fail: function(err) {
|
|
|
+ self.paying = false
|
|
|
+ var errCode = err && err.errCode
|
|
|
+ var msg = '支付取消'
|
|
|
+ if (errCode === -15007 || errCode === -15005) msg = '登录态已过期,请重新登录'
|
|
|
+ else if (err && err.errMsg) msg = err.errMsg
|
|
|
+ uni.showToast({ title: msg, icon: 'none' })
|
|
|
+ setTimeout(function() {
|
|
|
+ uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=fail' })
|
|
|
+ }, 800)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**步骤 4.4:语法校验**
|
|
|
+
|
|
|
+```bash
|
|
|
+# api.js 语法检查
|
|
|
+node --check /sc-data/cfc/cfc-frontend/utils/api.js && echo "api.js OK"
|
|
|
+
|
|
|
+# pay.vue script 块语法检查
|
|
|
+sed -n '/<script>/,/<\/script>/p' /sc-data/cfc/cfc-frontend/pages/membership/pay.vue | sed '1d;$d' > /tmp/pay-check.js
|
|
|
+node --check /tmp/pay-check.js && echo "pay.vue OK"
|
|
|
+```
|
|
|
+预期:两行均输出 `OK`
|
|
|
+
|
|
|
+**步骤 4.5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js cfc-frontend/pages/membership/pay.vue
|
|
|
+git commit -m "feat(frontend): pay.vue 支持 payType 分流,新增普通微信支付路径"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 5:全量编译验证
|
|
|
+
|
|
|
+**步骤 5.1:后端编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+**步骤 5.2:路由重复检查**
|
|
|
+
|
|
|
+```bash
|
|
|
+grep -rn '@Mapping' /sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u
|
|
|
+```
|
|
|
+预期:无 `/api/membership/orders` 重复路径
|
|
|
+
|
|
|
+**步骤 5.3:Bean 命名冲突检查**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 确认 PaymentService 和 MembershipService 无循环注入问题
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn compile -q 2>&1 | grep -i "circular\|cyclic\|bean.*conflict" || echo "no conflicts"
|
|
|
+```
|
|
|
+预期:`no conflicts`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 验收标准对照
|
|
|
+
|
|
|
+- [ ] `member_pay_virtual_enabled=1`(或省略)→ 接口返回 `payType=virtual`,含 `orderInfo/sign/signature`
|
|
|
+- [ ] `member_pay_virtual_enabled=0` → 接口返回 `payType=wechat`,含 `wechatPayParams`
|
|
|
+- [ ] 关闭虚拟支付时订单无 `groupNo`,`totalPeriods=1`(单订单全额支付)
|
|
|
+- [ ] v3 回调中 ORD 前缀 → `MembershipService.processPaymentCallback()`(幂等,含补开通逻辑)
|
|
|
+- [ ] trial 路径(`paymentType=trial`)返回 `payType=none`,行为不变
|
|
|
+- [ ] testMode 下返回订单信息(模拟支付),行为不变
|
|
|
+- [ ] 虚拟支付道具未配置 → 返回 null → Controller 转 46001
|
|
|
+- [ ] `mvn clean compile` 通过
|
|
|
+- [ ] 前端脚本语法检查通过
|