Selaa lähdekoodia

fix(backend): 下沉会员等级查询为 MemberLevelQueryService,打破配额域启动期真环

背景:
  启动期 eager 真环 ActivationQuotaService → BasicQuotaService →
MembershipService → ActivationQuotaService(三条边全 eager、无 @Lazy),
  迫使 application.yml 打开 spring.main.allow-circular-references。

改动:
- 新增 MemberLevelQueryService(只依赖 UserMapper/MembershipLevelMapper/FamilyMembershipMapper):
  承载 getMemberLevel / getLevelOrder / getFamilyEffectiveLevel /
  resolveEffectiveLevel / getActiveFamilyMembership,逻辑自
  MembershipService.java:1263-1290 逐行等价搬迁。
- MembershipService:getMemberLevel / getLevelOrder 改为委托 memberLevelQueryService,保持单一事实源。
- BasicQuotaService:membershipService 字段改为 memberLevelQueryService(@Resource,字段名与 Bean 名严格一致),
  第 108/109/184/185/278 行调用同步改。
- 测试:MembershipServiceTest 装配真实 MemberLevelQueryService(同一组 mock Mapper);
  新增 MemberLevelQueryServiceTest 覆盖 PROVIDER→FREE、FAMILY 过期→FREE、
  个人/家庭取高、未知等级回退 1 四条分支等价性。

验证:
  tools/check_circular_deps.py eager 环 1 → 0;
  mvn clean compile BUILD SUCCESS;
  86 个相关单测全绿;
  mvn spring-boot:run 启动成功,日志无 form a cycle(Tomcat 9082 up);
  不引入新 @Lazy,不影响其余 30+ 条既有的 @Lazy 设计环。
Sisyphus 3 viikkoa sitten
vanhempi
sitoutus
db00f6ccce

+ 11 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java

