Files
junhong_cmp_fiber/docs/7月迭代/独立方案/原需求/需求17-信用额度.md
2026-07-17 16:39:41 +08:00

350 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 需求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. 系统开放后配置客户角色的新建默认额度;已有店铺需要授信时仍由管理员在店铺资金页面逐个设置。
尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。