Files
junhong_cmp_fiber/openspec/changes/add-agent-self-recharge-payment-methods/design.md
break 7891189712
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
feat(代理自充): AUG26-017 代理自充收款方式与线下预存款审批字段
- 受控配置新增代理在线自充允许范围(仅微信/仅支付宝/同时支持),读侧与创建侧取允许范围与可用商户池交集,两侧失败关闭
- 新增允许范围查询与修改端点,读限代理与平台账号、写限超级管理员,复用受控配置写服务留痕
- tb_agent_recharge_record 新增交易流水号、线下收款方式三列快照与其他凭证列(成对迁移 000213)
- 线下申请校验启用的收款方式字典项与必填交易流水号,交易流水号独立于在线渠道交易号、不参与去重
- 扩展 offline_recharge_approval 场景可映射字段白名单与字典引用保护
- 新增付款凭证识别能力与交易流水号预填接口,识别不落库、日志不记录载荷
2026-09-11 15:21:23 +08:00

18 KiB
Raw Blame History

Context

现有代理在线充值链路已完整可用,本 Change 只在其外层补"允许范围"与"申报字段",不改造支付与入账闭环:

  • 创建与门禁:POST /api/admin/agent-rechargesadmin.AgentRechargeHandler.Create,在线分支为 createOnline 并显式拒绝 shop_id、凭证与备注(internal/handler/admin/agent_recharge.go:69-85);用例为 agentrecharge.OnlineCreationService.Executeinternal/application/agentrecharge/online_creation.go:64)。
  • 自身店铺门禁已内联在 loadCreationFacts:账号 user_type=agent ∧ status=1 ∧ account.shop_id == CurrentShopID、店铺启用、主钱包 main+normalinternal/application/agentrecharge/online_creation.go:174-200)。店铺取自认证上下文,客户端无法指定。
  • 可用方式当前只按商户池推导:OnlineCreationService.AvailablePaymentMethods 遍历固定顺序 [wechat, alipay],过滤启用池 + 启用商户 + MerchantConfig + adapter.Availableinternal/application/agentrecharge/online_creation.go:131-172),且对非代理直接 403。
  • 商户选路与快照冻结在创建事务内完成:merchantpayment.SelectForNewPaymentWithTxFreezeRouteinternal/application/merchantpayment/routing.go:203,357)。无可用商户时返回 CodeNoPaymentConfig,归档契约明确禁止回退旧综合支付配置。
  • 受控系统配置已具备完整写路径:PUT /api/admin/system-configs/:keysystemconfig.UpdateService.Execute,仅超级管理员、pg_advisory_xact_lock 串行、首次写入即 upsert、前后值审计、提交后缓存失效internal/application/systemconfig/update.go:72,104,110-140。Registry 原生支持 ValueType=string + EnumValuesinternal/infrastructure/systemconfig/registry.go:104-140Reader.GetStrict 在无行时返回代码注册的 DefaultValue,已落库的非法值失败关闭(internal/infrastructure/systemconfig/reader.go:118-143)。
  • 线下预存款申请事实就是 tb_agent_recharge_recordpayment_method='offline'),审批实例一业务单一个(uq_approval_instance_businessmigrations/000170_create_approval_core.up.sql:18),驳回即终态、无重提。创建用例 OfflineCreationService.Execute 与命令 CreateOfflineCommand 只含 ShopID/Amount/PaymentVoucherKeys/Remarkinternal/application/agentrecharge/offline_creation.go:21-29)。现表无收款方式引用、无交易流水号、无其他凭证列(internal/model/agent_wallet.go:76-100)。
  • 场景可映射字段是白名单硬编码:offline_recharge_approval 现有 9 个字段,不含收款方式与其他凭证(internal/application/wecom/scene.go:309-320)。
  • 线下收款方式字典已由员工账单 Change 建立并被其声明为共享分类:tb_employee_collection_payment_methodmigrations/000212_add_employee_collection_bills.up.sql:6-32),契约明确"由本能力独占维护MUST NOT 被定义为核销专用"openspec/specs/employee-collection-bill/spec.md)。当前字典读写与"被引用"判定只在 internal/application/employeecollection/payment_method.go 内,且只扫核销申请与尝试。
  • Gateway 客户端已具备 AES-128-ECB 加密、MD5 签名、网络级重试与统一错误映射(internal/gateway/client.gocrypto.go;契约见 docs/integrations/gateway/README.md)。附件对象存储提供按 key 读取:Provider.DownloadDownloadToTempStatExistspkg/storage/storage.go:12-18)。
  • 主 spec 中 agent-funds-commission 的"代理在线充值可用支付方式按支付配置判定"仍描述商户池改造前的旧口径,与本 Change 的目标判定冲突,需一并修正。

