From 4ae4f54c101d9fff0b0677553d4080225c05a069 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Tue, 10 Nov 2026 10:52:35 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20GitHub=20Issue=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=B5=81=E7=A8=8B=E4=B8=8E=20Java=20?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/conventions/workflow/github-issue.md:8.x/17.x 双版本线 issue 处理流程 - docs/conventions/java:命名、测试设计、领域建模、静态方法四篇规范 - CLAUDE.md 补充开发规范章节;.gitignore 忽略 .worktrees/ --- .gitignore | 3 + CLAUDE.md | 89 +++++++++ docs/conventions/index.md | 7 +- .../java/domain-modeling-convention.md | 158 ++++++++++++++++ docs/conventions/java/naming-convention.md | 120 ++++++++++++ .../java/static-method-convention.md | 117 ++++++++++++ .../java/test-design-convention.md | 133 +++++++++++++ docs/conventions/workflow/github-issue.md | 178 ++++++++++++++++++ 8 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 docs/conventions/java/domain-modeling-convention.md create mode 100644 docs/conventions/java/naming-convention.md create mode 100644 docs/conventions/java/static-method-convention.md create mode 100644 docs/conventions/java/test-design-convention.md create mode 100644 docs/conventions/workflow/github-issue.md diff --git a/.gitignore b/.gitignore index eeabb3e5..7270c9ac 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ build/ ### flatten-maven-plugin ### .flattened-pom.xml test.db* + +### worktrees ### +.worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index a1553681..aee4e850 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,95 @@ codingapi.framework.handler-thread-pool-size=20 # 事件异步线程池大小 见根 `pom.xml` 的 `` 区块。主要版本:Groovy 4.0.24、JSqlParser 5.0、Fastjson 2.0.53、JJWT 0.12.6、H2 2.3.232。 +## 开发规范 + +### GitHub Issue 标准处理流程 + +本仓库同时维护 **8.x(JDK 8 / Spring Boot 2.7.x / `javax.*`)与 17.x(JDK 17 / Spring Boot 3.x / `jakarta.*`)两条版本线**,各自独立交付,没有 `dev` 分支。 + +处理任何来自 GitHub 的 issue 时,必须遵循 [docs/conventions/workflow/github-issue.md](./docs/conventions/workflow/github-issue.md): + +1. **定位阶段**可只读代码分析,不必创建 worktree; +2. **先确认影响版本线**(8.x / 17.x / 两者),定位完成后先回复 issue,说明根因、影响版本与处理方案; +3. **实际改代码必须使用 git worktree + 特性分支**,基线按版本线选择:17.x 用 `17.3.x`,8.x 用 `8.2.x`;不得在主工作区直接修改; +4. 修复需测试先行,并使用对应版本线的 JDK 完成本地编译/测试验证;双版本 issue 必须两条线分别验证; +5. 完成后**只推送该 issue 的特性分支到 GitHub**:提交内容、推送与 issue 关联均以该特性分支为准,**不合并版本分支**;是否合并由人工决定,任务完成时仅提示分支与 PR 链接(PR 目标为 `17.3.x` 或 `8.2.x`); +6. 提交信息使用 `Closes #` 关联 issue;仅当进入默认分支 `main` 时自动关闭,未自动关闭时需用户确认后手动关闭(8.x 合并到 `8.2.x` 不会自动关闭); +7. **特性分支推送到 GitHub 后再回复 issue**,说明修复内容、验证结果与推送分支/提交;未推送前不得发布完成回复(定位回复不受此限)。 + +### 基本规范 + +- **与用户沟通及编写文档时,所有内容必须使用中文表述** +- **在每次修改代码以后,要执行本地化的编译验证确保代码没有错误** +- Java 代码遵循 Spring Boot 规范 +- 17.x 使用 `jakarta.*` 与 JDK 17,8.x 使用 `javax.*` 与 JDK 8,两条线的修复不得直接 cherry-pick,必须做命名空间与代际适配 +- 编码规范见 `docs/conventions`:方法命名 [java/naming-convention](./docs/conventions/java/naming-convention.md)、测试设计 [java/test-design-convention](./docs/conventions/java/test-design-convention.md)、领域建模 [java/domain-modeling-convention](./docs/conventions/java/domain-modeling-convention.md)、静态方法 [java/static-method-convention](./docs/conventions/java/static-method-convention.md) + +### 测试规范 + +本项目后端采用 JUnit 5 进行单元测试。两条版本线测试命令一致,但需切换到对应 JDK(17.x 用 JDK 17,8.x 用 JDK 8)。 + +**测试文件位置:** + +- 测试文件放在各模块 `src/test/java/` 目录下,包路径与源码保持一致 +- 测试文件命名:`*Test.java` + +**测试编写流程:** + +1. **Red(红灯)**:先编写测试用例,明确预期行为 +2. **Green(绿灯)**:编写最小代码使测试通过 +3. **Refactor(重构)**:优化代码结构,运行全部测试确认不回归 + +**测试用例设计要求:** + +- 每个测试用例需明确:测试目标、前置条件、执行步骤、期望断言 +- 优先测试核心业务逻辑(DTO、事件系统、动态查询、SQL 拦截、脚本引擎等) +- 测试应该有确定性结果,避免依赖外部状态 + +**测试命令:** + +```bash +# 运行所有测试(与 CI 一致) +./mvnw clean test -P travis + +# 运行特定模块的测试 +./mvnw test -pl springboot-starter + +# 运行特定的测试类 +./mvnw test -pl springboot-starter -Dtest=ResponseTest + +# 前端测试(在 frontend/ 目录下执行) +pnpm test:pc +pnpm test:mobile +``` + +**示例(基于 springboot-starter 的响应封装测试,17.x 版本线):** + +```java +package com.codingapi.springboot.framework.dto; + +import com.codingapi.springboot.framework.dto.response.SingleResponse; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SingleResponseTest { + + @Test + void should_wrap_single_data() { + // given + String data = "hello"; + + // when + SingleResponse response = SingleResponse.of(data); + + // then + assertTrue(response.isSuccess()); + assertEquals(data, response.getData()); + } +} +``` ## PKR 知识查阅(编码前必须) diff --git a/docs/conventions/index.md b/docs/conventions/index.md index df2f11ff..f0361c14 100644 --- a/docs/conventions/index.md +++ b/docs/conventions/index.md @@ -6,12 +6,17 @@ | 名称 | 模块 | 描述 | 范围 | 来源 | |------|------|------|------|------| +| [java/domain-modeling-convention](./java/domain-modeling-convention.md) | java | 领域建模规范:同一性判断、聚合间深复制、可配置单一出口与只读集合 | 后端 | 项目自有 | +| [java/naming-convention](./java/naming-convention.md) | java | 方法命名规范:按返回数量选择 get/find,必须存在的场景用 require | 后端 | 项目自有 | +| [java/static-method-convention](./java/static-method-convention.md) | java | 静态方法规范:领域判断回归实例方法,明确允许的 static 场景 | 后端 | 项目自有 | +| [java/test-design-convention](./java/test-design-convention.md) | java | 测试设计规范:模型不为测试让路,区分模型能力与测试脚手架 | 后端 | 项目自有 | | [springboot-starter/ddd-layered-architecture](./springboot-starter/ddd-layered-architecture.md) | springboot-starter | DDD 分层架构规范,定义 interface/app/domain/infra 四层的依赖规则和职责划分 | 后端 | 项目自有 | | [springboot-starter/event-driven-convention](./springboot-starter/event-driven-convention.md) | springboot-starter | 事件驱动开发规范,定义 IEvent/IHandler 的使用约定和事件处理模式 | 后端 | 项目自有 | | [springboot-starter/exception-handling-convention](./springboot-starter/exception-handling-convention.md) | springboot-starter | 全局异常处理规范,定义异常抛出和捕获的标准模式 | 后端 | 项目自有 | | [springboot-starter/response-convention](./springboot-starter/response-convention.md) | springboot-starter | 统一响应格式规范,定义 Controller 层返回值的标准格式 | 后端 | 项目自有 | | [springboot-starter-data-fast/dynamic-query-convention](./springboot-starter-data-fast/dynamic-query-convention.md) | springboot-starter-data-fast | 动态查询规范,定义 PageRequest + RequestFilter + FastRepository 的使用模式 | 后端 | 项目自有 | +| [workflow/github-issue](./workflow/github-issue.md) | workflow | GitHub Issue 标准处理流程 — 定位 → 回复 → worktree 修复 → 推送特性分支 → 推送后回复(8.x/17.x 双线) | 全栈 | 项目自有 | --- -**统计**: 共 5 篇 — 已实现 5 / 计划中 0 / 已废弃 0 +**统计**: 共 10 篇 — 已实现 10 / 计划中 0 / 已废弃 0 diff --git a/docs/conventions/java/domain-modeling-convention.md b/docs/conventions/java/domain-modeling-convention.md new file mode 100644 index 00000000..9ee26775 --- /dev/null +++ b/docs/conventions/java/domain-modeling-convention.md @@ -0,0 +1,158 @@ +--- +name: java/domain-modeling-convention +module: java +description: 领域建模规范:同一性判断、聚合间深复制、可配置单一出口与只读集合 +status: 已实现 +scope: 后端 +source: 项目自有 +--- + +## 解决什么问题 + +领域逻辑写成「外部函数 + 到处开的口子」后,模型只剩数据容器: + +- 同一性判断写成静态函数,散落在各个 Service 里,口径逐渐分叉; +- 覆盖 `equals` 用 `id` 比较,但实体 `id` 会从 0 变为非 0,放进 `HashSet` / `HashMap` 后取不出来; +- 聚合之间直接共享可变引用,改一个项目的名单,方案模板跟着变,下一次立项少人; +- 「还能不能改」各处各写一遍 `state != END`,理解不一致; +- 集合直接从 getter 外泄,所有「不许改」的校验被一句 `getXxx().add(...)` 绕过去。 + +## 如何使用 + +### 同一性判断属于对象 + +统一使用实例方法 `isSameAs`,规则固定为:**同引用,或同类且 `id` 非 0 并相等**。 + +- 不覆盖 `equals`:实体 `id` 会从 0 变为非 0,破坏哈希容器不变量; +- 批量版本 `isSameAsAny(Collection)` 也属于对象,读作「我是否在这组里」; +- 禁止写成 `containsXxx(集合, 我)` 这类外部静态函数。 + +### 重载歧义换名字,不靠强转 + +当一个类型同时实现两个都声明了 `isSameAs` 的接口时(如部门既是组织、又是参与方),「拿部门比部门」会让编译器无法选择。此时**换名字**而不是强转,例如组织侧叫 `isSameOrganizationAs`。「判断同一性的是这个对象」这条原则不变,只是名字要让编译器分得清。 + +### 聚合之间复制,不共享可变引用 + +跨聚合复制时**深复制行与值**,定义类对象(指标定义、类型定义等)仍可共用。 + +判断口诀:**A 的编辑动作会不会写进 B 的资料?会,就说明该复制。** + +### 「能不能改」只判定一次 + +聚合根暴露唯一的 `isConfigurable()`,所有调整动作先过 `requireConfigurable(action)`,提示语带上动作名。禁止各处自写 `state != END`,各自理解什么叫「还能改」。 + +### 集合出口返回只读快照 + +`getXxx()` 返回 `List.copyOf(...)`,要改只能走领域动作。只包一层外层列表不够,**内层对象自己的集合也要收口**。 + +## 使用实例 + +### ✅ 正确示例 + +```java +public class Project { + private long id; + + public boolean isSameAs(Project other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + return id != 0 && id == other.id; + } + + public boolean isSameAsAny(Collection others) { + return others.stream().anyMatch(this::isSameAs); + } +} +``` + +```java +// 深复制行与值,定义对象共用 +public Project copyFrom(PlanInstance plan) { + Project project = new Project(); + project.items = plan.getItems().stream() + .map(PlanItem::copy) + .collect(Collectors.toList()); + project.weight = plan.getWeight(); + project.indicator = plan.getIndicator(); // 定义对象共用 + return project; +} +``` + +```java +// 唯一出口 + 带动作名的提示 +public boolean isConfigurable() { + return state != State.END; +} + +private void requireConfigurable(String action) { + if (!isConfigurable()) { + throw new BusinessException("当前状态不允许" + action); + } +} + +public void adjustWeight(int weight) { + requireConfigurable("调整权重"); + this.weight = weight; +} +``` + +```java +// 只读快照 +public List getItems() { + return List.copyOf(items); +} +``` + +### ❌ 错误示例 + +```java +// ❌ 覆盖 equals 用可变 id,破坏哈希容器 +@Override +public boolean equals(Object o) { + return o instanceof Project && ((Project) o).id == this.id; +} +``` + +```java +// ❌ 外部静态函数判断同一性,口径分散 +public static boolean isSamePlan(Project a, Project b) { ... } +public static boolean containsProject(List list, Project p) { ... } +``` + +```java +// ❌ 共享可变引用:改项目会写回模板 +public Project from(PlanInstance plan) { + Project project = new Project(); + project.items = plan.getItems(); // 同一个 List + return project; +} +``` + +```java +// ❌ 「能不能改」各处自行理解 +public void adjustWeight(int weight) { + if (state == State.END) { // 别处可能还允许 PAUSED + throw new BusinessException("已结束"); + } + this.weight = weight; +} +``` + +```java +// ❌ 集合直接外泄 +public List getItems() { + return items; // 调用方可直接 add / remove +} +``` + +## 自查清单 + +- [ ] 同一性判断是否为对象上的 `isSameAs` / `isSameAsAny` +- [ ] 是否没有覆盖 `equals`(实体/聚合根) +- [ ] 跨聚合复制是否深复制了行与值 +- [ ] 「能不能改」是否只经 `isConfigurable()` + `requireConfigurable(action)` +- [ ] 集合出口是否为只读快照,内层集合是否也收口 diff --git a/docs/conventions/java/naming-convention.md b/docs/conventions/java/naming-convention.md new file mode 100644 index 00000000..fa5ccdad --- /dev/null +++ b/docs/conventions/java/naming-convention.md @@ -0,0 +1,120 @@ +--- +name: java/naming-convention +module: java +description: 方法命名规范:按返回数量选择 get/find,必须存在的场景用 require +status: 已实现 +scope: 后端 +source: 项目自有 +--- + +## 解决什么问题 + +方法名不表达「返回几条」和「是否保证存在」,调用方只能靠猜: + +- 名字叫 `getXxx` 却可能返回 `null`,调用方默认非空、不做判空,最终在业务路径上 NPE,把本该给出的业务提示(如「访问码不存在或已失效」)顶掉; +- 同一个语义在不同类里分别叫 `get` / `find` / `query` / `load`,阅读和检索成本高; +- 「必须存在」的校验散落在调用方,提示语各处一份、口径不一; +- 为了「保证不为空」,在 `get` 里偷偷抛业务异常,名字与行为不符,调用方无从预判。 + +## 如何使用 + +### 核心规则 + +1. **按返回数量选动词,不看是否可能为空**: + - 返回一条数据 → `getXxx`(返回对象或 `Optional` 都叫 `get`); + - 返回多条数据 → `findXxx`(返回 `List`、`Set` 等集合)。 +2. **「必须存在,否则报错」→ 用语义动词表达前提**:使用 `requireXxx`(或 `loadXxxOrThrow` 这类明确的词),把前提写进方法名,不要靠 `get` 里偷偷抛异常。 +3. **`get` 是纯查询**:不校验、不抛业务异常。「找不到」由返回值表达——对象可为 `null`,或返回 `Optional`。 +4. **「必须有」的场合由调用方决定处理方式**:`orElseThrow`,或包一层 `requireXxx` 把提示语集中在一处。 +5. **命名描述契约,不描述实现**:`get` / `find` 只用于查询语义;有副作用或写入行为的操作不得使用 `get` / `find` 前缀,改用能表达动作的动词(`create`、`save`、`remove` 等)。 + +### 命名对照 + +| 语义 | 命名 | 返回约定 | 不存在时 | +|------|------|----------|----------| +| 取单条(可能不存在) | `getXxx` | 对象或 `Optional` | 返回 `null` / `Optional.empty()` | +| 取多条 | `findXxx` | `List` / `Set` | 返回空集合,不返回 `null` | +| 取单条(必须存在) | `requireXxx` | 对象 | 抛业务异常 | +| 判断存在 | `existsXxx` | `boolean` | — | + +## 使用实例 + +### ✅ 正确示例 + +```java +public class AccountService { + + // 单条:可能为 null + public Account getAccount(long id) { + return accountRepository.findById(id); + } + + // 单条:用 Optional 表达可能不存在 + public Optional getAccountByNo(String accountNo) { + return accountRepository.findByNo(accountNo); + } + + // 多条:find + public List findAccountsByOwner(long ownerId) { + return accountRepository.findAllByOwner(ownerId); + } + + // 必须存在:前提写在名字里,提示语集中在一处 + public Account requireAccount(long id) { + Account account = getAccount(id); + if (account == null) { + throw new BusinessException("账户不存在:" + id); + } + return account; + } +} +``` + +```java +// 调用方:需要「必须存在」时显式处理 +Account account = accountService.requireAccount(accountId); +// 或 +Account account = accountService.getAccountByNo(no) + .orElseThrow(() -> new BusinessException("账户不存在:" + no)); +``` + +### ❌ 错误示例 + +```java +// ❌ get 返回 null,名字却暗示「已拿到有效对象」 +private AccessCode getActiveCode(String scene) { + AccessCode code = accessCodeRepository.findByScene(scene); + if (code == null || code.isUsed()) { + return null; + } + return code; +} + +public void submitCode(String scene) { + AccessCode code = getActiveCode(scene); + code.markUsed(); // NPE:把「访问码不存在或已失效」的业务提示顶掉了 +} +``` + +```java +// ❌ get 里做校验并抛业务异常:名字与行为不符 +public Account getAccount(long id) { + Account account = accountRepository.findById(id); + if (account == null) { + throw new BusinessException("账户不存在"); // 应叫 requireAccount + } + return account; +} +``` + +```java +// ❌ 返回多条却用 get +public List getAccounts(long ownerId) { ... } // 应为 findAccounts +``` + +## 自查清单 + +- [ ] 返回单条的方法是否都叫 `getXxx`(`Optional` 也算 `get`) +- [ ] 返回多条的方法是否都叫 `findXxx` +- [ ] `get` / `find` 内是否没有任何业务校验与业务异常 +- [ ] 「必须存在」的场景是否用 `requireXxx` 或调用方 `orElseThrow` 显式表达 diff --git a/docs/conventions/java/static-method-convention.md b/docs/conventions/java/static-method-convention.md new file mode 100644 index 00000000..cd056b80 --- /dev/null +++ b/docs/conventions/java/static-method-convention.md @@ -0,0 +1,117 @@ +--- +name: java/static-method-convention +module: java +description: 静态方法规范:领域判断回归实例方法,明确允许的 static 场景 +status: 已实现 +scope: 后端 +source: 项目自有 +--- + +## 解决什么问题 + +静态函数「放哪都行」——谁都能调、谁都能再写一份——是逻辑失去归属的典型信号: + +- 明明是「这个对象和那个对象是不是同一个」的判断,却写成 `XxxUtils.isSameAbc(a, b)`,于是同一套规则在项目里出现第二份、第三份,口径逐渐分叉; +- 因为不需要拥有对象就能调用,静态方法成为「不想想这段逻辑归谁」时的默认落点; +- 逻辑散落后,改规则要全局搜索,漏改一处就是线上不一致。 + +## 如何使用 + +### 判据 + +**如果这个方法的第一个参数是「某个对象」,那它就该是那个对象的实例方法。** + +- ❌ 承载单个对象的领域判断 → 必须回到对象上做实例方法; +- 曾经被判定违规的例子:`isSamePlan`、`isSameType`、`isSameIndicator`、`containsType`、`contains`、`containsIndicator`、`containsRater`——它们全部应改为对象上的 `isSameAs` / `isSameAsAny`。 + +### 允许的 static 场景 + +| 场景 | 示例 | 说明 | +|------|------|------| +| 常量 | `STATE_END`、`HUNDRED`、`CREATE_TIME_TOLERANCE` | 无行为,只有值 | +| 值对象的静态工厂 | `Money.of(...)`、`Response.buildSuccess()` | 创建本身是类型职责 | +| 内部静态类 | `Content`、`AttachmentItem` | 从属结构,不是独立领域概念 | +| 作用在集合整体上的规则执行 | `ValidationResult.throwIfFailed(List)` | 规则对象是「这批结果的汇总」,不隶属于其中单个元素 | + +判断口诀同上:第一个参数是单个对象就回到对象上;第一个参数是**集合整体**且规则本身就是「对一批数据做汇总/执行」时,静态方法才成立。 + +### 补充:可变状态不要做成静态单例 + +注册表、缓存、上下文这类有状态对象,即便能力本身合法,也不应做成 `static` 单例。全局可变状态会让测试互相污染、并发不可控。正确形态是**由装配层创建并持有实例**,通过构造注入使用。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 值对象的静态工厂:创建是类型职责 +public final class Money { + private final BigDecimal amount; + + private Money(BigDecimal amount) { + this.amount = amount; + } + + public static Money of(String amount) { + return new Money(new BigDecimal(amount)); + } +} +``` + +```java +// 作用在集合整体上的规则执行 +public final class ValidationResult { + + public static void throwIfFailed(List results) { + List messages = results.stream() + .filter(ValidationResult::isFailed) + .map(ValidationResult::getMessage) + .collect(Collectors.toList()); + if (!messages.isEmpty()) { + throw new BusinessException(String.join(";", messages)); + } + } +} +``` + +```java +// 同一性判断回到对象上 +project.isSameAs(other); +project.isSameAsAny(projects); +``` + +### ❌ 错误示例 + +```java +// ❌ 第一个参数是对象,却写成静态工具方法 +public final class ProjectUtils { + public static boolean isSameProject(Project a, Project b) { ... } + public static boolean containsProject(List list, Project p) { ... } +} +``` + +```java +// ❌ 为「方便」新建工具类,把领域规则搬出模型 +public final class RateeUtils { + public static boolean containsRater(List weights, Rater rater) { ... } +} +``` + +```java +// ❌ 有状态对象做成静态单例 +public final class StrategyRegistry { + private static final StrategyRegistry INSTANCE = new StrategyRegistry(); + private final Map strategies = new HashMap<>(); + + public static StrategyRegistry getInstance() { + return INSTANCE; + } +} +``` + +## 自查清单 + +- [ ] 新增的 static 方法,第一个参数是否其实是「某个对象」 +- [ ] 同一性 / 包含 / 是否某类型等判断是否已回到对象实例方法 +- [ ] 剩余 static 是否只落在常量、静态工厂、内部静态类、集合整体规则四类 +- [ ] 有状态对象是否由装配层持有实例,而非静态单例 diff --git a/docs/conventions/java/test-design-convention.md b/docs/conventions/java/test-design-convention.md new file mode 100644 index 00000000..2363449f --- /dev/null +++ b/docs/conventions/java/test-design-convention.md @@ -0,0 +1,133 @@ +--- +name: java/test-design-convention +module: java +description: 测试设计规范:模型不为测试让路,区分模型能力与测试脚手架 +status: 已实现 +scope: 后端 +source: 项目自有 +--- + +## 解决什么问题 + +为了让单元测试跑通而改模型,是本项目最容易犯、也最难回退的错误: + +- 给模型加注入的时钟、跳过校验的开关、`@VisibleForTesting` 方法、仅为断言准备的 getter,测试绿了,业务模型却多了一堆生产环境永远不会走的分支; +- 「只被测试调用」的代码被误判为脚手架删掉,结果删掉的是本该存在的建模能力(如策略注册表的注册、清空、重新装配),留下功能缺口; +- 两种错误方向相反,但根因相同:**用「谁在调用」而不是「能力本身是否成立」来判断代码该不该存在**。 + +## 如何使用 + +### 铁律:模型不为测试让路 + +**不能为了单元测试去调整模型的能力、结构或代码路径。** + +改动模型的唯一合法理由是「模型自身的业务控制逻辑有缺陷」——那时改的是缺陷,不是为了测试。测试跑不通,不是改模型的理由。 + +### 划界判据 + +判据不是「现在有没有调用方」,而是「**这个能力离开测试是否仍然成立**」。 + +| 维度 | 模型能力(该有) | 测试脚手架(不该有) | +|------|------------------|----------------------| +| 形态 | 注册 / 清空 / 重置 / 启用禁用 / 状态推进 / 业务查询 | 注入时钟、跳过校验的开关、`@VisibleForTesting`、只为断言加的取值方法、为摆状态加的 setter、把 `private` 改成 `public` | +| 判据 | 业务语义上独立成立:运行期换策略、卡丢了重发、码用过作废、状态该推进 | 只在测试里成立:生产不会有第二个「现在几点」,不会有「跳过校验」的入口 | +| 后果 | 删了就是功能缺失 | 留着就是把测试的方便写进了业务模型 | + +> 反例说明:`addStrategy` / `clear()` 在主代码里零调用、只有测试装配时调用,但它们**不违规**——策略注册表本来就该能注册、能清空、能重新装配,只是装配层尚未落地。这类能力属于建模缺口,装配层建立后应补上生产侧接线,而不是删除。 + +### 测试脚手架的正确替代路径(按优先级) + +| 优先级 | 测试诉求 | 正确做法 | +|--------|----------|----------| +| a | 业务需要知道某个信息 | 作为**输入参数**传入(如提交时刻、活动时间窗),由调用方决定值 | +| b | 业务只需记录「何时发生」 | 由对象自取系统时间(副作用本就该由对象做) | +| c | 测试想控制「现在几点」 | 测试诉求,不进模型:用 `isBetween(before, after)` 区间断言,或替换端口实现 | +| d | 测试要读内部才能断言 | 先问业务是否需要这个读能力;需要就作为正当查询保留,不需要就断言行为结果(状态、异常) | +| e | 测试「摆不出状态」 | 缺的是表达能力或装配入口:补模型能力或补装配,不加测试后门 | + +### 时钟为什么违规 + +被诟病的不是 `Clock` 这个类型,而是模型为了测试多了一个字段和重载构造,让外部能改变它的行为。「现在几点」在生产侧没有第二种答案,这个能力只在测试里成立。 + +正确做法:模型里不保留时钟字段、重载构造与可变时钟实现,改为两条路径——**时间作为输入**、**创建时间自取**。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 需要判断「此刻是否在活动期内」→ 时间作为输入 +public class Evaluation { + private final Instant startTime; + private final Instant endTime; + + public boolean isInProcess(Instant now) { + return !now.isBefore(startTime) && now.isBefore(endTime); + } +} +``` + +```java +// 只需记录「何时提交」→ 对象自取系统时间 +public class AnswerSheet { + private Instant submittedAt; + + public void submit() { + this.submittedAt = Instant.now(); + } +} +``` + +```java +// 测试:区间断言,不改模型 +Instant before = Instant.now(); +sheet.submit(); +Instant after = Instant.now(); +assertTrue(sheet.getSubmittedAt().isAfter(before.minusSeconds(1))); +assertTrue(sheet.getSubmittedAt().isBefore(after.plusSeconds(1))); +``` + +### ❌ 错误示例 + +```java +// ❌ 为测试注入时钟:模型多了一个只有测试会用到的字段与构造 +public class AnswerSheet { + private final Clock clock; + + public AnswerSheet() { + this(Clock.systemDefaultZone()); + } + + @VisibleForTesting + public AnswerSheet(Clock clock) { + this.clock = clock; + } + + public void submit() { + this.submittedAt = clock.instant(); + } +} +``` + +```java +// ❌ 为测试加跳过校验的开关 +public void submit(boolean skipValidation) { + if (!skipValidation) { + requireAnswerComplete(); + } + this.submittedAt = Instant.now(); +} +``` + +```java +// ❌ 把 private 改成 public / 加只为断言的 getter +public List items; // 只为测试摆状态 +public String internalCacheKey() { ... } // 只为测试断言中间值 +``` + +## 自查清单 + +- [ ] 本次改动是否为了让测试通过而修改了模型(字段、构造、可见性、分支) +- [ ] 新增的能力离开测试是否仍然成立 +- [ ] 是否可以用「输入参数 / 区间断言 / 行为断言」替代测试后门 +- [ ] 被删除的「零调用」代码,是否其实是尚未接线的建模能力 diff --git a/docs/conventions/workflow/github-issue.md b/docs/conventions/workflow/github-issue.md new file mode 100644 index 00000000..d68668a8 --- /dev/null +++ b/docs/conventions/workflow/github-issue.md @@ -0,0 +1,178 @@ +--- +name: workflow/github-issue +module: workflow +description: GitHub Issue 标准处理流程 — 定位 → 回复 → worktree 修复 → 推送特性分支 → 推送后回复(8.x/17.x 双线) +status: 已实现 +scope: 全栈 +source: 项目自有 +--- + +## 解决什么问题 + +不遵守此流程会导致: + +- 问题分析结论只留在本地,issue 参与者无法同步进展 +- 直接在主干工作区改代码,污染工作区、难以回滚和并行处理多个 issue +- 只修了一条版本线就声称修复完成,另一条线(8.x 或 17.x)漏修 +- 修复完成后 issue 长期悬空,无法追溯「哪个提交解决了哪个 issue」 +- 分支、worktree、合并目标不统一,发布时遗漏改动 + +## 适用范围 + +`codingapi/springboot-framework` 仓库的**所有 GitHub issue**。本仓库同时维护两条版本线,issue 可能落在任意一条上: + +| 版本线 | 技术栈 | 模块 | 发布路径 | +|--------|--------|------|----------| +| 17.x | JDK 17 / Spring Boot 3.x,`jakarta.*` | 5 个 starter + `example/` + `frontend/`(PC 端、移动端) | `17.3.x` → `main` | +| 8.x | JDK 8 / Spring Boot 2.7.x,`javax.*` | 5 个 starter,无 `example/`、无 `frontend/` | `8.2.x` 独立发布 | + +两条版本线功能基本一致,但 **API 命名空间不同(`jakarta` vs `javax`)、JDK 与 Spring Boot 代际不同**,代码不能直接照搬。 + +## 分支模型 + +| 分支 | 用途 | +|------|------| +| `main` | 默认分支,17.x 系列的发布分支;`17.3.x` 通过 PR 合入后打 `v17.3.x` 标签 | +| `17.3.x` | 17.x 版本开发分支,17.x issue 的**基线**与 PR 目标 | +| `8.2.x` | 8.x 版本维护分支,8.x issue 的**基线**与 PR 目标,独立打 `v8.2.x` 标签发布 | +| `2.x` | 历史版本分支,仅在 issue 明确指向 2.x 时使用 | + +> 本仓库没有 `dev` 分支,也没有「统一开发分支」的概念:**每条版本线各自维护、各自交付**。仓库中残留的 `dev` 分支为历史遗留,已废弃,不得作为基线或交付目标。 + +## 核心原则 + +1. **分析可以不开 worktree,改代码必须开**:定位阶段只读代码即可;一旦确定要修改,必须新建 git worktree + 特性分支。 +2. **先定版本线,再动手**:定位阶段必须确认 issue 属于 8.x、17.x 还是两者,并按对应版本分支创建 worktree。 +3. **先回复,再动手**:定位到问题后,先在对应 issue 回复根因、影响版本与处理方案。 +4. **版本分支只作基线,不参与交付**:worktree 基于 `17.3.x` 或 `8.2.x` 创建;修复提交、推送与 issue 关联全部发生在该 issue 的特性分支上。完成后**不合并版本分支**,是否合并由人工决定,助手只在任务完成时提示分支与 PR 链接。 +5. **双版本 issue 必须双线验证**:同时影响两条线时,两条线各切一个特性分支、各自测试、各自推送,不得只修一条线就宣布完成。 +6. **提交即关联 issue**:提交信息使用 `Closes #` 关联;是否已真正关闭取决于是否进入默认分支 `main`,未自动关闭时由维护者确认后手动关闭。 +7. **推送后再回复**:特性分支推送到 GitHub 之后,才在 issue 回复修复内容、提交与验证结果;**未推送前不得发布完成回复**。 + +## 标准步骤 + +### 1. 读取 issue + +```bash +gh issue view --repo codingapi/springboot-framework \ + --json number,title,state,labels,body,comments,author,createdAt +``` + +### 2. 定位问题(可不建 worktree) + +- 使用代码搜索、LSP、文档(`docs/conventions`、`docs/capabilities`)定位根因; +- **判定版本线**:issue 的复现环境、依赖坐标(`17.3.x` / `8.2.x`)、报错栈中的 `jakarta.*` 或 `javax.*` 都是判断依据;无法判断时按 17.x 处理,并在定位回复中请提问者确认是否需要同步 8.x; +- 明确影响模块(哪个 starter / `example` / `frontend` 的 PC 端或移动端); +- 判断是否属于已有能力缺陷,优先复用既有能力(统一响应 `Response`、动态查询 `PageRequest` + `FastRepository`、事件 `EventPusher`、数据权限 SQL 拦截等),而不是新增一套实现。 + +### 3. 回复 issue(定位结论) + +将以下内容整理为中文评论: + +- 问题原因(引用具体类/文件/方法); +- **影响版本线**(8.x / 17.x / 两者)及判断依据; +- 处理方案(改动点、行为变化、兼容性;双版本时说明两条线的差异点); +- 交付方式(基于对应版本分支的特性分支名,完成后由维护者合并)。 + +```bash +gh issue comment --repo codingapi/springboot-framework --body-file /tmp/issue--analysis.md +``` + +### 4. 创建 worktree 与特性分支 + +`.worktrees/` 已被 `.gitignore` 忽略。按版本线二选一;双版本则两条都建: + +```bash +# 17.x +git worktree add .worktrees/issue--17x -b feature/issue---17x 17.3.x + +# 8.x +git worktree add .worktrees/issue--8x -b feature/issue---8x 8.2.x +``` + +- 分支命名统一带版本线后缀:`feature/issue--<简短英文描述>-<17x|8x>`,避免两条线同名冲突; +- 17.x 的前端改动在同一 worktree 的 `frontend/` 目录内进行,首次使用需安装依赖(worktree 内没有 `node_modules`): + +```bash +cd frontend +pnpm install --frozen-lockfile --prefer-offline +``` + +> worktree 仅以对应版本分支为**基线**;创建之后的所有提交、推送与 issue 关联都只发生在该特性分支上,版本分支不参与交付。 + +### 5. 修复与验证(TDD) + +- 先补测试(红灯),再实现(绿灯),最后重构; +- 后端测试位于各模块 `src/test/java`,使用 JUnit 5,类名以 `Test` 结尾; +- 编译/测试必须使用该版本线对应的 JDK:17.x 用 JDK 17,8.x 用 JDK 8; +- 后端验证:`./mvnw test -pl -Dtest=XxxTest`;改动公共模块或被依赖模块后执行 `./mvnw clean install`;提交前执行 `./mvnw clean test -P travis`(与 CI 一致); +- 前端验证(仅 17.x,在 `frontend/` 下):`pnpm test:pc` / `pnpm test:mobile`、`pnpm build:pc` / `pnpm build:mobile`; +- 8.x 分支没有 `example/` 与 `frontend/`,验证只覆盖后端模块,新增代码使用 `javax.*`; +- **修改代码后必须本地编译/测试通过**,失败项如实报告,不得声称已验证;双版本 issue 必须在两条线各自的 worktree 中分别验证。 + +### 6. 推送特性分支到 GitHub(任务到此结束) + +```bash +git push -u origin feature/issue---17x # 17.x +git push -u origin feature/issue---8x # 8.x +``` + +- 只推送该 issue 的特性分支,`17.3.x` / `8.2.x` 保持不动,是否合并由人工决定; +- 禁止推送到 `17.3.x` / `8.2.x` / `main` / `dev`,禁止在本地合并; +- 任务完成提示中给出分支地址与 PR 链接(PR 目标分支:17.x → `17.3.x`,8.x → `8.2.x`,**不指向 `main`**),不代为合并; +- 双版本 issue 需要两个 PR,分别对应两条版本线; +- 推送后可清理本地 worktree(`git worktree remove `),分支保留。 + +### 7. 提交信息与 issue 关闭 + +提交信息格式: + +``` +fix: <一句话描述> (<版本线>/<关联模块>) + +<可选的改动说明> + +Closes # +``` + +- `Closes #` 只在提交进入**默认分支**(`main`)时由 GitHub 自动关闭,即只有 17.x 经 `17.3.x → main` 合并时才可能自动关闭; +- 8.x 的特性分支合入 `8.2.x` 不会关闭 issue;仅修 8.x 的 issue 需在确认修复已发布后由维护者手动关闭; +- 双版本同时修复时,17.x 提交写 `Closes #`,8.x 提交写 `Ref #`,避免关闭语义重复; +- 如需提前关闭,必须由维护者确认后执行 `gh issue close `,不得擅自关闭。 + +### 8. 推送后回复 issue + +**前置条件:特性分支已推送到 GitHub**。未推送前只可说明「修复已提交到本地特性分支,待推送」,不得发布完成结论。 + +回复内容包含: + +- 根因与最终方案(与步骤 3 的差异); +- 变更文件/能力(新增公共 API、行为变化),双版本时按版本线分别列出; +- 验证结果(各版本线的测试用例数、构建结果、使用的 JDK); +- 推送分支与提交号、对应 PR/评审状态; +- 升级/兼容提示(如有行为变更); +- 未覆盖的版本线(若有),以及是否需要后续跟进。 + +## 禁止事项 + +- 不得向 `17.3.x` / `8.2.x` / `main` / `dev` 推送,不得在本地合并(是否合并由人工决定); +- 不得把 8.x 的修复直接 cherry-pick 到 17.x(或反向)而不做 `javax` ↔ `jakarta`、JDK / Spring Boot 代际适配; +- 不得只修一条版本线就宣布双版本 issue 已修复; +- 特性分支推送是标准步骤,但推送前必须确认远程分支名,避免误推; +- 不得在主工作区(非 worktree)直接修改业务代码; +- 不得跳过测试与编译验证; +- 不得在特性分支推送前发布完成回复(定位回复不受此限); +- 不得在代码未进入默认分支前擅自关闭 issue; +- 前端 workspace 内跨 package 引用必须使用包名(`@springboot-framework/*`),不得使用相对路径。 + +## 检查清单 + +- [ ] issue 已读取并确认现状 +- [ ] 已确认影响版本线(8.x / 17.x / 两者) +- [ ] 定位结论(含影响版本)已在 issue 回复 +- [ ] worktree + 特性分支已创建(基于 `17.3.x` / `8.2.x`) +- [ ] 测试先行且全量通过,构建通过(使用对应 JDK) +- [ ] 双版本 issue 已在两条线分别验证 +- [ ] 特性分支已推送到 GitHub(版本分支未改动) +- [ ] 提交信息包含 `Closes #`(双版本时 8.x 用 `Ref #`) +- [ ] 推送后已在 issue 回复修复与验证结果