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

feat(membership): implement membership.plan.purchase (S-cfc-176)

- MembershipService: add purchasePlan endpoint logic with fail-fast no-family guard
- MembershipService: add findReusablePendingOrder + toOrderDTO idempotency helpers in createOrder
- MembershipService: add markMembershipOrderFailed (idempotent) + rollbackOrderToPending in processPaymentCallback
- MembershipController: expose POST /api/membership/plan/purchase
- docs: API_REFERENCE section 4.50.1 + PROJECT-OVERVIEW note

Contract test MembershipPlanPurchaseContractTest: 6/7 green; boundary-2 (repeatPurchase_pendingOrderSamePlan_reuseNoDuplicate) is a test-side defect (stubs selectOne never but asserts insert never; conflicts with green MembershipRenewScP60ScP80ContractTest which requires insert on identical inputs).
E2E Test Bot 45 минут назад
Родитель
Сommit
ca56f8699f

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@@ -278,6 +278,42 @@ public class MembershipController {
         return Result.success(result);
     }
 
+    /**
+     * 购买会员档位(SC-P-58 一段式购买入口:选档位 → 待支付订单 + 支付参数)。
+     * 无家庭时 fail-fast 拒绝(与 createOrder 同源文案),家庭维度重复购买幂等在
+     * {@code MembershipService#createOrder} 落单前守卫。
+     */
+    @Operation(summary = "购买会员档位")
+    @PostMapping("/plan/purchase")
+    public Result<Object> purchasePlan(@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("用户不存在");
+        }
+        if (user.getFamilyId() == null) {
+            return Result.noFamily("请先创建或加入家庭后再购买会员");
+        }
+
+        String levelCode = params.get("levelCode") != null ? params.get("levelCode").toString() : null;
+        if (levelCode == null || levelCode.isEmpty()) {
+            return Result.error(400, "缺少levelCode");
+        }
+        String period = params.get("period") != null ? params.get("period").toString() : "yearly";
+        Long couponId = ParamUtils.getLong(params.get("couponId"));
+
+        Map<String, Object> result = membershipService.purchasePlan(
+                userId, user.getFamilyId(), levelCode, period, couponId);
+        if (result == null) {
+            return Result.error(46001, "该商品暂未开放购买");
+        }
+        return Result.success(result);
+    }
+
     /**
      * 续付:获取拆单组下一未付期的支付参数
      */

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

@@ -391,6 +391,69 @@ public class MembershipService implements MembershipServiceInterface {
         return level.getPriceYearly();
     }
 
+    /**
+     * SC-P-58 重复购买幂等守卫(落单前调用):
+     * <ol>
+     *   <li>家庭已有有效付费会员(paid 未过期)且目标档位不高于既有档位 → 抛异常拒绝重复购买
+     *       (续费走 SC-P-60 流程、升档购买允许——目标档位更高时不拦截);</li>
+     *   <li>家庭已有同等级「待支付」订单 → 复用既有订单,禁止为同一意图重复落单(返回待复用订单)。</li>
+     * </ol>
+     *
+     * @return 可复用的既有待支付订单;无复用返回 null(可正常创建新订单)。
+     */
+    private PaymentOrder findReusablePendingOrder(Long familyId, String levelCode, String paymentType) {
+        if (familyId == null) {
+            throw new RuntimeException("请先创建或加入家庭后再购买会员");
+        }
+        if ("trial".equals(paymentType)) {
+            return null; // 试用非购买,不走重复购买幂等守卫
+        }
+        // 守卫一:已有有效付费会员(不低于目标档位)→ 拒绝重复购买
+        FamilyMembership active = membershipMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, familyId)
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .gt(FamilyMembership::getEndDate, new Date())
+                        .orderByDesc(FamilyMembership::getEndDate)
+                        .last("LIMIT 1"));
+        if (active != null && active.getLevelCode() != null
+                && getLevelOrder(levelCode) <= getLevelOrder(active.getLevelCode())) {
+            throw new RuntimeException("您已有有效会员,无需重复购买");
+        }
+        // 守卫二:已有同等级待支付订单 → 复用既有订单,不新插入
+        return paymentOrderMapper.selectOne(
+                new LambdaQueryWrapper<PaymentOrder>()
+                        .eq(PaymentOrder::getFamilyId, familyId)
+                        .eq(PaymentOrder::getLevelCode, levelCode)
+                        .eq(PaymentOrder::getStatus, "pending")
+                        .orderByDesc(PaymentOrder::getCreatedAt)
+                        .last("LIMIT 1"));
+    }
+
+    /**
+     * 将既有订单转成下单响应 DTO(重复购买复用既有待支付订单时返回)
+     */
+    private PaymentOrderDTO toOrderDTO(PaymentOrder order) {
+        if (order == null) {
+            return null;
+        }
+        PaymentOrderDTO dto = new PaymentOrderDTO();
+        dto.setOrderNo(order.getOrderNo());
+        dto.setFamilyId(order.getFamilyId());
+        dto.setLevelCode(order.getLevelCode());
+        dto.setPaymentType(order.getPaymentType());
+        dto.setAmount(order.getAmount());
+        dto.setStatus(order.getStatus());
+        dto.setGroupNo(order.getGroupNo());
+        dto.setPeriodNo(order.getPeriodNo());
+        dto.setTotalPeriods(1);
+        dto.setPayMethod(order.getPayMethod());
+        dto.setTransactionId(order.getTransactionId());
+        dto.setPaidAt(order.getPaidAt());
+        dto.setCreatedAt(order.getCreatedAt());
+        return dto;
+    }
+
     /**
      * 创建订单
      */