Goals / Non-Goals

Goals:

  • 让代理在线自充可用方式成为"超管允许范围 ∩ 可用商户池方式",且允许范围对代理与平台不可见。
  • 让线下预存款申请按 §17.1 留存可维护的收款方式、交易流水号与其他凭证。
  • 以既有 Gateway 通道提供付款凭证识别预填,且识别结果不成为资金事实、不进入日志。

Non-Goals:

  • 不新增代理自助线下充值入口,不改造 offline_recharge_approval 的一业务单一个审批实例语义与终态推进。
  • 不引入审批尝试记录模型,不实现驳回后重提。
  • 不改造员工账单建账判据、核销申请流程与退款冲销。
  • 不新建业务字典模块或收款账户目录。
  • 不改造 Gateway 客户端的加密、签名、重试与错误映射;不改动其既有能力文件。

Decisions

1. 允许范围落在受控系统配置,不新建表

新增一个注册 KeyValueType=stringEnumValues 为三个稳定取值(仅微信 / 仅支付宝 / 同时支持),默认值为"同时支持"以保持既有行为。读写复用既有 systemconfig.UpdateServiceReader.GetStrict,从而直接获得超管限定、咨询锁串行、前后值审计、枚举校验与缓存失效,无需新表或第二套配置约定。

替代方案(新建专用配置表)被拒绝:会与既有受控配置重复审计与缓存语义,且 GET /system-configs 已限定超级管理员读取(internal/query/systemconfig/list.go:29-31),天然满足"允许范围不得泄漏给代理与平台"。

管理端点按需求命名为 GET/PUT /agent-self-recharge-payment-methods,实际注册为 /api/admin 下的独立单段路由组(internal/routes/agent_self_recharge_payment_method.go)。需求路径与既有代理充值组前缀 /api/admin/agent-recharges 不同,无法挂在该组下,因而也无法继承该组「企业账号 403」的门禁读取与写入的角色门禁改由该路由组内显式声明读取仅代理与平台账号写入仅超级管理员且显式拒绝企业账号。写入仍复用同一配置写服务不新增第二条配置写路径。

2. 交集在读侧与创建侧各判定一次,两侧均失败关闭

读侧 AvailablePaymentMethods 增加允许范围过滤,并把可见角色从"仅代理"放开为"代理与平台账号";创建侧在 Execute 的域校验之后、读取店铺事实之前,用同一次配置读取结果判定请求方式是否在交集内,不在则拒绝。

只做创建侧校验会让代理看到无法使用的方式;只做读侧校验会让绕过查询的请求创建被禁用方式。两侧都必须走 GetStrict,配置非法时失败关闭,而不是回退默认值后继续放行。

创建侧允许范围门禁位于幂等回放之后:同一 request_id 的重试属于既有单而非新单,不因允许范围变更被拒绝;门禁只拦截会真正新建充值单与支付单的路径,被拒方式仍不新建任何行(internal/application/agentrecharge/online_creation.go:90-97)。

3. 交易流水号使用独立列,不复用在线渠道交易号

tb_agent_recharge_record 新增 external_transaction_no 列保存该笔充值的交易流水号,其值来自付款凭证识别的支付单号并经人工确认或更正。列名与语义对齐既有员工账单能力的外部交易流水号(tb_employee_collection_application.external_transaction_no,注释"外部交易流水号,可由 OCR 预填但以人工确认值为准"migrations/000212_add_employee_collection_bills.up.sql:262),全仓保持一套词汇,展示名仍为"交易流水号"。

不复用 payment_transaction_id,理由是结构性的而非命名偏好:

  • 该列是在线支付的渠道权威事实,只由在线回调写入(internal/application/agentrecharge/confirm_online_payment.go:127internal/service/agent_recharge/service.go:303),并作为入账对账键被 Outbox 消费者强校验:recharge.PaymentTransactionID 必须等于事件里的渠道交易号,否则返回冲突(internal/infrastructure/payment/agent_recharge_consumer.go:118)。一旦允许人工修改该列,在线入账对账即被破坏。
  • 该列同时是财务调查视图的交易流水号检索域(internal/query/audit/finance.go:480,1007)与代理充值审计身份字段(internal/infrastructure/audit/registry.go:795)。把人工申报值并入,会让渠道权威值与人工申报值在同一检索域内不可区分。
  • 申报值本质是"人工确认的付款证据",渠道值本质是"渠道返回的权威事实",二者来源与可信级别不同,111.md §0 第 4 条的"识别结果不是资金事实"要求它们在存储上可区分。

