迭代计划准备

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,264 +1,327 @@
# 需求17信用额度
> DDD 结构:`internal/domain/wallet/`
> 状态:待评审
> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query
> 关联需求BPO-009~012、需求19批量订购
---
## 业务规则
## 一、评审结论
### 两类主体
代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。
| 主体 | 信用额度来源 | 修改条件 |
|------|------------|---------|
| 代理(店铺) | 创建店铺时设置 `credit_limit` | 修改前必须先清零欠款(`balance >= 0`|
| 平台员工 | 基于**角色**,不是账号 | 角色权限表里配置 |
平台员工信用额度不实施,原因如下:
### 可用余额公式
- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。
- 角色表达权限范围,不表达资产、余额和债务,给角色配置资金会混淆 RBAC 与财务账本。
- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。
- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。
- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。
```
可用额度 = balance + credit_limit - frozen_balance
```
- `balance` 可以为负(表示欠款)
- `balance < 0` 时表示已在使用信用额度
- `balance < -credit_limit` 表示超额使用(理论上不可达,系统保护)
### BPO-009 "是否可授权额度"开关
- 代理创建时,新增 `enable_credit` 开关
- 开关关闭 = `credit_limit` 强制为 0不允许使用信用额度
- 平台员工账号通过角色自动拥有额度,无需开关
### BPO-012 负余额显示
- 代理前台AgentWallet`balance` 正常展示(可为负)
- 前端不做"不能为负"的前端校验,由后端控制
因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。
---
## 数据库变更
## 二、已确认范围:代理主钱包授信
### 1. Shop 表加信用额度字段
### 2.1 业务规则
```sql
ALTER TABLE tb_shop
ADD COLUMN enable_credit BOOLEAN NOT NULL DEFAULT FALSE
COMMENT '是否可使用信用额度',
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
COMMENT '授信额度上限(分)';
- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。
- 可用金额:`balance - frozen_balance + effective_credit_limit`
- `credit_enabled=false` 时,`effective_credit_limit=0`
- 余额可以为负,最低不能小于 `-(credit_limit - frozen_balance)`
- 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。
- 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。
- 金额统一使用分,禁止浮点数入库。
```mermaid
flowchart TD
Debit[请求扣款] --> Lock[按钱包ID和version加载]
Lock --> Calc[计算 balance - frozen + effective_credit]
Calc --> Enough{可用金额足够?}
Enough -->|否| Reject[拒绝:可用余额不足]
Enough -->|是| Update[条件更新余额和version]
Update --> Tx[同事务写资金流水]
Tx --> Success[返回扣款后余额]
```
### 2. AgentWallet 表加信用额度字段
### 2.2 信用开关
- 创建代理时可以提交 `enable_credit``credit_limit`
- 关闭开关时额度必须为 0。
- 打开开关时额度必须大于 0。
- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`
---
## 三、数据库变更
信用额度属于钱包,不在 `tb_shop``tb_agent_wallet` 各保存一份,避免双写失真。创建/编辑代理接口可以接收信用参数,但最终权威数据写入代理主钱包。
```sql
ALTER TABLE tb_agent_wallet
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
COMMENT '信用额度(分),与 shop.credit_limit 同步,余额可到 -credit_limit';
ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效';
COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效';
ALTER TABLE tb_agent_wallet
DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance;
ALTER TABLE tb_agent_wallet
ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative
CHECK (frozen_balance >= 0),
ADD CONSTRAINT chk_agent_wallet_credit_nonnegative
CHECK (credit_limit >= 0),
ADD CONSTRAINT chk_agent_wallet_available_nonnegative
CHECK (
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0
);
```
> 钱包上冗余一份,是为了避免每次扣款都要 join shop 表。通过 Shop 修改额度时同步更新钱包
### 3. 角色信用额度配置(平台员工)
```sql
ALTER TABLE tb_role
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
COMMENT '该角色的信用额度(分),仅对平台员工角色有效';
```
必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变
---
## Model 变更
## 四、领域模型
```go
// internal/model/shop.go
type Shop struct {
// ...原有字段...
EnableCredit bool `gorm:"column:enable_credit;not null;default:false;comment:是否可使用信用额度" json:"enable_credit"`
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:授信额度上限(分)" json:"credit_limit"`
}
// internal/model/agent_wallet.go
// AgentWallet 代理主钱包聚合根
type AgentWallet struct {
// ...原有字段Balance, FrozenBalance 已存在)...
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:信用额度(分)" json:"credit_limit"`
ID uint
WalletType string
Balance int64
FrozenBalance int64
CreditEnabled bool
CreditLimit int64
Version int64
}
// GetAvailableBalance 更新:可用余额 = 余额 - 冻结 + 信用额度
func (w *AgentWallet) GetAvailableBalance() int64 {
return w.Balance - w.FrozenBalance + w.CreditLimit
}
```
---
## 领域层DDD
```go
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根(扩展现有模型)
type AgentWalletAggregate struct {
model.AgentWallet
domainEvents []DomainEvent `gorm:"-"`
}
// Debit 扣款(含信用额度)
func (w *AgentWalletAggregate) Debit(amountCents int64, refType string, refID uint) error {
available := w.Balance - w.FrozenBalance + w.CreditLimit
if available < amountCents {
return ErrInsufficientBalance
// AvailableBalance 返回当前可用金额。
func (w *AgentWallet) AvailableBalance() int64 {
credit := int64(0)
if w.CreditEnabled {
credit = w.CreditLimit
}
before := w.Balance
w.Balance -= amountCents
w.recordEvent(WalletDebitedEvent{
WalletID: w.ID,
ShopID: w.ShopID,
Amount: amountCents,
BalanceBefore: before,
BalanceAfter: w.Balance,
RefType: refType,
RefID: refID,
})
return w.Balance - w.FrozenBalance + credit
}
// Debit 执行钱包扣款并维护信用边界。
func (w *AgentWallet) Debit(amount int64) error {
if amount <= 0 {
return ErrInvalidAmount
}
if w.AvailableBalance() < amount {
return ErrInsufficientAvailableBalance
}
w.Balance -= amount
return nil
}
// ChangeCredit 修改信用配置。
func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error {
if limit < 0 || (!enabled && limit != 0) || (enabled && limit == 0) {
return ErrInvalidCreditConfig
}
nextCredit := int64(0)
if enabled {
nextCredit = limit
}
if w.Balance-w.FrozenBalance+nextCredit < 0 {
return ErrCreditLimitBelowDebt
}
w.CreditEnabled = enabled
w.CreditLimit = limit
return nil
}
```
领域层只维护资金不变量,不查询角色、店铺名称或页面权限。角色能否授信由 Application 在调用聚合前校验。
---
## 应用层
## 五、应用用例与持久化
### 5.1 修改代理信用额度
```text
Handler
→ ChangeShopCreditUseCase
→ 校验操作人和代理数据权限
→ 加载代理主钱包
→ 调用 wallet.ChangeCredit()
→ 按 version 条件更新钱包
→ 写操作日志和信用变更流水
```
条件更新示例:
```sql
UPDATE tb_agent_wallet
SET credit_enabled = ?,
credit_limit = ?,
version = version + 1,
updated_at = NOW()
WHERE id = ?
AND wallet_type = 'main'
AND version = ?
AND balance - frozen_balance +
CASE WHEN ? THEN ? ELSE 0 END >= 0;
```
受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。
### 5.2 钱包扣款
所有现有主钱包扣款语句必须从:
```text
balance - frozen_balance >= amount
```
统一改为:
```text
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount
```
扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。
### 5.3 受影响用例
- 后台订单和批量订购的代理钱包支付。
- C端/代理端使用代理主钱包的订单支付。
- 钱包冻结与解冻。
- 退款回充和员工线下代充值。
- 资金概况、钱包详情和导出 Query。
实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。
---
## 六、查询方案
资金概况、钱包详情、列表和导出使用 Query 直接读取:
```sql
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance
```
响应统一增加:
```go
// internal/application/wallet/grant_credit.go
// GrantCreditCommand 授信命令
type GrantCreditCommand struct {
ShopID uint
CreditLimit int64 // 分
OperatorID uint
}
// GrantCreditUseCase 设置代理信用额度
type GrantCreditUseCase struct {
shopStore ShopStore
walletStore WalletStore
txManager TxManager
}
func (uc *GrantCreditUseCase) Execute(ctx context.Context, cmd GrantCreditCommand) error {
return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error {
shop, err := uc.shopStore.GetByID(ctx, cmd.ShopID)
if err != nil { return err }
// 修改信用额度前,必须当前余额 >= 0无欠款
wallet, err := uc.walletStore.GetMainWallet(ctx, cmd.ShopID)
if err != nil { return err }
if wallet.Balance < 0 {
return errors.New(errors.CodeForbidden, "存在欠款,请先清零后再修改信用额度")
}
// 同步更新 shop 和 wallet
if err := uc.shopStore.UpdateCreditLimit(ctx, tx, cmd.ShopID, cmd.CreditLimit); err != nil {
return err
}
return uc.walletStore.UpdateCreditLimit(ctx, tx, wallet.ID, cmd.CreditLimit)
})
}
CreditEnabled bool `json:"credit_enabled" description:"是否启用信用额度"`
CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用金额(分)"`
IsInDebt bool `json:"is_in_debt" description:"余额是否为负"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"`
```
`debt_amount = max(-balance, 0)`,不包含冻结金额。
---
## API 设计
## 七、API 设计
### 1. 创建店铺(新增字段)
### 7.1 创建代理
```
POST /admin/shops
现有:
```text
POST /api/admin/shops
```
新增参数:
```go
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
CreditLimit int64 `json:"credit_limit" description:"信额度(分)enable_credit=true 时有效"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信额度(分)"`
```
### 2. 修改信用额度
Application 创建店铺和主钱包时,将信用配置写入钱包;任何一步失败整笔事务回滚。
```
PUT /admin/shops/{id}/credit-limit
### 7.2 修改信用额度
```text
PUT /api/admin/shops/{id}/credit-limit
```
```go
type UpdateCreditLimitRequest struct {
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新授信额度(分),修改前需余额>=0"`
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"`
Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"`
}
```
错误场景:余额为负时,返回 `CodeForbidden`msg="存在欠款(-XX元请先清零后再修改信用额度"
### 7.3 查询展示
### 3. 角色信用额度配置(平台员工)
```
PUT /admin/roles/{id}/credit-limit
```
```go
type UpdateRoleCreditLimitRequest struct {
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"该角色的信用额度(分)"`
}
```
### 4. 查询钱包(余额展示更新)
现有 `GET /admin/agent-wallets/{shopID}` 响应新增字段:
```go
type AgentWalletResponse struct {
// ...原有字段...
CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用余额 = balance - frozen + credit_limit可为负"`
IsInDebt bool `json:"is_in_debt" description:"是否有欠款balance < 0"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额balance<0时有值"`
}
```
现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。
---
## 前端对接
## 八、前端技术方案
### 页面:创建/编辑代理
### 8.1 创建/编辑代理
新增"信用额度"区块:
```
信用额度
[☑ 开启信用额度]
授信上限:[____] 元 enable_credit=true 时展示)
```text
信用额度
[开关] 允许使用信用额度
授信上限 [金额输入,单位元]
```
提交时:`enable_credit: true, credit_limit: 100000`(单位:分,前端转换)
- 开关关闭时清空输入并提交 `credit_limit=0`
- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。
- 编辑代理基础资料不自动覆盖信用额度;信用调整使用独立权限和独立接口。
### 页面:代理详情 > 钱包信息
### 8.2 资金概况和详情
```
钱包余额:-¥200.00 (红色显示)
```text
账面余额:-¥200.00
冻结金额¥0.00
信用额度¥1,000.00
可用¥800.00
可用¥800.00
```
`is_in_debt=true` 时,余额展示红色,并加"欠款"标签
- `balance < 0` 时账面余额显示欠款样式
- 可用金额以接口值为准,不在前端自行重复计算。
- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。
- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。
### 页面:修改信用额度
平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。
入口:代理详情 > 钱包 > "修改信用额度"按钮
弹框:输入新额度 → `PUT /admin/shops/{id}/credit-limit`
---
如果当前有欠款(`is_in_debt=true`),禁用该按钮,展示"存在欠款,无法修改信用额度"。
## 九、审计与可观测性
### 页面:余额显示(全局)
每次信用配置变更记录:
代理管理列表的余额列:支持显示负数,负数红色标注
- 操作人、店铺、钱包 ID
- 变更前后开关、额度、余额、冻结金额和版本。
- `request_id`、IP、设备信息和时间。
### 平台员工余额(角色信用)
关键日志:
平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。
在批量订购确认页展示可用额度。
- 扣款因信用额度不足被拒绝。
- 钱包 version 冲突。
- 数据库信用边界约束失败。
- 信用额度调整失败和旧值/新值。
资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。
---
## 十、发布与回滚
本需求随七月迭代停机发布:
1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加信用字段,历史数据默认关闭。
2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。
3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。
4. 系统开放后再由有权限的管理员对指定代理逐个开启额度。
尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。