13 KiB
PRD:UR#38 代理主钱包信用额度与角色默认模板
Status: ready-for-agent
Problem Statement
当前代理主钱包只有账面余额、冻结金额和版本,可用金额固定为 balance - frozen_balance,数据库还通过历史约束禁止余额为负、禁止冻结金额超过余额。系统因此无法表达平台给代理授信后允许在额度内继续支付的业务规则。
角色和店铺之间也需要区分两类不同事实:客户角色只能为未来新建代理提供默认信用模板;某个既有代理真正可使用的信用额度必须固化在该代理主钱包中。若运行时继续关联角色,角色调整、店铺增减角色或多角色组合都会追溯改变已有资金边界。
当前创建店铺会依次创建店铺、初始账号、角色关系和主/佣金钱包,但这些步骤并未处在同一 PostgreSQL 事务中。信用模板复制若直接追加在现有流程里,失败时可能形成半建店铺或没有正确信用快照的钱包。
权限上,代理虽然可以管理自己或部分下级店铺,但信用额度代表平台授信,不能由代理自行或相互调整;普通店铺管理权限也不能隐式授予额度修改能力。
Solution
信用额度只落在代理主钱包。主钱包以统一公式计算总可用金额,允许账面余额在信用边界内为负;佣金钱包、资产钱包和平台员工不获得信用额度。
客户角色保存“新建代理默认信用模板”。创建店铺时读取请求中唯一的 default_role_id,在同一事务内把当时模板复制到新建主钱包。模板之后修改、店铺角色之后变化都不影响任何既有钱包。
既有店铺通过独立信用额度接口修改实际主钱包额度。代理账号无论店铺层级或数据范围如何都不能调用;只有超级管理员,或拥有独立信用额度管理权限的平台账号可以修改。变更使用钱包 version 乐观锁,并在同一事务内校验变更后的总可用金额不能为负。
所有会改变主钱包余额或冻结金额的完整用例统一进入 Wallet Domain/Application,维护相同不变量、版本、资金流水和可靠事件;资金概况等只读接口走 Query。
User Stories
- 作为角色管理员,我希望设置客户角色的新建代理默认信用,但明确知道它不会影响已有代理。
- 作为有授信权限的平台员工,我希望在代理资金页查看并调整其实际信用额度,并在额度不足以覆盖当前资金占用时得到明确拒绝。
- 作为代理,我可以查看权限范围内的资金概况,但不能给自己或下级代理调整信用额度。
- 作为代理主钱包使用方,我希望订单扣款、批量订购、冻结、充值和退款都使用同一个可用金额口径。
- 作为财务或审计人员,我希望区分账面余额、冻结金额、信用额度、总可用金额和实际欠款,并追溯每次授信变更。
Implementation Decisions
领域语言与资金不变量
-
账面余额
balance:已经入账的代理主钱包余额,允许在信用额度内小于零。 -
冻结金额
frozen_balance:已经预占但尚未最终扣除的资金,始终大于等于零。 -
有效信用额度:
effective_credit = credit_enabled ? credit_limit : 0 -
现金可用金额:
cash_available = balance - frozen_balance -
总可用金额,也是所有扣款、冻结和调额共同维护的边界:
available_balance = balance - frozen_balance + effective_credit available_balance >= 0 -
欠款只表示已经形成的负账面余额:
is_in_debt = balance < 0 debt_amount = max(-balance, 0)冻结金额会降低现金及总可用金额,但尚未最终扣除时不直接记为欠款。
-
credit_enabled=false时credit_limit必须为 0;credit_enabled=true时credit_limit必须大于 0。 -
信用额度不设置产品层固定上限。接口和数据库仍使用分为单位的
int64,任何负数、超出整数范围或计算balance - frozen_balance + effective_credit时发生溢出的请求都必须拒绝。 -
信用额度只属于
wallet_type=main的代理钱包。佣金钱包、资产钱包、平台员工和客户角色本身都不是授信或负债主体。
角色默认模板与新建店铺
tb_role增加default_credit_enabled、default_credit_limit,仅客户角色可以保存有效模板;平台角色固定关闭且额度为 0。- 当前
POST /api/admin/shops已要求唯一default_role_id,不存在从多个角色中选择信用来源的歧义。 - 创建店铺时在事务内重新读取并校验该客户角色,将模板当时的值复制到新建代理主钱包;请求方不能在创建店铺请求中另外覆盖信用字段。
- 店铺、初始账号、账号角色、店铺角色、主钱包、佣金钱包和信用模板复制必须处于同一个 PostgreSQL 事务;任一步失败全部回滚。
- 修改角色模板只影响之后创建的店铺,不扫描、不更新既有钱包。店铺后续增加、删除或更换角色也不改变钱包信用额度。
- 角色模板变更是简单写 Application 事务脚本,并记录全局 Audit Event;它不是钱包资金流水。
实际额度权限与 API
修改角色默认模板
PUT /api/admin/roles/{id}/default-credit
请求:
{
"credit_enabled": true,
"credit_limit": 100000
}
- 只允许超级管理员,或具备相应角色管理权限的平台账号操作客户角色。
- 代理账号不能配置角色信用模板。
- 更新模板与 Audit Event 在同一事务内完成;审计失败则业务更新回滚。
修改店铺实际额度
PUT /api/admin/shops/{id}/credit-limit
请求:
{
"credit_enabled": true,
"credit_limit": 100000,
"version": 3
}
- 代理账号无论操作目标是自己、直属下级还是更深层下级,都一律禁止。不得只调用现有
CanManageShop后放行。 - 超级管理员可以修改;普通平台账号必须同时拥有独立信用额度管理权限,并满足目标店铺的既有查看/数据范围约束。
version必须与主钱包当前版本一致。更新条件同时约束wallet_type=main、当前版本和新总可用金额不小于零;成功后版本加一。- 受影响行数为零时重新读取,用统一错误区分钱包已经变化的并发冲突、目标无权/不存在和新额度不足以覆盖当前资金占用。并发冲突不能覆盖别人的新值。
- 调额只改变资金边界,不创建金额为零的伪钱包交易流水;变更前后开关、额度、余额、冻结金额、版本、操作人和请求上下文写入全局 Audit Event。
钱包复杂写用例
tb_agent_wallet增加credit_enabled、credit_limit;历史主钱包和佣金钱包均迁移为关闭/0,既有行为不变。- 移除或替换历史
balance >= 0、frozen_balance <= balance约束。新数据库约束至少保证冻结金额和信用额度非负、开关与额度组合一致,以及主钱包总可用金额不小于零;佣金钱包仍保持无信用和既有非负边界。 - 迁移前必须查询开发及生产目标库的真实约束名和异常数据,不能把归档迁移中的约束名当成当前数据库事实。
- 所有被本需求触碰的主钱包复杂写完整迁入统一 Wallet Domain/Application:扣款、冻结、解冻、充值入账、退款回充、人工调整以及批量订购钱包支付。每个用例都在同一事务维护钱包版本、必要的业务单状态、真实金额钱包流水、Outbox/Audit Event。
- 不允许只修改模型的
GetAvailableBalance()而保留 Store 中旧的现金条件;所有条件更新必须使用同一有效信用公式。 - 金额写入使用行锁或
version条件更新防止并发超额。失败重试不得重复扣款、冻结、充值或退款,继续复用各业务单号/状态条件的幂等边界。 - UR#97 低余额预警继续只使用
cash_available = balance - frozen_balance,信用额度不得掩盖现金余额风险。
资金概况 Query 与前端
GET /api/admin/shops/fund-summary
- 沿用当前列表/详情所需的分页、店铺筛选和数据权限,不新增绕过现有范围的全量资金接口。
- 对每个代理返回至少:
shop_id、balance、frozen_balance、cash_available_balance、credit_enabled、credit_limit、available_balance、is_in_debt、debt_amount、version。 - 所有金额为分,前端显示为元;前端直接展示接口计算结果,不自行重算可用金额或欠款。
- 角色配置页只对客户角色展示“新建代理默认信用”,并提示“修改后不会影响已有店铺”。
- 店铺资金页按现有查看权限展示资金概况。代理账号和没有独立信用额度管理权限的平台账号不展示调额按钮,后端仍必须独立拒绝越权请求。
- 调额弹框展示当前余额、冻结金额、实际额度和服务端返回的当前总可用金额。前端可以做金额输入预览,但最终结果以后端为准;并发冲突后重新拉取最新资金概况和版本。
- 关闭信用时前端提交额度 0;开启时额度必须大于 0。不设置产品层固定最大额度提示,只做分/元精确转换和接口安全范围校验。
架构、审计与发布
- 代理主钱包扣款、冻结、解冻、充值、退款回充和调额属于复杂写:
Handler → Application UseCase → Wallet Domain → Repository/Infrastructure。 - 角色默认模板属于简单写 Application 事务脚本;资金概况、列表、统计和导出属于 Query,不经过聚合根。
- 只迁移本需求实际触碰的代理主钱包完整用例,不主动改造佣金钱包和资产钱包。
- 停机发布数据库约束/字段、所有主钱包写入端、查询/API 和前端,禁止只上线展示或部分扣款路径。
- 开放普通访问前,保持历史钱包信用关闭,验证普通现金支付、信用扣款、冻结、回充、调额和并发场景。之后再由授权平台人员配置角色模板和既有代理额度。
- 尚未启用信用且未产生负余额时可以回滚应用和可逆字段;一旦出现负余额,不得回滚到不理解信用额度的旧写入逻辑,也不得删除字段或强制关闭信用,必须先清偿欠款或继续保留新资金逻辑。
Testing Decisions
- 领域测试覆盖关闭/0、开启/正数、负额度、开启/0、关闭/非0、普通现金、使用部分信用、用尽信用、超过信用以及每个算术溢出边界。
- 调额测试覆盖提高额度、降低但仍可覆盖、降低后总可用小于0、欠款时关闭、冻结占用时降低、正确版本和并发旧版本。
- 权限测试覆盖超级管理员、具备独立权限的平台账号、无独立权限的平台账号、代理本人、直属下级和更深下级;证明
CanManageShop不能单独授予调额权。 - 角色模板测试覆盖客户/平台角色、开关组合、模板变更不级联、店铺后续角色变化不级联,以及创建店铺读取模板时与并发模板修改的事务快照语义。
- 创建店铺集成测试验证店铺、账号、角色关系、主/佣金钱包与信用快照全成全败,任一写入失败不会留下半成品。
- 对扣款、冻结、解冻、充值、退款回充、人工调整和批量订购逐一验证统一公式、版本递增、真实金额流水及业务幂等;并发请求不能让总可用金额小于0。
- 资金概况 Query 验证现金余额、冻结金额、信用额度、现金可用、总可用和欠款口径;
balance >= 0但冻结占用信用时不误报为欠款,balance < 0时欠款金额只取负余额绝对值。 - 数据库迁移先在
.env.local指向的开发 PostgreSQL 核验真实约束和历史数据,再验证新 CHECK;相关 HTTP 集成测试同时使用开发 PostgreSQL、Redis 和 Asynq 可控接缝,不在日志或测试产物中输出连接密钥。 - 前端验收覆盖角色提示、代理无调额入口、平台有/无权限、金额精确显示、降低额度失败、版本冲突刷新和停机发布后的历史钱包默认关闭状态。
Out of Scope
- 不给平台员工、角色、佣金钱包或资产钱包建立信用额度。
- 不允许代理给自己或任何下级代理调额。
- 不让角色模板变化、店铺编辑或角色变化自动级联既有钱包。
- 不设置产品层统一或按角色固定的最大授信上限;风险定额由有权限的平台人员逐店铺决定。
- 不建设还款计划、利息、账期、催收、逾期等级或自动调额策略。
- 不把信用额度计入 UR#97 的现金低余额预警。
- 不为调额伪造金额为零的钱包交易流水。
Further Notes
- 当前代码的
AgentWallet.GetAvailableBalance()只返回balance - frozen_balance,多个 Store/Service 还各自维护现金条件;实现时必须盘点并迁移所有被触碰的主钱包写入用例,不能以修改一个辅助函数代替完整收口。 - 当前店铺创建流程的多步写入不在统一事务内。本需求要求在复制角色默认信用模板时一并修复该完整创建用例的原子性,但不借机重构其他店铺查询或无关 CRUD。
- “没有固定业务上限”不代表允许整数溢出或浮点金额;后端和数据库必须保持
int64分单位的技术安全边界。