迭代方案确认
This commit is contained in:
456
docs/7月迭代/独立方案/基础规范/DDD规范.md
Normal file
456
docs/7月迭代/独立方案/基础规范/DDD规范.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# 项目 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
|
||||
数据库事务
|
||||
├── 保存聚合
|
||||
├── 保存操作日志
|
||||
└── 保存 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 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。
|
||||
- Asynq 暂时不可用:不回滚已提交业务,Relay 后续重试。
|
||||
- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。
|
||||
|
||||
---
|
||||
|
||||
## 八、仓储接口
|
||||
|
||||
```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` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。
|
||||
237
docs/7月迭代/独立方案/基础规范/系统配置.md
Normal file
237
docs/7月迭代/独立方案/基础规范/系统配置.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# 基础设施:系统配置(tb_system_config)
|
||||
|
||||
> 状态:独立方案来源稿;最终口径以7月迭代标准评审稿为准。
|
||||
> 被依赖:需求 09(C端支付限制)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Admin as 平台超管
|
||||
participant Web as 后台配置页
|
||||
participant API as SystemConfig Application
|
||||
participant DB as PostgreSQL
|
||||
participant Redis as Redis
|
||||
participant Biz as 业务读取方
|
||||
|
||||
Admin->>Web: 修改受控配置表单
|
||||
Web->>API: PUT /api/admin/system/config/{config_key}
|
||||
API->>API: 按配置 key 注册规则校验类型和值域
|
||||
API->>DB: 更新值并写审计日志
|
||||
API->>Redis: 删除对应缓存
|
||||
API-->>Web: 返回最新配置和更新时间
|
||||
Biz->>Redis: 下次读取缓存未命中
|
||||
Biz->>DB: 读取最新配置并回填缓存
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 迁移文件:YYYYMMDD_create_tb_system_config.sql
|
||||
CREATE TABLE tb_system_config (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
config_key VARCHAR(100) NOT NULL, -- 唯一键,格式:module.group.name
|
||||
config_value TEXT NOT NULL DEFAULT '', -- 值(string/number/json字符串)
|
||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string | int | bool | json
|
||||
module VARCHAR(50) NOT NULL DEFAULT 'general', -- 所属模块(便于按模块查询)
|
||||
description TEXT, -- 中文说明(前端展示用)
|
||||
is_readonly BOOLEAN NOT NULL DEFAULT FALSE, -- 是否只读(代码内部,不允许后台改)
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_system_config_key UNIQUE (config_key)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tb_system_config IS '系统全局配置表';
|
||||
|
||||
-- 初始化数据
|
||||
INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES
|
||||
-- C端支付限制(需求9)
|
||||
('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式(alipay/wechat/wallet)'),
|
||||
('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式');
|
||||
```
|
||||
|
||||
需求02不写入全局默认实名策略。H5 始终读取卡或设备自身的 `realname_policy`;新建资产使用模型默认 `after_order`,避免全局 Key 与资产字段产生两套优先级。
|
||||
|
||||
---
|
||||
|
||||
## 三、Model
|
||||
|
||||
```go
|
||||
// internal/model/system_config.go
|
||||
|
||||
// SystemConfig 系统全局配置模型
|
||||
type SystemConfig struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
ConfigKey string `gorm:"column:config_key;uniqueIndex;not null" json:"config_key"`
|
||||
ConfigValue string `gorm:"column:config_value;type:text;not null;default:''" json:"config_value"`
|
||||
ValueType string `gorm:"column:value_type;type:varchar(20);not null;default:'string'" json:"value_type"`
|
||||
Module string `gorm:"column:module;type:varchar(50);not null;default:'general';index" json:"module"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
IsReadonly bool `gorm:"column:is_readonly;not null;default:false" json:"is_readonly"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0" json:"updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (SystemConfig) TableName() string { return "tb_system_config" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Store
|
||||
|
||||
```go
|
||||
// internal/store/postgres/system_config_store.go
|
||||
|
||||
type SystemConfigStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s *SystemConfigStore) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
|
||||
func (s *SystemConfigStore) GetByModule(ctx context.Context, module string) ([]model.SystemConfig, error)
|
||||
func (s *SystemConfigStore) UpdateValue(ctx context.Context, key string, value string, updaterID uint) error
|
||||
func (s *SystemConfigStore) BatchGet(ctx context.Context, keys []string) (map[string]*model.SystemConfig, error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、配置读取辅助包
|
||||
|
||||
业务代码不直接操作 Store,通过辅助函数读取,带 Redis 缓存(5分钟TTL):
|
||||
|
||||
```go
|
||||
// pkg/sysconfig/config.go
|
||||
|
||||
// GetString 读取字符串配置,返回默认值
|
||||
func GetString(ctx context.Context, key string, defaultVal string) string
|
||||
|
||||
// GetStringSlice 读取 JSON 数组配置
|
||||
func GetStringSlice(ctx context.Context, key string) ([]string, error)
|
||||
|
||||
// GetBool 读取布尔配置
|
||||
func GetBool(ctx context.Context, key string, defaultVal bool) bool
|
||||
|
||||
// InvalidateCache 更新配置后清缓存(在 UpdateValue 后调用)
|
||||
func InvalidateCache(ctx context.Context, key string)
|
||||
```
|
||||
|
||||
Redis Key:`sys:config:{config_key}`,TTL 5分钟。
|
||||
|
||||
业务代码用法:
|
||||
```go
|
||||
// 读取卡的允许支付方式
|
||||
allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods")
|
||||
// 返回 ["alipay","wallet"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、API 设计
|
||||
|
||||
### 6.1 获取配置列表
|
||||
|
||||
```
|
||||
GET /api/admin/system/config?module=c2b.payment
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"config_key": "c2b.payment.card_allowed_methods",
|
||||
"config_value": "[\"alipay\",\"wallet\"]",
|
||||
"value_type": "json",
|
||||
"description": "卡资产C端允许的支付方式",
|
||||
"is_readonly": false,
|
||||
"updated_at": "2026-07-11T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 更新配置
|
||||
|
||||
```
|
||||
PUT /api/admin/system/config/{config_key}
|
||||
```
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"config_value": "[\"alipay\",\"wallet\",\"wechat\"]"
|
||||
}
|
||||
```
|
||||
|
||||
权限:仅平台超管可操作。
|
||||
|
||||
### DTO
|
||||
|
||||
```go
|
||||
// internal/model/dto/system_config_dto.go
|
||||
|
||||
// SystemConfigListRequest 配置列表查询请求
|
||||
type SystemConfigListRequest struct {
|
||||
Module string `query:"module" description:"按模块过滤(可选)"`
|
||||
}
|
||||
|
||||
// SystemConfigItem 配置项响应
|
||||
type SystemConfigItem struct {
|
||||
ConfigKey string `json:"config_key"`
|
||||
ConfigValue string `json:"config_value"`
|
||||
ValueType string `json:"value_type" description:"值类型 (string/int/bool/json)"`
|
||||
Module string `json:"module"`
|
||||
Description string `json:"description"`
|
||||
IsReadonly bool `json:"is_readonly"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UpdateSystemConfigRequest 更新配置请求
|
||||
type UpdateSystemConfigRequest struct {
|
||||
ConfigValue string `json:"config_value" validate:"required" description:"新配置值"`
|
||||
}
|
||||
```
|
||||
|
||||
更新接口不能只校验 `value_type`。Application 需要按 `config_key` 注册允许值,例如支付方式只能来自 `alipay/wechat/wallet`,实名策略只能来自 `none/before_order/after_order`。未知 key 默认只读,禁止通过通用页面写入任意系统配置。
|
||||
|
||||
---
|
||||
|
||||
## 七、前端对接
|
||||
|
||||
### 页面:系统设置 > 系统配置
|
||||
|
||||
**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。**
|
||||
|
||||
调用流程:
|
||||
1. 进入页面 → `GET /api/admin/system/config`(不传 module = 返回全部)
|
||||
2. 按 module 分组展示,`is_readonly=true` 的配置只读
|
||||
3. 修改某项 → `PUT /api/admin/system/config/{config_key}`,body: `{config_value}`
|
||||
4. 更新成功后后端立即删除该 key 的缓存,前端提示“配置已更新”并刷新当前值
|
||||
|
||||
**C端支付限制配置展示建议**(针对 `c2b.payment` 模块):
|
||||
|
||||
不要让后台用户手动填 JSON,前端渲染成 CheckboxGroup:
|
||||
|
||||
```
|
||||
卡资产允许支付方式:
|
||||
☑ 支付宝 ☑ 钱包 ☐ 微信
|
||||
|
||||
设备允许支付方式:
|
||||
☐ 支付宝 ☑ 钱包 ☑ 微信
|
||||
```
|
||||
|
||||
前端把选中项序列化成 `["alipay","wallet"]` 提交。
|
||||
|
||||
通用 Key-Value 页面只作为管理壳层,已知业务配置必须使用受控组件(单选、复选或开关),不向运营人员暴露 JSON 文本框。
|
||||
Reference in New Issue
Block a user