@@ -57,8 +57,13 @@ public class BasicQuotaService {
     @Resource
     private UserMapper userMapper;
 
+    // 会员等级查询下沉到 MemberLevelQueryService(只依赖 Mapper):
+    // 若此处直接注入 MembershipService,会形成
+    // ActivationQuotaService → BasicQuotaService → MembershipService → ActivationQuotaService
+    // 的启动期 eager 循环依赖(曾迫使开启 spring.main.allow-circular-references)。
+    // 字段名须与 Bean 名(memberLevelQueryService)严格一致。
     @Resource
-    private MembershipService membershipService;
+    private MemberLevelQueryService memberLevelQueryService;
 
     /**
      * 按配额类型返回周期内上限(0 表示无配额限制/未知类型)
@@ -105,8 +110,8 @@ public class BasicQuotaService {
         dto.setPeriodStart(periodStart);
         dto.setPeriodEnd(periodEnd);
         // FAMILY 及以上不限次:下发 unlimited 标记,前端不展示进度条、不做剩余次数拦截
-        int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
-        if (userOrder > membershipService.getLevelOrder("BASIC")) {
+        int userOrder = memberLevelQueryService.getLevelOrder(memberLevelQueryService.getMemberLevel(userId));
+        if (userOrder > memberLevelQueryService.getLevelOrder("BASIC")) {
             dto.setUnlimited(true);
         }
         return dto;
@@ -181,8 +186,8 @@ public class BasicQuotaService {
             result.setSuccess(true);
             return result;
         }
-        int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
-        int basicOrder = membershipService.getLevelOrder("BASIC");
+        int userOrder = memberLevelQueryService.getLevelOrder(memberLevelQueryService.getMemberLevel(userId));
+        int basicOrder = memberLevelQueryService.getLevelOrder("BASIC");
         if (userOrder > basicOrder) {
             // FAMILY/PREMIUM/LIFETIME:不限次
             result.setSuccess(true);
@@ -275,7 +280,7 @@ public class BasicQuotaService {
         if (userId == null) {
             return false;
         }
-        String level = membershipService.getMemberLevel(userId);
+        String level = memberLevelQueryService.getMemberLevel(userId);
         if (!"BASIC".equals(level)) {
             return false;
         }

+ 143 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MemberLevelQueryService.java

@@ -0,0 +1,143 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMembership;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 会员等级查询服务(只读下沉层)。
+ *
+ * <p>背景:原 {@code getMemberLevel / getLevelOrder} 挂在 MembershipService 上,导致
+ * {@code ActivationQuotaService → BasicQuotaService → MembershipService → ActivationQuotaService}
+ * 三条边全为 eager 的启动期循环依赖(迫使 application.yml 打开
+ * {@code spring.main.allow-circular-references: true})。</p>
+ *
+ * <p>做法:把等级判定逻辑逐行等价下沉到本类,使 {@code BasicQuotaService} 只依赖
+ * 「只读、无状态、不依赖任何 Service」的查询组件,从设计上断开启动期真环。</p>
+ *
+ * <p><b>强约束</b>:本类只允许依赖 Mapper(UserMapper / MembershipLevelMapper /
+ * FamilyMembershipMapper),<b>严禁注入任何 Service</b>,否则循环依赖只会被转移而非消除。
+ * 新增注入前必须先跑 {@code python3 tools/check_circular_deps.py} 确认依赖图无 eager 环。</p>
+ *
+ * @see MemberLevelQueryService#getMemberLevel(Long)
+ * @see MemberLevelQueryService#getLevelOrder(String)
+ */
+@Slf4j
+@Service
+public class MemberLevelQueryService {
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private MembershipLevelMapper levelMapper;
+
+    @Resource
+    private FamilyMembershipMapper membershipMapper;
+
+    /**
+     * 获取用户当前有效会员等级(检查有效期)。
+     * 会员身份包含两个维度:个人会员身份(users.member_level + member_expire_time)
+     * 与家庭会员身份(family_memberships 有效付费记录)。两者不一致时,取级别更高者
+     * 作为实际有效身份(级别对照 membership_levels.level_order)。
+     */
+    public String getMemberLevel(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return "FREE";
+        }
+
+        // 个人会员身份(含有效期校验)
+        String personal = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
+        // 服务商(PROVIDER)为独立角色,不构成会员等级身份,不参与会员权益判定
+        if ("PROVIDER".equals(personal)) {
+            personal = "FREE";
+        }
+        if ("FAMILY".equals(personal) && user.getMemberExpireTime() != null) {
+            if (user.getMemberExpireTime().before(new Date())) {
+                personal = "FREE";
+            }
+        }
+
+        // 家庭会员身份(family_memberships 有效付费记录,含有效期校验)
+        String family = user.getFamilyId() != null ? getFamilyEffectiveLevel(user.getFamilyId()) : "FREE";
+
+        // 个人与家庭身份不一致时,取级别更高者
+        return resolveEffectiveLevel(personal, family);
+    }
+
+    /**
+     * 获取等级编码对应的级别数字(数字越大级别越高;PROVIDER=1 与 FREE 同级,视为非会员),未知等级回退 1(最低)
+     */
+    public int getLevelOrder(String levelCode) {
+        if (levelCode == null || levelCode.isEmpty()) {
+            return 1;
+        }
+        try {
+            MembershipLevel level = levelMapper.selectOne(
+                    new LambdaQueryWrapper<MembershipLevel>()
+                            .eq(MembershipLevel::getLevelCode, levelCode)
+                            .last("LIMIT 1"));
+            return (level != null && level.getLevelOrder() != null) ? level.getLevelOrder() : 1;
+        } catch (Exception e) {
+            return 1;
+        }
+    }
+
+    /**
+     * 获取家庭有效会员等级(按家庭维度判定,不依赖任何特定用户)
+     * @return 等级代码(FAMILY/PREMIUM/LIFETIME/FREE),无有效会员返回 FREE
+     */
+    private String getFamilyEffectiveLevel(Long familyId) {
+        if (familyId == null) return "FREE";
+        FamilyMembership active = getActiveFamilyMembership(familyId);
+        if (active != null) return active.getLevelCode();
+        return "FREE";
+    }
+
+    /**
+     * 会员判定:取个人会员身份与家庭会员身份中级别更高者作为实际有效身份。
+     * 级别对照 membership_levels.level_order(数字越大级别越高)。
+     * 级别数字相同时(如 FREE 与 BASIC 同为 1),优先非 FREE 的一侧,避免同级覆盖。
+     */
+    private 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;
+    }
+
+    /**
+     * 方案B:获取家庭当前有效的付费会员记录(未过期、paymentStatus=paid)
+     * @return 有效会员记录,无则返回 null
+     */
+    private FamilyMembership getActiveFamilyMembership(Long familyId) {
+        if (familyId == null) return null;
+        return membershipMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, familyId)
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .gt(FamilyMembership::getEndDate, new Date())
+                        .orderByDesc(FamilyMembership::getEndDate)
+                        .last("LIMIT 1"));
+    }
+}

