更新一下prd
This commit is contained in:
285
.scratch/ur34-agent-recharge/PRD.md
Normal file
285
.scratch/ur34-agent-recharge/PRD.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# PRD:UR#34 代理在线扫码充值与平台线下代充值
|
||||
|
||||
Status: ready-for-agent
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
当前代理充值接口只创建本地充值记录,没有真正创建微信 Native 或支付宝 PreCreate 支付单并返回可供前端渲染的付款内容。在线回调直接在一次事务中尝试完成钱包入账,支付事实与钱包处理结果无法独立表达;回调也没有完整校验支付配置、金额、第三方交易号和业务关联。网络超时、回调丢失、重复回调、钱包事务失败和第三方迟到成功都缺少可靠恢复边界。
|
||||
|
||||
当前同一个创建接口还允许代理或平台提交目标 `shop_id`,平台可以替任意代理创建在线支付,容易混淆真实付款人和受益钱包。线下代充值仍通过本地 `offline-pay`、`reject` 和全局操作密码完成人工入账,没有接入已经冻结的企业微信审批公共能力;附件仍是字符串 Key 列表,审批结论、充值状态和钱包入账状态也未独立建模。
|
||||
|
||||
本需求必须把代理自主在线充值与平台线下代充值分成两条不可混用的路径,复用现有支付和钱包基础能力,保证第三方真实收款、企微审批结论、钱包实际入账和通知都可独立追踪,并在重复回调、重复 Worker、进程中断、查单未知和审批异常下保持资金不重不漏。
|
||||
|
||||
## Solution
|
||||
|
||||
保留代理充值资源,建立两条严格隔离的创建路径:代理只为当前店铺主钱包选择 `wechat` 或 `alipay` 发起在线充值,不提交目标店铺,也不进入审批;平台或超级管理员只为指定代理店铺发起 `offline` 线下代充值,提交固定金额、备注和 1~5 个结构化付款凭证,并接入 UR#37 的真实企业微信审批。
|
||||
|
||||
在线充值为每次用户主动创建生成新的充值单和支付单。微信 Native 或支付宝 PreCreate 返回的字符串或 HTTPS URL 通过 `qr_content` 原样返回,前端自行渲染二维码,后端不生成二维码图片。前端支付状态轮询只读取本地状态;后端通过支付回调和受控查单任务同步第三方真实状态。支付成功先可靠固化收款事实,再由独立 Worker 幂等增加代理主钱包并写唯一流水,钱包失败不回滚支付成功事实。
|
||||
|
||||
线下充值创建时把充值单、唯一企微审批实例和提交 Outbox 原子落库。企微只能同意或拒绝固定金额;同意后复用与在线充值相同的钱包入账 Worker,拒绝后原单终结。无论在线还是线下,只要主钱包实际入账成功,都向目标代理发送防重的站内到账通知。
|
||||
|
||||
## User Stories
|
||||
|
||||
1. 作为代理账号,我希望只为当前所属店铺的主钱包充值,不需要也不能选择目标店铺。
|
||||
2. 作为代理付款人,我希望在创建时选择微信或支付宝,并在订单创建后不能切换支付方式。
|
||||
3. 作为代理付款人,我希望每次主动点击创建支付都得到一张新的充值单和支付单,而不是复用相同金额的旧单。
|
||||
4. 作为代理付款人,我希望网络重试不会为同一次提交创建多张充值单。
|
||||
5. 作为代理付款人,我希望后端返回支付平台给出的付款字符串,由前端稳定渲染二维码。
|
||||
6. 作为代理付款人,我希望页面不展示一个本地猜测的精确失效倒计时,以免把第三方实际已经失效的码显示为有效。
|
||||
7. 作为代理付款人,我希望关闭页面后旧支付单继续等待回调或查单收敛,不因页面关闭而被取消。
|
||||
8. 作为代理付款人,我希望即使创建了多张支付单,每张真实付款的订单都能分别准确入账。
|
||||
9. 作为代理付款人,我希望付款完成后页面先显示第三方已收款,再准确展示钱包是否已经到账。
|
||||
10. 作为代理付款人,我希望第三方已经收款但钱包暂时失败时,系统明确显示“支付成功、入账重试中”,而不是显示支付失败。
|
||||
11. 作为代理付款人,我希望第三方成功回调晚于本地失败或关闭状态时,系统仍能核实并补充入账,不吞掉已付资金。
|
||||
12. 作为代理付款人,我希望回调丢失时系统能够通过第三方查单补偿,而不要求我再次付款。
|
||||
13. 作为代理付款人,我希望微信或支付宝不可用时页面只展示真实可用的支付方式。
|
||||
14. 作为代理付款人,我希望钱包实际到账后收到一条站内到账通知。
|
||||
15. 作为非主账号的在线充值提交人,我希望自己和目标店铺主账号都能收到到账通知。
|
||||
16. 作为平台员工,我希望选择目标代理店铺、金额、备注和付款凭证发起线下代充值。
|
||||
17. 作为平台员工,我希望线下代充值金额大于 0 即可,不受代理在线充值 100 元起付限制。
|
||||
18. 作为平台员工,我希望使用本人绑定的企微身份提交线下充值审批,业务系统同时保存我是真实提交人。
|
||||
19. 作为审批人,我希望企微表单展示充值单号、目标代理、固定金额、真实提交人、备注和付款凭证。
|
||||
20. 作为审批人,我只需要同意或拒绝线下代充值,不能修改金额,也不需要回业务系统输入操作密码。
|
||||
21. 作为平台提交人,我希望企微拒绝后原充值单只读终结;修正资料后通过创建入口提交一张新单。
|
||||
22. 作为平台提交人,我希望审批通过和钱包实际到账是两个独立状态,不会把审批通过误认为余额已增加。
|
||||
23. 作为目标代理主账号,我希望平台线下代充值实际到账后收到站内到账通知。
|
||||
24. 作为真实业务提交人,我希望线下审批通过或拒绝继续收到 UR#37 的审批结果通知。
|
||||
25. 作为代理管理者,我希望按既有店铺层级和充值查看权限查看本店及可管理下级店铺的充值记录,而不是只看自己创建的记录。
|
||||
26. 作为代理查看者,我希望看到充值金额、付款方式、业务凭证、真实提交人、审批状态和入账结果,但看不到平台内部审批人、意见、审批附件或支付配置。
|
||||
27. 作为平台财务,我希望钱包入账持续失败或审批通过后撤销时收到内部异常通知,以便人工处理。
|
||||
28. 作为财务人员,我希望已经入账后发生企微通过后撤销时系统不自动扣减代理余额,避免未经核实的反向资金动作。
|
||||
29. 作为系统维护人员,我希望支付回调、第三方查单、企微回调、企微轮询和重复 Worker 都进入统一幂等用例,不重复加钱。
|
||||
30. 作为审计人员,我希望充值单、支付单、企微审批实例、代理主钱包、钱包流水、通知和外部交互能够完整关联。
|
||||
31. 作为前端开发者,我希望在线支付、线下审批和钱包处理状态分别返回,不需要通过单个状态猜测业务阶段。
|
||||
32. 作为验收人员,我希望本地自动化覆盖完整后台业务和异常恢复,真实微信/支付宝留到部署测试环境人工扫码,真实企微仍在实现阶段参与线下充值验收。
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### 范围、依赖与架构边界
|
||||
|
||||
- UR#34 只负责代理在线充值、平台线下代充值、支付状态同步、企微终态消费、钱包入账、充值 Query 和前端业务页面。企微连接、平台账号绑定、模板版本、附件上传企微、提交、加密回调、轮询和审批详情复用 UR#37,不实现第二套企微客户端或审批状态机。
|
||||
- 站内到账通知复用 TECH 公共站内通知;支付和钱包关键事实使用全局 Audit Event,第三方支付与企微调用使用 Integration Log,不新建充值私有审计或通知体系。
|
||||
- 在线创建、支付确认、线下创建、企微终态消费和钱包入账均涉及远程调用、状态机、金额、并发与可靠事件,采用 `Handler → Application UseCase → Domain → Repository/Infrastructure` 的复杂写通道。充值列表、详情和轻量支付状态采用 Query 通道。
|
||||
- 只迁移完成 UR#34 所需的最小完整充值用例。现有旧 Service 可以暂时作为迁移门面,但支付确认和钱包入账不变量不得一半留在旧 Service、一半放在新 Domain。
|
||||
- 当前支付配置通常只有一套微信参数和一套支付宝参数。本期不建设多通道池、优先级、权重、自动切换或故障转移;代理选择的是支付方式,不选择也不感知具体支付通道。
|
||||
|
||||
### 角色与两条创建路径
|
||||
|
||||
- 代理账号只能创建 `payment_method=wechat|alipay` 的在线充值。目标店铺和主钱包从当前认证上下文确定;请求不接受 `shop_id`,代理不能给本店下级或其他店铺创建在线支付。
|
||||
- 平台账号和超级管理员只能创建 `payment_method=offline` 的线下代充值,必须提交目标 `shop_id`,并受既有目标店铺数据范围和管理权限约束。平台与超级管理员不能替代理创建微信或支付宝支付码。
|
||||
- 代理账号不能创建线下代充值;企业账号不能创建或访问任一代理充值路径。
|
||||
- 创建权限和查看权限分开处理。创建路径使用上述严格角色规则;列表、详情和支付状态继续使用既有充值业务权限与店铺层级数据范围。
|
||||
- 新创建不接受历史 `bank` 支付方式;历史 `bank` 记录只读兼容,不改变原事实。
|
||||
|
||||
### 可用支付方式接口
|
||||
|
||||
- `GET /api/admin/agent-recharges/payment-methods` 只供代理在线充值页面使用,返回当前真正配置完整且能够创建支付的 `wechat` 和/或 `alipay`。
|
||||
- `data` 固定包含:`methods:string[]`、`min_amount:int64=10000`、`max_amount:int64=100000000`。没有可用方式时 `methods=[]`,接口本身仍成功。
|
||||
- 接口不返回商户号、应用私钥、支付通道、支付配置 ID或具体缺失的敏感配置。支付方式顺序固定为 `wechat`、`alipay`,仅保留可用项。
|
||||
- 创建接口必须再次校验所选支付方式。配置在查询列表后失效时拒绝创建,不能只信任前端先前取得的列表。
|
||||
- 微信只有在当前微信支付配置支持本需求所需的扫码预下单、回调验签和查单能力时才视为可用;支付宝只有在 PreCreate 所需参数完整时才视为可用。
|
||||
|
||||
### 创建接口与请求幂等
|
||||
|
||||
- 保留 `POST /api/admin/agent-recharges`,根据 `payment_method` 使用严格的判别请求契约。
|
||||
- 在线请求只接受:`amount`、`payment_method=wechat|alipay`、`request_id`。`amount` 为分,范围 `10000~100000000`;不接受 `shop_id`、付款凭证或备注。
|
||||
- 线下请求只接受:`shop_id`、`amount`、`payment_method=offline`、`attachments`、可选 `remark`、`request_id`。`amount` 为分,范围 `1~100000000`;`remark` 最多 500 字。
|
||||
- 线下 `attachments` 必传 1~5 项,每项固定包含 `file_key`、`file_name`、`file_size`。后端使用现有真实对象存储元数据能力校验对象存在、当前上传主体、允许的文件类型和实际大小;请求元数据作为业务快照,不能替代对象事实。
|
||||
- `request_id` 只防止同一次用户提交因超时、重试或重复点击产生重复业务单。幂等范围为真实提交账号与 `request_id`,并保存请求指纹;同账号、同 ID、同载荷返回原业务结果,载荷变化返回冲突。
|
||||
- 用户每次主动点击创建或拉起支付必须生成新的 `request_id`。后端每次创建全新的充值单、支付单和 `qr_content`,不按相同金额、支付方式或既有待支付记录复用旧单。
|
||||
- 在线和线下创建都必须通过数据库唯一约束和请求指纹保证持久化幂等;Redis 只能减少并发,不能成为唯一正确性依据。
|
||||
|
||||
### 在线充值创建与付款内容
|
||||
|
||||
- 在线创建先在同一 PostgreSQL 事务写充值单和 `tb_payment`。支付单增加稳定 `order_type=agent_recharge`,关联充值 ID、支付方式、金额、创建时支付配置 ID和支付单号;充值单初始 `status=1`,支付单初始 `status=0`,处理状态为 0。
|
||||
- 微信使用 Native 扫码预下单;支付宝使用 `alipay.trade.precreate`。支付宝不再返回 WAP 支付链接作为本需求的扫码实现。
|
||||
- 微信或支付宝返回的二维码码串、协议字符串或 HTTPS URL 原样保存为 `qr_content` 并返回。前端使用二维码组件渲染;后端不生成、上传或返回二维码图片,也不新增“生成二维码”“重新生成二维码”接口。
|
||||
- 创建成功的在线 `data` 至少包含:`recharge_id`、`recharge_no`、`amount`、`payment_method`、`qr_content`、`status/status_name`、`payment_status/payment_status_name`、`processing_status/processing_status_name`、`approval_source=none`。不返回 `expires_at`、`payment_channel` 或支付配置 ID。
|
||||
- 本地计算时间不能代表第三方支付订单真实有效期。后台在线充值接口不返回 `expires_at`,前端不展示精确倒计时,也不根据本地时间判定二维码仍然有效。
|
||||
- 用户关闭页面只停止当前页面展示,不调用支付取消或关单。此前支付单继续等待支付回调或后端查单自然收敛;用户再次主动创建得到独立新单。
|
||||
- 多张在线支付单若都被第三方确认真实付款,每张分别进入幂等钱包入账,不能因为存在更新的支付单而吞掉旧单资金。
|
||||
|
||||
### 预下单失败和结果未知
|
||||
|
||||
- 第三方明确返回预下单失败时,支付单改为 `2=已失败`并保存稳定失败码/脱敏摘要,充值单改为 `4=已关闭`;创建接口返回统一支付错误。用户再次主动尝试使用新的 `request_id` 创建新单。
|
||||
- 网络超时、连接中断或响应丢失导致预下单结果未知时,不能直接把原支付单判失败,也不能以同一个 `request_id` 创建另一张支付单。必须保留原业务事实,并使用创建时支付配置和原支付单号执行安全恢复。
|
||||
- 结果未知恢复先向第三方查单:明确已支付则进入统一支付确认;明确不存在时可用同一支付单号重新预下单;明确未支付且第三方支持幂等恢复付款内容时保存并返回原支付单的 `qr_content`;仍无法判断时保持未知并继续可靠恢复,不伪造失败或成功。
|
||||
- 同一 `request_id` 重试只能返回原充值/支付事实或当前稳定错误,不能创建新单。用户主动使用新 `request_id` 创建的新单不取消未知旧单,旧单仍需查单收敛。
|
||||
- 预下单结果未知、恢复尝试和最终结论写 Integration Log;外部原始响应、商户密钥和完整付款内容不得进入普通日志或审计详情。
|
||||
|
||||
### 支付状态、充值状态与处理状态
|
||||
|
||||
- `tb_payment` 状态统一沿用当前 Go 常量:`0=待支付`、`1=已支付`、`2=已失败`、`3=已退款`。本需求不新增“已关闭”支付状态;第三方明确关闭或失效时使用 `2=已失败`并保存结构化失败原因。
|
||||
- 充值业务状态沿用 `1~6`:`1=待支付/线下待审批`、`2=已支付或审批通过后正在入账`、`3=已完成`、`4=已关闭`、`5=已退款`、`6=已驳回`。不新增 `7=已退回`。
|
||||
- 钱包处理状态统一为:`0=未触发`、`1=处理中`、`2=处理成功`、`3=处理失败`。所有响应返回对应 `processing_status_name`,不再提供同义字段 `wallet_posting_status`。
|
||||
- 在线记录另返回支付状态及 `payment_status_name`;线下记录没有第三方支付单,支付状态字段为不适用,不用伪造“已支付”。审批状态完全复用 UR#37 的公共企微状态。
|
||||
- 状态类字段使用 `int`,方式/来源类使用 `string`。DTO description 必须从公共 constants 原文复制,不能按迁移注释或旧文档重新编号。
|
||||
- 历史 `tb_payment` 建表迁移的注释和默认值与当前 Go `0~3` 常量不一致。实施前必须按状态、业务类型和创建入口盘点存量值;新迁移统一默认值、注释和约束到 `0~3`,遇到无法证明含义的存量值必须中止并输出异常清单,禁止盲目整体加减 1。
|
||||
|
||||
### 支付回调、查单补偿与迟到成功
|
||||
|
||||
- 微信、支付宝回调先完成渠道协议验签/解密,再按支付单号和 `order_type=agent_recharge` 分发到统一支付确认用例,不再只依赖充值单号前缀猜测业务类型。
|
||||
- 支付确认必须核验:原支付方式、创建时支付配置、商户订单号、业务关联、第三方交易号、支付金额和第三方成功状态。金额或关联不一致时拒绝入账并记录严重 Integration Log/Audit Event,不能把异常回调当成功。
|
||||
- 第三方交易号必须具备持久化唯一性或等价防重;同一支付单重复回调、回调与查单并发、第三方重复通知都只固化一次支付成功事实。
|
||||
- 前端每 3 秒调用轻量支付状态接口时只查询本地数据库,不直接请求微信或支付宝。页面不可见时暂停,恢复时立即刷新;到达本地终态或离开页面时停止。
|
||||
- 后端可靠任务按受控、可运维调整的频率查询仍待支付或预下单结果未知的支付单。具体频率是实现配置,不成为前端或业务契约;任务必须有限流、租约和批次上限,不能形成无限并发查单。
|
||||
- 查单与回调进入同一支付确认用例。只有第三方明确返回已支付、已关闭/失效或订单不存在时才推进对应本地结论;超时、限流和未知状态继续保持原状态并重试。
|
||||
- 第三方明确关闭或失效且未支付时,支付单改为已失败,充值单改为已关闭。不得仅根据本地 `expire_at` 或页面停留时间关闭第三方订单。
|
||||
- 本地曾因第三方明确关闭而标记失败后,如果又收到可验证的迟到成功回调,以真实收款事实为准:支付单改为已支付,原充值单进入入账流程。已关闭业务状态不能吞掉已付资金。
|
||||
|
||||
### 两阶段钱包入账与资金幂等
|
||||
|
||||
- 在线支付确认采用两阶段处理。第一阶段在一个事务中把支付单条件更新为已支付,保存第三方交易号和支付时间,把充值单更新为 `2=已支付`、`processing_status=1`,并写钱包入账 Outbox。事务成功后即可向支付渠道返回成功,不等待钱包余额更新。
|
||||
- 线下企微首次同意同样只把充值单推进到 `2`、`processing_status=1`并写钱包入账 Outbox;企微同步线程不直接修改钱包。
|
||||
- 钱包入账 Worker 在独立事务中锁定目标代理主钱包,使用钱包版本/条件更新增加余额,创建唯一充值流水,把充值单改为 `3=已完成`、`processing_status=2`并写资金 Audit Event。
|
||||
- 钱包流水以充值单号或等价稳定业务键建立唯一约束。若流水已经存在而充值单尚未完成,重试只核验并补齐充值单状态和审计关联,不再次增加余额。
|
||||
- Worker 通过处理状态、条件更新和有期限租约领取。失败时支付或审批事实保持不变,充值单 `processing_status=3`并保存脱敏错误摘要,由可靠任务重试;前端没有人工“再次入账”按钮。
|
||||
- 重复支付回调、重复企微终态、重复 Outbox、Asynq 至少一次投递、租约过期和进程中断均不能重复加钱、重复流水或重复到账通知。
|
||||
- 充值增加主钱包账面余额;钱包原余额为负时允许正常入账并自然冲减欠款,不因欠款状态拒绝充值。
|
||||
|
||||
### 线下代充值与企微终态
|
||||
|
||||
- 创建线下充值前必须确认 `offline_recharge_approval` 场景、当前模板和当前平台/超级管理员本人的企微绑定可用。任一前置不可用时,在充值单、审批实例和 Outbox 落库前失败,前端保留表单。
|
||||
- 线下充值单、真实业务提交人快照、结构化申请附件、唯一企微审批实例和提交 Outbox 在同一 PostgreSQL 事务创建。远程企微异步提交;创建成功返回充值详情及 `approval_source=wecom`、审批“提交中”。
|
||||
- 企微表单至少展示充值单号、目标代理店铺、充值金额、真实业务提交人、申请备注和 1~5 个付款凭证。金额提交后固定;企微只允许同意或拒绝,不提供金额编辑、退回修改或本地审批动作。
|
||||
- 企微拒绝把原充值单条件更新为 `6=已驳回`,处理状态保持未触发,保存拒绝原因和审批快照。原单、凭证和拒绝原因只读保留;修正后必须通过创建接口生成新的充值 ID、单号、资料和企微审批。
|
||||
- 系统不新增 `7=已退回`,不提供 `resubmit`,也不注册旧 `offline-pay` 或业务单级 `reject`。企微同意后自动入账,不校验全局操作密码。
|
||||
- 企微在通过前撤销或删除时,充值单改为 `4=已关闭`且不入账,可以重新创建。企微通过后、钱包尚未入账时发生撤销,终止后续入账并关闭充值单。
|
||||
- 钱包已经入账后再收到通过后撤销时,不自动扣减代理余额,充值单保持已完成;系统记录 `critical` Audit Event并向平台财务/运维发送异常通知,交由人工处理。
|
||||
|
||||
### 到账通知与异常通知
|
||||
|
||||
- 无论在线扫码还是平台线下代充值,只有钱包入账事务成功后才产生“充值到账”可靠事件;支付成功或企微同意本身不提前发送到账通知。
|
||||
- 目标代理店铺主账号必须收到到账通知。在线充值的真实提交人若不是店铺主账号,也收到到账通知;同一账号同时符合多种身份时按“充值单 + 接收人”唯一键只生成一条。
|
||||
- 平台线下充值提交人不因为提交身份收到代理到账通知,但继续通过 UR#37 收到审批通过/拒绝结果通知。若平台提交人同时也是目标代理接收账号,则仍按接收人防重。
|
||||
- 通知至少展示充值单号、金额、支付方式/线下代充来源、到账时间和受控充值详情引用,不保存任意 URL。通知投递失败不回滚资金事务,并由公共通知 Worker可靠重试。
|
||||
- 入账持续失败、金额异常、重复第三方交易号和通过后撤销只通知有权的平台财务/运维,代理页面显示业务化处理状态,不暴露数据库、支付配置或第三方原始错误。
|
||||
|
||||
### 查询、权限与信息投影
|
||||
|
||||
- 保留 `GET /api/admin/agent-recharges` 和 `GET /api/admin/agent-recharges/{id}`,新增 `GET /api/admin/agent-recharges/{id}/payment-status`。列表、详情和支付状态必须使用相同认证、充值业务权限和店铺层级数据范围。
|
||||
- 充值记录不按创建账号隔离。代理在具备充值查看权限时,可查看本店及有权管理的下级店铺记录;平台和超级管理员沿用既有数据范围;企业账号统一拒绝。
|
||||
- 代理可见充值金额、支付方式、业务付款凭证、真实提交人、充值状态、企微状态和钱包处理结果;不可见企微审批人、内部意见、审批人附件、企微成员 ID、支付配置 ID、商户号、支付通道和内部失败详情。
|
||||
- 平台和超级管理员只有同时具备充值业务查看权限时,才能读取 UR#37 提供的完整审批详情。企微运营权限不能绕过充值业务权限。
|
||||
- 资源不存在与无权访问统一返回禁止访问语义,不能借列表、详情、支付状态或附件下载的差异探测其他店铺充值。
|
||||
- 列表沿用 `page/page_size`,默认第 1 页、每页 20、最大 100;默认 `created_at DESC, id DESC`。店铺、充值状态、支付方式、提交人和起止时间等筛选按 AND 组合,空参数不改变查询。
|
||||
- 列表项与 UR#44 对齐,至少返回充值 ID/单号、店铺、金额、支付方式、真实提交人、充值状态及名称、`approval_source`、审批状态及名称、代理投影为空的当前审批人摘要、处理状态及名称、创建和完成时间。
|
||||
- 详情返回完整充值业务资料、结构化申请附件、支付摘要、根级处理状态以及按主体投影的 `approval` 对象。在线固定 `approval_source=none`、`approval=null`;新线下固定 `approval_source=wecom`;历史本地审批使用 `legacy`,不伪造企微时间线。
|
||||
- 轻量支付状态接口只适用于在线充值,至少返回:充值 ID/单号、充值 `status/status_name`、`payment_status/payment_status_name`、`processing_status/processing_status_name`、可空 `paid_at/completed_at`。不返回 `qr_content`、钱包余额、审批详情、`expires_at`、支付通道或配置。
|
||||
|
||||
### 数据模型、索引与迁移
|
||||
|
||||
- `tb_agent_recharge_record` 保留现有业务主表,并补充:`request_id`、请求指纹、结构化申请附件、真实提交人显示快照、`approval_instance_id`、处理状态/错误/开始/完成时间、处理租约、乐观锁版本及可靠执行所需字段。数据库不建立外键,也不使用 GORM 关联标签。
|
||||
- 新线下附件使用结构化 `{file_key,file_name,file_size}` 列表保存。历史 `payment_voucher_key` 字符串或字符串数组只读兼容;迁移不能丢失旧凭证,也不能把短期预签名 URL 写成业务事实。
|
||||
- `tb_payment` 增加 `order_type=agent_recharge` 常量及本需求需要的 `qr_content`、稳定失败码/摘要和预下单恢复事实。付款内容不得写普通访问日志;API 仅在在线创建成功响应中返回。
|
||||
- 充值记录对 `(user_id,request_id)` 建有效记录唯一约束并保存请求指纹;`approval_instance_id` 对新线下充值保持一单一审批唯一性;为待处理状态/租约、店铺+创建时间和支付单业务关联建立必要索引。
|
||||
- 支付单号、第三方交易号和钱包充值流水分别建立符合其语义的唯一约束。软删除不能允许相同第三方资金事实再次入账。
|
||||
- 数据库金额约束继续允许历史及线下 `amount>=1`;在线 `amount>=10000` 和两条路径的 `amount<=100000000` 必须在 Domain 中按支付方式重校验。不得用一个全局最低 100 元约束破坏线下小额代充。
|
||||
- 发布前盘点充值状态、支付状态、重复第三方交易号、重复充值流水、孤立支付单、待处理线下单和历史附件格式。任何不确定资金记录进入异常清单,不由迁移脚本猜测完成状态。
|
||||
|
||||
### 停机发布、存量迁移与回滚
|
||||
|
||||
- 采用已确认的停机发布,同时切换迁移、API、Worker、支付回调分发、企微终态消费和前端。新版本不保留旧本地审批兼容窗口。
|
||||
- 历史已完成、已关闭、已退款和已驳回的线下充值只读保留并标记/投影为 `approval_source=legacy`,不补企微审批,也不伪造审批人或时间线。
|
||||
- 发布时仍待处理的线下充值,只有在真实提交人可确认、平台账号已绑定企微且金额/付款凭证等资料完整时,才幂等创建真实企微审批继续处理;其余进入明确迁移异常清单,不自动入账,也不能再调用旧 `offline-pay`。
|
||||
- 上线前必须确认 UR#37 的线下充值场景和模板已发布、平台绑定可用、回调/轮询可达、钱包入账 Worker 和通知 Worker 已部署;条件不满足时不得开放线下创建入口。
|
||||
- 回滚应用时保留已经创建的充值单、支付单、真实支付事实、企微实例、Outbox、Integration Log、Audit Event、钱包流水和通知。已进入第三方支付或真实企微的业务不能恢复旧本地按钮,只能继续安全同步、重试或人工处置。
|
||||
- 已经实际入账的资金不通过迁移或应用回滚自动反向删除;任何资金冲正必须是后续独立、受审计的业务流程。
|
||||
|
||||
### 统一错误与安全响应
|
||||
|
||||
- 所有接口使用统一 `{code,msg,data,timestamp}`;分页使用项目公共结构。金额始终使用分,时间使用项目统一时区/RFC3339格式。
|
||||
- 请求字段、在线/线下金额、支付方式、备注或附件元数据非法使用 `CodeInvalidParam=1001`;未登录使用 `CodeUnauthorized=1004`;企业账号、错误创建角色、越权店铺及无权资源统一使用 `CodeForbidden=1005`。
|
||||
- 相同 `request_id` 的请求指纹不一致使用 `CodeConflict=1007`;状态不允许的业务动作使用 `CodeInvalidStatus=1050`;无可用支付配置使用 `CodeNoPaymentConfig=1175`;企微场景/模板/本人绑定不可用复用 UR#37 的稳定错误语义。
|
||||
- 微信/支付宝明确预下单失败或恢复失败使用统一、可区分支付方式的支付错误;若现有错误码不足,新增公共错误码而不是在 Handler 拼接底层错误。结果未知返回稳定“支付创建结果确认中/请稍后重试”语义,不透传 SDK、HTTP、数据库或 Redis 错误。
|
||||
- Handler 参数验证统一返回公共参数错误;Application/Domain/Query 不向调用方返回 `fmt.Errorf`。日志、审计和响应不得包含私钥、Secret、商户签名、完整回调报文、对象存储永久凭证或企微敏感标识。
|
||||
|
||||
### 前端页面与交互
|
||||
|
||||
- 代理资金页保留充值入口。打开时获取可用支付方式,金额输入单位为元并明确最低 100 元、最高 100 万元;只展示后端返回的微信/支付宝选项,无可用方式时禁止提交并显示统一业务提示。
|
||||
- 用户选择支付方式并点击创建时生成新的 `request_id`。同一次请求超时重试复用原 ID;用户再次主动点击“创建支付”生成新 ID和新订单,不搜索或复用旧待支付单。
|
||||
- 创建成功后用 `qr_content` 渲染二维码,展示充值单号、金额、支付方式和本地支付/入账状态。不展示精确过期倒计时,不请求二维码图片,也不显示“取消支付”或调用“重新生成二维码”接口。
|
||||
- 在线页面可提供“再次创建支付”用户动作,但它本质上重新调用创建接口并形成独立业务单;旧单在充值记录中继续自然收敛。
|
||||
- 页面可见时每 3 秒读取本地轻量状态,隐藏时暂停、恢复时立即刷新。支付成功但入账处理中/失败时保留明确提示;入账完成后停止轮询、刷新现有钱包资金概况并展示成功。
|
||||
- 平台线下创建页只展示目标店铺、金额、可选备注和 1~5 个付款凭证。提交中禁止重复操作;场景、绑定、附件或企微提交前置失败时保留表单内容。
|
||||
- 线下创建成功进入充值详情,分为业务资料、企微审批和钱包处理结果三个区域。不展示本地确认、拒绝、退回、重提或操作密码输入框。
|
||||
- 列表和详情按 `approval_source` 展示:`none` 审批列为“-”;`wecom` 展示只读企微状态;`legacy` 展示“历史审批”且无操作按钮。代理界面不展示审批人列或内部意见。
|
||||
- 加载、空、网络失败、支付方式为空、支付创建失败、支付已成功但入账失败、企微提交中/失败、审批拒绝和异常撤销均有独立展示,不由前端自行推导或改写服务端状态。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
### 最高公共测试接缝
|
||||
|
||||
- 主要自动化接缝为:Fiber 真实路由与认证 → Recharge Application/Domain/Query → GORM → 现有测试 PostgreSQL → Outbox/公开 Worker Handler → 代理主钱包、唯一流水、通知和统一审计。测试只断言公开响应和持久化业务事实,不断言私有函数或目录结构。
|
||||
- 微信/支付宝网络边界使用可编程 Payment Adapter,覆盖预下单、查单、回调确认和异常;不在本地或 Agent 自动化中调用真实微信、支付宝。支付协议验签与基础能力优先复用 C 端已经验证的实现,本需求重点验证后台充值分发和业务闭环。
|
||||
- 企微网络边界复用 UR#37 的可编程 WeCom Adapter,并保留真实企微线下充值验收;对象存储直接使用现有真实 S3 Provider,不建立内存替身。
|
||||
- 实现时沉淀可复用测试 Harness:环境守卫、唯一运行标识、真实认证/Fiber 请求、受控支付与企微 Adapter、Outbox 捕获、公开 Worker 驱动、真实 S3、精确资源台账和失败清理,并提供不含敏感信息的 HTTP/curl 冒烟模板。
|
||||
|
||||
### 环境隔离
|
||||
|
||||
- 本地开发和 Agent 自动化测试固定使用 Redis DB 7;已部署测试环境继续使用 Redis DB 6。测试启动前必须核验普通 Redis Client、Asynq Client和 Worker Server 实际 DB,任一不是 7 时立即失败。
|
||||
- 自动化不得向 DB 6 投递任务,不执行 `FLUSHDB`、全前缀删除或清空队列。每次运行生成唯一 `run_id`,只清理本次实际创建的精确 Key和任务事实。
|
||||
- PostgreSQL 继续使用现有测试数据库,不新建 Agent 专用数据库。所有账号、店铺、钱包、充值、支付、审批、流水和通知夹具使用唯一运行标识,只按实际主键精确清理;禁止 `TRUNCATE`、模糊删除或改写既有业务数据。
|
||||
- S3 测试对象 Key包含唯一运行标识,覆盖真实上传、Head/元数据、读取及精确删除。网络、鉴权或删除失败使测试失败;禁止按目录前缀或 Bucket 级清理。
|
||||
- 自动化捕获待投递任务后直接调用公开 Worker Handler,不通过 `sleep` 等待测试环境 Worker 抢任务。生产仍使用真实 Asynq 投递。
|
||||
|
||||
### 在线创建、支付和查单场景
|
||||
|
||||
- HTTP 创建覆盖:代理成功、平台/超管在线拒绝、企业拒绝、请求携带 `shop_id` 拒绝、微信/支付宝可用性、配置在列表后失效、金额 9999/10000/100000000/100000001、支付方式非法和支付方式创建后不可切换。
|
||||
- 幂等覆盖:同账号同 `request_id` 同载荷返回原单,不同载荷冲突,并发重复只有一张充值单和支付单;新 `request_id` 在金额相同且旧单待支付时仍创建新单。
|
||||
- Adapter 预下单覆盖微信 Native 与支付宝 PreCreate 成功、明确失败、超时但未创建、超时后第三方已创建、响应丢失、恢复仍未知和重复恢复;断言不会因同一请求再建支付单。
|
||||
- 响应覆盖原样 `qr_content`、前端可渲染字符串/HTTPS URL、不返回 `expires_at`、支付通道或配置,不创建二维码图片文件。
|
||||
- 回调覆盖签名/验签失败、订单不存在、业务类型错误、支付方式不符、配置不符、金额不符、第三方交易号冲突、业务关联错误、成功、重复成功和乱序通知。
|
||||
- 查单覆盖明确待支付、已支付、已关闭/失效、不存在、未知、超时和限流;证明前端状态接口不调用 Adapter,查单与回调并发只固化一次支付成功。
|
||||
- 迟到成功覆盖支付单已失败、充值单已关闭后收到有效成功回调,最终仍只入账一次;多张独立支付单均成功时分别入账。
|
||||
|
||||
### 钱包、通知和审计场景
|
||||
|
||||
- 两阶段测试证明支付成功事务完成后即使钱包事务失败,支付单仍已支付、充值单为已支付、处理状态失败/重试,不能回到待支付或向渠道返回失败。
|
||||
- 钱包覆盖正常余额、负余额自然冲减、乐观锁冲突、数据库失败、流水已存在但状态未完成、Worker 崩溃前/后、租约过期、重复 Outbox和两个 Worker 并发;余额、版本、唯一流水和完成审计只变化一次。
|
||||
- 通知覆盖在线主账号、在线非主账号提交人、线下目标代理主账号、同一接收人身份重合、重复 Worker和通知投递失败;每个充值单与接收人最多一条到账通知,通知失败不回滚钱包。
|
||||
- 审计覆盖在线创建、线下创建、明确预下单失败、结果未知、支付确认、第三方状态异常、钱包成功/失败、审批拒绝、撤销和通过后撤销;验证金额、状态前后值和关联 ID完整且不泄漏敏感配置。
|
||||
|
||||
### 线下企微场景
|
||||
|
||||
- 创建覆盖平台/超级管理员本人绑定成功、未绑定、场景暂停、模板失效、企业/代理拒绝、目标店铺越权、金额 0/1/100000000/100000001、备注边界、附件 0/1/5/6、对象不存在、其他主体对象和元数据不符。
|
||||
- 事务测试证明充值单、结构化附件、唯一企微实例和提交 Outbox要么全部存在,要么全部不存在;相同 `request_id` 不产生第二条审批。
|
||||
- 状态自动化覆盖提交中、审批中、同意、拒绝、撤销、删除、通过后撤销、重复/乱序回调和轮询并发;拒绝后原单不可编辑或重提,新申请产生全新业务事实。
|
||||
- 入账前撤销验证任务被阻止且充值关闭;入账后撤销验证余额不自动扣回、充值保持完成,并产生严重审计和平台异常通知。
|
||||
- 真实企微至少使用平台本人绑定身份创建一笔独立线下代充值,上传真实 S3付款凭证,完成 `applyevent`、人工同意、加密回调或轮询同步、钱包入账、唯一流水、到账通知和审计核对。回调沿用“企业微信 → 用户中转应用 → 本地服务”原样转发,后端仍完成验签解密。
|
||||
- 真实企微不要求每次人工制造撤销、删除或通过后撤销,这些异常由可编程 WeCom Adapter 自动化稳定覆盖。
|
||||
|
||||
### 查询、权限、迁移和前端验收
|
||||
|
||||
- 查询覆盖默认分页、最大页大小、稳定排序、多个筛选 AND组合、代理本店/下级范围、平台范围、企业拒绝,以及列表/详情/支付状态/附件使用同一权限投影。
|
||||
- 信息投影分别以平台和代理读取同一线下充值:平台在具备业务权限时可见完整审批资料,代理只能看到业务资料和最小审批摘要;任何响应不返回支付配置、通道或内部错误。
|
||||
- 迁移演练覆盖支付状态盘点、默认值/注释/约束不一致、历史终态 legacy、可迁移待处理线下单、缺少平台绑定或附件的异常清单、重复执行幂等,以及旧 `offline-pay`、`reject`、`resubmit` 路由确实不存在。
|
||||
- 前端验收覆盖可用方式加载、每次主动新建、二维码渲染、无倒计时、3 秒本地轮询、页面隐藏暂停、支付成功/入账处理中/失败、余额刷新、线下表单、企微只读详情、审批与到账两类通知、加载/空/失败状态。
|
||||
- 本地自动化不要求真实微信或支付宝。部署到测试环境后由用户手工完成一笔微信 Native 和一笔支付宝 PreCreate 最低金额扫码,验证真实付款内容、回调/查单、本地支付状态、钱包入账和到账通知;该人工联调不阻塞本地实现完成门禁,但属于上线前验收。
|
||||
- 完成门禁至少包括目标单元/集成测试、相关包测试、全量 Go 测试、并发/竞态专项、静态检查、迁移演练、OpenAPI 生成校验、真实 S3和真实企微线下充值。新增 Handler 时同步两个接口文档生成入口。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不建设多支付通道池、通道优先级、权重轮询、自动切换或故障转移。
|
||||
- 不让前端选择或提交支付通道、支付配置 ID、商户号或目标在线充值店铺。
|
||||
- 不生成二维码图片,不新增二维码生成、重新生成、主动取消支付或手工关单接口。
|
||||
- 不向后台在线充值返回 `expires_at`,不展示本地推算的精确倒计时,也不以本地时间直接关闭第三方支付单。
|
||||
- 不复用相同金额或未过期支付单;也不因新单创建而自动取消旧单。
|
||||
- 不在本地或 Agent 自动化中调用真实微信、支付宝;真实扫码由部署测试环境人工验收。
|
||||
- 不接入微信/支付宝自动退款,不因充值状态 5 扩展本期退款流程。
|
||||
- 不允许代理线下代充值,不允许平台/超级管理员替代理创建在线支付,不允许企业账号访问。
|
||||
- 不为新充值支持 `bank`,不允许创建后修改金额、目标店铺或支付方式。
|
||||
- 不保留本地确认入账、拒绝、退回、重提、操作密码或人工重复入账按钮。
|
||||
- 不新增 `7=已退回`、支付“已关闭”状态或 `wallet_posting_status` 同义字段。
|
||||
- 不向代理公开企微审批人、内部意见、审批人附件、支付配置或内部异常详情。
|
||||
- 不借 UR#34 重构 C 端全部支付、全仓钱包、全局支付配置或未触碰的历史订单模块。
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 当前代码已经有代理充值表、主钱包和流水、统一 `tb_payment`、微信回调基础、支付宝 WAP 支付基础及支付配置,但代理充值创建只支持 `wechat/offline`,未创建支付单或返回付款内容;支付宝 PreCreate、微信 Native 后台充值、支付查单补偿和两阶段入账需要补齐。
|
||||
- 当前支付配置模型把微信提供方和支付宝参数放在同一条全局生效配置中。UR#34 按现状复用,不把它扩成多通道路由系统。
|
||||
- 当前微信 v3 已有查单/关单能力,微信 v2适配器缺少查单;支付宝 SDK具备 PreCreate、TradeQuery和TradeClose。支付方式可用性必须以本需求实际需要的 Adapter能力为准,不能只判断某个字段非空。
|
||||
- 当前 `tb_payment` 迁移写的是 `1~4`,运行代码和 DTO使用 `0~3`;这是实施前必须通过存量核查解决的历史一致性问题,不是新 Agent可以忽略的文档差异。
|
||||
- 当前旧线下充值使用字符串 Key列表、操作密码和本地 `offline-pay/reject`。新实现以本 PRD 和 UR#37公共企微契约为准,旧实现仅用于迁移事实核对,不代表目标行为。
|
||||
219
.scratch/ur35-refund-wecom-approval/PRD.md
Normal file
219
.scratch/ur35-refund-wecom-approval/PRD.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# PRD:UR#35 退款企微审批与整单终结
|
||||
|
||||
Status: ready-for-agent
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
当前退款流程把退款申请、平台内审批、退款金额修改、钱包回款、佣金回扣和套餐失效混在同一个旧 Service 中。系统仍提供本地通过、拒绝、退回和重提接口,审批人能够修改实际退款金额;审批通过后又用进程内 Goroutine 分别处理佣金与套餐,失败后既不能可靠恢复,也可能错误地把未完成的处理标记为完成。
|
||||
|
||||
现有退款创建接口还要求调用方提交订单实收金额,并允许用单条套餐使用记录限定退款范围。这样会把本应由订单事实决定的数据交给前端,也与本期确认的“金额可以小于实收,但一旦通过就按整张订单终结”相冲突。代理退款查询当前按创建账号隔离,上级代理无法在既有店铺层级数据范围内管理下级退款;平台内部审批资料又缺少按主体投影,存在向代理泄漏审批人、意见或审批附件的风险。
|
||||
|
||||
退款涉及真实资金、负余额、已发放佣金、套餐队列、资产状态和外部企微终态。系统必须明确区分申请金额、实际完成金额、企微审批状态和本地业务处理状态,并保证重复回调、重复 Worker、进程中断和局部失败不会造成重复回款、重复扣佣或伪造成功。
|
||||
|
||||
## Solution
|
||||
|
||||
保留现有退款创建、列表和详情资源,但把新退款申请接入 UR#37 提供的企业微信审批公共能力。创建时后端从订单读取实收金额及相关快照,只接收申请金额、原因、备注和 1~5 个本地对象存储附件;金额提交后固定,企微审批人只能同意或拒绝。退款单、唯一企微审批实例和提交 Outbox 在同一 PostgreSQL 事务创建,接口立即返回本地退款及“企微提交中”状态。
|
||||
|
||||
企微同意后,无论申请金额是否等于订单实收金额,都启动整单退款终结:订单变为已退款,该订单产生的有效套餐全部失效,相关佣金全部失效;已发放佣金从对应佣金钱包全额扣回,余额允许为负。只有代理主钱包支付订单自动按原扣款流水回溯原钱包;微信、支付宝、线下及个人资产钱包订单均由财务先在系统外完成退款,再在企微同意,系统不调用支付渠道退款,也不回充资产钱包。
|
||||
|
||||
审批结论和本地业务处理分开保存与展示。企微已经同意后,即使资金、佣金或套餐处理失败,审批状态和退款状态仍保持已通过,失败由独立处理状态、错误摘要和可靠 Worker 重试表达。企微拒绝即终结当前退款;业务人员修正问题后若仍需退款,必须新建退款单,不能编辑或重提原单。
|
||||
|
||||
## User Stories
|
||||
|
||||
1. 作为代理退款发起人,我希望使用订单事实创建退款,而不需要自行填写订单实收金额或选择某条套餐使用记录。
|
||||
2. 作为平台退款发起人,我希望填写固定的申请退款金额、原因、备注和业务凭证,并在提交后立即获得本地退款单号。
|
||||
3. 作为退款发起人,我希望系统在申请金额不大于订单实收金额时允许提交,并明确告诉我本次通过后会整单终结。
|
||||
4. 作为退款发起人,我希望同一订单存在审批中或业务处理未完成的退款时不能再次创建,避免并发退款。
|
||||
5. 作为平台账号,我希望通过本人绑定的企微成员发起审批,确保企微中的实际发起身份可追溯。
|
||||
6. 作为代理账号,我希望无需绑定企微,由配置的固定成员代提交,同时审批表单仍展示我才是真实业务提交人。
|
||||
7. 作为审批人,我希望在企微看到退款单号、订单实收金额、可退款区间、本次申请金额、原因、备注、附件和真实提交人。
|
||||
8. 作为审批人,我只能同意或拒绝固定金额,不能在审批时修改退款金额。
|
||||
9. 作为业务人员,我希望金额填写错误时拒绝当前单并重新创建,而不是改写已经提交的业务事实。
|
||||
10. 作为财务人员,我希望微信、支付宝、线下及资产钱包订单先在线下完成人工退款,再通过企微表达已确认完成,无需回到系统点击第二次确认。
|
||||
11. 作为代理主钱包所有者,我希望企微同意后资金准确退回当时真实扣款的钱包,而不是按当前代理关系猜测退款钱包。
|
||||
12. 作为欠款代理,我希望退款可以自然冲减主钱包负余额,而不是因余额为负而拒绝入账。
|
||||
13. 作为个人客户,我希望资产钱包支付订单仍可申请退款,但系统不会错误地把资金自动充回资产钱包。
|
||||
14. 作为佣金归属代理,我希望订单退款时未发放佣金停止发放,已发放佣金被完整扣回,且每笔变化可追溯。
|
||||
15. 作为佣金归属代理,我接受退款回扣后佣金钱包余额为负,以真实反映已经支取但现应追回的佣金。
|
||||
16. 作为套餐使用者,我希望退款通过后该订单生成的有效套餐全部失效,主套餐失效时其加油包也一并失效。
|
||||
17. 作为资产使用者,我希望退款套餐失效后系统尝试激活下一条排队主套餐;没有可激活套餐时停止资产使用。
|
||||
18. 作为退款发起人,我希望看到申请金额与实际退款金额的区别,避免把审批通过误认为所有本地处理都已完成。
|
||||
19. 作为退款发起人,我希望企微拒绝后原退款单保持只读,并能从订单重新进入创建流程发起一张新退款。
|
||||
20. 作为代理管理者,我希望按既有店铺层级和退款查看权限看到本店及有权管理的下级店铺退款,而不是只能看到自己创建的记录。
|
||||
21. 作为代理查看者,我希望查看退款凭证、真实业务提交人、审批状态和业务处理结果,但看不到平台内部审批人、意见或审批人附件。
|
||||
22. 作为平台查看者,我希望只有在具备退款业务查看权限时才能读取完整企微审批时间线和附件,企微运营权限不能绕过业务权限。
|
||||
23. 作为运维人员,我希望审批状态、退款状态和业务处理状态分别展示,能准确识别“企微已通过但本地处理失败”。
|
||||
24. 作为运维人员,我希望资金、佣金、订单和套餐步骤失败后可以可靠重试,且已经完成的步骤不会重复执行。
|
||||
25. 作为审计人员,我希望退款、订单、钱包、资金流水、佣金、套餐和企微实例之间可以完整关联,并保留金额与状态的前后事实。
|
||||
26. 作为审计人员,我希望自动退款、佣金失效、通过后撤销和人工异常处置都有明确原因与操作者,而不是只依赖自由文本备注。
|
||||
27. 作为系统维护人员,我希望企微重复回调、回调与轮询并发、Worker 重投和进程中断都不会重复回款或重复扣佣。
|
||||
28. 作为系统维护人员,我希望企微撤销、删除和通过后撤销进入明确异常处置,不会自动放行新的退款或自动冲正已经完成的资金。
|
||||
29. 作为前端开发者,我希望创建、列表和详情返回稳定的退款、审批及处理契约,不需要在页面推断资金是否已经完成。
|
||||
30. 作为验收人员,我希望实现阶段同时通过可重复的企微 Adapter 自动化和真实企微同意/拒绝链路,而不是把真实企微验证推迟到后续联调。
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### 范围、依赖与领域口径
|
||||
|
||||
- UR#35 负责退款申请、退款终态消费、整单退款终结、退款 Query 和前端业务页面;企微连接、身份绑定、模板版本、附件上传、提交、加密回调、轮询和审批详情由 UR#37 的公共能力提供,退款模块不得自行实现第二套企微客户端或审批状态机。
|
||||
- “申请退款金额”是提交企微的固定金额;“实际退款金额”只表示资金已经实际完成。两者不因为整单终结而自动改成订单实收金额。
|
||||
- “整单退款终结”表示订单、该订单生成的套餐和该订单佣金资格全部终结,不表示必须按订单全额向客户退款。即使申请金额小于订单实收金额,也不保留差额的第二次退款权利。
|
||||
- 一张退款单只对应一条企微审批实例。企微拒绝后的再次退款是新的业务事实,必须有新的退款 ID、退款单号、申请资料、提交人快照和企微审批实例。
|
||||
- 退款创建、企微终态消费、代理钱包回溯、佣金失效和套餐失效属于复杂写用例,采用 Application UseCase、Domain、Repository 与 Infrastructure Adapter 分层;退款列表和详情采用 Query 通道。只迁移完成本需求所需的最小完整退款用例,不主动迁移未触碰的订单、钱包或套餐模块。
|
||||
|
||||
### 创建退款契约
|
||||
|
||||
- 保留 `POST /api/admin/refunds`。允许超级管理员、平台账号和代理账号发起;企业账号不允许发起。所有主体仍受既有认证、退款业务权限、订单数据范围和越权防护约束。
|
||||
- 请求只包含:`order_id`;`requested_refund_amount`;必填 `refund_reason`(最多 1000 字);可选 `remark`(最多 500 字);必填 `attachments`(1~5 项)。
|
||||
- 每个附件项固定包含 `file_key`、`file_name` 和 `file_size`。附件必须来自当前本地私有对象存储授权范围,后端按现有存储规则校验对象存在、上传归属、文件类型和实际大小;请求中的名称与大小作为申请快照,不能替代对象元数据校验。
|
||||
- 新请求不再接受 `actual_received_amount` 或 `package_usage_id`。订单实收金额、订单号、资产、店铺、买卖方、支付方式和必要的资金关联事实由后端从有权限访问的订单读取并固化快照。
|
||||
- 申请金额必须满足 `0 < requested_refund_amount <= 订单实收金额`。只允许对已支付且尚未整单退款的订单创建。
|
||||
- 创建前必须确认 `refund_approval` 场景、当前模板和本次企微发起身份可用。平台/超级管理员使用本人有效企微绑定;代理使用配置的固定代提交成员。任一前置不可用时,在写入退款、审批实例或 Outbox 前失败,前端保留当前表单。
|
||||
- 同一订单存在提交中、审批中、已通过但业务处理未成功,或尚未完成异常人工处置的退款时拒绝创建。已拒绝退款不再占用活跃名额;已撤销、已删除或通过后撤销不能自动放行新建。
|
||||
- 退款单、唯一审批实例和 `WeComApprovalSubmissionRequested` Outbox 必须在同一 PostgreSQL 事务创建。创建接口不等待远程企微,成功响应返回退款详情以及 `approval.status=0` 的“提交中”状态。
|
||||
- 创建并发通过数据库唯一约束、状态条件或等价的持久化业务约束保证同一订单不会产生两张活跃退款;Redis 只能作为快速防重,不能成为唯一正确性依据。
|
||||
|
||||
### 固定金额与企微表单
|
||||
|
||||
- 退款企微模板至少展示:退款单号、订单号、店铺、真实业务提交人、订单实收金额、可退款区间、本次申请退款金额、退款原因、申请备注和申请附件。
|
||||
- 可退款区间在提交时固定为大于 0 且不超过订单实收金额;本次申请金额只读。企微审批人只能使用模板配置的同意或拒绝动作,不提供金额修改控件。
|
||||
- 系统不提供本地审批按钮,也不向企微回写或伪造审批结论。认为金额错误时必须拒绝当前退款,再创建新退款。
|
||||
- 申请附件的本地对象 Key 是权威资料;上传企微的 `media_id` 只是审批副本。审批人后续上传的附件属于企微审批资料,不能替代退款申请附件。
|
||||
|
||||
### 三套独立状态
|
||||
|
||||
- 退款业务状态为生命周期 `int`:`1=待审批`、`2=已通过`、`3=已拒绝`、`4=已退回`、`5=已撤销/审批已删除`。状态 4 只保留历史兼容,新企微审批不再产生“退回”。
|
||||
- 本地业务处理状态为生命周期 `int`:`0=未触发`、`1=处理中`、`2=处理成功`、`3=处理失败`。各响应同时返回对应的 `processing_status_name`。
|
||||
- 企微审批状态完全复用 UR#37 公共常量:`0=提交中`、`1=审批中`、`2=已通过`、`3=已驳回`、`4=已撤销`、`5=通过后撤销`、`6=已删除`、`7=提交失败`、`8=提交结果未知`。退款模块不得复制或重新编号。
|
||||
- 三套状态分别返回,禁止用退款状态或处理状态覆盖企微状态。所有状态 DTO 的 description 必须从公共 constants 原文复制,并提供对应中文名称字段。
|
||||
- 企微驳回把退款状态从待审批条件更新为已拒绝,处理状态保持未触发;拒绝原因和企微时间线来自公共审批快照。
|
||||
- 企微同意把退款状态条件更新为已通过,并触发可靠的退款终态 Outbox。即使后续本地处理失败,退款状态和企微状态也不得降级或回改。
|
||||
- 企微撤销或删除把退款状态置为 5,记录异常原因并进入人工处置;不自动执行资金动作,也不自动允许新退款。
|
||||
- 通过后撤销时,资金尚未执行则阻止后续资金任务;资金已执行则不自动冲正,保留实际退款金额,写 `critical` Audit Event 和站内告警,交由人工处理。
|
||||
|
||||
### 资金处理与实际退款金额
|
||||
|
||||
- `requested_refund_amount` 始终是申请金额。新增 `actual_refund_amount`,只在资金已实际完成时写入本次申请金额;未完成时为 `null`。
|
||||
- 微信、支付宝、线下及其他非代理主钱包支付订单,由财务在系统外完成退款后再同意企微。企微同意被视为财务已经确认完成,终态 Worker 写入 `actual_refund_amount=requested_refund_amount`;系统不调用支付渠道退款 API,也不提供本地 `manual-complete` 二次确认。
|
||||
- 个人客户使用资产钱包支付的订单同样由财务在系统外退款。系统不得调用现有资产钱包自动回充逻辑,不创建资产钱包退款流水;企微同意时记录实际退款金额并继续整单终结。
|
||||
- 代理主钱包支付订单只按原订单扣款流水定位原主钱包和原资金关系,退回本次申请金额并写唯一退款流水。禁止根据当前店铺上下级关系、当前买卖方或当前钱包猜测退款目标。
|
||||
- 如果代理订单缺少可核验的原扣款流水,资金步骤失败,`actual_refund_amount` 保持为空,处理状态为失败并记录可运维错误摘要;不得走历史兼容猜测分支。
|
||||
- 代理主钱包余额增加与钱包版本、唯一退款流水、`actual_refund_amount` 和资金 Audit Event 在同一事务提交。钱包原余额为负时允许正常增加,结果自然冲减欠款。
|
||||
- 资金已经完成但后续佣金或套餐处理失败时,`actual_refund_amount` 必须保留,不能因整单处理未成功而清空或重复退款。
|
||||
- 旧 `approved_refund_amount` 只保留历史读取和迁移兼容,不再是新创建、企微表单或新公共响应中的业务概念。
|
||||
|
||||
### 订单、佣金与套餐整单终结
|
||||
|
||||
- 一旦企微同意,本次处理以整张订单为边界。资金步骤完成后,订单支付状态条件更新为已退款;申请金额小于实收金额时也执行同样更新,并禁止该订单再申请差额退款。
|
||||
- 查找该订单产生的全部佣金记录。处于已冻结、解冻中、尚未发放或待人工修正的记录不移动钱包,直接改为已失效;处于已发放的记录先从对应佣金钱包全额扣回,再改为已失效,钱包允许变为负数。
|
||||
- 佣金记录增加结构化失效事实:`invalid_reason` 至少支持 `order_refund` 和 `manual_resolution`;退款自动失效时保存 `invalid_refund_id`、`invalidated_at`,人工失效时另存人工操作者。不能只在 `remark` 中描述失效。
|
||||
- 每条已发放佣金以“退款单 + 佣金记录”为业务防重键。佣金状态、佣金钱包余额和版本、回扣流水及统一 Audit Event 在同一事务提交;命中已完成防重事实时直接返回成功,不重复扣款或重复审计。
|
||||
- 退款失效后的佣金不得被原有解冻、发放或补算任务重新发放。订单佣金流程的派生结论必须与全部佣金已失效保持一致。
|
||||
- 失效该订单生成的所有仍有效套餐,不接受 `package_usage_id` 精确失效。命中主套餐时级联失效其加油包,并为套餐保存退款 ID、退款单号及失效时间等退款快照。
|
||||
- 套餐失效完成后,按现有套餐队列规则尝试激活下一条待生效主套餐;没有下一条主套餐时按公共卡/设备状态写入能力停止资产。该过程复用套餐与卡状态领域能力,不在退款模块复制状态判断。
|
||||
- 资金、订单、每条佣金和套餐步骤均需可单独识别已完成事实。任何步骤失败都把处理状态置为失败并由可靠 Worker 重试;只有全部必要步骤成功后才把处理状态置为处理成功。
|
||||
|
||||
### 可靠执行、幂等与审计
|
||||
|
||||
- 不再使用进程内 Goroutine 处理佣金或套餐。企微首次进入终态时由公共审批能力写业务 Outbox,退款终态 Worker 使用 Asynq 执行;载荷只传结构化退款标识,不传预序列化字节、附件内容或密钥。
|
||||
- Worker 通过处理状态、条件更新和有期限租约领取任务。未过期的处理中任务不能被第二个消费者重复执行;租约过期可恢复。失败摘要不得包含数据库连接、对象存储签名 URL、企微密钥或原始第三方响应。
|
||||
- 退款终态事件、资金回款、佣金回扣、套餐失效和处理成功都必须各自具备持久化业务幂等事实。Redis 锁可以减少并发,但不能代替数据库条件更新、唯一约束和钱包乐观锁。
|
||||
- 审计使用全局统一 Audit Event,不新建退款私有审计表。至少关联退款、订单、审批实例、钱包、钱包流水、佣金记录和套餐使用记录,并记录操作来源、真实业务提交人、系统执行身份、失效原因、金额和状态前后值。
|
||||
- 钱包余额/版本/流水与其资金 Audit Event 同事务;佣金状态/钱包/回扣流水与其 Audit Event 同事务;订单及关键退款状态变更与对应 Audit Event 同事务。重复任务命中已完成事实时不新增第二条等价审计。
|
||||
- 外部企微调用、附件上传、回调和详情同步使用公共 Integration Log;Integration Log 只记录接口、耗时、企微错误码/摘要和关联标识,不记录 Token、Secret、EncodingAESKey、完整对象 Key、临时 `media_id` 或文件内容。
|
||||
|
||||
### 权限、数据范围与信息投影
|
||||
|
||||
- 移除当前代理按 `creator` 隔离退款的专属规则。代理主账号及店铺内具备退款查看权限的账号按既有店铺层级数据范围读取本店及可管理下级店铺退款。
|
||||
- 列表、详情、业务附件下载和导出必须复用同一主体权限投影。资源不存在与无权访问统一返回禁止访问语义,不能借错误差异探测其他店铺退款。
|
||||
- 代理可见:退款业务资料、申请附件、订单及金额快照、真实业务提交人、企微审批状态和时间、业务处理状态及面向业务的失败提示。
|
||||
- 代理不可见:企微审批人、内部意见、审批人上传附件、企微内部成员标识、模板内部映射及运维错误详情。
|
||||
- 平台账号和超级管理员仍必须具备退款业务查看权限,才可读取完整审批人、意见、时间线和审批附件。企微审批运营或异常恢复权限本身不能绕过退款业务数据权限。
|
||||
- 附件返回受保护的业务附件引用及下载能力,不把对象存储签名 URL 或企微临时 `media_id` 作为永久字段。历史导出或已获得的附件引用也不能绕过当前权限。
|
||||
|
||||
### API 与查询契约
|
||||
|
||||
- 保留 `POST /api/admin/refunds`、`GET /api/admin/refunds` 和 `GET /api/admin/refunds/{id}`。
|
||||
- 下线并不再注册:`POST /api/admin/refunds/{id}/approve`、`/reject`、`/return`、`/resubmit`,以及任何本地人工退款确认接口。不能保留隐藏兼容入口。
|
||||
- 列表继续使用 `page`、`page_size`、退款状态、订单、店铺和资产标识等既有筛选;默认第 1 页、每页 20、最大 100,默认按创建时间倒序并以 ID 作为并列排序键。所有筛选按 AND 组合,空参数不改变原查询。
|
||||
- 创建成功的 `data` 与详情使用同一退款详情结构。列表项固定返回退款 ID/单号、订单与资产快照、店铺、真实业务提交人、订单实收金额、申请金额、实际退款金额、退款状态及名称、企微来源/状态及名称、当前审批人摘要和处理状态及名称。代理投影中的当前审批人摘要固定为空。
|
||||
- 详情返回完整退款业务资料、申请附件、订单/支付快照、`approval` 对象以及根级处理状态。申请附件项返回 `file_key`、`file_name`、`file_size` 和受保护下载能力;不能返回对象存储永久地址或企微 `media_id`。
|
||||
- `approval` 固定包含 `source`、`approval_instance_id`、可空 `sp_no`、`status`、`status_name`、`template_version`、真实业务提交人、状态更新时间和 `business_process_result`;平台完整投影另包含审批人、意见、审批附件和时间线,代理投影不返回这些内部字段。
|
||||
- 详情根级处理字段至少包含 `processing_status`、`processing_status_name`、面向当前主体脱敏后的 `processing_error`、开始时间和完成时间。审批状态、退款状态和处理状态不得合并成单个前端状态。
|
||||
- 历史本地审批返回 `approval.source=legacy`,不伪造 `sp_no`、审批节点或企微时间线。新企微退款固定返回 `approval.source=wecom`。
|
||||
- 所有接口使用统一 `{code,msg,data,timestamp}` 响应和项目分页结构。错误码固定复用当前公共语义:请求字段、金额或附件元数据非法使用 `CodeInvalidParam=1001`;未登录使用 `CodeUnauthorized=1004`;订单或退款不存在与越权统一使用 `CodeForbidden=1005`;订单并非已支付、已经整单退款或退款状态不允许使用 `CodeInvalidStatus=1050`;活跃退款及并发重复创建使用 `CodeConflict=1007`;对象不存在/类型非法分别使用 `CodeStorageFileNotFound=1093`、`CodeStorageInvalidFileType=1095`;场景或模板不可用、代理固定成员不可用使用 `CodeServiceUnavailable=2004`。平台本人未绑定使用 `CodeInvalidStatus=1050` 和稳定消息“请先绑定企业微信”,前端结合本人绑定查询显示绑定入口。任何错误均不得透传底层企微、数据库、Redis、对象存储或验证器信息。
|
||||
|
||||
### 前端页面与交互
|
||||
|
||||
- 退款创建表单只展示订单、申请退款金额、必填原因、可选备注和 1~5 个附件。订单实收金额及“通过后整单终结”的提示由后端订单/退款契约展示,不允许前端提交或覆盖实收金额。
|
||||
- 提交中禁用重复提交;创建成功后进入退款详情,显示企微“提交中”。场景暂停、平台账号未绑定、代理固定成员不可用或附件校验失败时保留全部表单内容,并展示后端明确原因;平台账号未绑定时可原地进入 UR#37 的本人绑定流程。
|
||||
- 退款详情分为“退款业务信息”“企微审批信息”“业务处理结果”三个稳定区域。审批与处理状态各自有加载、空、失败和刷新表现。
|
||||
- 页面不显示本地通过、拒绝、退回、重提、审批金额修改或人工退款确认按钮。企微拒绝后只读展示原因;若仍需退款,从订单重新进入创建页,不在旧退款详情中编辑。
|
||||
- 对非代理钱包订单明确提示财务必须先在系统外完成退款再同意企微;对代理钱包订单提示同意后系统自动回溯原主钱包。
|
||||
- 处理失败时代理只看到可行动的业务提示,平台按权限看到脱敏错误摘要和“系统重试中/联系管理员”;不得提供会重复执行资金动作的前端按钮。
|
||||
- 通过后撤销等高风险异常使用明显告警,并说明资金不会自动冲正。代理与平台页面严格遵守各自审批资料投影。
|
||||
|
||||
### 数据迁移、发布与回滚
|
||||
|
||||
- 退款记录补充唯一审批实例引用、真实提交人显示快照、结构化申请附件、申请备注、实际退款金额、处理状态、错误摘要、处理开始/完成时间及处理租约/版本等可靠执行字段。数据库不建立外键,也不使用 GORM 关联标签。
|
||||
- 现有 `actual_received_amount` 继续作为后端生成的订单实收快照;新接口不再接受客户端值。现有 `package_usage_id` 和 `approved_refund_amount` 仅保留历史兼容,新退款不写入且整单处理不读取。
|
||||
- 现有 `remark` 若包含历史审批备注,不直接改写语义;新申请备注使用明确的申请备注字段。新附件使用结构化元数据保存,旧 `refund_voucher_key` 只读兼容,并在对象仍存在时投影为历史业务附件。
|
||||
- 佣金记录补充结构化失效原因、关联退款、失效时间和必要的人工操作者字段;已发放佣金回扣流水建立退款与佣金记录级唯一业务约束。
|
||||
- 停机发布前盘点待审批、已通过但旧异步标记未完整、已拒绝、已退回及历史终态退款。历史终态保留为 `legacy`;待审批记录按 UR#37 迁移规则创建真实企微审批,缺少平台绑定、代理固定身份、附件或真实提交人事实的记录进入明确的迁移待处理清单。
|
||||
- 历史状态 4 保持“已退回”只读,不自动创建新审批或改为拒绝。历史已通过但资金、佣金或套餐事实不一致的记录先进入对账与人工处置,不允许迁移脚本猜测已完成。
|
||||
- 发布顺序必须先具备 UR#37 公共企微能力、真实模板映射、平台绑定和代理固定成员,再启用退款创建与终态 Worker;旧本地审批路由在同一停机窗口移除。
|
||||
- 应用回滚必须保留已形成的退款、企微实例、Outbox、Integration Log、Audit Event、资金流水和失效事实。已经进入真实企微的退款不得恢复旧本地审批按钮,只能继续同步、重试安全的本地步骤或人工处置。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- 最高公共自动化接缝为:Fiber HTTP 路由与真实认证 → Refund Application/Domain/Query → GORM → 现有测试 PostgreSQL → Outbox/公开 Worker Handler → 钱包、佣金、套餐和统一审计;企业微信网络边界使用可编程 WeCom Adapter。测试只断言公开响应、数据库业务事实、资金流水、状态和审计,不断言私有函数或目录结构。
|
||||
- 本地开发和 Agent 自动化测试必须使用 Redis DB 7;已部署测试环境继续使用 DB 6。测试入口在启动前读取并验证 Redis Client、Asynq Client 与 Worker Server 的实际 DB,任一不是 7 就立即失败。禁止向 DB 6 投递任务,禁止对 DB 7 执行 `FLUSHDB`。
|
||||
- PostgreSQL 沿用现有测试库,不新建数据库。每次运行使用唯一标识创建隔离订单、店铺、钱包、佣金、套餐和退款夹具,只按实际创建 ID 精确清理;禁止 `TRUNCATE`、清表、模糊删除或修改既有业务数据。
|
||||
- 对象存储直接使用现有真实 S3,不建立内存替身。测试使用唯一 Key 上传申请附件,验证元数据、下载及企微附件提交,结束时只删除本次创建对象;真实上传、读取或删除失败均使测试失败。
|
||||
- 自动化测试捕获待投递任务后直接调用公开 Worker Handler,不通过 `sleep` 等待后台 Worker。实现需沉淀可复用的环境守卫、唯一夹具、精确清理、真实 S3 和 Worker 驱动 Harness,并提供真实 HTTP/curl 冒烟模板;curl 不替代 Go 自动化断言。
|
||||
- 创建 HTTP 测试覆盖三类允许账号、企业账号拒绝、订单越权、订单不存在、未支付/已退款订单、金额为零/负数/超过实收、缺少原因、备注超长、附件数量/元数据/归属错误、场景不可用、平台未绑定、代理固定成员失效和并发重复创建。
|
||||
- 创建事务测试证明退款、唯一企微实例和提交 Outbox 要么全部成功,要么全部不存在;远程企微尚未响应时接口仍只产生一组本地事实。
|
||||
- 固定金额测试证明申请金额写入后不会被企微详情、重复回调或终态 Worker 修改;企微表单包含实收金额、可退款区间和申请金额,审批动作不接受金额字段。
|
||||
- 状态测试覆盖企微提交中、审批中、通过、拒绝、撤销、删除、通过后撤销、提交失败和结果未知,并证明退款状态、企微状态与处理状态独立。历史已退回只读且旧重提路由不存在。
|
||||
- 非代理钱包测试覆盖微信、支付宝、线下和个人资产钱包:企微通过后记录实际退款金额,不调用任何渠道退款或资产钱包回充,不生成对应自动退款流水,但仍执行订单、佣金和套餐整单终结。
|
||||
- 代理钱包测试使用真实钱包与版本字段,覆盖按原扣款流水退款、负余额冲减、缺失原扣款流水、重复 Worker、并发 Worker、余额更新后崩溃恢复和唯一退款流水;证明系统不按当前代理关系猜测钱包。
|
||||
- 整单语义测试至少包含“申请金额小于订单实收金额”,验证只退申请金额但订单仍标记已退款、该订单全部有效套餐及佣金均终结,且不能再申请剩余差额。
|
||||
- 佣金测试覆盖已冻结、解冻中、已发放、已失效和待人工修正;已发放记录全额扣佣金钱包并允许负数,其他未发放状态不动钱包;验证结构化失效字段、唯一回扣流水、同事务 Audit Event 和原发放任务不能复活记录。
|
||||
- 套餐测试覆盖订单生成的待生效/生效中等有效主套餐、加油包级联、下一主套餐激活、无下一套餐时停止资产、重复处理、处理中崩溃和状态写入失败恢复;不得使用单个 `package_usage_id` 缩小范围。
|
||||
- 局部失败测试依次制造资金失败、佣金中途失败、套餐失效失败和资产状态失败。资金未完成时实际退款金额为空;资金完成后的下游失败保留实际退款金额;修复后重试只补未完成步骤,最终不产生重复资金或审计。
|
||||
- 权限测试对同一退款使用本店代理、上级代理、无管理关系代理、具备业务权限平台、仅具备企微运营权限平台和超级管理员读取,验证店铺层级范围及平台业务权限。列表、详情、附件下载和导出必须给出一致投影。
|
||||
- 信息投影测试证明代理可见业务凭证、真实提交人、审批状态和处理结果,但看不到审批人、内部意见或审批人附件;有退款业务权限的平台可以看到完整审批详情,无业务权限的平台即使有企微运营权限也不能读取。
|
||||
- 可靠性测试覆盖 Outbox 重投、Asynq 重投、租约过期、回调与轮询并发、重复终态、乱序状态、乐观锁冲突和处理成功后再次消费。每个外部终态只触发一次业务处理,完成步骤不重复。
|
||||
- 可编程 WeCom Adapter 自动化必须覆盖真实企微难以稳定制造的超时、明确失败、响应丢失、重复/乱序回调、撤销、删除、通过后撤销、并发轮询和限流。加密回调自动化仍从真实 HTTP 回调入口进入,使用测试 Token/AES Key 生成协议密文并验证签名、解密和 CorpID,不能绕过协议直接调用内部同步函数。
|
||||
- 真实企微验收是 UR#35 实现完成门禁,不得推迟到 INT-06。真实参数、模板和密钥只通过环境变量或安全配置提供,不写入 Spec、源码、日志或报告;回调按“企业微信 → 用户提供的中转应用 → 本地服务”进入,中转应用原样转发企微查询参数与请求体,后端仍执行完整验签、解密和 CorpID 校验。
|
||||
- 真实企微至少执行两张独立退款:其一由代理创建,使用固定企微成员代提交并人工同意,建议选择代理主钱包订单,同时验证真实附件上传、`applyevent`、加密回调、钱包回溯、佣金、套餐、订单和审计;其二由平台账号创建,使用本人绑定企微身份并人工拒绝,验证轮询兜底、退款终结且不触发资金或套餐处理。
|
||||
- 真实企微验收采用可复用两阶段流程:阶段一创建唯一隔离夹具并通过真实 HTTP 创建退款,输出运行 ID、退款号与 `sp_no`;人工在企微同意或拒绝;阶段二等待或主动驱动公共同步/轮询和退款 Worker,再自动核对数据库、钱包流水、佣金、套餐、审批实例、Audit Event 与幂等结果,并生成不含敏感信息的通过/失败报告。
|
||||
- 真实企微每次不强制人工制造撤销、删除或通过后撤销,这些异常由可编程 Adapter 自动化覆盖。真实验收必须证明同意、拒绝、真实附件、两类发起身份、真实加密回调和回调缺失时的轮询兜底。
|
||||
- 前端人工验收覆盖创建表单保留、本人绑定入口、代理代提交、列表和详情三状态分区、代理/平台投影、拒绝后新建、处理失败、通过后撤销高风险告警以及所有加载、空、失败和无权限状态。
|
||||
- 完成门禁同时要求:相关 Go 自动化、数据库和真实 S3 测试通过;可编程 Adapter 异常矩阵通过;两条真实企微验收通过;迁移演练、旧路由不存在、生成的 OpenAPI、前端接入和数据核对均通过。INT-06 只做跨需求与前后端复验,不能替代本需求真实企微门禁。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不建设本地审批流、审批任务、审批节点配置、审批按钮或本地“待我审批”。
|
||||
- 不允许企微审批人修改退款金额,也不保留 `approved_refund_amount` 作为新业务字段。
|
||||
- 不提供原退款单编辑、退回修改、重提或拒绝后的复用;再次退款必须新建。
|
||||
- 不调用微信、支付宝或其他支付渠道的自动退款 API。
|
||||
- 不自动回充个人资产钱包,不把资产钱包退款扩展为资金渠道能力。
|
||||
- 不提供非代理钱包的本地人工退款确认按钮或 `manual-complete` 接口。
|
||||
- 不自动冲正通过后撤销前已经完成的资金、佣金或套餐动作。
|
||||
- 不允许一张订单通过多张退款单拆分退款,也不保留申请金额之外差额的后续退款权利。
|
||||
- 不向代理公开审批人、内部意见、审批人附件或企微内部标识。
|
||||
- 不在 UR#35 重复实现 UR#37 的连接配置、账号绑定、模板发布、回调协议、轮询调度或异常恢复后台。
|
||||
- 不在本需求迁移无关订单、钱包、佣金、套餐或卡状态模块的全部旧代码。
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 当前代码仍要求前端提交实收金额和可选套餐使用记录,仍注册本地通过、拒绝、退回与重提接口;实现必须以本 Spec 为准删除这些新流程入口,不能把当前行为当成兼容要求。
|
||||
- 当前代理退款查询按创建账号过滤,与已确认的店铺层级查看范围冲突;Query 改造必须覆盖列表、详情、附件和导出,不能只改列表。
|
||||
- 当前代理钱包退款在找不到原扣款流水时会按关系猜测钱包,个人资产钱包会自动回充;两条兼容分支都与本 Spec 冲突,必须从新退款终态用例中移除或隔离。
|
||||
- 当前佣金和套餐后处理使用进程内 Goroutine,并可能在部分失败后写完成布尔值;这些布尔值只能用于历史对账,不能作为新处理链路的可靠幂等事实。
|
||||
- 当前套餐失效能力在不传 `package_usage_id` 时已经能够按订单查找目标并级联主套餐加油包,可作为迁移时的行为参考,但必须纳入可靠 Worker、状态条件、审计和下一套餐/资产状态闭环。
|
||||
- UR#37 必须先提供可调用的公共企微契约;UR#44 列表摘要、UR#42 退款附件导出和 UR#57 退款中禁止换货应消费本 Spec 的状态与权限投影,不得自行定义另一套“活跃退款”或审批信息。
|
||||
- 本需求同时触及企微、资金、佣金、套餐、权限、迁移和真实环境验收,预计超过一个高质量实现上下文。进入实现前应基于本 Spec 评估并拆分窄的端到端 tracer-bullet tickets;拆分不能按 Model、Service、Handler 和测试做水平切层。
|
||||
285
.scratch/ur36-bulk-package-purchase/PRD.md
Normal file
285
.scratch/ur36-bulk-package-purchase/PRD.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# PRD:UR#36 批量订购套餐
|
||||
|
||||
Status: ready-for-agent
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
运营人员需要根据一份离散资产清单批量为卡或设备订购套餐。现有后台单笔下单只能一次处理一个资产,无法提供批次进度、逐行结果、部分成功恢复和稳定幂等;原始需求中把整批理解为单一代理,也无法覆盖同一文件包含多个代理名下资产的真实场景。
|
||||
|
||||
本需求需要在不弱化现有单笔订单规则的前提下,增加 CSV 直传、异步解析和逐行下单能力。批次只统一支付方式,不统一代理:系统在处理每一行时根据资产当前归属确定结算代理,再校验该代理的套餐授权、成本价和主钱包。文件结构错误必须整批失败且不产生订单;资产、套餐、归属、重复行和余额等业务错误允许逐行失败。
|
||||
|
||||
## Solution
|
||||
|
||||
新增“批量订购套餐”入口。操作员在创建批次时选择一个套餐和一种支付方式,前端通过现有对象存储预签名接口把只含资产标识的 CSV 和线下凭证直传真实私有 S3,再提交唯一 `request_id`、`package_id`、整批支付方式和稳定对象 Key 创建任务;请求不包含 `shop_id`,也不上传文件字节。
|
||||
|
||||
创建接口完成对象归属、类型和 10MB 大小校验后立即返回任务。Worker 下载完整 CSV,先完成文件级校验并持久化逐行明细,再严格按 CSV 行号执行。每个有效业务行在独立事务中解析当前结算代理、复用统一套餐可售策略和订单领域,以 `wallet` 扣结算代理主钱包,或以 `offline` 创建已支付订单。任务和明细使用状态条件、处理租约及稳定幂等键保证重复消费不会重复下单或扣款。
|
||||
|
||||
## User Stories
|
||||
|
||||
1. 作为平台运营人员,我希望上传一份 CSV 为多张卡或设备订购套餐,而不是逐笔创建订单。
|
||||
2. 作为平台运营人员,我希望同一 CSV 可以包含不同代理名下的资产,不需要预先按代理拆文件或在页面选择代理。
|
||||
3. 作为平台运营人员,我希望整批明确选择钱包或线下支付,避免一份文件混合不同支付语义。
|
||||
4. 作为运营人员,我希望 CSV 只填写资产标识,由系统统一识别卡或设备及其支持的标识形式。
|
||||
5. 作为平台运营人员,我希望文件结构错误整批失败且不产生订单,而单行业务错误不影响其他合规行。
|
||||
6. 作为平台运营人员,我希望任务刷新后仍能恢复进度,并按行查看结算代理、金额、订单和中文失败原因。
|
||||
7. 作为财务人员,我希望线下凭证作为批次业务资料永久保留,但本期不把它扩展为财务核销系统。
|
||||
8. 作为代理,我希望批量钱包订购只扣我的主钱包,并继续遵守我的套餐授权、成本价和信用额度规则。
|
||||
9. 作为审计人员,我希望每一行都能追溯原始标识、规范资产、结算代理、套餐、金额和最终订单。
|
||||
10. 作为系统维护人员,我希望重复 HTTP 请求、重复 Worker 消费和进程中断恢复都不会产生重复订单或重复扣款。
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### 范围与领域口径
|
||||
|
||||
- “批量订购批次”是一份选择单个套餐并使用统一支付方式的 CSV 订购任务,不绑定单一代理。
|
||||
- “结算代理”是每一行处理时根据资产当前归属解析出的代理;套餐授权、成本价、钱包、订单买卖方快照和失败原因均以该代理为准。
|
||||
- `shop_id` 不是创建参数、CSV 字段或前端隐含参数。后端即使收到未知字段也不能据此改变结算代理。
|
||||
- 批量用例复用 Order 领域和统一 Wallet Domain,只迁移完成本用例所需的最小完整复杂写边界;列表、任务详情和明细使用 Query 通道。
|
||||
- 不复制现有巨大订单 Service 的宽松分支。需要把资产、套餐、可售策略、定价、订单、套餐生效和钱包扣款收口为可由单笔后台下单与批量单行命令共同调用的领域/Application 能力。
|
||||
- 本需求不改变现有单笔后台下单 HTTP 接口,也不把未触碰的订单模块一次性整体迁移。
|
||||
|
||||
### 入口与现有认证边界
|
||||
|
||||
- 不新增 `order:bulk_purchase` 或其他批量订购权限码,不在后端增加超级管理员、平台、代理或企业类型的显式拦截。
|
||||
- 页面是否展示入口由前端现有菜单与可见性规则决定;能够通过现有后台认证调用接口的主体,后端视为可以使用该能力。
|
||||
- 创建、任务详情和明细接口只复用现有后台路由认证及公共数据范围机制,不额外建立任务创建人隔离或新的 RBAC 判断。
|
||||
- “无新增后端权限拦截”不等于跳过业务规则。每一行仍必须校验资产当前归属、结算代理、所选套餐授权、可售状态、定价和钱包。
|
||||
- 创建任务成功、任务终态及每个成功订单均写公共审计。审计包含任务号、支付方式、文件安全摘要、凭证数量、汇总、操作者及逐行关联,不记录预签名 URL、鉴权令牌或环境密钥。
|
||||
|
||||
### CSV 契约
|
||||
|
||||
- 只接受 `.csv`,不接受 `.xlsx`、`.xls` 或把 Excel 文件改扩展名后的内容。
|
||||
- 编码固定为 UTF-8,可带 UTF-8 BOM;换行允许 LF 或 CRLF。
|
||||
- 前端随版本发布静态模板,后端不增加模板下载接口。建议模板文件名为 `批量订购套餐模板-v1.csv`。
|
||||
- 固定且唯一的表头为单列 `资产标识`。缺列、多列、重复列或未知列均为文件级错误;CSV 不包含资产类型、套餐编码或套餐名称。
|
||||
- 套餐由创建任务请求中的单个 `package_id` 决定,整个批次的每一行都订购该套餐。
|
||||
- 资产标识去除首尾空白后交给系统统一资产解析能力,解析结果包含资产类型、资产 ID 和规范标识。批量订购不得另写一套卡/设备识别分支或维护自己的标识白名单。
|
||||
- 当前统一解析能力支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN;未来统一解析能力新增或修正标识规则时,批量订购自动复用。
|
||||
- 空资产标识、未命中或无法唯一解析属于行级失败;禁止猜测或自动修复科学计数法、控制字符等被破坏的数据。
|
||||
- 单个文件最大 10MB,数据行最多 1000 行。空文件、只有表头、非法 UTF-8、CSV 引号语法错误、表头错误或第 1001 行出现,均使任务整体失败且不创建任何订单。
|
||||
- Worker 必须先完成整个文件的结构和行数校验,再开始任何订单写入;不能边解析边下单后才发现文件级错误。
|
||||
- 文件有效后,每一个数据行都持久化为明细。业务无效行、空字段行和重复行仍占用原始行号并计入失败,确保 `total_count = success_count + fail_count`。
|
||||
|
||||
### 资产解析与文件内判重
|
||||
|
||||
- Worker 通过统一资产解析能力得到唯一资产类型、资产 ID 和规范标识后再判定文件内重复,不能直接按用户填写的字符串判重。
|
||||
- 明细同时保留用户原始资产标识和统一解析结果;规范标识的选取规则属于统一资产能力,批量订购不自行决定卡或设备的标识优先级。
|
||||
- 未命中或命中多个资产时该行失败;统一资产解析能力必须保证唯一结果,不能使用 `First` 任取一条。
|
||||
- 文件内重复键为统一解析得到的 `资产类型 + 资产ID`。整个批次只有一个套餐,因此重复键不再包含套餐。
|
||||
- 重复键首次出现的行正常进入业务处理,后续行失败并在 `failure_reason` 中指出首次出现的 CSV 行号。
|
||||
- 本期不把重复行解释为数量;未来需要多份订购时再增加明确数量字段。
|
||||
- 统一资产解析能力必须提供适用于批量调用的接口,避免逐行跨表查询形成 N+1;历史脏数据多命中必须明确失败,不能由批量用例任取一条。
|
||||
|
||||
### 对象存储与上传归属
|
||||
|
||||
- 复用 `POST /api/admin/storage/upload-url`,新增用途 `bulk_purchase`。该用途只生成批量订购目录下的 `.csv` Key,Content-Type 固定为允许的 CSV 类型。
|
||||
- 线下凭证继续使用 `attachment` 用途;允许项目现有支持的图片或文件类型,数量沿用后台订单凭证上限,当前为 1 至 5 个。
|
||||
- 前端使用预签名 URL 直接 `PUT` 到当前真实私有 S3,业务接口只接收稳定 `file_key` 和 `voucher_keys`,不接收 multipart 或字节流。
|
||||
- 现有存储 Provider 缺少对象元数据和上传主体证明。实现必须补充对象元数据查询能力,至少返回对象是否存在、实际大小和 Content-Type;同时保存预签名上传授权记录,包含 Key、purpose、申请人账号、声明文件名/类型和签发时间。
|
||||
- 创建任务时校验 CSV Key 来自 `bulk_purchase` 用途、上传授权属于当前账号、对象真实存在、实际大小不超过 10MB、扩展名和 Content-Type 合法。凭证 Key 也必须属于当前账号的附件上传授权并真实存在。
|
||||
- 上传授权在任务创建事务中绑定到该任务。同一 Key 可以随相同 `request_id` 的幂等重试返回原任务,但不能被另一个批次或另一个账号再次绑定。
|
||||
- 预签名 URL 和永久对象 Key 是不同概念。任务只保存稳定私有 Key,不保存会过期的上传 URL;查询时按现有受权下载机制展示凭证。
|
||||
- 源 CSV 和凭证按对象存储统一生命周期保留。Worker 下载失败属于任务级基础设施失败;已经确定的订单结果不因后续对象清理失败而回滚,但必须记录告警。
|
||||
- 自动化测试直接调用真实 S3,不实现或保留内存 Provider 替身。测试使用本次运行的唯一对象 Key,并在结束时只删除自己创建的对象;真实上传、Head、下载或删除失败都必须使相应测试失败。
|
||||
|
||||
### 创建任务 API
|
||||
|
||||
- `POST /api/admin/bulk-purchases` 使用 JSON 请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "01J...",
|
||||
"package_id": 1001,
|
||||
"payment_method": "wallet",
|
||||
"file_key": "bulk-purchase/2026/07/unique.csv",
|
||||
"voucher_keys": []
|
||||
}
|
||||
```
|
||||
|
||||
- `request_id` 必填,最大 64 字符,由前端为一次用户提交生成全局唯一值。`package_id` 必填且大于 0,一个任务只接受一个套餐。`payment_method` 只允许复用现有后台订单枚举 `wallet|offline`,不新增 `agent_wallet`。
|
||||
- 创建任务时加载并快照所选套餐的 ID、编码和名称,确认套餐存在;各结算代理是否拥有授权、价格是否有效及当前是否可售仍在逐行处理时按最新事实校验。
|
||||
- `wallet` 时 `voucher_keys` 必须为空;`offline` 时必须提供 1 至 5 个凭证 Key。一个批次不能混合支付方式。
|
||||
- 请求 DTO 不声明 `shop_id`,创建接口若检测到显式提交 `shop_id` 必须返回参数错误,不能忽略后让调用方误以为它参与了结算;其他未知字段延续项目统一 JSON 兼容策略,但不参与业务决策。
|
||||
- `request_id` 建立数据库唯一约束。相同 `request_id`、相同操作者和相同请求指纹重复提交时返回原任务,不重复绑定文件、创建任务或投递消息。
|
||||
- 相同 `request_id` 但 `package_id`、支付方式、文件 Key、凭证 Key 或操作者不同,返回冲突错误,不能静默返回语义不同的旧任务。
|
||||
- 创建事务保存任务、上传授权绑定和可靠任务事件。Asynq 不可用时不能丢失已提交任务;数据库中的待处理任务/事件是事实源,由投递器重试发送。
|
||||
- 成功响应至少返回 `task_id`、`task_no`、`request_id`、所选套餐快照、`payment_method`、`status`、`status_name` 和 `created_at`。成功只表示任务已接收,不表示 CSV 已通过或订单已创建。
|
||||
|
||||
### 任务与明细查询 API
|
||||
|
||||
- `GET /api/admin/bulk-purchases/{task_id}` 返回任务汇总,至少包括:
|
||||
- 任务 ID、任务号、请求 ID、所选套餐 ID/编码/名称快照、支付方式。
|
||||
- 状态与中文状态名、源文件名、凭证数量和有权预览所需的附件引用。
|
||||
- 总行数、已处理数、成功数、失败数、涉及代理数。
|
||||
- 全部有效成功行金额合计;金额单位固定为分,类型为 `int64`。
|
||||
- 任务级错误码与中文原因、操作者快照、创建/开始/完成时间。
|
||||
- `GET /api/admin/bulk-purchases/{task_id}/items` 支持 `page`、`page_size`、`status` 和 `asset_identifier`:
|
||||
- 默认第 1 页、每页 20 条,`page_size` 最大 100。
|
||||
- 默认按 `row_no ASC`,不允许前端改变业务处理顺序。
|
||||
- `status` 只允许明细状态枚举;多个筛选参数使用 AND 组合。
|
||||
- `asset_identifier` 去除首尾空白后,对该任务内的用户原始标识或规范标识做精确匹配,不做跨任务搜索。
|
||||
- 明细响应至少包括:行号、解析后的资产类型、原始资产标识、解析资产 ID、规范标识、结算代理 ID/名称快照、金额、状态与中文名、订单 ID、失败码、中文失败原因和处理时间;套餐信息统一来自任务快照,不保存不存在的逐行套餐输入。
|
||||
- 查询仅返回业务安全信息,不返回 SQL、底层错误、对象存储永久凭证、Redis Key 或其他代理的钱包余额。
|
||||
- 所有接口使用统一 `{code,msg,data,timestamp}` 响应。参数校验统一返回参数错误;任务不存在返回统一不存在错误;现有后台认证失败沿用公共认证错误;同一请求 ID 的不同载荷、文件已绑定等返回冲突;对象不存在、类型错误、文件过大和存储失败复用或补齐统一存储错误码。
|
||||
|
||||
### 数据模型与索引
|
||||
|
||||
- 新建独立任务表和逐行明细表,不复用语义不同的导入、导出或设备批量分配表;不建立数据库外键或 GORM 关联标签。
|
||||
- 任务至少保存:任务号、`request_id`、请求指纹、源文件 Key/名称/类型/大小、所选套餐 ID/编码/名称快照、支付方式、凭证 Key 快照、操作者账号/类型/名称快照、涉及代理数、总数/已处理数/成功数/失败数、成功金额、状态、任务级错误码/原因、任务租约持有者/到期时间、开始/完成时间和公共审计字段。
|
||||
- 明细至少保存:任务 ID、CSV 行号、统一解析得到的资产类型、原始资产标识、解析资产 ID、规范标识快照、结算代理 ID/名称快照、金额、状态、订单 ID、失败码/原因、幂等键、处理租约和处理时间。
|
||||
- 上传授权记录需要稳定保存 Key、purpose、申请账号、声明元数据、绑定业务类型/ID和绑定时间,以便创建接口证明“属于当前上传主体”;不通过可猜测目录前缀代替归属校验。
|
||||
- 任务号、`request_id` 和上传授权 Key 使用有效记录唯一索引;明细使用 `(task_id,row_no)` 和 `idempotency_key` 唯一索引,并为 `(task_id,status,row_no)` 建查询索引。
|
||||
- `request_id` 是全局唯一;行幂等键固定为 `bulk_purchase:{task_id}:{row_no}`。
|
||||
- 状态类字段使用 `int`,类型/方式类使用 `string`。任务复用全局异步任务状态常量,不定义 Bulk 私有任务状态;逐行明细使用公共行处理状态。支付方式、资产类型、失败码和 Redis Key 生成函数定义到公共常量包,并添加中文注释。
|
||||
- 不需要迁移或回填历史订单。新表上线前为空;已成功生成的标准订单继续按现有订单事实保留。
|
||||
|
||||
### 状态机与任务恢复
|
||||
|
||||
- 全局异步任务状态统一为:`1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消`;任务响应必须包含对应 `status_name`。
|
||||
- 文件有效并完成全部行业务处理后,任务固定为 `3:已完成`。全部成功、部分成功或全部业务行失败由 `success_count` 与 `fail_count` 表达,不创建“部分成功”状态。
|
||||
- 文件级校验失败、源对象下载失败或无法恢复的任务级基础设施错误进入 `4:已失败`。文件级失败不得创建订单;是否保留零条明细由错误发生阶段决定,并通过任务错误说明。
|
||||
- 逐行明细不是独立异步任务,使用公共行处理状态:`1:待处理, 2:处理中, 3:成功, 4:失败`,并返回对应 `status_name`;不为明细增加没有业务语义的取消状态。
|
||||
- 本期任务没有取消入口,但保留全局任务状态码 `5:已取消`,不把它改作其他含义。
|
||||
- Worker 使用状态条件和带过期时间的租约领取任务。只有待处理或租约已过期的处理中任务可被领取;终态不可被重新执行。
|
||||
- 文件校验通过后,在开始下单前一次性持久化全部明细。Worker 重启时读取现有明细继续,而不是重新生成不同的行号或幂等键。
|
||||
- 每个明细也通过条件更新或行锁领取。成功/失败终态不可被另一消费者覆盖;租约过期的处理中明细根据事务事实安全恢复。
|
||||
- 任务汇总始终从明细表重新聚合,不信任进程内累加值。`processed_count = success_count + fail_count`,终态时等于 `total_count`。
|
||||
- Asynq 载荷只传结构化 `task_id`,不得传预序列化 `[]byte`、CSV 字节、临时路径、凭证内容或认证上下文。
|
||||
|
||||
### 严格行序与逐行业务流程
|
||||
|
||||
- 文件通过后严格按 `row_no ASC` 串行推进业务结算。可以批量预加载只读数据,但不得并发执行钱包扣款或改变行序结果。
|
||||
- 每行处理时重新读取资产当前归属,不使用创建任务时的归属快照;结算代理不存在、归属异常或没有有效主钱包时只失败当前行。
|
||||
- 解析出的结算代理必须拥有对应套餐当前有效授权;金额使用该代理当前授权成本价和现有订单定价规则,不使用 CSV 套餐名称或前端金额。
|
||||
- 每行复用统一套餐可售策略。批量订购不是个人本人续费入口,不能利用后台或批量身份绕过下架限制;渠道下架套餐固定拒绝。
|
||||
- 继续执行现有后台订单中适用于该支付方式的资产状态、套餐组合、互斥、使用期、生效、赠送、强充等不变量。批量入口不得复制一套较宽规则。
|
||||
- `offline` 把整批凭证快照关联到每个成功订单,直接创建符合现有后台线下语义的已支付订单并激活套餐;不扣代理钱包。
|
||||
- `wallet` 逐行锁定该资产结算代理的有效主钱包,使用统一公式计算可用金额:只有启用信用时才计入有效信用额度。
|
||||
- `wallet` 不预占整批或某一代理的全部金额。当前行余额不足只失败当前行并继续;后续金额更小的行在当时可用金额足够时仍可成功。因此,同一代理资金不足时 CSV 行序就是订购优先级。
|
||||
- 单个成功行的事务必须原子提交:订单、订单明细、套餐使用/激活、结算代理与价格快照、钱包余额与版本、钱包真实金额流水、明细成功状态,以及本用例要求的可靠事件/审计。
|
||||
- 单行事务失败不得留下已扣钱包但无订单、已有订单但明细仍可重复执行,或套餐已生效但订单回滚的中间事实。
|
||||
- 行级业务失败记录稳定 `failure_code` 和中文原因后继续下一行;底层数据库、S3 或 Redis 错误不能原样写给用户。
|
||||
|
||||
### 幂等、并发与失败码
|
||||
|
||||
- HTTP 幂等由 `request_id` 唯一约束和请求指纹保护;任务投递幂等由任务 ID 和任务状态/租约保护;单行幂等由 `(task_id,row_no)`、稳定幂等键、明细行锁/条件状态和单行事务共同保护。
|
||||
- 同一任务的两个 Worker、Worker 崩溃后重试以及 Asynq 至少一次投递都不能重复创建订单、扣款、写钱包流水、激活套餐或写成功审计。
|
||||
- 钱包扣款使用统一 Wallet Domain 的版本/条件更新或等价并发保护,不能只在内存判断余额;并发扣款后总可用金额不得小于 0。
|
||||
- 与普通订单并发购买同一资产时仍要由 Order Domain 保护套餐和资产不变量,不能只依赖批量任务自身租约。
|
||||
- 推荐稳定任务失败码包括:`storage_download_failed`、`invalid_encoding`、`invalid_header`、`unknown_column`、`invalid_csv`、`empty_file`、`row_limit_exceeded`。
|
||||
- 推荐稳定行失败码包括:`invalid_asset_identifier`、`asset_not_found`、`asset_identifier_ambiguous`、`duplicate_row`、`package_not_authorized`、`package_not_purchasable`、`settlement_agent_invalid`、`main_wallet_not_found`、`insufficient_balance`、`order_create_failed`。
|
||||
- 失败码供前端稳定展示和筛选,`failure_reason` 使用用户可理解中文并可补充首次重复行号等上下文;不把整个中文文案当作程序判断条件。
|
||||
|
||||
### 线下凭证语义
|
||||
|
||||
- 线下凭证是整个批次的业务资料快照,所有成功的线下订单都能追溯到该批次及凭证。
|
||||
- 本期不校验同一凭证是否在其他批次使用,不建立凭证金额与订单金额的自动核销,也不因重复文件内容拒绝批次。
|
||||
- 凭证可以是图片或文件,保存私有对象 Key;页面通过受权下载地址预览或下载,不把附件字节写入 CSV、数据库大字段或任务载荷。
|
||||
- 对象存储中的凭证按现有附件保留策略长期可访问;不能把创建时的短期预签名 URL 当作永久业务地址。
|
||||
|
||||
### 前端交互
|
||||
|
||||
- 页面采用“参数确认 → 上传 → 处理中 → 结果”四个稳定阶段,可放在批量订购独立页或现有订单页入口。
|
||||
- 参数阶段选择单个套餐和整批支付方式,不展示代理选择器。`offline` 显示 1 至 5 个凭证上传;`wallet` 不显示或清空凭证。
|
||||
- CSV 模板是前端静态资源,只有 `资产标识` 一列,说明标识复用系统统一识别能力,当前支持 ICCID、卡虚拟号、MSISDN、设备虚拟号、IMEI 和 SN,并明确不接受 Excel。
|
||||
- 整个 CSV 使用参数阶段选择的套餐,前端不让用户在行内填写或为不同资产选择不同套餐。
|
||||
- 前端先申请上传 URL并直传真实 S3,再以同一个用户动作生成的 `request_id` 创建任务。网络超时重试必须复用原 `request_id`,用户主动新建批次才生成新值。
|
||||
- 提交前展示所选套餐、支付方式、CSV 文件名和凭证数量,不展示虚构的单一代理或整批钱包余额;提交期间禁止重复点击。
|
||||
- 创建成功后保存任务 ID并刷新详情。页面刷新、关闭后重开或网络恢复时,可以根据任务 ID恢复,不依赖持续驻留的轮询内存状态。
|
||||
- 处理中显示任务号、操作员、总数、已处理数、成功数、失败数、成功金额和涉及代理数。解析尚未完成时允许总数为 0,并显示“正在校验文件”。
|
||||
- 结果页默认筛选失败明细,可切换全部/成功/失败,并按资产标识精确搜索;展示行号、原始资产、规范资产、批次套餐、结算代理、金额、订单和中文原因。
|
||||
- 任务完成后根据成功数和失败数展示“全部成功/部分成功/全部业务行失败”的结果摘要,不把“部分成功”当成状态码。本期没有单行重试接口;用户复制失败行、修正后重新上传会创建全新任务,旧任务历史不改变。
|
||||
- 文件级失败展示任务错误和模板修正建议;行级失败展示逐行原因。前端不得自行推断或改写后端失败码。
|
||||
|
||||
### 发布、回滚与依赖
|
||||
|
||||
- 钱包路径依赖统一 Wallet Domain 已具备信用启用开关、有效信用额度公式、版本并发保护和真实金额流水。若 UR#38 尚未落地,必须先完成这段共享能力,禁止在批量代码中复制旧余额判断。
|
||||
- 套餐校验依赖统一可售策略能够区分 C 端本人续费与后台/批量入口。若 UR#40 尚未落地,必须先具备该策略,批量不能暂时放宽下架限制。
|
||||
- 发布包含新表、索引、权限、上传用途、存储元数据能力、API、任务投递器和 Worker。Worker 尚未部署或数据库迁移未完成时不得开放前端入口。
|
||||
- 上线前验证 PostgreSQL、Redis、Asynq 和真实 S3 配置,生成接口文档,并完成上传、任务、钱包、线下、部分成功和现有后台认证联调。
|
||||
- 发布窗口内短暂停止新批量任务,先部署兼容迁移和 Worker,再部署 API/前端。旧版本不会读取新表,不需要历史回填。
|
||||
- 回滚时先关闭前端入口和任务创建,等待或人工处置已领取任务,再回滚应用。已经创建的标准订单、钱包流水、套餐使用、任务和审计均作为业务事实保留,不做反向删除。
|
||||
- 不在仍有待处理或处理中任务时删除新表或上传用途;数据库降级迁移不是常规应用回滚步骤。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
### 可复用 Agent 集成测试规范与工具
|
||||
|
||||
- 本需求实现时同时沉淀一份“Agent 集成测试规范”,覆盖环境防误连、夹具命名、唯一运行标识、精确清理、失败后清理、敏感信息保护和禁止事项,供后续批量任务复用。
|
||||
- 提供可复用 Go 测试 Harness,至少封装:配置守卫、真实 PostgreSQL/Redis/S3 连接、Fiber 请求、测试账号和真实认证令牌、受控任务投递捕获、公开 Worker Handler 驱动、统一响应解码、资源清理台账。
|
||||
- 为本需求提供 UTF-8、BOM、非法表头、重复资产、多代理、钱包不足和线下凭证等固定 CSV `testdata`,并提供穿过公开边界的完整示例测试。
|
||||
- Go 集成测试是主要自动化入口;`curl` 只作为已部署环境的真实网络冒烟模板,验证登录、预签名上传、PUT、创建、查询和终态,不承担并发、幂等和数据库断言。
|
||||
- 测试公共行为,不直接测试私有函数。创建接口使用 Fiber `app.Test` 穿过真实路由、认证、Handler、Application/Domain、GORM 和统一响应;Worker 通过公开任务 Handler 驱动,不依赖私有解析方法。
|
||||
- 为避免后台 Worker 抢任务,集成测试在应用注入点捕获待投递 `task_id`,然后直接调用公开 Worker Handler;不通过 `sleep` 等待异步碰运气。生产仍使用真实 Asynq 投递器。
|
||||
|
||||
### Redis 与 Asynq 隔离
|
||||
|
||||
- 已部署测试环境固定使用 Redis DB 6;本地开发和 Agent 自动化测试固定使用 Redis DB 7。
|
||||
- 每次自动化测试启动前必须读取实际生效配置,并确认普通 Redis Client、Asynq Client 和 Worker Server 的 DB 都等于 7。任一不是 7 时立即失败,绝不向 DB 6 写普通 Key或投递任务。
|
||||
- 测试不得执行 `FLUSHDB`、全前缀扫描删除或清空队列。每次运行生成唯一 `run_id`,Redis 业务键、任务标识和清理名单只指向本次实际创建的精确 Key。
|
||||
- 测试结束和失败清理都按资源台账删除精确 Key;其他本地进程在 DB 7 的数据不受影响。
|
||||
|
||||
### PostgreSQL 共享测试库
|
||||
|
||||
- 自动化测试继续使用当前现有测试数据库,不创建新的数据库或专用数据库。
|
||||
- 每次运行创建带唯一 `run_id` 的平台账号、权限、代理、钱包、资产、套餐、授权和其他夹具,只使用本次创建记录的实际主键执行业务。
|
||||
- 清理按依赖顺序和实际主键精确删除本次创建的记录;禁止 `TRUNCATE`、整表删除、模糊条件删除、复用后篡改现有业务数据或假设测试库为空。
|
||||
- 测试开始前记录资源清理台账;任何中途失败仍执行清理,并将未清理的精确 ID 输出为诊断信息,但不得输出密码、Token、S3 密钥或完整敏感业务数据。
|
||||
- 钱包并发测试也在该共享测试库内使用完全独立的本次夹具,不能锁定或扣减已有代理钱包。
|
||||
|
||||
### 真实 S3
|
||||
|
||||
- 自动化测试直接使用当前可调用的真实 S3 Provider,不使用内存替身、临时本地对象模拟或绕过预签名协议。
|
||||
- 每个测试对象 Key 都包含唯一 `run_id`,CSV 和凭证走真实上传;测试覆盖对象 Head/元数据、下载和精确删除。
|
||||
- 清理只删除本次资源台账中记录的对象 Key,禁止删除目录前缀或执行 Bucket 级清理。
|
||||
- 真实 S3 网络、鉴权、上传、下载、Head 或删除失败均使集成测试失败,便于及早发现环境和 Provider 契约问题。
|
||||
|
||||
### 后端场景
|
||||
|
||||
- 上传契约覆盖 `bulk_purchase` purpose、`.csv`、实际 Content-Type、10MB 边界、对象不存在、其他账号 Key、其他用途 Key、Key 已绑定、凭证类型和 1/5/6 个凭证。
|
||||
- CSV 解析覆盖 UTF-8、UTF-8 BOM、LF、CRLF、单列中文固定表头、缺失/额外/重复列、非法引号、空文件、只有表头、1000/1001 行、空字段、控制字符和伪装 Excel。
|
||||
- 资产解析通过统一公共接缝覆盖卡 ICCID、卡虚拟号、唯一 MSISDN、MSISDN 未命中/多命中、设备虚拟号、IMEI、SN、未命中和历史多命中;断言批量用例没有另一套识别规则。
|
||||
- 判重覆盖同一资产使用不同受支持标识只首行处理、后续指出首行号,以及无法解析资产的行不会误判成同一个空资产。
|
||||
- 认证测试覆盖现有后台认证有效和失效;断言没有新增 `order:bulk_purchase`、账号类型拦截或任务创建人隔离。
|
||||
- 请求幂等覆盖相同 `request_id` 同载荷返回原任务、改变 `package_id` 或其他载荷/操作者时冲突、并发创建只有一个任务和一个可靠事件。
|
||||
- 文件级错误验证任务失败且订单、钱包流水、套餐使用均为零;行业务错误验证部分成功且已成功行不被回滚;全部行业务失败仍是任务已完成且 `success_count=0`、`fail_count=total_count`。
|
||||
- `wallet` 覆盖多代理各扣自己的主钱包、信用启用/禁用、无主钱包、余额不足、行序优先、前一行不足但后一便宜行成功,以及不产生整批预占。
|
||||
- `offline` 覆盖凭证必填、成功订单立即支付和激活、不扣钱包、凭证可跨批复用且保留批次关联。
|
||||
- 套餐规则覆盖创建时套餐不存在、任务套餐快照、各结算代理的授权成本、未授权、禁用、下架不能被批量绕过,以及现有组合/资产状态/强充规则;任何价格都不能由前端或 CSV 提交。
|
||||
- 重复消费覆盖两个 Worker 同时领取、任务租约过期、单行处理中崩溃、事务提交前/后故障、重复 Asynq 消息,断言订单、扣款、流水和激活各只有一次。
|
||||
- 查询覆盖默认分页、最大页大小、行号排序、状态和资产标识筛选使用 AND、任务汇总、全局状态名称和失败码。
|
||||
- 审计覆盖任务创建/终态、逐行订单和钱包事实,并验证日志和响应不包含环境密钥、认证令牌、预签名 URL 或底层错误。
|
||||
|
||||
### 前端、联调和完成命令
|
||||
|
||||
- 前端验收覆盖入口可见性、单套餐选择、四阶段页面、单列静态 CSV 模板、真实直传、支付方式切换、线下凭证、重复点击、刷新恢复、解析中空进度、计数表达部分成功、失败默认筛选、精确搜索和新任务重试。
|
||||
- 部署联调使用 `curl` 模板完成真实 HTTP 和真实 S3 PUT,但凭据只从环境读取,不写入脚本、文档或终端回显;验证部署环境明确连接 Redis DB 6。
|
||||
- 自动化完成门禁至少包括目标单元/集成测试、相关包测试、全量 Go 测试、竞态或并发专项测试、静态检查、OpenAPI 生成校验和数据库迁移验证。
|
||||
- 新 Handler 必须同步两个接口文档生成入口;DTO 枚举描述从公共常量原文复制,所有状态响应包含中文 `status_name`。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不支持 Excel,不维护 CSV/Excel 双解析器。
|
||||
- 不在创建请求或 CSV 中接受 `shop_id`,不让前端选择或推断结算代理。
|
||||
- 不允许一批混合 `wallet` 与 `offline`,也不新增 `agent_wallet` 支付枚举。
|
||||
- 不在批量订购内维护资产标识白名单或另写卡/设备识别逻辑;不支持模糊搜索或资产号段匹配。
|
||||
- 不用重复行表达套餐数量,不提供单行重试或修改旧任务接口。
|
||||
- 不为钱包支付预占整批或某代理全部金额,不因一行余额不足回滚其他成功行。
|
||||
- 不允许批量订购绕过下架、授权、资产状态、定价、强充或订单领域规则。
|
||||
- 不对线下凭证做跨批唯一、金额核销或自动财务对账。
|
||||
- 不新增后端 CSV 模板下载或失败结果文件下载接口。
|
||||
- 不创建新的 PostgreSQL 测试数据库,不对共享测试库或 Redis DB 7 执行破坏性清理。
|
||||
- 不为对象存储实现内存替身;自动化和联调均验证当前真实 S3。
|
||||
- 不新增批量订购后端权限码、账号类型拦截或任务创建人隔离。
|
||||
- 不重构当前需求未触碰的整个订单、钱包或存储模块。
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 当前仓库没有批量订购 API、任务模型或 Worker,需要作为新用例实现。
|
||||
- 当前后台订单已使用 `wallet|offline`,批量必须复用这两个常量;部分现有 DTO 对线下凭证和支付枚举的描述不完全一致,实现时以公共常量和本规格为准并同步修正触碰处。
|
||||
- 当前已有统一 `Asset.Resolve` 和全局资产标识注册表,但 fallback 对历史多命中仍可能任取第一条。本需求必须复用并完善这一个统一解析边界,使单个和批量解析都能返回唯一资产类型与 ID;不能在批量模块内再实现一套。
|
||||
- 当前存储接口只有存在性、上传/下载和预签名能力,没有对象元数据与上传主体归属事实;这两项是安全接收 `file_key` 的必要实现,不得只检查字符串前缀。
|
||||
- 当前 Redis 普通客户端、Asynq Client 和 Worker Server使用同一个 Redis DB 配置来源,测试守卫必须验证最终生效值为 7,而不是只检查某个环境变量字符串。
|
||||
- 用户已最终确认:CSV 只有 `资产标识`,资产类型由统一解析能力识别;创建任务选择单个 `package_id`;后端不新增显性权限拦截;任务状态复用全局五态且部分成功只通过计数表达;自动化测试直接使用真实 S3,PostgreSQL 沿用现有测试库。
|
||||
@@ -75,7 +75,7 @@ Status: ready-for-agent
|
||||
- 场景保存稳定 `scene_code`、中文名、状态、当前模板版本 ID、暂停原因和乐观锁版本;不建立数据库外键或 GORM 关联标签。
|
||||
- 模板版本保存场景码、递增版本号、企微模板 ID、名称、控件映射、模板快照、指纹、验证结果、发布人和发布时间。历史版本不可修改;每个场景只能有一个启用版本。
|
||||
- 模板发布流程为:暂停场景并停止发放新提交租约,等待所有未过期租约释放,读取新模板,完成业务字段到控件的可视化映射,后端重新调用企微校验,事务内停用旧版本、写新版本、切换当前版本并恢复场景。
|
||||
- 退款必填映射至少包含店铺、退款单号、订单号、资产标识、申请退款金额、退款原因、附件和真实业务提交人;线下充值至少包含店铺、充值单号、金额、备注、附件和真实业务提交人。
|
||||
- 退款稳定业务字段至少包含店铺、退款单号、订单号、资产标识、订单实收金额、可退款区间、固定申请退款金额、退款原因、申请备注、附件和真实业务提交人;除申请备注可为空外均需完成控件映射。金额控件只用于展示,审批人不得修改;线下充值至少包含店铺、充值单号、金额、备注、附件和真实业务提交人。
|
||||
- 后端必须验证模板可访问、控件存在、控件类型匹配和所有必填业务字段已映射。前端不能提交控件类型或名称作为可信事实,也不直接编辑原始映射 JSON。
|
||||
- 后台每 10 分钟验证启用模板。模板不可访问或指纹变化时暂停对应场景并告警;系统不能假设能从旧模板 ID 自动发现企微生成的新模板 ID。
|
||||
- 场景处于暂停中、已暂停或当前模板失效时,创建退款或线下充值必须在任何业务单、审批实例或 Outbox 写入前失败。前端保留表单,恢复后用户重新发起创建请求;不留下待补提的孤儿业务单。
|
||||
@@ -218,7 +218,7 @@ Status: ready-for-agent
|
||||
|
||||
- 主要自动化接缝采用最高公共行为边界:Fiber HTTP 路由与认证 → Application/Domain/Query → GORM → 开发 PostgreSQL、Redis 和 Asynq 可控队列;企业微信网络统一替换为可编程 WeCom Adapter。测试不直接断言私有函数或目录结构。
|
||||
- 回调测试从真实 HTTP 回调入口注入使用测试 Token/AES Key 生成的签名密文,验证 URL 校验、签名错误、解密错误、错误 CorpID、重复事件、快速响应和统一状态同步,不绕过回调协议直接调用内部函数。
|
||||
- 使用 `.env.local` 指向的开发 PostgreSQL 与 Redis 验证迁移、唯一约束、绑定会话、Token 锁、提交租约、轮询领取和幂等;测试及日志不得输出任何连接密码或企微密钥。
|
||||
- 使用现有测试 PostgreSQL 与本地 Redis DB 7 验证迁移、唯一约束、绑定会话、Token 锁、提交租约、轮询领取和幂等;测试启动时必须校验 Redis Client、Asynq Client 和 Worker Server 的实际 DB 均为 7,禁止向已部署测试环境使用的 DB 6 投递任务,也不得执行 `FLUSHDB`。测试及日志不得输出任何连接密码或企微密钥。
|
||||
- WeCom Adapter 契约测试覆盖 Token 获取/刷新、成员身份、模板读取、附件上传、发起审批、详情查询,以及企微非零错误、超时、响应丢失和限流。自动化测试不依赖真实企微网络。
|
||||
- 场景测试覆盖首次发布、暂停中停止发租约、租约自然释放/续租、已暂停、新版本原子切换、模板不可访问、指纹变化、必填映射缺失、控件类型不匹配和发布失败保持暂停。
|
||||
- 身份测试覆盖平台本人扫码、代理禁止绑定、非企业成员、过期/重复 `state`、同一成员绑定第二账号、换绑、自助解绑、强制解绑、固定代理成员可用/失效,以及历史实例不随绑定变化。
|
||||
@@ -233,7 +233,7 @@ Status: ready-for-agent
|
||||
- 日志与响应测试验证 Secret、Token、EncodingAESKey、对象 Key、临时 `media_id`、原始固定 `userid` 和完整附件不会出现在 API、日志或审计详情中。
|
||||
- 停机迁移测试覆盖历史终态标为 `legacy`、待审批按平台/代理身份迁移、缺失绑定进入待处理、重复执行不重复创建实例,以及旧审批路由确实不再注册。
|
||||
- 前端人工验收覆盖连接配置、模板维护、暂停等待、本人扫码绑定、代理代提交、审批运行筛选、立即同步、两种异常恢复、权限菜单、详情投影、通过后撤销红色告警以及所有加载/空/失败状态。
|
||||
- 真实企微联调单独在受控环境完成两个模板的读取/发布、平台本人发起、代理固定账号代发、多级/会签/或签、意见附件、通过/拒绝、回调丢失后的轮询和提交结果未知处置;联调记录不得包含密钥或个人敏感信息。
|
||||
- 真实企微验收是企微公共能力及首个接入业务的实现完成门禁,不得只推迟到后续联调。至少使用真实模板完成平台本人发起、代理固定账号代发、真实附件、通过/拒绝、加密回调和回调丢失后的轮询兜底;回调可按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发,后端仍完整验签解密。自动化 Adapter 继续覆盖超时、响应未知、重复/乱序、撤销、删除、通过后撤销和限流等难以稳定人工制造的异常。真实参数和验收记录不得泄漏密钥或个人敏感信息。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ Status: ready-for-agent
|
||||
- 新建独立 `tb_device_batch_allocation_task`,不复用语义不同的设备导入任务或导出任务表,也不建立数据库外键。
|
||||
- 任务至少保存:任务号、源文件 Key、`operation_type`、目标 ID、操作者账号 ID、操作者类型与店铺快照、总数、已处理数、成功数、幂等数、失败数、状态、失败明细、错误摘要、开始时间和完成时间。
|
||||
- `operation_type` 固定为 `assign_shop|assign_series`。
|
||||
- 状态沿用项目异步任务的 int 生命周期枚举:`1:待处理, 2:处理中, 3:已完成, 4:失败`,响应必须同时返回 `status_name`。
|
||||
- 状态复用全局异步任务 int 生命周期枚举:`1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消`,响应必须同时返回 `status_name`。本需求没有取消入口,但保留状态码 5;部分成功不是状态,由成功数、幂等数和失败数表达。
|
||||
- 行级业务失败不把整个任务标为系统失败;CSV 被完整处理后,即使全部行都因业务原因失败,任务仍是“已完成”并通过 `fail_count` 和明细表达结果。文件级解析错误、存储错误或不可恢复的基础设施错误才是“失败”。
|
||||
- `total_count = success_count + fail_count`,`success_count` 包含实际变更与同目标幂等成功;`idempotent_count` 是成功数的子集。重复 CSV 行计入失败,避免汇总数字无法对齐上传行数。
|
||||
- 失败明细最多 1000 条,与单文件行数上限一致;每项返回原始行号、脱敏或安全的输入设备号和中文原因。不得把数据库错误或其他租户资源信息原样返回。
|
||||
|
||||
Reference in New Issue
Block a user