更新一下prd

This commit is contained in:
2026-07-22 11:08:04 +09:00
parent 4902a02c87
commit da9c805d89
10 changed files with 1026 additions and 200 deletions

View File

@@ -0,0 +1,285 @@
# PRDUR#34 代理在线扫码充值与平台线下代充值
Status: ready-for-agent
---
## Problem Statement
当前代理充值接口只创建本地充值记录,没有真正创建微信 Native 或支付宝 PreCreate 支付单并返回可供前端渲染的付款内容。在线回调直接在一次事务中尝试完成钱包入账,支付事实与钱包处理结果无法独立表达;回调也没有完整校验支付配置、金额、第三方交易号和业务关联。网络超时、回调丢失、重复回调、钱包事务失败和第三方迟到成功都缺少可靠恢复边界。
当前同一个创建接口还允许代理或平台提交目标 `shop_id`,平台可以替任意代理创建在线支付,容易混淆真实付款人和受益钱包。线下代充值仍通过本地 `offline-pay``reject` 和全局操作密码完成人工入账,没有接入已经冻结的企业微信审批公共能力;附件仍是字符串 Key 列表,审批结论、充值状态和钱包入账状态也未独立建模。
本需求必须把代理自主在线充值与平台线下代充值分成两条不可混用的路径,复用现有支付和钱包基础能力,保证第三方真实收款、企微审批结论、钱包实际入账和通知都可独立追踪,并在重复回调、重复 Worker、进程中断、查单未知和审批异常下保持资金不重不漏。
## Solution
保留代理充值资源,建立两条严格隔离的创建路径:代理只为当前店铺主钱包选择 `wechat``alipay` 发起在线充值,不提交目标店铺,也不进入审批;平台或超级管理员只为指定代理店铺发起 `offline` 线下代充值,提交固定金额、备注和 15 个结构化付款凭证,并接入 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` 为分,范围 `10000100000000`;不接受 `shop_id`、付款凭证或备注。
- 线下请求只接受:`shop_id``amount``payment_method=offline``attachments`、可选 `remark``request_id``amount` 为分,范围 `1100000000``remark` 最多 500 字。
- 线下 `attachments` 必传 15 项,每项固定包含 `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=已失败`并保存结构化失败原因。
- 充值业务状态沿用 `16``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 `03` 常量不一致。实施前必须按状态、业务类型和创建入口盘点存量值;新迁移统一默认值、注释和约束到 `03`,遇到无法证明含义的存量值必须中止并输出异常清单,禁止盲目整体加减 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`、审批“提交中”。
- 企微表单至少展示充值单号、目标代理店铺、充值金额、真实业务提交人、申请备注和 15 个付款凭证。金额提交后固定;企微只允许同意或拒绝,不提供金额编辑、退回修改或本地审批动作。
- 企微拒绝把原充值单条件更新为 `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 秒读取本地轻量状态,隐藏时暂停、恢复时立即刷新。支付成功但入账处理中/失败时保留明确提示;入账完成后停止轮询、刷新现有钱包资金概况并展示成功。
- 平台线下创建页只展示目标店铺、金额、可选备注和 15 个付款凭证。提交中禁止重复操作;场景、绑定、附件或企微提交前置失败时保留表单内容。
- 线下创建成功进入充值详情,分为业务资料、企微审批和钱包处理结果三个区域。不展示本地确认、拒绝、退回、重提或操作密码输入框。
- 列表和详情按 `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` 迁移写的是 `14`,运行代码和 DTO使用 `03`;这是实施前必须通过存量核查解决的历史一致性问题,不是新 Agent可以忽略的文档差异。
- 当前旧线下充值使用字符串 Key列表、操作密码和本地 `offline-pay/reject`。新实现以本 PRD 和 UR#37公共企微契约为准,旧实现仅用于迁移事实核对,不代表目标行为。

View File

