34 KiB
需求15/16/18/19/20/21 技术方案
状态:原需求来源稿;其中本地审批流、审批页面、操作密码以及基于原退款单
resubmit的方案均已废弃,最终以企微审批方案和标准评审稿为准。 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。
需求15:套餐下架后允许续费
业务规则
- 下架套餐(
shelf_status=2)不可被新购 - 下架套餐可以续费(已在使用该套餐的客户)
- 续费仅支持客户自己购买(不允许代理代购下架套餐给新客户)
flowchart TD
Start[请求购买套餐] --> Enabled{套餐是否启用?}
Enabled -->|否| RejectDisabled[拒绝:套餐已禁用]
Enabled -->|是| Shelf{是否已下架?}
Shelf -->|否| Allow[允许继续下单]
Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?}
Renewal -->|否| RejectNew[拒绝:下架套餐不可新购]
Renewal -->|是| Actor{是否客户本人续费?}
Actor -->|是| Allow
Actor -->|否| RejectProxy[拒绝:下架套餐不可代购]
“客户本人”必须由后端登录主体与资产归属关系判断,不能信任前端传入 is_renewal=true。
后端
当前逻辑:下架套餐在购买时被拦截。
修改:在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”,不能接收或信任前端 is_renewal:
// internal/service/order/service.go 或 client_order/service.go
func (s *Service) validatePackageAvailability(
ctx context.Context,
pkg *model.Package,
asset *ResolvedAsset,
actor *PurchaseActor,
) error {
if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go
return errors.New(errors.CodeForbidden, "套餐已禁用")
}
if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架
if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) {
return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费")
}
}
return nil
}
续费资格判断:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格:
func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool {
return actor.OwnsAsset(asset) &&
s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID)
}
前端
C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表。
后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。
需求16:代理分销码与佣金提现(已移出7月迭代)
状态:已移出本期 决策日期:2026-07-14 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布
本次范围调整包含整个需求 16:
- 代理/员工分销码和推广二维码。
- H5 代理申请、进度查询和退回重提。
- 代理申请接入通用审批及审批后自动开店。
- 店铺发展人关系和代理申请来源字段。
- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。
因此 7 月迭代不创建 tb_distribution_code、tb_agent_application,不修改 tb_shop 发展人字段和提现材料字段,不注册代理申请相关 API,不发布 agent_application_approval,也不建设对应前端页面。
通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力,但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。
需求18:多人审批(APR-001~009)
依赖
APR-009(企微审批对接)= Phase 2。
sequenceDiagram
actor Applicant as 提交人
participant Biz as 退款/充值业务
participant Approval as 审批流
participant Notice as 站内消息
actor Approver1 as 当前节点审批人
actor Approver2 as 下一节点审批人
Applicant->>Biz: 提交申请
Biz->>Approval: 同事务创建流程实例和首任务
Approval->>Notice: TaskCreated
Notice-->>Approver1: 待审批提醒
Approver1->>Approval: 审批通过
Approval->>Notice: 下一节点 TaskCreated
Notice-->>Approver2: 待审批提醒
Approver2->>Approval: 通过/驳回/退回
Approval->>Notice: 流程结果事件
Notice-->>Applicant: 结果和原因
实现要点
| 编号 | 需求 | 实现 |
|---|---|---|
| APR-001~003 | 充值/退款多级审核 | 见需求20/21;需求16已移出本期 |
| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 |
| APR-005 | 待审核有消息提示 | 站内消息 NotifyTypeApprovalPending |
| APR-006 | 上一级完成后才提示下一级 | ProcessInstance 完成当前任务并激活下一节点,写入 TaskCreated Outbox 事件 |
| APR-007 | 通过后通知申请人 | ProcessApproved 事件处理器 |
| APR-008 | 驳回/退回后通知申请人含原因 | ProcessRejected / ProcessReturned 事件处理器 |
| APR-009 | 对接企微 | Phase 2 |
默认流程与可配置边界
- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。
- 每个节点可配置
role或user审批人来源,以及any或all完成方式。 - 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。
- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。
- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。
- 已发起实例始终使用发起时保存的流程定义快照。
业务表与审批流的关联
充值单和退款单接入审批流,各自新增 approval_instance_id 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。
本段是历史设计。旧接入协议见 本地通用审批流 - 业务接入协议。
退款和充值详情统一返回 approval_source=none|workflow|legacy。代理在线充值等无需审批的记录返回 none;发布前已经结束且没有流程实例的历史审批记录返回 legacy 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。
前端技术方案
- 历史前端交互见 前端共性方案历史稿;当前已改为企微审批只读页面。
- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。
- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。
- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。
- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。
- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。
需求19:批量订购套餐(BPO-001~008)
支付方式粒度
评审结论:支付方式按整批统一设计:
- 页面选择
offline或wallet,CSV 不再重复填写支付方式。wallet复用现有后台订单支付枚举,在批量任务中表示逐行扣结算代理主钱包,不新增同义枚举agent_wallet。 - 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
- CSV 不包含支付方式列,后端拒绝同一批次混合支付。
业务流程
flowchart TD
Start[员工选择整批支付方式] --> Upload[上传可含不同代理资产的 CSV]
Upload --> Parse[解析并持久化逐行明细]
Parse --> Validate[校验资产、套餐、归属和重复行]
Validate --> Item{处理下一条有效明细}
Item -->|代理钱包| Lock[锁定钱包并校验可用余额]
Item -->|线下支付| Voucher[校验整批支付凭证]
Lock --> Order[单条事务创建订单并扣款]
Voucher --> OrderOffline[单条事务创建已支付订单]
Order --> Result[记录订单 ID 和成功状态]
OrderOffline --> Result
Result --> More{还有待处理明细?}
More -->|是| Item
More -->|否| Summary[汇总任务结果]
Validate -->|校验失败| Failed[记录结构化失败原因]
Failed --> More
任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。
Worker 复用系统统一资产解析能力,把资产标识解析为唯一资产类型和资产 ID,再按“资产类型 + 资产 ID”判断重复。一个批次在创建时只选择一个套餐,因此重复键不包含套餐;同一资产使用不同受支持标识仍是重复,首次出现的行正常处理,后续重复行失败并记录首次出现的行号。本期不把重复行解释为购买多份,未来如有多份订购需求,应增加明确数量字段。
统一资产解析当前支持 ICCID、卡 virtual_no、MSISDN、设备 virtual_no、IMEI 和 SN;未命中或无法唯一解析时只失败该行,禁止任意选择资产。明细保存用户原始资产标识、解析后的资产类型/ID和规范标识快照,便于审计和跨标识判重。
本页面入口沿用前端现有可见性规则;后端不新增批量订购权限码、账号类型拦截或任务创建人隔离,能够通过现有后台认证调用接口的主体视为可以使用。该入口决定不能替代逐行的资产归属、套餐授权、成本价和钱包业务校验。
数据库变更
CREATE TABLE tb_bulk_purchase_task (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
task_no VARCHAR(30) NOT NULL,
request_id VARCHAR(64) NOT NULL,
source_file_key VARCHAR(500) 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,
voucher_keys JSONB NOT NULL DEFAULT '[]',
involved_shop_count INT NOT NULL DEFAULT 0,
total_amount BIGINT NOT NULL DEFAULT 0,
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
fail_count INT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
error_message TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_task_no
ON tb_bulk_purchase_task(task_no)
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 (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
task_id BIGINT NOT NULL,
row_no INT NOT NULL,
asset_type VARCHAR(20),
input_asset_identifier VARCHAR(100) NOT NULL,
resolved_asset_id BIGINT,
canonical_identifier VARCHAR(100) NOT NULL DEFAULT '',
resolved_shop_id BIGINT,
resolved_shop_name VARCHAR(200) NOT NULL DEFAULT '',
amount BIGINT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
order_id BIGINT,
failure_code VARCHAR(50) NOT NULL DEFAULT '',
failure_reason VARCHAR(500) NOT NULL DEFAULT '',
idempotency_key VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row
ON tb_bulk_purchase_item(task_id, row_no);
CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency
ON tb_bulk_purchase_item(idempotency_key);
CREATE INDEX idx_bulk_purchase_item_task_status
ON tb_bulk_purchase_item(task_id, status);
状态建议:
- 任务统一复用全局异步任务状态:
1=待处理, 2=处理中, 3=已完成, 4=已失败, 5=已取消;逐行明细使用1=待处理, 2=处理中, 3=成功, 4=失败。 - 文件有效并完成全部行业务处理后任务为“已完成”;全部成功、部分成功和全部行业务失败由成功数/失败数表达,不建立“部分成功”状态。本期没有取消入口,但保留全局取消状态码。
处理与幂等
- 前端选择单个套餐和整批支付方式,通过对象存储预签名地址直传 CSV 和线下凭证,再由 API 校验
package_id、file_key、支付方式和voucher_keys并创建任务,返回task_id;业务接口不接收shop_id或文件字节。创建接口校验套餐存在以及对象归属、扩展名/Content-Type 和 10MB 大小,不同步解析 CSV;逐行处理时再校验结算代理当前套餐授权、价格和可售规则。 - Worker 根据
source_file_key下载并解析 UTF-8 CSV(允许 BOM),校验固定表头、未知列、CSV 语法、空文件和 1000 行上限;任一文件级校验失败时任务整体失败且不创建明细订单。文件有效后将每一行先写入明细表,再开始业务处理;不接受 Excel。 - 同一任务严格按 CSV 行号顺序处理;每行按资产当前归属解析结算代理,同一任务可以依次处理不同代理的钱包。
wallet不预占整批或某一代理全部行的金额,当前行余额不足只失败该行并继续后续行;后续金额较小且当时余额足够的行仍可成功,因此行序就是同一代理资金不足时的订购优先级。 - 每行使用独立事务。代理钱包支付时锁定该行结算代理的主钱包,按有效信用额度校验总可用金额后,在同一事务扣款、创建订单、资金流水并更新明细;无归属、归属异常、无主钱包或余额不足只失败该行。
idempotency_key使用bulk_purchase:{task_id}:{row_no}。Worker 重试时,已存在成功订单的明细直接跳过。- 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。
- 任务汇总从明细表计算,不信任内存计数。
Asynq 载荷只传任务 ID,不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理,确保 Worker 重试期间仍可读取。
单文件上限 1000 行;超过上限由 Worker 将任务标记为整体失败,不创建任何订单。
API 设计
前端静态 CSV 模板:
模板由前端项目随版本发布,后端不提供下载接口。模板只有一列:
资产标识
套餐由创建任务请求中的单个 package_id 决定。资产标识统一交给系统资产解析能力识别资产类型和 ID;批量模块不维护自己的标识白名单。后端必须校验单列表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
上传并提交:
POST /api/admin/bulk-purchases
Content-Type: application/json
{
"request_id": "01J...",
"package_id": 1001,
"payment_method": "offline",
"file_key": "bulk-purchase/2026/07/xxx.csv",
"voucher_keys": ["attachment/2026/07/key1"]
}
CSV 先调用 POST /api/admin/storage/upload-url 并使用独立 purpose=bulk_purchase 获取预签名地址后直传;线下凭证使用附件用途直传。package_id 为大于 0 的单个套餐 ID;offline 时 voucher_keys 必填,wallet 时必须为空。创建任务前校验套餐存在、对象归属、CSV 类型和 10MB 大小限制;编码、表头、语法、空文件和行数由 Worker 校验。
查询任务状态:
GET /api/admin/bulk-purchases/{task_id}
分页查询任务明细:
GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
前端技术方案
- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 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:退款审批
依赖
本需求依赖七月统一企业微信审批公共能力,以稳定场景码 refund_approval 复用真实提交人、平台本人绑定、代理固定成员代提交、模板版本、附件上传、加密回调、轮询和终态 Outbox。退款模块不再依赖已废弃的本地审批流,也不自行实现审批节点、候选审批人或审批按钮。
创建契约与固定金额
- 保留
POST /api/admin/refunds,允许超级管理员、平台和代理发起;企业账号不允许发起。 - 请求只接受
order_id、requested_refund_amount、必填且最多 1000 字的refund_reason、可选且最多 500 字的remark,以及 1~5 个{file_key,file_name,file_size}附件。 - 不再接受前端提交的
actual_received_amount或package_usage_id。后端读取订单实收金额、支付方式、买卖方、资产和店铺事实并固化快照。 - 申请金额校验
0 < requested_refund_amount <= 订单实收金额,提交后固定。企微表单只读展示订单实收金额、可退款区间和本次申请金额,审批人只能同意或拒绝,不能修改金额。 - 退款单、唯一企微审批实例和提交 Outbox 同一 PostgreSQL 事务创建;远程企微异步提交,业务接口返回本地退款和“企微提交中”。场景、模板、平台本人绑定或代理固定成员不可用时,在任何业务事实落库前失败。
- 同一订单存在提交中、审批中、已通过但处理未成功或异常人工处置未完成的退款时禁止新建。已拒绝退款不占活跃名额;撤销、删除和通过后撤销不自动放行。
独立状态与数据事实
退款状态:1=待审批 2=已通过 3=已拒绝 4=已退回(仅历史) 5=已撤销/审批已删除
处理状态:0=未触发 1=处理中 2=处理成功 3=处理失败
- 企微审批状态独立复用公共枚举:
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和必要的人工操作者事实;已发放佣金回扣以“退款单 + 佣金记录”唯一防重。
企微终态和资金处理
- 企微拒绝把退款状态置为已拒绝,处理状态保持未触发;该审批和退款单均终结。若修正问题后仍需退款,重新调用创建接口生成新退款 ID、退款单号、快照、附件和企微实例,原单不可编辑或重提。
- 微信、支付宝、线下及其他非代理主钱包支付由财务先在系统外退款,再同意企微;企微同意即代表退款完成。本系统不调用渠道退款 API,也不提供
manual-complete二次确认。 - 个人客户资产钱包支付同样走系统外人工退款。本系统不自动回充资产钱包、不创建资产钱包退款流水;现有个人钱包自动回充分支必须从新终态用例移除或隔离。
- 代理主钱包支付在企微同意后只按原订单扣款流水定位原主钱包,增加本次申请金额并写唯一退款流水。余额允许为负,退款自然冲减欠款;缺少可核验原扣款流水时处理失败,禁止根据当前代理关系、买卖方或当前钱包猜测。
- 企微撤销或删除进入异常人工处置,不执行资金动作也不自动放行新退款。通过后撤销且资金尚未执行时阻止后续资金步骤;资金已执行时不自动冲正,保留实际退款金额,记录
criticalAudit Event 和站内告警。
整单终结、可靠性和审计
- 无论申请金额是否等于订单实收金额,企微同意后都以整张订单为边界:订单标记已退款,该订单全部有效套餐和佣金资格终结,该订单不允许再申请未退差额。向客户实际退款的金额仍是本次申请金额。
- 已冻结、解冻中、尚未发放或待人工修正的佣金不移动钱包,直接改为已失效;已发放佣金先从对应佣金钱包全额扣回,再改为已失效,钱包允许为负。佣金状态、钱包余额/版本、回扣流水和统一 Audit Event 同事务。
- 该订单生成的全部有效套餐失效,不按单个套餐使用记录缩小范围。主套餐失效级联其加油包,然后尝试激活下一条待生效主套餐;没有下一条时通过公共卡/设备状态能力停止资产。
- 企微首次终态通过公共业务 Outbox 触发可靠 Asynq Worker,不使用进程内 Goroutine。Worker 通过状态条件和租约领取任务;资金、订单、每条佣金和套餐分别保存持久化幂等事实,局部失败只重试未完成步骤,全部完成后才置处理成功。
- 代理主钱包余额/版本/退款流水与实际退款金额、已发放佣金回扣、订单状态和关键套餐状态分别按最小一致性边界写统一 Audit Event。重复回调、轮询和 Worker 命中已完成事实时不产生第二笔资金、第二次失效或第二条等价审计。
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。 - 代理退款查询不再按创建账号隔离,改为既有店铺层级数据范围和退款业务权限。代理可见申请资料、业务凭证、真实提交人、审批状态和处理结果,但看不到审批人、内部意见和审批人附件。平台/超级管理员也必须有退款业务查看权限,才可读取完整企微详情。
- 列表、详情、附件下载和导出复用同一权限投影;企微运营权限不能绕过退款业务权限。
- 创建页不提交实收金额或套餐使用记录,明确提示“申请金额可以小于实收金额,但通过后仍按整单终结”。详情固定分为退款业务信息、企微审批信息和业务处理结果,不显示本地审批、金额修改、重提或人工确认按钮。
- 非代理钱包提示财务先系统外退款再同意企微;代理钱包提示同意后系统自动回溯。处理失败只展示脱敏摘要和系统重试状态;通过后撤销使用高风险告警并说明不会自动冲正。
测试与发布门禁
- 自动化最高接缝为 Fiber HTTP 与真实认证,经退款 Application/Domain/Query、GORM、现有测试 PostgreSQL、Outbox 和公开 Worker Handler,最终核对钱包、佣金、套餐和统一审计;企微网络边界使用可编程 Adapter 覆盖超时、重复、乱序、撤销、删除、通过后撤销和并发重试。
- 本地/Agent 测试强制 Redis DB 7,已部署测试环境继续使用 DB 6;测试启动前校验普通 Redis、Asynq Client 和 Worker 的实际 DB,禁止向 DB 6 投递、禁止
FLUSHDB。PostgreSQL 沿用现有测试库并按唯一运行标识精确清理;对象存储使用真实 S3 和唯一 Key,不做内存替身。 - 真实企微验收是实现完成门禁,不得推迟到 INT-06。至少执行两张独立退款:代理使用固定成员代提交并同意,验证真实附件、加密回调及代理钱包/佣金/套餐/审计;平台账号使用本人绑定发起并拒绝,验证轮询兜底且不触发资金处理。
- 回调按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发查询参数和请求体,后端仍完整验签、解密并核对 CorpID。真实参数只通过安全配置提供。撤销、删除和通过后撤销由可编程 Adapter 稳定覆盖,不强制每次真实企微人工制造。
- 停机发布时先具备真实企微公共能力、模板映射、平台绑定和代理固定成员,再迁移待审批退款并启用终态 Worker;历史终态保留
legacy,历史已退回只读。旧状态不一致记录进入对账/人工处置,迁移脚本不得猜测资金、佣金或套餐已完成。
需求21:充值审核流程
充值单现有状态
tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
代码中已经存在 6=已驳回,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 7=已退回:
-- 现有:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 6=已驳回
-- 新增:7=已退回
ALTER TABLE tb_agent_recharge_record
ADD COLUMN approval_instance_id BIGINT,
ADD COLUMN processing_status INT NOT NULL DEFAULT 0,
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '',
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN return_reason VARCHAR(500) NOT NULL DEFAULT '';
CREATE INDEX idx_agent_recharge_approval_instance
ON tb_agent_recharge_record(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
充值业务的状态语义:
1=待支付:创建未支付(线下充值等待审批时也停在这里,由approval_instance_id查询审批状态)2=已支付:在线支付已确认,或线下充值审批通过后正在执行钱包入账3=已完成:充值到账4=已关闭:取消/超时5=已退款:退款6=已驳回:审批流程拒绝7=已退回:审批人退回给提交人修改
rejection_reason 只保存驳回原因,新增 return_reason 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。
充值与退款处理状态统一为:0=未触发, 1=处理中, 2=处理成功, 3=处理失败,共用同一组中文状态语义,业务页面再结合审批和支付方式解释当前动作。
流程
代理自行充值(不走审批):
flowchart LR
A[代理提交充值申请] --> B[系统生成收款码]
B --> C[代理扫码支付]
C --> D[支付回调幂等入账]
员工线下代充值(走审批):
现有 offline-pay 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 operation_password 动作字段;审批动作适配器调用现有 OperationPasswordService 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。
sequenceDiagram
actor Staff as 平台员工
actor Approver as 审批人
participant Recharge as Recharge Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as RechargeApprovalHandler
Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline)
Recharge->>DB: 同事务创建充值单(status=1)
Recharge->>Approval: StartProcess(recharge, recharge_id)
Approval->>DB: 创建实例、首任务、审批人、Outbox
Recharge->>DB: 回写 approval_instance_id
Approver->>Approval: 按 task_id 审批
DB-->>Worker: 投递流程结果事件
alt 审批通过
Worker->>DB: status 从 1 更新为 2,processing_status=1
Worker->>DB: 幂等增加钱包余额并写流水
Worker->>DB: status 从 2 更新为 3,processing_status=2
else 审批拒绝
Worker->>DB: status 从 1 更新为 6,写 rejection_reason
else 退回修改
Worker->>DB: status 从 1 更新为 7,写 return_reason
end
充值接口独立返回审批状态和业务处理状态。RechargeApprovalHandler 使用 recharge:{recharge_no} 作为幂等键;钱包余额、版本、充值单和交易流水必须在同一事务更新。
充值处理同样使用 processing_started_at 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。
退回后重新提交
POST /api/admin/agent-recharges/{id}/resubmit
→ 校验 status=7
→ 请求体可修改 amount、payment_voucher_key、remark
→ 在同一事务新建 ProcessInstance
→ 更新 approval_instance_id,status 回到 1(待支付/待审批)
→ processing_status 重置为 0,清空处理错误和 return_reason
→ 旧审批实例保留为历史记录
新增 resubmit 路由时沿用现有 /api/admin/agent-recharges 资源名,不另建 /agent-recharge-records 路径。店铺、支付方式和提交人不可修改;编辑与新流程创建必须同事务完成。
停机切换
现有 POST /api/admin/agent-recharges/{id}/offline-pay 和 POST /api/admin/agent-recharges/{id}/reject 都会绕过通用审批任务,本次不保留兼容窗口:
- 发布前进入维护模式,停止创建和处理线下充值。
- 执行审批关联字段迁移,同时发布新 API、Worker 和前端。
- 初始化并启用
recharge → recharge_approval绑定。 - 为存量“平台员工创建 + 线下支付 + 尚未入账”的充值记录幂等创建流程实例,代理在线充值不回填审批。
- 新前端创建线下充值后直接进入审批详情,不再展示“确认线下充值”按钮。
- 新版本不注册
offline-pay和业务单级reject路由;线下充值只能由ProcessApproved事件触发幂等入账,驳回统一由任务级审批接口产生ProcessRejected。 - 验证审批通过、驳回、退回、处理失败重试和钱包流水后再解除维护模式。
前端技术方案
- 代理自行充值保留现有收款码和支付状态页面,不显示审批信息。
- 平台员工选择
offline时,提交成功进入充值详情并展示审批时间线。 status=6展示“已驳回”,status=7展示“已退回”;两者按钮不同,只有已退回可编辑和重新提交。- 审批通过但
processing_status=1时显示“充值处理中”;状态为 3 且处理成功后才显示最新钱包余额。 processing_status=3时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。