替代方案(复用 payment_transaction_id)被拒绝,原因如上;替代方案(另建申报表)过度:该字段始终 1:1 属于充值单。

不新增唯一约束:同一外部付款的重复申报由企业微信终审人员以第三方记录核验,与员工账单能力对跨申请累计分摊不做系统防重的既有裁决一致(openspec/specs/employee-collection-bill/spec.md)。因此线下申报的交易流水号不做跨记录去重,也不作为入账前置条件。

在线充值单的 payment_transaction_id 语义与写入方保持不变;线下列可空以满足历史行兼容与 down 回滚。

4. 收款方式以三列快照引用既有字典,命名与支付方式枚举区分

tb_agent_recharge_record 新增 offline_payment_method_idoffline_payment_method_codeoffline_payment_method_name 三列,引用 tb_employee_collection_payment_method。刻意不命名为 payment_method_*:该表已有 payment_method 列表示 wechat/alipay/bank/offline 支付方式枚举,同前缀会造成两套语义混淆。快照列与员工账单申请/尝试的 payment_method_id/code/name 同构。

新建申请时校验字典项存在且启用;历史行三列为空。

字典的"被引用"判定需从只扫核销申请与尝试扩展为同时覆盖代理充值申请,否则超管可删除或改码一个仍被引用的字典项,破坏"被引用只可停用"的可观察语义。该扩展是引用判定的最小补齐,不改动员工账单的建账、核销与退款逻辑。

5. 其他凭证使用独立列

新增 other_voucher_keysjsonb 数组,对象键引用),与既有 payment_voucher_key 并列不合并为单一列表§17.1 要求"支付凭证"与"其他凭证"在审批表单中分别呈现,合并后无法区分两类。

6. 付款凭证识别新增 Gateway 能力,不复用带响应日志的泛型入口

internal/gateway 新增能力文件封装 POST /ai/ocr/extract-payment,入参为 image_base64。图片字节由后端按附件对象键读取对象存储后编码,避免把 Gateway 凭证下发给前端。

该能力刻意既不用 doRequest 也不用 doRequestWithResponse,两条既有路径都会把识别载荷写进日志:

  • doRequest 在 Info 级别打印加密前的完整明文请求体internal/gateway/client.gozap.ByteString("body", dataBytes)),而本能力的请求体正是凭证图片的 base64。
  • doRequestWithResponsedoRequest 之上再于 Info 级别打印完整原始响应(同文件的 zap.ByteString("data", data)),会把识别出的付款人、金额与单号一并写入日志。

因此本能力改用 Client.doRequestWithoutPayloadLog:它与 doRequest 共用同一套序列化、AES-128-ECB 加密、MD5 签名与网络级重试流程(executeWithRetrylogPayload 分支),只在记录内容上分叉——仅记录路径、耗时与结果字节数摘要,响应由能力文件自行反序列化。既有能力的请求行为与日志语义保持原样,不受本能力影响。

识别结果不落库、不写入任何资金事实字段系统只在请求内向调用方返回支付单号供交易流水号表单预填。Gateway 返回的其余字段不进入本接口响应。

识别结果到表单字段的映射

Gateway 识别响应含 amountorder_numberpayeepayment_methodpayment_timeremark,但本 Change 只消费和返回一个字段:

