16 KiB
UR#38 代理主钱包信用额度功能总结
当前完成范围
任务 2.23~2.32 已完成代理主钱包信用额度、订单扣款、资金预占、正向入账、退款回充、资金概况读取与测试环境切换收口:
tb_agent_wallet新增信用开关和分单位信用额度,历史主钱包与分佣钱包默认保持关闭、额度为零。- Wallet Domain 统一定义现金可用金额、有效信用额度、总可用金额、欠款状态和欠款金额,并拒绝非法配置与
int64算术溢出。 - 数据库 CHECK 保证只有主钱包可以启用信用、分佣钱包保持原现金边界、冻结金额与版本非负、总可用金额不为负。
- 迁移运行时读取目标库真实 CHECK 定义;发现历史异常即中止,不静默修正任何钱包金额。
统一资金口径
effective_credit = credit_enabled ? credit_limit : 0
cash_available = balance - frozen_balance
available_balance = cash_available + effective_credit
is_in_debt = balance < 0
debt_amount = max(-balance, 0)
冻结金额只降低现金及总可用金额,不直接形成欠款。信用额度仅属于代理主钱包,不扩展到分佣钱包、资产钱包或平台员工。
发布与回滚
执行迁移前必须保留目标库输出的真实约束定义与异常钱包清单。若已经启用信用、产生负余额或冻结金额超过账面余额,降级迁移会主动拒绝执行;此时必须继续使用理解信用边界的新钱包逻辑,不能删除字段或恢复旧 Writer。
本任务已经切换代理订单创建即支付和待支付订单的代理主钱包扣款入口,并交付统一冻结、释放、冻结资金完成扣除、正向入账和订单退款回充接缝;批量订购仍由 3.24、3.28 使用该接缝逐项实现。旧 AgentWallet Store 写接缝已删除零调用的主钱包扣款/冻结能力,保留能力强制限定为分佣钱包;旧非事务店铺创建实现和主钱包直写清理脚本也已停用。本测试环境里程碑未运行真实 PostgreSQL 或自动化测试,验证状态为“代码完成、验证延期”。
角色默认信用模板
客户角色可通过 PUT /api/admin/roles/{id}/default-credit 配置只作用于未来新建店铺的默认信用模板。接口明确返回 scope=new_shops_only 和 affects_existing_wallets=false;修改模板不会扫描既有店铺、修改既有钱包,也不会随店铺角色增删而级联。
超级管理员按既有规则放行;普通平台账号必须拥有独立权限 role:default-credit:manage;代理及企业账号始终拒绝。平台角色由数据库约束固定为关闭信用、额度为零。Audit Event 按本 Change 的测试环境冻结决策延期至 6.5,当前更新仍保留操作者字段,但不得据此宣称完成正式审计验收。
新建店铺信用快照
店铺创建事务会在事务内重新读取请求中的唯一启用客户角色,并把当时的 default_credit_enabled/default_credit_limit 复制到新主钱包;分佣钱包始终写入关闭/0。店铺、初始主账号、账号角色、店铺角色和两个钱包仍在同一 PostgreSQL 事务内全成全败。创建完成后修改角色模板或店铺角色关系,都不会追溯改变该钱包快照。
既有店铺实际额度调整
PUT /api/admin/shops/{id}/credit-limit 用于调整既有店铺主钱包的实际信用额度。按当前产品决定,后端不校验 shop:credit-limit:manage 或账号类型;该权限编码只供前端决定是否展示按钮,能够看到按钮的账号即可调用。请求携带钱包 version,更新同时约束主钱包类型、版本和调整后的总可用金额,成功后版本加一;降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
统一订单扣款
代理自购、代理为下级代购以及后台代理钱包订购统一调用 Wallet Application 的 DebitInTx。用例按主钱包行锁读取最新状态,以 balance - frozen_balance + effective_credit 校验资金边界,同时保留版本条件更新;现金不足时可在额度内形成负余额,超过总可用金额时整笔事务回滚。
每个订单最多写入一条成功代理主钱包扣款流水,软删除也不能绕过该资金幂等键。钱包订单另保存 SHA-256 幂等指纹,并在事务内通过 PostgreSQL advisory lock 串行同一指纹;即使事务提交后的 Redis 标记写入失败,三分钟窗口内的重试也会返回原订单,不会创建新订单再次扣款或激活。Redis 锁使用随机 owner token 和比较后删除,缓存不可用时失败关闭。
订单、订单明细、钱包版本和余额、真实扣款流水、Payment、套餐处理以及 wallet.agent_main.debited Outbox 事件在同一事务提交,任何一步失败都不会留下已支付订单或部分资金事实。Worker 已注册该事件消费者,并在确认投递前复核权威扣款流水;后续余额预警在这个稳定消费接缝上扩展。流水继续保留自购/代购子类型、关联下级店铺及资产快照。冻结或关闭钱包会拒绝扣款;资产钱包、佣金钱包和订单无关创建流程保持原实现,不在本任务迁移。
迁移 000174 为订单幂等指纹字段和两个索引添加中文数据库备注,并在创建扣款唯一索引前主动扫描包含软删除记录在内的历史重复成功流水;发现异常会中止而不是自动删除资金事实。若已经产生统一扣款 Outbox 事实,降级迁移会拒绝移除防重字段和约束。
统一资金预占
Wallet Application 提供代理主钱包订单资金的冻结、释放和完成扣除能力。冻结只增加 frozen_balance,以总可用金额校验信用边界,不会直接形成欠款;释放只减少冻结金额;完成扣除同时减少账面余额和冻结金额,并创建真实扣款流水及 wallet.agent_main.debited 事件。
tb_agent_wallet_reservation 以订单业务引用唯一记录预占金额、付款钱包和唯一终态。释放与完成只接收订单引用,并从预占事实读取权威钱包与金额,因此代理代购取消不会把买方店铺误当付款钱包。重复冻结、重复释放或重复完成不会二次改变钱包;释放与完成互为排斥终态,钱包行锁、版本条件、预占状态条件、流水和 Outbox 均在调用方事务内维护。
迁移 000175 为预占表、全部字段和索引添加中文数据库备注,不建立外键。升级前若发现无法关联稳定业务引用的历史主钱包冻结金额或历史待支付代理钱包订单会中止;降级时在事务内取得预占表排他锁,存在任何预占事实就拒绝删表,避免检查与删除之间产生新事实。该能力只覆盖代理主钱包订单预占,佣金钱包提现和资产钱包冻结保持原边界。
统一充值与人工调整入账
Wallet Domain 的 Credit 只允许正常代理主钱包执行正金额入账,并使用安全加法拒绝 int64 溢出。入账只增加账面余额和版本,不修改冻结金额或信用额度;钱包原有负余额时会自然表现为欠款减少或清偿。
Wallet Application 的 PostInTx 仅接受 topup/recharge 和 manual_adjustment/adjustment 两组受控业务类型。调用方必须先持久化具有唯一业务键的充值或人工调整业务事实,并以该事实 ID 作为稳定 reference_id;充值单号等可读业务号作为 correlation_id 贯穿事件。用例锁定主钱包并校验可选钱包 ID 归属,以 reference_type + reference_id 查询成功流水幂等,随后在调用方事务内更新余额和版本、写真实金额流水及 wallet.agent_main.credited Outbox。
现有代理充值的线下确认和在线支付回调已改为调用统一入账能力,充值单状态、钱包、流水和 Outbox 全成全败。在线回调在资金写入前校验订单不是线下充值、创建时支付渠道、回调金额和非空第三方交易号;同一渠道的第三方交易号只能绑定一张代理充值单。重复回调或 Worker 重试不会二次入账;旧 Service 不再直接拼接代理主钱包 balance + amount。仓库当前没有独立的人工余额调整 Handler 或业务表,本任务不虚构管理入口,只交付供后续业务事实调用的稳定幂等接缝。支付查单、企微审批和到账通知由 UR#34 后续任务负责,退款回充仍由 2.30 迁移,佣金钱包和资产钱包保持原实现。
迁移 000176 更新代理钱包流水类型的中文数据库备注,扫描历史重复成功入账和重复渠道交易号后创建包含软删除事实的部分唯一索引;若已经产生统一入账 Outbox,降级会拒绝移除防重约束,并在同一事务排他锁定充值单、资金流水和 Outbox 后再删除索引。Worker 已注册入账事件消费者,并在确认投递前复核权威成功流水。
统一订单退款回充
代理钱包订单退款统一调用 Wallet Application 的 RefundInTx。正常订单必须从包含软删除记录的成功 order/deduct 流水读取实际付款主钱包、原扣款金额、代购关联店铺、交易子类型和资产快照;退款金额不得超过原扣款绝对值。信用扣款无需单独分支,退款只增加账面余额并自然减少或清偿欠款,不修改冻结金额和信用额度。
历史订单缺少扣款流水时,退款编排层才按旧订单字段推导付款店铺和代购关联店铺,并优先使用订单实付金额作为退款上限,缺失时兼容总金额。新路径不会查询当前店铺关系来猜测付款方,避免代购关系变化后退错钱包。
钱包行锁、Domain 安全加法、版本条件更新、以退款单 ID 为业务键的唯一成功流水及 wallet.agent_main.refunded Outbox 在退款审批事务内全成全败。重复审批、Worker 重试或并发请求不能重复回充;消费者确认事件前会同时复核退款流水、原扣款流水、金额上限和资产快照。迁移 000177 在创建包含软删除事实的部分唯一索引前扫描重复退款流水,并为涉及字段和索引保留中文数据库备注;降级在同一事务排他锁定资金流水和 Outbox,存在统一退款事实时拒绝移除防重约束。
代理退款调用点已不再直接执行余额加法。个人资产钱包退款、佣金回扣、套餐失效、退款后资产处理、渠道退款和审批终态编排均保持原边界,后续分别由对应任务处理。Audit Event 按测试环境冻结决定延期至 6.5,Domain Ledger 与 Outbox 不延期。
资金概况信用投影
GET /api/admin/shops/fund-summary 延续现有分页、店铺名称、主账号用户名和店铺层级数据范围,并保留 main_balance/main_frozen_balance 兼容字段。响应新增由服务端统一计算的 cash_available_balance、credit_enabled、credit_limit、available_balance、is_in_debt、debt_amount 和 version;前端不得自行重算金额或把读取能力解释为调额权限。
该用例已完整迁到 internal/query/shop,不经过 Wallet 聚合根、不执行写操作。Query 在 Count 和分页前应用店铺与主账号筛选,按 created_at DESC, id DESC 稳定排序,再以固定次数批量投影本页主钱包、佣金钱包、提现汇总和主账号,避免逐店铺查询。现金可用金额固定为账面余额减冻结金额,总可用金额只加启用后的额度,欠款只由负账面余额决定;冻结占用信用但余额非负时不会误报欠款。缺少主钱包的历史异常店铺暂按零值兼容,完整性告警由 UR#97 负责。
企业账号访问代理资金概况会使用资金功能专用提示返回 403;平台和代理仍只读取当前既有店铺数据范围。信用额度不会加入 UR#97 的现金低余额口径。资金概况属于普通受权读取,Audit Event 登记为 N/A;Access Log 和当前数据权限继续生效。
开放接口 GET /api/open/v1/wallet/balance 同步返回 cash_available_balance、credit_enabled、credit_limit、available_balance、is_in_debt、debt_amount 和 version。其中 available_balance 已统一为包含生效信用额度的总可用金额,避免开放接口仍按旧现金口径判断可支付金额。
测试环境停机切换清单
停机前
- 停止代理钱包订单、充值、退款、店铺创建及相关 Worker 新写入。
- 记录
tb_agent_wallet当前 CHECK 定义,核对迁移000171~000177的执行顺序。 - 查询并阻断以下异常:未知钱包类型、负冻结金额、负版本、历史信用非关闭/非零、主钱包现金可用为负、分佣钱包余额为负或冻结超过余额。
- 确认主钱包写入口仅为 Wallet Application:订单扣款、预占、充值/人工调整、退款回充与调额;旧 Store 方法只能写分佣钱包。
- 确认 API、Worker 与 OpenAPI 为同一构建版本,四类钱包 Outbox 消费者均已注册。
迁移前异常查询口径:
SELECT id, shop_id, wallet_type, balance, frozen_balance,
credit_enabled, credit_limit, version
FROM tb_agent_wallet
WHERE wallet_type NOT IN ('main', 'commission')
OR frozen_balance < 0
OR version < 0
OR credit_enabled
OR credit_limit <> 0
OR (wallet_type = 'main' AND balance::numeric - frozen_balance::numeric < 0)
OR (wallet_type = 'commission' AND (balance < 0 OR frozen_balance > balance));
迁移后、开放访问前
- 确认信用字段、六个资金 CHECK、信用启用部分索引、订单幂等索引、预占表、入账和退款唯一索引均存在。
- 确认全部历史钱包仍为
credit_enabled=false, credit_limit=0;只有授权平台人员在开放访问后按业务决定启用信用。 - 核对三个接口及真实路由:角色默认信用、店铺实际额度、后台资金概况;同时核对开放接口钱包余额的信用投影。
- 执行
gofmt、OpenAPI 生成和go build ./...;自动化、并发与真实 PostgreSQL/Redis/Asynq 验收保持延期到任务 6.1、6.3,不能标记通过。
监控与异常处理
- 监控钱包条件更新
RowsAffected=0、Outbox 积压/失败、消费者权威流水不一致、钱包版本冲突和数据库 CHECK 拒绝。 - 出现支付/退款/充值事实已提交但消费失败时保留 Domain Ledger 与 Outbox,暂停异常生产者并前向恢复,不回滚资金事实。
- 发现未知旧写入口时保持维护状态;禁止临时恢复 Store 主钱包写方法或直接 SQL 改余额。
前端联调
- 角色页明确提示默认信用只影响未来新建店铺。
- 调额按钮按
shop:credit-limit:manage控制展示,后端行为仍以当前冻结产品决定为准。 - 所有金额按分传输、按元展示;现金可用与总可用分别展示,前端不自行计算。
- 降额失败保持原值;版本冲突后重新拉取资金概况和最新
version。 - 代理无调额入口,资金概况和开放接口均正确显示信用、欠款与总可用金额。
回滚边界
- 尚未启用信用、未产生负余额且不存在冻结超过账面余额时,才可评估执行可逆降级。
- 一旦启用信用、产生负余额或形成旧逻辑无法解释的冻结占用,禁止删除信用字段、关闭信用或恢复旧 Writer;必须先清偿欠款,或继续运行理解信用边界的新资金逻辑。
- 已产生的钱包流水、订单、充值、退款、预占、Outbox 和消费事实不得清理或伪造回滚。
本批验证结果
- 已执行 OpenAPI 生成,
docs/admin-openapi.yaml与当前 DTO/路由同步。 - 已执行静态写入口盘点:旧主钱包 Store 扣款/冻结方法已删除,保留方法均带
wallet_type=commission条件;旧店铺创建和主钱包直写运维脚本已收缩。 - 已执行
go build ./...,退出码为 0;Go 模块统计缓存出现只读警告,不影响构建结果。 - 按本 Change 的测试环境豁免,未新增或运行
_test.go,未连接真实 PostgreSQL、Redis 或 Asynq;相关验证保留在 6.1、6.3。