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

chore: auto bump version and changelog [skip ci]

iwt 3 недель назад
Родитель
Сommit
0b8802d5e7

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-dea7fb76f548a1077a8ccb40889c08553d17cd7f
+f812fcc244d22581b411f7e70048d77d708cc655

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1238",
+  "version": "1.0.1239",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1238",
+      "version": "1.0.1239",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1239",
+  "version": "1.0.1240",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 12 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,18 @@
 
 ---
 
+## v1.0.1240 (2026-08-29)
+
+### 新功能
+- 优化家庭挑战创建流程,增加成员数量前置校验和友好提示
+
+### 其他
+- - submitForm 新增客户端校验:选择成员 < 2 时阻止提交并提示友好信息
+- - 新增 goToAddMember 方法:引导用户前往家庭成员页添加成员
+- - 避免后端 500 错误'家庭挑战至少需要2名成员参与'直接暴露给用户
+- 
+
+
 ## v1.0.1239 (2026-08-29)
 
 ### Bug 修复

+ 13 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1239
+> 当前版本: v1.0.1240
 
 ## 历史版本
 
@@ -8,6 +8,18 @@
 
 ---
 
+## v1.0.1240 (2026-08-29)
+
+### 新功能
+- 优化家庭挑战创建流程,增加成员数量前置校验和友好提示
+
+### 其他
+- - submitForm 新增客户端校验:选择成员 < 2 时阻止提交并提示友好信息
+- - 新增 goToAddMember 方法:引导用户前往家庭成员页添加成员
+- - 避免后端 500 错误'家庭挑战至少需要2名成员参与'直接暴露给用户
+- 
+
+
 ## v1.0.1239 (2026-08-29)
 
 ### Bug 修复

+ 396 - 0
docs/superpowers/specs/2026-08-29-membership-pay-switch-design.md