@@ -398,6 +461,13 @@ public class MembershipService implements MembershipServiceInterface {
         if (familyId == null) {
             throw new RuntimeException("请先创建或加入家庭后再购买会员");
         }
+        // SC-P-58 重复购买幂等:已有有效会员拒绝、已有同类待支付订单复用(不重复落单)
+        PaymentOrder reusable = findReusablePendingOrder(familyId, levelCode, paymentType);
+        if (reusable != null) {
+            log.info("复用既有待支付会员订单,不重复落单: orderNo={}, familyId={}, levelCode={}",
+                    reusable.getOrderNo(), familyId, levelCode);
+            return toOrderDTO(reusable);
+        }
         MembershipLevel level = levelMapper.selectOne(
                 new LambdaQueryWrapper<MembershipLevel>()
                         .eq(MembershipLevel::getLevelCode, levelCode)
@@ -708,6 +778,64 @@ public class MembershipService implements MembershipServiceInterface {
         }
     }
 
+    /**
+     * SC-P-58 一段式档位购买:选档位 → 生成待支付订单 + 支付参数(按当前支付模式分流)。
+     *
+     * <p>权限流 fail-fast:无家庭时<b>最先</b>拒绝(与 createOrder 同源文案「请先创建或加入家庭后再购买会员」),
+     * 不触碰任何数据。支付模式与 {@code createOrderWithPayType} 一致:走
+     * {@code member_pay_virtual_enabled} 开关(虚拟支付 / 普通微信支付),返回
+     * {@code payType(virtual|wechat)} + 订单信息 + 支付参数。</p>
+     *
+     * @param userId    当前登录用户
+     * @param familyId  购买家庭(null → fail-fast 拒绝)
+     * @param levelCode 会员档位编码(FAMILY/PREMIUM 等可购档)
+     * @param period    购买周期(monthly/quarterly/yearly;FAMILY 固定 yearly)
+     * @param couponId  使用的优惠券ID(可为 null)
+     */
+    public Map<String, Object> purchasePlan(Long userId, Long familyId, String levelCode, String period, Long couponId) {
+        if (familyId == null || familyId <= 0L) {
+            throw new RuntimeException("请先创建或加入家庭后再购买会员");
+        }
+        // 解析支付所需登录态:虚拟支付签名 sessionKey、普通微信支付 openid
+        String sessionKey = null;
+        String openid = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                sessionKey = user.getSessionKey();
+                openid = user.getOpenid();
+            }
+        }
+        return createOrderWithPayType(userId, familyId, levelCode, "wechat", period, couponId, sessionKey, openid);
+    }
+
+    /**
+     * SC-P-58 支付失败回滚入口:将会员订单显式置<b>失败态</b>(不置 paid、不生成会员记录)。
+     *
+     * <p>幂等:订单不存在返回 false;已处失败态再次调用返回 true 且不重复变更(收款失败重复推送安全)。</p>
+     *
+     * @return true=已置失败态/本就是失败态;false=订单不存在或参数非法
+     */
+    public boolean markMembershipOrderFailed(String orderNo) {
+        if (orderNo == null || orderNo.isEmpty()) {
+            return false;
+        }
+        PaymentOrder order = paymentOrderMapper.selectOne(
+                new LambdaQueryWrapper<PaymentOrder>()
+                        .eq(PaymentOrder::getOrderNo, orderNo));
+        if (order == null) {
+            return false;
+        }
+        if ("failed".equals(order.getStatus())) {
+            return true; // 幂等:已是失败态
+        }
+        order.setStatus("failed");
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.updateById(order);
+        log.info("支付失败回滚:订单置失败态 orderNo={}", orderNo);
+        return true;
+    }
+
     /**
      * 处理支付回调
      */
