# PRD：UR#97 代理主钱包固定 100 元低余额预警

Status: ready-for-agent

---

## Problem Statement

代理主钱包现金余额不足会导致后续购包失败，但当前资金概况只返回总余额和冻结余额，没有统一的现金可用余额或低余额提示，也没有跨越阈值时的可靠通知。钱包余额可由扣款、冻结、解冻、充值和退款回充等多个入口改变；若只在某个 Service 增加通知，会漏掉其他资金路径，并在持续低余额时反复刷屏。

七月迭代还会加入信用额度，但预警表达的是代理实际现金不足，不能因信用可用额度而隐藏现金风险。

## Solution

在统一代理主钱包聚合的每次余额或冻结金额变更中，以 `cash_available = balance - frozen_balance` 比较事务前后快照。只有从 `>10000` 分跨越到 `<=10000` 分时产生一次低余额领域事件；持续低位不再产生，回升到阈值以上后下一次再次跌破会自然形成新事件。

事件与钱包事务同事务进入 Outbox，公共 Notification Worker 向当前启用的店铺主账号和可用店铺业务员分别发送一条站内通知。现有资金概况接口增加服务端计算的现金可用余额和预警布尔值，前端不自行拼阈值规则。

## User Stories

1. 作为代理主账号，我希望主钱包现金可用余额首次降至 100 元及以下时收到一次站内提醒。
2. 作为店铺的平台业务员，我希望我负责的店铺发生低余额时收到同一提醒。
3. 作为代理用户，我希望持续低余额的多次扣款不会不断收到重复通知。
4. 作为代理用户，我希望充值恢复到 100 元以上后，再次跌破还能收到新一轮提醒。
5. 作为运营人员，我希望资金概况明确展示现金可用余额和低余额状态，且不把信用额度算进来。
6. 作为财务维护人员，我希望通知系统失败不回滚正确的钱包资金事务，且能从事件和审计追踪本次跨阈值。

## Implementation Decisions

### 精确业务规则

- 固定常量 `AgentWalletLowBalanceThresholdCents int64 = 10000` 放入 `pkg/constants/` 并添加中文注释；本期不提供系统、角色或店铺级阈值配置。
- 只检查 `wallet_type=main` 的代理主钱包。分佣钱包、资产钱包、个人客户钱包均不参与。
- `cash_available = balance - frozen_balance`，单位为分；信用额度、可用授信和佣金余额全部不参与预警计算。
- 触发条件严格为 `before_cash_available > 10000 && after_cash_available <= 10000`。等于 100 元属于预警范围；从 100 元以下继续扣款不重复触发。
- 充值、退款回充或解冻使现金可用余额回到 `>10000` 后，无需单独发送“余额恢复”通知；下一次由 `>10000` 降到 `<=10000` 会产生新的预警轮次。
- 新建时余额为 0 的钱包不补发低余额通知，因为没有发生从安全区跌破阈值的事件；未来首次回升并再次跌破时正常通知。
- 冻结可能降低可用现金并触发，解冻可能重新布防；从冻结余额正式扣除时若 `balance` 和 `frozen_balance` 同额减少、现金可用值不变，则不触发。

### 钱包领域与并发

- 本需求复用七月统一 `AgentWallet` 聚合和资金事务边界，不在 `AgentWalletStore` 的通用 SQL 更新方法中偷偷发送通知，也不在订单、充值、退款等 Service 各写一份判断。
- 所有主钱包余额/冻结金额变更必须加载并锁定同一钱包快照，由领域方法计算变更前后 `cash_available`、校验资金不变量、增加 `version` 并记录跨阈值事件。
- 既有扣款、冻结、解冻、充值、退款回充及其他触碰主钱包的完整用例都必须接入统一变更边界；若仍有入口直接执行 `balance +/-` 或 `frozen_balance +/-`，不得把 UR#97 标记完成。
- 不需要单独持久化 `low_balance_alert_active`：前后现金可用值已经完整表达跌破、持续低位和回升后再跌破。并发由行锁/乐观锁和钱包 `version` 保证只提交一条实际跨越。
- 低余额事件至少快照 `event_id`、钱包 ID、店铺 ID、变更类型/业务引用、余额和冻结金额前后值、现金可用前后值、钱包提交后版本、`request_id/correlation_id`。
- 稳定事件 ID 使用本次已提交的钱包业务变更/版本生成；同一 Outbox 事件重放 ID 不变。不得在每次 Worker 重试时生成新 ID。
- 钱包状态、流水、Audit Event 和 Outbox 在同一数据库事务中写入；Outbox 写失败回滚资金事务。后续通知写入失败不回滚资金，由 Relay/Worker 重试。

### 接收人和防重

- 接收人为该店铺当前 `status=1` 且 `is_primary=true` 的代理主账号，以及 `business_owner_account_id` 指向且当前仍启用的平台业务员。
- 软删除、禁用或关系已解除的账号在消费时跳过；主账号与业务员 ID 去重后逐人发送。没有可用接收人时记录 `no_recipient`，不无限重试。
- 使用公共通知类型 `wallet.low_balance`，类别为 `system` 或公共注册表确定的资金告警类别，级别为 `warning`。正文快照包含店铺名称和脱敏业务信息，可展示现金可用金额，不包含信用额度。
- 公共通知唯一键 `event_id + recipient_kind + recipient_id` 防止 Outbox/Asynq 重放；每次真正再次跌破产生不同事件 ID，因此可再次通知。
- 受控目标使用店铺资金概况类型和店铺 ID，不保存任意 URL；目标解析后仍按当前账号的店铺数据权限检查。

