# 项目 DDD 设计规范

> 状态：项目渐进迁移规范；7月迭代标准稿已采用。
> 务实型 DDD——不是学院派，不强制 Event Sourcing，不追求完美，追求可维护、可扩展、AI 辅助友好。
> 基准文档：`docs/改造方向.md`（绞杀者模式）

---

## 一、为什么用 DDD / 什么不是 DDD

### 现状问题

- `order/service.go` 3031 行：订单创建、支付、佣金计算、代购、库存扣减全混一起
- Model 只是 GORM 映射，没有业务行为（贫血模型）
- 新增业务联动必须修改原有 Service，牵一发动全身

### 目标

不是重写，是**渐进替换**：

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/Redis/GORM）
│   ├── wallet/
│   │   ├── wallet.go           ← 聚合根（含业务方法）
│   │   ├── events.go           ← 领域事件定义
│   │   ├── repository.go       ← 仓储接口（interface）
│   │   └── vo.go               ← 值对象（Money, CreditLimit）
│   ├── approval/               ← 新：审批流领域
│   ├── notification/           ← 新：通知领域
│   ├── distribution/           ← 后续预留：需求16独立立项后再创建
│   └── package/                ← 套餐领域（迁移中）
│
├── application/                ← 应用层（用例，只做编排，不含业务判断）
│   ├── wallet/
│   │   ├── debit_wallet.go     ← 一个文件 = 一个用例
│   │   ├── credit_wallet.go
│   │   └── grant_credit.go     ← 新：授信
│   ├── approval/
│   │   ├── submit_approval.go
│   │   ├── advance_step.go
│   │   └── reject_approval.go
│   └── notification/
│       └── send_notification.go
│
├── query/                      ← 读取侧（可直接使用 GORM，返回 DTO/Projection）
│   ├── order/
│   ├── refund/
│   ├── approval/
│   ├── dashboard/
│   └── report/
│
├── infrastructure/             ← 基础设施层（实现 domain 里的 interface）
│   ├── persistence/            ← GORM Repository，可委托现有 Store
│   ├── messaging/              ← Outbox Relay、Asynq 发布与消费
│   └── adapter/                ← 外部系统和旧模块防腐层
│
├── handler/                    ← 原有 handler（保持不动）
├── service/                    ← 原有 service（保持不动，新模块不在这里）
└── store/                      ← 原有 store（保持不动）
```

**过渡期规则**：
- 旧模块：`internal/service/xxx` + `internal/store/postgres/xxx`
- 复杂写用例：`internal/domain/xxx` + `internal/application/xxx`
- 简单写用例：`internal/application/xxx`
- 读取用例：`internal/query/xxx`
- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
- 全新领域优先使用纯领域对象；迁移旧模块时可临时复用现有 GORM Model，但不得让 Fiber、Redis 或 GORM 查询进入业务方法

---

## 四、领域模块划分

| 领域 | 聚合根 | 核心业务规则 | 状态 |
|------|--------|------------|------|
| **Asset（资产）** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 |
| **Order（订单）** | `Order` | 支付、退款、佣金触发 | 迁移中 |
| **Package（套餐）** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
| **Wallet（钱包）** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
| **Shop（代理）** | `Shop` | 层级关系、信用策略 | 部分迁移 |
| **Approval（审批）** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** |
| **Notification（通知）** | `Notification` | 分发、已读状态 | **全新 DDD** |
| **Distribution（分销）** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项，本期不创建 |

---

## 五、聚合根设计原则（富模型）

### 5.1 核心原则

业务规则住在聚合根里，外部只通过方法操作，不能直接改字段。

```go
// ❌ 贫血模型（禁止）——所有判断在 Service 里
func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error {
    wallet, _ := s.store.Get(ctx, walletID)
    if wallet.Balance-wallet.FrozenBalance < amount {
        return errors.New(errors.CodeInsufficientBalance)
    }
    wallet.Balance -= amount
    s.store.Update(ctx, wallet)
}

// ✅ 富模型（推荐）——判断逻辑在聚合根里
func (w *AgentWallet) Debit(amount Money) error {
    available := w.Balance - w.FrozenBalance + w.CreditLimit  // 含信用额度
    if available < amount.Cents() {
        return ErrInsufficientBalance
    }
    w.Balance -= amount.Cents()
    w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance})
    return nil
}

// Application 层只做编排
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
    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)
}
```

### 5.2 聚合根必须包含

```go
type AggregateRoot struct {
    // 业务字段...

    // 未发布领域事件，由 Application 在事务内写入 Outbox
    domainEvents []DomainEvent
}

// PopEvents 获取并清空领域事件
func (a *AggregateRoot) PopEvents() []DomainEvent {
    events := a.domainEvents
    a.domainEvents = nil
    return events
}

func (a *AggregateRoot) recordEvent(e DomainEvent) {
    a.domainEvents = append(a.domainEvents, e)
}
```

### 5.3 与现有 GORM 的共存

迁移旧模块时，聚合根可以临时包装现有 GORM Model，减少一次性重构成本：

```go
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根
// 直接复用并扩展现有 model.AgentWallet
type AgentWallet struct {
    model.AgentWallet // 迁移期复用持久化字段
    domainEvents []DomainEvent
}