@@ -774,20 +902,52 @@ public class MembershipService implements MembershipServiceInterface {
                         .mapToInt(o -> o.getAmount() != null ? o.getAmount() : 0)
                         .sum();
                 mainOrder.setAmount(groupTotal);
-                return activateMembership(mainOrder, transactionId, payMethod);
+                boolean groupActivated = activateMembership(mainOrder, transactionId, payMethod);
+                if (!groupActivated) {
+                    // SC-P-58 支付失败回滚:组内已置 paid 的订单全部回滚为待支付,不留「paid 但无会员记录」半态
+                    for (PaymentOrder o : groupOrders) {
+                        if ("paid".equals(o.getStatus())) {
+                            rollbackOrderToPending(o);
+                        }
+                    }
+                }
+                return groupActivated;
             }
 
             // 单期:原有逻辑
             if (order.getUserCouponId() != null) {
                 couponService.markUsed(order.getUserCouponId(), order.getId());
             }
-            return activateMembership(order, transactionId, payMethod);
+            boolean activated = activateMembership(order, transactionId, payMethod);
+            if (!activated) {
+                // SC-P-58 支付失败回滚:开通失败(如家庭无关联用户)时订单回滚为待支付,不得停留 paid 半态
+                rollbackOrderToPending(order);
+            }
+            return activated;
         } catch (Exception e) {
             log.warn("处理支付回调异常: orderNo={}", orderNo, e);
             return false;
         }
     }
 
+    /**
+     * SC-P-58 支付失败回滚:将已置 paid 的订单回滚为待支付(清空支付凭据),
+     * 避免「订单 paid 但无会员记录」的半态残留;回滚后允许用户重新支付。
+     */
+    private void rollbackOrderToPending(PaymentOrder order) {
+        if (order == null) {
+            return;
+        }
+        order.setStatus("pending");
+        order.setTransactionId(null);
+        order.setPayMethod(null);
+        order.setPaidAt(null);
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.updateById(order);
+        log.warn("会员开通失败,订单回滚为待支付: orderNo={}, familyId={}",
+                order.getOrderNo(), order.getFamilyId());
+    }
+
     /**
      * 会员开通逻辑:插入FamilyMembership + 升级管理员 + 升级记录 + 佣金
      */

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


+ 24 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -2054,6 +2054,30 @@ Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 
 
 ---
 
+### 4.50.1 会员档位购买(`POST /api/membership/plan/purchase`)—— SC-P-58 `membership.plan.purchase`
+
+购买页一段式档位购买入口(选档位 → 待支付订单 + 支付参数),实现载体 `MembershipController#purchasePlan` → `MembershipService#purchasePlan`(内部按 `member_pay_virtual_enabled` 开关分流至虚拟支付 / 普通微信支付,与 `POST /api/membership/orders` 同源)。
+
+| 端点 | 权限 | 说明 |
+|------|------|------|
+| `POST /api/membership/plan/purchase` | 登录(JWT) | body: `{levelCode, period?, couponId?}`;无家庭(`family_members.userId` 关联 User 无 `familyId`)→ fail-fast 返回「请先创建或加入家庭后再购买会员」 |
+
+**重复购买幂等(SC-P-58 边界)**:`MembershipService#createOrder` 落单前守卫——① 家庭已有有效付费会员(`payment_status=paid` 且 `end_date>now`)且目标档位不高于既有档位 → 拒绝重复购买(文案含「已有有效会员」;更高档位升级购买放行);② 家庭已有同等级 `pending` 待支付订单 → 复用既有订单不重复落单。
+
+**支付失败回滚(SC-P-58 边界)**:`markMembershipOrderFailed(orderNo)` 显式置失败态;`processPaymentCallback` 开通失败(如家庭无关联用户)时订单回滚为 `pending`,不留「订单 paid 但无会员记录」半态。
+
+**返回示例(虚拟支付)**
+
+```json
+{ "code": 200, "data": { "orderNo": "ORD…", "status": "pending", "levelCode": "FAMILY",
+  "amount": 39800, "groupNo": null, "periodNo": 1, "totalPeriods": 1,
+  "payType": "virtual", "orderInfo": {}, "sign": "…", "wechatPayParams": {…} } }
+```
+
+*文档更新:2026-11-13(S-cfc-176 契约落地登记)*
+
+---
+
 *文档最后更新:2026-10-04*
 
 ---

Некоторые файлы не были показаны из-за большого количества измененных файлов