Bladeren bron

fix(membership): 会员判定取个人与家庭身份中更高级别,防止家庭同步覆盖更高个人会员

- getMemberLevel: 个人有效身份(users.member_level+有效期) 与家庭有效身份(family_memberships)
  max 比较取更高者,覆盖全部用户侧调用方(折扣/可见等级/权限/赠礼前置校验等)
- resolveEffectiveLevel: level_order 比较,平级时优先非 FREE(当前 FREE=BASIC=1 防同级覆盖)
- syncMembershipToFamilyMembers/syncMembershipForNewMember: 家庭同步只升不降,
  成员已有更高级别个人身份时保留,避免覆盖
- downgradeFamilyMembers: 按家庭历史最高 level_order 守卫,仅降级不高于该级别的成员,
  保留 LIFETIME/PREMIUM 等纯个人身份
- 新增 MembershipServiceTest 17 用例(纯 Mockito)
Xiaogang Liao 5 dagen geleden
bovenliggende
commit
d82bb8d599

+ 62 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -923,6 +923,10 @@ public class MembershipService implements MembershipServiceInterface {
         Date now = new Date();
         for (User u : familyUsers) {
             String fromLevel = u.getMemberLevel() != null ? u.getMemberLevel() : "FREE";
+            // 家庭会员同步只升不降:成员已有更高级别的个人会员身份时保留(避免覆盖)
+            if (getLevelOrder(levelCode) < getLevelOrder(fromLevel)) {
+                continue;
+            }
             // 只有等级变化或到期时间延长时才写入(避免不必要的降级覆盖)
             if (levelCode.equals(u.getMemberLevel())
                     && u.getMemberExpireTime() != null
@@ -958,10 +962,28 @@ public class MembershipService implements MembershipServiceInterface {
                 new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId));
         if (familyUsers == null || familyUsers.isEmpty()) return;
 
+        // 家庭历史最高会员级别:仅当成员当前级别不高于该级别时才可判定其身份来自家庭并降级,
+        // 更高级别(如个人购买的 LIFETIME/PREMIUM)保留,避免误清个人会员身份
+        int maxFamilyOrder = 0;
+        List<FamilyMembership> allMemberships = membershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, familyId));
+        if (allMemberships != null) {
+            for (FamilyMembership m : allMemberships) {
+                if (m.getLevelCode() != null) {
+                    maxFamilyOrder = Math.max(maxFamilyOrder, getLevelOrder(m.getLevelCode()));
+                }
+            }
+        }
+
         Date now = new Date();
         int downgraded = 0;
         for (User u : familyUsers) {
             if (!"FREE".equals(u.getMemberLevel())) {
+                if (getLevelOrder(u.getMemberLevel()) > maxFamilyOrder) {
+                    log.info("保留成员更高级别个人会员: userId={}, level={}", u.getId(), u.getMemberLevel());
+                    continue;
+                }
                 u.setMemberLevel("FREE");
                 u.setMemberExpireTime(null);
                 u.setUpdatedAt(now);
@@ -986,6 +1008,10 @@ public class MembershipService implements MembershipServiceInterface {
         User u = userMapper.selectById(userId);
         if (u == null) return;
         String fromLevel = u.getMemberLevel() != null ? u.getMemberLevel() : "FREE";
+        // 家庭身份只升不降:成员已有更高级别的个人会员身份时保留
+        if (getLevelOrder(active.getLevelCode()) < getLevelOrder(fromLevel)) {
+            return;
+        }
         // 只有当前等级更低或到期更早时才同步升级
         if (active.getLevelCode().equals(u.getMemberLevel())
                 && u.getMemberExpireTime() != null
@@ -1162,7 +1188,10 @@ public class MembershipService implements MembershipServiceInterface {
     }
 
     /**
-     * 获取用户当前有效会员等级(检查有效期)
+     * 获取用户当前有效会员等级(检查有效期)。
+     * 会员身份包含两个维度:个人会员身份(users.member_level + member_expire_time)
+     * 与家庭会员身份(family_memberships 有效付费记录)。两者不一致时,取级别更高者
+     * 作为实际有效身份(级别对照 membership_levels.level_order)。
      */
     public String getMemberLevel(Long userId) {
         User user = userMapper.selectById(userId);
@@ -1170,13 +1199,19 @@ public class MembershipService implements MembershipServiceInterface {
             return "FREE";
         }
 
-        String level = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
-        if (("FAMILY".equals(level) || "BASIC".equals(level)) && user.getMemberExpireTime() != null) {
+        // 个人会员身份(含有效期校验)
+        String personal = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
+        if (("FAMILY".equals(personal) || "BASIC".equals(personal)) && user.getMemberExpireTime() != null) {
             if (user.getMemberExpireTime().before(new Date())) {
-                return "FREE";
+                personal = "FREE";
             }
         }
-        return level;
+
+        // 家庭会员身份(family_memberships 有效付费记录,含有效期校验)
+        String family = user.getFamilyId() != null ? getFamilyEffectiveLevel(user.getFamilyId()) : "FREE";
+
+        // 个人与家庭身份不一致时,取级别更高者
+        return resolveEffectiveLevel(personal, family);
     }
 
     /**
@@ -1197,6 +1232,28 @@ public class MembershipService implements MembershipServiceInterface {
         }
     }
 
+    /**
+     * 会员判定:取个人会员身份与家庭会员身份中级别更高者作为实际有效身份。
+     * 级别对照 membership_levels.level_order(数字越大级别越高)。
+     * 级别数字相同时(如 FREE 与 BASIC 同为 1),优先非 FREE 的一侧,避免同级覆盖。
+     */
+    public String resolveEffectiveLevel(String personalLevel, String familyLevel) {
+        String personal = (personalLevel == null || personalLevel.isEmpty()) ? "FREE" : personalLevel;
+        String family = (familyLevel == null || familyLevel.isEmpty()) ? "FREE" : familyLevel;
+        int personalOrder = getLevelOrder(personal);
+        int familyOrder = getLevelOrder(family);
+        if (familyOrder > personalOrder) {
+            return family;
+        }
+        if (personalOrder > familyOrder) {
+            return personal;
+        }
+        if ("FREE".equals(personal) && !"FREE".equals(family)) {
+            return family;
+        }
+        return personal;
+    }
+
     /**
      * 获取用户可见的会员等级编码集合:级别数字 &lt;= 用户级别数字 的所有等级编码(低级别可见则高级别也可见)。
      * 游客/匿名(userId 为空或&lt;=0)返回空集合,仅可见 min_visible_level IS NULL(全部可见)的商品/分类。

+ 262 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/MembershipServiceTest.java

@@ -0,0 +1,262 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMembership;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
+import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * MembershipService 单元测试(纯单元测试,无 SpringBootTest,避免 MySQL 连接)。
+ *
+ * 测试范围:
+ * - getMemberLevel: 会员判定取个人/家庭身份中更高级别(含有效期、FREE/BASIC 平级 tie-break)
+ * - resolveEffectiveLevel: 级别比较核心逻辑
+ * - syncMembershipToFamilyMembers: 家庭同步只升不降,不覆盖更高级别个人身份
+ * - downgradeFamilyMembers: 降级时保留高于家庭历史最高级别的个人身份
+ *
+ * 说明:level_code → level_order 的映射属于配置数据而非被测逻辑,通过 spy stub
+ * getLevelOrder 提供(与生产 membership_levels 数据一致:FREE=1 BASIC=1 FAMILY=2
+ * PROVIDER=3 PREMIUM=4 LIFETIME=5)。
+ */
+public class MembershipServiceTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMembershipMapper membershipMapper;
+
+    @Mock
+    private MembershipLevelMapper levelMapper;
+
+    @Mock
+    private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
+
+    private MembershipService service;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        MembershipService raw = new MembershipService();
+        setField(raw, "userMapper", userMapper);
+        setField(raw, "membershipMapper", membershipMapper);
+        setField(raw, "levelMapper", levelMapper);
+        setField(raw, "memberUpgradeRecordMapper", memberUpgradeRecordMapper);
+        service = Mockito.spy(raw);
+        doReturn(1).when(service).getLevelOrder("FREE");
+        doReturn(1).when(service).getLevelOrder("BASIC");
+        doReturn(2).when(service).getLevelOrder("FAMILY");
+        doReturn(3).when(service).getLevelOrder("PROVIDER");
+        doReturn(4).when(service).getLevelOrder("PREMIUM");
+        doReturn(5).when(service).getLevelOrder("LIFETIME");
+    }
+
+    private void setField(Object target, String fieldName, Object value) {
+        try {
+            Field f = target.getClass().getDeclaredField(fieldName);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private User user(Long id, String level, Date expire, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setMemberLevel(level);
+        u.setMemberExpireTime(expire);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private FamilyMembership activeMembership(String code) {
+        FamilyMembership fm = new FamilyMembership();
+        fm.setLevelCode(code);
+        fm.setPaymentStatus("paid");
+        fm.setEndDate(future());
+        return fm;
+    }
+
+    private FamilyMembership membership(String code, Date end) {
+        FamilyMembership fm = new FamilyMembership();
+        fm.setLevelCode(code);
+        fm.setEndDate(end);
+        return fm;
+    }
+
+    private Date future() {
+        return new Date(System.currentTimeMillis() + 86_400_000L);
+    }
+
+    private Date past() {
+        return new Date(System.currentTimeMillis() - 86_400_000L);
+    }
+
+    // ── getMemberLevel ─────────────────────────────────────────────────
+
+    @Test
+    void getMemberLevel_nullUser_returnsFree() {
+        when(userMapper.selectById(any())).thenReturn(null);
+        assertEquals("FREE", service.getMemberLevel(999L));
+    }
+
+    @Test
+    void getMemberLevel_personalFamily_noFamilyRecord_returnsFamily() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", future(), null));
+        assertEquals("FAMILY", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFamily_expired_returnsFree() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", past(), null));
+        assertEquals("FREE", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFree_familyPremiumActive_returnsPremium() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FREE", null, 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("PREMIUM"));
+        assertEquals("PREMIUM", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalPremium_familyFamily_returnsPremium() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "PREMIUM", future(), 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("FAMILY"));
+        assertEquals("PREMIUM", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFamily_familyPremium_returnsPremium() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", future(), 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("PREMIUM"));
+        assertEquals("PREMIUM", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalBasic_tieBreakKeepsBasic() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "BASIC", future(), 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(null);
+        assertEquals("BASIC", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFree_familyBasic_tieBreakReturnsBasic() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FREE", null, 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("BASIC"));
+        assertEquals("BASIC", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalLifetime_noFamily_returnsLifetime() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "LIFETIME", null, null));
+        assertEquals("LIFETIME", service.getMemberLevel(1L));
+    }
+
+    // ── resolveEffectiveLevel ──────────────────────────────────────────
+
+    @Test
+    void resolveEffectiveLevel_higherFamilyWins() {
+        assertEquals("FAMILY", service.resolveEffectiveLevel("FREE", "FAMILY"));
+    }
+
+    @Test
+    void resolveEffectiveLevel_higherPersonalWins() {
+        assertEquals("PREMIUM", service.resolveEffectiveLevel("PREMIUM", "FAMILY"));
+    }
+
+    @Test
+    void resolveEffectiveLevel_tie_prefersNonFree() {
+        assertEquals("BASIC", service.resolveEffectiveLevel("FREE", "BASIC"));
+        assertEquals("BASIC", service.resolveEffectiveLevel("BASIC", "FREE"));
+    }
+
+    @Test
+    void resolveEffectiveLevel_nullInputs_returnsFree() {
+        assertEquals("FREE", service.resolveEffectiveLevel(null, null));
+        assertEquals("FREE", service.resolveEffectiveLevel("FREE", null));
+    }
+
+    // ── syncMembershipToFamilyMembers ──────────────────────────────────
+
+    @Test
+    void syncMembership_neverOverwritesHigherPersonalLevel() {
+        User premium = user(1L, "PREMIUM", future(), 10L);
+        when(userMapper.selectList(any())).thenReturn(Collections.singletonList(premium));
+        callSync("FAMILY");
+        verify(userMapper, never()).updateById(premium);
+    }
+
+    @Test
+    void syncMembership_writesWhenFamilyLevelHigher() {
+        User freeUser = user(1L, "FREE", null, 10L);
+        when(userMapper.selectList(any())).thenReturn(Collections.singletonList(freeUser));
+        callSync("FAMILY");
+        verify(userMapper).updateById(freeUser);
+    }
+
+    private void callSync(String levelCode) {
+        try {
+            Method m = MembershipService.class.getDeclaredMethod(
+                    "syncMembershipToFamilyMembers", Long.class, String.class, Date.class, String.class, Long.class);
+            m.setAccessible(true);
+            m.invoke(service, 10L, levelCode, future(), "pay", 100L);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    // ── downgradeFamilyMembers ─────────────────────────────────────────
+
+    @Test
+    void downgradeFamilyMembers_keepsHigherPersonalLevel() {
+        User lifetime = user(1L, "LIFETIME", null, 10L);
+        User family = user(2L, "FAMILY", future(), 10L);
+        when(userMapper.selectList(any())).thenReturn(Arrays.asList(lifetime, family));
+        when(membershipMapper.selectList(any())).thenReturn(
+                Collections.singletonList(membership("FAMILY", past())));
+        callDowngrade();
+        verify(userMapper, never()).updateById(lifetime);
+        verify(userMapper).updateById(family);
+    }
+
+    @Test
+    void downgradeFamilyMembers_noFamilyHistory_keepsAllPaidMembers() {
+        User premium = user(1L, "PREMIUM", future(), 10L);
+        when(userMapper.selectList(any())).thenReturn(Collections.singletonList(premium));
+        when(membershipMapper.selectList(any())).thenReturn(Collections.emptyList());
+        callDowngrade();
+        verify(userMapper, never()).updateById(premium);
+    }
+
+    private void callDowngrade() {
+        try {
+            Method m = MembershipService.class.getDeclaredMethod("downgradeFamilyMembers", Long.class);
+            m.setAccessible(true);
+            m.invoke(service, 10L);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+}