识别字段 表单字段 处置
order_number 交易流水号(external_transaction_no 唯一预填字段§17.1 的“交易流水号”即此值
amount 不预填、不返回
remark 不预填、不返回
payment_time 不预填、不返回
payment_method 不预填、不返回
payee 不预填、不返回

payment_method 不可映射到收款方式字典:它描述付款方使用的支付工具(如某银行信用购),而 §17.1 的收款方式是公司侧业务维护的收款账户类目。payee 也不可映射到付款方字段名意为收款人示例值为公司名称但外部文档描述写作“付款人”§17.1 的付款方口径则是“对应店铺”。即使未来扩大 OCR 返回字段,二者仍不得自动映射。

识别值仅作预填,实测可能不完整(上游对长号码存在只返回部分位数的情况,见 docs/integrations/gateway/README.md 的限制说明),必须由提交人人工核对后以人工确认值作为业务事实;系统不得将识别值当作可信的完整流水号使用,也不得据此做任何自动比对或防重。

7. 识别接口面向本 Change 场景,并做附件归属校验

新增 POST /api/admin/agent-recharges/payment-voucher-ocr,落在既有代理充值路由组(组级已拒绝企业账号),入参为附件对象键。创建申请前调用,因此不带充值单 ID。

接口对每个对象键校验对象存在且为非空,并以对象元数据判定为图片类型;非图片或对象不存在返回明确失败,不阻断人工填写。既有线下与核销路径只校验键的数量与长度、不校验归属,此处不扩大该缺口,但至少保证不因识别接口把任意对象内容回显为字段。

8. OCR 预填必须在创建申请前可人工更正

交易流水号是付款凭证识别的预填值,不是 OCR 写入的冻结值。提交人必须能在创建线下预存款申请前修改 order_number 预填的交易流水号;创建时保存人工确认后的 external_transaction_no,并按既有申请创建审计记录操作者与最终值。

本 Change 不新增在企业微信审批已提交后原地修改材料的语义:审批表单由 Worker 在提交时从 tb_approval_instance.request_snapshot 渲染(internal/infrastructure/wecom/approval_submission_consumer.go:51-52)。已被企业微信受理的表单不可由本地字段覆盖,否则本地记录与审批人看到的材料不一致。若后续需要“提交后修正且审批人看到修正值”,必须以新审批实例重提,届时再独立引入审批尝试记录与重提状态机,不能在本 Change 伪造同步修改。

Risks / Trade-offs

  • [识别返回的 payment_method 与收款方式字典语义不同] → 识别返回的支付方式描述的是付款方使用的支付工具如某银行信用购§17.1 的收款方式是公司侧业务维护的收款账户类目。设计明确二者不可互相自动映射:收款方式必须由提交人从字典中选择,识别值只可作提示,不得自动命中字典项。
  • [重复交易流水号] → 交易流水号是人工确认的申报值而非系统去重键,不设唯一约束、不做跨记录防重,与员工账单能力对跨申请累计分摊不做系统防重的既有裁决一致;重复申报由企业微信终审人员以第三方记录核验。
  • [base64 图片体积导致请求过大或超时] → 单次识别只接受单个附件键;复用既有 Gateway 超时配置与网络级重试;识别失败返回明确失败而不阻断人工填写。
  • [识别接口放大对象读取面] → 校验对象存在、非空且为图片类型;仅返回识别字段,不返回对象内容;接口不承诺资源级附件鉴权,与既有附件契约一致。
  • [字典引用判定扩展触及员工账单代码] → 只扩展引用查询范围,不改建账判据、核销申请、分摊或退款冲销;变更点集中在字典删除与改码两处校验。
  • [修正主 spec 的可用方式 Requirement 可能与其他 Change 冲突] → 该 Requirement 描述的是商户池改造前的旧口径,与实际实现和归档商户池契约都冲突;本 Change 以 RENAMED改名+ MODIFIED全量替换同步保留既有四个场景名以免归档时丢弃场景并按商户池口径重写其内容。

Migration Plan

  1. 新增成对迁移(.up.sql/.down.sql)为 tb_agent_recharge_record 增加 external_transaction_no、收款方式三列与 other_voucher_keys,并为其补充注释与查询索引;不修改任何既有迁移。在线渠道列 payment_transaction_id 的定义与写入方保持不变。迁移编号在实施开始前按当时 migrations/ 目录最大编号顺延,不预占。
  2. 新增列的线下必填由应用层保证,数据库列保持可空以兼容历史行,使 down 迁移无需清理数据即可回滚。
  3. 不新增业务类型、不扩展 tb_wecom_approval_scene 的业务类型 CHECK、不新增审批场景配置行本 Change 只扩展 offline_recharge_approval 场景的可映射业务字段白名单,需由超级管理员在既有场景配置中把新字段映射到企业微信模板控件后才在详情中可见。
  4. 允许范围未配置时使用代码注册默认值,因此不存在需要迁移写入的基准数据;配置由超级管理员按需设置。
  5. 按 ENG-TEST-001 在维护者指定的 junhong_cmp_test PostgreSQL 与 Redis DB 6 验证:迁移从本地工作区以显式 DB_* 执行 scripts/migrate.sh,只创建、删除本 Change 自己的 fixture禁止重置整库仅连接、迁移或实际行为失败时才阻塞对应场景。验证项见 tasks。
  6. 回滚:仅在本 Change fixture 已清理、且维护者确认新增收款方式、交易流水号与凭证快照没有留存需求时,先停用新入口,再执行 down 删除新增列。允许范围配置行保留且不影响旧版本行为down 不得影响既有充值记录读取。存在仍需保留的真实新增列数据时,禁止将 down 作为正常回滚场景执行MUST NOT 通过重置整个 junhong_cmp_test 规避该边界。

Open Questions

无。识别接口的字段规模、置信度与失败分级以 docs/integrations/gateway/README.md 的契约为准;契约变化时更新该文档,不影响本 Change 的规格与任务拆分。