|
|
@@ -0,0 +1,394 @@
|
|
|
+# CFC 后端循环依赖诊断与修复方案(V2 · 经源码实证重写)
|
|
|
+
|
|
|
+> 诊断日期:2026-10-25
|
|
|
+> 报错现场:`Error starting ApplicationContext` / `The dependencies of some of the beans in the application context form a cycle`
|
|
|
+> 代码基线:诊断基于 `origin/cfclub` HEAD `be62f7465`(2026-10-25 10:45:50);实施基于 `94b5a3d3f`
|
|
|
+> 状态:**✅ 已实施并通过独立核验**(分支 `fix/circular-deps`,5 个提交,核验结论 approve-with-notes)—— 详见第九节
|
|
|
+>
|
|
|
+> **V2 更正说明**:V1 中有两处判断经本轮实证被证伪,已在第二节明确更正:
|
|
|
+> 1. ❌「`@Resource + @Lazy` 对字段注入不生效」→ ✅ **生效**(Spring 4.2+ `CommonAnnotationBeanPostProcessor.buildLazyResourceProxy`,Javadoc 标注 *Since: 4.2*)。
|
|
|
+> 2. ❌「用户报错的那条链仍未修复」→ ✅ **已被 `02ca6aa0c`(10:21) 修复**;用户报错日志时间 10:18:35 **早于**该提交。
|
|
|
+>
|
|
|
+> 但 V2 离线全量依赖图扫描**新发现一个此前无人发现的启动期真环**(配额域小环),这才是 `allow-circular-references: true` 至今必须保留的真正原因。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 一、结论先行(Executive Summary)
|
|
|
+
|
|
|
+| # | 结论 | 证据强度 |
|
|
|
+|---|---|---|
|
|
|
+| 1 | 用户 10:18:35 报的那条 11 节点长链,**已在 10:21 被 `02ca6aa0c` 打断**(`CfBalanceServiceImpl → MembershipService` 改为 `@Lazy @Autowired`) | 时间线 + 源码 |
|
|
|
+| 2 | **当前 HEAD 仍存在 1 个启动期 eager 真环**:`ActivationQuotaService → BasicQuotaService → MembershipService → ActivationQuotaService`(三条边全部 eager) | 离线依赖图全量扫描(含 setter/构造器注入),**无虚警** |
|
|
|
+| 3 | 这个配额域小环是 `6191b66c2` 加 `allow-circular-references: true` 的**真正原因**——不是长链没修干净 | 依赖图 + 时间线 |
|
|
|
+| 4 | `@Resource + @Lazy` **是有效的**(Spring 4.2+),远端已有的 19 处 `@Lazy` 均真实生效 | Spring 官方 Javadoc |
|
|
|
+| 5 | 全仓共 **19 处 `@Lazy`** 延迟注入边,掩盖了 **30+ 条设计环**(含长链)——启动期不报错,但运行时行为不确定 | 离线依赖图 |
|
|
|
+| 6 | 治本方案:把「配额初始化」与「等级查询」两个能力从 `MembershipService` 上剥离,配额域不再反向依赖会员域 | 源码逐行核验 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 二、V1 两处判断的更正(重要)
|
|
|
+
|
|
|
+### 2.1 更正一:`@Resource + @Lazy` **是生效的**(V1 判断错误)
|
|
|
+
|
|
|
+V1 结论:「`@Resource` 走 JSR-250 路径,不经 `ContextAnnotationAutowireCandidateResolver`,因此 `@Resource` 字段上的 `@Lazy` 被完全忽略」。
|
|
|
+
|
|
|
+**实证推翻**。Spring 官方 Javadoc(`CommonAnnotationBeanPostProcessor`,Spring 4.3 / 6.0 / 7.0-M8 三个版本一致):
|
|
|
+
|
|
|
+```
|
|
|
+protected Object buildLazyResourceProxy(LookupElement element, String requestingBeanName)
|
|
|
+Obtain a lazily resolving resource proxy for the given name and type,
|
|
|
+delegating to getResource(...) on demand once a method call comes in.
|
|
|
+Since: 4.2
|
|
|
+```
|
|
|
+
|
|
|
+即:**`CommonAnnotationBeanPostProcessor` 自 Spring 4.2 起就实现了 `@Resource` 字段的延迟代理**。本项目 Spring Boot **2.7.18**(Spring Framework 5.3.31),远高于 4.2,**`@Resource + @Lazy` 完全有效**。
|
|
|
+
|
|
|
+Spring 官方 GitHub issue 中 Spring 核心成员 sbrannen 的说明亦印证:
|
|
|
+> "if a user wishes to use `@Lazy` with dependency injection by type, the injection point should be annotated with `@Autowired` or `@Inject` instead of `@Resource`" —— 这是**风格建议**(避免 `@Resource` by-name 匹配失败时出现双层代理),**不是**"`@Lazy` 不生效"。
|
|
|
+
|
|
|
+**影响**:远端已有的 19 处 `@Resource + @Lazy` **全部真实生效**,V1 第 4.3 节「清理伪修复」的建议**作废**(若按 V1 执行,会把有效的延迟代理删掉,直接把 30+ 条设计环变成启动期崩溃)。
|
|
|
+
|
|
|
+### 2.2 更正二:用户报的那条链**已经修好了**(V1 判断错误)
|
|
|
+
|
|
|
+用户报错时间 **10:18:35**。相关提交时间线:
|
|
|
+
|
|
|
+| 时间 | 提交 | 内容 |
|
|
|
+|---|---|---|
|
|
|
+| 09:56:07 | `9b4c11953` | 恢复 CF 与 AI 链路缺失的服务注入(**引入环的元凶**) |
|
|
|
+| 10:16:47 | `c4813f72d` | `@Lazy` 打破 `MembershipService ↔ CfCommissionService` 环 |
|
|
|
+| **10:18:35** | — | **← 用户报错日志产生** |
|
|
|
+| 10:21:05 | `02ca6aa0c` | `@Lazy @Autowired` 打破 `CfBalanceServiceImpl → MembershipService`(**闭环边**) |
|
|
|
+| 10:25:03 | `6191b66c2` | 加 `allow-circular-references: true` |
|
|
|
+| 10:30:53 | `207333d7e` | chore bump |
|
|
|
+| 10:45:50 | `be62f7465` | fix(langgraph)(当前 HEAD) |
|
|
|
+
|
|
|
+用户报错链的**闭环边**是 `CfBalanceServiceImpl → MembershipService`(`CfBalanceServiceImpl` 查会员等级)。这条边在 10:21 被 `02ca6aa0c` 改为 `@Lazy @Autowired` —— **报错之后 2.5 分钟**。
|
|
|
+
|
|
|
+**验证**:离线依赖图扫描确认,在当前 HEAD 上,用户报错链上的所有关键边均为 lazy 或已断开:
|
|
|
+
|
|
|
+```
|
|
|
+FamilyMemberService → MembershipService EAGER
|
|
|
+MembershipService → PaymentService EAGER
|
|
|
+PaymentService → CommissionService EAGER
|
|
|
+PaymentService → MembershipService LAZY ← 已被 @Lazy 打断
|
|
|
+CommissionService → InviteMilestoneService EAGER
|
|
|
+InviteMilestoneService → CfPathwayEarnService EAGER
|
|
|
+CfBalanceServiceImpl → MembershipService LAZY ← 10:21 由 02ca6aa0c 打断
|
|
|
+CfPathwayEarnService → UserService LAZY
|
|
|
+```
|
|
|
+
|
|
|
+### 2.3 新发现:当前 HEAD 仍残留 **1 个启动期真环**
|
|
|
+
|
|
|
+全量离线依赖图扫描(754 个 Bean、2098 条注入边,含字段注入 / setter 注入 / 构造器注入三种形态),**仅 eager 边**构成的环:
|
|
|
+
|
|
|
+```
|
|
|
+ActivationQuotaService → BasicQuotaService → MembershipService → ActivationQuotaService
|
|
|
+```
|
|
|
+
|
|
|
+三条边逐条核验(全部 `@Resource` + eager,**无 `@Lazy`**):
|
|
|
+
|
|
|
+| # | 边 | 位置 | 用法 |
|
|
|
+|---|---|---|---|
|
|
|
+| 1 | `ActivationQuotaService → BasicQuotaService` | `ActivationQuotaService.java:16-17` | 唯一用途:第 23 行 `basicQuotaService.initUserQuotaRecords(userId)`(纯委托) |
|
|
|
+| 2 | `BasicQuotaService → MembershipService` | `BasicQuotaService.java:60-61` | 第 108/184/278 行:`getMemberLevel(userId)`、`getLevelOrder(...)` |
|
|
|
+| 3 | `MembershipService → ActivationQuotaService` | `MembershipService.java:83-84` | 唯一用途:第 793 行 `activationQuotaService.activateBasicQuota(adminUserId, order.getFamilyId())` |
|
|
|
+
|
|
|
+**这就是 `6191b66c2` 必须加 `allow-circular-references: true` 的真正原因**——它掩盖的不是长链,而是这个**配额域小环**。长链早在 10:21 就被 `02ca6aa0c` 打断了。
|
|
|
+
|
|
|
+> **重要推论**:若直接按 V1 方案"删掉 `allow-circular-references: true`",**启动会立即失败**,报 `form a cycle`(就是这个三节点环)。必须先解环,再删开关。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 三、根因分析
|
|
|
+
|
|
|
+### 3.1 为什么会产生这个环
|
|
|
+
|
|
|
+- `ActivationQuotaService` 是 2026-10-23 新增的**极薄壳类**(全文 25 行,只做一件事:把 `activateBasicQuota` 委托给 `BasicQuotaService.initUserQuotaRecords`)。
|
|
|
+- 它被放进 `service/` 根包并加了 `@Service`,于是成为 Spring Bean,被 `MembershipService` 注入。
|
|
|
+- 而 `BasicQuotaService` 反过来依赖 `MembershipService` 查会员等级 → 三方闭环。
|
|
|
+
|
|
|
+**本质**:`ActivationQuotaService` 这个壳本身**没有独立职责**,它存在的唯一目的是"给 `BasicQuotaService.initUserQuotaRecords` 换个入口名"。业务上,`MembershipService` 第 793 行的真实需求只是**支付成功后初始化配额记录**,完全可以直接调 `BasicQuotaService.initUserQuotaRecords(...)`,不必经由壳类。
|
|
|
+
|
|
|
+### 3.2 为什么不能简单用 `@Lazy` 掩盖
|
|
|
+
|
|
|
+用 `@Lazy` 打断这个三节点环是可行的(且与项目现有 19 处做法一致),但**不推荐作为最终方案**,原因:
|
|
|
+
|
|
|
+1. `@Lazy` 只是把"启动期解析"推迟到"首次调用",不消除依赖环本身。运行时首次调用若发生在另一个 Bean 的初始化过程中,仍会触发 `BeanCurrentlyInCreationException`(只是概率性、难复现)。
|
|
|
+2. 代理对象与真实对象行为不一致(事务/AOP 语义、`this` 引用等)。
|
|
|
+3. 项目已有 19 处 `@Lazy`,掩盖了 30+ 条设计环——**再加一处只会加剧技术债**。
|
|
|
+
|
|
|
+### 3.3 治本切点(三条候选,均已核验可行性)
|
|
|
+
|
|
|
+| 方案 | 动作 | 影响面 | 评价 |
|
|
|
+|---|---|---|---|
|
|
|
+| **A(推荐)** | **删除 `ActivationQuotaService` 壳类**,`MembershipService` 第 793 行直接调 `basicQuotaService.initUserQuotaRecords(adminUserId)` | 删 1 个文件 + 改 2 行 | **最彻底**:壳类本就无独立职责;`MembershipService → BasicQuotaService` 这条边由 `MembershipService → ActivationQuotaService → BasicQuotaService` 缩短为一条,**环直接消失** |
|
|
|
+| B | `BasicQuotaService` 抽 `MemberLevelQuery` 窄接口(`getMemberLevel` + `getLevelOrder`),自己用 `UserMapper`/`MembershipLevelMapper` 实现 | 新增 1 接口 + 1 实现,改 `BasicQuotaService` 3 处调用 | 也可行,但 `getMemberLevel` 逻辑较复杂(个人/家庭双身份 + 有效期 + PROVIDER 特判 + 取高者),**搬迁风险高、收益低** |
|
|
|
+| C | 给 `MembershipService.activationQuotaService` 加 `@Lazy` | 1 行 | 治标,留债 |
|
|
|
+
|
|
|
+**推荐 A**。理由:方案 B 要搬迁 `getMemberLevel` 的复杂判定逻辑(`MembershipService.java:1263-1290`,含 PROVIDER 特判、有效期校验、双身份取高),搬迁极易引入语义偏差;而方案 A 只是**删掉一个没有独立职责的空壳**,把一次委托压平,风险最低、收益最大。
|
|
|
+
|
|
|
+**A 方案的关键前提**:`BasicQuotaService` 已注入 `MembershipService`(反向依赖存在),若 `MembershipService` 再注入 `BasicQuotaService`,则形成新的两节点环 `MembershipService ↔ BasicQuotaService`!
|
|
|
+
|
|
|
+**因此 A 方案必须配合下面的 C 一起做**,否则环会从三节点缩成两节点但不会消失:
|
|
|
+
|
|
|
+- **A**:`MembershipService` 依赖 `BasicQuotaService`(删壳类)
|
|
|
+- **C**:`BasicQuotaService → MembershipService` 这条边保持不变(查等级),但需打断 `MembershipService → BasicQuotaService`
|
|
|
+
|
|
|
+等等 —— 重新推导:`BasicQuotaService → MembershipService`(eager)+ `MembershipService → BasicQuotaService`(eager)= 两节点环,**依然成环**。
|
|
|
+
|
|
|
+**所以 A 方案单独用不成立**。真正的治本必须打断 `BasicQuotaService → MembershipService` 这条边,即走 **方案 B**(把等级查询下沉出去),或者:
|
|
|
+
|
|
|
+**方案 D(最优 · 推荐)**:`BasicQuotaService` 需要的只是 `getMemberLevel(userId)` 与 `getLevelOrder(code)` **两个纯查询能力**。把这二者下沉到独立的 **`MemberLevelQueryService`**(新 `@Service`,只依赖 `UserMapper` + `MembershipLevelMapper` + `FamilyMembershipMapper`,不依赖任何 Service):
|
|
|
+
|
|
|
+- `BasicQuotaService → MemberLevelQueryService`(eager,**不再依赖 MembershipService**)→ 环断
|
|
|
+- `MembershipService → ActivationQuotaService → BasicQuotaService`(eager)→ 不再回到 MembershipService → 环断
|
|
|
+- `MembershipService.getMemberLevel()` / `getLevelOrder()` **保留原实现不动**(`MembershipService` 内部自用),可另加委托给新 Service(可选,避免双份逻辑)
|
|
|
+
|
|
|
+> **注意**:`getMemberLevel` 依赖 `getFamilyEffectiveLevel`(家庭身份),实现体 1263-1290 行,需要 `FamilyMembershipMapper`。新 Service 必须**逐行等价搬迁**,并保证 `MembershipService.getMemberLevel()` 与新实现**结果完全一致**(建议:`MembershipService.getMemberLevel()` 改为委托新 Service,保证单一事实源)。这是本方案唯一的实质风险点。
|
|
|
+
|
|
|
+**最终推荐组合**:
|
|
|
+- **D1**:`BasicQuotaService` 改依赖新建的 `MemberLevelQueryService`(承载 `getMemberLevel` + `getLevelOrder`),`MembershipService` 的对应方法改为委托它 → **单一事实源 + 环断**
|
|
|
+- **D2**:`ActivationQuotaService` 壳类保留但**不再被 `MembershipService` 注入**?→ 不行,`MembershipService` 仍需调它。
|
|
|
+
|
|
|
+再理一遍 D1 之后的依赖:
|
|
|
+- `BasicQuotaService → MemberLevelQueryService`(不再 → MembershipService)✅
|
|
|
+- `MembershipService → ActivationQuotaService → BasicQuotaService → MemberLevelQueryService` ✅ **无环**
|
|
|
+- `MembershipService → MemberLevelQueryService`(若委托)✅ 无环
|
|
|
+
|
|
|
+**D1 单项即可彻底解环**,`ActivationQuotaService` 壳类**无需删除**(改动面最小)。这是最优解。
|
|
|
+
|
|
|
+### 3.4 其他设计环:本期不动,登记为技术债
|
|
|
+
|
|
|
+离线扫描发现另外 30+ 条设计环(被 `@Lazy` 掩盖,启动期不报错),例如:
|
|
|
+
|
|
|
+```
|
|
|
+MembershipService → PaymentService → CommissionService → CfPathwayEarnService → UserService
|
|
|
+ → FamilyMemberService → CouponService → MembershipService (CouponService→MembershipService 为 lazy)
|
|
|
+```
|
|
|
+
|
|
|
+**决策**:本期**不做大重构**。理由:
|
|
|
+1. 它们当前**不导致启动失败**(`@Lazy` 生效),且业务在运行中;
|
|
|
+2. 大范围改动 30+ 条环风险极高,收益是"架构更干净"而非"修 bug";
|
|
|
+3. 用户的核心诉求是「新代码有循环依赖问题,要作为强约束在开发中避免」——**重点在"防新增",不在"清历史债"**。
|
|
|
+
|
|
|
+处理方式:**登记为技术债清单**(见第六节),并在巡检脚本中持续监控"eager 环数量",一旦新增立即拦截。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、修复方案(实施清单)
|
|
|
+
|
|
|
+### 4.1 必做:解掉配额域真环
|
|
|
+
|
|
|
+**新增**:`cfc-backend/src/main/java/com/etotem/cfc/service/MemberLevelQueryService.java`
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 会员等级纯查询服务(无 Service 依赖,仅依赖 Mapper)。
|
|
|
+ * 存在意义:打破 BasicQuotaService → MembershipService → ActivationQuotaService
|
|
|
+ * → BasicQuotaService 的启动期循环依赖。
|
|
|
+ * 逻辑来源:MembershipService.getMemberLevel / getLevelOrder(逐行等价搬迁,唯一事实源)。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class MemberLevelQueryService {
|
|
|
+ @Resource private UserMapper userMapper;
|
|
|
+ @Resource private MembershipLevelMapper levelMapper;
|
|
|
+ @Resource private FamilyMembershipMapper familyMembershipMapper;
|
|
|
+ // ... getMemberLevel(Long userId) / getLevelOrder(String levelCode)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**修改 1**:`BasicQuotaService.java`
|
|
|
+- 字段:`@Resource private MembershipService membershipService;` → `@Resource private MemberLevelQueryService memberLevelQueryService;`
|
|
|
+- 第 108、109、184、185、278 行:`membershipService.getMemberLevel(...)` / `membershipService.getLevelOrder(...)` → `memberLevelQueryService.*`
|
|
|
+
|
|
|
+**修改 2**:`MembershipService.java`
|
|
|
+- `getMemberLevel(Long)` 与 `getLevelOrder(String)` **改为委托** `memberLevelQueryService`(保证单一事实源,避免两份逻辑漂移)
|
|
|
+- 新增字段 `@Resource private MemberLevelQueryService memberLevelQueryService;`
|
|
|
+- ⚠ 注意:`MemberLevelQueryService` **不得**反向依赖 `MembershipService`(否则环转移)。`getFamilyEffectiveLevel` / `resolveEffectiveLevel` 若为 private,需一并下沉到新 Service。
|
|
|
+
|
|
|
+**修改 3**:`application.yml`
|
|
|
+```yaml
|
|
|
+spring:
|
|
|
+ main:
|
|
|
+ # 删除 allow-circular-references: true —— 循环依赖必须从设计上消除
|
|
|
+```
|
|
|
+删除后 `spring.main` 若为空则整段删掉。
|
|
|
+
|
|
|
+### 4.2 必做:把"禁止循环依赖"固化为开发强约束
|
|
|
+
|
|
|
+> 用户原话:「新代码有循环依赖问题,要作为强约束在开发中避免」
|
|
|
+
|
|
|
+#### 4.2.1 `E:\cfc\AGENTS.md` —— 新增「依赖注入纪律」小节
|
|
|
+
|
|
|
+```markdown
|
|
|
+### 依赖注入纪律(强约束 · 2026-10-25 起)
|
|
|
+
|
|
|
+1. **禁止循环依赖**:Bean 依赖图必须无环。
|
|
|
+ - **NEVER** 在 `application.yml` 中设置 `spring.main.allow-circular-references: true`(视为违规,评审打回)。
|
|
|
+ - 出现 A→B→A 时,必须**从设计上打破**:下沉逻辑 / 抽窄接口 / 事件解耦 / 删除无独立职责的空壳 Service。
|
|
|
+ - **禁止**用 `@Lazy` 掩盖新产生的环(`@Lazy` 是治标,且会掩盖真实的架构问题)。
|
|
|
+2. **新增 Service 注入前必须查环**:注入前确认目标 Service 的依赖链不会回绕到当前类。
|
|
|
+ - 自查:`python tools/check_circular_deps.py`(见 4.2.3)输出中不得出现新增 eager 环。
|
|
|
+3. **优先抽窄接口,而不是加 `@Lazy`**:当 A 只用到 B 的一两个方法时,把这几个方法抽成窄接口,让 A 依赖接口。
|
|
|
+4. **注意**:`@Resource + @Lazy` 在 Spring 4.2+ 是**生效**的(`CommonAnnotationBeanPostProcessor.buildLazyResourceProxy`,Since: 4.2),
|
|
|
+ 但项目统一用 `@Resource`(by-name),若字段名与 Bean 名不一致会产生双层代理 —— 延迟注入时字段名必须与 Bean 名严格一致。
|
|
|
+5. **提交门禁**:后端改动后必须 `mvn clean compile` 通过,且启动日志中**不含** `form a cycle`。
|
|
|
+```
|
|
|
+
|
|
|
+#### 4.2.2 派发任务强制话术模板(所有后续 opencode 开发会话必带)
|
|
|
+
|
|
|
+```
|
|
|
+⛔ 强约束(不可违反 · 违反即打回):
|
|
|
+- 禁止引入循环依赖;禁止设置 spring.main.allow-circular-references=true
|
|
|
+- 禁止用 @Lazy 掩盖新环;必须从设计上打破(抽窄接口 / 下沉 / 事件解耦)
|
|
|
+- 新增 Service 注入前必须确认依赖图无环(运行 tools/check_circular_deps.py 自查)
|
|
|
+- @Lazy 仅在字段名与 Bean 名严格一致时使用
|
|
|
+- 完成后必须:mvn clean compile 通过 + mvn spring-boot:run 启动日志无 'form a cycle'(贴出日志片段)
|
|
|
+```
|
|
|
+
|
|
|
+#### 4.2.3 新增静态检查脚本 `tools/check_circular_deps.py`(仓库内)
|
|
|
+
|
|
|
+**这是把强约束从"文档"变成"可执行门禁"的关键**。基于本轮离线扫描器(已在 `C:\temp\cfc_cycle_scan2.py` 验证可行)落地为仓库脚本:
|
|
|
+
|
|
|
+- 扫描 `cfc-backend/src/main/java` 全部 Java 文件,解析 `@Resource` / `@Autowired` 字段注入、setter 注入、构造器注入
|
|
|
+- 解析 Bean 名(`@Service`/`@Component` 等显式 value 或类名首字母小写),`@Resource` 按 by-name 解析,其余按 by-type + 接口实现类回退
|
|
|
+- 构建依赖图,区分 **eager 边**(无 `@Lazy`)与 **lazy 边**
|
|
|
+- 输出:① eager 环清单(**必须为空**,否则退出码 1)② lazy 掩盖的设计环清单(技术债台账)③ `@Lazy` 使用清单
|
|
|
+
|
|
|
+**接入点**:
|
|
|
+- `.githooks/pre-commit`:暂存区含 `cfc-backend/` 变更时自动运行,有 eager 环则阻断提交
|
|
|
+- `tools/xaf_agent_hub/check_agents.py` 巡检:作为「实例健康总览」的附加检查项
|
|
|
+
|
|
|
+#### 4.2.4 `cfc-backend/AGENTS.md` —— CONVENTIONS / ANTI-PATTERNS 补条目
|
|
|
+
|
|
|
+在 `ANTI-PATTERNS (本项目禁止)` 段追加:
|
|
|
+```markdown
|
|
|
+- **NEVER** 设置 `spring.main.allow-circular-references: true` 掩盖循环依赖
|
|
|
+- **NEVER** 用 `@Lazy` 掩盖新引入的循环依赖(必须从设计上打破)
|
|
|
+```
|
|
|
+
|
|
|
+### 4.3 不做(明确排除,避免范围蔓延)
|
|
|
+
|
|
|
+- ❌ 不删除 `ActivationQuotaService`(保留,避免动 30+ 处引用面;`BasicQuotaService.java:312` 注释已说明其为委托壳)
|
|
|
+- ❌ 不动 30+ 条被 `@Lazy` 掩盖的设计环(登记技术债,本期不重构)
|
|
|
+- ❌ 不改 `02ca6aa0c` 的 `@Lazy @Autowired`(已生效,虽偏离 `@Resource` 规范但改动风险大于收益;记入技术债)
|
|
|
+- ❌ 不改 `9b4c11953` 引入的其他注入
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、实施顺序(派发给 opencode 开发会话)
|
|
|
+
|
|
|
+| # | 步骤 | 文件 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| 1 | 同步远端 | — | `git fetch origin cfclub`,确认基线 `be62f7465` |
|
|
|
+| 2 | 新建等级查询服务 | `MemberLevelQueryService.java`(新) | 逐行搬迁 `getMemberLevel` + `getLevelOrder`,**只依赖 Mapper**,不得依赖任何 Service |
|
|
|
+| 3 | 改造配额服务 | `BasicQuotaService.java` | 依赖改为 `MemberLevelQueryService`,改 5 处调用 |
|
|
|
+| 4 | 消除双份逻辑 | `MembershipService.java` | `getMemberLevel` / `getLevelOrder` 改为委托新 Service;私有辅助方法一并下沉 |
|
|
|
+| 5 | 移除开关 | `application.yml` | 删 `allow-circular-references: true` |
|
|
|
+| 6 | 新增检查脚本 | `tools/check_circular_deps.py`(新) | 依赖图构建 + eager 环检测 + 技术债台账 |
|
|
|
+| 7 | 接入门禁 | `.githooks/pre-commit` | `cfc-backend/` 变更时自动跑检查脚本,有 eager 环阻断提交 |
|
|
|
+| 8 | 固化规范 | `AGENTS.md`、`cfc-backend/AGENTS.md` | 写入 4.2.1 / 4.2.4 的纪律条目 |
|
|
|
+| 9 | 验证 | — | ① `mvn clean compile` 退出码 0;② `mvn spring-boot:run` 启动日志**无** `form a cycle`;③ `python tools/check_circular_deps.py` 的 **eager 环 = 0**;④ 贴出启动日志与脚本输出 |
|
|
|
+
|
|
|
+**验收标准(硬 · 逐条可验证)**:
|
|
|
+
|
|
|
+1. 启动日志中**不存在** `form a cycle`。
|
|
|
+2. `application.yml` 中**不存在** `allow-circular-references`。
|
|
|
+3. `tools/check_circular_deps.py` 输出的 **eager 环数量 = 0**。
|
|
|
+4. `mvn clean compile` 退出码 **0**。
|
|
|
+5. 现有单测不回归(至少 `CfPathwayEarnServiceTest`、`CfGrowthPathwayEarnServiceTest`、`BasicQuotaService` 相关测试)。
|
|
|
+6. `MemberLevelQueryService.getMemberLevel/getLevelOrder` 与搬迁前 `MembershipService` 版本**逐分支等价**(PROVIDER→FREE、FAMILY 过期→FREE、个人/家庭取高、未知等级回退 1)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、技术债台账(登记,不在本期修)
|
|
|
+
|
|
|
+| # | 项 | 位置 | 风险 | 建议 |
|
|
|
+|---|---|---|---|---|
|
|
|
+| D1 | 30+ 条被 `@Lazy` 掩盖的设计环 | 见 `tools/check_circular_deps.py` 输出 | 中(运行期首次调用可能触发 `BeanCurrentlyInCreationException`) | 按业务域分批重构,优先 `MembershipService ↔ PaymentService` |
|
|
|
+| D2 | `CfBalanceServiceImpl.membershipService` 用 `@Autowired @Lazy`,偏离项目 `@Resource` 规范 | `CfBalanceServiceImpl.java:96-99` | 低(已生效) | 下次触碰该类时顺手改回 `@Resource @Lazy` |
|
|
|
+| D3 | `ActivationQuotaService` 是无独立职责的空壳(仅委托) | `ActivationQuotaService.java` | 低 | 长期看应下沉为 `BasicQuotaService` 的直接调用或事件监听 |
|
|
|
+| D4 | `9b4c11953` 批量恢复注入,未做依赖图审查 | 多个 Service | 中 | 已由本方案门禁覆盖后续 |
|
|
|
+| D5 | `MembershipService` 残留 3 份等级查询等价副本(`resolveEffectiveLevel` / `getFamilyEffectiveLevel` / `getActiveFamilyMembership`,生产无调用方) | `MembershipService.java` | 中(单边修改漂移) | 标记 `@Deprecated` 或收敛到 `MemberLevelQueryService` |
|
|
|
+| D6 | 循环依赖约束仅本地生效(`core.hooksPath` 不入库 + 无 CI workflow 调用检查器) | `.githooks/`、`.github/`(缺失) | 中(`--no-verify` 可绕过) | 补 CI 闸门:PR 上跑 `tools/check_circular_deps.py` |
|
|
|
+| D7 | `pom.xml:28` `<skipTests>true</skipTests>` 使 `mvn test` 静默跳过 | `cfc-backend/pom.xml:28` | 中(既有失败长期未暴露) | 设 `skipTests=false` 或在 CI 显式 `-DskipTests=false` |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 七、风险与回滚
|
|
|
+
|
|
|
+| 风险 | 概率 | 应对 |
|
|
|
+|---|---|---|
|
|
|
+| `getMemberLevel` 搬迁出语义偏差(PROVIDER 特判 / FAMILY 有效期 / 双身份取高 / 未知回退) | **中高** | 逐行搬迁不改逻辑;`MembershipService` 侧改为委托保证单一事实源;验收标准第 6 条逐分支核对;`getFamilyEffectiveLevel`/`resolveEffectiveLevel` 一并下沉,不留悬空调用 |
|
|
|
+| `BasicQuotaService` 的 `getMemberLevel` 调用点漏改 | 中 | 编译期可发现(类型不同 → 编译失败),`mvn clean compile` 拦截 |
|
|
|
+| 删除开关后暴露其它未发现的 eager 环 | 低(离线全量扫描已覆盖字段/setter/构造器三种形态) | 扫描器输出为 ground truth;若启动仍报错,以报错链为准回到扫描器定位 |
|
|
|
+| 远端 `cfclub` 继续前进导致基线漂移 | 中 | 实施前 `git fetch` 并 rebase 到最新 HEAD |
|
|
|
+| 30+ 设计环中某条在运行期暴露 | 中 | 巡检脚本持续输出技术债台账,按优先级逐条处理 |
|
|
|
+
|
|
|
+**回滚**:所有改动拆为独立提交,可 `git revert` 单个提交恢复。**禁止** `git reset --hard`(见 `AGENTS.md` Git 纪律)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 八、待确认(已定稿 · 2026-10-25 用户拍板)
|
|
|
+
|
|
|
+| # | 事项 | 选项 | 定稿 |
|
|
|
+|---|---|---|---|
|
|
|
+| **D1** | 配额域真环的治本方式 | (a) 新建 `MemberLevelQueryService` 承载等级查询 / (b) `MembershipService.activationQuotaService` 加 `@Lazy` 治标 / (c) 删除 `ActivationQuotaService` 空壳 + `BasicQuotaService` 抽等级接口 | **(a) 已采纳**。理由:唯一能同时做到"断环 + 单一事实源 + 不搬迁复杂判定逻辑以外的东西"。(b) 留债;(c) 改动面最大 |
|
|
|
+| **D2** | 30+ 条设计环是否本期一并重构 | (a) 本期只登记 + 门禁防新增 / (b) 本期全部重构 | **(a) 已采纳**。风险收益比悬殊,且用户诉求重心在"防新增" |
|
|
|
+| **D3** | `allow-circular-references` 是否全局禁止 | (a) 全局禁止并进 `AGENTS.md` + pre-commit 门禁 / (b) 仅 cfc-backend 模块 | **(a) 已采纳**。符合"强约束"要求 |
|
|
|
+| **D4** | `MemberLevelQueryService` 放 `service/` 根包还是 `service/impl/` | (a) `service/` 根包(与 `BasicQuotaService` 同级) / (b) `service/impl/` | **(a) 已采纳**。项目现有 Service 绝大多数在根包 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 九、实施与核验结果(2026-10-25,已完成)
|
|
|
+
|
|
|
+### 9.1 实施:分支 `fix/circular-deps`,基于 `origin/cfclub@94b5a3d3f`,5 个提交
|
|
|
+
|
|
|
+| commit | 内容 |
|
|
|
+|---|---|
|
|
|
+| `33cbc73aa` | `fix(backend)` 新增 `MemberLevelQueryService`(`getMemberLevel`/`getLevelOrder` 自 `MembershipService:1263-1290` 逐行等价搬迁,仅依赖 3 个 Mapper);`MembershipService` 改委托;`BasicQuotaService` 5 处调用改注入 |
|
|
|
+| `cfd9e7c57` | `fix` 移除 `spring.main.allow-circular-references`(`spring.main` 段空,整段删除) |
|
|
|
+| `ab0f1177f` | `chore` 新增 `tools/check_circular_deps.py`(Tarjan SCC)+ `.gitignore` 例外规则 |
|
|
|
+| `01d4a73d9` | `chore` `.githooks/pre-commit` 接入循环依赖阻断 + `docs/scripts/install_git_hooks.sh` 安装脚本文案 |
|
|
|
+| `c13b591d5` | `docs` 根 `AGENTS.md` 与 `cfc-backend/AGENTS.md` 写入「依赖注入纪律」(4.2.1 禁止循环依赖 / 4.2.4 `@Lazy` 纪律与静态检查),各 +31 行 |
|
|
|
+
|
|
|
+**未新增任何 `@Lazy`**:既有 21 处延迟注入点原样保留、未清理。
|
|
|
+
|
|
|
+### 9.2 六条硬验收(全部达标 · 实跑非声称)
|
|
|
+
|
|
|
+| # | 标准 | 结果 | 关键证据 |
|
|
|
+|---|---|---|---|
|
|
|
+| 1 | `mvn clean compile` 退出码 0 | ✅ | `MVN_EXIT=0`(Maven 3.8.7 + JDK8,1949 源文件,BUILD SUCCESS) |
|
|
|
+| 2 | 启动日志无 `form a cycle` | ✅ | 实测 `mvn spring-boot:run` → `Started CfcApplication in 13.672 seconds` / Tomcat 9082;1182 行日志中 `form a cycle`=0、`circular reference`=0、`BeanCurrentlyInCreationException`=0 |
|
|
|
+| 3 | `application.yml` 无该开关 | ✅ | `grep_exit=1`(零匹配);全仓配置扫描(排除 `.worktrees/`)零匹配 |
|
|
|
+| 4 | `check_circular_deps.py` eager 环 = 0 | ✅ | `✅ 未发现 eager 循环依赖` / `结论: ✅ eager 环 = 0(通过)` / exit 0;**修复前同脚本 exit 1 抓到真环,修复后归零**(自证有效) |
|
|
|
+| 5 | 现有单测不回归 | ✅ | 点名 3 类全绿:`CfPathwayEarnServiceTest` 32/32、`CfGrowthPathwayEarnServiceTest` 16/16、配额域 20/20;另 `MemberLevelQueryServiceTest` 9/9、`MembershipServiceTest` 17/17(合计 86 用例) |
|
|
|
+| 6 | 等级查询搬迁逐分支等价 | ✅ | 5 个方法体 `diff` 比对:3 个 IDENTICAL、2 个仅 `public→private`;四分支(PROVIDER→FREE / FAMILY 过期→FREE / 个人家庭取高 / 未知回退 1)源码行 + 9 个单测双背书 |
|
|
|
+
|
|
|
+**门禁有效性负向测试**:临时注入 eager 环 `TmpCycleProbeA↔B` → 检查器 exit 1 → pre-commit 成功阻断提交;探测文件已删除并复位(重新检查 exit 0)。钩子触发规则实测:`cfc-backend/**` 与 `tools/check_circular_deps.py` 触发,`cfc-web/**` 正确跳过。
|
|
|
+
|
|
|
+### 9.3 独立核验结论(第二个 opencode 会话复核):**approve-with-notes**
|
|
|
+
|
|
|
+核验方逐条独立实跑,10 项检查无一项依赖修复方自述。核心结论:**循环依赖真环已被从设计上打破、`allow-circular-references` 已移除且应用可无环启动。** 6 条硬验收全部达标。
|
|
|
+
|
|
|
+### 9.4 核验遗留 notes(转入技术债台账 D5–D7)
|
|
|
+
|
|
|
+1. **`MembershipService` 残留 3 份等级查询等价副本**(`resolveEffectiveLevel` / `getFamilyEffectiveLevel` / `getActiveFamilyMembership`,生产已无调用方,仅测试引用)→ 单边修改存在漂移风险,建议标记 `@Deprecated` 或收敛。
|
|
|
+2. **约束目前仅本地生效**:`core.hooksPath` 是本地仓库配置(不入库)+ 仓库 `.github/` 下无任何工作流调用 `check_circular_deps.py` → 建议补一条 CI 闸门,防 `--no-verify` 绕过。
|
|
|
+3. **`pom.xml:28` 的 `<skipTests>true</skipTests>` 使 `mvn test` 静默不执行**,是既有失败 `MembershipPaySwitchTest.createOrderWithPayType_virtualEnabled_configMissing_returnsNull`(自 `75627206a` 起即失败)长期未被 CI 暴露的根因。本次核验须显式 `-DskipTests=false` 才可见。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 附录 A:核验方法说明
|
|
|
+
|
|
|
+**依赖图扫描**(本轮实证手段,可复现):
|
|
|
+1. `git archive origin/cfclub cfc-backend/src/main/java | tar -x` 导出远端源码到临时目录(不改动仓库)
|
|
|
+2. 脚本解析 1946 个 Java 文件 → 754 个 Bean → 2098 条注入边(字段 2079 eager / 19 lazy)
|
|
|
+3. 环检测:DFS 枚举全部基本环,分别在「仅 eager 边」与「全部边」两个图上运行
|
|
|
+4. **盲区已补**:第二轮扫描额外解析 setter 注入与构造器注入,结论不变(仍只有 1 个 eager 环)
|
|
|
+
|
|
|
+**局限说明**:脚本基于正则解析,理论上可能漏掉非常规注入(如 `ApplicationContext` 动态查找、`@Bean` 方法内 `new`、SpEL 注入)。因此**启动验证(`mvn spring-boot:run` 无 cycle)仍是最终门禁**,静态扫描作为前置快速拦截。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+*附:本方案基于 `origin/cfclub` HEAD `be62f7465` 的源码逐一核验 + 全量离线依赖图扫描。V2 已更正 V1 中两处经实证证伪的判断。第九节记录实施与独立核验结果(2026-10-25)。*
|