@@ -0,0 +1,396 @@
+# 会员费虚拟支付开关设计
+
+**日期:** 2026-08-29
+**状态:** 设计稿 v1
+**优先级:** P1
+**范围:** 会员费支付方式可切换(虚拟支付 ↔ 普通微信支付)
+
+---
+
+## 1. 需求与背景
+
+### 用户故事
+
+As 平台管理员, I want 在后台通过配置开关控制会员费走虚拟支付还是普通微信支付, So that 在虚拟支付联调/上线过渡期可灵活切换,无需改代码重新发版。
+
+### 现状(勘察结论)
+
+- `POST /api/membership/orders`(`MembershipController.createOrder()`)目前**强制**走虚拟支付:
+  1. `createOrder()` 创建订单(`payment_orders`,前缀 `ORD`,status=pending)
+  2. 非 testMode 时调用 `virtualPayService.generateMembershipPayParams()` 生成 `VirtualPayParamsDTO`(含 `orderInfo/sign/signature`)
+  3. 前端 `pay.vue` 收到 `orderInfo` → `wx.requestVirtualPayment`
+- **会员无普通微信支付(v3 JSAPI)路径**。现有 JSAPI 能力:
+  - `PaymentService.createWechatPrepay(orderNo, description, totalFee, openid)` — 通用 v3 JSAPI 预付单(已用于 `PackagePaymentService` 的套餐支付)
+  - `PaymentService.handleWechatNotify()` — v3 回调已按订单前缀路由,但 `ORD` 前缀未接入
+  - 前端 `payment.vue`(商城)已有 `wechatPayParams` + `wx.requestPayment` 分支先例
+- `sys_config` 表(`SysConfigService`)已提供 key-value 配置能力,后端 `admin` 角色可经 `POST /api/admin/config/update` 修改——**后台开关可直接复用,无需新表**
+
+### 已确认决策
+
+| 决策项 | 结论 |
+|--------|------|
+| 开关范围 | **全局开关**(所有会员等级同时生效) |
+| 开关机制 | `sys_config` 表 `member_pay_virtual_enabled`(`1`=虚拟支付,`0`=普通微信支付;缺省=1 保持现状) |
+| 前端口径 | **payType 分流**:后端在响应中明确 `payType=virtual/wechat`,前端分支调用 `requestVirtualPayment` / `wx.requestPayment` |
+| 普通支付 | WeChat v3 JSAPI(`wx.requestPayment`),复用 `PaymentService.createWechatPrepay` |
+
+---
+
+## 2. 整体架构
+
+```
+┌───────────────────── cfc-frontend pay.vue ─────────────────────┐
+│  handlePay()                                                    │
+│    ① createOrder(levelCode, 'pay', period, couponId, openid)   │
+│        │  ② 后端返回统一响应体: { payType, orderXXX, ... }      │
+│        ▼                                                       │
+│    payType == 'virtual'? ──是──► requestVirtualPayment(dto)     │
+│        │ 否                                                    │
+│        ▼                                                       │
+│    wx.requestPayment(wechatPayParams)  ③ 拉起微信支付           │
+└────────────┬─────────────────────────────────────────────────────┘
+             ▼  微信支付回调(notify_url 指向后端)
+┌───────────────────── cfc-backend ──────────────────────────────┐
+│  PaymentService.handleWechatNotify()(v3 回调已按前缀路由)      │
+│    ④ ORD 前缀 → MembershipService.processPaymentCallback()      │
+│        (幂等已内置,与虚拟支付发货推送同一入口)                  │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 3. 配置开关
+
+### `sys_config` key:`member_pay_virtual_enabled`
+
+| 值 | 行为 |
+|:--:|------|
+| `1`(或不存在,缺省) | 虚拟支付:返回 `VirtualPayParamsDTO`(现状不变) |
+| `0` | 普通微信支付:返回订单信息 + `wechatPayParams` |
+
+- **读取方式**:`SysConfigService.getValue("member_pay_virtual_enabled")`,解析 `"0"` 为关闭,其余(含 null)视为开启
+- **后台操作路径(已存在,零新增)**:
+  - 查看:`POST /api/admin/config/all` / `/list`
+  - 修改:`POST /api/admin/config/update`(body: `{configKey, configValue, description}`)
+  - Web 管理端:`SysConfig.xml` 页面已有编辑入口(如无该菜单项则手动补一行即可,不属本次范围)
+- **迁移要求**:无。`sys_config` 表已存在;缺省 null 即虚拟支付,向后兼容
+
+---
+
+## 4. 后端变更
+
+### 4.1 `MembershipService` — 新增 `createOrderWithPayType()`
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java`
+
+```java
+/**
+ * 创建会员订单并按后台开关决定支付方式。
+ * <p>开关 member_pay_virtual_enabled:1/缺省=虚拟支付;0=普通微信支付(JSAPI)。
+ * @param openid 普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null
+ */
+public Map<String, Object> createOrderWithPayType(Long userId, Long familyId, String levelCode,
+        String paymentType, String period, Long userCouponId, String openid) {
+
+    // 1. 创建订单(现状逻辑不变,trial 直接开通返回)
+    PaymentOrderDTO order = createOrder(userId, familyId, levelCode, paymentType, period, userCouponId);
+
+    // 类型统一返回(避免 controller 判断 trial/testMode)
+    Map<String, Object> resp = new HashMap<>();
+    resp.put("orderNo", order.getOrderNo());
+    resp.put("status", order.getStatus());
+    resp.put("levelCode", order.getLevelCode());
+    resp.put("amount", order.getAmount());
+    resp.put("groupNo", order.getGroupNo());
+    resp.put("periodNo", order.getPeriodNo());
+    resp.put("totalPeriods", order.getTotalPeriods());
+
+    // 2. trial 单已直接开通,无需支付
+    if ("trial".equals(paymentType)) {
+        resp.put("payType", "none");
+        return resp;
+    }
+
+    // 3. 判定支付方式:开关缺省=虚拟支付
+    boolean virtualEnabled = !"0".equals(sysConfigService.getValue("member_pay_virtual_enabled"));
+
+    if (virtualEnabled) {
+        VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(order.getOrderNo(), sessionKey);
+        // 注:sessionKey 由 controller 传入(Controller 持 user,VirtualPayService 不读库)
+        //     —— 具体签名:本方法加参 String sessionKey,controller 传 user.getSessionKey()
+        if (dto == null) {
+            // 道具未配置 → 沿用现状 46001(由 controller 转 error)
+        }
+        resp.put("payType", "virtual");
+        resp.putAll(dto 字段);   // orderInfo/sign/signature/env/signType/currentPeriod/totalPeriods
+        return resp;
+    }
+
+    // 4. 普通微信支付:创建 v3 JSAPI 预付单
+    String description = "浠艾福-" + levelCode + "会员";
+    Map<String, Object> payParams = paymentService.createWechatPrepay(
+            order.getOrderNo(), description, order.getAmount(), openid);
+    resp.put("payType", "wechat");
+    resp.put("wechatPayParams", payParams);
+    return resp;
+}
+```
+
+> ⚠️ 实现注意:
+> - `MembershipService` 需注入 `PaymentService`(`@Resource`,字段名 `paymentService`)——两者无循环依赖(PaymentService 不依赖 MembershipService)
+> - `createOrder()` 内部已有拆单逻辑(`groupNo/periodNo`),普通支付模式**不支持拆单**(微信 JSAPI 单订单金额可任意,道具拆单逻辑仅虚拟支付需要)。即:开关关闭时,`createOrder()` 中基于 `virtual_goods_config.goods_price` 的拆单判断应跳过。**拆单判断在 createOrder 内部通过查 MEMBERSHIP 道具配置触发**(L370-L384),需用参数控制或按开关跳过:
+>   - 方案:给 `createOrder()` 加 `boolean splitEnabled` 参数或开关读取 → 关闭虚拟支付时不查道具、不拆单
+>   - 简化:`createOrder()` 内部本来就是在 `goodsConfig != null` 时才拆——关闭虚拟支付时业务上可正常跳过(金额不要求是道具价整数倍),但为稳妥,按开关显式跳过拆单
+
+### 4.2 `MembershipController.createOrder()` 改造
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java`
+
+```java
+@PostMapping("/orders")
+public Result<Object> createOrder(@RequestBody Map<String, Object> params,
+        @RequestAttribute("userId") Long userId) {
+    // ... 现有参数解析不变
+    String openid = (String) params.get("openid");   // 新增:普通支付 JSAPI 需要
+
+    // trial 单:直接返回(createOrder 内已开通)
+    // testMode 单:返回订单信息(现状兼容)
+    //       ↑ 注:testMode 分支保留在 controller 或下沉 service——下沉后 testMode 返回 payType='wechat' 会调微信 mock,也可接受
+    
+    User user = userMapper.selectById(userId);
+    if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+        return Result.error(400, "会话失效,请重新登录");
+    }
+    
+    Map<String, Object> result = membershipService.createOrderWithPayType(
+            userId, familyId, levelCode, paymentType, period, couponId, user.getSessionKey(), openid);
+    
+    if ("virtual".equals(result.get("payType"))
+            && (result.get("orderInfo") == null || result.get("sign") == null)) {
+        return Result.error(46001, "该商品暂未开放购买");   // 道具未配置
+    }
+    return Result.success(result);
+}
+```
+
+**Controller 调整点汇总:**
+
+| 变化 | 说明 |
+|------|------|
+| 请求体新增 `openid`(可选) | 普通支付时需要;虚拟支付/试用不含也兼容 |
+| 返回体新增 `payType` | `virtual` / `wechat` / `none`(trial) |
+| `testMode` 分支 | 保留测试模式捷径:不在 service 内调微信(testMode 时 `PaymentService.doPost` 返回 mock 预付单——**可接受**,前端仍走 JSAPI 拉起,微信端 mock 失败属预期测试行为) |
+| 46001 错误码 | 仅虚拟支付时道具缺失返回 |
+
+### 4.3 `PaymentService.handleWechatNotify()` — ORD 前缀接入会员回调
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java`
+
+现状(L329-334):
+```java
+if (orderNo != null && orderNo.startsWith("PO")) {
+    productOrderService.handlePaymentSuccess(orderNo, transactionId);
+} else {
+    handlePaymentCallback(orderNo, transactionId, "wechat");   // 走 package_orders
+}
+```
+
+改造:`ORD` 前缀 → `MembershipService.processPaymentCallback`:
+```java
+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");
+}
+```
+
+> ⚠️ `PaymentService` 注入 `MembershipService`:PaymentService 当前不依赖 MembershipService,无循环依赖风险。
+> 注意 `processPaymentCallback` 内部已有幂等(status=paid 短路 + 补开通逻辑),拆单组全部付清后自动开通——与虚拟支付发货推送同一入口,行为一致。
+
+---
+
+## 5. 前端变更
+
+### 5.1 `utils/api.js` — `createOrder()` 增加 openid
+
+```js
+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)
+}
+```
+
+### 5.2 `pages/membership/pay.vue` — payType 分流
+
+```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
+
+    if (data.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)
+      self.payInstallments(data)
+    } else if (data.payType === 'wechat' && data.wechatPayParams) {
+      // 普通微信支付(新增)
+      self.payWechat(data.wechatPayParams)
+    } else {
+      // testMode/trial:模拟成功(现状逻辑不变)
+      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' })
+    }
+  })
+},
+
+// 普通微信支付(新增方法,复用商城 payment.vue 的分支模式)
+payWechat: function(pp) {
+  var self = this
+  uni.requestPayment({
+    provider: 'wxpay',
+    timeStamp: pp.timeStamp,
+    nonceStr: pp.nonceStr,
+    package: pp.package,
+    signType: pp.signType || 'RSA',
+    paySign: pp.paySign || pp.sign,
+    success: function() {
+      // 后端回调异步开通,跳结果页轮询(沿用 membership/result 现状)
+      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
+      uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=fail' })
+    }
+  })
+}
+```
+
+> ⚠️ 小程序限制遵守:不用可选链、不用 `:key` 表达式、Vue2 Options API。
+
+---
+
+## 6. API 响应体(新)
+
+### 6.1 虚拟支付开启时(现状不变 + payType 字段)
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "payType": "virtual",
+    "orderNo": "ORD20260829xxx",
+    "status": "pending",
+    "levelCode": "FAMILY",
+    "amount": 131400,
+    "totalPeriods": 1,
+    "currentPeriod": 1,
+    "orderInfo": { "offerId": "...", "productId": "member1314", "goodsPrice": 131400, "outTradeNo": "ORD..." , "attach": "membership:FAMILY"},
+    "extInfo": {},
+    "sign": "...",
+    "signature": "...",
+    "env": 0,
+    "signType": "HMAC-SHA256"
+  }
+}
+```
+
+### 6.2 普通微信支付开启时(新增)
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "payType": "wechat",
+    "orderNo": "ORD20260829xxx",
+    "status": "pending",
+    "levelCode": "FAMILY",
+    "amount": 131400,
+    "totalPeriods": 1,
+    "periodNo": 1,
+    "wechatPayParams": {
+      "appId": "wx5ba8038ef16fb245",
+      "timeStamp": "1724900000",
+      "nonceStr": "abc123",
+      "package": "prepay_id=wx...",
+      "signType": "RSA",
+      "paySign": "..."
+    }
+  }
+}
+```
+
+---
+
+## 7. 边界与异常处理
+
+| 场景 | 行为 |
+|------|------|
+| 开关=0 且未传 openid | JSAPI 预付单创建失败(微信报错),返回 500——**可接受**:前端正常流程必带 openid(登录时已存 storage) |
+| 开关=0 且支付成功回调 | `PaymentService.handleWechatNotify` → ORD 前缀 → `processPaymentCallback`(幂等) |
+| 拆单 | 普通支付模式**不拆单**(`createOrder` 按开关跳过道具金额拆单逻辑),单次支付全额 |
+| 开关在支付中途切换 | 订单按创建时的 payType 走(支付参数已生成),不影响已生成支付参数;下次下单按新开关 |
+| 优惠券 | 两种模式均走 `createOrder` 现有券逻辑,行为一致 |
+| testMode | 保留现状捷径:直接返回订单信息,前端模拟成功 |
+| 道具缺配(虚拟支付) | 保留 46001「该商品暂未开放购买」 |
+
+---
+
+## 8. 测试策略
+
+1. **单测**(新增 `MembershipPaySwitchTest` 或并入现有):
+   - 开关=1/缺省 → `payType=virtual` 且含 `orderInfo/sign`
+   - 开关=0 → `payType=wechat` 且含 `wechatPayParams`;`createWechatPrepay` 被调用(mock)
+   - `trial` 单 → `payType=none`
+   - 开关=0 时拆单被跳过(`groupNo=null`、单订单、金额=全价)
+2. **回调路由单测**:`handleWechatNotify` ORD 前缀 → `processPaymentCallback`;重复回调幂等
+3. **回归**:开关缺省时行为与现状完全一致(虚拟支付路径零改动)
+4. **编译**:`mvn clean compile`;路由重复检查 `grep -rn '@Mapping' ... | sort -u`
+5. **前端**:`node --check` 语法校验 pay.vue 的 script 块(不打包)
+
+---
+
+## 9. 验收标准
+
+- [ ] `sys_config` 添加 `member_pay_virtual_enabled=0` 后,会员下单返回 `payType=wechat` 且含 `wechatPayParams`,前端拉起普通微信支付
+- [ ] 开关恢复 `1` 后,会员下单返回 `payType=virtual`(现状行为不变)
+- [ ] 普通支付成功 → v3 回调 → ORD 前缀路由 → 会员开通(FamilyMembership + 用户升级 + 佣金),与虚拟支付等价
+- [ ] 普通支付拆单行为:单订单一次支付全额,无 groupNo
+- [ ] trial / testMode 路径不受影响
+- [ ] 46001 道具未配置行为在虚拟支付模式下保留
+- [ ] `mvn clean compile` 通过;无路由重复
+
+---
+
+## 10. 未覆盖范围(后续迭代)
+
+- 测评订单 / 订阅订单的支付开关(本次仅会员;如需要按同一模式扩展)
+- Web 管理端开关 UI 美化(当前直接用 `sys_config` 编辑入口即可)
+- 普通支付的退款链路(会员退款现有 `refundByOrderNo` 基于虚拟支付推送触发;普通支付退款需再接 v3 refund 回调,另行评估)