// Debit 从钱包扣款（含信用额度）
func (w *AgentWallet) Debit(amount Money) error {
    available := w.Balance - w.FrozenBalance + w.CreditLimit
    if available < amount.Cents() {
        return ErrInsufficientBalance
    }
    w.Balance -= amount.Cents()
    w.recordEvent(WalletDebitedEvent{...})
    return nil
}
```

这只是迁移期折中，不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象，由 Infrastructure 负责领域对象与 GORM Model 的转换。

---

## 六、值对象设计

值对象：没有 ID，靠值来判断相等，不可变。

```go
// internal/domain/wallet/vo.go

// Money 金额值对象（分为单位，防止浮点数精度问题）
type Money struct {
    cents int64
}

func NewMoney(cents int64) (Money, error) {
    if cents < 0 {
        return Money{}, ErrNegativeMoney
    }
    return Money{cents: cents}, nil
}

func (m Money) Cents() int64    { return m.cents }
func (m Money) Yuan() float64   { return float64(m.cents) / 100 }
func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} }

// CreditLimit 信用额度值对象
type CreditLimit struct {
    maxCents     int64  // 最大授信额度（分）
    allowNegative bool  // 是否允许负余额
}
```

---

## 七、领域事件与可靠投递

领域对象只记录已经发生的业务事实，不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox，再由 Relay 异步投递到 Asynq。

```go
// internal/domain/wallet/events.go

// DomainEvent 领域事件接口
type DomainEvent interface {
    EventType() string
    OccurredAt() time.Time
}

// WalletDebitedEvent 钱包扣款事件
type WalletDebitedEvent struct {
    WalletID     uint
    ShopID       uint
    Amount       Money
    BalanceBefore int64
    BalanceAfter  int64
    RefType      string
    RefID        uint
    occurredAt   time.Time
}

func (e WalletDebitedEvent) EventType() string    { return "wallet.debited" }
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
```

**事务边界**：

```text
数据库事务
  ├── 保存聚合
  ├── 保存 Audit Event（该动作需要审计时）
  └── 保存 Outbox 事件
提交事务
  ↓
Outbox Relay → Asynq → 事件处理器
```

**Application UseCase 保存事件**：

```go
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
        }
        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 写入失败：事务回滚，避免“业务成功但关键事件永久丢失”。
- 高风险成功操作的 Audit Event 写入失败：事务回滚，避免“业务事实存在但无法回答谁做了什么”。业务已回滚后的失败或拒绝审计使用独立短事务，禁止裸 goroutine。
- Asynq 暂时不可用：不回滚已提交业务，Relay 后续重试。
- 消费者必须幂等；涉及余额、退款、充值时使用业务键或状态条件更新。

### 公共能力决策

每个新增或修改的完整用例都必须在 PRD/Ticket 中明确以下决定，不能只写“接入公共基础”：

- 是否写 Audit Event；若否，写明 N/A 理由，并增量维护全系统审计覆盖基线；
- 是否已有 Domain Ledger 作为金额、订单或状态事实，Audit Event 不得替代它；
- 是否存在外部请求或回调，需要 Integration Log；
- 是否存在提交后可靠副作用，需要同事务 Outbox，并明确事件类型、版本和消费者幂等键；
- 最终发布门禁所依赖的具体公共 Ticket，不得使用无法判断完成状态的模糊依赖。

---

## 八、仓储接口

```go
// internal/domain/wallet/repository.go

// WalletRepository 钱包仓储接口
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
}
```

实现在 `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

// DebitCommand 扣款命令
type DebitCommand struct {
    WalletID  uint
    Amount    domainWallet.Money
    RefType   string
    RefID     uint
    OperatorID uint
}

// 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(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
        }
        if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
            return err
        }
        return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
    })
}
```

---

## 十、Handler 层调用方式

- 复杂写、简单写：Handler → Application UseCase。
- 读取：Handler → Query。
- 尚未迁移的旧用例：Handler → Service。
- Handler 不直接访问 GORM，也不承载业务规则或复杂 DTO 拼装。

```go
// internal/handler/admin/wallet_handler.go
func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
    var req dto.GrantCreditRequest
    // ... 参数解析

    cmd := walletApp.GrantCreditCommand{
        ShopID:    req.ShopID,
        MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents),
        OperatorID: middleware.GetUserID(c),
    }
    if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil {
        return err
    }
    return response.OK(c, nil)
}
```

---

## 十一、禁止事项

| 禁止 | 原因 |
|------|------|
| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 |
| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用，运行时通过 Repository 加载 |
| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 |
| 在一个用例文件里实现多个业务流程 | 一文件一用例 |
| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失，必须事务写 Outbox |
| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 |
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
| Query 执行写操作 | Query 只负责读取模型，写入必须进入 Application |
| 使用 Query 快照替代写侧校验 | 查询结果可能已过期，Domain 必须重新验证不变量 |
| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合，复杂读取属于 Query |
| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁，不是 DDD 迁移 |

---

## 十二、本次迭代 DDD 落地计划

| 新模块 | 领域路径 | 用例路径 |
|--------|---------|---------|
| 信用额度（需求17） | `internal/domain/wallet/` | `internal/application/wallet/` |
| 审批流（需求18/20/21） | `internal/domain/approval/` | `internal/application/approval/`，使用版本快照和 Outbox |
| 站内消息（需求18/22） | `internal/domain/notification/` | `internal/application/notification/` |
| 批量订购（需求19） | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |

需求 16 已于 2026-07-14 移出 7 月迭代，本期不创建 `distribution` 领域目录、聚合和应用用例；后续独立立项时再按本规范判断领域边界。