+ 11 - 34
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -83,6 +83,12 @@ public class MembershipService implements MembershipServiceInterface {
     @Resource
     private ActivationQuotaService activationQuotaService;
 
+    // 会员等级判定的唯一事实源在 MemberLevelQueryService(只依赖 Mapper);
+    // 本类仅做委托,避免 getMemberLevel/getLevelOrder 存在两套实现。
+    // 字段名须与 Bean 名(memberLevelQueryService)严格一致。
+    @Resource
+    private MemberLevelQueryService memberLevelQueryService;
+
     // ==================== 基础查询 ====================
 
     /**
@@ -1259,48 +1265,19 @@ public class MembershipService implements MembershipServiceInterface {
      * 会员身份包含两个维度:个人会员身份(users.member_level + member_expire_time)
      * 与家庭会员身份(family_memberships 有效付费记录)。两者不一致时,取级别更高者
      * 作为实际有效身份(级别对照 membership_levels.level_order)。
+     *
+     * <p>实现已下沉至 {@link MemberLevelQueryService}(唯一事实源,只依赖 Mapper,
+     * 不依赖任何 Service),本方法仅为保持调用方不变的委托入口。</p>
      */
     public String getMemberLevel(Long userId) {
-        User user = userMapper.selectById(userId);
-        if (user == null) {
-            return "FREE";
-        }
-
-        // 个人会员身份(含有效期校验)
-        String personal = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
-        // 服务商(PROVIDER)为独立角色,不构成会员等级身份,不参与会员权益判定
-        if ("PROVIDER".equals(personal)) {
-            personal = "FREE";
-        }
-        if ("FAMILY".equals(personal) && user.getMemberExpireTime() != null) {
-            if (user.getMemberExpireTime().before(new Date())) {
-                personal = "FREE";
-            }
-        }
-
-        // 家庭会员身份(family_memberships 有效付费记录,含有效期校验)
-        String family = user.getFamilyId() != null ? getFamilyEffectiveLevel(user.getFamilyId()) : "FREE";
-
-        // 个人与家庭身份不一致时,取级别更高者
-        return resolveEffectiveLevel(personal, family);
+        return memberLevelQueryService.getMemberLevel(userId);
     }
 
     /**
      * 获取等级编码对应的级别数字(数字越大级别越高;PROVIDER=1 与 FREE 同级,视为非会员),未知等级回退 1(最低)
      */
     public int getLevelOrder(String levelCode) {
-        if (levelCode == null || levelCode.isEmpty()) {
-            return 1;
-        }
-        try {
-            MembershipLevel level = levelMapper.selectOne(
-                    new LambdaQueryWrapper<MembershipLevel>()
-                            .eq(MembershipLevel::getLevelCode, levelCode)
-                            .last("LIMIT 1"));
-            return (level != null && level.getLevelOrder() != null) ? level.getLevelOrder() : 1;
-        } catch (Exception e) {
-            return 1;
-        }
+        return memberLevelQueryService.getLevelOrder(levelCode);
     }
 
     /**

+ 168 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/MemberLevelQueryServiceTest.java

@@ -0,0 +1,168 @@
+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.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.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.when;
+
+/**
+ * MemberLevelQueryService 单元测试(纯单元测试,无 SpringBootTest,避免 MySQL 连接)。
+ *
+ * 说明:本服务由 MembershipService 下沉而来(getMemberLevel / getLevelOrder 逐行等价搬迁,
+ * 唯一事实源,只依赖 Mapper、不依赖任何 Service)。本测试覆盖搬迁前后必须逐分支等价的核心语义:
+ * <ol>
+ *   <li>PROVIDER → FREE(服务商为独立角色,不构成会员等级)</li>
+ *   <li>FAMILY 过期 → FREE(含 member_expire_time 校验)</li>
+ *   <li>个人/家庭取高(含 FREE/BASIC 同级 tie-break 优先非 FREE)</li>
+ *   <li>未知等级回退 1(level_order 缺失或空/未知 code)</li>
+ * </ol>
+ *
+ * level_code → level_order 属于配置数据而非被测逻辑,通过 spy stub getLevelOrder 提供
+ * (与生产 membership_levels 数据一致:FREE=1 BASIC=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5)。
+ */
+public class MemberLevelQueryServiceTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMembershipMapper membershipMapper;
+
+    @Mock
+    private MembershipLevelMapper levelMapper;
+
+    private MemberLevelQueryService service;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        MemberLevelQueryService raw = new MemberLevelQueryService();
+        setField(raw, "userMapper", userMapper);
+        setField(raw, "membershipMapper", membershipMapper);
+        setField(raw, "levelMapper", levelMapper);
+        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 Date future() {
+        return new Date(System.currentTimeMillis() + 86_400_000L);
+    }
+
+    private Date past() {
+        return new Date(System.currentTimeMillis() - 86_400_000L);
+    }
+
+    // ── 分支1:PROVIDER → FREE ──────────────────────────────────────────
+
+    @Test
+    void getMemberLevel_provider_returnsFree() {
+        // 服务商为独立角色,不构成会员等级身份;即便家庭侧有更高会员也按 FREE 参与比较
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "PROVIDER", future(), null));
+        assertEquals("FREE", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_nullUser_returnsFree() {
+        when(userMapper.selectById(999L)).thenReturn(null);
+        assertEquals("FREE", service.getMemberLevel(999L));
+    }
+
+    // ── 分支2:FAMILY 过期 → FREE ────────────────────────────────────────
+
+    @Test
+    void getMemberLevel_familyExpired_returnsFree() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", past(), null));
+        assertEquals("FREE", service.getMemberLevel(1L));
+    }
+
+    // ── 分支3:个人/家庭取高 ─────────────────────────────────────────────
+
+    @Test
+    void getMemberLevel_personalHigher_thanFamily_returnsPersonal() {
+        // 个人 PREMIUM(4) > 家庭 FAMILY(2)
+        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_familyHigher_thanPersonal_returnsFamily() {
+        // 家庭 PREMIUM(4) > 个人 FREE(1)
+        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_tieBetweenFreeAndBasic_prefersNonFree() {
+        // FREE 与 BASIC 同为 level_order=1,平级时优先非 FREE 一侧
+        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_noFamilyRecord_usesPersonalLevel() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", future(), null));
+        assertEquals("FAMILY", service.getMemberLevel(1L));
+    }
+
+    // ── 分支4:未知等级回退 1 ────────────────────────────────────────────
+
+    @Test
+    void getLevelOrder_unknownCode_fallsBackToOne() {
+        assertEquals(1, service.getLevelOrder("UNKNOWN"));
+    }
+
+    @Test
+    void getLevelOrder_nullOrEmpty_fallsBackToOne() {
+        assertEquals(1, service.getLevelOrder(null));
+        assertEquals(1, service.getLevelOrder(""));
+    }
+}

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

@@ -57,11 +57,28 @@ public class MembershipServiceTest {
     @BeforeEach
     public void setUp() {
         MockitoAnnotations.openMocks(this);
+        // 会员等级判定的唯一事实源已下沉至 MemberLevelQueryService:
+        // 用同一组 mock Mapper 装配真实的查询服务,再注入 MembershipService 的委托字段,
+        // 保持既有用例断言语义不变(getMemberLevel 走「MembershipService → MemberLevelQueryService」
+        // 委托链,spy stub 提供 level_order 配置映射,与生产 membership_levels 一致)。
+        MemberLevelQueryService query = new MemberLevelQueryService();
+        setField(query, "userMapper", userMapper);
+        setField(query, "membershipMapper", membershipMapper);
+        setField(query, "levelMapper", levelMapper);
+        query = Mockito.spy(query);
+        doReturn(1).when(query).getLevelOrder("FREE");
+        doReturn(1).when(query).getLevelOrder("BASIC");
+        doReturn(2).when(query).getLevelOrder("FAMILY");
+        doReturn(3).when(query).getLevelOrder("PROVIDER");
+        doReturn(4).when(query).getLevelOrder("PREMIUM");
+        doReturn(5).when(query).getLevelOrder("LIFETIME");
+
         MembershipService raw = new MembershipService();
         setField(raw, "userMapper", userMapper);
         setField(raw, "membershipMapper", membershipMapper);
         setField(raw, "levelMapper", levelMapper);
         setField(raw, "memberUpgradeRecordMapper", memberUpgradeRecordMapper);
+        setField(raw, "memberLevelQueryService", query);
         service = Mockito.spy(raw);
         doReturn(1).when(service).getLevelOrder("FREE");
         doReturn(1).when(service).getLevelOrder("BASIC");