迭代计划准备

This commit is contained in:
2026-07-16 15:07:59 +08:00
parent 1a9db9328e
commit c4f430ccb3
22 changed files with 2969 additions and 1569 deletions

View File

@@ -1,6 +1,7 @@
# 项目 DDD 设计规范
> 务实型 DDD——不是学院派不强制 Event Sourcing不追求完美追求可维护、可扩展、AI 辅助友好。
> 状态:待评审
> 务实型 DDD——不是学院派不强制 Event Sourcing不追求完美追求可维护、可扩展、AI 辅助友好。
> 基准文档:`docs/改造方向.md`(绞杀者模式)
---
@@ -17,17 +18,71 @@
不是重写,是**渐进替换**
1. **新功能按新结构写**,不往旧 Service 加代码
2. **旧功能迁移**每次迁移一个用例,跑通后再下一个
3. **AI 辅助**结构清晰AI 生成代码时自动落入正确位置
1. **复杂写功能**:进入 Application + Domain不继续堆入旧 Service
2. **简单写功能**使用 Application 事务脚本,不强行创建聚合
3. **读取功能**进入 Query 读取模型,不通过聚合根
4. **旧功能迁移**:只在需求触碰时迁移一个完整用例,不做模块级重写
5. **AI 辅助**结构清晰AI 生成代码时自动落入正确位置
---
## 二、目录结构
## 二、什么时候进入 DDD
DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。
### 2.1 优先使用 DDD 的场景
当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块:
1. 有明确生命周期、状态机、状态转换限制或并发不变量。
2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。
3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。
4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。
5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。
审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。
### 2.2 通常不迁移的场景
以下需求通常保持原有 `Handler → Service → Store → Model`
1. 单表 CRUD、字段增删、简单列表筛选和格式转换。
2. 局部缺陷修复,且不改变业务边界或核心规则。
3. 没有独立领域语言、生命周期或跨模块不变量。
4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。
### 2.3 触碰式迁移
- 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。
- 迁移单位是**完整用例**,不是整个模块、文件或数据表。
- 简单字段、筛选、格式转换和局部修复默认沿用旧结构。
- 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。
- 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。
- 旧 Service 可以暂时作为**内部代码迁移门面**调用新 UseCase调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。
- 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。
### 2.4 查询侧规则
复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型:
```text
Handler → Query → GORM/DTO
```
- 列表、详情、联表、统计、报表和导出属于 Query。
- Query 可以直接使用 GORM、CTE、子查询和批量查询并负责读取权限与分页。
- Query 返回专用 DTO/Projection禁止返回后用于业务写入。
- 只有查询结果参与写操作判定时Domain 必须重新校验,不能信任读取快照。
- 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/<context>`
- Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。
---
## 三、目录结构
```
internal/
├── domain/ ← 领域层(纯业务逻辑,不依赖 Fiber/GORM
├── domain/ ← 领域层(不依赖 Fiber/Redis/GORM
│ ├── wallet/
│ │ ├── wallet.go ← 聚合根(含业务方法)
│ │ ├── events.go ← 领域事件定义
@@ -35,7 +90,7 @@ internal/
│ │ └── vo.go ← 值对象Money, CreditLimit
│ ├── approval/ ← 新:审批流领域
│ ├── notification/ ← 新:通知领域
│ ├── distribution/ ← 新:分销领域
│ ├── distribution/ ← 后续预留需求16独立立项后再创建
│ └── package/ ← 套餐领域(迁移中)
├── application/ ← 应用层(用例,只做编排,不含业务判断)
@@ -50,8 +105,17 @@ internal/
│ └── notification/
│ └── send_notification.go
├── query/ ← 读取侧(可直接使用 GORM返回 DTO/Projection
│ ├── order/
│ ├── refund/
│ ├── approval/
│ ├── dashboard/
│ └── report/
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface
── persistence/ ← 原来的 store/postgres/(保持不动)
── persistence/ ← GORM Repository可委托现有 Store
│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费
│ └── adapter/ ← 外部系统和旧模块防腐层
├── handler/ ← 原有 handler保持不动
├── service/ ← 原有 service保持不动新模块不在这里
@@ -60,12 +124,15 @@ internal/
**过渡期规则**
- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx`
- 新模块`internal/domain/xxx` + `internal/application/xxx`
- Handler 层统一调 Application 层(新)或 Service 层(旧)
- 复杂写用例`internal/domain/xxx` + `internal/application/xxx`
- 简单写用例:`internal/application/xxx`
- 读取用例:`internal/query/xxx`
- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model但不得让 Fiber、Redis 或 GORM 查询进入业务方法
---
## 、领域模块划分
## 、领域模块划分
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|------|--------|------------|------|
@@ -74,15 +141,15 @@ internal/
| **Package套餐** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
| **Wallet钱包** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
| **Shop代理** | `Shop` | 层级关系、信用策略 | 部分迁移 |
| **Approval审批** | `ApprovalFlow` | 多级审批、推进、驳回 | **全新 DDD** |
| **Approval审批** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** |
| **Notification通知** | `Notification` | 分发、已读状态 | **全新 DDD** |
| **Distribution分销** | `DistributionRelation` | 发展人体系、二维码注册 | **全新 DDD** |
| **Distribution分销** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 |
---
## 、聚合根设计原则(富模型)
## 、聚合根设计原则(富模型)
### 4.1 核心原则
### 5.1 核心原则
业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。
@@ -113,24 +180,22 @@ func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) err
wallet, err := uc.repo.GetByID(ctx, cmd.WalletID)
if err != nil { return err }
if err := wallet.Debit(cmd.Amount); err != nil { return err }
// 完整事务和 Outbox 写入方式见“应用服务(用例)”章节
return uc.repo.Save(ctx, wallet)
}
```
### 4.2 聚合根必须包含
### 5.2 聚合根必须包含
```go
type AggregateRoot struct {
// 内嵌 GORM model过渡期
gorm.Model
// 业务字段...
// 未发布领域事件内存中Save 后由框架分发)
domainEvents []DomainEvent `gorm:"-" json:"-"`
// 未发布领域事件,由 Application 在事务内写入 Outbox
domainEvents []DomainEvent
}
// 获取并清空领域事件(由 Repository.Save 调用)
// PopEvents 获取并清空领域事件
func (a *AggregateRoot) PopEvents() []DomainEvent {
events := a.domainEvents
a.domainEvents = nil
@@ -142,17 +207,17 @@ func (a *AggregateRoot) recordEvent(e DomainEvent) {
}
```
### 4.3 与现有 GORM 的共存
### 5.3 与现有 GORM 的共存
过渡期,聚合根 **就是** 现有 GORM Model(加业务方法),不需要单独建领域对象
迁移旧模块时,聚合根可以临时包装现有 GORM Model,减少一次性重构成本
```go
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根
// 直接复用并扩展现有 model.AgentWallet
type AgentWallet struct {
model.AgentWallet // 内嵌原有 GORM 模型
domainEvents []DomainEvent `gorm:"-"`
model.AgentWallet // 迁移期复用持久化字段
domainEvents []DomainEvent
}
// Debit 从钱包扣款(含信用额度)
@@ -167,9 +232,11 @@ func (w *AgentWallet) Debit(amount Money) error {
}
```
这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。
---
## 、值对象设计
## 、值对象设计
值对象:没有 ID靠值来判断相等不可变。
@@ -201,9 +268,9 @@ type CreditLimit struct {
---
## 、领域事件(用 Asynq 实现)
## 、领域事件与可靠投递
领域事件不用消息队列,直接复用现有 Asynq 体系
领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox再由 Relay 异步投递到 Asynq
```go
// internal/domain/wallet/events.go
@@ -230,29 +297,45 @@ func (e WalletDebitedEvent) EventType() string { return "wallet.debited" }
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
```
**Repository.Save 发布事件**
**事务边界**
```text
数据库事务
├── 保存聚合
├── 保存操作日志
└── 保存 Outbox 事件
提交事务
Outbox Relay → Asynq → 事件处理器
```
**Application UseCase 保存事件**
```go
// internal/infrastructure/persistence/wallet_repo.go
func (r *WalletRepository) Save(ctx context.Context, wallet *domainWallet.AgentWallet) error {
err := r.db.WithContext(ctx).Save(&wallet.AgentWallet).Error
if err != nil {
return err
}
// 发布领域事件
for _, evt := range wallet.PopEvents() {
if err := r.publishEvent(ctx, evt); err != nil {
r.logger.Error("领域事件发布失败", zap.Error(err), zap.String("type", evt.EventType()))
// 事件发布失败不回滚业务(异步,可补偿)
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
}
return nil
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。
- Asynq 暂时不可用不回滚已提交业务Relay 后续重试。
- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。
---
## 、仓储接口
## 、仓储接口
```go
// internal/domain/wallet/repository.go
@@ -262,17 +345,20 @@ type WalletRepository interface {
GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error)
GetByID(ctx context.Context, id uint) (*AgentWallet, error)
Save(ctx context.Context, wallet *AgentWallet) error
SaveInTx(ctx context.Context, tx *gorm.DB, wallet *AgentWallet) error
}
```
实现在 `internal/infrastructure/persistence/wallet_repo.go`,复用现有 Store 逻辑。
实现在 `internal/infrastructure/persistence/wallet_repo.go`可以复用现有 Store 逻辑。事务由 Application 注入的 `TxManager` 管理Repository 从事务上下文获取 GORM `tx`,领域接口不得暴露 `*gorm.DB`
---
## 、应用服务(用例)
## 、应用服务(用例)
一个文件 = 一个用例。用例只做编排,不含业务判断。
一个文件 = 一个用例。
- 复杂写用例Application 只做事务和跨聚合编排,业务不变量位于 Domain。
- 简单写用例Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。
- Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。
```go
// internal/application/wallet/debit_wallet.go
@@ -289,29 +375,36 @@ type DebitCommand struct {
// DebitWalletUseCase 扣款用例
type DebitWalletUseCase struct {
walletRepo domainWallet.WalletRepository
outboxRepo OutboxRepository
txManager TxManager
}
// Execute 执行扣款
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error {
wallet, err := uc.walletRepo.GetByID(ctx, cmd.WalletID)
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
return uc.walletRepo.SaveInTx(ctx, tx, wallet)
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
---
## 、Handler 层调用方式
## 、Handler 层调用方式
新模块Handler → Application UseCase(不经过 Service 层)
- 复杂写、简单写Handler → Application UseCase
- 读取Handler → Query。
- 尚未迁移的旧用例Handler → Service。
- Handler 不直接访问 GORM也不承载业务规则或复杂 DTO 拼装。
```go
// internal/handler/admin/wallet_handler.go
@@ -333,25 +426,31 @@ func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
---
## 十、禁止事项
## 十、禁止事项
| 禁止 | 原因 |
|------|------|
| 在 Application 层做业务判断 | 业务规则必须在领域对象里 |
| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 |
| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 |
| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 |
| 在一个用例文件里实现多个业务流程 | 一文件一用例 |
| 领域事件发布失败时回滚业务 | 事件是异步补偿,不是事务一部分 |
| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox |
| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 |
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application |
| 使用 Query 快照替代写侧校验 | 查询结果可能已过期Domain 必须重新验证不变量 |
| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query |
| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 |
---
## 十、本次迭代 DDD 落地计划
## 十、本次迭代 DDD 落地计划
| 新模块 | 领域路径 | 用例路径 |
|--------|---------|---------|
| 信用额度需求17 | `internal/domain/wallet/` | `internal/application/wallet/` |
| 审批流需求18/20/21 | `internal/domain/approval/` | `internal/application/approval/` |
| 审批流需求18/20/21 | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox |
| 站内消息需求18/22 | `internal/domain/notification/` | `internal/application/notification/` |
| 分销码需求16 | `internal/domain/distribution/` | `internal/application/distribution/` |
| 批量订购需求19 | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |
需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。