Andressann/HOLOCRON
GitHub: Andressann/HOLOCRON
一个采用纯六边形架构实现的星际舰队管理演示系统,展示领域驱动设计、不可变审计日志和各层完整测试覆盖的最佳实践。
Stars: 0 | Forks: 0
# 帝国舰队审计系统
帝国舰队的管理与审计系统。记录星际飞船、征召飞行员、管理分配任务,并维护每个操作的不可变审计日志。采用纯六边形架构、严格的分层分离以及各层级的测试覆盖。
## 为什么存在这个项目
作品集中常见的大多数管理系统都是直接的 CRUD:controller → repository,业务逻辑与基础设施之间没有任何分离。这种方法适用于演示,但不能反映真实系统的构建方式——系统会增长、会更换数据库、需要认真测试、由团队维护。
这个项目就是为了展示相反的做法:
- **领域层不知道 Spring 的存在。** `Starship`、`Pilot` 和 `AssignPilotDomainService` 类是纯 Java。领域层中没有任何框架注解。
- **业务规则位于正确的位置。** BR-01(CADET 不能驾驶 DESTROYER)在 `AssignPilotDomainService` 中建模,而不是在 controller 的 `if` 语句中。
- **审计日志在结构上是不可变的。** 该表没有 `UPDATE` 或 `DELETE`。数据库保证这一点。
- **测试独立覆盖每一层。** 82 个测试:领域的单元测试、持久化的集成测试、controller 的切片测试,以及完整流程的端到端测试。
## 技术栈
### 后端
| 层级 | 技术 | 版本 |
|---|---|---|
| 语言 | Java | 21 |
| 框架 | Spring Boot | 3.4.1 |
| 持久化 | Spring Data JPA + Hibernate | — |
| 数据库 | PostgreSQL | 16 |
| 迁移 | Flyway | — |
| ID 生成 | java-uuid-generator (UUID v7) | 5.0 |
| 容器 | Docker + Docker Compose | — |
| 构建 | Maven | 3.9+ |
### 前端
| 层级 | 技术 | 版本 |
|---|---|---|
| 框架 | React | 19 |
| 语言 | TypeScript | 5 |
| 构建工具 | Vite | 8 |
| 样式 | Tailwind CSS | 4 |
| 动画 | Framer Motion | 12 |
| 路由 | React Router | 7 |
| 服务端状态 | TanStack Query | 5 |
| HTTP 客户端 | Axios | 1.14 |
### 测试
| 类型 | 工具 |
|---|---|
| 单元测试 | JUnit 5 + Mockito |
| Controller 测试 | MockMvc |
| 数据库集成 | Testcontainers + PostgreSQL |
| 集成测试 | @SpringBootTest + TestRestTemplate |
## 架构
系统遵循**六边形架构(端口与适配器)**,也称为整洁架构。核心规则很简单:依赖只指向内部。领域不认识任何人。基础设施认识领域,但反之不行。
```
┌─────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────┐ │
│ │ REST API │ │ Persistence │ │
│ │ │ │ (JPA + Flyway + PostgreSQL) │ │
│ │ Controllers │ │ StarshipPersistenceAdapter │ │
│ │ DTOs │ │ PilotPersistenceAdapter │ │
│ │ Mappers │ │ AuditLogPersistenceAdapter │ │
│ └──────┬──────┘ └──────────────┬──────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ APPLICATION │ │
│ │ │ │
│ │ CreateStarshipUseCase GetStarshipUseCase │ │
│ │ CreatePilotUseCase AssignPilotUseCase │ │
│ │ UpdateStarshipStatusUseCase UnassignPilotUseCase │ │
│ │ │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DOMAIN │ │
│ │ │ │
│ │ Starship Pilot AuditEvent │ │
│ │ ShipClass PilotRank StarshipStatus │ │
│ │ AssignPilotDomainService (BR-01) │ │
│ │ │ │
│ │ <> │ │
│ │ StarshipRepository PilotRepository AuditLogPort │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 操作流程
```
POST /api/v1/pilots/{id}/assign
│
▼
PilotController ← HTTP boundary, transforma request a Command
│
▼
AssignPilotToStarshipUseCase ← orquesta: carga entidades, llama servicio de dominio
│
├──▶ PilotRepository.findById() ← Port (interfaz del dominio)
├──▶ StarshipRepository.findById() ← Port
├──▶ AssignPilotDomainService.validate() ← BR-01: CADET + DESTROYER → 422
├──▶ pilot.assignToStarship(starshipId) ← Mutación en el aggregate root
├──▶ PilotRepository.save(pilot) ← Port
└──▶ AuditLogPort.record(event) ← Puerto de auditoría
│
▼
PilotPersistenceAdapter ← Implementa el port con JPA
AuditLogPersistenceAdapter ← Implementa el port, persiste JSONB
```
## 领域模型
### 实体
**Starship** — 聚合根。拥有不可变的 `shipClass` 和可变的 `status`。对 status 的唯一写操作是 decommission。
**Pilot** — 聚合根。拥有不可变的 `rank` 和可空的 `starshipId`。Null 表示未分配。
### 值对象
```
ShipClass: DESTROYER | CRUISER | FIGHTER
PilotRank: SITH(3) | OFFICER(2) | CADET(1)
StarshipStatus: ACTIVE | DECOMMISSIONED
```
`PilotRank` 携带数值授权级别。`ShipClass.DESTROYER` 需要级别 >= 2。这个逻辑位于 `AssignPilotDomainService` 中,而不是枚举中。
### 业务规则
| ID | 规则 | 实现 | HTTP |
|---|---|---|---|
| BR-01 | CADET 不能被分配到 DESTROYER 飞船 | `AssignPilotDomainService` | 422 |
| BR-02 | 已分配的飞行员不能重新分配,必须先取消分配 | `AssignPilotToStarshipUseCase` | 409 |
| BR-03 | 不能取消分配没有分配的飞行员 | `UnassignPilotFromStarshipUseCase` | 409 |
| BR-04 | DECOMMISSIONED 飞船不接受分配 | `AssignPilotToStarshipUseCase` | 409 |
### 审计日志
审计日志在结构上是不可变的。没有 `@PreUpdate`,没有 `updated_at`,也没有对该表的 UPDATE 或 DELETE 操作。每个事件都是一条带有灵活 JSONB 负载的新记录。
```
CREATE TABLE audit_log (
id UUID NOT NULL PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
entity_type VARCHAR(64) NOT NULL,
entity_id UUID NOT NULL,
actor VARCHAR(128),
payload JSONB,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- Sin updated_at. Intencional.
);
```
## 数据库 schema
```
starships
├── id UUID PK
├── name VARCHAR(255)
├── model VARCHAR(255)
├── ship_class VARCHAR(50)
├── status VARCHAR(50) DEFAULT 'ACTIVE'
├── created_at TIMESTAMPTZ
└── updated_at TIMESTAMPTZ
pilots
├── id UUID PK
├── name VARCHAR(255)
├── rank VARCHAR(50)
├── starship_id UUID FK → starships(id) NULLABLE
├── created_at TIMESTAMPTZ
└── updated_at TIMESTAMPTZ
audit_log (append-only)
├── id UUID PK
├── event_type VARCHAR(64)
├── entity_type VARCHAR(64)
├── entity_id UUID
├── actor VARCHAR(128)
├── payload JSONB
└── occurred_at TIMESTAMPTZ
```
ID 是 **UUID v7**(时间有序)。与 UUID v4 不同,UUID v7 是单调递增的,这改善了 PostgreSQL 中 B-tree 索引的性能,减少了页面分裂和碎片化。
## REST API
### 飞船
```
POST /api/v1/starships → 201 Registrar nave
GET /api/v1/starships → 200 Listar todas
GET /api/v1/starships/{id} → 200 Obtener por ID
POST /api/v1/starships/{id}/decommission → 200 Descomisionar
```
### 飞行员
```
POST /api/v1/pilots → 201 Enlistar piloto
GET /api/v1/pilots → 200 Listar todos
GET /api/v1/pilots/{id} → 200 Obtener por ID
POST /api/v1/pilots/{id}/assign → 200 Asignar a nave
POST /api/v1/pilots/{id}/unassign → 200 Desasignar
```
### 错误代码
| 代码 | 含义 |
|---|---|
| 400 | 请求中的数据无效 |
| 404 | 飞船或飞行员未找到 |
| 409 | 状态冲突(已分配、未分配、飞船已退役) |
| 422 | 业务规则违反(BR-01:级别不足) |
| 500 | 未处理的内部错误 |
## 测试
```
82 tests — 0 fallos
Unit tests (dominio)
├── PilotTest (7) — comportamiento del aggregate root
├── StarshipTest (5) — comportamiento del aggregate root
└── AssignPilotDomainServiceTest (7) — validación BR-01
Unit tests (application)
├── CreateStarshipUseCaseTest (5)
├── CreatePilotUseCaseTest (5)
├── GetStarshipUseCaseTest (3)
├── GetPilotUseCaseTest (3)
├── ListStarshipsUseCaseTest (2)
├── ListPilotsUseCaseTest (2)
├── UpdateStarshipStatusUseCaseTest (4)
├── AssignPilotToStarshipUseCaseTest (5)
└── UnassignPilotFromStarshipUseCaseTest (4)
Integration tests (persistencia — PostgreSQL real)
├── StarshipPersistenceAdapterTest (4)
├── PilotPersistenceAdapterTest (4)
└── AuditLogPersistenceAdapterTest (4)
Controller tests (MockMvc)
├── StarshipControllerTest (6)
└── PilotControllerTest (7)
End-to-end integration
└── FleetIntegrationTest (4) — flujos completos sobre HTTP
```
持久化测试针对真实的 PostgreSQL 运行(不是 H2,不是 mock)。使用 `@DataJpaTest` 配合 `@AutoConfigureTestDatabase(replace = NONE)` 指向与开发相同的 Docker 实例,使用 `test` profile。
端到端测试使用 `@SpringBootTest(webEnvironment = RANDOM_PORT)` 启动完整上下文,并使用 `TestRestTemplate` 进行真实 HTTP 调用。
## 如何运行
### 前提条件
- Java 21
- Maven 3.9+
- Docker Desktop
- Node.js 18+
### 后端
```
# 启动 PostgreSQL
docker compose up -d
# 运行应用程序
JAVA_HOME=/path/to/java21 mvn spring-boot:run
# API 可在 http://localhost:8080 访问
```
### 前端
```
cd frontend
npm install
npm run dev
# 可在 http://localhost:5173 访问
# 代理已配置至 http://localhost:8080
```
### 测试
```
# 完整套件
JAVA_HOME=/path/to/java21 mvn test
# 仅集成
JAVA_HOME=/path/to/java21 mvn test -Dtest="**/integration/**"
```
### 数据种子
```
cd frontend
node seed.mjs
```
插入 20 艘飞船和 22 名来自正传系列的经典飞行员(Executor、Millennium Falcon、Darth Vader、Luke Skywalker 等)。
## 项目结构
```
gentle/
├── src/main/java/com/imperialfleet/auditor/
│ ├── domain/
│ │ ├── model/ Starship.java, Pilot.java
│ │ ├── valueobject/ ShipClass, PilotRank, StarshipStatus
│ │ ├── exception/ Excepciones de dominio (8)
│ │ ├── port/ StarshipRepository, PilotRepository, AuditLogPort
│ │ └── service/ AssignPilotDomainService
│ ├── application/
│ │ ├── command/ Commands (5 records)
│ │ └── usecase/ Use cases (9 clases)
│ └── infrastructure/
│ ├── config/ UseCaseConfig, InfrastructureConfig
│ ├── web/ Controllers, DTOs, GlobalExceptionHandler
│ └── persistence/ Adapters, JPA entities, Mappers, Flyway migrations
├── src/test/ 82 tests organizados por capa
├── frontend/ React + TypeScript SPA
└── docker-compose.yml PostgreSQL 16
```
## 相关技术决策
**使用 UUID v7 而非 UUID v4**
UUID v4 完全随机。当用作 PostgreSQL 中的主键时,每次 INSERT 可能落在 B-tree 索引的任何页面上,导致碎片化。UUID v7 是时间有序的:最近的插入位于索引末尾,与 SERIAL 相同,但避免了分布式系统中的协调问题。
**Upsert 在适配器中,而非领域层**
持久化适配器的 `save()` 方法在保存前执行 `findById().orElseGet(() -> toJpaEntity())`。这避免了 `@PrePersist`(只在 INSERT 时运行)在 JPA 对现有实体调用 `merge()` 时丢失 `created_at`。领域对此一无所知。
**Use cases 中没有 `@Service`**
Use cases 是没有 Spring 注解的纯 Java 类。它们在 `UseCaseConfig` 中通过 `@Bean` 工厂方法注册为 bean。这使应用层保持无框架依赖,与领域层相同。
**审计日志负载使用 JSONB**
审计事件的数据因事件类型而异。使用 JSONB 列允许添加新事件类型而不改变 schema。PostertinoSQL 使用 GIN 索引高效索引 JSONB,如需按内容搜索。
**使用 `@MockitoBean` 而非 `@MockBean`**
在 `@WebMvcTest` 的 controller 测试中,使用 `@MockitoBean`(Spring Boot 3.4+)而非已弃用的 `@MockBean`。区别在于:`@MockitoBean` 与 Mockito 5 集成,不会产生弃用警告。
标签:Anchore, DDD, Docker, Docker Compose, Flyway, Hexagonal Architecture, Hibernate, Immutable Audit Log, Java 21, Maven, PostgreSQL, React 19, Spring Boot 3.4, Spring Data JPA, TypeScript, 不可变审计日志, 企业级应用, 全栈项目, 六边形架构, 前端架构, 单元测试, 后端架构, 域名枚举, 安全插件, 安全防御评估, 微服务架构, 测试覆盖率, 清洁架构, 漏洞验证, 端到端测试, 端口和适配器模式, 舰队管理系统, 软件架构, 集成测试, 项目架构, 领域驱动设计