350 lines
12 KiB
Markdown
350 lines
12 KiB
Markdown
# 需求17:信用额度
|
||
|
||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||
> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query
|
||
> 关联需求:BPO-009~012、需求19批量订购
|
||
|
||
---
|
||
|
||
## 一、评审结论
|
||
|
||
代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。
|
||
|
||
平台员工信用额度不实施,原因如下:
|
||
|
||
- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。
|
||
- 客户角色可以保存“新建店铺默认额度”模板,但角色本身不持有余额和债务;实际额度仍写入代理主钱包。
|
||
- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。
|
||
- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。
|
||
- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。
|
||
|
||
因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。
|
||
|
||
---
|
||
|
||
## 二、已确认范围:代理主钱包授信
|
||
|
||
### 2.1 业务规则
|
||
|
||
- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。
|
||
- 可用金额:`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.2 角色默认与店铺实际额度
|
||
|
||
- 客户角色可以配置 `default_credit_enabled` 和 `default_credit_limit`,作为以后新建店铺的默认值。
|
||
- 新建店铺时读取请求中的 `default_role_id`,将该角色当时的默认配置复制到新建主钱包。
|
||
- 修改角色默认值不更新任何已有店铺,也不批量扫描钱包。
|
||
- 已有店铺通过独立资金接口直接修改实际额度;店铺后续角色变化不影响钱包。
|
||
- 关闭开关时额度必须为 0。
|
||
- 打开开关时额度必须大于 0。
|
||
- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`。
|
||
|
||
---
|
||
|
||
## 三、数据库变更
|
||
|
||
角色字段只是新建店铺模板,不参与运行时扣款。创建店铺后,最终权威数据只读取代理主钱包;修改角色默认值不会产生角色与钱包双写同步。
|
||
|
||
```sql
|
||
ALTER TABLE tb_agent_wallet
|
||
ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0;
|
||
|
||
ALTER TABLE tb_role
|
||
ADD COLUMN default_credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||
ADD COLUMN default_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 '信用额度上限(分),仅主钱包有效';
|
||
COMMENT ON COLUMN tb_role.default_credit_enabled IS '新建代理店铺是否默认启用信用额度,仅客户角色有效';
|
||
COMMENT ON COLUMN tb_role.default_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
|
||
);
|
||
```
|
||
|
||
必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。
|
||
|
||
---
|
||
|
||
## 四、领域模型
|
||
|
||
```go
|
||
// AgentWallet 代理主钱包聚合根
|
||
type AgentWallet struct {
|
||
ID uint
|
||
WalletType string
|
||
Balance int64
|
||
FrozenBalance int64
|
||
CreditEnabled bool
|
||
CreditLimit int64
|
||
Version int64
|
||
}
|
||
|
||
// AvailableBalance 返回当前可用金额。
|
||
func (w *AgentWallet) AvailableBalance() int64 {
|
||
credit := int64(0)
|
||
if w.CreditEnabled {
|
||
credit = w.CreditLimit
|
||
}
|
||
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
|
||
Handler
|
||
→ UpdateRoleDefaultCreditUseCase
|
||
→ 校验角色为客户角色
|
||
→ 校验开关和额度组合
|
||
→ 只更新 tb_role 默认模板
|
||
→ 写角色配置审计
|
||
→ 不查询、不更新已有店铺钱包
|
||
```
|
||
|
||
### 5.3 钱包扣款
|
||
|
||
所有现有主钱包扣款语句必须从:
|
||
|
||
```text
|
||
balance - frozen_balance >= amount
|
||
```
|
||
|
||
统一改为:
|
||
|
||
```text
|
||
balance - frozen_balance +
|
||
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount
|
||
```
|
||
|
||
扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。
|
||
|
||
### 5.4 受影响用例
|
||
|
||
- 后台订单和批量订购的代理钱包支付。
|
||
- C端/代理端使用代理主钱包的订单支付。
|
||
- 钱包冻结与解冻。
|
||
- 退款回充和员工线下代充值。
|
||
- 资金概况、钱包详情和导出 Query。
|
||
|
||
实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。
|
||
|
||
---
|
||
|
||
## 六、查询方案
|
||
|
||
资金概况、钱包详情、列表和导出使用 Query 直接读取:
|
||
|
||
```sql
|
||
balance - frozen_balance +
|
||
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance
|
||
```
|
||
|
||
响应统一增加:
|
||
|
||
```go
|
||
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 设计
|
||
|
||
### 7.1 角色默认额度
|
||
|
||
```text
|
||
PUT /api/admin/roles/{id}/default-credit
|
||
```
|
||
|
||
```go
|
||
type UpdateRoleDefaultCreditRequest struct {
|
||
EnableCredit bool `json:"enable_credit" description:"新建店铺是否默认开启信用额度"`
|
||
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新建店铺默认信用额度(分)"`
|
||
}
|
||
```
|
||
|
||
现有 `POST /api/admin/shops` 继续使用 `default_role_id`。Application 在创建店铺和主钱包的同一事务中读取角色默认值并复制到钱包;角色模板之后变化不影响该钱包。
|
||
|
||
### 7.2 修改信用额度
|
||
|
||
```text
|
||
PUT /api/admin/shops/{id}/credit-limit
|
||
```
|
||
|
||
```go
|
||
type UpdateCreditLimitRequest struct {
|
||
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
|
||
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"`
|
||
Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"`
|
||
}
|
||
```
|
||
|
||
### 7.3 查询展示
|
||
|
||
现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。
|
||
|
||
---
|
||
|
||
## 八、前端技术方案
|
||
|
||
### 8.1 角色管理
|
||
|
||
```text
|
||
新建代理默认信用额度
|
||
[开关] 默认允许使用信用额度
|
||
默认授信上限 [金额输入,单位元]
|
||
提示:修改后只影响以后新建的代理,不影响已有店铺
|
||
```
|
||
|
||
- 开关关闭时清空输入并提交 `credit_limit=0`。
|
||
- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。
|
||
- 仅客户角色展示该配置;平台角色不展示。
|
||
|
||
### 8.2 店铺资金概况和详情
|
||
|
||
```text
|
||
账面余额:-¥200.00
|
||
冻结金额:¥0.00
|
||
信用额度:¥1,000.00
|
||
可用金额:¥800.00
|
||
```
|
||
|
||
- `balance < 0` 时账面余额显示欠款样式。
|
||
- 可用金额以接口值为准,不在前端自行重复计算。
|
||
- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。
|
||
- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。
|
||
- 修改店铺角色、账号角色或角色默认额度都不能覆盖已有钱包额度。
|
||
- 编辑代理基础资料不自动修改信用额度;信用调整使用独立权限和独立接口。
|
||
|
||
平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。
|
||
|
||
---
|
||
|
||
## 九、审计与可观测性
|
||
|
||
每次信用配置变更记录:
|
||
|
||
- 操作人、店铺、钱包 ID。
|
||
- 变更前后开关、额度、余额、冻结金额和版本。
|
||
- `request_id`、IP、设备信息和时间。
|
||
|
||
关键日志:
|
||
|
||
- 扣款因信用额度不足被拒绝。
|
||
- 钱包 version 冲突。
|
||
- 数据库信用边界约束失败。
|
||
- 信用额度调整失败和旧值/新值。
|
||
|
||
资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。
|
||
|
||
---
|
||
|
||
## 十、发布与回滚
|
||
|
||
本需求随七月迭代停机发布:
|
||
|
||
1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加钱包信用字段和角色默认模板字段,历史钱包默认关闭。
|
||
2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。
|
||
3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。
|
||
4. 系统开放后配置客户角色的新建默认额度;已有店铺需要授信时仍由管理员在店铺资金页面逐个设置。
|
||
|
||
尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。
|