Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ build/
### flatten-maven-plugin ###
.flattened-pom.xml
test.db*

### worktrees ###
.worktrees/
89 changes: 89 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,95 @@ codingapi.framework.handler-thread-pool-size=20 # 事件异步线程池大小

见根 `pom.xml` 的 `<properties>` 区块。主要版本: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编号>` 关联 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<String> response = SingleResponse.of(data);

// then
assertTrue(response.isSuccess());
assertEquals(data, response.getData());
}
}
```

<!-- PKR-START -->
## PKR 知识查阅(编码前必须)
Expand Down
7 changes: 6 additions & 1 deletion docs/conventions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
158 changes: 158 additions & 0 deletions docs/conventions/java/domain-modeling-convention.md
Original file line number Diff line number Diff line change
@@ -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<Project> 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<PlanItem> 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<Project> 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<PlanItem> getItems() {
return items; // 调用方可直接 add / remove
}
```

## 自查清单

- [ ] 同一性判断是否为对象上的 `isSameAs` / `isSameAsAny`
- [ ] 是否没有覆盖 `equals`(实体/聚合根)
- [ ] 跨聚合复制是否深复制了行与值
- [ ] 「能不能改」是否只经 `isConfigurable()` + `requireConfigurable(action)`
- [ ] 集合出口是否为只读快照,内层集合是否也收口
Loading
Loading