@@ -0,0 +1,219 @@
# PRDUR#35 退款企微审批与整单终结
Status: ready-for-agent
---
## Problem Statement
当前退款流程把退款申请、平台内审批、退款金额修改、钱包回款、佣金回扣和套餐失效混在同一个旧 Service 中。系统仍提供本地通过、拒绝、退回和重提接口,审批人能够修改实际退款金额;审批通过后又用进程内 Goroutine 分别处理佣金与套餐,失败后既不能可靠恢复,也可能错误地把未完成的处理标记为完成。
现有退款创建接口还要求调用方提交订单实收金额,并允许用单条套餐使用记录限定退款范围。这样会把本应由订单事实决定的数据交给前端,也与本期确认的“金额可以小于实收,但一旦通过就按整张订单终结”相冲突。代理退款查询当前按创建账号隔离,上级代理无法在既有店铺层级数据范围内管理下级退款;平台内部审批资料又缺少按主体投影,存在向代理泄漏审批人、意见或审批附件的风险。
退款涉及真实资金、负余额、已发放佣金、套餐队列、资产状态和外部企微终态。系统必须明确区分申请金额、实际完成金额、企微审批状态和本地业务处理状态,并保证重复回调、重复 Worker、进程中断和局部失败不会造成重复回款、重复扣佣或伪造成功。
## Solution
保留现有退款创建、列表和详情资源,但把新退款申请接入 UR#37 提供的企业微信审批公共能力。创建时后端从订单读取实收金额及相关快照,只接收申请金额、原因、备注和 15 个本地对象存储附件;金额提交后固定,企微审批人只能同意或拒绝。退款单、唯一企微审批实例和提交 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`15 项)。
- 每个附件项固定包含 `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 LogIntegration 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、对象存储或验证器信息。
### 前端页面与交互
- 退款创建表单只展示订单、申请退款金额、必填原因、可选备注和 15 个附件。订单实收金额及“通过后整单终结”的提示由后端订单/退款契约展示,不允许前端提交或覆盖实收金额。
- 提交中禁用重复提交;创建成功后进入退款详情,显示企微“提交中”。场景暂停、平台账号未绑定、代理固定成员不可用或附件校验失败时保留全部表单内容,并展示后端明确原因;平台账号未绑定时可原地进入 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 和测试做水平切层。

View File

@@ -0,0 +1,285 @@
# PRDUR#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` KeyContent-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`;后端不新增显性权限拦截;任务状态复用全局五态且部分成功只通过计数表达;自动化测试直接使用真实 S3PostgreSQL 沿用现有测试库。

View File

@@ -75,7 +75,7 @@ Status: ready-for-agent
- 场景保存稳定 `scene_code`、中文名、状态、当前模板版本 ID、暂停原因和乐观锁版本不建立数据库外键或 GORM 关联标签。 - 场景保存稳定 `scene_code`、中文名、状态、当前模板版本 ID、暂停原因和乐观锁版本不建立数据库外键或 GORM 关联标签。
- 模板版本保存场景码、递增版本号、企微模板 ID、名称、控件映射、模板快照、指纹、验证结果、发布人和发布时间。历史版本不可修改每个场景只能有一个启用版本。 - 模板版本保存场景码、递增版本号、企微模板 ID、名称、控件映射、模板快照、指纹、验证结果、发布人和发布时间。历史版本不可修改每个场景只能有一个启用版本。
- 模板发布流程为:暂停场景并停止发放新提交租约,等待所有未过期租约释放,读取新模板,完成业务字段到控件的可视化映射,后端重新调用企微校验,事务内停用旧版本、写新版本、切换当前版本并恢复场景。 - 模板发布流程为:暂停场景并停止发放新提交租约,等待所有未过期租约释放,读取新模板,完成业务字段到控件的可视化映射,后端重新调用企微校验,事务内停用旧版本、写新版本、切换当前版本并恢复场景。
- 退款必填映射至少包含店铺、退款单号、订单号、资产标识、申请退款金额、退款原因、附件和真实业务提交人;线下充值至少包含店铺、充值单号、金额、备注、附件和真实业务提交人。 - 退款稳定业务字段至少包含店铺、退款单号、订单号、资产标识、订单实收金额、可退款区间、固定申请退款金额、退款原因、申请备注、附件和真实业务提交人;除申请备注可为空外均需完成控件映射。金额控件只用于展示,审批人不得修改;线下充值至少包含店铺、充值单号、金额、备注、附件和真实业务提交人。
- 后端必须验证模板可访问、控件存在、控件类型匹配和所有必填业务字段已映射。前端不能提交控件类型或名称作为可信事实,也不直接编辑原始映射 JSON。 - 后端必须验证模板可访问、控件存在、控件类型匹配和所有必填业务字段已映射。前端不能提交控件类型或名称作为可信事实,也不直接编辑原始映射 JSON。
- 后台每 10 分钟验证启用模板。模板不可访问或指纹变化时暂停对应场景并告警;系统不能假设能从旧模板 ID 自动发现企微生成的新模板 ID。 - 后台每 10 分钟验证启用模板。模板不可访问或指纹变化时暂停对应场景并告警;系统不能假设能从旧模板 ID 自动发现企微生成的新模板 ID。
- 场景处于暂停中、已暂停或当前模板失效时,创建退款或线下充值必须在任何业务单、审批实例或 Outbox 写入前失败。前端保留表单,恢复后用户重新发起创建请求;不留下待补提的孤儿业务单。 - 场景处于暂停中、已暂停或当前模板失效时,创建退款或线下充值必须在任何业务单、审批实例或 Outbox 写入前失败。前端保留表单,恢复后用户重新发起创建请求;不留下待补提的孤儿业务单。
@@ -218,7 +218,7 @@ Status: ready-for-agent
- 主要自动化接缝采用最高公共行为边界Fiber HTTP 路由与认证 → Application/Domain/Query → GORM → 开发 PostgreSQL、Redis 和 Asynq 可控队列;企业微信网络统一替换为可编程 WeCom Adapter。测试不直接断言私有函数或目录结构。 - 主要自动化接缝采用最高公共行为边界Fiber HTTP 路由与认证 → Application/Domain/Query → GORM → 开发 PostgreSQL、Redis 和 Asynq 可控队列;企业微信网络统一替换为可编程 WeCom Adapter。测试不直接断言私有函数或目录结构。
- 回调测试从真实 HTTP 回调入口注入使用测试 Token/AES Key 生成的签名密文,验证 URL 校验、签名错误、解密错误、错误 CorpID、重复事件、快速响应和统一状态同步不绕过回调协议直接调用内部函数。 - 回调测试从真实 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 获取/刷新、成员身份、模板读取、附件上传、发起审批、详情查询,以及企微非零错误、超时、响应丢失和限流。自动化测试不依赖真实企微网络。 - WeCom Adapter 契约测试覆盖 Token 获取/刷新、成员身份、模板读取、附件上传、发起审批、详情查询,以及企微非零错误、超时、响应丢失和限流。自动化测试不依赖真实企微网络。
- 场景测试覆盖首次发布、暂停中停止发租约、租约自然释放/续租、已暂停、新版本原子切换、模板不可访问、指纹变化、必填映射缺失、控件类型不匹配和发布失败保持暂停。 - 场景测试覆盖首次发布、暂停中停止发租约、租约自然释放/续租、已暂停、新版本原子切换、模板不可访问、指纹变化、必填映射缺失、控件类型不匹配和发布失败保持暂停。
- 身份测试覆盖平台本人扫码、代理禁止绑定、非企业成员、过期/重复 `state`、同一成员绑定第二账号、换绑、自助解绑、强制解绑、固定代理成员可用/失效,以及历史实例不随绑定变化。 - 身份测试覆盖平台本人扫码、代理禁止绑定、非企业成员、过期/重复 `state`、同一成员绑定第二账号、换绑、自助解绑、强制解绑、固定代理成员可用/失效,以及历史实例不随绑定变化。
@@ -233,7 +233,7 @@ Status: ready-for-agent
- 日志与响应测试验证 Secret、Token、EncodingAESKey、对象 Key、临时 `media_id`、原始固定 `userid` 和完整附件不会出现在 API、日志或审计详情中。 - 日志与响应测试验证 Secret、Token、EncodingAESKey、对象 Key、临时 `media_id`、原始固定 `userid` 和完整附件不会出现在 API、日志或审计详情中。
- 停机迁移测试覆盖历史终态标为 `legacy`、待审批按平台/代理身份迁移、缺失绑定进入待处理、重复执行不重复创建实例,以及旧审批路由确实不再注册。 - 停机迁移测试覆盖历史终态标为 `legacy`、待审批按平台/代理身份迁移、缺失绑定进入待处理、重复执行不重复创建实例,以及旧审批路由确实不再注册。
- 前端人工验收覆盖连接配置、模板维护、暂停等待、本人扫码绑定、代理代提交、审批运行筛选、立即同步、两种异常恢复、权限菜单、详情投影、通过后撤销红色告警以及所有加载/空/失败状态。 - 前端人工验收覆盖连接配置、模板维护、暂停等待、本人扫码绑定、代理代提交、审批运行筛选、立即同步、两种异常恢复、权限菜单、详情投影、通过后撤销红色告警以及所有加载/空/失败状态。
- 真实企微联调单独在受控环境完成两个模板的读取/发布、平台本人发起、代理固定账号代发、多级/会签/或签、意见附件、通过/拒绝、回调丢失后的轮询和提交结果未知处置;联调记录不得包含密钥或个人敏感信息。 - 真实企微验收是企微公共能力及首个接入业务的实现完成门禁,不得只推迟到后续联调。至少使用真实模板完成平台本人发起、代理固定账号代发、真实附件、通过/拒绝、加密回调和回调丢失后的轮询兜底;回调可按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发,后端仍完整验签解密。自动化 Adapter 继续覆盖超时、响应未知、重复/乱序、撤销、删除、通过后撤销和限流等难以稳定人工制造的异常。真实参数和验收记录不得泄漏密钥或个人敏感信息。
## Out of Scope ## Out of Scope

View File

@@ -87,7 +87,7 @@ Status: ready-for-agent
- 新建独立 `tb_device_batch_allocation_task`,不复用语义不同的设备导入任务或导出任务表,也不建立数据库外键。 - 新建独立 `tb_device_batch_allocation_task`,不复用语义不同的设备导入任务或导出任务表,也不建立数据库外键。
- 任务至少保存:任务号、源文件 Key、`operation_type`、目标 ID、操作者账号 ID、操作者类型与店铺快照、总数、已处理数、成功数、幂等数、失败数、状态、失败明细、错误摘要、开始时间和完成时间。 - 任务至少保存:任务号、源文件 Key、`operation_type`、目标 ID、操作者账号 ID、操作者类型与店铺快照、总数、已处理数、成功数、幂等数、失败数、状态、失败明细、错误摘要、开始时间和完成时间。
- `operation_type` 固定为 `assign_shop|assign_series` - `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` 和明细表达结果。文件级解析错误、存储错误或不可恢复的基础设施错误才是“失败”。 - 行级业务失败不把整个任务标为系统失败CSV 被完整处理后,即使全部行都因业务原因失败,任务仍是“已完成”并通过 `fail_count` 和明细表达结果。文件级解析错误、存储错误或不可恢复的基础设施错误才是“失败”。
- `total_count = success_count + fail_count``success_count` 包含实际变更与同目标幂等成功;`idempotent_count` 是成功数的子集。重复 CSV 行计入失败,避免汇总数字无法对齐上传行数。 - `total_count = success_count + fail_count``success_count` 包含实际变更与同目标幂等成功;`idempotent_count` 是成功数的子集。重复 CSV 行计入失败,避免汇总数字无法对齐上传行数。
- 失败明细最多 1000 条,与单文件行数上限一致;每项返回原始行号、脱敏或安全的输入设备号和中文原因。不得把数据库错误或其他租户资源信息原样返回。 - 失败明细最多 1000 条,与单文件行数上限一致;每项返回原始行号、脱敏或安全的输入设备号和中文原因。不得把数据库错误或其他租户资源信息原样返回。

View File

@@ -32,6 +32,46 @@
**欠款 / Debt**:代理主钱包账面余额低于零的部分;冻结信用不直接形成欠款。 **欠款 / Debt**:代理主钱包账面余额低于零的部分;冻结信用不直接形成欠款。
**代理自主在线充值 / Agent Self-service Online Recharge**:代理只能为当前登录账号所属店铺的主钱包发起在线充值,请求不接受目标 `shop_id`;代理选择支付方式 `wechat``alipay`,无需审批。平台、超级管理员和企业账号不能用该路径替代理创建在线支付二维码。
**代理充值金额边界 / Agent Recharge Amount Boundary**:代理在线扫码充值金额为 `10000100000000`100 元至 100 万元);平台或超级管理员线下代充值保持现有 `1100000000` 分,即金额必须大于 0 且最高 100 万元。两条路径使用各自的金额校验,不能把在线起付金额套在线下代充值上。
**平台线下代充值 / Platform Offline Recharge**:平台或超级管理员为指定代理店铺主钱包发起的 `offline` 充值,必须明确目标 `shop_id` 并进入企业微信审批;代理和企业账号不能发起该路径。
**线下代充值申请资料 / Offline Recharge Application Materials**:平台或超级管理员创建线下代充值时,目标 `shop_id` 和充值金额必填;付款凭证必传 15 个,每项提交本地对象存储的 `file_key`、原始 `file_name``file_size`;备注选填且最多 500 字。充值金额在创建时固定,企微审批人只能同意或拒绝,不能修改金额。
**线下代充值驳回终结 / Offline Recharge Rejection Finalization**:企微拒绝后,原线下充值单以 `6=已驳回`终结,原申请资料、审批结果和拒绝原因保留用于审计。系统不提供退回修改、`7=已退回`或原单重新提交;修正金额、凭证或备注后仍需充值时,必须创建新的充值单和企微审批。
**线下代充值审批撤销 / Offline Recharge Approval Cancellation**:企微在通过前撤销或删除时,充值单改为 `4=已关闭`且不入账,可以重新创建;企微通过后、钱包尚未入账时发生撤销,终止入账并关闭充值单;钱包已经入账后再收到撤销时不自动扣减代理余额,充值单保持已完成,同时记录严重 Audit Event 并通知财务人工处理。
**线下代充值停机迁移 / Offline Recharge Cutover Migration**:停机发布时,历史已完成、已关闭、已退款和已驳回的线下充值只读保留,不补企微审批。仍待处理的线下充值仅在真实提交人平台账号已绑定企微且申请资料完整时创建真实企微审批继续处理;其余记录进入明确的迁移异常清单,不自动入账,也不能再通过旧 `offline-pay` 处理。
**代理充值支付配置 / Agent Recharge Payment Configuration**:代理只选择支付方式 `wechat``alipay`,前端不提交、不选择支付通道。后端分别使用当前配置好的微信或支付宝支付参数创建支付单;本期不建设多通道池、优先级、自动切换或故障转移。支付单仍需固化创建时使用的支付配置,以便后续查询、回调验签和对账使用原配置。
**代理充值可用支付方式 / Available Agent Recharge Payment Methods**`GET /api/admin/agent-recharges/payment-methods` 只返回配置完整且当前能够创建支付的 `wechat` 和/或 `alipay`,不向前端暴露商户号、支付通道或敏感配置诊断。创建充值时必须再次校验所选方式;配置在列表查询后失效时直接拒绝创建。
**代理充值付款码内容 / Agent Recharge QR Content**:微信或支付宝预下单返回的字符串或 HTTPS URL 由后端通过 `qr_content` 原样交给前端,前端自行渲染二维码;后端不生成二维码图片,也不提供独立的二维码生成接口。创建接口不返回 `expires_at`,前端不展示精确倒计时;用户重新拉起支付时创建全新的充值单和支付单,不复用原单。
**代理充值第三方失效 / Agent Recharge Third-party Expiration**:本地计算时间不能代表微信或支付宝订单的真实有效期,也不能据此直接关闭业务单。第三方查单确认订单未支付且已经关闭或失效后,不新增支付单状态码,现有 `tb_payment` 使用 `2=已失败`并记录第三方失效原因,对应充值单使用 `4=已关闭`。用户后续重新创建新充值单和新支付单。
**代理充值迟到成功回调 / Late Successful Recharge Callback**:本地因付款码过期而标记失败或关闭后,若收到微信或支付宝的有效成功回调,并完成第三方交易号、金额、创建时支付配置及业务关联核验,则以支付平台真实收款事实为准,将支付单恢复为已支付并对原充值单执行幂等钱包入账,不能吞掉用户已经支付的资金。
**代理充值预下单结果未知 / Unknown Recharge Pre-order Result**:微信或支付宝明确返回预下单失败时,支付单记为已失败、充值单记为已关闭,用户使用新的 `request_id` 创建新单。请求超时或结果未知时,不得直接关闭或另建支付;必须先按原支付单号向原支付配置查单,确认失败后才能关闭,确认成功则返回原 `qr_content`。同一提交账号重复使用相同 `request_id` 时始终返回原业务结果;参数发生变化则拒绝幂等冲突。
**代理充值主动新建 / Deliberate New Agent Recharge**`request_id` 只防止同一次提交因网络重试重复创建。代理每次主动点击创建或拉起支付,都必须使用新的 `request_id`,后端创建全新的充值单、支付单和 `qr_content`,不根据相同金额或既有未过期支付单复用旧单。此前未付款的支付单继续等待各自自然过期;多张支付单若均真实付款,则分别幂等入账。
**代理在线充值支付验收 / Agent Online Recharge Payment Acceptance**:本地和 Agent 自动化不调用真实微信或支付宝,复用 C 端已经验证的支付基础能力,并在支付网络边界使用可控替身验证后台充值的预下单契约、回调分发、金额与业务关联校验、状态推进、幂等入账及异常恢复。真实微信 Native 和支付宝 PreCreate 扫码支付在部署测试环境后由用户手工验收,不作为本地实现完成门禁;线下充值依赖的真实企微验收仍按企微公共能力执行。
**代理充值支付状态同步 / Agent Recharge Payment Status Synchronization**:前端每 3 秒调用轻量支付状态接口时只读取本地状态,不直接触发微信或支付宝查单。后端可靠任务按受控频率查询仍待支付的第三方订单,用于补偿回调丢失并确认第三方关闭或失效;查单结果和支付回调进入同一个幂等支付确认用例。只有第三方明确返回支付成功或已关闭时才推进本地状态,查询超时或未知状态继续保持待支付;用户主动创建新支付不影响旧单继续同步和自然收敛。
**充值支付状态与入账状态 / Recharge Payment and Processing Status**`payment_status` 只表达第三方支付是否完成;`processing_status` 统一表达在线支付成功或线下审批通过后的钱包入账进度,固定为 `0=未触发, 1=处理中, 2=处理成功, 3=处理失败`,不再提供同义字段 `wallet_posting_status`。第三方已经收款但钱包入账失败时,支付状态仍为成功,处理状态为失败并由可靠任务重试;前端不得提供人工重复入账按钮。
**代理充值两阶段入账 / Two-stage Agent Recharge Posting**:在线支付回调事务只固化真实收款事实:支付单变为已支付、充值单变为 `2=已支付``processing_status=1`,并可靠写入入账 Outbox随后由 Worker 在独立事务中更新代理主钱包余额和版本、创建唯一充值流水、把充值单改为 `3=已完成``processing_status=2`,并写资金 Audit Event。Worker 失败不回滚支付事实,改为 `processing_status=3`并可靠重试;支付回调在收款事实成功落库后即可向渠道返回成功,不等待钱包入账。
**代理充值到账通知 / Agent Recharge Posted Notification**:无论代理在线扫码充值还是平台线下代充值,只要目标代理主钱包实际入账成功,都必须向目标代理发送一条站内“充值到账”通知。线下充值的企微审批结果仍由企微公共能力另行通知真实业务提交人,审批结果与实际到账是两类不同通知;通知投递失败不回滚资金事务,并按充值单与接收人防重。入账持续失败、通过后撤销等内部异常只通知平台财务或运维,不向代理暴露内部错误。
**代理充值查看范围 / Agent Recharge Visibility**:充值记录不按创建账号隔离。代理账号在具备充值查看权限时,按既有店铺层级数据范围查看本店及有权管理的下级店铺充值,可读取充值金额、支付方式、付款凭证、真实提交人、审批状态和钱包入账结果,但不能读取企微审批人、内部意见、审批人附件或支付配置等内部信息。平台和超级管理员沿用既有数据范围,企业账号不可访问;列表、详情和支付状态接口使用完全相同的数据范围。
## 企业微信审批WeCom Approval ## 企业微信审批WeCom Approval
**业务提交人 / Business Submitter**:在本系统实际创建退款或线下充值申请的登录账号,是审批单展示、通知、数据权限和审计中的真实发起人。 **业务提交人 / Business Submitter**:在本系统实际创建退款或线下充值申请的登录账号,是审批单展示、通知、数据权限和审计中的真实发起人。
@@ -40,10 +80,34 @@
**单次退款申请 / Single Refund Application**:一张退款单只对应一条企微审批申请。企微拒绝后,审批和该退款单同时终结,原退款单不可修改、不可重提;业务人员纠正问题后若仍需退款,必须重新创建退款单,生成新的退款 ID、退款单号、业务快照和企微审批申请。 **单次退款申请 / Single Refund Application**:一张退款单只对应一条企微审批申请。企微拒绝后,审批和该退款单同时终结,原退款单不可修改、不可重提;业务人员纠正问题后若仍需退款,必须重新创建退款单,生成新的退款 ID、退款单号、业务快照和企微审批申请。
**整单退款终结 / Whole-order Refund Finalization**:退款创建接口只接受订单 ID、申请退款金额、原因、备注和附件订单实收金额由后端读取并固化快照不接受前端提交的 `actual_received_amount`,也不再接受 `package_usage_id`。申请退款金额必须大于 0 且小于等于订单实收金额,提交后不可由企微审批修改。无论申请金额是否等于实收金额,企微通过后的业务处理都按整张订单终结:订单标记已退款、该订单产生的套餐整体失效、该订单已入账佣金整体回扣;该订单不能再对未退差额发起第二次退款。
**资产钱包订单退款 / Asset-wallet Order Refund**:个人客户使用资产钱包余额购买的订单允许创建退款申请,但退款资金不自动回充资产钱包。财务必须先在系统外向客户完成人工退款,再在企微同意;企微同意后系统只执行整单终结,不调用支付渠道,也不产生资产钱包退款入账。只有代理主钱包支付的订单才按原扣款流水自动回溯原代理主钱包。
**退款申请资料 / Refund Application Materials**:退款原因必填,最多 1000 字;申请备注选填,最多 500 字。退款业务凭证必传 15 个,每项提交本地对象存储的 `file_key`、原始 `file_name``file_size`;本地业务资料是权威事实,上传企微的文件仅为审批副本,不能用审批人附件替代申请凭证。
**退款佣金失效 / Refund Commission Invalidation**:订单整单退款时,该订单尚未发放、解冻中、冻结中或待人工处理的佣金不再具备发放资格,直接改为已失效;已经发放的佣金先从对应佣金钱包全额扣回,钱包余额允许为负,再将佣金记录改为已失效。佣金记录使用结构化 `invalid_reason` 区分 `order_refund``manual_resolution`,因退款失效时同时保存退款单引用和失效时间,不能只在备注中写原因。每条已发放佣金以退款单和佣金记录组成业务防重键;佣金状态、钱包余额、扣回流水与统一 Audit Event 在同一事务提交,审计关联退款、订单、佣金、钱包和流水并保存变更前后事实。
**固定退款金额审批 / Fixed Refund Amount Approval**:创建退款时后端校验 `0 < requested_refund_amount <= actual_received_amount` 并固化金额。企微审批单只读展示订单实收金额、可退款区间和本次申请金额;审批人只能同意或拒绝,不能修改金额,认为金额有误时应拒绝,由业务人员创建新退款单。本系统不回改企微审批结论:企微已通过后即使本地资金或整单终结处理失败,审批状态和退款状态仍保持已通过,以独立 `processing_status=失败`、失败摘要和可靠重试表达本地失败。
**实际退款金额 / Actual Refund Amount**`actual_refund_amount` 只表示资金已经实际完成的金额,不等同于审批金额。非代理钱包由财务先在系统外退款再同意企微,企微通过时写入申请金额;代理主钱包只有余额回溯与唯一退款流水事务成功后才写入申请金额。驳回、撤销、删除及尚未完成代理钱包回溯时为空;资金已完成但佣金或套餐处理失败时保留该金额,并由业务处理状态说明整单终结尚未完成。旧 `approved_refund_amount` 只保留历史兼容,不作为新业务对外概念。
**代理退款查看范围 / Agent Refund Visibility**:退款申请向代理开放后,不再按创建账号隔离。代理主账号及店铺内具备退款查看权限的账号按既有店铺层级数据范围查看本店及有权管理的下级店铺退款;退款资料、业务凭证、真实提交人、审批状态和业务处理结果仍受业务权限与数据范围约束。任何代理均不得看到企微审批人、内部意见或审批人附件;平台和超级管理员只有同时具备退款业务查看权限时才可读取完整审批详情。
## 批量订购Bulk Purchase ## 批量订购Bulk Purchase
**批量订购批次 / Bulk Purchase Batch**:内部员工一种统一支付方式提交的一份 CSV 订购任务。批次不绑定单一代理,同一批次可以包含不同代理名下的资产。 **批量订购批次 / Bulk Purchase Batch**:内部员工选择一个套餐和一种统一支付方式提交的一份 CSV 订购任务。CSV 只提供资产标识;批次不绑定单一代理,同一批次可以包含不同代理名下的资产。
**结算代理 / Settlement Agent**:批量订购每一行根据资产处理时的当前归属解析出的代理。该行的套餐授权、成本价和代理主钱包均以结算代理为准;结算代理不是前端提交的批次参数。 **结算代理 / Settlement Agent**:批量订购每一行根据资产处理时的当前归属解析出的代理。该行的套餐授权、成本价和代理主钱包均以结算代理为准;结算代理不是前端提交的批次参数。
**批量订购重复行 / Duplicate Bulk Purchase Row**同一 CSV 中“资产类型 + 标准化资产标识 + 套餐编码”相同的行。首次出现的行正常处理,后续重复行失败并指向首次出现的行号;同一资产的不同套餐不算重复,重复行也不表达购买多份 **批量订购重复行 / Duplicate Bulk Purchase Row**Worker 通过统一资产解析能力将输入标识解析为唯一的资产类型和资产 ID 后,同一 CSV 中再次指向该资产的行。一个批次只有一个套餐,因此无需再把套餐作为重复键;同一资产使用不同可识别标识仍属于重复,首次出现的行正常处理,后续重复行失败并指向首次出现的行号。
**批量订购资产标识 / Bulk Purchase Asset Identifier**:批量订购 CSV 中唯一由用户填写的业务字段。Worker 复用系统统一资产解析能力,由标识识别资产类型和资产 ID当前统一能力可识别 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN。未命中或无法唯一解析时该行失败。
**批量订购钱包支付 / Bulk Purchase Wallet Payment**:批量订购的 `payment_method=wallet` 复用现有后台订单枚举,逐行扣该资产结算代理的主钱包;不使用同义值 `agent_wallet`
**批量订购文件级失败 / Bulk Purchase File-level Failure**:创建接口只检查对象存储对象、上传主体、文件类型和 10MB 大小。Worker 发现编码、表头、未知列、CSV 语法、空文件或超过 1000 行时,任务整体失败且不创建订单;可部分成功仅适用于文件有效后的逐行业务校验。
**批量订购钱包行序 / Bulk Purchase Wallet Row Order**`wallet` 批次按 CSV 行号逐行结算不预占整批或某一代理全部行的金额。余额不足只失败当前行并继续后续行所以同一代理资金不足时CSV 行序就是订购优先级。
**异步任务完成 / Async Task Completed**:异步任务已经把其可处理工作执行到终点,不等于每个业务项都成功。全部成功、部分成功和全部业务项失败由成功数与失败数表达;只有任务无法完成解析或执行时才属于任务失败。

View File

@@ -336,7 +336,7 @@ sequenceDiagram
→ 原子发布新版本并恢复场景 → 原子发布新版本并恢复场景
``` ```
退款至少映射店铺、退款单号、申请金额、原因、附件和提交人;线下充值至少映射店铺、充值单号、金额、备注、附件和提交人。后台每 10 分钟验证启用版本,模板不可访问或 fingerprint 变化时暂停场景并发送系统告警。 退款至少映射店铺、退款单号、订单实收金额、可退款区间、固定申请金额、原因、备注、附件和真实业务提交人;线下充值至少映射店铺、充值单号、金额、备注、附件和提交人。退款金额在提交后只读,企微审批人只能同意或拒绝。后台每 10 分钟验证启用版本,模板不可访问或 fingerprint 变化时暂停场景并发送系统告警。
场景处于暂停中、已暂停,或当前模板版本失效时,新的退款/线下充值创建请求必须在写入任何业务单、审批实例或 Outbox 之前失败,并返回明确的“审批场景当前不可用”。前端保留用户已经填写的表单,待场景恢复后由用户重新提交;后端不为失败请求保留待补提的孤儿业务单。暂停前已经成功创建的审批实例继续接收回调、执行 2 分钟兜底同步和终态业务处理,不受场景暂停影响。 场景处于暂停中、已暂停,或当前模板版本失效时,新的退款/线下充值创建请求必须在写入任何业务单、审批实例或 Outbox 之前失败,并返回明确的“审批场景当前不可用”。前端保留用户已经填写的表单,待场景恢复后由用户重新提交;后端不为失败请求保留待补提的孤儿业务单。暂停前已经成功创建的审批实例继续接收回调、执行 2 分钟兜底同步和终态业务处理,不受场景暂停影响。
@@ -585,7 +585,7 @@ stateDiagram-v2
Submitting --> Processing: 创建任务成功 Submitting --> Processing: 创建任务成功
Submitting --> Editing: 参数或上传失败 Submitting --> Editing: 参数或上传失败
Processing --> Processing: 轮询进度 Processing --> Processing: 轮询进度
Processing --> Completed: 全部或部分完成 Processing --> Completed: 业务处理完成
Processing --> Failed: 任务失败 Processing --> Failed: 任务失败
Completed --> [*] Completed --> [*]
Failed --> Editing: 修正后创建新任务 Failed --> Editing: 修正后创建新任务
@@ -594,7 +594,8 @@ stateDiagram-v2
- 创建成功后立即请求一次详情,再按 2、3、5 秒退避,最大间隔 10 秒。 - 创建成功后立即请求一次详情,再按 2、3、5 秒退避,最大间隔 10 秒。
- 页面不可见时暂停轮询,恢复可见时立即刷新。 - 页面不可见时暂停轮询,恢复可见时立即刷新。
- 网络错误不等于业务失败,展示“状态获取失败,点击重试”。 - 网络错误不等于业务失败,展示“状态获取失败,点击重试”。
- 必须展示总数、成功数、失败数和部分成功状态。 - 全局异步任务状态固定为 `1=待处理, 2=处理中, 3=已完成, 4=已失败, 5=已取消`;各业务不得另占状态码或定义“部分成功状态。
- 必须展示总数、成功数和失败数;全部成功、部分成功和全部业务项失败是完成任务的结果摘要,由计数推导。
- 页面刷新后根据任务 ID 恢复进度。 - 页面刷新后根据任务 ID 恢复进度。
- 批量文件模板由前端静态资源提供;设备批量分配和批量订购均只接受 CSV但各自使用独立表头契约。后端仍严格校验表头、编码、文件大小和内容。 - 批量文件模板由前端静态资源提供;设备批量分配和批量订购均只接受 CSV但各自使用独立表头契约。后端仍严格校验表头、编码、文件大小和内容。
@@ -1217,18 +1218,19 @@ GET /api/admin/shops/fund-summary 返回信用和可用金额
#### 规则和流程 #### 规则和流程
- 支付方式整批选择 `offline` `agent_wallet`CSV 不包含支付方式 - 创建批次时选择单个 `package_id` 和整批支付方式 `offline|wallet`CSV 不包含套餐或支付方式。`wallet` 复用现有后台订单支付枚举,在本批次中表示逐行扣结算代理主钱包,不新增同义枚举 `agent_wallet`
- 混合支付必须拆成不同批次。 - 混合支付必须拆成不同批次。
- 批次不选择代理、不接收 `shop_id`;同一 CSV 可以包含不同代理的资产。Worker 逐行以资产当前归属解析结算代理,套餐授权、成本价、钱包和数据权限均以该行解析结果为准。 - 批次不选择代理、不接收 `shop_id`;同一 CSV 可以包含不同代理的资产。Worker 逐行以资产当前归属解析结算代理,套餐授权、成本价、钱包和数据权限均以该行解析结果为准。
- 创建、查询批次和查看明细只允许超级管理员,或具备独立“批量订购套餐”权限的平台账号;代理和企业账号无页面及 API 权限。平台身份只授予发起批次的能力,不跳过任何逐行资产、结算代理、套餐授权、成本价或钱包校验。 - 不新增批量订购后端权限码、账号类型拦截或任务创建人隔离。页面是否展示入口沿用前端现有可见性规则;能够通过现有后台认证调用接口的主体视为可以使用。此决定不跳过逐行资产、结算代理、套餐授权、成本价或钱包校验。
- CSV 固定 UTF-8允许 BOM模板字段:资产类型、资产标识、套餐编码、套餐名称;实际匹配使用套餐编码,套餐名称只供人工核对。不接受 Excel - CSV 固定 UTF-8允许 BOM只有一列表头 `资产标识`,不接受 Excel。Worker 复用系统统一资产解析能力识别资产类型和资产 ID不在批量用例维护标识白名单当前统一能力支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN
- 同一 CSV 内按“资产类型 + 标准化资产标识 + 套餐编码”识别重复业务行:首次出现的行正常处理,后续重复行记为失败并返回首次出现的行号;同一资产订购不同套餐不视为重复。本期不通过复制相同行表达购买多份,未来需要多份时增加明确数量字段 - Worker 按统一解析得到的“资产类型 + 资产 ID”识别重复行。一个批次只有一个套餐因此重复键不包含套餐同一资产即使使用不同受支持标识仍属于重复首次出现的行正常处理,后续重复行失败并返回首次行号。本期不通过复制相同行表达购买多份。
- 资产标识未命中或无法唯一解析时只失败对应行,禁止任意选择资产。明细保留用户原始资产标识、解析后的资产类型/ID和规范标识快照便于审计和跨标识判重。
- 任务允许部分成功,每行是独立、可重试、可审计的业务单元。 - 任务允许部分成功,每行是独立、可重试、可审计的业务单元。
- 文件最大 10MB、最多 1000 行;失败明细最多保存 1000 条。 - 文件最大 10MB、最多 1000 行;失败明细最多保存 1000 条。创建接口只校验对象存在、属于当前上传主体、扩展名/Content-Type 和 10MB 大小不同步解析文件Worker 校验 UTF-8允许 BOM、固定表头、未知列、CSV 语法、空文件和 1000 行上限。任一文件级校验失败时任务整体失败且不创建订单;资产、套餐、归属、钱包和重复行等业务错误才进入逐行失败并允许部分成功。
```mermaid ```mermaid
flowchart TD flowchart TD
Submit[选择支付方式CSV及凭证直传私有对象存储] --> Task[提交file_key和voucher_keys创建任务] Submit[选择单个套餐和支付方式CSV及凭证直传私有对象存储] --> Task[提交package_id、file_key和voucher_keys创建任务]
Task --> Parse[解析并持久化逐行明细] Task --> Parse[解析并持久化逐行明细]
Parse --> Item{处理下一行} Parse --> Item{处理下一行}
Item -->|钱包| Wallet[锁钱包并校验有效可用金额] Item -->|钱包| Wallet[锁钱包并校验有效可用金额]
@@ -1245,11 +1247,10 @@ flowchart TD
| 表 | 关键字段 | | 表 | 关键字段 |
|----|----------| |----|----------|
| `tb_bulk_purchase_task` | 任务号、`request_id`、文件 Key、支付方式、凭证快照、涉及代理数、金额和数量汇总、状态、处理租约 | | `tb_bulk_purchase_task` | 任务号、`request_id`、文件 Key、所选套餐 ID/编码/名称快照、支付方式、凭证快照、涉及代理数、金额和数量汇总、状态、处理租约 |
| `tb_bulk_purchase_item` | 行号、资产、结算代理快照、套餐快照、金额、状态、订单 ID、错误码、错误原因、幂等键 | | `tb_bulk_purchase_item` | 行号、解析后的资产类型、用户原始资产标识、解析后资产 ID、规范标识快照、结算代理快照、金额、状态、订单 ID、错误码、错误原因、幂等键 |
任务状态:`1=待处理, 2=处理中, 3=完成, 4=部分成功, 5=失败` 任务复用全局异步任务状态:`1=待处理, 2=处理中, 3=完成, 4=已失败, 5=已取消`。文件有效且逐行处理结束时任务为已完成,部分成功只通过 `success_count``fail_count` 表达;本期没有取消入口,但保留状态码 5。逐行明细不是独立任务使用 `1=待处理, 2=处理中, 3=成功, 4=失败`
明细状态:`1=待处理, 2=处理中, 3=成功, 4=失败`
必须具备: 必须具备:
@@ -1259,9 +1260,10 @@ flowchart TD
- 钱包可用金额统一使用 `credit_enabled` 后的有效信用额度,禁止直接无条件加 `credit_limit` - 钱包可用金额统一使用 `credit_enabled` 后的有效信用额度,禁止直接无条件加 `credit_limit`
- 钱包余额、版本、订单、资金流水和明细成功状态在同一事务。 - 钱包余额、版本、订单、资金流水和明细成功状态在同一事务。
- 钱包支付逐行扣该资产当前所属代理的主钱包;资产无代理归属、归属异常或该代理没有有效主钱包时只将该行记为失败。同一批次可依次锁定不同代理钱包,不存在整批共享钱包。 - 钱包支付逐行扣该资产当前所属代理的主钱包;资产无代理归属、归属异常或该代理没有有效主钱包时只将该行记为失败。同一批次可依次锁定不同代理钱包,不存在整批共享钱包。
- `wallet` 批次严格按 CSV 行号顺序逐行结算不预占整批或某一代理全部行的金额。当前行余额不足只失败该行并继续处理后续行后续金额较小的行若当时余额足够仍可成功。因此同一代理资金不足时CSV 行序就是订购优先级。
- 单行失败不回滚其他成功行,任务统计从明细表重新聚合。 - 单行失败不回滚其他成功行,任务统计从明细表重新聚合。
- 批量订购必须复用需求 15 的套餐可售策略,不能绕过下架续费限制。 - 批量订购必须复用需求 15 的套餐可售策略,不能绕过下架续费限制。
- 任务详情和明细只能由上述有权平台主体读取;不得通过猜测 `task_id` 向代理或企业账号暴露跨代理资产、钱包或失败原因 - 任务详情和明细复用现有后台认证及公共数据范围,不新增基于账号类型、权限码或任务创建人的过滤
API API
@@ -1272,16 +1274,33 @@ GET /api/admin/bulk-purchases/{task_id}
GET /api/admin/bulk-purchases/{task_id}/items GET /api/admin/bulk-purchases/{task_id}/items
``` ```
CSV 先使用 `purpose=bulk_purchase` 获取预签名地址并直传私有对象存储;线下凭证使用附件用途直传。`POST /api/admin/bulk-purchases` 只接收 JSON`request_id``payment_method``file_key``voucher_keys`,不接收 `shop_id`、multipart 或文件字节。创建任务前校验对象存在、归属当前上传主体、扩展名/Content-Type 和大小;Worker 按稳定 `file_key` 下载解析 CSV 先使用 `purpose=bulk_purchase` 获取预签名地址并直传私有对象存储;线下凭证使用附件用途直传。`POST /api/admin/bulk-purchases` 只接收 JSON`request_id`单个 `package_id``payment_method``file_key``voucher_keys`,不接收 `shop_id`、multipart 或文件字节。创建任务前校验套餐存在以及对象归属、类型和 10MB 大小并立即返回任务;逐行按结算代理的当前套餐授权、价格和可售规则再次校验。Worker 按稳定 `file_key` 下载并执行文件级解析校验
线下凭证仅作为本批次业务资料和审计快照,本期不校验跨批次唯一性或建立财务核销规则。 线下凭证仅作为本批次业务资料和审计快照,本期不校验跨批次唯一性或建立财务核销规则。
#### 自动化测试环境隔离
- 已部署测试环境的 API/Worker 使用 Redis DB 6本地开发和 Agent 自动化测试统一使用 Redis DB 7。自动化测试禁止向 DB 6 写入普通 Redis Key 或 Asynq 任务。
- 测试入口必须在启动前校验实际生效的 Redis DB只有 DB 7 才允许继续;不能只依赖调用方记得覆盖环境变量。当前 Redis 客户端、Asynq Client 和 Worker Server均从同一 `Redis.DB` 配置取值,测试必须保持三者一致。
- DB 7 也可能被多个本地进程共用,测试不得执行 `FLUSHDB`;测试数据、业务键和清理由每次运行的唯一标识及实际创建 ID 限定。
- 自动化测试直接使用当前可调用的真实 S3不建立内存对象存储替身每次运行使用唯一对象 Key并只删除本次创建的测试对象。真实上传、下载和删除失败都必须作为测试失败暴露。
- PostgreSQL 继续使用现有测试库,不新增专用数据库。测试夹具必须带唯一运行标识并按实际创建记录 ID 精确清理,禁止 `TRUNCATE`、清整表、模糊条件删除或修改既有业务数据;并发钱包测试也在该测试库内使用隔离夹具执行。
- 自动化以 Go HTTP 集成测试为主:通过 Fiber `app.Test` 穿过真实路由、认证、Handler、Application/Domain、GORM 和统一响应;测试接缝捕获待投递任务后直接调用公开 Worker Handler不依赖 `sleep` 等待后台 Worker生产仍使用真实 Asynq。
- 本需求实现时必须沉淀可供后续 Agent 复用的集成测试规范、环境守卫与资源清理 Harness、典型 CSV `testdata` 和完整示例;另提供真实部署环境的 `curl` 冒烟模板,但 `curl` 不替代 Go 自动化测试。
### 6.5 需求 20退款审批 ### 6.5 需求 20退款审批
退款金额在申请时完成合法性校验并固定,企微审批不允许修改金额。退款凭证先保存本地对象存储,再上传企微副本;审批人可在企微看到业务字段、提交备注和附件 退款创建只接收 `order_id``requested_refund_amount`、必填且最多 1000 字的 `refund_reason`、可选且最多 500 字的 `remark`,以及 15 个 `{file_key,file_name,file_size}` 附件。订单实收金额由后端读取并固化快照,不接受前端提交 `actual_received_amount`,也不再接受 `package_usage_id`
申请金额必须满足 `0 < requested_refund_amount <= 订单实收金额`,提交后固定。企微展示订单实收金额、可退款区间和申请金额,审批人只能同意或拒绝,不能修改金额。退款凭证先保存本地对象存储,再上传企微副本;本地对象 Key 是权威资料,企微 `media_id` 只是临时审批副本。
退款状态在历史枚举基础上追加 `5=已撤销/审批已删除`,保留历史 `4=已退回` 语义但新企微审批不再产生退回状态。独立保存业务处理结果,避免“企微已通过但代理钱包回溯或资产处理失败”被展示为全部完成。 退款状态在历史枚举基础上追加 `5=已撤销/审批已删除`,保留历史 `4=已退回` 语义但新企微审批不再产生退回状态。独立保存业务处理结果,避免“企微已通过但代理钱包回溯或资产处理失败”被展示为全部完成。
```text
退款状态1=待审批 2=已通过 3=已拒绝 4=已退回(仅历史) 5=已撤销/审批已删除
处理状态0=未触发 1=处理中 2=处理成功 3=处理失败
```
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
actor User as 平台员工/财务 actor User as 平台员工/财务
@@ -1294,25 +1313,34 @@ sequenceDiagram
User->>Refund: 创建退款申请 User->>Refund: 创建退款申请
Refund->>DB: 保存退款单、业务快照和企微实例 Refund->>DB: 保存退款单、业务快照和企微实例
Refund-->>WeCom: 异步提交申请和附件 Refund-->>WeCom: 异步提交申请和附件
User->>WeCom: 审批;非代理钱包先完成人工退款 User->>WeCom: 非代理钱包先人工退款;审批只能同意或拒绝
WeCom-->>Sync: 回调/轮询同步终态 WeCom-->>Sync: 回调/轮询同步终态
Sync->>DB: 保存审批详情并写终态Outbox Sync->>DB: 保存审批详情并写终态Outbox
DB-->>Worker: 首次通过时处理业务终态 DB-->>Worker: 首次通过时处理业务终态
Worker->>DB: 代理钱包回溯、订单/佣金/资产处理 Worker->>DB: 资金确认、订单/佣金/套餐整单终结
``` ```
关键规则: 关键规则:
- 微信、支付宝、线下等非代理钱包支付由财务在系统外人工退款后再通过企微;企微通过即表示人工退款已确认,本系统不调用渠道退款 API也不再提供本地 `manual-complete` 二次确认 - 无论申请金额是否等于订单实收金额,企微同意后的业务处理都按整张订单终结:订单标记已退款、该订单产生的全部有效套餐失效、全部佣金失效,且该订单不能再申请剩余差额。实际向客户退款的金额仍为本次申请金额
- 代理钱包支付订单在企微通过后按原扣款流水定位原代理主钱包,幂等回溯并写退款流水;可自然冲减负余额 - 微信、支付宝、线下及其他非代理钱包支付由财务在系统外人工退款后再通过企微;企微通过即表示人工退款已确认。本系统不调用渠道退款 API也不提供本地 `manual-complete` 二次确认
- 个人客户资产钱包不自动回款,现有 `BuyerTypePersonal` 自动资产钱包退款分支在迁移时删除或隔离。 - 个人客户资产钱包支付同样由财务系统外退款,本系统不自动回充资产钱包、不写资产钱包退款流水;现有 `BuyerTypePersonal` 自动资产钱包退款分支在迁移时删除或隔离。
- 代理主钱包支付订单在企微通过后必须按原扣款流水定位原代理主钱包,幂等回溯并写唯一退款流水;可自然冲减负余额。缺失原扣款流水时处理失败,禁止按当前店铺关系或买卖方猜测钱包。
- `actual_refund_amount` 只表示资金已经完成:非代理钱包在企微同意时写申请金额;代理主钱包在余额和退款流水事务成功时写申请金额。驳回、撤销、删除及代理钱包尚未回溯时为空;资金完成但佣金或套餐失败时保留。旧 `approved_refund_amount` 仅作历史兼容,不再是新业务概念。
- 已冻结、解冻中、未发放或待人工修正的佣金直接失效;已发放佣金先从对应佣金钱包全额扣回,再失效,钱包允许为负。佣金保存结构化 `invalid_reason``invalid_refund_id``invalidated_at`,每条已发放佣金以退款单和佣金记录组成防重键;状态、钱包、回扣流水和 Audit Event 同事务。
- 该订单产生的全部有效套餐失效;主套餐失效级联加油包,随后尝试激活下一条待生效主套餐,没有下一条时通过公共卡/设备状态能力停止资产。
- 驳回时本地状态改为已拒绝,审批和该退款单同时终结;原退款单不可编辑、不可再次提交。业务人员纠正驳回原因后仍需退款时,重新走 `POST /api/admin/refunds` 创建新退款单。撤销/删除时进入异常状态和人工处置,同样不开放原单重新提交。 - 驳回时本地状态改为已拒绝,审批和该退款单同时终结;原退款单不可编辑、不可再次提交。业务人员纠正驳回原因后仍需退款时,重新走 `POST /api/admin/refunds` 创建新退款单。撤销/删除时进入异常状态和人工处置,同样不开放原单重新提交。
- 通过后撤销且资金已执行时不自动冲正,记录 `critical` 审计、站内告警并人工处理;资金尚未执行时终止后续任务。 - 通过后撤销且资金已执行时不自动冲正,记录 `critical` 审计、站内告警并人工处理;资金尚未执行时终止后续任务。
- 原退款业务单级 `approve/reject/return` 路由下线,不存在本地审批动作 API。 - 原退款业务单级 `approve/reject/return` 路由下线,不存在本地审批动作 API。
- 原进程内佣金和套餐 Goroutine 改为 Outbox + 可靠 Worker。资金、佣金、套餐、资产状态各自保存持久化幂等事实局部失败只重试未完成步骤全部完成后才把处理状态置为成功。
一张退款单只对应一条企微审批申请。企微同意或拒绝后,该审批申请和退款单均形成不可变终态;本期下线既有 `POST /api/admin/refunds/{id}/resubmit`,不提供任何原退款单编辑或重提接口。拒绝后再次退款属于新的业务事实:前端重新进入退款创建流程,用户根据拒绝原因重新填写金额、凭证和原因,后端生成新的退款 ID、退款单号、业务快照、提交人快照和企微审批申请。新旧退款单只因指向同一订单或资产而具有关联不继承审批节点、意见、附件、状态或企微发起身份已拒绝退款不计入该订单或资产的活跃退款但仍须阻止与其他活跃退款并存。企微意外返回撤销、删除或通过后撤销时只进入异常处置不作为创建新退款的自动放行依据。 一张退款单只对应一条企微审批申请。企微同意或拒绝后,该审批申请和退款单均形成不可变终态;本期下线既有 `POST /api/admin/refunds/{id}/resubmit`,不提供任何原退款单编辑或重提接口。拒绝后再次退款属于新的业务事实:前端重新进入退款创建流程,用户根据拒绝原因重新填写金额、凭证和原因,后端生成新的退款 ID、退款单号、业务快照、提交人快照和企微审批申请。新旧退款单只因指向同一订单或资产而具有关联不继承审批节点、意见、附件、状态或企微发起身份已拒绝退款不计入该订单或资产的活跃退款但仍须阻止与其他活跃退款并存。企微意外返回撤销、删除或通过后撤销时只进入异常处置不作为创建新退款的自动放行依据。
前端只读展示企微审批状态、意见/附件和业务处理结果,不显示本地审批或人工退款确认按钮 代理退款查询不再按创建账号隔离,改为既有店铺层级和退款业务权限范围;代理可看业务凭证、真实提交人、审批状态和处理结果,但看不到审批人、内部意见和审批人附件。平台和超级管理员也必须具备退款业务查看权限才可读取完整审批详情。列表、详情、附件下载和导出复用同一权限投影
前端只读展示退款状态、企微审批状态和业务处理结果,不显示本地审批、金额修改、重提或人工退款确认按钮。详情固定分为退款业务信息、企微审批信息和业务处理结果;处理失败展示脱敏错误摘要及系统重试状态。
本需求实现完成必须同时通过两类门禁:可编程 WeCom Adapter 的可重复自动化,以及真实企微验收。真实企微至少完成“代理固定成员代提交并同意”和“平台本人绑定提交并拒绝”两张独立退款,覆盖真实附件、`applyevent`、加密回调、轮询兜底及本地资金/佣金/套餐/审计核对。回调按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发,后端仍完整验签解密;真实参数只通过安全配置提供。撤销、删除和通过后撤销由 Adapter 自动化稳定覆盖,不强制每次真实企微人工制造。
### 6.6 需求 21代理在线充值与员工线下充值审批 ### 6.6 需求 21代理在线充值与员工线下充值审批

View File

@@ -57,7 +57,7 @@ FE/BE 研发需求开发完成
| #38 不同渠道额度处理 | `[FE][UR#38] 角色默认信用和店铺实际额度管理` | `[BE][UR#38] 代理主钱包信用额度与并发资金不变量` | INT-05 钱包支付 | | #38 不同渠道额度处理 | `[FE][UR#38] 角色默认信用和店铺实际额度管理` | `[BE][UR#38] 代理主钱包信用额度与并发资金不变量` | INT-05 钱包支付 |
| #37 审核流转 | `[FE][UR#37] 企微审批状态、意见附件和结果通知展示` | `[BE][UR#37] 企微审批模板、账号绑定、回调和轮询补偿` | INT-06 企微审批 | | #37 审核流转 | `[FE][UR#37] 企微审批状态、意见附件和结果通知展示` | `[BE][UR#37] 企微审批模板、账号绑定、回调和轮询补偿` | INT-06 企微审批 |
| #36 批量订购套餐 | `[FE][UR#36] 批量订购上传、支付方式、进度和失败明细` | `[BE][UR#36] 批量订购任务、逐行幂等下单和钱包扣款` | INT-04 批量与导出、INT-05 钱包支付 | | #36 批量订购套餐 | `[FE][UR#36] 批量订购上传、支付方式、进度和失败明细` | `[BE][UR#36] 批量订购任务、逐行幂等下单和钱包扣款` | INT-04 批量与导出、INT-05 钱包支付 |
| #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态、人工退款和代理钱包回溯` | INT-06 企微审批 | | #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态与整单退款终结` | INT-06 企微审批 |
| #34 充值审核流程 | `[FE][UR#34] 代理扫码充值与员工线下审批状态页面` | `[BE][UR#34] 微信/支付宝充值入账和线下充值企微终态` | INT-05 钱包支付、INT-06 企微审批 | | #34 充值审核流程 | `[FE][UR#34] 代理扫码充值与员工线下审批状态页面` | `[BE][UR#34] 微信/支付宝充值入账和线下充值企微终态` | INT-05 钱包支付、INT-06 企微审批 |
| #33 套餐临期提醒 | `[FE][UR#33] 临期列表、各端高亮、3天置顶和续费入口` | `[BE][UR#33] 最终到期临期Query与15/7/3站内通知` | INT-03 套餐生命周期 | | #33 套餐临期提醒 | `[FE][UR#33] 临期列表、各端高亮、3天置顶和续费入口` | `[BE][UR#33] 最终到期临期Query与15/7/3站内通知` | INT-03 套餐生命周期 |
@@ -112,8 +112,8 @@ FE/BE 研发需求开发完成
| #40 下架套餐续费 | **标题:**C端当前套餐续费入口。<br>**页面:**当前套餐旁显示续费;下架套餐不出现在新购列表。 | **标题:**下架套餐续费资格。<br>**接口:**`GET /api/c/v1/asset/packages` 返回 `can_purchase/purchase_mode/disabled_reason``POST /api/c/v1/orders/create` 强校验资产所有人和历史使用记录。 | | #40 下架套餐续费 | **标题:**C端当前套餐续费入口。<br>**页面:**当前套餐旁显示续费;下架套餐不出现在新购列表。 | **标题:**下架套餐续费资格。<br>**接口:**`GET /api/c/v1/asset/packages` 返回 `can_purchase/purchase_mode/disabled_reason``POST /api/c/v1/orders/create` 强校验资产所有人和历史使用记录。 |
| #38 代理信用额度 | **标题:**角色默认信用和店铺实际额度管理。<br>**页面:**客户角色配置新建默认额度并提示不影响存量;店铺资金页单独调整实际额度。 | **标题:**代理主钱包信用额度。<br>**接口:**`PUT /api/admin/roles/{id}/default-credit``PUT /api/admin/shops/{id}/credit-limit``GET /api/admin/shops/fund-summary`。<br>**入参:**开关、额度、钱包版本。<br>**返回:**额度、可用金额、欠款和版本。 | | #38 代理信用额度 | **标题:**角色默认信用和店铺实际额度管理。<br>**页面:**客户角色配置新建默认额度并提示不影响存量;店铺资金页单独调整实际额度。 | **标题:**代理主钱包信用额度。<br>**接口:**`PUT /api/admin/roles/{id}/default-credit``PUT /api/admin/shops/{id}/credit-limit``GET /api/admin/shops/fund-summary`。<br>**入参:**开关、额度、钱包版本。<br>**返回:**额度、可用金额、欠款和版本。 |
| #37 企微审核流转 | **标题:**企微配置、账号绑定和审批详情。<br>**页面:**企微配置页、个人扫码绑定、审批运行列表;业务详情只读展示意见和附件。 | **标题:**企业微信审批接入。<br>**接口:**`GET /api/admin/wecom/status``POST /api/admin/wecom/account-binding/sessions``GET /api/admin/wecom/approvals``POST /api/admin/wecom/approvals/{id}/sync`。<br>**业务详情:**统一返回 `approval` 对象。 | | #37 企微审核流转 | **标题:**企微配置、账号绑定和审批详情。<br>**页面:**企微配置页、个人扫码绑定、审批运行列表;业务详情只读展示意见和附件。 | **标题:**企业微信审批接入。<br>**接口:**`GET /api/admin/wecom/status``POST /api/admin/wecom/account-binding/sessions``GET /api/admin/wecom/approvals``POST /api/admin/wecom/approvals/{id}/sync`。<br>**业务详情:**统一返回 `approval` 对象。 |
| #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。<br>**页面:**仅超管/具备独立权限的平台账号可用;选择整批支付方式CSV及线下凭证先直传对象存储不选择代理同一CSV可含不同代理资产不接受Excel。 | **标题:**批量订购任务。<br>**接口:**`POST /api/admin/storage/upload-url``POST /api/admin/bulk-purchases``GET /api/admin/bulk-purchases/{task_id}``GET /api/admin/bulk-purchases/{task_id}/items`。<br>**权限:**超管或独立“批量订购套餐”权限;代理/企业拒绝。<br>**入参:**支付方式`file_key``voucher_keys`,无`shop_id`。<br>**判重**同文件相同资产类型、标准化标识和套餐编码仅首行处理,后续行失败。<br>**返回:**任务和逐行结算代理结果。 | | #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。<br>**页面:**沿用现有前端入口可见性;选择单个套餐和整批支付方式CSV及线下凭证先直传对象存储不选择代理同一CSV可含不同代理资产不接受Excel。 | **标题:**批量订购任务。<br>**接口:**`POST /api/admin/storage/upload-url``POST /api/admin/bulk-purchases``GET /api/admin/bulk-purchases/{task_id}``GET /api/admin/bulk-purchases/{task_id}/items`。<br>**权限:**后端仅复用现有后台认证,不新增权限码、账号类型拦截或任务创建人隔离。<br>**入参:**单个`package_id``payment_method=wallet|offline``file_key``voucher_keys`,无`shop_id``wallet`按CSV行号逐行扣结算代理主钱包余额不足只失败当前行并继续。<br>**文件**CSV仅一列“资产标识”创建接口校验套餐和对象/类型/10MBWorker校验编码、表头、语法、空文件及1000行文件级错误整批失败且不下单。<br>**资产:**复用统一资产解析能力识别类型和ID当前支持ICCID、卡`virtual_no`、MSISDN、设备`virtual_no`、IMEI和SN。<br>**判重:**按解析后的资产类型和资产ID判重同一资产不同标识也仅首行处理。<br>**状态:**统一五态,部分成功由计数表达。<br>**返回:**任务和逐行结算代理结果。 |
| #35 退款审核 | **标题:**退款企微审批和退款处理状态。<br>**页面:**创建时上传备注附件;详情展示审批、人工退款说明和业务处理结果;拒绝后重新退款必须新建退款单。 | **标题:**退款企微终态处理。<br>**接口:**`POST /api/admin/refunds``GET /api/admin/refunds/{id}`;下线原 `approve/reject/return/resubmit`。<br>**返回**退款数据、`approval``processing_status`。一单一审批,拒绝终结原单;代理钱包通过后幂等回溯。 | | #35 退款审核 | **标题:**退款企微审批和整单处理状态。<br>**页面:**创建只提交订单、固定申请金额、必填原因、可选备注及15个结构化附件不提交实收金额或套餐使用记录详情分区展示退款、审批和处理结果;拒绝后重新退款必须新建退款单;代理隐藏审批人、内部意见和审批附件。 | **标题:**退款企微终态与整单终结。<br>**接口:**`POST /api/admin/refunds``GET /api/admin/refunds``GET /api/admin/refunds/{id}`;下线原 `approve/reject/return/resubmit` 及任何 `manual-complete`。<br>**规则**后端读取实收并校验固定申请金额;无论是否全额,通过后订单、该订单套餐和佣金均整单终结且不可再退差额。只有代理钱包按原扣款流水自动回溯;其他方式系统外退款,资产钱包不回充。佣金结构化失效并可靠回扣,处理状态独立且可重试。代理按店铺层级权限查看。<br>**门禁:**可编程Adapter自动化和真实企微代理同意/平台拒绝两条链路均必过。 |
| #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。<br>**页面:**在线充值展示支付方式、二维码和支付状态;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。<br>**接口:**`GET /api/admin/agent-recharges/payment-methods``POST /api/admin/agent-recharges``GET /api/admin/agent-recharges/{id}/payment-status``GET /api/admin/agent-recharges/{id}`。<br>**返回:**二维码、过期时间、支付/审批/入账状态。 | | #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。<br>**页面:**在线充值展示支付方式、二维码和支付状态;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。<br>**接口:**`GET /api/admin/agent-recharges/payment-methods``POST /api/admin/agent-recharges``GET /api/admin/agent-recharges/{id}/payment-status``GET /api/admin/agent-recharges/{id}`。<br>**返回:**二维码、过期时间、支付/审批/入账状态。 |
| #33 套餐临期提醒 | **标题:**临期列表、各端高亮和续费入口。<br>**页面:**临期页3天内置顶普通资产列表只高亮代理首页显示数量C端显示续费按钮。 | **标题:**预计最终到期临期Query和站内通知。<br>**接口:**`GET /api/admin/expiring-assets`、资产列表/详情增加临期字段、`GET /api/c/v1/asset/info`、通知接口。<br>**返回:**最终到期、剩余天数、颜色节点15/7/3天通知防重。 | | #33 套餐临期提醒 | **标题:**临期列表、各端高亮和续费入口。<br>**页面:**临期页3天内置顶普通资产列表只高亮代理首页显示数量C端显示续费按钮。 | **标题:**预计最终到期临期Query和站内通知。<br>**接口:**`GET /api/admin/expiring-assets`、资产列表/详情增加临期字段、`GET /api/c/v1/asset/info`、通知接口。<br>**返回:**最终到期、剩余天数、颜色节点15/7/3天通知防重。 |

View File

@@ -1345,21 +1345,23 @@
页面入口:批量订购套餐页或现有订单页批量入口。 页面入口:批量订购套餐页或现有订单页批量入口。
权限:仅超级管理员或具备独立“批量订购套餐”权限的平台账号展示入口并调用创建、任务详情和明细接口;代理和企业账号不展示入口,后端仍必须独立拒绝 入口:页面是否展示沿用前端现有可见性规则;后端不新增批量订购权限码或账号类型拦截,调用复用现有后台认证
页面结构: 页面结构:
1. 选择整批支付方式线下或代理钱包并上传CSV不选择代理线下支付时上传整批凭证不接受Excel。 1. 选择一个套餐和整批支付方式线下或代理钱包并上传CSV不选择代理线下支付时上传整批凭证不接受Excel。
2. CSV模板下载使用前端静态文件编码为UTF-8并允许BOM。 2. CSV模板下载使用前端静态文件编码为UTF-8并允许BOM,唯一表头为“资产标识”
3. 创建后展示任务号、状态、总数、成功数、失败数、金额汇总和失败明细表。 3. 创建后展示任务号、状态、总数、成功数、失败数、金额汇总和失败明细表。
4. 失败明细包含行号、资产、套餐编码、错误原因支持按任务ID恢复页面。 4. 失败明细包含行号、原始/规范资产标识和错误原因支持按任务ID恢复页面。资产类型由统一资产解析能力识别当前支持ICCID、卡virtual_no、MSISDN、设备virtual_no、IMEI和SN前端不要求用户填写资产类型。
接口约定: 接口约定:
- CSV先调用POST /api/admin/storage/upload-urlpurpose=bulk_purchase使用返回的upload_url直传后取得file_key线下凭证按附件用途直传取得voucher_keys。 - CSV先调用POST /api/admin/storage/upload-urlpurpose=bulk_purchase使用返回的upload_url直传后取得file_key线下凭证按附件用途直传取得voucher_keys。
- POST /api/admin/bulk-purchases只接收JSONrequest_id、payment_method、file_key、voucher_keys不接收shop_id、multipart或文件字节。 - POST /api/admin/bulk-purchases只接收JSONrequest_id、单个package_id、payment_method、file_key、voucher_keys不接收shop_id、multipart或文件字节。
- GET /api/admin/bulk-purchases/{task_id} 返回任务汇总。 - GET /api/admin/bulk-purchases/{task_id} 返回任务汇总。
- GET /api/admin/bulk-purchases/{task_id}/items?page=&size=&status= 返回逐行结果。 - GET /api/admin/bulk-purchases/{task_id}/items?page=&page_size=&status= 返回逐行结果。
交互规则一个批次不能混合支付方式同一CSV允许不同代理资产代理归属由后端逐行解析前端不提交或猜测部分成功视为任务终态;某代理钱包余额不足只影响使用该钱包的相应行,不回滚已成功行。 交互规则:一个批次固定一个套餐且不能混合支付方式同一CSV允许不同代理资产代理归属由后端逐行解析前端不提交或猜测任务状态统一为1待处理、2处理中、3已完成、4已失败、5已取消部分成功只由成功数/失败数表达wallet批次严格按CSV行号逐行结算行序就是同一代理余额不足时的订购优先级当前行余额不足只失败该行并继续尝试后续行不预占或回滚该代理全部行。
文件级错误异步展示创建接口通过后不代表CSV内容有效Worker发现编码、表头、未知列、CSV语法、空文件或超过1000行时任务整体失败且不会创建任何订单。资产、套餐、归属、钱包和重复行错误才展示为逐行失败。MSISDN未命中或命中多张卡时只失败该行前端展示后端原因。
完成标准:上传、进度恢复、部分成功、失败筛选和凭证展示完整。 完成标准:上传、进度恢复、部分成功、失败筛选和凭证展示完整。
``` ```
@@ -1381,11 +1383,13 @@
接口POST /api/admin/bulk-purchasesGET /api/admin/bulk-purchases/{task_id}GET /api/admin/bulk-purchases/{task_id}/items。 接口POST /api/admin/bulk-purchasesGET /api/admin/bulk-purchases/{task_id}GET /api/admin/bulk-purchases/{task_id}/items。
权限仅超级管理员或具备独立“批量订购套餐”权限的平台账号可创建和读取任务代理、企业账号一律拒绝。规则整批选择offline或agent_wallet不接收shop_id同一CSV可以包含不同代理资产逐行以资产当前归属解析结算代理CSV和凭证先直传私有对象存储业务接口只接收稳定file_key/voucher_keys仅接受UTF-8 CSV允许BOM不接受Excel模板按套餐编码匹配文件最大10MB、最多1000行;同一文件按“资产类型+标准化资产标识+套餐编码”判重,首行正常处理、后续重复行失败并指出首行号,同一资产的不同套餐不算重复request_id唯一返回原任务行幂等键为bulk_purchase:{task_id}:{row_no}。 入口与规则后端不新增批量订购权限码、账号类型拦截或任务创建人隔离复用现有后台认证创建任务选择单个package_id和整批offline|walletwallet表示逐行扣结算代理主钱包不新增agent_wallet不接收shop_id同一CSV可以包含不同代理资产逐行以资产当前归属解析结算代理CSV只有“资产标识”一列资产类型和ID由系统统一资产解析能力识别当前支持ICCID、卡virtual_no、MSISDN、设备virtual_no、IMEI和SN批量模块不另写识别规则CSV和凭证先直传私有对象存储业务接口只接收稳定file_key/voucher_keys创建接口校验套餐存在以及对象、上传归属、类型和10MB大小Worker校验UTF-8允许BOM、单列表头、未知列、CSV语法、空文件和1000行上限文件级错误使任务失败且不创建订单不接受Excel标识未命中或无法唯一解析只失败该行;同一文件按解析后的“资产类型+资产ID”判重同一资产使用不同标识仍只处理首行后续失败并指出首行号request_id唯一返回原任务行幂等键为bulk_purchase:{task_id}:{row_no}。任务统一状态为1待处理、2处理中、3已完成、4已失败、5已取消部分成功只由success_count/fail_count表达逐行明细状态为1待处理、2处理中、3成功、4失败。
钱包行事务:锁该行资产所属代理的主钱包,按信用不变量校验,订单、扣款、流水、结算代理快照和明细成功状态同事务;无代理归属、无有效主钱包或余额不足只失败对应行,单行失败不回滚其他行;任务统计从明细重新聚合。线下凭证只做本批资料,不校验跨批唯一。 钱包行事务:严格按CSV行号逐行处理并锁该行资产所属代理的主钱包,按信用不变量校验,订单、扣款、流水、结算代理快照和明细成功状态同事务;不预占整批或某一代理全部行金额,无代理归属、无有效主钱包或余额不足只失败当前行并继续后续行,后续较小金额若余额足够仍可成功;任务统计从明细重新聚合。线下凭证只做本批资料,不校验跨批唯一。
完成标准Worker处理租约、重复消费、进程中断恢复和部分成功均不产生重复订单或重复扣款。 完成标准Worker处理租约、重复消费、进程中断恢复和部分成功均不产生重复订单或重复扣款。
测试环境约定已部署测试环境使用Redis DB 6本地开发和Agent自动化测试强制使用DB 7测试启动时必须校验实际生效DB并在不是7时失败禁止向DB 6投递任务也禁止对DB 7执行FLUSHDB。自动化测试直连当前真实S3并按唯一Key精确清理不做内存替身PostgreSQL沿用现有测试库夹具按唯一运行标识和实际记录ID精确清理禁止TRUNCATE、清表、模糊删除或修改既有业务数据。Go HTTP集成测试使用Fiber app.Test穿过真实认证和业务链路捕获任务后直接调用公开Worker Handler不通过sleep等待后台Worker实现时沉淀可复用的测试规范、环境守卫/清理Harness、CSV testdata和完整示例并提供只用于部署冒烟的curl模板。
``` ```
## UR#35 退款审核 ## UR#35 退款审核
@@ -1408,21 +1412,21 @@
页面入口:退款创建、退款列表、退款详情。 页面入口:退款创建、退款列表、退款详情。
页面结构: 页面结构:
1. 创建表单包含退款金额、原因、备注和附件;金额提交后企微审批不可修改 1. 创建表单包含订单、退款金额、必填原因、可选备注和15个附件附件提交file_key、file_name、file_size金额提交后企微审批不可修改前端不提交实收金额或套餐使用记录
2. 详情分为退款业务信息、企微审批信息、业务处理结果三个区域。 2. 详情分为退款业务信息、企微审批信息、业务处理结果三个区域。
3. 审批区展示sp_no、状态、申请人、审批人、意见、附件和时间线。 3. 审批区按主体权限展示:代理只见真实申请人、审批状态和业务处理结果;平台/超级管理员具备退款查看权限时才展示sp_no、审批人、意见、审批附件和时间线。
4. 处理区展示processing_status、失败摘要和“系统重试中/联系管理员”。 4. 处理区展示processing_status、失败摘要和“系统重试中/联系管理员”。
5. 不显示本地通过、驳回、退回或人工退款确认按钮。 5. 不显示本地通过、驳回、退回或人工退款确认按钮。
接口约定: 接口约定:
- POST /api/admin/refunds 创建退款。 - POST /api/admin/refunds 创建退款请求只包含order_id、requested_refund_amount、refund_reason、remark和attachments
- GET /api/admin/refunds/{id} 返回退款数据、approval对象和processing_status。 - GET /api/admin/refunds/{id} 返回退款数据、approval对象和processing_status。
- attachments使用现有对象存储上传结果提交结构为 {file_key,file_name,file_size}[]。 - attachments使用现有对象存储上传结果提交结构为 {file_key,file_name,file_size}[]。
- approval结构至少包含 source、sp_no、status、status_name、template_version、applicant、approvers、comments、attachments、timeline、business_process_result。 - approval结构至少包含 source、sp_no、status、status_name、template_version、applicant、approvers、comments、attachments、timeline、business_process_result。
交互规则非代理钱包由财务在系统外人工退款后再在企微通过。企微拒绝后当前退款单终结详情只读且不提供编辑或重提业务人员处理拒绝原因后仍需退款时重新进入创建退款流程并填写金额、凭证和原因成功后展示新的退款ID、退款单号和审批信息。 交互规则非代理钱包由财务在系统外人工退款后再在企微通过。企微拒绝后当前退款单终结详情只读且不提供编辑或重提业务人员处理拒绝原因后仍需退款时重新进入创建退款流程并填写金额、凭证和原因成功后展示新的退款ID、退款单号和审批信息。
完成标准:审批中、通过处理中、处理成功、驳回、撤销和通过后撤销异常状态均展示明确。 完成标准:审批中、通过处理中、处理成功、处理失败、驳回、撤销和通过后撤销异常状态均展示明确;申请金额小于实收金额时明确提示通过后仍按整单终结
``` ```
### 后端研发需求 ### 后端研发需求
@@ -1430,26 +1434,30 @@
**标题** **标题**
```text ```text
[BE][UR#35] 退款企微终态与人工退款处理 [BE][UR#35] 退款企微终态与整单退款终结
``` ```
**描述** **描述**
```markdown ```markdown
目标:退款通过企微审批驱动业务终态,系统只自动回溯代理主钱包支付。 目标:退款通过企微审批驱动整单业务终态,系统只自动回溯代理主钱包支付。
预计工时后端45小时。 预计工时后端45小时。
接口POST /api/admin/refundsGET /api/admin/refunds/{id}下线原approve/reject/return/resubmit路由。 接口POST /api/admin/refundsGET /api/admin/refundsGET /api/admin/refunds/{id}下线原approve/reject/return/resubmit及任何manual-complete路由。
规则: 规则:
1. 非代理钱包支付由财务人工退款企微通过代表人工退款已确认不调用渠道退款API 1. 创建请求只接受order_id、requested_refund_amount、必填refund_reason、可选remark和15个结构化attachments后端读取订单实收金额并校验0<申请金额<=实收金额不接受actual_received_amount或package_usage_id
2. 代理钱包订单通过后按原扣款流水幂等回溯原代理主钱包并写退款流水 2. 申请金额在企微只读,审批人只能同意或拒绝。无论申请金额是否等于实收金额,通过后都把订单标记已退款、该订单全部有效套餐失效、全部佣金失效,并禁止再退剩余差额
3. 个人客户或资产钱包不自动回款 3. 非代理主钱包支付由财务系统外退款企微通过代表人工退款已确认不调用渠道退款API、不回充个人资产钱包也不再二次人工确认actual_refund_amount在资金已完成时写申请金额
4. 驳回更新为已拒绝;撤销/删除更新为已撤销通过后撤销且资金已执行不自动冲正记录critical审计 4. 代理钱包订单通过后只按原扣款流水幂等回溯原代理主钱包并写唯一退款流水;缺少原流水则失败,不按当前关系猜测钱包
5. 一张退款单只创建一条企微审批申请业务表保存唯一approval_instance_id并由(biz_type,biz_id)唯一约束防重。正常结果只有同意或拒绝任一结果产生后审批与退款单同时完结拒绝后原退款单不可修改、不可重提。若仍需退款必须重新调用创建接口生成新的退款ID、退款单号、业务快照、提交人快照和企微审批旧单只保留为历史事实已拒绝退款不阻止新建但仍需阻止存在其他活跃退款时重复创建。撤销、删除或通过后撤销只作外部异常处置不作为自动放行新退款的依据 5. 已发放佣金全额从佣金钱包扣回并允许负余额,其他未发放佣金直接失效;佣金记录保存结构化失效原因、退款引用和失效时间。订单套餐按整单失效,主套餐级联加油包并尝试下一排队主套餐
6. 驳回更新为已拒绝;撤销/删除更新为已撤销通过后撤销且资金已执行不自动冲正记录critical审计和站内告警。
7. 一张退款单只创建一条企微审批申请业务表保存唯一approval_instance_id并由(biz_type,biz_id)唯一约束防重。正常结果只有同意或拒绝任一结果产生后审批与退款单同时完结拒绝后原退款单不可修改、不可重提。若仍需退款必须重新调用创建接口生成新的退款ID、退款单号、业务快照、提交人快照和企微审批旧单只保留为历史事实已拒绝退款不阻止新建但仍需阻止存在其他活跃退款时重复创建。撤销、删除或通过后撤销只作外部异常处置不作为自动放行新退款的依据。
8. 代理按既有店铺层级和退款业务权限查看本店及可管理下级退款不再按creator隔离代理不见审批人、内部意见或审批人附件平台/超级管理员也须有退款业务查看权限。
9. 企微终态通过Outbox和可靠Worker处理processing_status固定0未触发、1处理中、2处理成功、3处理失败局部失败可安全重试不使用进程内Goroutine。
完成标准:审批状态与业务处理状态分离,重复终态不重复回款,失败任务可可靠重试。 完成标准:审批状态与业务处理状态分离,重复终态不重复回款/扣佣/失效套餐,失败任务可可靠重试实现期必须通过可编程Adapter自动化和真实企微两张独立退款验收代理代提交同意、平台本人提交拒绝INT-06不能替代
``` ```
## UR#34 充值审核流程 ## UR#34 充值审核流程
@@ -1596,7 +1604,7 @@
交付内容: 交付内容:
1. 统一加载、空数据、权限不足、接口失败和重试状态。 1. 统一加载、空数据、权限不足、接口失败和重试状态。
2. 统一异步任务进度结构:任务状态、总数、成功数、失败数、部分成功和失败明细 2. 统一异步任务进度结构:状态固定为1待处理、2处理中、3已完成、4已失败、5已取消总数、成功数、失败数和失败明细独立返回部分成功只由计数表达不占状态码
3. 创建任务后按2秒、3秒、5秒递增轮询最大间隔10秒页面不可见暂停恢复后立即刷新。 3. 创建任务后按2秒、3秒、5秒递增轮询最大间隔10秒页面不可见暂停恢复后立即刷新。
4. 页面刷新后通过task_id恢复任务详情。 4. 页面刷新后通过task_id恢复任务详情。

View File

@@ -166,7 +166,7 @@ sequenceDiagram
评审结论:支付方式按**整批统一**设计: 评审结论:支付方式按**整批统一**设计:
- 页面选择 `offline``agent_wallet`CSV 不再重复填写支付方式。 - 页面选择 `offline``wallet`CSV 不再重复填写支付方式。`wallet` 复用现有后台订单支付枚举,在批量任务中表示逐行扣结算代理主钱包,不新增同义枚举 `agent_wallet`
- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。 - 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
- CSV 不包含支付方式列,后端拒绝同一批次混合支付。 - CSV 不包含支付方式列,后端拒绝同一批次混合支付。
@@ -193,9 +193,11 @@ flowchart TD
任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。 任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。
同一 CSV 内按“资产类型 + 标准化资产标识 + 套餐编码”判断重复:首次出现的行正常处理,后续重复行失败并记录首次出现的行号;同一资产订购不同套餐不算重复。本期不把重复行解释为购买多份,未来如有多份订购需求,应增加明确数量字段。 Worker 复用系统统一资产解析能力,把资产标识解析为唯一资产类型和资产 ID再按“资产类型 + 资产 ID”判断重复。一个批次在创建时只选择一个套餐因此重复键不包含套餐同一资产使用不同受支持标识仍是重复首次出现的行正常处理,后续重复行失败并记录首次出现的行号。本期不把重复行解释为购买多份,未来如有多份订购需求,应增加明确数量字段。
本页面和全部批量订购 API 只允许超级管理员,或具备独立“批量订购套餐”权限的平台账号访问;代理和企业账号一律禁止。平台权限不能替代逐行的资产归属、套餐授权、成本价和钱包业务校验 统一资产解析当前支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN未命中或无法唯一解析时只失败该行禁止任意选择资产。明细保存用户原始资产标识、解析后的资产类型/ID和规范标识快照便于审计和跨标识判重
本页面入口沿用前端现有可见性规则;后端不新增批量订购权限码、账号类型拦截或任务创建人隔离,能够通过现有后台认证调用接口的主体视为可以使用。该入口决定不能替代逐行的资产归属、套餐授权、成本价和钱包业务校验。
### 数据库变更 ### 数据库变更
@@ -208,10 +210,15 @@ CREATE TABLE tb_bulk_purchase_task (
creator BIGINT NOT NULL DEFAULT 0, creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0, updater BIGINT NOT NULL DEFAULT 0,
task_no VARCHAR(30) NOT NULL, task_no VARCHAR(30) NOT NULL,
request_id VARCHAR(64) NOT NULL,
source_file_key VARCHAR(500) NOT NULL, source_file_key VARCHAR(500) NOT NULL,
operator_id BIGINT NOT NULL, operator_id BIGINT NOT NULL,
package_id BIGINT NOT NULL,
package_code_snapshot VARCHAR(50) NOT NULL DEFAULT '',
package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '',
payment_method VARCHAR(20) NOT NULL, payment_method VARCHAR(20) NOT NULL,
voucher_keys JSONB NOT NULL DEFAULT '[]', voucher_keys JSONB NOT NULL DEFAULT '[]',
involved_shop_count INT NOT NULL DEFAULT 0,
total_amount BIGINT NOT NULL DEFAULT 0, total_amount BIGINT NOT NULL DEFAULT 0,
total_count INT NOT NULL DEFAULT 0, total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0, success_count INT NOT NULL DEFAULT 0,
@@ -226,19 +233,22 @@ CREATE UNIQUE INDEX idx_bulk_purchase_task_no
ON tb_bulk_purchase_task(task_no) ON tb_bulk_purchase_task(task_no)
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_bulk_purchase_task_request_id
ON tb_bulk_purchase_task(request_id)
WHERE deleted_at IS NULL;
CREATE TABLE tb_bulk_purchase_item ( CREATE TABLE tb_bulk_purchase_item (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
task_id BIGINT NOT NULL, task_id BIGINT NOT NULL,
row_no INT NOT NULL, row_no INT NOT NULL,
asset_type VARCHAR(20) NOT NULL, asset_type VARCHAR(20),
asset_identifier VARCHAR(100) NOT NULL, input_asset_identifier VARCHAR(100) NOT NULL,
package_code VARCHAR(50) NOT NULL, resolved_asset_id BIGINT,
canonical_identifier VARCHAR(100) NOT NULL DEFAULT '',
resolved_shop_id BIGINT, resolved_shop_id BIGINT,
resolved_shop_name VARCHAR(200) NOT NULL DEFAULT '', resolved_shop_name VARCHAR(200) NOT NULL DEFAULT '',
package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '',
package_id BIGINT,
amount BIGINT NOT NULL DEFAULT 0, amount BIGINT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1, status INT NOT NULL DEFAULT 1,
order_id BIGINT, order_id BIGINT,
@@ -260,14 +270,14 @@ CREATE INDEX idx_bulk_purchase_item_task_status
状态建议: 状态建议:
- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败` - 任务统一复用全局异步任务状态`1=待处理, 2=处理中, 3=已完成, 4=已失败, 5=已取消`;逐行明细使用 `1=待处理, 2=处理中, 3=成功, 4=失败`
- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败` - 文件有效并完成全部行业务处理后任务为“已完成”;全部成功、部分成功和全部行业务失败由成功数/失败数表达,不建立“部分成功”状态。本期没有取消入口,但保留全局取消状态码
### 处理与幂等 ### 处理与幂等
1. 前端通过对象存储预签名地址直传 CSV 和线下凭证,再由 API 校验 `file_key`、支付方式和 `voucher_keys` 并创建任务,返回 `task_id`;业务接口不接收 `shop_id` 或文件字节。 1. 前端选择单个套餐和整批支付方式,通过对象存储预签名地址直传 CSV 和线下凭证,再由 API 校验 `package_id``file_key`、支付方式和 `voucher_keys` 并创建任务,返回 `task_id`;业务接口不接收 `shop_id` 或文件字节。创建接口校验套餐存在以及对象归属、扩展名/Content-Type 和 10MB 大小,不同步解析 CSV逐行处理时再校验结算代理当前套餐授权、价格和可售规则。
2. Worker 根据 `source_file_key` 下载并解析 UTF-8 CSV允许 BOM将每一行先写入明细表再开始业务处理不接受 Excel。 2. Worker 根据 `source_file_key` 下载并解析 UTF-8 CSV允许 BOM校验固定表头、未知列、CSV 语法、空文件和 1000 行上限;任一文件级校验失败时任务整体失败且不创建明细订单。文件有效后将每一行先写入明细表,再开始业务处理;不接受 Excel。
3. 同一任务按行顺序处理;每行按资产当前归属解析结算代理,同一任务可以依次处理不同代理的钱包。 3. 同一任务严格按 CSV 行号顺序处理;每行按资产当前归属解析结算代理,同一任务可以依次处理不同代理的钱包。`wallet` 不预占整批或某一代理全部行的金额,当前行余额不足只失败该行并继续后续行;后续金额较小且当时余额足够的行仍可成功,因此行序就是同一代理资金不足时的订购优先级。
4. 每行使用独立事务。代理钱包支付时锁定该行结算代理的主钱包,按有效信用额度校验总可用金额后,在同一事务扣款、创建订单、资金流水并更新明细;无归属、归属异常、无主钱包或余额不足只失败该行。 4. 每行使用独立事务。代理钱包支付时锁定该行结算代理的主钱包,按有效信用额度校验总可用金额后,在同一事务扣款、创建订单、资金流水并更新明细;无归属、归属异常、无主钱包或余额不足只失败该行。
5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。 5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。
6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。 6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。
@@ -275,19 +285,19 @@ CREATE INDEX idx_bulk_purchase_item_task_status
Asynq 载荷只传任务 ID不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理确保 Worker 重试期间仍可读取。 Asynq 载荷只传任务 ID不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理确保 Worker 重试期间仍可读取。
建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间 单文件上限 1000 行;超过上限由 Worker 将任务标记为整体失败,不创建任何订单
### API 设计 ### API 设计
**前端静态 CSV 模板** **前端静态 CSV 模板**
模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为 模板由前端项目随版本发布,后端不提供下载接口。模板只有一列
```text ```text
资产类型 | 资产标识 | 套餐编码 | 套餐名称 资产标识
``` ```
`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。 套餐由创建任务请求中的单个 `package_id` 决定。资产标识统一交给系统资产解析能力识别资产类型和 ID批量模块不维护自己的标识白名单。后端必须校验单列表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
**上传并提交** **上传并提交**
@@ -297,13 +307,14 @@ Content-Type: application/json
{ {
"request_id": "01J...", "request_id": "01J...",
"package_id": 1001,
"payment_method": "offline", "payment_method": "offline",
"file_key": "bulk-purchase/2026/07/xxx.csv", "file_key": "bulk-purchase/2026/07/xxx.csv",
"voucher_keys": ["attachment/2026/07/key1"] "voucher_keys": ["attachment/2026/07/key1"]
} }
``` ```
CSV 先调用 `POST /api/admin/storage/upload-url` 并使用独立 `purpose=bulk_purchase` 获取预签名地址后直传;线下凭证使用附件用途直传。`offline``voucher_keys` 必填,`agent_wallet` 时必须为空。创建任务前校验对象存在、归属当前上传主体、CSV 类型和 10MB 大小限制。 CSV 先调用 `POST /api/admin/storage/upload-url` 并使用独立 `purpose=bulk_purchase` 获取预签名地址后直传;线下凭证使用附件用途直传。`package_id` 为大于 0 的单个套餐 ID`offline``voucher_keys` 必填,`wallet` 时必须为空。创建任务前校验套餐存在、对象归属、CSV 类型和 10MB 大小限制;编码、表头、语法、空文件和行数由 Worker 校验
**查询任务状态** **查询任务状态**
@@ -320,158 +331,84 @@ GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
### 前端技术方案 ### 前端技术方案
- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。 - 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。
- 提交前展示支付方式、凭证数量和文件名的二次确认;不展示整批目标代理或单一钱包余额。任务结果按行展示后端解析的结算代理及金额。 - 提交前展示所选单个套餐、支付方式、凭证数量和文件名;不展示整批目标代理或单一钱包余额。任务结果按行展示后端解析的结算代理及金额。
- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。 - 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。
- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。 - 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。
- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。 - 任务状态为已完成后,根据成功数和失败数显示全部成功、部分成功或全部业务行失败摘要;再次上传失败行会创建新任务,不修改旧任务历史。
- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。 - 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。
### 自动化测试环境隔离
- 已部署测试环境继续使用 Redis DB 6本地开发和 Agent 自动化测试统一使用 Redis DB 7禁止自动化测试向 DB 6 投递 Asynq 任务。
- 测试入口必须读取并校验实际 Redis Client 配置DB 不是 7 时立即失败。普通 Redis Client、Asynq Client 和 Worker Handler 使用同一 DB 配置,不能只隔离普通 Key 而把任务送到 DB 6。
- 不允许对 DB 7 执行 `FLUSHDB`;每次运行使用唯一测试标识,并只清理本次运行实际创建的 Key 和数据。
- 自动化测试直接连接当前真实 S3使用唯一对象 Key 上传 CSV 和凭证,结束时只删除本次创建的对象;不提供内存对象存储替身。
- PostgreSQL 继续使用现有测试库,不新增 Agent 专用库。测试只创建带唯一运行标识的夹具,并按实际记录 ID 精确清理;禁止清表、`TRUNCATE`、模糊删除和修改既有业务数据,并发钱包场景也遵守相同隔离规则。
- Go HTTP 集成测试是主要自动化入口:使用 Fiber `app.Test` 穿过真实认证和业务链路,捕获待投递任务后直接驱动公开 Worker Handler不通过 `sleep` 等待测试环境 Worker 抢取任务。
- 实现时同步保留可复用的 Agent 集成测试规范、环境守卫与精确清理 Harness、典型 CSV `testdata` 和完整示例;真实部署环境另提供 `curl` 冒烟模板,但不以 `curl` 代替自动化断言。
--- ---
## 需求20退款审批 ## 需求20退款审批
### 依赖 ### 依赖
段历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md) 需求依赖七月统一企业微信审批公共能力,以稳定场景码 `refund_approval` 复用真实提交人、平台本人绑定、代理固定成员代提交、模板版本、附件上传、加密回调、轮询和终态 Outbox。退款模块不再依赖已废弃的本地审批流也不自行实现审批节点、候选审批人或审批按钮
### 退款单现有状态(不变) ### 创建契约与固定金额
``` - 保留 `POST /api/admin/refunds`,允许超级管理员、平台和代理发起;企业账号不允许发起。
1=待审批 2=已通过 3=已拒绝 4=已退回 - 请求只接受 `order_id``requested_refund_amount`、必填且最多 1000 字的 `refund_reason`、可选且最多 500 字的 `remark`,以及 15 个 `{file_key,file_name,file_size}` 附件。
``` - 不再接受前端提交的 `actual_received_amount``package_usage_id`。后端读取订单实收金额、支付方式、买卖方、资产和店铺事实并固化快照。
- 申请金额校验 `0 < requested_refund_amount <= 订单实收金额`,提交后固定。企微表单只读展示订单实收金额、可退款区间和本次申请金额,审批人只能同意或拒绝,不能修改金额。
- 退款单、唯一企微审批实例和提交 Outbox 同一 PostgreSQL 事务创建;远程企微异步提交,业务接口返回本地退款和“企微提交中”。场景、模板、平台本人绑定或代理固定成员不可用时,在任何业务事实落库前失败。
- 同一订单存在提交中、审批中、已通过但处理未成功或异常人工处置未完成的退款时禁止新建。已拒绝退款不占活跃名额;撤销、删除和通过后撤销不自动放行。
退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态: ### 独立状态与数据事实
```text ```text
processing_status0=待处理 1=处理中 2=已完成 3=处理失败 退款状态1=待审批 2=已通过 3=已拒绝 4=已退回(仅历史) 5=已撤销/审批已删除
处理状态0=未触发 1=处理中 2=处理成功 3=处理失败
``` ```
`status=2` 表示审批结论已通过,`processing_status` 表示实际退款动作是否完成。接口和前端必须同时展示两者 - 企微审批状态独立复用公共枚举:`0=提交中, 1=审批中, 2=已通过, 3=已驳回, 4=已撤销, 5=通过后撤销, 6=已删除, 7=提交失败, 8=提交结果未知`。退款、审批和业务处理状态不得互相覆盖
- 退款记录保存唯一 `approval_instance_id`、真实提交人显示快照、结构化申请附件、申请备注、处理状态/错误/开始与完成时间,以及可靠 Worker 所需的租约或版本事实。
- `actual_received_amount` 继续作为后端生成的订单实收快照;`actual_refund_amount` 只表示资金已经实际完成的金额。旧 `approved_refund_amount``package_usage_id` 和字符串 Key 列表只作历史兼容,新退款不再依赖。
- 非代理钱包在财务已系统外退款并同意企微时写 `actual_refund_amount=申请金额`;代理主钱包只在原钱包回溯与唯一退款流水事务成功后写。驳回、撤销、删除及尚未完成代理回溯时为空;资金完成后即使佣金或套餐失败也保留。
- 佣金记录增加结构化 `invalid_reason``invalid_refund_id``invalidated_at` 和必要的人工操作者事实;已发放佣金回扣以“退款单 + 佣金记录”唯一防重。
### 数据库变更 ### 企微终态和资金处理
```sql - 企微拒绝把退款状态置为已拒绝,处理状态保持未触发;该审批和退款单均终结。若修正问题后仍需退款,重新调用创建接口生成新退款 ID、退款单号、快照、附件和企微实例原单不可编辑或重提。
ALTER TABLE tb_refund_request - 微信、支付宝、线下及其他非代理主钱包支付由财务先在系统外退款,再同意企微;企微同意即代表退款完成。本系统不调用渠道退款 API也不提供 `manual-complete` 二次确认。
ADD COLUMN approval_instance_id BIGINT, - 个人客户资产钱包支付同样走系统外人工退款。本系统不自动回充资产钱包、不创建资产钱包退款流水;现有个人钱包自动回充分支必须从新终态用例移除或隔离。
ADD COLUMN processing_status INT NOT NULL DEFAULT 0, - 代理主钱包支付在企微同意后只按原订单扣款流水定位原主钱包,增加本次申请金额并写唯一退款流水。余额允许为负,退款自然冲减欠款;缺少可核验原扣款流水时处理失败,禁止根据当前代理关系、买卖方或当前钱包猜测。
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '', - 企微撤销或删除进入异常人工处置,不执行资金动作也不自动放行新退款。通过后撤销且资金尚未执行时阻止后续资金步骤;资金已执行时不自动冲正,保留实际退款金额,记录 `critical` Audit Event 和站内告警。
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_operator_id BIGINT,
ADD COLUMN manual_refund_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_remark TEXT NOT NULL DEFAULT '',
ADD COLUMN manual_refund_voucher_key JSONB NOT NULL DEFAULT '[]';
CREATE INDEX idx_refund_request_approval_instance ### 整单终结、可靠性和审计
ON tb_refund_request(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
```
`processing_error` 只保存可运维排查的摘要。`manual_refund_*` 只在人工退款确认时写入,退款申请时提交的 `refund_voucher_key` 仍是申请业务资料,不能混用为财务完成凭证 - 无论申请金额是否等于订单实收金额,企微同意后都以整张订单为边界:订单标记已退款,该订单全部有效套餐和佣金资格终结,该订单不允许再申请未退差额。向客户实际退款的金额仍是本次申请金额
- 已冻结、解冻中、尚未发放或待人工修正的佣金不移动钱包,直接改为已失效;已发放佣金先从对应佣金钱包全额扣回,再改为已失效,钱包允许为负。佣金状态、钱包余额/版本、回扣流水和统一 Audit Event 同事务。
- 该订单生成的全部有效套餐失效,不按单个套餐使用记录缩小范围。主套餐失效级联其加油包,然后尝试激活下一条待生效主套餐;没有下一条时通过公共卡/设备状态能力停止资产。
- 企微首次终态通过公共业务 Outbox 触发可靠 Asynq Worker不使用进程内 Goroutine。Worker 通过状态条件和租约领取任务;资金、订单、每条佣金和套餐分别保存持久化幂等事实,局部失败只重试未完成步骤,全部完成后才置处理成功。
- 代理主钱包余额/版本/退款流水与实际退款金额、已发放佣金回扣、订单状态和关键套餐状态分别按最小一致性边界写统一 Audit Event。重复回调、轮询和 Worker 命中已完成事实时不产生第二笔资金、第二次失效或第二条等价审计。
现有退款审批允许确认 `approved_refund_amount`,切换到通用审批后必须保留:配置为最终决策的节点在完成时返回金额动作字段,审批人可确认实际退款金额;省略时使用申请金额。退款动作适配器在审批事务内校验金额大于 0且不超过申请退款金额和订单实收金额并写入退款单和审批操作日志。会签需要指定金额决策人时流程定义增加其专属最终节点禁止第一位会签人预先锁定金额。 ### API、权限与前端
### 流程 - 保留 `POST /api/admin/refunds``GET /api/admin/refunds``GET /api/admin/refunds/{id}`。下线并不再注册原 `approve/reject/return/resubmit` 及任何 `manual-complete` 路由。
- 列表默认每页 20、最大 100按创建时间倒序并以 ID 作并列排序键;筛选参数按 AND 组合。详情和列表同时返回退款状态、`approval` 对象和独立处理状态,历史本地审批使用 `approval.source=legacy`,新企微退款使用 `wecom`
- 代理退款查询不再按创建账号隔离,改为既有店铺层级数据范围和退款业务权限。代理可见申请资料、业务凭证、真实提交人、审批状态和处理结果,但看不到审批人、内部意见和审批人附件。平台/超级管理员也必须有退款业务查看权限,才可读取完整企微详情。
- 列表、详情、附件下载和导出复用同一权限投影;企微运营权限不能绕过退款业务权限。
- 创建页不提交实收金额或套餐使用记录,明确提示“申请金额可以小于实收金额,但通过后仍按整单终结”。详情固定分为退款业务信息、企微审批信息和业务处理结果,不显示本地审批、金额修改、重提或人工确认按钮。
- 非代理钱包提示财务先系统外退款再同意企微;代理钱包提示同意后系统自动回溯。处理失败只展示脱敏摘要和系统重试状态;通过后撤销使用高风险告警并说明不会自动冲正。
```mermaid ### 测试与发布门禁
sequenceDiagram
actor Applicant as 提交人
actor Approver as 审批人
participant Refund as Refund Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as AgentWalletRefundHandler
actor Finance as 财务人员
Applicant->>Refund: POST /api/admin/refunds - 自动化最高接缝为 Fiber HTTP 与真实认证,经退款 Application/Domain/Query、GORM、现有测试 PostgreSQL、Outbox 和公开 Worker Handler最终核对钱包、佣金、套餐和统一审计企微网络边界使用可编程 Adapter 覆盖超时、重复、乱序、撤销、删除、通过后撤销和并发重试。
Refund->>DB: 同事务创建退款单(status=1) - 本地/Agent 测试强制 Redis DB 7已部署测试环境继续使用 DB 6测试启动前校验普通 Redis、Asynq Client 和 Worker 的实际 DB禁止向 DB 6 投递、禁止 `FLUSHDB`。PostgreSQL 沿用现有测试库并按唯一运行标识精确清理;对象存储使用真实 S3 和唯一 Key不做内存替身。
Refund->>Approval: StartProcess(refund, refund_id) - 真实企微验收是实现完成门禁,不得推迟到 INT-06。至少执行两张独立退款代理使用固定成员代提交并同意验证真实附件、加密回调及代理钱包/佣金/套餐/审计;平台账号使用本人绑定发起并拒绝,验证轮询兜底且不触发资金处理。
Approval->>DB: 创建实例、首任务、审批人、Outbox - 回调按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发查询参数和请求体,后端仍完整验签、解密并核对 CorpID。真实参数只通过安全配置提供。撤销、删除和通过后撤销由可编程 Adapter 稳定覆盖,不强制每次真实企微人工制造。
Refund->>DB: 回写 approval_instance_id - 停机发布时先具备真实企微公共能力、模板映射、平台绑定和代理固定成员,再迁移待审批退款并启用终态 Worker历史终态保留 `legacy`,历史已退回只读。旧状态不一致记录进入对账/人工处置,迁移脚本不得猜测资金、佣金或套餐已完成。
Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额
Approval->>DB: 提交 ProcessApproved/Rejected/Returned
alt 代理钱包支付且审批通过
DB-->>Worker: Outbox + Asynq 至少一次投递
Worker->>DB: claim processing_status=1status=2
Worker->>DB: 幂等回退原扣款代理主钱包并写资金流水
Worker->>DB: processing_status=2
else 非代理钱包支付且审批通过
Refund->>DB: status=2, processing_status=0待人工退款
Finance->>Refund: POST manual-complete
Refund->>DB: 条件更新处理状态并记录确认信息
else 审批拒绝
Worker->>DB: status 从 1 更新为 3
else 退回修改
Worker->>DB: status 从 1 更新为 4
end
```
`AgentWalletRefundHandler` 仅处理代理钱包订单,使用 `refund:{refund_id}` 作为业务幂等键,并读取审批事务已经持久化的 `approved_refund_amount`。它必须按原扣款资金流水定位原代理主钱包,余额、版本、钱包退款流水和处理状态在同一事务更新。处理失败时单独更新 `processing_status=3` 和错误摘要后返回可重试错误;不得回滚已经完成的审批实例,也不得重复回退。
处理器通过条件更新领取任务:`processing_status IN (0,3)`,或状态为处理中但 `processing_started_at` 已超过约定租约。重复消费者看到未过期的处理中状态时不重复执行;进程在副作用完成后崩溃时,下一次重试依靠业务幂等键恢复并补写成功状态。
本期不调用第三方退款 API也不增加商户退款号、渠道退款号或渠道结果字段。个人/客户资产钱包退款不属于本期自动回退范围,现有对应分支必须在实施时隔离或拒绝进入本流程。
人工退款确认接口:
```text
POST /api/admin/refunds/{id}/manual-complete
```
请求包含 `request_id`、可选 `remark` 和最多 5 个完成凭证。后端仅允许具备财务确认权限的账号对 `status=2 AND processing_status IN (0,3)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。
### 退回后重新提交(已废弃,禁止实施)
> 本节是旧本地审批方案的历史记录。七月最终方案下线该接口;企微拒绝会终结原退款单,后续仍需退款时重新创建新退款单。
```
POST /api/admin/refunds/{id}/resubmit
→ 校验 status=4
→ 请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason
→ 在同一事务新建 ProcessInstance
→ 更新 approval_instance_idstatus 回到 1待审批
→ processing_status 重置为 0清空本次处理错误和人工确认记录
→ 旧审批实例保留为历史记录
```
复用当前真实路由 `POST /api/admin/refunds/{id}/resubmit`,不新增单独 `PUT`。重提命令沿用现有 `ResubmitRefundRequest` 字段范围;禁止修改订单 ID、资产快照、提交人或已形成的历史审批记录。
### API 响应与前端
退款列表和详情增加:
```json
{
"approval_instance_id": 1001,
"approval_source": "workflow",
"approval_status": 2,
"approval_status_name": "已通过",
"current_node_name": "",
"processing_status": 0,
"processing_status_name": "待人工退款",
"processing_error": ""
}
```
前端展示规则:
| 审批状态 | 处理状态 | 展示 |
|----------|----------|------|
| 审批中 | 待处理 | 待审批 + 当前节点 |
| 已通过 + 代理钱包 | 处理中 | 审批已通过,代理钱包回退处理中 |
| 已通过 + 非代理钱包 | 待处理 | 审批已通过,待人工退款;财务可确认完成 |
| 已通过 | 已完成 | 退款已完成 |
| 已通过 + 代理钱包 | 处理失败 | 系统重试中;管理员可查看错误摘要 |
| 已拒绝 | 待处理 | 已拒绝 + 原因 |
| 已退回 | 待处理 | 已退回,可编辑并重新提交 |
列表页不直接放固定审批按钮。点击进入详情后,根据审批接口返回的 `available_actions` 渲染通过、驳回和退回操作。
决策节点根据 `action_form` 展示“实际退款金额”输入,默认等于申请金额。审批通过后的非代理钱包退款展示“确认人工退款”入口,仅具备财务确认权限时显示;完成凭证与审批附件分区展示。前端只负责元/分转换和基础格式校验,金额上限以后端在审批事务中的校验为准。
停机发布后,现有按退款业务单 ID 直接通过、驳回或退回的路由不再注册;所有退款审批动作统一操作 `task_id`,避免绕过审批人快照、并发控制和操作日志。
维护窗口内需要为 `status=1 AND approval_instance_id IS NULL` 的存量退款单执行幂等回填,从 `refund_approval` 首节点创建流程实例和任务;历史终态退款不伪造流程实例。
--- ---
@@ -513,7 +450,7 @@ CREATE INDEX idx_agent_recharge_approval_instance
`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。 `rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。
充值处理状态保持`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量 充值与退款处理状态统一为`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`,共用同一组中文状态语义,业务页面再结合审批和支付方式解释当前动作
### 流程 ### 流程