### API 与前端

- 复用现有 `GET /api/admin/shops/fund-summary`，不另建单店铺的同名新接口。其每个 `ShopFundSummaryItem` 在保留 `main_balance`、`main_frozen_balance` 等兼容字段的基础上新增：
  - `cash_available:int64`：`main_balance - main_frozen_balance`。
  - `low_balance_warning:bool`：主钱包存在且 `cash_available <= 10000`。
- 店铺没有主钱包的异常数据不可伪装成“0 元正常钱包”；返回 `cash_available=0`、`low_balance_warning=true` 的同时记录数据完整性告警，或按统一 Query 错误策略失败，实施时必须保持同一接口所有页面一致。
- Query 继续应用现有店铺层级数据权限和服务端分页，批量加载主钱包，禁止逐店铺 N+1；信用额度字段即使同时存在也不能影响这两个新字段。
- 资金概况在 `low_balance_warning=true` 时显示红色“现金余额不足 100 元”状态，并展示现金余额与冻结金额；金额均按分转元，前端不得自行用信用额度或阈值重新计算布尔值。
- 通知中心和顶部抽屉复用公共通知 API；点击后受控跳转到对应店铺资金概况。目标已无权限时按统一不可用语义处理。
- 不展示阈值输入框、余额恢复消息开关或企业微信通知选项。

### 审计与发布

- 钱包跨阈值时的 Audit Event 记录钱包/店铺、业务来源、余额/冻结/现金可用前后快照、领域事件 ID 和接收人解析结果；信用额度不得出现在预警判断依据字段中。
- 普通资金变化继续以钱包流水为领域事实，低余额 Audit Event/通知只表达告警，不替代流水。
- 发布依赖统一钱包变更边界、公共 Outbox、公共站内通知和 UR#96 业务员关系；依赖尚未完成时可以先完成领域事件，但不得另建临时通知表或直接发消息。
- 存量低余额钱包不批量补发，避免上线瞬间产生大量无业务变更通知；上线后只监听新提交的跨阈值事务。

## 公共能力发布依赖

- 最终发布票必须明确阻塞于公共基础 12 号票、全局审计 19 号票、公共通知 08 号票和 UR#38 钱包切换 10 号票。
- 低余额事件必须登记载荷版本、消费者幂等键、通知接收人和防重策略；钱包 Domain Ledger 是余额权威，通知事实不替代资金审计。
- 当前 PRD 尚未拆票；生成 tickets 时必须把上述依赖落到最终发布门禁，不得只写“依赖公共基础”。

## Testing Decisions

- 领域单元测试覆盖 `10100→10000`、`10001→10000`、`10000→9999`、`9000→8000`、`9000→10100→10000`，验证只在严格跨越时发事件。
- 覆盖扣款、冻结、解冻、充值、退款回充和冻结余额正式扣除；验证冻结可触发、解冻可重新布防、总额与冻结额同减导致现金不变时不触发。
- 并发集成测试让两个事务同时尝试把同一钱包从阈值上方扣到下方，验证乐观锁/行锁后只有实际提交的跨越产生一个事件、流水和 Outbox。
- 对所有主钱包写入口做回归测试或静态清单检查，证明没有绕开统一聚合直接更新余额/冻结金额的生产路径。
- Notification Worker 测试覆盖主账号+业务员两接收人、同一账号去重、停用/删除跳过、无接收人、重复事件投递和再次跌破的新事件。
- HTTP 集成测试使用开发 PostgreSQL/Redis，穿过真实认证、权限、Query 和统一响应，验证平台、不同层级代理看到的资金行范围不变，新字段在大于、等于、小于 100 元时正确。
- 验证信用额度为 0、正数以及已使用产生负现金余额时，`cash_available/low_balance_warning` 与通知规则均只看现金余额和冻结金额。
- 前端验收覆盖红色提示、分转元、加载/空/失败状态、通知已读和受控跳转；禁止对真实钱包执行测试扣款或发送真实通知。

## Out of Scope

- 不支持自定义阈值、分级阈值、按角色/店铺配置或余额恢复通知。
- 不把信用额度、佣金、资产钱包或个人钱包纳入预警。
- 不发送企业微信、短信、邮件或其他外部渠道消息。
- 不补发上线前已经低于阈值的存量钱包通知。
- 不在本需求实现公共站内通知中心或公共 Outbox 的另一份副本。
- 不通过前端定时查询余额来推断和发送预警。

## Further Notes

- 当前仓库已有主钱包 `balance/frozen_balance/version` 和资金概况接口，但加减余额仍分散在多个 Service/Store；完整接入统一钱包写边界是本需求可靠性的核心，不是附带重构。
- 现有资金概况字段名是 `main_balance/main_frozen_balance`，禅道稿中笼统写的 `balance/frozen_balance` 不能覆盖真实兼容契约。
- “不足 100 元”在本需求产品文案中包含恰好 100 元，后端判断以 `<=10000` 为准。
