Explorar o código

chore: auto bump version and changelog [skip ci]

iwt hai 2 meses
pai
achega
e7752fb86d

+ 187 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -16,7 +16,9 @@ import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.UUID;
 import java.util.stream.Collectors;
 
@@ -60,6 +62,12 @@ public class MembershipService implements MembershipServiceInterface {
     @Resource
     private VirtualGoodsConfigService virtualGoodsConfigService;
 
+    @Resource
+    private VirtualPayService virtualPayService;
+
+    @Resource
+    private PaymentService paymentService;
+
     // ==================== 基础查询 ====================
 
     /**
@@ -445,6 +453,185 @@ public class MembershipService implements MembershipServiceInterface {
         return dto;
     }
 
+    /**
+     * 创建会员订单并按 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;
+    }
+
     /**
      * 处理支付回调
      */

+ 192 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/MembershipPaySwitchTest.java

@@ -0,0 +1,192 @@
+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 org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+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;
+
+@MockitoSettings(strictness = Strictness.LENIENT)
+@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 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-636f900899adc309ca68333d8dd7d243bf38cca9
+508427acaef9921ac5b06ced4bb7d1a0c3586759

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1241",
+  "version": "1.0.1242",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1241",
+      "version": "1.0.1242",
       "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.1242",
+  "version": "1.0.1243",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,19 @@
 
 ---
 
+## v1.0.1243 (2026-08-29)
+
+### Bug 修复
+- 小程序前端 GET 请求全部改为 POST + 成长打卡页美化与自动返回
+
+### 其他
+- - 前端: 7 处 GET→POST (api.js, service-tasks/chat/execution/rating/invite.vue)
+- - 成长打卡页美化 (diet/exercise/sleep/emotion-checkin.vue):
+-   * 统一卡片式布局、导航栏、表单/标签/媒体工具栏/提交按钮样式
+-   * 打卡成功后 800ms 自动 navigateBack 回成长页
+- 
+
+
 ## v1.0.1242 (2026-08-29)
 
 ### 文档

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1242
+> 当前版本: v1.0.1243
 
 ## 历史版本
 
@@ -8,6 +8,19 @@
 
 ---
 
+## v1.0.1243 (2026-08-29)
+
+### Bug 修复
+- 小程序前端 GET 请求全部改为 POST + 成长打卡页美化与自动返回
+
+### 其他
+- - 前端: 7 处 GET→POST (api.js, service-tasks/chat/execution/rating/invite.vue)
+- - 成长打卡页美化 (diet/exercise/sleep/emotion-checkin.vue):
+-   * 统一卡片式布局、导航栏、表单/标签/媒体工具栏/提交按钮样式
+-   * 打卡成功后 800ms 自动 navigateBack 回成长页
+- 
+
+
 ## v1.0.1242 (2026-08-29)
 
 ### 文档