29 Commits

Author SHA1 Message Date
67893617fe feat(退款): AUG26-006 补充当前退款套餐已用量与总量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m33s
补齐 PRD §2.3.1「退款管理补充字段」:退款列表、详情与导出新增
「当前退款套餐已用量」与「当前退款套餐总量」两个纯展示字段。

- 套餐定位口径与退款套餐失效保持一致,按优先级取唯一一条:
  冻结的 package_usage_id(且属于该订单)→ 订单主套餐 → 订单任一套餐,
  同级按标识升序。不按当前世代或当前生效套餐推断;不按套餐状态过滤,
  使退款后套餐转已失效时仍能回看用量。
- 列表与详情用固定两次查询批量解析(按标识、按订单),查询次数不随条数增长;
  详情复用同一函数。解析不到套餐或记录已物理删除时返回 0,不阻断读取。
- 导出新增两列并改用同一优先级的 LATERAL 取法,不再依赖只按 r.package_usage_id
  的 join——生产库 1296 条退款仅 157 条带该字段,旧取法会让多数行显示零值。
- 不改变退款金额校验、冻结实收、套餐失效、接续、停机与佣金回溯任何规则。

验证:测试库 junhong_cmp_test 实测冻结记录、订单主套餐回退、记录缺失返回 0 三项
解析场景与「4 条退款固定 2 次查询」;并以同批 43 条退款对拍 Go 解析器与导出 SQL,
口径不一致 0 条;导出 43 行列数与表头一致。无迁移、无接口路径变化。
2026-09-14 12:11:55 +08:00
09abee9778 docs(归档): 归档退款方式与原路退款变更并同步主规格
- 将 add-refund-methods-and-original-route-refunds 归档为
  2026-09-14-add-refund-methods-and-original-route-refunds。
- 合并两份 delta 到主规格:
  * order-refund-exchange:改写「订单、退款与换货状态门禁」,新增「退款实收金额与方式矩阵」
    「企业微信唯一终审与审批尝试重提」「原路退款渠道能力与执行」「退款权益与订单状态时点」
    「退款终态事实与失败分类」五项行为要求。
  * merchant-payment-routing:改写「商户与微信授权配置管理」与「新支付商户快照与历史兼容」
    (删除「不得新增渠道退款能力」与「不新增富友退款」,改由退款能力按商户凭证执行;
    微信 v2 客户端证书改为可选凭证键)。
- 同步上下文健康检查证据链与入口矩阵:为新要求登记证据行,并把退款创建、重提、
  企微审批回调、退款详情与 refund:channel:recovery 任务与对应要求双向关联。
2026-09-14 12:00:35 +08:00
ba0855d9eb feat(退款): AUG26-006 退款方式选择与原路退款
按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00
48c85a4916 docs(归档): 归档已用完套餐展示与支付购包即时复机两个变更
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m35s
- fix-depleted-package-display → archive/2026-09-14-fix-depleted-package-display;
  补记验证:`GetCurrentMainPackage`(package_usage_store.go:63-66)按 status IN (1,2) 读取主套餐,
  资产信息 `fillPackageInfo`(service/asset/service.go:348-349)复用该读取,
  已用完主套餐返回名称、使用记录、时间与流量指标,待生效/已过期/已失效仍不作为当前套餐;
  实现提交 ff25586,4 项任务全部完成。
- fix-immediate-package-payment-resume → archive/2026-09-14-fix-immediate-package-payment-resume;
  补记八月迭代同步验证:定点同步提交 b38b2b3 已是 Iteration/8-11 的 HEAD 祖先,
  在途支付商户装配保留(98c145f);services.go:277 `orderService.SetResumeCallback(stopResumeService)`
  在位,`go build ./cmd/api ./cmd/worker` 通过;2.1/2.2 据此勾选。
- 主 Spec 同步:personal-customer 新增「资产信息展示当前可用或已用完主套餐」;
  package-lifecycle 修改为支付成功后已生效主套餐触发一次即时自动复机检查。

openspec validate --all 为 38 passed / 0 failed;doctor healthy。
2026-09-14 09:49:03 +08:00
bb06cc89c5 修复
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-14 09:45:47 +08:00
575d056f54 feat(代理分销提现): 落地扫码注册、提现资料资格与企微终审提现
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
AUG26-008。

- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
  tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
  tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
  不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
  消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
  审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
  重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
  超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
  保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
  通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
  不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
  场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
  以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。

验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
  退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
  openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。

运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
2026-09-14 09:45:13 +08:00
315a7de3e4 docs(归档): 归档代理自充支付方式与员工代收款路由前缀两个变更
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
- add-agent-self-recharge-payment-methods 归档为 2026-09-11-add-agent-self-recharge-payment-methods,delta 应用后主规格 agent-funds-commission 完成 1 条 Requirement 改名并新增 4 条 Requirement
- 在可达操作索引补充代理自充支付方式配置与付款凭证识别共 3 个端点
- 同步 requirement-evidence.json 与 entry-capability-requirement-matrix.json 证据链
- fix-employee-collection-route-prefix 归档为 2026-09-11-fix-employee-collection-route-prefix 并勾选任务 2.5

门禁:context-health 通过、openspec validate --all 40 passed / 0 failed、doctor healthy
2026-09-11 15:50:15 +08:00
5ee8e3cb4a docs(员工代收款): 新增路由前缀修复治理变更并勾选 AUG26-017 门禁
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m30s
- 新增 OpenSpec Change fix-employee-collection-route-prefix,承载已落地的 ff1362d 路由前缀修复(无规格 delta,skip_specs)
- 记录根因(Register 的 basePath 只服务文档)、影响面(7 条根级残留、15 条同层抢占)、修复方式与验证方式
- AUG26-017 全局健康门禁实际通过后勾选 5.7(tasks 27/27)
2026-09-11 15:38:05 +08:00
7891189712 feat(代理自充): AUG26-017 代理自充收款方式与线下预存款审批字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
- 受控配置新增代理在线自充允许范围(仅微信/仅支付宝/同时支持),读侧与创建侧取允许范围与可用商户池交集,两侧失败关闭
- 新增允许范围查询与修改端点,读限代理与平台账号、写限超级管理员,复用受控配置写服务留痕
- tb_agent_recharge_record 新增交易流水号、线下收款方式三列快照与其他凭证列(成对迁移 000213)
- 线下申请校验启用的收款方式字典项与必填交易流水号,交易流水号独立于在线渠道交易号、不参与去重
- 扩展 offline_recharge_approval 场景可映射字段白名单与字典引用保护
- 新增付款凭证识别能力与交易流水号预填接口,识别不落库、日志不记录载荷
2026-09-11 15:21:23 +08:00
e687a266e6 补齐员工代收款账单能力证据链与可达操作索引 2026-09-11 15:20:21 +08:00
ff1362df3f fix(员工代收款): 修正路由前缀注册方式,消除 /api/admin 根级 /:id 抢占
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m19s
Register 的 basePath 仅用于生成 OpenAPI 文档,不参与路由注册;员工代收款
三个注册函数把资源前缀传给了 basePath、path 只写相对段,导致
GET /api/admin/:id 与 POST /api/admin/:id/close 落在 /api/admin 根上。
账单单段路径被当作路径 ID 解析返回“无效的路径ID”,并抢占其后注册的
同层单段 GET(/api/admin/refunds、/system-configs 等)。

改为 router.Group(前缀) 注册,与仓库既有写法一致;路由布局与文档路径不变。
2026-09-11 14:40:23 +08:00
9c3e3fe32b 归档员工代收款账单闭环变更并同步主规格
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m36s
- 新增主规格 openspec/specs/employee-collection-bill/spec.md(5 条 Requirement、22 个 Scenario)
- 变更目录归档至 openspec/changes/archive/2026-09-11-add-employee-collection-bills
2026-09-11 09:45:12 +08:00
fe07df0b3e docs(企业微信审批): 场景业务类型枚举补充员工代收款核销审批
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m41s
- wecom/scenes/{business_type} 与 scenes/{business_type}/fields 的 business_type
  路径参数描述补齐第三个业务类型,避免管理员按文档无法配置核销审批场景
- 同步补机读 enum 标签,与既有可枚举参数约定一致

OpenSpec Change: add-employee-collection-bills
2026-09-11 09:24:29 +08:00
69b37eb89b docs(员工代收款): 补充审批中通过后撤销兜底语义并清理死参数
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
- spec.md 增补「审批中收到通过后撤销」场景与规范条文:释放该次尝试全部审批中预占、转异常终态并记录原因、保留审计、禁止自动重提
- design.md「企业微信审批结果消费」补充审批中命中该决策的兜底处理与理由(避免申请永久停在审批中且预占永久占用账单)
- query/employeecollection 删除 approvalStatusOfAttempts 恒为 true 的 withOpinion 形参、修正失真注释,行为不变

OpenSpec Change: add-employee-collection-bills
2026-09-10 18:45:43 +08:00
ce24d5612e feat(员工代收款): 新增员工代收款账单闭环
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
- 新增 6 张表与成对迁移 000212,扩展企业微信审批场景业务类型白名单
- 后台线下套餐订单与两条代理线下充值入账路径在来源成功事务内建账,来源唯一键幂等
- 核销申请、审批尝试记录、账单分摊预占与驳回重提,审批业务类型 employee_collection_approval
- 企业微信终态消费幂等:通过转已核销、驳回释放预占、通过后撤销不回滚并转异常终态
- 退款成功事务内按 bill_id+refund_id 幂等冲销账单或仅写退款关联提示
- 线下收款方式字典、账单查询/统计/关闭、申请查询与代办权限,均写入事务内审计

OpenSpec Change: add-employee-collection-bills
2026-09-10 18:24:05 +08:00
dc4e0d4103 归档支付商户池变更并同步主规格
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m25s
2026-09-10 12:03:28 +08:00
1e776da292 补齐上下文健康检查证据链
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m27s
2026-09-10 11:39:56 +08:00
b9e8592cc4 修复换货迁移后 after_order 实名门槛误判
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m33s
换货迁移只迁移套餐权益不迁移订单,已支付订单仍挂在旧资产上,
导致新资产 HasValidRechargeOrPaidOrder 查不到支付事实,返回 1189。
现增加套餐权益兜底:当前世代存在未退款套餐权益且其关联订单已支付,
即视为满足充值/购买条件。
2026-09-10 11:26:07 +08:00
54823290c3 完成支付购包自动复机变更任务 2026-09-10 11:26:07 +08:00
b38b2b39c9 修复支付购包后自动复机 2026-09-10 11:26:07 +08:00
6f8db180fb 补齐支付商户池字段备注
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m41s
2026-09-10 11:03:20 +08:00
a9e2302f7c 1 2026-09-10 10:53:07 +08:00
bcb1304937 修复支付商户路由权限泄漏
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m8s
registerPaymentMerchantRoutes 使用空前缀 Group 承载权限中间件,
Fiber 将其等价于在整个 /api/admin 上 Use,导致代理账号访问
/api/admin/agent-recharges 等无关路由被误拦。

改为 requirePaymentMerchantAccess 包装函数,仅包装 payment-merchants、
payment-merchant-pools、wechat-authorizations 共 13 个管理路由,
路径、方法与权限语义不变。
2026-09-10 10:00:53 +08:00
88d7965641 固定测试镜像迁移工具版本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
2026-09-09 18:34:23 +08:00
e09c4632fb 修正测试环境按提交镜像部署
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 6m5s
Gitea workflow 只以 github.sha 构建/推送镜像标签,部署时写入
IMAGE_TAG 到 .env;compose 的 api/worker image 引用 IMAGE_TAG,
默认 latest 仅作本地兜底,测试部署必须解析为提交 SHA。
2026-09-09 18:18:13 +08:00
98c145fe70 实现支付商户池与微信授权配置
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s
新增收款商户、商户池轮询、微信授权配置独立管理;三类新支付
(C端套餐购买、C端资产钱包充值、代理在线预存款充值)无条件
经商户池选择并冻结路由,无旧综合配置回退。merchant_id 为空
历史支付继续按 payment_config_id 双读。凭证版本化加载与
ID+版本缓存保证轮换一致性。删除商户池新支付创建开关及全部
引用。
2026-09-09 18:13:04 +08:00
ff25586dc9 修复已用完套餐展示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m43s
2026-09-08 17:25:34 +08:00
a48ff5d782 切换测试环境部署至八月迭代分支
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m36s
2026-09-07 17:32:19 +08:00
696120ab38 feat: 资产套餐历史增加主子层级查询 2026-09-07 17:17:15 +08:00
275 changed files with 26183 additions and 2401 deletions

View File

@@ -3,7 +3,7 @@ name: 构建并部署到测试环境(无 SSH
on:
push:
branches:
- main
- Iteration/8-11
env:
REGISTRY: registry.boss160.cn
@@ -28,7 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
echo "tag=latest" >> $GITHUB_OUTPUT
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: 登录 Docker Registry
run: |
@@ -37,28 +37,25 @@ jobs:
- name: 构建 API 镜像
run: |
docker build -f Dockerfile.api -t ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} .
docker tag ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.API_IMAGE }}:${{ github.sha }}
- name: 构建 Worker 镜像
run: |
docker build -f Dockerfile.worker -t ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} .
docker tag ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.WORKER_IMAGE }}:${{ github.sha }}
- name: 推送镜像到 Registry
run: |
docker push ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.API_IMAGE }}:${{ github.sha }}
docker push ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.WORKER_IMAGE }}:${{ github.sha }}
- name: 部署到测试环境(仅 main 分支)
if: github.ref == 'refs/heads/main'
- name: 部署到测试环境(仅八月迭代分支)
if: github.ref == 'refs/heads/Iteration/8-11'
run: |
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
mkdir -p ${{ env.DEPLOY_DIR }}/logs
umask 077
{
printf 'IMAGE_TAG=%s\n' '${{ github.sha }}'
printf 'JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true\n'
printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n'
printf 'JUNHONG_WORKER_ROLE=all\n'

View File

@@ -84,6 +84,7 @@ openspec validate --all
```
启动、隔离数据库重置、smoke 与日志读取见 [`README.md`](README.md)。迁移使用 `scripts/migrate.sh` 和显式 `DB_*` 参数;生产迁移仅按生产运行说明由维护者手工执行。
- 测试验证涉及迁移、Redis、部署或集成 Smoke 时MUST 读取 [`docs/engineering/工程约束.md`](docs/engineering/工程约束.md) 的 ENG-TEST-001维护者指定的测试环境是唯一验证面。
## 渐进披露

View File

@@ -1,32 +0,0 @@
# 新卡管系统
新卡管系统保存物联网卡、设备、套餐、订单、钱包、分佣、审批与运营协作的本地业务事实,并协调外部支付、运营商和企业微信能力。
## 资金与审批
**员工代收款账单**:员工代客户完成套餐购买或充值等业务后生成的待核销记录,表示该员工经办业务形成的暂挂欠款;可由已匹配支付记录的客户付款凭证核销。
_Avoid_: 员工账单、客户应收款、销账单
**核销**:公司对员工代收款账单及已匹配支付记录的客户付款凭证作出的确认,使对应暂挂欠款减少或结清的业务决定。
_Avoid_: 客户付款、订单支付
**核销申请**:员工针对一笔外部付款提交的、包含凭证和一至多条账单分摊明细的审批业务单;一张申请对应一个企业微信审批实例。
_Avoid_: 单张账单审批、付款截图
**核销分摊明细**:核销申请对一张员工代收款账单确认的本次核销金额。
_Avoid_: 账单金额、付款金额
**账单核销状态**:员工代收款账单的结算状态,取待核销、部分核销、已核销或已关闭;与核销申请的审批状态相互独立。
_Avoid_: 审批状态、企业微信状态
**审批实例**:本地保存、唯一关联一笔业务单的审批生命周期事实;企业微信是该实例的审批渠道,而非本地业务事实的替代。
_Avoid_: 企业微信审批单
**交易流水号**支付或退款渠道为一笔交易生成的外部标识OCR 识别结果只能预填该字段,须由业务人员最终确认。
_Avoid_: OCR 结果、系统订单号
**线下收款方式**:业务字典中供核销等线下付款场景选择的收款路径标识,例如某个指定微信或银行卡;它不等同于线上支付渠道枚举。
_Avoid_: 支付方式枚举、固定收款人名单、收款账户目录
**业务字典**:由研发固定注册的业务分类及其由业务维护的字典项,用于稳定的业务选项;业务人员不能自行创建字典分类。
_Avoid_: 系统配置、任意自定义字段平台

View File

@@ -32,7 +32,9 @@ RUN GOOS=linux GOARCH=amd64 go build \
./cmd/api
# 下载 golang-migrate 工具(使用 GOPROXY 加速)
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# 固定 v4.19.1:其 go.mod 仅要求 go 1.24.0,与基础镜像 Go 1.25.6 兼容;
# @latest如 v4.20.1)要求 Go >= 1.25.11,在 GOTOOLCHAIN=local 的 1.25.6 环境下构建失败
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
# ================================
# 阶段 2: 运行阶段

View File

@@ -719,6 +719,8 @@ KeyAuthToken 缺失
| `jwt.secret_key` | `JUNHONG_JWT_SECRET_KEY` |
| `logging.level` | `JUNHONG_LOGGING_LEVEL` |
三类新线上支付C 端套餐购买、C 端资产钱包充值、代理在线预存款充值)始终从对应启用商户池选择并冻结商户路由;商户池、成员缺失或停用时明确返回“暂无可用商户”,绝不回退旧综合支付配置。`merchant_id` 为空只代表留存期内的历史支付,其回调、查单和既有退款路径仍按 `payment_config_id` 双读,直到独立 Change 删除旧读取路径。
### 必填配置
以下配置项必须通过环境变量设置(无默认值或需要覆盖):

View File

@@ -29,6 +29,9 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(nil, nil, nil)
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)

View File

@@ -38,6 +38,9 @@ func generateAdminDocs(outputPath string) error {
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(nil, nil, nil)
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)

View File

@@ -18,7 +18,11 @@ import (
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
@@ -82,6 +86,9 @@ type workerRuntime struct {
pollingIotCardStore *postgres.IotCardStore
pollingBase *task.PollingBase
lifecycleSvc *polling.PollingLifecycleService
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
refundChannelService *refundchannelApp.Service
}
func main() {
@@ -153,6 +160,7 @@ func runWorker(cfg *config.Config) {
taskHandler.RegisterHandlers()
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
@@ -376,7 +384,23 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
)
refundService.SetNotificationOutbox(outbox.NewRepository())
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient))
refundService.SetLifecycleAudit(auditWriter)
// 员工代收款退款冲销与建账共用同一审计 Writer接入点仅在企微退款成功事务内。
refundService.SetEmployeeCollectionRefundOffset(
employeecollectionApp.NewRefundOffsetService(auditWriter),
)
refundChannelService := refundchannelApp.NewService(
runtime.db,
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(runtime.redisClient), appLogger),
auditWriter,
).SetLogger(appLogger).SetCompletionNotifier(refundService)
refundService.SetChannelRefundService(refundChannelService)
runtime.refundChannelService = refundChannelService
if err := runtime.outboxConsumers.Register(refundchannelApp.EventRefundChannelRefund, refundchannelApp.NewConsumer(refundChannelService)); err != nil {
appLogger.Fatal("注册渠道原路退款 Outbox 消费者失败", zap.Error(err))
}
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
}
@@ -390,8 +414,23 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
decisionDispatcher := approvalApp.NewDecisionDispatcher(
approvalInfra.NewDecisionDeliveryStore(runtime.db),
map[string]approvalApp.BusinessDecisionHandler{
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit),
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(
runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit,
employeecollectionApp.NewBillCreationService(auditWriter),
),
constants.ApprovalBusinessTypeRefund: refundService,
constants.ApprovalBusinessTypeEmployeeCollection: employeecollectionApp.NewApprovalDecisionHandler(
runtime.db, auditWriter,
),
constants.ApprovalBusinessTypeAgentDistribution: distributionwithdrawalApp.NewDistributionApprovalHandler(
runtime.db, auditWriter, shopInfra.NewSubordinateCache(runtime.redisClient),
),
constants.ApprovalBusinessTypeWithdrawalQualification: distributionwithdrawalApp.NewQualificationApprovalHandler(
runtime.db, auditWriter,
),
constants.ApprovalBusinessTypeCommissionWithdrawal: distributionwithdrawalApp.NewWithdrawalApprovalHandler(
runtime.db, auditWriter,
),
},
owner,
appLogger,
@@ -448,6 +487,7 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
)
recovery := agentrechargeApp.NewRecoverOnlinePaymentService(
runtime.db,
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
paymentInfra.NewFuiouScanAdapter(integration, appLogger),
@@ -459,6 +499,18 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
appLogger.Info("注册代理在线充值支付恢复任务处理器", zap.String("task_type", constants.TaskTypeAgentRechargeRecovery))
}
// registerRefundChannelRecoveryTask 注册渠道原路退款结果恢复任务。
// 该任务只查询渠道并回填结果,绝不重复发起资金动作。
// 必须复用执行路径的同一用例实例:恢复确认的成功同样需要补写退款完成通知。
func registerRefundChannelRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.refundChannelService == nil {
appLogger.Fatal("渠道原路退款用例未配置")
}
handler := paymentInfra.NewRefundChannelRecoveryTaskHandler(runtime.refundChannelService)
mux.HandleFunc(constants.TaskTypeRefundChannelRecovery, handler.Handle)
appLogger.Info("注册渠道原路退款结果恢复任务处理器", zap.String("task_type", constants.TaskTypeRefundChannelRecovery))
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
@@ -723,6 +775,16 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册代理在线充值支付恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeRefundChannelRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundChannelRecovery)),
)); err != nil {
return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeOrderExpire,
nil,

View File

@@ -29,7 +29,7 @@
services:
api:
image: registry.boss160.cn/junhong/cmp-fiber-api:latest
image: registry.boss160.cn/junhong/cmp-fiber-api:${IMAGE_TAG:-latest}
container_name: junhong-cmp-api
restart: unless-stopped
ports:
@@ -102,7 +102,7 @@ services:
max-file: "3"
worker:
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
image: registry.boss160.cn/junhong/cmp-fiber-worker:${IMAGE_TAG:-latest}
container_name: junhong-cmp-worker
restart: unless-stopped
environment:

View File

@@ -74,6 +74,28 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
### 商户池支付路由发布与回滚
本节是维护者操作清单不是已执行证据。迁移、生产发布、Redis 操作和富友真实渠道核验均由维护者执行;本轮未执行,不能以本地构建替代。
**前置条件**
1. 留存维护者指定测试环境或本地验证证据,且不得记录密钥或完整报文中的敏感凭证。富友仅沿用现有实现;未进行外部渠道实测不构成开发、测试部署、任务完成、归档或发布前置。
2. 确认本次商户池 Schema 迁移已完成可恢复备份及校验;停止服务后确认迁移锁影响、无长事务和可接受维护窗口。
3. 上传支持 `merchant_id`/`payment_config_id` 双读的 API 与 Worker 二进制。商户池新支付没有运行时开关。
**发布后检查**
1. 由维护者执行迁移并部署双读二进制。C 端套餐购买、C 端资产钱包充值、代理在线预存款充值的后续新支付立即经启用商户池创建并冻结 `merchant_id`、商户池与 `routing_epoch`
2. 无可用商户池、成员缺失或池停用必须稳定失败,不得回退旧综合支付配置或自动换商户;后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池。
3. 检查首次成功唯一累计,以及回调/查单/退款 A 对 `merchant_id` 新单和 `payment_config_id` 历史单的双读分流应用、审计和集成日志不得包含凭证、私钥、Token、证书或完整敏感配置。
**回滚与记录**
1. 不存在关闭商户池新支付创建的运行时开关。故障只能在仍支持双读的二进制上前向修复,不得恢复旧综合支付配置创建。
2. 只要存在 `merchant_id` 非空支付、成功累计事实或新商户池配置,禁止部署不识别新路由的旧二进制,也禁止执行破坏这些事实的 down 迁移。
3. 维护者记录二进制版本、时间、目标 PostgreSQL/Redis 的脱敏标识、备份校验、验证结果与全部阻塞原因。
### 零金额退款发布后核验
发布本次退款审批变更后,维护者应先等待既有重试处理稳定事件 `approval:26:approved`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:

View File

@@ -300,3 +300,16 @@
- **Owner**:基础设施负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增配置或依赖升级
## ENG-TEST-001
- **状态**:生效
- **适用范围**Agent 执行的迁移、Redis、API/Worker、部署与集成 Smoke 验证。
- **规则**:维护者指定的测试 PostgreSQL `junhong_cmp_test`、Redis DB 6、`Iteration/8-11` 测试部署和 `cmp-test` 日志主机构成唯一测试验证面MUST 使用该环境不额外要求独立数据库、Redis DB 或 namespace。迁移从本地工作区以明确 `DB_*` 参数执行;测试 fixture 仅可创建、删除当前 Change 自己的记录MUST NOT 重置整个测试库。测试部署通过 Gitea 工作流完成SSH 仅用于日志、容器状态与受控 Smoke。
- **理由**:同一可控测试面避免每个 Change 重复索取环境,且保留可复现的迁移、缓存、并发和部署证据。
- **最小正例**:当前 Change 在 `junhong_cmp_test` 执行迁移 up/down/up清理自己的 fixture并在测试部署后读取 `cmp-test` 日志。
- **最小反例**:以未提供额外“隔离环境”为由暂停,或重置整个测试数据库。
- **机械检查/人工原因**记录显式目标、迁移命令、fixture 清理范围、Redis 操作与测试部署 SHA通过 API/Worker 日志和可观察状态核对。
- **例外条件**:真实支付渠道和外部审批系统不作自动验证;第三方协议变更以契约文档和维护者提供的证据为准。生产仍按生产运行说明由维护者执行。
- **Owner**:基础设施负责人
- **最后验证日期**2026-09-08
- **更新触发条件**测试库、Redis DB、部署分支、测试主机或验证授权变化

View File

@@ -9,20 +9,37 @@
## 当前实际使用范围
系统使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);未发现退款、撤销或查单能力
系统使用微信预下单 `POST <ApiURL>/wxPreCreate`、主扫统一下单 `POST <ApiURL>/preCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序),主扫下单的订单类型为 `WECHAT``ALIPAY`;本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过
## 原路退款
退款申请 `POST <ApiURL>/commonRefund`,必填 `version``ins_cd``mchnt_cd``term_id``mchnt_order_no``random_str``sign``order_type``refund_order_no``total_amt``refund_amt`;选填 `operator_id``reserved_fy_term_id``reserved_origi_dt``reserved_addn_inf``reserved_refund_desc`。响应 `result_code=000000` 表示渠道受理成功,此时取 `refund_id`(富友退款流水号)、`transaction_id``reserved_refund_amt`(退款金额,分)、`reserved_fy_settle_dt`(清算日期)。`reserved` 开头字段随报文发出但不参与签名。
退款查询 `POST <ApiURL>/refundQuery`,入参为 `refund_order_no`;响应 `trans_stat` 取值为 `SUCCESS`(退款成功)或 `PAYERROR`(退款失败),未返回该字段表示仍在办理中。
全局约束:`mchnt_order_no``refund_order_no` 均为全局永久唯一,重复提交会被直接拒绝;商户退款单号格式为「机构码(4 位) + 日期(yyyyMMdd) + 随机段(818 位字母数字)」,本系统按该规则生成,三渠道共用同一生成器;接口支持全额退款与多次部分退款。
原交易日期决定可退时限:不传 `reserved_origi_dt` 仅支持 30 天内的原交易,传了可退 360 天内的原交易。本系统始终回传原支付成功时间,因此按 360 天判定可退性,超出该时限的申请在选择退款方式阶段即禁用原路。
退款查询接口只支持查询 3 日内的退款交易。超出该窗口且结果仍未知时,系统保留原路退款处理中状态、标记审批异常并转人工核对,绝不重复发起退款。
## 配置、认证与传输
运行配置包含 API 地址、机构号、商户号、终端号、RSA 私钥、公钥及通知地址。请求先生成 XML再转换为 GBK并对请求参数做双重 URL 编码;请求和响应使用 RSA 签名/验签。除 `reserved` 外的请求字段即使为空也参与 XML 与签名。
关键请求字段包括 `mchnt_order_no``order_amt`(分)、`txn_begin_ts``notify_url``trade_type``sub_openid``sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。
关键支付请求字段包括 `mchnt_order_no``order_amt`(分)、`txn_begin_ts``notify_url``trade_type``sub_openid``sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。
## 幂等、失败与重试
`mchnt_order_no` 是渠道业务幂等键;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。
`mchnt_order_no` 是渠道业务幂等键,退款侧对应 `refund_order_no`;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。
退款调用以冻结在审批尝试记录上的渠道退款请求号作为幂等标识:同一次尝试的渠道重试复用同一请求号,重提会生成新请求号。结果未知时只由查询恢复回填,不得重复发起资金动作。
## 安全与验证
RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go``pkg/fuiou/wxprecreate.go``pkg/fuiou/types.go``internal/handler/callback/payment.go`。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符和重复通知;本次不调用真实渠道
RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go``pkg/fuiou/wxprecreate.go``pkg/fuiou/scan.go``pkg/fuiou/refund.go``pkg/fuiou/types.go``internal/handler/callback/payment.go``internal/infrastructure/payment/fuiou_scan.go``internal/infrastructure/payment/refund_adapter.go`
本文按官方契约记录退款接口,**未做真实渠道实测**:未实测只作记录,不作为阻塞、未完成任务或上线前置;真实渠道可退款性由维护者后续手工验证。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符、重复通知、退款受理与退款查询;本次不调用真实渠道。
端点、编码、签名字段、成功码、退款字段或通知语义变化时更新本文。
端点、编码、签名字段、成功码或通知语义变化时更新本文。

View File

@@ -4,13 +4,27 @@
- OwnerIoT Gateway 适配维护人
- 实现:`internal/gateway/`
- 核验日期2026-08-07
- 证据:`internal/gateway/client.go``crypto.go``card_status.go``flow_card.go``device.go`
- 核验日期2026-09-11
- 证据:`internal/gateway/client.go``crypto.go``card_status.go``flow_card.go``device.go``payment_voucher.go`
## 当前实际使用范围
Gateway 是运营商流量卡、实名、停复机、限速和设备信息的统一封装入口。具体路径、请求字段和响应字段以同目录详细协议与 `internal/gateway/*.go` 的实际调用交集为准;文档中出现但代码未调用的接口不视为系统能力。
付款凭证识别(`POST /ai/ocr/extract-payment`,入参 `image_base64`)由 `internal/gateway/payment_voucher.go` 封装,当前唯一调用方是代理线下预存款申请的「交易流水号表单预填」。该能力只消费响应中的 `order_number`(作为交易流水号预填值);`amount``remark``payment_method``payee``payment_time` 不进入本系统响应、不预填、不落库,识别结果不是资金事实。核验证据:`internal/gateway/payment_voucher.go` 的类型定义只对外暴露支付单号,`internal/application/agentrecharge/payment_voucher_ocr.go` 只返回该字段,接口响应 DTO 仅含 `external_transaction_no`
付款凭证识别刻意不走 `doRequest` / `doRequestWithResponse`:前者在 Info 级别打印加密前完整请求体、后者在 Info 级别打印完整原始响应,会把凭证图片内容与识别原始结果写进日志。该能力改用 `Client.doRequestWithoutPayloadLog`,仅记录路径、耗时与结果字节数摘要;既有能力的请求与日志语义保持不变(`internal/gateway/client.go``executeWithRetry``logPayload` 分支)。
### 付款凭证识别的已知限制
- **长号码可能不完整**:对位数较多的转账单号,该接口可能只返回前若干位,实测存在识别值与凭证图片所示号码不一致的情况(位数少于凭证所示)。连续多次识别同一凭证所得长度与内容稳定,属上游侧确定性截断,而非本系统侧裁剪;预填值**必须**由提交人对照凭证人工核对,系统以人工确认值为准。
- **单号缺失即失败**:响应未给出单号时,本系统按识别失败返回明确失败(`CodeGatewayInvalidResp`,中文提示),不返回空值。
- **字段类型会漂移**:响应 `data``amount` 为 JSON 数值而非字符串。本系统只解码 `order_number`,不声明其余字段,故不受类型漂移影响;新增消费字段前必须重新核对上游类型。
- **解析失败不回显原文**:响应解码失败时只返回固定中文提示,不携带底层解析错误,避免第三方库的错误消息把识别原始结果带进日志与错误上下文。
- **单次识别只接受单个附件键**图片由后端读取对象存储后编码Gateway 凭证不下发前端;识别结果不落库、不构成资金事实。
本条限制的核验方式(可复现、不依赖样本取值):对同一图片凭证**连续三次**调用该识别接口,比较三次返回值的**位数与内容是否一致**——一致说明是上游确定性行为而非随机抖动;再将该位数与凭证图片所示号码的位数(用等长掩码计数,只比位数)对照,得出是否缺位。判定责任方时看本系统的解码路径 `internal/gateway/payment_voucher.go`:它只对返回值做 `strings.TrimSpace`,无截断、无按长度裁剪、无正则截取,因此位数差异只能来自上游。识别结果不落库,复核该接口的返回值需重新发起识别调用,不能从业务表反查。
## 配置、认证与报文
配置键为 `gateway.base_url``gateway.app_id``gateway.app_secret``gateway.timeout`。业务参数先包装为 `{"params": ...}`,使用 AppSecret 做 AES-128-ECB 加密;外层请求含 `appId``data``sign``timestamp`,签名使用 MD5。HTTP 方法统一为 POST内容类型为 `application/json;charset=utf-8`。HTTP 200 且 Gateway `code=200` 才算成功,`data` 再按具体能力解码。

View File

@@ -33,7 +33,7 @@
| AUG26-014 | 导出与统一时间筛选 | PRD-08-014、PRD-08-020 |
| AUG26-015 | 报表管理 | PRD-08-016报表原编号重复 |
| AUG26-016 | 优先轮询通道 | PRD-08-019 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021、PRD-08-013预存款审批字段 |
## 1. 已确认的领域语言

View File

@@ -0,0 +1,114 @@
# AUG26-013 实施与验证记录
## 当前完成范围
- 已完成并在 Change 任务中勾选1.1 至 4.2。3.1 至 4.2 的勾选适用下文“用户授权的完成判定”,不等同于所有运行时场景已经实际通过。
- 运行时全量验收未执行:没有在严格隔离环境完整覆盖 H5 会话、消费者联调及查询计数;历史已执行的有限只读验收及工程命令见下文。
- 未修改 Schema、迁移、套餐状态、金额、退款或外部支付审批流程。
## 已执行的脱敏命令与结果
| 命令 | 结果 |
| --- | --- |
| `gofmt -w internal/query/asset/package_history.go internal/service/asset/service.go internal/handler/app/client_asset.go internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go` | 成功,无输出。 |
| `go build ./cmd/api ./cmd/worker` | 初次因废弃的 `sort` 导入失败,移除后以临时可写 Go 缓存重新执行成功。 |
| `go run cmd/gendocs/main.go` | 成功生成 `docs/admin-openapi.yaml`。 |
| `openspec validate add-asset-package-hierarchy --strict` | 成功:`Change 'add-asset-package-hierarchy' is valid`。 |
| `openspec doctor --json` | 成功root healthy`status: []`。 |
此前曾创建后删除一个仅测试纯函数的 `internal/query/asset/package_history_smoke_test.go` 并运行 `go test`。该行为不符合项目“自动化测试 N/A”的后续执行约束文件已删除结果不作为任务 3.x 的隔离环境入口验收证据。后续不再创建 `*_test.go` 或运行 `go test`
## 本轮局部兼容修复
- 独立审查确认:旧 H5 历史使用的 `AssetPackageResponse` 会无条件序列化零值 `order_id:0`;新的 `ClientAssetPackageHistoryNode` 曾遗漏该可观察字段。
- 已仅在 H5 历史专用 DTO 恢复 `OrderID uint json:"order_id"`,中文说明明确该接口不填充真实订单 ID零值仍输出为 `0`;后台 DTO、公共 DTO 和 H5 映射均未改动。
- 第二项审查结论:上述四个历史数组在生成 OpenAPI 中均误标 `nullable:true`,但运行时契约要求始终返回 `[]`。已仅为 `AssetPackageHistoryNode.Children``ClientAssetPackageHistoryNode.Children``AssetPackagesResult.Items``AssetPackageHistoryResponse.List` 添加项目生成器支持的 `nullable:"false"` tag未扩展生成器行为未手改 YAML。
- 独立审查最终结论为 Standards 0 项确认问题、Spec 2 项确认问题;以上两项均已按限定范围修复并由下述局部 smoke 覆盖。
### 本轮精确局部验证
| 命令 | 可观察结果 |
| --- | --- |
| `gofmt -w internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go && go build ./cmd/api ./cmd/worker && go run cmd/gendocs/main.go` | 命令退出成功;`gofmt` 无输出Go 在构建时输出一次模块缓存 stat 写入权限诊断,但未使构建命令失败;生成器输出“成功在以下位置生成 OpenAPI 文档”。 |
| `go run asset_package_history_contract_smoke.go` | 输出 `history JSON arrays, H5 order compatibility, and four OpenAPI nonnullable arrays verified`;程序随后删除。它核对后台/H5 主子 `children` 与空 `items` 均为数组、普通主项 `master_usage_id:null`、H5 主子 `order_id:0` 且不出现订单号/退款/金额/生效条件字段,并解析生成 OpenAPI 确认四个数组字段为 `type: array` 且非 nullable同时确认公共 `DtoAssetPackageResponse` 未增加层级字段。 |
此为 DTOJSON生成文档的局部契约 smoke不触发真实 API、数据库或消费者联调不替代任务 3.x 或 4.1 的实际验收。
## 真实测试环境只读验收(本轮)
- 用户已明确 `.env.local` 指向测试环境;本轮按该事实执行,未访问生产环境。
- `source .env.local` 仅在子进程内完成且未回显值。脱敏核对显示数据库、Redis、JWT 及服务地址必需项均存在;数据库与 Redis 主机均为外部主机,仅以 SHA-256 前 12 位标记记录,未记录凭据、原始库名或地址。
- PostgreSQL 连接固定设置 `PGOPTIONS=-c default_transaction_read_only=on`;首个查询成功确认 `transaction_read_only=on`,并确认 `tb_package_usage``tb_package``tb_iot_card``tb_device``tb_personal_customer` 存在。未执行任何迁移、DDL、DML、事务写入或外部支付审批调用。
- 最小 API 首次以 README 所示的 `go run cmd/api/main.go` 启动,因 `undefined: generateOpenAPIDocs` 退出;改为 `go run ./cmd/api` 后监听 `127.0.0.1:18181` 成功。启动前只读确认有 4 个启用超级管理员,因此 `initDefaultAdmin` 只会走存在检查与跳过分支。进程的日志仅写入 `/tmp`,验收后已停止。
- 配置 Redis DB `7` 无既有后台或 H5 会话,复用会导致后台历史请求返回 `401/code=1003`。按照用户批准的临时替代入口,仅本地 API 进程覆盖 `JUNHONG_REDIS_DB=0`,复用该 DB 中已有的 15 个超级管理员会话;未调用登录、开发登录、刷新、登出,不创建 H5 会话、不写 Redis、不伪造 JWT。随后后台入口返回 `200/code=0`
### 脱敏 SQL 与 HTTP 结果
| 范围 | 只读 SQLHTTP 摘要 | 实际结果 |
| --- | --- | --- |
| 数据关系盘点 | 对未软删除 `tb_package_usage` 聚合 `master_usage_id`、状态、退款、父记录存在性与软删除商品 | 共 78 条主项、0 条子项;物理缺失主项、软删父项、软删商品、退款子项、待生效子项、失效/过期/用尽子项、非待生效且无生效时间子项均为 0。卡与设备均不存在任何主子关系组。 |
| 后台候选卡 | 对资产标识 SHA-256 前缀 `1f0b3a3a5ee3` 查询 usage`GET /api/admin/assets/<hash>/packages` 使用已有超级管理员 token | SQL 得到 3 条顶层主项,状态分布为生效中 1、失效 2`master_usage_id=NULL``page=1&page_size=100` 返回 `200/code=0``total=3`、3 项、全部 `children=[]`;返回 ID 的哈希序与 SQL `created_at DESC,id DESC` 完全一致。 |
| 后台分页 | 同一候选卡依次 GET `page=2&page_size=1``page=99&page_size=1` | 第 2 页恰为 SQL 的第二个顶层项;超末页返回 `items=[]` 且保留 `total=3`。 |
| 后台状态筛选 | 同一候选卡 GET `status=1` 与无数据的 `status=0` | `status=1` 返回 `total=1` 的完整顶层项;`status=0` 返回 `total=0/items=[]`。 |
| 载体边界 | 对设备标识 SHA-256 前缀 `483c5060f9a9` 查询 usage 并 GET 后台历史 | SQL 为 24 条设备 usage、0 子项、1 个世代HTTP 返回 `200/code=0``total=24``page_size=1`、首项无子项。卡与设备均在各自资产范围内响应,未见跨载体内容。 |
| 当前套餐兼容 | `GET /api/admin/assets/<card-hash>/current-package` | `200/code=0`;实际 JSON 不含 `children``master_usage_id`,未被历史层级 DTO 连带改变。修改套餐接口是写接口,受本轮只读限制未调用。 |
| 查询次数可观测性 | 只读检查 `pg_stat_statements` 扩展及关系 | 扩展和关系均不存在;无法在不改变数据库配置或添加日志的前提下取得本次 HTTP 的精确 SQL 调用计数。 |
### 先前真实数据覆盖结论(当时)
| 任务 | 结论 | 未完成的精确原因 |
| --- | --- | --- |
| 3.1 | 后台已部分验证;当时不勾选 | 实际数据只有多主/无子项,子项总数为 0没有多子项、子项超过页大小、异常独立项。H5 没有既有会话,不能执行该入口的真实 JSON 验收。 |
| 3.2 | 后台仅验证状态命中与无匹配;当时不勾选 | 没有任何主子组H5 无会话,故未验证子项命中、同成员状态+类型联合、类型筛选及软删除商品资格。 |
| 3.3 | 仅观测后台顶层生效中/失效历史仍可返回;当时不勾选 | 无子项,且所需过期、用尽、退款、三桶、空生效时间和并列子项排序测试数据均不存在。 |
| 3.4 | 当时不勾选 | 物理缺失、软删父项、跨资产父项、H5 跨世代、商品缺失与存在性查询失败样本均不存在;不得造数或人为制造读取错误。 |
| 3.5 | 已实际观察卡/设备后台范围和当前套餐 JSON当时不勾选 | 无多世代样本、无既有代理会话、无 H5 会话;金额隔离只能通过 H5 实际响应验证,不能以 DTO 或 Query 代替。修改套餐接口为写接口,未调用。 |
| 3.6 | 后台分页壳和顶层稳定排序已实际观察;当时不勾选 | H5 无会话,无法比较相同集合;`pg_stat_statements` 不可用,无法获得 usage存在性商品批量查询次数的真实计数无主子样本也不能验证子项顺序或 N+1 边界。 |
| 3.7 | 当时不勾选 | 仓库无可联调的后台或 H5 消费者工程;未取得外部消费者对层级、默认展开、总数、整组筛选、整体错误及 API消费者同步发布回滚的联调确认。 |
当时 `tasks.md` 的 3.1—3.7、4.1、4.2 均保持未勾选。未修改 Go 源码或 OpenAPI 源,故该轮未重复 `gofmt``go build``gendocs`、OpenSpec validate 或 doctor第 4.x 的既有记录见上文,不能替代缺失的 H5 与主子实际验收。
### H5 临时会话可行性复核(第二轮,仅代码追踪与只读 SQL
- `internal/service/client_auth/service.go:940-975``DevLogin` 在事务内调用 `findOrCreateCustomer``bindAsset`,不是仅写 Redis 的认证入口。
- 即使 OpenID 已存在,`findOrCreateCustomer` 也会在 `service.go:718-749` 读取客户后无条件执行 `customerStore.Update(ctx, customer)`746 行);昵称、头像为空时不会改变内存字段,但仍不能排除业务表 `UPDATE`。未命中时 799-823 行会创建个人客户和 OpenID 记录。
- `bindAsset` 转至 `customer_binding.Service.Bind``service.go:891-893`);已有有效绑定的 PCD/PCI 分支会在 `customer_binding/service.go:265-288` 或 313-341 行返回而不创建绑定,但这不能消除前述客户 `UPDATE`。未绑定时相应的 271-302 或 319-349 行会创建绑定、首次绑定可修改资产并写审计。
- 只读 SQL 盘点:`tb_personal_customer_openid``app_id='dev_test_app' AND open_id LIKE 'dev_test_%'` 为 0 条、0 个客户;有 usage 的资产中,卡为 7 个6 个已有有效 H5 绑定)、设备为 6 个6 个已有有效 H5 绑定),但这些绑定均不属于确定的 `dev_test` 客户;所有绑定资产的子项数及多 usage 世代数均为 0。
若需继续完成 H5 真实验收,仅接受以下任一前置:
1. 提供与候选资产有效绑定对应的既有、可只读复用的 H5 会话或凭据;会话获取路径不得创建客户、绑定或其他业务记录。
2. 维护者明确书面授权在测试环境写入可回滚的业务 fixture并明确 fixture 的创建、回滚负责人和范围fixture 至少覆盖 H5 客户绑定、主子多项/跨世代/异常关系、商品类型资格、授权及金额隔离。未经该授权不得造数或调用会写业务表的认证入口。
- 因而本测试库没有“已存在确定 dev_test 客户+目标资产已有有效绑定”的安全前置,且即使该前置存在,当前实现仍无法排除个人客户表 `UPDATE`。本轮不调用 `DevLogin`、不请求用户凭据、不伪造 JWTH5 真实 GET 继续保持未验收。
## 已核对的生成文档
`docs/admin-openapi.yaml` 已包含:
- 后台 `GET /api/admin/assets/{identifier}/packages` 的全部世代层级、顶层 total 与关系异常说明;
- H5 `GET /api/c/v1/asset/package-history` 的当前世代、同一成员联合筛选与关系异常说明;
- `DtoAssetPackageHistoryNode``DtoClientAssetPackageHistoryNode``children``expand_by_default``relationship_status``relationship_status_name` 字段;
-`DtoAssetPackageResponse` 和当前套餐/修改套餐路由仍存在。
- `.gitignore` 明确忽略 `docs/admin-openapi.yaml`,且该路径不在 Git 跟踪清单;它是本地生成产物而非提交源文件。
- 项目交付方式是 `go run cmd/gendocs/main.go`(等价 Make 目标 `docs`):生成器将路由注册结果写入该固定路径。本轮已重新生成;`DtoClientAssetPackageHistoryNode.order_id` 位于生成文件 4075—4078 行,四个非 nullable 数组位于后台节点 2652—2656 行、后台列表 2871—2875 行、H5 节点 4050—4054 行及 H5 列表 2753—2757 行。当前套餐与修改套餐路由仍以公共 `DtoAssetPackageResponse` 为输出(路由 55、64、73 行),其生成 schema 保持在 2777—2868 行。
后台 API 的有限真实 JSON 验收已见“真实测试环境只读验收”H5 会话及主子/异常/授权测试数据缺失,运行时全量验收未执行。
## 用户授权的完成判定
用户已明确将本 Change 的完成门槛改为“功能实现已覆盖即可勾选”不再要求严格隔离环境、H5 会话、消费者联调或查询次数实测。本节据此记录 3.1—4.2 的勾选依据;这些勾选**不表示**完整实际环境、H5 消费者联调或发布回滚已经通过,运行时全量验收未执行。
| 任务 | 勾选依据 | 未作出的运行时声明 |
| --- | --- | --- |
| 3.1 | `PackageHistoryQuery.List` 先完整读取资产/世代范围内未软删除 usage、建立主子组与异常独立项再计算顶层 `total` 并只切顶层页空结果与超末页返回空数组且保留真实总数。后台、H5 都接入该 Query。 | 未在完整严格隔离环境以多子项和超页数据实际请求两端。 |
| 3.2 | 同一 `matchesPackageHistoryUsage` 同时判定 `status` 和类型资格;组内任一成员命中即保留完整组。类型资格查询使用默认软删除范围,故已软删除商品不成为 H5 类型命中。 | 未以真实 H5 会话和对应 fixture 复现全部组合。 |
| 3.3 | 三桶排序由 `packageHistoryChildBucket``packageHistoryChildLess` 固定实现:非待生效且有生效时间、非待生效空生效时间、待生效;桶内 ID 升序顶层创建时间ID 降序,映射直接保留原状态、退款及关联字段。 | 未在实际数据中覆盖每种失效/退款及并列排序组合。 |
| 3.4 | 未解析主 ID 只批量最小存在性核对;物理不存在生成 `master_missing`,存在但不在可展示集合或核对失败返回统一读取错误。商品批量读取与 usage 关系判定分离,展示映射保留 usage 名称快照并允许商品缺失回退。 | 未人为制造物理缺失、软删除、跨资产/世代或数据库失败。 |
| 3.5 | Query 仅接受 carddevice 并按对应载体过滤后台不传世代、H5 传资产当前世代且先做有效绑定校验。后台历史 DTO 仅平台填充成本价H5 专用 DTO 未映射订单、退款、金额或生效条件;当前套餐和修改路由继续使用公共 DTO。 | 未完成代理、H5、跨世代和修改写接口的全量实际验收存量企业授权问题未声称修复。 |
| 3.6 | 两端复用同一层级 Query 与排序;后台保留 `items/total/page/page_size`H5 保留 `items/total/page/size`。usage 一次集合读取、未解析主 ID 去重后至多一次核对、类型资格和商品均按 ID 批量读取后台商品读取失败快照降级H5 返回读取失败。 | 未实测 SQL 查询计数,未以同一真实集合比对两端顺序。 |
| 3.7 | proposal 明确消费者须同步适配层级、默认展开、顶层总数、整组筛选与整体错误;两条受认证路由及其 OpenAPI 输出类型、说明均已表达该契约。design 记录 API消费者同步发布与共同回滚为原平铺契约且无 Schema写入数据回滚。 | 未取得真实后台或 H5 消费者联调、同步发布或回滚确认,不宣称展示验收完成。 |
| 4.1 | 本文已有 `gofmt``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go` 成功记录,以及 DTOJSON生成 OpenAPI 的局部 smoke有限后台实际 JSON 和当前套餐兼容观察也已记录。 | 本轮未重跑工程命令H5 实际 JSON 与两端完整运行时兼容未验收。 |
| 4.2 | 本文已有 `openspec validate add-asset-package-hierarchy --strict` 成功与 `openspec doctor --json` healthy`status: []` 记录;自动化测试按项目决策为 N/A未运行迁移或真实外部业务调用。 | 未把此前有限 smoke 表述为完整运行时验收。 |
因此3.1—4.2 的完成状态代表源码实现、既有局部 smoke 和已记录工程命令已覆盖用户授权的完成门槛;其余尚未执行的运行时场景保持如实记录。

View File

@@ -199,7 +199,8 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位"
"order-payment-wallet::金额单位",
"agent-funds-commission::线下预存款审批收款方式、其他凭证与交易流水号"
],
"classification": "behavior"
},
@@ -210,7 +211,8 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位"
"order-payment-wallet::金额单位",
"agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定"
],
"classification": "behavior"
},
@@ -221,7 +223,8 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位"
"order-payment-wallet::金额单位",
"agent-funds-commission::线下预存款审批收款方式、其他凭证与交易流水号"
],
"classification": "behavior"
},
@@ -330,7 +333,12 @@
"capability": "asset-device",
"requirements": [
"asset-device::资产业务状态",
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"package-lifecycle::资产套餐层级投影",
"package-lifecycle::套餐历史整组筛选与分页",
"package-lifecycle::套餐历史稳定排序",
"package-lifecycle::物理缺失与不可展示关系区分",
"package-lifecycle::套餐历史读取范围与兼容边界"
],
"classification": "behavior"
},
@@ -390,7 +398,8 @@
"capability": "operations-audit",
"requirements": [
"identity-access::数据范围拒绝",
"operations-audit::审计时间线"
"operations-audit::审计时间线",
"operations-audit::审计调查留存边界连续"
],
"classification": "behavior"
},
@@ -470,7 +479,8 @@
"capability": "operations-audit",
"requirements": [
"identity-access::数据范围拒绝",
"operations-audit::审计时间线"
"operations-audit::审计时间线",
"operations-audit::审计调查留存边界连续"
],
"classification": "behavior"
},
@@ -1085,7 +1095,9 @@
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::代理退款查询按所属店铺隔离"
"order-refund-exchange::代理退款查询按所属店铺隔离",
"order-refund-exchange::退款终态事实与失败分类",
"order-refund-exchange::退款展示当前退款套餐用量"
],
"classification": "behavior"
},
@@ -1096,7 +1108,9 @@
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::代理退款查询按所属店铺隔离"
"order-refund-exchange::代理退款查询按所属店铺隔离",
"order-refund-exchange::退款终态事实与失败分类",
"order-refund-exchange::退款展示当前退款套餐用量"
],
"classification": "behavior"
},
@@ -1242,7 +1256,8 @@
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::代理钱包与提现状态门禁",
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"agent-distribution-withdrawal::提现冻结与企业微信终审"
],
"classification": "behavior"
},
@@ -1347,7 +1362,8 @@
"entry": "GET /api/c/v1/asset/info",
"capability": "personal-customer",
"requirements": [
"personal-customer::资产归属查询"
"personal-customer::资产归属查询",
"personal-customer::资产信息展示当前可用或已用完主套餐"
],
"classification": "behavior"
},
@@ -1356,7 +1372,12 @@
"entry": "GET /api/c/v1/asset/package-history",
"capability": "personal-customer",
"requirements": [
"personal-customer::资产归属查询"
"personal-customer::资产归属查询",
"package-lifecycle::资产套餐层级投影",
"package-lifecycle::套餐历史整组筛选与分页",
"package-lifecycle::套餐历史稳定排序",
"package-lifecycle::物理缺失与不可展示关系区分",
"package-lifecycle::套餐历史读取范围与兼容边界"
],
"classification": "behavior"
},
@@ -1514,7 +1535,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1524,7 +1546,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1534,7 +1557,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1544,7 +1568,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1751,7 +1776,10 @@
"agent-funds-commission::充值边界",
"external-integration::富友调用超时兼容行为",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位"
"order-payment-wallet::金额单位",
"external-integration::富友主扫统一下单与订单查询",
"merchant-payment-routing::新支付商户快照与历史兼容",
"agent-funds-commission::线下预存款审批收款方式、其他凭证与交易流水号"
],
"classification": "behavior"
},
@@ -1762,7 +1790,8 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位"
"order-payment-wallet::金额单位",
"employee-collection-bill::员工代收款账单来源、金额与建账判据"
],
"classification": "behavior"
},
@@ -1841,7 +1870,8 @@
"entry": "POST /api/admin/commission/withdrawal-requests/{id}/approve",
"capability": "agent-funds-commission",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"agent-distribution-withdrawal::提现本地人工终审边界"
],
"classification": "behavior"
},
@@ -1850,7 +1880,8 @@
"entry": "POST /api/admin/commission/withdrawal-requests/{id}/reject",
"capability": "agent-funds-commission",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"agent-distribution-withdrawal::提现本地人工终审边界"
],
"classification": "behavior"
},
@@ -2168,7 +2199,8 @@
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁",
"package-lifecycle::套餐使用记录价格快照"
"package-lifecycle::套餐使用记录价格快照",
"employee-collection-bill::员工代收款账单来源、金额与建账判据"
],
"classification": "behavior"
},
@@ -2294,7 +2326,8 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::退款实收金额与方式矩阵"
],
"classification": "behavior"
},
@@ -2304,7 +2337,10 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"refund-approval::零金额退款审批通过",
"refund-approval::零金额退款不产生资金回款",
"refund-approval::非法退款金额仍被拒绝"
],
"classification": "behavior"
},
@@ -2324,7 +2360,8 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::企业微信唯一终审与审批尝试重提"
],
"classification": "behavior"
},
@@ -2407,7 +2444,9 @@
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::代理钱包与提现状态门禁",
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"agent-distribution-withdrawal::提现资料资格",
"agent-distribution-withdrawal::提现冻结与企业微信终审"
],
"classification": "behavior"
},
@@ -2675,7 +2714,8 @@
"capability": "order-payment-wallet",
"requirements": [
"external-integration::富友调用超时兼容行为",
"order-payment-wallet::钱包并发"
"order-payment-wallet::钱包并发",
"merchant-payment-routing::新支付商户快照与历史兼容"
],
"classification": "behavior"
},
@@ -2687,7 +2727,8 @@
"agent-funds-commission::充值边界",
"external-integration::富友调用超时兼容行为",
"order-payment-wallet::金额单位",
"order-payment-wallet::钱包并发"
"order-payment-wallet::钱包并发",
"merchant-payment-routing::新支付商户快照与历史兼容"
],
"classification": "behavior"
},
@@ -2759,7 +2800,10 @@
"entry": "POST /api/callback/wecom/approval/{application_id}",
"capability": "external-integration",
"requirements": [
"external-integration::企微回调与补偿"
"external-integration::企微回调与补偿",
"employee-collection-bill::核销申请审批、重提与幂等",
"order-refund-exchange::企业微信唯一终审与审批尝试重提",
"order-refund-exchange::退款权益与订单状态时点"
],
"classification": "behavior"
},
@@ -3213,14 +3257,20 @@
"entry_type": "async",
"entry": "constants.OutboxEventTypeApprovalSubmissionRequested",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"employee-collection-bill::核销申请审批、重提与幂等"
],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.OutboxEventTypeApprovalTerminalDecision",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"employee-collection-bill::员工代收款账单来源、金额与建账判据",
"employee-collection-bill::核销申请审批、重提与幂等",
"employee-collection-bill::收款方式字典、退款联动与可追溯性"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3283,14 +3333,9 @@
"entry_type": "async",
"entry": "constants.TaskTypeAuditDailyArchive",
"capability": "infrastructure",
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAuditMonthlyRetention",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"operations-audit::审计归档与日留存受控执行"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3319,7 +3364,8 @@
"agent-funds-commission::退款后处理可补偿",
"order-commission-delivery::佣金计算重复投递幂等",
"order-commission-delivery::已支付订单佣金计算可靠投递",
"order-commission-delivery::待计算订单可补偿"
"order-commission-delivery::待计算订单可补偿",
"agent-distribution-withdrawal::待审提现与佣金回溯的释放接缝"
],
"classification": "route_index_or_infrastructure"
},
@@ -3399,14 +3445,10 @@
"entry_type": "async",
"entry": "constants.TaskTypeIntegrationDailyArchive",
"capability": "infrastructure",
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeIntegrationMonthlyFinalize",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"external-integration::外部交互日志归档与留存受控执行",
"external-integration::外部交互日志逐日物理留存"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3466,7 +3508,11 @@
"entry_type": "async",
"entry": "constants.TaskTypePackageQueueActivation",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"package-queue-activation::当前主套餐过期后自动激活下一个",
"package-queue-activation::孤儿待生效套餐必须公平恢复",
"package-queue-activation::Asynq 激活结果必须准确且可重试"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3474,7 +3520,12 @@
"entry": "constants.TaskTypePollingCardStatus",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
],
"classification": "behavior"
},
@@ -3483,7 +3534,12 @@
"entry": "constants.TaskTypePollingCarddata",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
],
"classification": "behavior"
},
@@ -3492,7 +3548,9 @@
"entry": "constants.TaskTypePollingPackage",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"polling-load-control::轮询全局并发与背压",
"polling-operations::停复机遵循实际生效实名策略"
],
"classification": "behavior"
},
@@ -3501,7 +3559,8 @@
"entry": "constants.TaskTypePollingProtect",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"polling-load-control::轮询全局并发与背压"
],
"classification": "behavior"
},
@@ -3510,7 +3569,12 @@
"entry": "constants.TaskTypePollingRealname",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
],
"classification": "behavior"
},
@@ -3520,7 +3584,8 @@
"capability": "external-integration",
"requirements": [
"external-integration::企微回调与补偿",
"external-integration::外部调用重试边界"
"external-integration::外部调用重试边界",
"employee-collection-bill::核销申请审批、重提与幂等"
],
"classification": "behavior"
},
@@ -3529,7 +3594,8 @@
"entry": "constants.TaskTypeWeComApprovalSync",
"capability": "external-integration",
"requirements": [
"external-integration::企微审批状态"
"external-integration::企微审批状态",
"employee-collection-bill::核销申请审批、重提与幂等"
],
"classification": "behavior"
},
@@ -3560,5 +3626,378 @@
"order-refund-exchange::历史待审批退款可主动接入企业微信审批"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAuditDailyRetention",
"capability": "operations-audit",
"requirements": [
"external-integration::外部交互日志归档与留存受控执行",
"external-integration::外部交互日志逐日物理留存",
"operations-audit::审计归档与日留存受控执行",
"operations-audit::审计在线数据逐日物理留存",
"operations-audit::日留存独立运行日志"
],
"classification": "behavior"
},
{
"entry_type": "cli",
"entry": "scripts/migration/migrate_runtime.py",
"capability": "qicheng-migration-package-lifecycle-overrides",
"requirements": [
"qicheng-migration-package-lifecycle-overrides::迁移配置支持逐卡套餐生命周期覆盖",
"qicheng-migration-package-lifecycle-overrides::覆盖必须完整且可验证",
"qicheng-migration-package-lifecycle-overrides::未覆盖资产维持严格冲突阻断",
"qicheng-migration-package-usage::迁移套餐使用记录保存计时条款快照",
"qicheng-migration-package-usage::迁移套餐使用 SQL 满足快照完整性约束"
],
"classification": "behavior"
},
{
"entry_type": "cli",
"entry": "scripts/migration/import_shops.py",
"capability": "shop-bulk-import",
"requirements": [
"shop-bulk-import::导入输入必须在生成 SQL 前完成全量预检",
"shop-bulk-import::生成的 SQL 必须在写入前校验目标库状态",
"shop-bulk-import::批量导入 SQL 必须创建完整的店铺初始事实",
"shop-bulk-import::导入执行必须由维护者审核后以单事务手工确认",
"shop-bulk-import::导入结果必须可审核和重跑定位"
],
"classification": "behavior"
},
{
"entry_type": "cli",
"entry": "scripts/context-health.sh",
"capability": "system-operations",
"requirements": [
"system-operations::上下文健康检查证据链完整"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchants",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchants",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchants/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/payment-merchants/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "DELETE /api/admin/payment-merchants/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/wechat-authorizations",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/wechat-authorizations/current",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchant-pools",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchant-pools",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchant-pools/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/payment-merchant-pools/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchant-pools/{id}/enable",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchant-pools/{id}/disable",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-payment-methods",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/employee-collection-payment-methods",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/employee-collection-payment-methods/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性",
"agent-funds-commission::线下收款方式字典对代理充值引用的可见性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "DELETE /api/admin/employee-collection-payment-methods/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性",
"agent-funds-commission::线下收款方式字典对代理充值引用的可见性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-bills",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-bills/statistics",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-bills/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/employee-collection-bills/{id}/close",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/employee-collection-applications",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::外部付款核销申请与分摊",
"employee-collection-bill::核销申请审批、重提与幂等"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-applications",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::外部付款核销申请与分摊",
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-applications/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::外部付款核销申请与分摊",
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/employee-collection-applications/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::核销申请审批、重提与幂等"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/agent-self-recharge-payment-methods",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定",
"agent-funds-commission::代理自充允许方式配置可审计"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/agent-self-recharge-payment-methods",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::代理自充允许方式配置可审计"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/agent-recharges/payment-voucher-ocr",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::付款凭证识别仅作交易流水号预填"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeRefundChannelRecovery",
"capability": "order-refund-exchange",
"requirements": [
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::代理退款查询按所属店铺隔离",
"order-refund-exchange::原路退款渠道能力与执行",
"order-refund-exchange::退款终态事实与失败分类"
],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "http",
"entry": "POST /api/c/v1/agent-distribution-registrations",
"capability": "agent-distribution-withdrawal",
"requirements": [
"agent-distribution-withdrawal::分销码与待审批代理注册"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/shops/{shop_id}/withdrawal-qualifications",
"capability": "agent-distribution-withdrawal",
"requirements": [
"agent-distribution-withdrawal::提现资料资格"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/shops/{shop_id}/withdrawal-qualifications",
"capability": "agent-distribution-withdrawal",
"requirements": [
"agent-distribution-withdrawal::提现资料资格"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/withdrawal-qualifications/{id}/void",
"capability": "agent-distribution-withdrawal",
"requirements": [
"agent-distribution-withdrawal::提现资料资格"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}",
"capability": "agent-distribution-withdrawal",
"requirements": [
"agent-distribution-withdrawal::提现冻结与企业微信终审",
"agent-distribution-withdrawal::待审提现与佣金回溯的释放接缝"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/shops/{shop_id}/withdrawal-requests/{id}",
"capability": "agent-distribution-withdrawal",
"requirements": [
"agent-distribution-withdrawal::提现冻结与企业微信终审"
],
"classification": "behavior"
}
]

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
@@ -20,11 +21,17 @@ type ApprovalDecisionHandler struct {
db *gorm.DB
posting *walletapp.PostingService
audit RechargeAuditWriter
billCreation *employeecollectionapp.BillCreationService
}
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit}
func NewApprovalDecisionHandler(
db *gorm.DB,
posting *walletapp.PostingService,
audit RechargeAuditWriter,
billCreation *employeecollectionapp.BillCreationService,
) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit, billCreation: billCreation}
}
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
@@ -32,6 +39,9 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.
if h == nil || h.db == nil || h.posting == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
}
if h.billCreation == nil {
return errors.New(errors.CodeInternalError, "员工代收款建账能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
}
@@ -99,6 +109,10 @@ func (h *ApprovalDecisionHandler) applyApproved(
if err != nil {
return err
}
// 员工代收款建账:锚点为“平台账号发起的线下充值入账成功”,按来源唯一键 recharge:{id} 幂等。
if _, err := h.billCreation.CreateFromRechargeInTx(ctx, tx, record); err != nil {
return err
}
if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied {
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -81,7 +82,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity)
command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo)
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
if command.PaymentNo == "" || command.PaidAt.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
@@ -91,6 +92,9 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
if err != nil {
return err
}
if payment.MerchantID == nil && command.ConfigID == 0 {
return errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
if err != nil {
return err
@@ -114,6 +118,10 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
if paymentUpdate.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
}
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, paidAt); err != nil {
return err
}
rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}).
Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt})
@@ -127,7 +135,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed",
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo,
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaymentMethod: payment.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
ParentEventID: command.ParentEventID,
}
@@ -186,6 +194,8 @@ func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRech
OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel,
PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID,
FrozenMerchant: payment.MerchantID != nil, FrozenMerchantPaymentMethod: payment.MerchantPaymentMethodSnapshot,
FrozenMerchantProviderType: payment.MerchantProviderTypeSnapshot,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount,
PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status),

View File

@@ -24,6 +24,11 @@ type CreateOfflineCommand struct {
RechargeNo string
Amount int64
PaymentVoucherKeys []string
OtherVoucherKeys []string
// OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。
OfflinePaymentMethodID uint
// ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。
ExternalTransactionNo string
Remark string
}
@@ -78,8 +83,20 @@ func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID
SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID,
RechargeNo: record.RechargeNo, Amount: record.Amount,
PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark,
OtherVoucherKeys: []string(record.OtherVoucherKeys),
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if record.ExternalTransactionNo != nil {
command.ExternalTransactionNo = *record.ExternalTransactionNo
}
// 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。
var frozenCode, frozenName string
if record.OfflinePaymentMethodCode != nil {
frozenCode = *record.OfflinePaymentMethodCode
}
if record.OfflinePaymentMethodName != nil {
frozenName = *record.OfflinePaymentMethodName
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName)
if err != nil {
return nil, err
}
@@ -154,20 +171,31 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
paymentChannel := constants.RechargeMethodOffline
record := &model.AgentRechargeRecord{
externalTransactionNo := strings.TrimSpace(command.ExternalTransactionNo)
var record *model.AgentRechargeRecord
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentMethod, err := loadEnabledOfflinePaymentMethod(ctx, tx, command.OfflinePaymentMethodID)
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, paymentMethod.Code, paymentMethod.Name)
if err != nil {
return err
}
record = &model.AgentRechargeRecord{
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
ExternalTransactionNo: &externalTransactionNo,
OfflinePaymentMethodID: &paymentMethod.ID,
OfflinePaymentMethodCode: &paymentMethod.Code,
OfflinePaymentMethodName: &paymentMethod.Name,
OtherVoucherKeys: model.StringJSONBArray(command.OtherVoucherKeys),
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
}
@@ -218,17 +246,71 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
if command.OfflinePaymentMethodID == 0 {
return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
}
for _, key := range command.PaymentVoucherKeys {
if strings.TrimSpace(key) == "" {
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil {
return err
}
if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil {
return err
}
return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个")
}
// validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。
func validateRechargeTransactionNo(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号")
}
if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength {
return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制")
}
return nil
}
// validateVoucherKeys 校验凭证对象键数量与内容minCount 为 0 时允许为空。
func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error {
if len(keys) < minCount || len(keys) > maxCount {
return errors.New(errors.CodeInvalidParam, message)
}
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空")
}
if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制")
}
if _, exists := seen[trimmed]; exists {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复")
}
seen[trimmed] = struct{}{}
}
return nil
}
// loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。
// 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。
func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
}
var paymentMethod model.EmployeeCollectionPaymentMethod
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &paymentMethod, nil
}
func (s *OfflineCreationService) loadHistoricalFacts(
ctx context.Context, record *model.AgentRechargeRecord,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
@@ -291,7 +373,7 @@ func (s *OfflineCreationService) loadCreationFacts(
return &account, &shop, &wallet, nil
}
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": command.SubmitterAccountID, "account_name": submitterName,
"user_type": command.SubmitterUserType,
@@ -309,6 +391,10 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
constants.ApprovalFieldSubmitterName: submitterName,
constants.ApprovalFieldOfflinePaymentMethod: paymentMethodName,
constants.ApprovalFieldOfflinePaymentMethodCode: paymentMethodCode,
constants.ApprovalFieldExternalTransactionNo: strings.TrimSpace(command.ExternalTransactionNo),
constants.ApprovalFieldOtherVoucherKey: command.OtherVoucherKeys,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")

View File

@@ -11,6 +11,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -47,20 +48,27 @@ type AvailablePaymentMethodsResult struct {
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
policy *OnlinePaymentMethodPolicy
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入三个渠道 Adapter
func NewOnlineCreationService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
// SetPaymentMethodPolicy 注入代理在线自充允许范围策略
func (s *OnlineCreationService) SetPaymentMethodPolicy(policy *OnlinePaymentMethodPolicy) {
s.policy = policy
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入运行时路由和三个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil || s.policy == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
@@ -79,14 +87,19 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
}
// 幂等回放先于允许范围门禁:同一 request_id 的重试属于既有单,不是新单,
// 不因允许范围变更被拒绝;允许范围只拦截会真正新建充值单与支付单的路径。
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil {
return nil, err
}
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
result, config, adapter, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet)
if err != nil {
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
return replay, replayErr
@@ -125,26 +138,56 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
return result, nil
}
// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。
// AvailablePaymentMethods 按允许范围与可用商户池交集返回在线支付方式。
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
result := AvailablePaymentMethodsResult{
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
}
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "代理账号可以查询在线支付方式")
if s == nil || s.db == nil || s.runtime == nil {
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
return result, apperrors.New(apperrors.CodeForbidden, "仅代理或平台账号可以查询在线支付方式")
}
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
if s.policy == nil {
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围策略未配置")
}
allowed, err := s.policy.AllowedMethods(ctx)
if err != nil {
return result, err
}
for _, method := range allowed {
var merchants []model.PaymentMerchant
err := s.db.WithContext(ctx).
Model(&model.PaymentMerchant{}).
Joins("JOIN tb_payment_merchant_pool_member AS member ON member.merchant_id = tb_payment_merchant.id AND member.deleted_at IS NULL").
Joins("JOIN tb_payment_merchant_pool AS pool ON pool.id = member.pool_id AND pool.deleted_at IS NULL").
Where("pool.payment_method = ? AND pool.status = ? AND tb_payment_merchant.payment_method = ? AND tb_payment_merchant.status = ?", method, model.PaymentMerchantStatusEnabled, method, model.PaymentMerchantStatusEnabled).
Order("member.sort_order ASC").Find(&merchants).Error
if err != nil {
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询代理在线支付商户池失败")
}
for index := range merchants {
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
merchant := &merchants[index]
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
var authErr error
authorization, authErr = s.runtime.LoadAuthorization(ctx)
if authErr != nil {
continue
}
}
config, configErr := merchantpayment.MerchantConfig(merchant, authorization)
if configErr != nil {
continue
}
adapter := s.adapter(method, config)
if adapter != nil && adapter.Available(config) {
result.Methods = append(result.Methods, method)
break
}
if s.wechat.Available(&config) || s.fuiou.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
}
return result, nil
}
@@ -152,40 +195,29 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
return nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
adapter := s.adapter(command.PaymentMethod, &config)
if adapter == nil || !adapter.Available(&config) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return &account, &shop, &wallet, &config, adapter, nil
return &account, &shop, &wallet, nil
}
func (s *OnlineCreationService) createLocalFacts(
@@ -195,15 +227,39 @@ func (s *OnlineCreationService) createLocalFacts(
account *model.Account,
shop *model.Shop,
wallet *model.AgentWallet,
config *model.WechatConfig,
) (*CreateOnlineResult, error) {
) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) {
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
if err != nil {
return nil, err
return nil, nil, nil, err
}
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
if err != nil {
return nil, err
return nil, nil, nil, err
}
var record *model.AgentRechargeRecord
var payment *model.Payment
var config *model.WechatConfig
var adapter OnlinePaymentPort
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, command.PaymentMethod, time.Now())
if err != nil {
return err
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if route.Merchant.ProviderType == model.ProviderTypeWechat || route.Merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.runtime.LoadAuthorization(ctx)
if err != nil {
return err
}
}
config, err = merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return err
}
adapter = s.adapter(command.PaymentMethod, config)
if adapter == nil || !adapter.Available(config) {
return apperrors.New(apperrors.CodeNoPaymentConfig)
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
@@ -211,20 +267,18 @@ func (s *OnlineCreationService) createLocalFacts(
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record := &model.AgentRechargeRecord{
record = &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
RequestID: &requestID, RequestFingerprint: &fingerprint,
Status: constants.RechargeStatusPending, RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment := &model.Payment{
payment = &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
PaymentMethod: command.PaymentMethod, Amount: command.Amount,
Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
merchantpayment.FreezeRoute(payment, route)
if err := tx.Create(record).Error; err != nil {
return err
}
@@ -238,9 +292,13 @@ func (s *OnlineCreationService) createLocalFacts(
})
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return nil, nil, nil, err
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {

View File

@@ -0,0 +1,58 @@
package agentrecharge
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// OnlinePaymentMethodConfigReader 提供代理在线自充允许范围的严格读取能力。
type OnlinePaymentMethodConfigReader interface {
GetStrict(ctx context.Context, key string) (string, error)
}
// OnlinePaymentMethodPolicy 将受控配置值映射为对外可见的线上支付方式集合。
type OnlinePaymentMethodPolicy struct {
reader OnlinePaymentMethodConfigReader
}
// NewOnlinePaymentMethodPolicy 创建代理在线自充允许范围策略。
func NewOnlinePaymentMethodPolicy(reader OnlinePaymentMethodConfigReader) *OnlinePaymentMethodPolicy {
return &OnlinePaymentMethodPolicy{reader: reader}
}
// AllowedMethods 严格读取允许范围;配置缺失使用注册默认值,非法值失败关闭。
func (p *OnlinePaymentMethodPolicy) AllowedMethods(ctx context.Context) ([]string, error) {
if p == nil || p.reader == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围未配置")
}
value, err := p.reader.GetStrict(ctx, constants.SystemConfigAgentSelfRechargeAllowedMethods)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "读取代理在线充值允许范围失败")
}
switch value {
case constants.AgentSelfRechargeAllowedWechatOnly:
return []string{constants.RechargeMethodWechat}, nil
case constants.AgentSelfRechargeAllowedAlipayOnly:
return []string{constants.RechargeMethodAlipay}, nil
case constants.AgentSelfRechargeAllowedBoth:
return []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay}, nil
default:
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "代理在线充值允许范围值非法")
}
}
// IsAllowed 判断业务支付方式是否在当前受控允许范围内。
func (p *OnlinePaymentMethodPolicy) IsAllowed(ctx context.Context, method string) error {
methods, err := p.AllowedMethods(ctx)
if err != nil {
return err
}
for _, allowed := range methods {
if allowed == method {
return nil
}
}
return apperrors.New(apperrors.CodeNoPaymentConfig, "当前支付方式不在代理在线充值允许范围内")
}

View File

@@ -0,0 +1,109 @@
package agentrecharge
import (
"context"
"encoding/base64"
"io"
"net/http"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
// PaymentVoucherObjectStore 提供付款凭证附件的元数据与内容读取能力。
type PaymentVoucherObjectStore interface {
Stat(ctx context.Context, key string) (*storage.ObjectMetadata, error)
Download(ctx context.Context, key string) (io.ReadCloser, error)
}
// PaymentVoucherRecognizer 是付款凭证识别的外部能力接缝,只暴露支付单号。
type PaymentVoucherRecognizer interface {
ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error)
}
// PaymentVoucherRecognitionResult 是识别结果中本系统消费的唯一字段。
type PaymentVoucherRecognitionResult struct {
// ExternalTransactionNo 是识别出的支付单号,仅作交易流水号表单预填值。
ExternalTransactionNo string
}
// PaymentVoucherOCRService 按附件对象键识别付款凭证,只返回交易流水号预填值。
// 识别不创建申请、不写入任何资金事实字段;其余识别字段一律不返回、不落库。
//
// ENG-AUDIT-001 事实决定:识别调用不产生状态变更、不涉及资金与权限,因此
// 不写 Audit Event、Domain Ledger、Integration Log 与 Outbox调用记录由 Access Log 与
// Gateway 客户端的路径级日志承载,识别载荷与原始结果不进入任何一类事实。
type PaymentVoucherOCRService struct {
objects PaymentVoucherObjectStore
recognizer PaymentVoucherRecognizer
}
// NewPaymentVoucherOCRService 创建付款凭证识别用例。
func NewPaymentVoucherOCRService(objects PaymentVoucherObjectStore, recognizer PaymentVoucherRecognizer) *PaymentVoucherOCRService {
return &PaymentVoucherOCRService{objects: objects, recognizer: recognizer}
}
// Recognize 校验附件为图片后调用识别能力,只返回交易流水号预填值。
// 非图片、对象不存在、内容为空或识别失败都返回明确失败,不阻断人工填写。
func (s *PaymentVoucherOCRService) Recognize(ctx context.Context, objectKey string) (*PaymentVoucherRecognitionResult, error) {
if s == nil || s.objects == nil || s.recognizer == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "付款凭证识别能力未配置")
}
key := strings.TrimSpace(objectKey)
if key == "" || len([]rune(key)) > constants.AgentRechargeVoucherKeyMaxLength {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象键无效")
}
metadata, err := s.objects.Stat(ctx, key)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "付款凭证对象不存在或不可读")
}
if metadata == nil || metadata.Size <= 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
}
if metadata.Size > constants.AgentRechargeVoucherMaxBytes {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
}
reader, err := s.objects.Download(ctx, key)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证对象失败")
}
defer func() { _ = reader.Close() }()
content, err := io.ReadAll(io.LimitReader(reader, constants.AgentRechargeVoucherMaxBytes+1))
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证内容失败")
}
if int64(len(content)) > constants.AgentRechargeVoucherMaxBytes {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
}
if len(content) == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
}
if !isPaymentVoucherImage(metadata.ContentType, content) {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证必须是图片文件")
}
// base64 编码只存在于本次调用内存中,禁止写入日志、审计或错误信息。
orderNumber, err := s.recognizer.ExtractPaymentVoucherOrderNumber(ctx, base64.StdEncoding.EncodeToString(content))
if err != nil {
return nil, err
}
if strings.TrimSpace(orderNumber) == "" {
return nil, apperrors.New(apperrors.CodeGatewayInvalidResp, "未从付款凭证中识别出交易流水号")
}
return &PaymentVoucherRecognitionResult{ExternalTransactionNo: strings.TrimSpace(orderNumber)}, nil
}
// isPaymentVoucherImage 校验对象声明的类型为图片,并用内容嗅探拦截被改名的非图片文件。
// 嗅探结果为空或 application/octet-stream 表示未知容器(如 webp交由识别服务判定
// 明确识别为其他类型的PDF、压缩包、文本等直接拒绝。
func isPaymentVoucherImage(declaredContentType string, content []byte) bool {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(declaredContentType)), "image/") {
return false
}
sniffed := strings.ToLower(strings.TrimSpace(http.DetectContentType(content)))
if sniffed == "" || sniffed == "application/octet-stream" || strings.HasPrefix(sniffed, "image/") {
return true
}
return false
}

View File

@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -14,6 +15,7 @@ import (
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
@@ -23,13 +25,13 @@ type RecoverOnlinePaymentService struct {
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
func NewRecoverOnlinePaymentService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
@@ -52,7 +54,13 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
for index := range payments {
payment := &payments[index]
recharge := recharges[payment.OrderID]
config := recoveryConfig(payment, configs)
config, configErr := s.recoveryConfig(ctx, payment, configs)
if configErr != nil {
if firstErr == nil {
firstErr = configErr
}
continue
}
if recharge == nil || config == nil {
if firstErr == nil {
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
@@ -141,7 +149,7 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
configIDs := make([]uint, 0, len(payments))
for index := range payments {
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
if payments[index].PaymentConfigID != nil {
if payments[index].MerchantID == nil && payments[index].PaymentConfigID != nil {
configIDs = append(configIDs, *payments[index].PaymentConfigID)
}
}
@@ -151,8 +159,10 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
}
var configRows []model.WechatConfig
if len(configIDs) > 0 {
if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败")
// merchant_id 为空仅为留存期内历史支付;独立 Change 删除旧路径前,
// 必须按其 payment_config_id 读取,包括已软删除的原始配置。
if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值历史配置失败")
}
}
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
@@ -166,11 +176,27 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
return recharges, configs, nil
}
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
if payment.PaymentConfigID == nil {
return nil
// recoveryConfig 对冻结商户支付单按当前凭证版本加载;历史支付单保持 payment_config_id 路径。
func (s *RecoverOnlinePaymentService) recoveryConfig(ctx context.Context, payment *model.Payment, configs map[uint]*model.WechatConfig) (*model.WechatConfig, error) {
if payment.MerchantID != nil {
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, err
}
return configs[*payment.PaymentConfigID]
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.runtime.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
}
return merchantpayment.MerchantConfig(merchant, authorization)
}
if payment.PaymentConfigID == nil {
return nil, nil
}
return configs[*payment.PaymentConfigID], nil
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {

View File

@@ -0,0 +1,155 @@
// Package distributionwithdrawal 收口代理分销注册、提现资料资格与提现企业微信终审的用例。
// 三者都以审批尝试/资料版本/注册记录主键作为通用审批业务标识,终态消费幂等且可重放。
package distributionwithdrawal
import (
"context"
stderrors "errors"
"strconv"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。
// 校验成功即消费验证码,同一验证码不可二次使用。
type VerificationCodeVerifier interface {
VerifyCode(ctx context.Context, phone string, code string) error
}
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。
// 日志与审计不得记录密码、完整证件号、完整手机号或附件内容。
type AuditChange struct {
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
EventID string
// ActionCode 是已注册的审计动作码。
ActionCode string
// Summary 是给人工阅读的中文摘要。
Summary string
// CorrelationID 是来源业务链路标识。
CorrelationID string
// Registration 是本次动作后的扫码注册记录事实。
Registration *model.AgentDistributionRegistration
// ParentShop 是扫码注册使用的上级店铺。
ParentShop *model.Shop
// Shop 是本次动作所属或引用的店铺。
Shop *model.Shop
// CreatedShop 是注册审批通过时新建的店铺。
// CreatedShop.DistributionCode 是本次为新店铺生成的随机码;
// AppliedDistributionCode 是注册时使用的上级店铺码快照,二者必须区分,不得混用。
CreatedShop *model.Shop
// AppliedDistributionCode 是注册提交时使用的上级店铺分销码快照。
AppliedDistributionCode string
// Qualification 是本次动作后的提现资料资格版本。
Qualification *model.WithdrawalQualification
// Withdrawal 是本次动作后的提现申请事实。
Withdrawal *model.CommissionWithdrawalRequest
// Attempt 是本次动作对应的提现审批尝试记录。
Attempt *model.CommissionWithdrawalRequestAttempt
// Wallet 是本次动作影响的佣金钱包。
Wallet *model.AgentWallet
// Transaction 是本次动作产生的钱包流水。
Transaction *model.AgentWalletTransaction
// BeforeData 与 AfterData 是脱敏前后的字段快照。
BeforeData map[string]any
AfterData map[string]any
// Result 是审计结果,空值按成功处理。
Result string
// ErrorCode 与 ErrorSummary 是失败或拒绝审计的稳定错误信息。
ErrorCode string
ErrorSummary string
}
// AuditWriter 在业务事务内追加统一 Audit Event。
type AuditWriter interface {
WriteDistributionWithdrawal(ctx context.Context, tx *gorm.DB, change AuditChange) error
}
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
func RecordFailure(ctx context.Context, db *gorm.DB, writer AuditWriter, change AuditChange, businessErr error) {
if writer == nil || db == nil || businessErr == nil {
return
}
appErr := changeError(businessErr)
change.Result = constants.AuditResultFailed
switch appErr.Code {
case errors.CodeForbidden, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeConflict,
errors.CodeInvalidStatus, errors.CodeInsufficientBalance, errors.CodeShopLevelExceeded:
change.Result = constants.AuditResultDenied
}
if change.ErrorCode == "" {
change.ErrorCode = strconv.Itoa(appErr.Code)
}
if change.ErrorSummary == "" {
change.ErrorSummary = appErr.Message
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return writer.WriteDistributionWithdrawal(ctx, tx, change)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(
change.ActionCode, "", "", change.CorrelationID, change.ErrorCode, err,
)
}
}
// changeError 归一化底层错误为稳定 AppError避免失败审计泄露底层文本。
func changeError(err error) *errors.AppError {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return appErr
}
return errors.New(errors.CodeInternalError, "分销注册或提现审批操作失败")
}
// approvalSnapshots 生成通用审批的提交人快照与业务表单快照。
func approvalSnapshots(accountID uint, accountName string, business map[string]any) ([]byte, []byte, error) {
submitter, err := marshalJSON(map[string]any{
"account_id": accountID, "account_name": accountName,
})
if err != nil {
return nil, nil, err
}
request, err := marshalJSON(business)
if err != nil {
return nil, nil, err
}
return submitter, request, nil
}
// marshalJSON 使用 sonic 序列化业务快照,禁止写入密码、完整证件号或附件内容。
func marshalJSON(value any) ([]byte, error) {
payload, err := sonic.Marshal(value)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化审批业务快照失败")
}
return payload, nil
}
// createApprovalInTx 在业务事务内创建通用审批实例并返回引用。
func createApprovalInTx(
ctx context.Context,
tx *gorm.DB,
port approvalapp.Port,
preparation approvalapp.Preparation,
businessType string,
businessID uint,
submitterAccountID uint,
submitterSnapshot []byte,
requestSnapshot []byte,
correlationID string,
) (approvalapp.Reference, error) {
if port == nil {
return approvalapp.Reference{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置")
}
return port.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: businessType, BusinessID: businessID,
SubmitterAccountID: submitterAccountID, SubmitterSnapshot: submitterSnapshot,
RequestSnapshot: requestSnapshot, CorrelationID: correlationID,
})
}

View File

@@ -0,0 +1,463 @@
package distributionwithdrawal
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// QualificationResult 返回已落库的资料版本与审批实例引用。
type QualificationResult struct {
QualificationID uint
Status int
ApprovalInstanceID uint
ApprovalStatus int
}
// QualificationService 受理提现资料资格的提交、替换、作废与停用失效。
// 资格事实按版本不可变保存;替换合同或法人身份证即新增版本并在同一事务内失效旧有效版本。
type QualificationService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewQualificationService 创建提现资料资格用例。
func NewQualificationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *QualificationService {
return &QualificationService{db: db, approval: approval, audit: audit}
}
// Submit 提交或替换本人代理店铺的提现资料资格。
// 已有待审批版本时拒绝;已有效版本在合同或法人身份证未变化时拒绝重复提交。
func (s *QualificationService) Submit(
ctx context.Context,
shopID uint,
input distributiondomain.QualificationInput,
) (*QualificationResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
// 越权提交资格属关键拒绝,必须留痕:以目标店铺为主要资源记录拒绝事实。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID本条是失败/拒绝事实,同一店铺可被拒绝多次,
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
Summary: "提交提现资料资格被拒绝:越权或非本人店铺",
Shop: failureShopResolved(ctx, s.db, shopID, nil),
}, err)
return nil, err
}
normalized, err := distributiondomain.ValidateQualificationInput(input)
if err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
submitter, err := resolveShopPrimaryAccount(ctx, s.db, shopID)
if err != nil {
return nil, err
}
correlationID := "withdrawal_qualification:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeWithdrawalQualification,
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
shop, err := loadShop(ctx, s.db, shopID)
if err != nil {
return nil, err
}
version := &model.WithdrawalQualification{
ShopID: shopID, SubjectType: normalized.SubjectType, SubjectCode: normalized.SubjectCode,
LegalPersonIDCard: normalized.LegalPersonIDCard, ContractFileKey: normalized.ContractFileKey,
IDCardFrontFileKey: normalized.IDCardFrontFileKey, IDCardBackFileKey: normalized.IDCardBackFileKey,
BusinessLicenseFileKey: normalized.BusinessLicenseFileKey, ShopFrontFileKey: normalized.ShopFrontFileKey,
InvoiceFileKey: normalized.InvoiceFileKey, InvoiceTitle: normalized.InvoiceTitle,
InvoiceSubjectCode: normalized.InvoiceSubjectCode,
Status: constants.WithdrawalQualificationStatusPending,
Creator: operatorID, Updater: operatorID,
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
qualificationApprovalForm(version, shop))
if err != nil {
return nil, err
}
result := &QualificationResult{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
replaced, err := invalidateReplacedVersion(ctx, tx, shopID, normalized, operatorID)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(version).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现资料资格版本失败")
}
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
constants.ApprovalBusinessTypeWithdrawalQualification, version.ID, submitter.ID,
submitterSnapshot, requestSnapshot, correlationID)
if err != nil {
return err
}
if err := attachQualificationInstance(ctx, tx, version, reference.InstanceID); err != nil {
return err
}
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":submit",
ActionCode: constants.AuditActionWithdrawalQualificationSubmitted,
Summary: qualificationSubmitSummary(replaced),
CorrelationID: correlationID, Qualification: version, Shop: shop,
AfterData: qualificationAuditSnapshot(version),
}); err != nil {
return err
}
result.QualificationID = version.ID
result.Status = version.Status
result.ApprovalInstanceID = reference.InstanceID
result.ApprovalStatus = reference.Status
return nil
})
if err != nil {
// 提交在创建资料版本前被拒绝(存在待审批版本或参数非法),此时没有资料版本可作主要资源,
// 以店铺为主要资源记录拒绝事实。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID本条是失败/拒绝事实,同一店铺可被拒绝多次,
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
Summary: "提交提现资料资格被拒绝", CorrelationID: correlationID,
Shop: shop,
}, err)
return nil, err
}
return result, nil
}
// Void 由超级管理员填写原因后作废有效提现资料资格。
// 原因必填;已失效或非有效版本返回稳定冲突错误。
func (s *QualificationService) Void(ctx context.Context, id uint, reason string) error {
if s == nil || s.db == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
reason = strings.TrimSpace(reason)
if reason == "" {
businessErr := errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
// 关键拒绝必须留痕:作废原因必填是权限相关拒绝,按超管作废动作记录拒绝事实。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID该拒绝与「作废成功」是同一实体的两次不同发生
// 手工 ID 会让随后的成功作废审计被 event_id 唯一约束吞掉,造成审计与事实相反。
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
Summary: "作废提现资料资格被拒绝:未填写原因",
Qualification: &model.WithdrawalQualification{ID: id},
}, businessErr)
return businessErr
}
operatorID := middleware.GetUserIDFromContext(ctx)
var version model.WithdrawalQualification
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&version, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现资料资格不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
}
if version.Status != constants.WithdrawalQualificationStatusApproved {
return errors.New(errors.CodeConflict, "仅有效提现资料资格可作废")
}
before := qualificationAuditSnapshot(&version)
if err := invalidateVersion(ctx, tx, &version, reason, operatorID); err != nil {
return err
}
shop, err := loadShop(ctx, tx, version.ShopID)
if err != nil {
return err
}
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":void",
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
Summary: "超级管理员作废提现资料资格", Qualification: &version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(&version),
})
})
if err != nil {
// 失败审计必须可追溯且恰好有一个主要资源:带上目标资料版本(至少含 ID
RecordFailure(ctx, s.db, s.audit, AuditChange{
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
Summary: "作废提现资料资格失败",
Qualification: &model.WithdrawalQualification{ID: id},
}, err)
return err
}
return nil
}
// InvalidateByShopDisable 在店铺停用事务内使该店铺全部有效资格失效。
// 历史版本与审批结果保留;由调用方保证与店铺停用处于同一事务。
func (s *QualificationService) InvalidateByShopDisable(
ctx context.Context,
tx *gorm.DB,
shopID uint,
reason string,
) error {
if tx == nil || shopID == 0 {
return nil
}
var versions []model.WithdrawalQualification
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
Order("id ASC").Find(&versions).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待失效提现资料资格失败")
}
if len(versions) == 0 {
return nil
}
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusInvalidated,
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格失败")
}
if result.RowsAffected == 0 {
return nil
}
shop, err := loadShop(ctx, tx, shopID)
if err != nil {
return err
}
first := versions[0]
first.Status = constants.WithdrawalQualificationStatusInvalidated
first.InvalidReason = reason
first.InvalidatedAt = &now
summary := "代理店铺停用,全部有效提现资料资格失效"
if strings.Contains(reason, "删除") {
summary = "代理店铺已删除,全部有效提现资料资格失效"
}
// 不手工构造 EventID同一店铺可先停用失效、后删除失效属同一实体的两次不同发生
// 手工 ID 会让第二次失效审计被吞掉。
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
Summary: summary, Qualification: &first, Shop: shop,
AfterData: map[string]any{
"shop_id": shopID, "invalidated_count": result.RowsAffected,
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
},
})
}
// invalidateReplacedVersion 在替换合同或法人身份证时失效旧有效版本。
// 返回被失效的版本;没有需失效的版本时返回 nil。
func invalidateReplacedVersion(
ctx context.Context,
tx *gorm.DB,
shopID uint,
input distributiondomain.QualificationInput,
operatorID uint,
) (*model.WithdrawalQualification, error) {
var pending int64
if err := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusPending).
Count(&pending).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审批提现资料资格失败")
}
if pending > 0 {
return nil, errors.New(errors.CodeConflict, "已存在待审批的提现资料资格,请等待审批结果")
}
var current model.WithdrawalQualification
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
First(&current).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定有效提现资料资格失败")
}
if !qualificationRequiresApproval(&current, input) {
return nil, errors.New(errors.CodeConflict, "提现资料资格已生效,合同与法人身份证未变化")
}
if err := invalidateVersion(ctx, tx, &current, "代理替换合同或法人身份证资料", operatorID); err != nil {
return nil, err
}
return &current, nil
}
// qualificationRequiresApproval 判断本次提交是否改变了合同或法人身份证事实。
func qualificationRequiresApproval(
current *model.WithdrawalQualification,
input distributiondomain.QualificationInput,
) bool {
return current.ContractFileKey != input.ContractFileKey ||
current.IDCardFrontFileKey != input.IDCardFrontFileKey ||
current.IDCardBackFileKey != input.IDCardBackFileKey ||
current.SubjectCode != input.SubjectCode ||
current.LegalPersonIDCard != input.LegalPersonIDCard
}
// invalidateVersion 条件更新单个资料版本为已失效。
func invalidateVersion(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
reason string,
operatorID uint,
) error {
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, version.Status).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
"invalidated_at": now, "invalidated_by": operatorID, "updater": operatorID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格版本失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusInvalidated
version.InvalidReason = reason
version.InvalidatedAt = &now
version.InvalidatedBy = operatorID
return nil
}
// attachQualificationInstance 回写资料版本关联的审批实例,写入一次后不可修改。
func attachQualificationInstance(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND approval_instance_id IS NULL", version.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现资料资格审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格审批实例关联已变化")
}
version.ApprovalInstanceID = &instanceID
return nil
}
// qualificationApprovalForm 生成企业微信审批表单业务快照。
// 证件号按脱敏值写入,附件只写入对象存储 Key 引用,不写入附件内容。
func qualificationApprovalForm(version *model.WithdrawalQualification, shop *model.Shop) map[string]any {
shopName := ""
if shop != nil {
shopName = shop.ShopName
}
return map[string]any{
constants.ApprovalFieldQualificationShopID: version.ShopID,
constants.ApprovalFieldQualificationShopName: shopName,
constants.ApprovalFieldQualificationSubjectType: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType),
constants.ApprovalFieldQualificationSubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode),
constants.ApprovalFieldQualificationLegalPersonMasked: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard),
constants.ApprovalFieldQualificationContractKey: version.ContractFileKey,
constants.ApprovalFieldQualificationIDCardFrontKey: version.IDCardFrontFileKey,
constants.ApprovalFieldQualificationIDCardBackKey: version.IDCardBackFileKey,
constants.ApprovalFieldQualificationBusinessLicenseKey: version.BusinessLicenseFileKey,
constants.ApprovalFieldQualificationShopFrontKey: version.ShopFrontFileKey,
constants.ApprovalFieldQualificationInvoiceKey: version.InvoiceFileKey,
constants.ApprovalFieldQualificationInvoiceTitle: version.InvoiceTitle,
}
}
// qualificationAuditSnapshot 生成资料版本审计快照,证件号按脱敏值记录,不含附件内容。
func qualificationAuditSnapshot(version *model.WithdrawalQualification) map[string]any {
instanceID := uint(0)
if version.ApprovalInstanceID != nil {
instanceID = *version.ApprovalInstanceID
}
return map[string]any{
"id": version.ID, "shop_id": version.ShopID, "subject_type": version.SubjectType,
"subject_code_masked": distributiondomain.MaskSubjectCode(version.SubjectCode),
"status": version.Status, "approval_instance_id": instanceID,
"invalid_reason": version.InvalidReason,
"attachment_count": 3 + boolToInt(version.BusinessLicenseFileKey != "") +
boolToInt(version.ShopFrontFileKey != "") + boolToInt(version.InvoiceFileKey != ""),
}
}
// qualificationSubmitSummary 区分首次提交与替换提交的审计摘要。
func qualificationSubmitSummary(replaced *model.WithdrawalQualification) string {
if replaced != nil {
return "替换合同或法人身份证资料,旧有效提现资料资格已失效"
}
return "提交提现资料资格"
}
// ensureOwnAgentShop 校验当前账号为代理身份且目标即本人店铺。
func ensureOwnAgentShop(ctx context.Context, shopID uint) error {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅代理商用户可提交提现资料资格")
}
if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) {
return errors.New(errors.CodeForbidden, "仅可为本人店铺提交提现资料资格")
}
return nil
}
// resolveShopPrimaryAccount 解析店铺启用的主账号,作为审批发起主体。
func resolveShopPrimaryAccount(ctx context.Context, db *gorm.DB, shopID uint) (*model.Account, error) {
var account model.Account
if err := db.WithContext(ctx).
Where("shop_id = ? AND status = ? AND is_primary = TRUE", shopID, constants.StatusEnabled).
First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "店铺缺少启用的主账号,无法提交审批")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主账号失败")
}
return &account, nil
}
// loadShopOrNil 读取店铺事实;店铺已软删除或不存在时返回 nil供终态收敛使用。
func loadShopOrNil(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
shop, err := loadShop(ctx, db, shopID)
if err != nil {
return nil
}
return shop
}
// loadShop 读取店铺事实,未找到返回稳定不存在错误。
func loadShop(ctx context.Context, db *gorm.DB, shopID uint) (*model.Shop, error) {
var shop model.Shop
if err := db.WithContext(ctx).First(&shop, shopID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
}
return &shop, nil
}
// boolToInt 将布尔值转换为 0/1用于审计计数。
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}

View File

@@ -0,0 +1,195 @@
package distributionwithdrawal
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// QualificationApprovalHandler 将渠道无关企业微信终态应用到提现资料资格版本。
// 通过才使版本生效;驳回只标记该版本,不影响其他版本已记录的审批结果。
type QualificationApprovalHandler struct {
db *gorm.DB
audit AuditWriter
}
// NewQualificationApprovalHandler 创建提现资料资格审批终态消费者。
func NewQualificationApprovalHandler(db *gorm.DB, audit AuditWriter) *QualificationApprovalHandler {
return &QualificationApprovalHandler{db: db, audit: audit}
}
// Handle 幂等消费标准审批终态。
// 业务标识为资料版本主键;先锁定版本并校验审批实例一致,再以条件更新推进状态。
func (h *QualificationApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "提现资料资格审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeWithdrawalQualification ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "提现资料资格审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var version model.WithdrawalQualification
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&version, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现资料资格版本不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
}
if version.ApprovalInstanceID == nil || *version.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "提现资料资格版本关联的审批实例不一致")
}
if version.Status != constants.WithdrawalQualificationStatusPending {
// 已是终态(含被替换或作废):重复或乱序回调不再改变事实。
return nil
}
// 店铺可能已被软删除:终态必须仍能收敛,不得把「店铺不存在」当成致命错误,
// 否则该版本永久卡在待审批且终态事件永久重投。审计的店铺资源此时允许为空。
shop := loadShopOrNil(ctx, tx, version.ShopID)
before := qualificationAuditSnapshot(&version)
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, &version, shop, before, event)
case constants.ApprovalDecisionRejected,
constants.ApprovalDecisionCancelled,
constants.ApprovalDecisionDeleted,
constants.ApprovalDecisionRevokedAfterApproved:
return h.applyRejected(ctx, tx, &version, shop, before, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的提现资料资格审批终态")
}
})
}
// applyApproved 使资料版本生效。
// 代理已提交替换版本时该版本已被失效,条件更新不再命中,不会覆盖更新版本。
func (h *QualificationApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
shop *model.Shop,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
if qualificationShopDisabled(ctx, tx, version.ShopID) {
// 店铺停用或不存在时资格必须失效:若停留在待审批,则「有待审批版本」门禁会让该店铺
// 永远无法获得有效资格(作废仅接受有效版本),因此就地收敛为已失效终态并写审计。
return h.invalidateForDisabledShop(ctx, tx, version, before, event, now)
}
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusApproved,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "使提现资料资格版本生效失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusApproved
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":approved",
ActionCode: constants.AuditActionWithdrawalQualificationApproved,
Summary: "企业微信通过提现资料资格,版本已生效",
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}
// invalidateForDisabledShop 在店铺停用或不存在时把待审批资料版本收敛为已失效。
// 与代理停用联动失效语义一致invalidated_by=0 表示系统联动),使该店铺可重新提交资格。
func (h *QualificationApprovalHandler) invalidateForDisabledShop(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
now time.Time,
) error {
reason := "代理店铺已停用,资格自动失效"
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusInvalidated,
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效停用店铺的提现资料资格失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusInvalidated
version.InvalidReason = reason
version.InvalidatedAt = &now
version.InvalidatedBy = 0
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":disabled",
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
Summary: "企业微信通过时店铺已停用,提现资料资格直接失效",
CorrelationID: event.CorrelationID, Qualification: version,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}
// qualificationShopDisabled 判断资料版本所属店铺是否已停用或不存在。
func qualificationShopDisabled(ctx context.Context, tx *gorm.DB, shopID uint) bool {
var enabled int64
if err := tx.WithContext(ctx).Model(&model.Shop{}).
Where("id = ? AND status = ?", shopID, constants.ShopStatusEnabled).
Count(&enabled).Error; err != nil {
return true
}
return enabled == 0
}
// applyRejected 标记资料版本已驳回,不影响其他版本已记录的审批结果。
func (h *QualificationApprovalHandler) applyRejected(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
shop *model.Shop,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusRejected,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现资料资格版本已驳回失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusRejected
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":rejected",
ActionCode: constants.AuditActionWithdrawalQualificationRejected,
Summary: "企业微信未通过提现资料资格",
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}

View File

@@ -0,0 +1,240 @@
package distributionwithdrawal
import (
"context"
"strconv"
"strings"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
type RegistrationResult struct {
RegistrationID uint
Status int
ApprovalInstanceID uint
ApprovalStatus int
}
// RegistrationService 受理公开扫码注册。
// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。
type RegistrationService struct {
db *gorm.DB
verifier VerificationCodeVerifier
approval approvalapp.Port
audit AuditWriter
}
// NewRegistrationService 创建公开扫码注册用例。
func NewRegistrationService(
db *gorm.DB,
verifier VerificationCodeVerifier,
approval approvalapp.Port,
audit AuditWriter,
) *RegistrationService {
return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit}
}
// Register 创建待审批注册记录。
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库。
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。
func (s *RegistrationService) Register(
ctx context.Context,
input distributiondomain.RegistrationInput,
code string,
) (*RegistrationResult, error) {
if s == nil || s.db == nil || s.verifier == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
}
normalized, err := distributiondomain.ValidateRegistrationInput(input)
if err != nil {
return nil, err
}
code = strings.TrimSpace(code)
if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
}
var parent *model.Shop
if err := s.db.WithContext(ctx).
Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled).
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
}
// 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。
if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
if err != nil {
return nil, err
}
correlationID := "agent_distribution:registration:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeAgentDistribution,
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
registration := &model.AgentDistributionRegistration{
DistributionCode: normalized.DistributionCode, ParentShopID: parent.ID,
Phone: normalized.Phone, PasswordHash: string(passwordHash),
ShopName: normalized.ShopName, ShopCode: normalized.ShopCode, Username: normalized.Username,
ContactName: normalized.ContactName, Province: normalized.Province,
City: normalized.City, District: normalized.District, Address: normalized.Address,
Status: constants.AgentDistributionRegistrationStatusPending,
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
registrationApprovalForm(normalized, parent))
if err != nil {
return nil, err
}
result := &RegistrationResult{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(registration).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建待审批注册记录失败")
}
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
constants.ApprovalBusinessTypeAgentDistribution, registration.ID, submitter.ID,
submitterSnapshot, requestSnapshot, correlationID)
if err != nil {
return err
}
if err := attachRegistrationInstance(ctx, tx, registration, reference.InstanceID); err != nil {
return err
}
// 公开注册提交不写审计:该链路在 personal.go 的 Use() 之前注册,不经任何认证中间件,
// 因而没有可信的 actor/sourceAppend 会以「审计操作者或入口不符合动作注册规则」失败)。
// tasks 1.7 只要求分销码生成、注册通过/驳回与资格相关审计,提交动作不在其列,
// 故移除该非必需审计而不是伪造操作者身份。
result.RegistrationID = registration.ID
result.Status = registration.Status
result.ApprovalInstanceID = reference.InstanceID
result.ApprovalStatus = reference.Status
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID uint) (*model.Account, error) {
var account model.Account
if err := db.WithContext(ctx).
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
}
return &account, nil
}
// registrationApprovalForm 生成企业微信审批表单业务快照。
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any {
return map[string]any{
constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode),
constants.ApprovalFieldDistributionParentShopID: parent.ID,
constants.ApprovalFieldDistributionParentShopName: parent.ShopName,
constants.ApprovalFieldDistributionShopName: input.ShopName,
constants.ApprovalFieldDistributionShopCode: input.ShopCode,
constants.ApprovalFieldDistributionUsername: input.Username,
constants.ApprovalFieldDistributionPhoneMasked: distributiondomain.MaskPhone(input.Phone),
constants.ApprovalFieldDistributionContactName: input.ContactName,
constants.ApprovalFieldDistributionRegion: strings.TrimSpace(
input.Province + input.City + input.District + input.Address),
}
}
// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。
func attachRegistrationInstance(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND approval_instance_id IS NULL", registration.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联扫码注册审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册审批实例关联已变化")
}
registration.ApprovalInstanceID = &instanceID
return nil
}
// registrationAuditSnapshot 生成注册记录审计快照,手机号按脱敏值记录,不含密码哈希。
func registrationAuditSnapshot(registration *model.AgentDistributionRegistration) map[string]any {
instanceID := uint(0)
if registration.ApprovalInstanceID != nil {
instanceID = *registration.ApprovalInstanceID
}
return map[string]any{
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
"distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode),
"phone_masked": distributiondomain.MaskPhone(registration.Phone),
"username": registration.Username, "shop_code": registration.ShopCode,
"status": registration.Status, "approval_instance_id": instanceID,
}
}
// uintText 将无符号整数转换为审计标识与键的十进制文本。
func uintText(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// intText 将整数转换为审计标识与键的十进制文本。
func intText(value int) string {
return strconv.Itoa(value)
}
// composeAuditEventID 拼接审计事件标识,并约束在审计列宽内。
func composeAuditEventID(parts ...string) (string, error) {
eventID := strings.Join(parts, ":")
if len(eventID) > 128 {
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
}
return eventID, nil
}
// lockRegistrationForUpdate 以行锁读取注册记录,未找到返回稳定不存在错误。
func lockRegistrationForUpdate(
ctx context.Context,
tx *gorm.DB,
id uint,
) (*model.AgentDistributionRegistration, error) {
var registration model.AgentDistributionRegistration
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&registration, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败")
}
return &registration, nil
}

View File

@@ -0,0 +1,279 @@
package distributionwithdrawal
import (
"context"
"time"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SubordinateCacheInvalidator 在审批通过事务提交后清理上级店铺下级集合缓存。
type SubordinateCacheInvalidator interface {
InvalidateSubordinateCache(ctx context.Context, shopID uint)
}
// DistributionApprovalHandler 将渠道无关企业微信终态应用到代理扫码注册记录。
// 通过才在单一事务内创建启用店铺、代理主账号、所需钱包、上级层级与业务员快照;
// 驳回只标记注册记录,不创建任何实体;重复或乱序回调不重复创建账号、层级或钱包。
type DistributionApprovalHandler struct {
db *gorm.DB
audit AuditWriter
cache SubordinateCacheInvalidator
}
// NewDistributionApprovalHandler 创建代理分销注册审批终态消费者。
func NewDistributionApprovalHandler(
db *gorm.DB,
audit AuditWriter,
cache SubordinateCacheInvalidator,
) *DistributionApprovalHandler {
return &DistributionApprovalHandler{db: db, audit: audit, cache: cache}
}
// Handle 幂等消费标准审批终态。
// 业务标识为待审批注册记录主键;先锁定注册记录并校验审批实例一致,再按条件更新推进状态。
func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "代理分销注册审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeAgentDistribution ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理分销注册审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
parentShopID := uint(0)
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
registration, err := lockRegistrationForUpdate(ctx, tx, event.BusinessID)
if err != nil {
return err
}
if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "扫码注册记录关联的审批实例不一致")
}
if registration.Status != constants.AgentDistributionRegistrationStatusPending {
// 已是终态:重复或乱序回调不再改变事实。
return nil
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
parentShopID = registration.ParentShopID
return h.applyApproved(ctx, tx, registration, event)
case constants.ApprovalDecisionRejected,
constants.ApprovalDecisionCancelled,
constants.ApprovalDecisionDeleted:
return h.applyRejected(ctx, tx, registration, event)
case constants.ApprovalDecisionRevokedAfterApproved:
// 注册记录无已建立的对外资金事实;通过后撤销按驳回处理并保留渠道决策痕迹。
return h.applyRejected(ctx, tx, registration, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的代理分销注册审批终态")
}
})
if err != nil {
return err
}
if parentShopID != 0 && h.cache != nil {
// 缓存清理必须在事务提交后执行,避免回滚后缓存与库内事实不一致。
h.cache.InvalidateSubordinateCache(ctx, parentShopID)
}
return nil
}
// applyApproved 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
// 上级店铺必须仍然存在且启用;手机号或用户名已被并发注册占用时整体回滚,不留半套实体。
func (h *DistributionApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
event approvalapp.TerminalDecisionEvent,
) error {
parent, err := loadEnabledParentShop(ctx, tx, registration.ParentShopID)
if err != nil {
return err
}
level := parent.Level + 1
if level > constants.ShopMaxLevel {
return errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
role, err := loadEnabledCustomerRole(ctx, tx)
if err != nil {
return err
}
shop := &model.Shop{
ShopName: registration.ShopName, ShopCode: registration.ShopCode,
ParentID: &parent.ID, Level: level,
ContactName: registration.ContactName, Province: registration.Province,
City: registration.City, District: registration.District, Address: registration.Address,
Status: constants.ShopStatusEnabled,
}
shop.BusinessOwnerAccountID = parent.BusinessOwnerAccountID
shop.Creator = registration.ID
shop.Updater = registration.ID
// 新店铺生成自己的分销码:注册记录上的分销码是上级店铺快照,复用会与父店铺同码并命中唯一索引。
if err := shopapp.CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
return err
}
account := &model.Account{
Username: registration.Username, Phone: registration.Phone,
Password: registration.PasswordHash, UserType: constants.UserTypeAgent,
ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true,
}
account.Creator = registration.ID
account.Updater = registration.ID
if err := tx.WithContext(ctx).Create(account).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建扫码注册代理账号失败")
}
if err := tx.WithContext(ctx).Create(&model.AccountRole{
AccountID: account.ID, RoleID: role.ID, Status: constants.StatusEnabled,
Creator: registration.ID, Updater: registration.ID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "为扫码注册代理账号分配角色失败")
}
if err := tx.WithContext(ctx).Create(&model.ShopRole{
ShopID: shop.ID, RoleID: role.ID, Status: constants.StatusEnabled,
Creator: registration.ID, Updater: registration.ID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "设置扫码注册店铺默认角色失败")
}
if err := tx.WithContext(ctx).Create([]*model.AgentWallet{
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain,
CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission,
CreditEnabled: false, CreditLimit: 0,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "初始化扫码注册店铺钱包失败")
}
if err := markRegistrationApproved(ctx, tx, registration); err != nil {
return err
}
// 建店与业务员归属的访问审计不在本用例职责内:该动作面向后台账号入口,
// 由审批消费任务触发的建店无法提供其要求的操作者/数据范围投影,
// 强行写入会以「账号权限或组织审计操作者不完整」失败并中止事务。
// 新建店铺已作为 CreatedShop 资源记录在本用例的分销审计中。
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "agent-distribution:" + uintText(registration.ID) + ":approved",
ActionCode: constants.AuditActionAgentDistributionRegistrationApproved,
Summary: "企业微信通过扫码注册,已创建店铺与代理账号",
CorrelationID: event.CorrelationID, Registration: registration,
ParentShop: parent, CreatedShop: shop,
AppliedDistributionCode: registration.DistributionCode,
AfterData: registrationAuditSnapshot(registration),
})
}
// applyRejected 只标记注册记录终态,不创建店铺、账号、钱包或层级。
func (h *DistributionApprovalHandler) applyRejected(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
reason := rejectionReason(event.Decision)
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
Updates(map[string]any{
"status": constants.AgentDistributionRegistrationStatusRejected,
"reject_reason": reason, "decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已驳回失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
}
before := registration.Status
registration.Status = constants.AgentDistributionRegistrationStatusRejected
registration.RejectReason = reason
registration.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "agent-distribution:" + uintText(registration.ID) + ":rejected",
ActionCode: constants.AuditActionAgentDistributionRegistrationRejected,
Summary: "企业微信未通过扫码注册,未创建任何实体",
CorrelationID: event.CorrelationID, Registration: registration,
BeforeData: map[string]any{"status": before},
AfterData: registrationAuditSnapshot(registration),
})
}
// markRegistrationApproved 以待审批状态条件更新标记注册记录已通过,重复回调不重复推进。
func markRegistrationApproved(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
) error {
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
Updates(map[string]any{
"status": constants.AgentDistributionRegistrationStatusApproved,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
}
registration.Status = constants.AgentDistributionRegistrationStatusApproved
registration.DecidedAt = &now
return nil
}
// rejectionReason 把渠道决策映射为可查询的中文驳回原因。
func rejectionReason(decision string) string {
switch decision {
case constants.ApprovalDecisionCancelled:
return "企业微信审批已撤销"
case constants.ApprovalDecisionDeleted:
return "企业微信审批已删除"
case constants.ApprovalDecisionRevokedAfterApproved:
return "企业微信审批通过后撤销"
default:
return "企业微信审批已驳回"
}
}
// loadEnabledParentShop 校验分销码所属店铺仍存在且启用。
func loadEnabledParentShop(ctx context.Context, tx *gorm.DB, shopID uint) (*model.Shop, error) {
var parent model.Shop
if err := tx.WithContext(ctx).First(&parent, shopID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "上级店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺失败")
}
if parent.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺已停用,不允许注册下级")
}
return &parent, nil
}
// loadEnabledCustomerRole 读取启用的客户角色,用于新建代理店铺的默认角色与信用额度。
func loadEnabledCustomerRole(ctx context.Context, tx *gorm.DB) (*model.Role, error) {
var role model.Role
if err := tx.WithContext(ctx).
Where("role_type = ? AND status = ?", constants.RoleTypeCustomer, constants.StatusEnabled).
Order("id ASC").First(&role).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "缺少启用的客户角色,无法创建代理店铺")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询启用客户角色失败")
}
return &role, nil
}

View File

@@ -0,0 +1,830 @@
package distributionwithdrawal
import (
"context"
"crypto/rand"
"math/big"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// WithdrawalPolicy 是提现申请使用的当前配置快照,由调用方从提现配置读取。
type WithdrawalPolicy struct {
MinAmount int64
FeeRate int64
DailyWithdrawalLimit int
}
// WithdrawalInput 是提现申请或重提的规范化输入。
type WithdrawalInput struct {
Amount int64
WithdrawalMethod string
AccountName string
AccountNumber string
InvoiceKeys []string
}
// WithdrawalResult 返回已原子保存的提现申请与审批尝试记录。
type WithdrawalResult struct {
RequestID uint
WithdrawalNo string
AttemptID uint
AttemptNo int
Amount int64
Fee int64
FeeRate int64
ActualAmount int64
Status int
ApprovalInstanceID uint
ApprovalStatus int
CreatedAt time.Time
}
// WithdrawalService 创建与重提提现申请。
// 申请、审批尝试记录、审批实例与佣金钱包冻结在同一事务完成;
// 余额不足、资格无效或非本人代理时不创建申请、审批实例或任何冻结。
type WithdrawalService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewWithdrawalService 创建提现申请用例。
func NewWithdrawalService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *WithdrawalService {
return &WithdrawalService{db: db, approval: approval, audit: audit}
}
// Create 为本人代理店铺创建提现申请。
func (s *WithdrawalService) Create(
ctx context.Context,
shopID uint,
policy WithdrawalPolicy,
input WithdrawalInput,
) (*WithdrawalResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
return s.submit(ctx, shopID, policy, nil, input)
}
// Resubmit 由本人代理修改金额、收款信息与本次发票后重提已被企业微信驳回的提现申请。
// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史快照与审批结果不被覆盖。
func (s *WithdrawalService) Resubmit(
ctx context.Context,
requestID uint,
policy WithdrawalPolicy,
input WithdrawalInput,
) (*WithdrawalResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
if requestID == 0 {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return s.submit(ctx, 0, policy, &requestID, input)
}
// ensureReady 校验依赖完整,缺失时失败关闭,避免绕过企业微信终审。
func (s *WithdrawalService) ensureReady() error {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "提现申请能力尚未配置")
}
return nil
}
// submit 在同一事务内完成资格校验、钱包加锁冻结、写申请与审批尝试记录、创建审批实例。
func (s *WithdrawalService) submit(
ctx context.Context,
shopID uint,
policy WithdrawalPolicy,
resubmitRequestID *uint,
input WithdrawalInput,
) (*WithdrawalResult, error) {
submitter, err := currentSubmitter(ctx)
if err != nil {
return nil, err
}
if input.Amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "提现金额必须大于 0")
}
if policy.MinAmount > 0 && input.Amount < policy.MinAmount {
return nil, errors.New(errors.CodeInvalidParam, "提现金额低于当前最低提现额度")
}
correlationID := "commission_withdrawal:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeCommissionWithdrawal,
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
result := &WithdrawalResult{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var request *model.CommissionWithdrawalRequest
if resubmitRequestID != nil {
request, err = lockWithdrawalRequest(ctx, tx, *resubmitRequestID)
if err != nil {
return err
}
if request.ShopID != submitter.ShopID {
return errors.New(errors.CodeNotFound, "提现申请不存在")
}
if request.Status != constants.WithdrawalStatusRejected {
return errors.New(errors.CodeConflict, "仅已被驳回的提现申请可重提")
}
if request.ApprovalInstanceID == nil {
return errors.New(errors.CodeConflict, "存量提现申请不支持企业微信重提")
}
// 重提先释放旧未结算尝试的冻结,避免产生第二笔冻结。
if _, err := releaseUnsettledAttemptsForRequest(ctx, tx, request.ID); err != nil {
return err
}
shopID = request.ShopID
}
if err := ensureOwnAgentShopForShopID(ctx, submitter, shopID); err != nil {
return err
}
qualification, err := loadValidQualification(ctx, tx, shopID)
if err != nil {
return err
}
if err := validateWithdrawalInvoice(qualification, input.InvoiceKeys); err != nil {
return err
}
if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil {
return err
}
wallet, err := lockCommissionWallet(ctx, tx, shopID)
if err != nil {
return err
}
fee := input.Amount * policy.FeeRate / 10000
actualAmount := input.Amount - fee
if err := freezeCommissionBalance(ctx, tx, wallet, input.Amount); err != nil {
return err
}
accountInfo, err := marshalJSON(map[string]string{
"account_name": input.AccountName, "account_number": input.AccountNumber,
})
if err != nil {
return err
}
invoiceKeys, err := marshalJSON(normalizeInvoiceKeys(input.InvoiceKeys))
if err != nil {
return err
}
if request == nil {
request = &model.CommissionWithdrawalRequest{
WithdrawalNo: generateWithdrawalNo(),
ShopID: shopID,
AgentID: submitter.ID,
ApplicantID: submitter.ID,
Amount: input.Amount,
FeeRate: policy.FeeRate,
Fee: fee,
ActualAmount: actualAmount,
WithdrawalMethod: input.WithdrawalMethod,
AccountInfo: accountInfo,
Status: constants.WithdrawalStatusPending,
}
request.Creator = submitter.ID
request.Updater = submitter.ID
if err := tx.WithContext(ctx).Create(request).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现申请失败")
}
} else {
if err := updateWithdrawalRequestForResubmit(ctx, tx, request, input, policy, fee, actualAmount, accountInfo); err != nil {
return err
}
}
attemptNo, err := nextWithdrawalAttemptNo(ctx, tx, request.ID)
if err != nil {
return err
}
attempt := &model.CommissionWithdrawalRequestAttempt{
RequestID: request.ID, AttemptNo: attemptNo,
Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount,
WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo,
InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID,
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败")
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
withdrawalApprovalForm(request, attempt, shopName(ctx, tx, shopID)))
if err != nil {
return err
}
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
constants.ApprovalBusinessTypeCommissionWithdrawal, attempt.ID, submitter.ID,
submitterSnapshot, requestSnapshot, correlationID)
if err != nil {
return err
}
if err := attachWithdrawalAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateWithdrawalLatest(ctx, tx, request, attempt, reference.InstanceID); err != nil {
return err
}
transaction, err := recordWithdrawalFreezeTransaction(ctx, tx, wallet, request, submitter.ID, input.Amount)
if err != nil {
return err
}
eventID, err := composeAuditEventID(
"commission-withdrawal", uintText(request.ID), "attempt", intText(attempt.AttemptNo), "submit")
if err != nil {
return err
}
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: eventID, ActionCode: constants.AuditActionCommissionWithdrawalAttemptSubmitted,
Summary: withdrawalSubmitSummary(resubmitRequestID != nil), CorrelationID: correlationID,
Withdrawal: request, Attempt: attempt, Wallet: wallet, Transaction: transaction,
AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
}); err != nil {
return err
}
result.RequestID = request.ID
result.WithdrawalNo = request.WithdrawalNo
result.AttemptID = attempt.ID
result.AttemptNo = attempt.AttemptNo
result.Amount = attempt.Amount
result.Fee = attempt.Fee
result.FeeRate = attempt.FeeRate
result.ActualAmount = attempt.ActualAmount
result.Status = request.Status
result.ApprovalInstanceID = reference.InstanceID
result.ApprovalStatus = reference.Status
result.CreatedAt = request.CreatedAt
return nil
})
if err != nil {
// 失败审计必须可追溯带上店铺shopID 在手上),使审计恰好有一个主要资源。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID同一店铺的提现可被拒绝多次手工 ID 会让后续拒绝被
// event_id 唯一约束吞掉;由审计 Writer 生成唯一 evt_<uuid>。
ActionCode: constants.AuditActionCommissionWithdrawalAttemptRejected,
Summary: "提交提现申请被拒绝", CorrelationID: correlationID,
Shop: failureShopResolved(ctx, s.db, shopID, resubmitRequestID),
}, err)
return nil, err
}
return result, nil
}
// failureShopResolved 为失败审计解析店铺引用:优先用入参 shopID
// Resubmit 场景下 shopID 为空则从提现申请行回查店铺,保证拒绝事实有可追溯的店铺主资源。
func failureShopResolved(ctx context.Context, db *gorm.DB, shopID uint, requestID *uint) *model.Shop {
if shopID == 0 && requestID != nil {
var request model.CommissionWithdrawalRequest
if err := db.WithContext(ctx).Select("id", "shop_id").First(&request, *requestID).Error; err == nil {
shopID = request.ShopID
}
}
return failureShop(ctx, db, shopID)
}
// failureShop 为失败审计解析店铺引用;店铺查询失败时退回仅含 ID 的最小引用,
// 保证拒绝事实仍有可追溯的店铺主资源。
func failureShop(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
if shopID == 0 {
return nil
}
if shop := loadShopOrNil(ctx, db, shopID); shop != nil {
return shop
}
// gorm.Model 的 ID 是提升字段,无法在复合字面量中设置,这里显式赋值。
minimal := &model.Shop{}
minimal.ID = shopID
return minimal
}
// withdrawalSubmitter 是发起提现的真实操作者。
type withdrawalSubmitter struct {
ID uint
Username string
ShopID uint
}
// currentSubmitter 从上下文取当前代理账号与其店铺,未认证时拒绝。
func currentSubmitter(ctx context.Context) (withdrawalSubmitter, error) {
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return withdrawalSubmitter{}, errors.New(errors.CodeUnauthorized, "未授权访问")
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return withdrawalSubmitter{}, errors.New(errors.CodeForbidden, "代理账号缺少店铺信息")
}
return withdrawalSubmitter{
ID: accountID, Username: middleware.GetUsernameFromContext(ctx), ShopID: shopID,
}, nil
}
// ensureOwnAgentShopForShopID 复核代理身份与店铺归属,越权与不存在返回同一结果。
func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmitter, shopID uint) error {
if shopID == 0 || shopID != submitter.ShopID {
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
return nil
}
// loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请。
func loadValidQualification(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (*model.WithdrawalQualification, error) {
var qualification model.WithdrawalQualification
err := tx.WithContext(ctx).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
First(&qualification).Error
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败")
}
var shop model.Shop
if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil {
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
}
if shop.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效")
}
return &qualification, nil
}
// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。
func validateWithdrawalInvoice(qualification *model.WithdrawalQualification, invoiceKeys []string) error {
keys := normalizeInvoiceKeys(invoiceKeys)
if len(keys) == 0 {
return nil
}
if qualification.SubjectType != constants.WithdrawalQualificationSubjectTypeEnterprise {
return errors.New(errors.CodeInvalidParam, "发票仅企业主体可提交")
}
if qualification.InvoiceSubjectCode == "" || qualification.InvoiceTitle == "" {
return errors.New(errors.CodeInvalidParam, "有效提现资料资格未登记发票资料")
}
return nil
}
// normalizeInvoiceKeys 归一化发票对象键列表,去除空串。
func normalizeInvoiceKeys(keys []string) []string {
result := make([]string, 0, len(keys))
for _, key := range keys {
if trimmed := strings.TrimSpace(key); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// ensureDailyWithdrawalLimit 校验当日提现次数上限;重提不占用新的当日次数。
func ensureDailyWithdrawalLimit(
ctx context.Context,
tx *gorm.DB,
shopID uint,
limit int,
countNewRequest bool,
) error {
if !countNewRequest || limit <= 0 {
return nil
}
today := time.Now().Format("2006-01-02")
var todayCount int64
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, today+" 00:00:00", today+" 23:59:59").
Count(&todayCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询当日提现次数失败")
}
if int(todayCount) >= limit {
return errors.New(errors.CodeInvalidParam, "今日提现次数已达上限")
}
return nil
}
// lockWithdrawalRequest 以行锁读取提现申请,未找到返回稳定不存在错误。
func lockWithdrawalRequest(
ctx context.Context,
tx *gorm.DB,
id uint,
) (*model.CommissionWithdrawalRequest, error) {
var request model.CommissionWithdrawalRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&request, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定提现申请失败")
}
return &request, nil
}
// lockCommissionWallet 以行锁读取店铺佣金钱包。
func lockCommissionWallet(ctx context.Context, tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeCommission).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺佣金钱包失败")
}
return &wallet, nil
}
// freezeCommissionBalance 以条件更新冻结可提现余额,影响行数不为 1 时判定余额不足。
func freezeCommissionBalance(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
amount int64,
) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND balance - frozen_balance >= ?",
wallet.ID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance + ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结可提现余额失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeInsufficientBalance, "可提现余额不足或并发冲突,请稍后重试")
}
wallet.FrozenBalance += amount
return nil
}
// releaseCommissionBalance 以条件更新释放冻结余额并返回释放是否发生。
// 释放金额取尝试记录事实,重复释放不会重复调整余额。
func releaseCommissionBalance(
ctx context.Context,
tx *gorm.DB,
walletID uint,
amount int64,
) (bool, error) {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
walletID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "释放冻结余额失败")
}
return result.RowsAffected == 1, nil
}
// nextWithdrawalAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
func nextWithdrawalAttemptNo(ctx context.Context, tx *gorm.DB, requestID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("request_id = ?", requestID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试序号失败")
}
if row.MaxAttemptNo >= constants.WithdrawalAttemptMaxCount {
return 0, errors.New(errors.CodeConflict, "提现重提次数已达上限,请联系平台处理")
}
return row.MaxAttemptNo + 1, nil
}
// updateWithdrawalRequestForResubmit 以已驳回状态条件更新申请为最新尝试的镜像。
func updateWithdrawalRequestForResubmit(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
input WithdrawalInput,
policy WithdrawalPolicy,
fee int64,
actualAmount int64,
accountInfo []byte,
) error {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusRejected).
Updates(map[string]any{
"amount": input.Amount, "fee": fee, "fee_rate": policy.FeeRate, "actual_amount": actualAmount,
"withdrawal_method": input.WithdrawalMethod, "account_info": accountInfo,
"status": constants.WithdrawalStatusPending, "processed_at": nil,
"reject_reason": "", "updater": request.ApplicantID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新提现申请重提内容失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化,请刷新后重试")
}
request.Amount = input.Amount
request.Fee = fee
request.FeeRate = policy.FeeRate
request.ActualAmount = actualAmount
request.WithdrawalMethod = input.WithdrawalMethod
request.AccountInfo = accountInfo
request.Status = constants.WithdrawalStatusPending
request.ProcessedAt = nil
request.RejectReason = ""
return nil
}
// attachWithdrawalAttemptInstance 回写尝试记录关联的审批实例,写入一次后不可修改。
func attachWithdrawalAttemptInstance(
ctx context.Context,
tx *gorm.DB,
attempt *model.CommissionWithdrawalRequestAttempt,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现审批实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateWithdrawalLatest 回填申请的最新尝试与审批实例引用,仅用于列表投影。
func updateWithdrawalLatest(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
instanceID uint,
) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
}
if request.ApprovalInstanceID == nil {
// 首次接入企业微信审批时记录稳定门禁标识,本地人工终审据此拒绝。
updates["approval_instance_id"] = instanceID
}
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ?", request.ID).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填提现申请最新审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请最新审批实例回填已变化")
}
request.LatestAttemptID = attempt.ID
request.LatestApprovalInstanceID = instanceID
if request.ApprovalInstanceID == nil {
request.ApprovalInstanceID = &instanceID
}
return nil
}
// recordWithdrawalFreezeTransaction 写入提现冻结钱包流水,金额为负且状态为处理中。
func recordWithdrawalFreezeTransaction(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
request *model.CommissionWithdrawalRequest,
operatorID uint,
amount int64,
) (*model.AgentWalletTransaction, error) {
remark := "提现冻结,单号:" + request.WithdrawalNo
refType := constants.ReferenceTypeWithdrawal
refID := request.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: operatorID,
TransactionType: constants.AgentTransactionTypeWithdrawal,
Amount: -amount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - amount,
Status: constants.TransactionStatusProcessing,
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
Creator: operatorID, ShopIDTag: request.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现冻结钱包流水失败")
}
return transaction, nil
}
// shopName 读取店铺名称用于审批表单展示,缺失时留空。
func shopName(ctx context.Context, db *gorm.DB, shopID uint) string {
var shop model.Shop
if err := db.WithContext(ctx).Select("id", "shop_name").First(&shop, shopID).Error; err != nil {
return ""
}
return shop.ShopName
}
// withdrawalApprovalForm 生成企业微信审批表单业务快照。
// 收款账号按原值写入供审批人核验,其他敏感内容不写入。
func withdrawalApprovalForm(
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
shopNameValue string,
) map[string]any {
accountName, accountNumber := decodeAccountInfo(attempt.AccountInfo)
return map[string]any{
constants.ApprovalFieldWithdrawalNo: request.WithdrawalNo,
constants.ApprovalFieldWithdrawalAttemptNo: attempt.AttemptNo,
constants.ApprovalFieldWithdrawalShopID: request.ShopID,
constants.ApprovalFieldWithdrawalShopName: shopNameValue,
constants.ApprovalFieldWithdrawalAmount: formatAmountYuan(attempt.Amount),
constants.ApprovalFieldWithdrawalAmountCent: attempt.Amount,
constants.ApprovalFieldWithdrawalFee: formatAmountYuan(attempt.Fee),
constants.ApprovalFieldWithdrawalActualAmount: formatAmountYuan(attempt.ActualAmount),
constants.ApprovalFieldWithdrawalMethod: attempt.WithdrawalMethod,
constants.ApprovalFieldWithdrawalAccountName: accountName,
constants.ApprovalFieldWithdrawalAccountNumber: accountNumber,
constants.ApprovalFieldWithdrawalInvoiceKey: decodeInvoiceKeys(attempt.InvoiceKeys),
}
}
// decodeAccountInfo 解析收款账户信息快照,解析失败时留空。
func decodeAccountInfo(payload []byte) (string, string) {
var info map[string]string
if err := sonic.Unmarshal(payload, &info); err != nil {
return "", ""
}
return info["account_name"], info["account_number"]
}
// decodeInvoiceKeys 解析发票对象键列表,解析失败时返回空列表。
func decodeInvoiceKeys(payload []byte) []string {
var keys []string
if err := sonic.Unmarshal(payload, &keys); err != nil {
return []string{}
}
return keys
}
// withdrawalAuditSnapshot 生成提现审计快照,不含收款账号与发票内容。
func withdrawalAuditSnapshot(
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
wallet *model.AgentWallet,
) map[string]any {
snapshot := map[string]any{
"id": request.ID, "withdrawal_no": request.WithdrawalNo, "shop_id": request.ShopID,
"amount": attempt.Amount, "fee": attempt.Fee, "fee_rate": attempt.FeeRate,
"actual_amount": attempt.ActualAmount, "withdrawal_method": attempt.WithdrawalMethod,
"status": request.Status, "attempt_id": attempt.ID, "attempt_no": attempt.AttemptNo,
"latest_approval_instance_id": request.LatestApprovalInstanceID,
"anomaly_flag": request.AnomalyFlag,
"invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)),
}
if wallet != nil {
snapshot["wallet_id"] = wallet.ID
snapshot["wallet_frozen_balance"] = wallet.FrozenBalance
}
return snapshot
}
// withdrawalSubmitSummary 区分首次提交与重提的审计摘要。
func withdrawalSubmitSummary(resubmit bool) string {
if resubmit {
return "重提佣金提现申请,已释放旧未结算冻结"
}
return "提交佣金提现申请并冻结可提现余额"
}
// ReleaseUnsettledAttemptsInTx 幂等释放指定店铺全部未结算的提现审批尝试冻结。
// 释放金额取 try.amount 事实,释放完成写入 released_at已释放的尝试不会被重复释放。
// 供后续佣金回溯在扣减佣金余额前先释放冻结,返回本次实际释放金额合计。
func (s *WithdrawalService) ReleaseUnsettledAttemptsInTx(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (int64, error) {
if s == nil || s.db == nil || tx == nil || shopID == 0 {
return 0, errors.New(errors.CodeInvalidParam, "提现冻结释放参数无效")
}
return releaseUnsettledAttempts(ctx, tx, shopID)
}
// releaseUnsettledAttemptsForRequest 在重提事务内释放指定申请的全部未结算尝试冻结。
func releaseUnsettledAttemptsForRequest(
ctx context.Context,
tx *gorm.DB,
requestID uint,
) (int64, error) {
return releaseUnsettledForRequests(ctx, tx, []uint{requestID})
}
// releaseUnsettledAttempts 释放指定店铺范围内未结算的提现审批尝试冻结。
// 全局加锁顺序固定为「申请 → 尝试 → 钱包」:本函数先锁申请行,再交由释放原语锁尝试行。
func releaseUnsettledAttempts(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (int64, error) {
var requestIDs []uint
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("shop_id = ?", shopID).Order("id ASC").Pluck("id", &requestIDs).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺提现申请失败")
}
return releaseUnsettledForRequests(ctx, tx, requestIDs)
}
// releaseUnsettledForRequests 通过共享释放原语释放尝试冻结并解冻钱包。
// 释放金额一律取尝试记录事实;原语的 released_at 条件更新保证重复调用不重复释放。
func releaseUnsettledForRequests(
ctx context.Context,
tx *gorm.DB,
requestIDs []uint,
) (int64, error) {
if len(requestIDs) == 0 {
return 0, nil
}
requests := make(map[uint]*model.CommissionWithdrawalRequest, len(requestIDs))
for _, requestID := range requestIDs {
request, err := lockWithdrawalRequest(ctx, tx, requestID)
if err != nil {
return 0, err
}
requests[requestID] = request
}
now := time.Now().UTC()
amounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, now)
if err != nil {
return 0, err
}
total := int64(0)
for _, requestID := range requestIDs {
amount, exists := amounts[requestID]
if !exists || amount == 0 {
continue
}
request := requests[requestID]
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
if err != nil {
return 0, err
}
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, amount)
if err != nil {
return 0, err
}
if !ok {
return 0, errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
}
total += amount
}
return total, nil
}
// generateWithdrawalNo 生成提现单号格式W + 时间戳 + 随机数。
func generateWithdrawalNo() string {
return "W" + time.Now().Format("20060102150405") + randomDigits(6)
}
// randomDigits 生成指定位数的数字随机串,用于提现单号。
func randomDigits(length int) string {
const digits = "0123456789"
buf := make([]byte, 0, length)
limit := big.NewInt(int64(len(digits)))
for range length {
value, err := rand.Int(rand.Reader, limit)
if err != nil {
return strings.Repeat("0", length)
}
buf = append(buf, digits[value.Int64()])
}
return string(buf)
}
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
func formatAmountYuan(amount int64) string {
negative := amount < 0
if negative {
amount = -amount
}
value := distributiondomain.FormatCentYuan(amount)
if negative {
return "-" + value
}
return value
}

View File

@@ -0,0 +1,365 @@
package distributionwithdrawal
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WithdrawalApprovalHandler 将渠道无关企业微信终态应用到提现申请。
// 通过时仅一次从冻结余额扣减并保持 WithdrawalStatusApproved=2同时写入到账时间
// 驳回、撤销与删除仅一次释放本次尝试的冻结余额并记录释放时间;
// 通过后撤销不回滚、不重新冻结、不自动重提,只写入正交异常标记与原因。
type WithdrawalApprovalHandler struct {
db *gorm.DB
audit AuditWriter
}
// NewWithdrawalApprovalHandler 创建佣金提现审批终态消费者。
func NewWithdrawalApprovalHandler(db *gorm.DB, audit AuditWriter) *WithdrawalApprovalHandler {
return &WithdrawalApprovalHandler{db: db, audit: audit}
}
// Handle 幂等消费标准审批终态。
// 业务标识为提现审批尝试记录主键;先锁定尝试记录并校验审批实例一致,再按条件更新推进状态。
func (h *WithdrawalApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "佣金提现审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeCommissionWithdrawal ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "佣金提现审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。
// 因此先用不加锁读取得 request_id再按序加锁避免与退款回扣路径形成死锁环。
var lookup model.CommissionWithdrawalRequestAttempt
if err := tx.WithContext(ctx).Select("id", "request_id").
First(&lookup, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败")
}
request, err := lockWithdrawalRequest(ctx, tx, lookup.RequestID)
if err != nil {
return err
}
var attempt model.CommissionWithdrawalRequestAttempt
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&attempt, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现审批尝试记录失败")
}
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "提现审批尝试记录关联的审批实例不一致")
}
if attempt.RequestID != request.ID {
return errors.New(errors.CodeConflict, "提现审批尝试记录归属已变化")
}
if request.LatestAttemptID != attempt.ID {
// 已被更新尝试取代的历史尝试终态不再改变申请事实。
return nil
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, request, &attempt, event)
case constants.ApprovalDecisionRejected,
constants.ApprovalDecisionCancelled,
constants.ApprovalDecisionDeleted:
return h.applyClosed(ctx, tx, request, &attempt, event)
case constants.ApprovalDecisionRevokedAfterApproved:
return h.applyRevoked(ctx, tx, request, &attempt, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的提现申请审批终态")
}
})
}
// applyApproved 仅一次从冻结余额扣减,保持已通过状态并写入到账时间。
// 幂等守卫为「申请仍待审核 + paid_at 为空 + 尝试未释放」的条件更新且影响行数为 1。
func (h *WithdrawalApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
if err != nil {
return err
}
now := time.Now().UTC()
if attempt.ReleasedAt != nil {
// 已结算的尝试不再扣减,避免重复扣款。
return nil
}
if wallet.FrozenBalance < attempt.Amount {
return errors.New(errors.CodeConflict, "冻结余额不足以完成提现扣减,请人工核对")
}
// 通过即视为已到账:先以 released_at IS NULL 条件更新标记本次冻结已结算,保证重复回调不重复扣减。
settled, err := markAttemptReleased(ctx, tx, attempt, now)
if err != nil {
return err
}
if !settled {
return nil
}
// 通过时保持状态 2 并写入到账时间,禁止使用已到账状态值 4。
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ? AND paid_at IS NULL", request.ID, constants.WithdrawalStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalStatusApproved,
"paid_at": now,
"processed_at": now,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化")
}
if err := deductFrozenBalance(ctx, tx, wallet, attempt.Amount); err != nil {
return err
}
transaction, err := recordWithdrawalDeductTransaction(ctx, tx, wallet, request, attempt)
if err != nil {
return err
}
attempt.ReleasedAt = &now
before := map[string]any{"status": constants.WithdrawalStatusPending, "paid_at": nil, "frozen_balance": wallet.FrozenBalance + attempt.Amount}
request.Status = constants.WithdrawalStatusApproved
request.PaidAt = &now
request.ProcessedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":approved",
ActionCode: constants.AuditActionCommissionWithdrawalAttemptApproved,
Summary: "企业微信通过佣金提现,已从冻结余额扣减并记录到账时间",
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
Wallet: wallet, Transaction: transaction,
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
})
}
// applyClosed 处理最终驳回、撤销与删除:仅一次释放本次尝试冻结并记录释放时间。
// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。
func (h *WithdrawalApprovalHandler) applyClosed(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
if attempt.ReleasedAt != nil {
return nil
}
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
if err != nil {
return err
}
now := time.Now().UTC()
released, err := markAttemptReleased(ctx, tx, attempt, now)
if err != nil {
return err
}
if !released {
return nil
}
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, attempt.Amount)
if err != nil {
return err
}
if !ok {
return errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
}
reason := rejectionReason(event.Decision)
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalStatusRejected,
"processed_at": now,
"reject_reason": reason,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已驳回失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化")
}
frozenBefore := wallet.FrozenBalance + attempt.Amount
wallet.FrozenBalance = frozenBefore - attempt.Amount
transaction, err := recordWithdrawalReleaseTransaction(ctx, tx, wallet, request, attempt, frozenBefore)
if err != nil {
return err
}
attempt.ReleasedAt = &now
before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore}
request.Status = constants.WithdrawalStatusRejected
request.ProcessedAt = &now
request.RejectReason = reason
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":closed",
ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed,
Summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额",
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
Wallet: wallet, Transaction: transaction,
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
})
}
// applyRevoked 处理通过后撤销。
// 已通过:不回滚已到账金额、不重新冻结、不自动重提,只写正交异常标记与原因。
// 仍在待审核(渠道乱序投递):按驳回同等处理,释放本次尝试冻结并转驳回状态。
func (h *WithdrawalApprovalHandler) applyRevoked(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
if request.Status == constants.WithdrawalStatusPending {
return h.applyClosed(ctx, tx, request, attempt, event)
}
if request.Status != constants.WithdrawalStatusApproved {
// 已驳回等终态不再改变事实。
return nil
}
if request.AnomalyFlag == constants.WithdrawalAnomalyFlagRevokedAfterApproved {
return nil
}
now := time.Now().UTC()
reason := "企业微信通过后撤销:" + rejectionReason(event.Decision)
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ? AND anomaly_flag = ?",
request.ID, constants.WithdrawalStatusApproved, constants.WithdrawalAnomalyFlagNone).
Updates(map[string]any{
"anomaly_flag": constants.WithdrawalAnomalyFlagRevokedAfterApproved,
"anomaly_reason": reason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现异常标记失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请异常标记已变化")
}
before := map[string]any{
"status": request.Status, "anomaly_flag": constants.WithdrawalAnomalyFlagNone,
"paid_at": request.PaidAt, "amount": attempt.Amount,
}
request.AnomalyFlag = constants.WithdrawalAnomalyFlagRevokedAfterApproved
request.AnomalyReason = reason
request.UpdatedAt = now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":anomaly",
ActionCode: constants.AuditActionCommissionWithdrawalAnomalyFlagged,
Summary: "企业微信通过后撤销,已到账金额不回滚、不重新冻结,仅写入异常标记",
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, nil),
})
}
// markAttemptReleased 以未释放条件更新写入尝试释放时间,返回是否本次完成释放。
func markAttemptReleased(
ctx context.Context,
tx *gorm.DB,
attempt *model.CommissionWithdrawalRequestAttempt,
now time.Time,
) (bool, error) {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Where("id = ? AND released_at IS NULL", attempt.ID).
Update("released_at", now)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现尝试释放时间失败")
}
return result.RowsAffected == 1, nil
}
// deductFrozenBalance 以冻结余额充足条件更新同时扣减余额与冻结余额。
func deductFrozenBalance(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, amount int64) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
wallet.ID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", amount),
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "从冻结余额扣减提现金额失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "冻结余额不足或已被并发处理")
}
wallet.Balance -= amount
wallet.FrozenBalance -= amount
return nil
}
// recordWithdrawalDeductTransaction 写入通过时的钱包流水,余额与冻结余额同时减少。
func recordWithdrawalDeductTransaction(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
) (*model.AgentWalletTransaction, error) {
remark := "企业微信终审通过,提现到账,单号:" + request.WithdrawalNo
refType := constants.ReferenceTypeWithdrawal
refID := request.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
TransactionType: constants.AgentTransactionTypeWithdrawal,
Amount: -attempt.Amount,
BalanceBefore: wallet.Balance + attempt.Amount, BalanceAfter: wallet.Balance,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现到账钱包流水失败")
}
return transaction, nil
}
// recordWithdrawalReleaseTransaction 写入驳回时的钱包流水,仅冻结余额减少。
func recordWithdrawalReleaseTransaction(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
frozenBefore int64,
) (*model.AgentWalletTransaction, error) {
remark := "企业微信未通过,释放提现冻结,单号:" + request.WithdrawalNo
refType := constants.ReferenceTypeWithdrawal
refID := request.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
TransactionType: constants.AgentTransactionTypeRefund,
Amount: attempt.Amount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现释放钱包流水失败")
}
_ = frozenBefore
return transaction, nil
}

View File

@@ -0,0 +1,671 @@
package employeecollection
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ApplicationAllocationCommand 描述核销申请中单张账单的本次分摊。
type ApplicationAllocationCommand struct {
BillID uint
Amount int64
}
// SubmitApplicationCommand 描述创建或重提核销申请的稳定输入。
type SubmitApplicationCommand struct {
PaymentMethodID uint
PaidAmount int64
PayerName string
PaidAt time.Time
ExternalTransactionNo string
PaymentVoucherKeys []string
Remark string
ActingReason string
Allocations []ApplicationAllocationCommand
}
// ApplicationSubmitResult 返回已原子保存的申请、审批尝试记录与分摊。
type ApplicationSubmitResult struct {
Application *model.EmployeeCollectionApplication
Attempt *model.EmployeeCollectionApplicationAttempt
Allocations []*model.EmployeeCollectionApplicationAllocation
Bills []*model.EmployeeCollectionBill
InstanceID uint
InstanceStatus int
}
// ApplicationService 创建与重提核销申请。
// 申请、审批尝试记录、审批实例与账单预占在同一事务完成;任一校验失败都不留下半成品事实。
type ApplicationService struct {
db *gorm.DB
approval approvalapp.Port
audit ApplicationAuditWriter
}
// NewApplicationService 创建核销申请用例。
func NewApplicationService(db *gorm.DB, approval approvalapp.Port, audit ApplicationAuditWriter) *ApplicationService {
return &ApplicationService{db: db, approval: approval, audit: audit}
}
// Create 为本人可见账单创建核销申请;超级管理员可为账单欠款人代办并必须填写代办原因。
func (s *ApplicationService) Create(ctx context.Context, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
caller, err := currentApplicationCaller(ctx)
if err != nil {
return nil, err
}
return s.submit(ctx, caller, 0, command)
}
// Resubmit 修改并重提已驳回的核销申请,新增审批尝试记录与新的企业微信审批实例。
func (s *ApplicationService) Resubmit(ctx context.Context, applicationID uint, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
caller, err := currentApplicationCaller(ctx)
if err != nil {
return nil, err
}
if applicationID == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return s.submit(ctx, caller, applicationID, command)
}
// ensureReady 校验用例依赖完整,缺失时失败关闭,避免绕过企业微信终审。
func (s *ApplicationService) ensureReady() error {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "核销申请能力尚未配置")
}
return nil
}
// applicationCaller 是发起核销申请的真实操作者。
type applicationCaller struct {
AccountID uint
AccountName string
IsAdmin bool
}
// currentApplicationCaller 从上下文取当前操作者,未认证时拒绝。
func currentApplicationCaller(ctx context.Context) (applicationCaller, error) {
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return applicationCaller{}, errors.New(errors.CodeUnauthorized)
}
return applicationCaller{
AccountID: accountID,
AccountName: middleware.GetUsernameFromContext(ctx),
IsAdmin: middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin,
}, nil
}
// submit 在同一事务内完成校验、加锁、写申请、写审批尝试记录、创建审批实例与账单预占。
// 加锁次序全仓统一为「申请行 → 账单行ID 升序)」,与审批终态消费者保持一致,避免死锁。
func (s *ApplicationService) submit(
ctx context.Context,
caller applicationCaller,
applicationID uint,
command SubmitApplicationCommand,
) (*ApplicationSubmitResult, error) {
if command.PaymentMethodID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "核销申请必须选择线下收款方式")
}
allocationCommands, err := sortedAllocationCommands(command.Allocations)
if err != nil {
return nil, err
}
correlationID := "employee_collection:application:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
SubmitterAccountID: caller.AccountID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
var result *ApplicationSubmitResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing *model.EmployeeCollectionApplication
if applicationID != 0 {
existing, err = lockApplication(ctx, tx, applicationID)
if err != nil {
return err
}
if err := employeecollectiondomain.ValidateApplicationResubmit(existing.Status); err != nil {
return err
}
if existing.ApplicantAccountID != caller.AccountID && !caller.IsAdmin {
return errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
}
bills, err := lockBillsInAscendingOrder(ctx, tx, allocationCommands)
if err != nil {
return err
}
paymentMethod, err := loadEnabledPaymentMethod(ctx, tx, command.PaymentMethodID)
if err != nil {
return err
}
applicantAccountID, err := resolveApplicantAccountID(caller, existing, bills)
if err != nil {
return err
}
acting := applicantAccountID != caller.AccountID
normalized, err := employeecollectiondomain.NormalizeApplicationInput(employeecollectiondomain.ApplicationInput{
PaidAmount: command.PaidAmount, PayerName: command.PayerName, PaidAt: command.PaidAt,
ExternalTransactionNo: command.ExternalTransactionNo, Remark: command.Remark,
ActingReason: command.ActingReason, PaymentVoucherKeys: command.PaymentVoucherKeys,
}, acting)
if err != nil {
return err
}
candidates := make([]employeecollectiondomain.AllocationCandidate, 0, len(allocationCommands))
for _, item := range allocationCommands {
bill := bills[item.BillID]
candidates = append(candidates, employeecollectiondomain.AllocationCandidate{
BillID: item.BillID, BillStatus: bill.Status, Amount: item.Amount,
Available: billAmounts(bill).Available(),
})
}
if err := employeecollectiondomain.ValidateAllocations(normalized.PaidAmount, candidates); err != nil {
return err
}
application, beforeData, err := prepareApplication(
ctx, tx, caller, existing, applicantAccountID, paymentMethod, normalized)
if err != nil {
return err
}
attemptNo, err := nextAttemptNo(ctx, tx, application.ID)
if err != nil {
return err
}
attempt, err := buildAttempt(ctx, tx, application.ID, attemptNo, caller, paymentMethod, normalized, bills, allocationCommands)
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(application.ID, caller, paymentMethod, normalized, bills, allocationCommands)
if err != nil {
return err
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
BusinessID: attempt.ID, SubmitterAccountID: caller.AccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: correlationID,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateApplicationLatest(ctx, tx, application, attempt, reference.InstanceID); err != nil {
return err
}
allocations, reservedBills, err := createAllocations(ctx, tx, application.ID, attempt.ID, caller, bills, allocationCommands)
if err != nil {
return err
}
submitEventID, err := composeAuditEventID(
"employee_collection", "application", uintText(application.ID), "attempt", intText(attempt.AttemptNo), "submit")
if err != nil {
return err
}
if err := s.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: submitEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationSubmitted,
Summary: submitSummary(existing != nil),
Application: application, Attempt: attempt, Allocations: allocations, Bills: reservedBills,
BeforeData: beforeData, AfterData: applicationAuditSnapshot(application),
CorrelationID: correlationID,
}); err != nil {
return err
}
result = &ApplicationSubmitResult{
Application: application, Attempt: attempt, Allocations: allocations,
Bills: reservedBills, InstanceID: reference.InstanceID, InstanceStatus: reference.Status,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// submitSummary 区分首次提交与重提的审计摘要。
func submitSummary(resubmit bool) string {
if resubmit {
return "重提员工代收款核销申请"
}
return "提交员工代收款核销申请"
}
// sortedAllocationCommands 校验分摊入参基本形态并按账单 ID 升序返回,保证锁序唯一。
func sortedAllocationCommands(items []ApplicationAllocationCommand) ([]ApplicationAllocationCommand, error) {
if len(items) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊")
}
if len(items) > constants.EmployeeCollectionAllocationMaxCount {
return nil, errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制")
}
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.BillID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单")
}
if item.Amount <= 0 {
return nil, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if _, exists := seen[item.BillID]; exists {
return nil, errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊")
}
seen[item.BillID] = struct{}{}
}
sorted := append([]ApplicationAllocationCommand(nil), items...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].BillID < sorted[j].BillID })
return sorted, nil
}
// lockApplication 以行锁读取核销申请,未找到返回稳定不存在错误。
func lockApplication(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionApplication, error) {
var application model.EmployeeCollectionApplication
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&application, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定核销申请失败")
}
return &application, nil
}
// lockBillsInAscendingOrder 按账单 ID 升序逐行加锁并返回账单事实。
// 单条 `WHERE id IN (...) ORDER BY id FOR UPDATE` 在 PostgreSQL 中先取行加锁再排序,
// 无法保证加锁次序;因此对每个账单各发一条只锁一行的语句,由调用方保证 ID 升序且不重复。
func lockBillsInAscendingOrder(
ctx context.Context,
tx *gorm.DB,
items []ApplicationAllocationCommand,
) (map[uint]*model.EmployeeCollectionBill, error) {
bills := make(map[uint]*model.EmployeeCollectionBill, len(items))
for _, item := range items {
var bill model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&bill, item.BillID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败")
}
bills[item.BillID] = &bill
}
return bills, nil
}
// loadEnabledPaymentMethod 读取启用中的线下收款方式字典项作为冻结来源。
func loadEnabledPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var paymentMethod model.EmployeeCollectionPaymentMethod
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &paymentMethod, nil
}
// resolveApplicantAccountID 依据所选账单确定申请人。
// 新建:非超级管理员只能选择本人欠款账单;超级管理员代办时全部账单必须属于同一欠款人。
// 重提:被选账单必须仍属于原申请人,申请人的其他越权访问与不存在返回同一错误。
func resolveApplicantAccountID(
caller applicationCaller,
existing *model.EmployeeCollectionApplication,
bills map[uint]*model.EmployeeCollectionBill,
) (uint, error) {
if existing != nil {
applicant := existing.ApplicantAccountID
for _, bill := range bills {
if bill.DebtorAccountID != applicant {
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
}
return applicant, nil
}
applicant := uint(0)
for _, bill := range bills {
if !caller.IsAdmin && bill.DebtorAccountID != caller.AccountID {
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
if applicant == 0 {
applicant = bill.DebtorAccountID
continue
}
if applicant != bill.DebtorAccountID {
return 0, errors.New(errors.CodeInvalidParam, "代办核销申请时全部账单必须属于同一欠款人")
}
}
return applicant, nil
}
// billAmounts 将账单持久化事实映射为领域金额事实。
func billAmounts(bill *model.EmployeeCollectionBill) employeecollectiondomain.BillAmounts {
return employeecollectiondomain.BillAmounts{
Receivable: bill.ReceivableAmount, Received: bill.ReceivedAmount, Reserved: bill.ReservedAmount,
Closed: bill.Status == constants.EmployeeCollectionBillStatusClosed,
}
}
// nextAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
func nextAttemptNo(ctx context.Context, tx *gorm.DB, applicationID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("application_id = ?", applicationID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试序号失败")
}
return row.MaxAttemptNo + 1, nil
}
// prepareApplication 新建或就地更新核销申请,返回申请事实与变更前快照。
// 重提使用 expected-status 条件更新,状态已变化时返回冲突。
func prepareApplication(
ctx context.Context,
tx *gorm.DB,
caller applicationCaller,
existing *model.EmployeeCollectionApplication,
applicantAccountID uint,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
) (*model.EmployeeCollectionApplication, map[string]any, error) {
actingOperatorID := uint(0)
if applicantAccountID != caller.AccountID {
actingOperatorID = caller.AccountID
}
if existing == nil {
application := &model.EmployeeCollectionApplication{
ApplicantAccountID: applicantAccountID, ActingOperatorID: actingOperatorID,
ActingReason: normalized.ActingReason,
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code,
PaymentMethodName: paymentMethod.Name, PaidAmount: normalized.PaidAmount,
PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
ExternalTransactionNo: normalized.ExternalTransactionNo,
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
Remark: normalized.Remark,
Status: constants.EmployeeCollectionApplicationStatusPending,
Creator: caller.AccountID, Updater: caller.AccountID,
}
if err := tx.WithContext(ctx).Create(application).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销申请失败")
}
return application, nil, nil
}
beforeData := applicationAuditSnapshot(existing)
expectedStatus := existing.Status
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", existing.ID, expectedStatus).
Updates(map[string]any{
"acting_operator_id": actingOperatorID,
"acting_reason": normalized.ActingReason,
"payment_method_id": paymentMethod.ID,
"payment_method_code": paymentMethod.Code,
"payment_method_name": paymentMethod.Name,
"paid_amount": normalized.PaidAmount,
"payer_name": normalized.PayerName,
"paid_at": normalized.PaidAt,
"external_transaction_no": normalized.ExternalTransactionNo,
"payment_voucher_keys": model.StringJSONBArray(normalized.PaymentVoucherKeys),
"remark": normalized.Remark,
"status": constants.EmployeeCollectionApplicationStatusPending,
"decided_at": nil,
"terminal_reason": "",
"updater": caller.AccountID,
})
if result.Error != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请失败")
}
if result.RowsAffected != 1 {
return nil, nil, errors.New(errors.CodeConflict, "核销申请状态已变化,请刷新后重试")
}
existing.ActingOperatorID = actingOperatorID
existing.ActingReason = normalized.ActingReason
existing.PaymentMethodID = paymentMethod.ID
existing.PaymentMethodCode = paymentMethod.Code
existing.PaymentMethodName = paymentMethod.Name
existing.PaidAmount = normalized.PaidAmount
existing.PayerName = normalized.PayerName
existing.PaidAt = normalized.PaidAt
existing.ExternalTransactionNo = normalized.ExternalTransactionNo
existing.PaymentVoucherKeys = model.StringJSONBArray(normalized.PaymentVoucherKeys)
existing.Remark = normalized.Remark
existing.Status = constants.EmployeeCollectionApplicationStatusPending
existing.DecidedAt = nil
existing.TerminalReason = ""
existing.Updater = caller.AccountID
return existing, beforeData, nil
}
// buildAttempt 新增一条不可变审批尝试记录,冻结当次收款方式、外部付款、附件与账单分摊快照。
func buildAttempt(
ctx context.Context,
tx *gorm.DB,
applicationID uint,
attemptNo int,
caller applicationCaller,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) (*model.EmployeeCollectionApplicationAttempt, error) {
snapshot, err := allocationSnapshot(bills, items)
if err != nil {
return nil, err
}
attempt := &model.EmployeeCollectionApplicationAttempt{
ApplicationID: applicationID, AttemptNo: attemptNo,
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code, PaymentMethodName: paymentMethod.Name,
PaidAmount: normalized.PaidAmount, PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
ExternalTransactionNo: normalized.ExternalTransactionNo,
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
Remark: normalized.Remark, SubmittedByAccountID: caller.AccountID,
ActingReason: normalized.ActingReason, AllocationSnapshot: snapshot,
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销审批尝试记录失败")
}
return attempt, nil
}
// allocationSnapshot 生成账单分摊快照,只保存账单摘要与金额,不含付款凭证内容。
func allocationSnapshot(
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]byte, error) {
entries := make([]map[string]any, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
entries = append(entries, map[string]any{
"bill_id": bill.ID, "source_type": bill.SourceType, "source_no": bill.SourceNo,
"bill_status": bill.Status, "receivable_amount": bill.ReceivableAmount,
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
"amount": item.Amount,
})
}
payload, err := sonic.Marshal(entries)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化账单分摊快照失败")
}
return payload, nil
}
// approvalSnapshots 生成通用审批的提交人快照与企业微信表单业务快照。
// 表单快照必须包含审批人核验所需的付款信息,因此保留经人工确认的完整外部流水号。
func approvalSnapshots(
applicationID uint,
caller applicationCaller,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": caller.AccountID, "account_name": caller.AccountName,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销申请提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldCollectionApplicationID: applicationID,
constants.ApprovalFieldCollectionPaymentMethod: paymentMethod.Name,
constants.ApprovalFieldCollectionPaidAmount: formatAmountYuan(normalized.PaidAmount),
constants.ApprovalFieldCollectionPaidAmountCent: normalized.PaidAmount,
constants.ApprovalFieldCollectionPayerName: normalized.PayerName,
constants.ApprovalFieldCollectionPaidAt: normalized.PaidAt.Format(time.RFC3339),
constants.ApprovalFieldCollectionExternalTransactionNo: normalized.ExternalTransactionNo,
constants.ApprovalFieldPaymentVoucherKey: normalized.PaymentVoucherKeys,
constants.ApprovalFieldRemark: normalized.Remark,
constants.ApprovalFieldSubmitterID: caller.AccountID,
constants.ApprovalFieldSubmitterName: caller.AccountName,
constants.ApprovalFieldCollectionBillCount: len(items),
constants.ApprovalFieldCollectionBillSummary: allocationSummary(bills, items),
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销审批业务快照失败")
}
return submitterSnapshot, requestSnapshot, nil
}
// allocationSummary 生成给审批人阅读的账单分摊摘要。
func allocationSummary(bills map[uint]*model.EmployeeCollectionBill, items []ApplicationAllocationCommand) string {
parts := make([]string, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
parts = append(parts, fmt.Sprintf("账单%d%s应收%s 本次分摊%s",
bill.ID, bill.SourceNo, formatAmountYuan(bill.ReceivableAmount), formatAmountYuan(item.Amount)))
}
return strings.Join(parts, "")
}
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
func formatAmountYuan(amount int64) string {
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
}
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.EmployeeCollectionApplicationAttempt, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联核销审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销审批实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateApplicationLatest 更新申请的最新审批尝试与审批实例引用,仅用于展示。
func updateApplicationLatest(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ?", application.ID).
Updates(map[string]any{
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请最新审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请最新审批实例更新已变化")
}
application.LatestAttemptID = attempt.ID
application.LatestApprovalInstanceID = instanceID
return nil
}
// createAllocations 写入分摊行并按账单 ID 升序预占余额。
// 预占使用条件更新并要求 RowsAffected 为 1避免并发申请超额占用同一账单。
func createAllocations(
ctx context.Context,
tx *gorm.DB,
applicationID uint,
attemptID uint,
caller applicationCaller,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]*model.EmployeeCollectionApplicationAllocation, []*model.EmployeeCollectionBill, error) {
allocations := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(items))
reservedBills := make([]*model.EmployeeCollectionBill, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
allocation := &model.EmployeeCollectionApplicationAllocation{
ApplicationID: applicationID, AttemptID: attemptID, BillID: bill.ID,
Amount: item.Amount, Status: constants.EmployeeCollectionAllocationStatusPending,
Creator: caller.AccountID, Updater: caller.AccountID,
}
if err := tx.WithContext(ctx).Create(allocation).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "写入核销分摊失败")
}
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND reserved_amount + ? <= receivable_amount - received_amount", bill.ID, item.Amount).
Updates(map[string]any{
"reserved_amount": gorm.Expr("reserved_amount + ?", item.Amount),
"updater": caller.AccountID,
})
if result.Error != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "预占账单可核销余额失败")
}
if result.RowsAffected != 1 {
return nil, nil, errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
}
bill.ReservedAmount += item.Amount
allocations = append(allocations, allocation)
reservedBills = append(reservedBills, bill)
}
return allocations, reservedBills, nil
}
// applicationAuditSnapshot 生成申请审计快照,外部交易流水号按脱敏值记录。
func applicationAuditSnapshot(application *model.EmployeeCollectionApplication) map[string]any {
return map[string]any{
"id": application.ID, "applicant_account_id": application.ApplicantAccountID,
"acting_operator_id": application.ActingOperatorID,
"payment_method_id": application.PaymentMethodID, "payment_method_code": application.PaymentMethodCode,
"paid_amount": application.PaidAmount, "payer_name": application.PayerName,
"external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo(application.ExternalTransactionNo),
"voucher_count": len(application.PaymentVoucherKeys),
"status": application.Status,
"latest_attempt_id": application.LatestAttemptID,
"latest_approval_instance_id": application.LatestApprovalInstanceID,
}
}

View File

@@ -0,0 +1,37 @@
package employeecollection
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// ApplicationAudit 描述核销申请、审批尝试记录与受影响账单的事实变化。
type ApplicationAudit struct {
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
EventID string
// ActionCode 是已注册的核销申请审计动作码。
ActionCode string
// Summary 是给人工阅读的中文摘要。
Summary string
// Application 是本次动作后的核销申请事实。
Application *model.EmployeeCollectionApplication
// Attempt 是本次动作对应的审批尝试记录。
Attempt *model.EmployeeCollectionApplicationAttempt
// Allocations 是本次动作涉及的分摊事实。
Allocations []*model.EmployeeCollectionApplicationAllocation
// Bills 是本次动作影响的员工代收款账单事实。
Bills []*model.EmployeeCollectionBill
// BeforeData 与 AfterData 是脱敏前后的字段快照,不得包含付款凭证内容。
BeforeData map[string]any
AfterData map[string]any
// CorrelationID 是申请链路标识。
CorrelationID string
}
// ApplicationAuditWriter 在员工代收款核销事务内追加统一 Audit Event。
type ApplicationAuditWriter interface {
WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change ApplicationAudit) error
}

View File

@@ -0,0 +1,501 @@
package employeecollection
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalDecisionHandler 将渠道无关企业微信审批终态应用到员工代收款核销申请。
// 通过才增加账单已核销金额,驳回才释放预占;重复、乱序或延迟回调都不重复入账。
type ApprovalDecisionHandler struct {
db *gorm.DB
audit ApplicationAuditWriter
}
// NewApprovalDecisionHandler 创建员工代收款核销审批终态消费者。
func NewApprovalDecisionHandler(db *gorm.DB, audit ApplicationAuditWriter) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, audit: audit}
}
// Handle 幂等消费标准审批终态。
// 业务标识为审批尝试记录主键:先锁定尝试记录并校验审批实例一致,再按申请与账单 ID 升序加锁。
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "员工代收款核销审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeEmployeeCollection || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工代收款核销审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var attempt model.EmployeeCollectionApplicationAttempt
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&attempt, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "核销审批尝试记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定核销审批尝试记录失败")
}
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "核销审批尝试记录关联的审批实例不一致")
}
application, err := lockApplication(ctx, tx, attempt.ApplicationID)
if err != nil {
return err
}
if application.LatestAttemptID != attempt.ID {
// 已被更新尝试取代的历史尝试终态不再改变申请事实。
return nil
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, application, &attempt, event)
case constants.ApprovalDecisionRejected:
return h.applyClosed(ctx, tx, application, &attempt, event,
constants.EmployeeCollectionApplicationStatusRejected, "企业微信审批已驳回")
case constants.ApprovalDecisionCancelled:
return h.applyClosed(ctx, tx, application, &attempt, event,
constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已撤销")
case constants.ApprovalDecisionDeleted:
return h.applyClosed(ctx, tx, application, &attempt, event,
constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已删除")
case constants.ApprovalDecisionRevokedAfterApproved:
return h.applyRevoked(ctx, tx, application, &attempt, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的核销申请审批终态")
}
})
}
// applyApproved 将本次尝试的全部预占分摊转入已核销并重算账单状态。
// 仅当申请仍处于审批中时推进,重复或乱序回调不重复增加已核销金额。
func (h *ApprovalDecisionHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
if application.Status != constants.EmployeeCollectionApplicationStatusPending {
return nil
}
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
return err
}
now := time.Now().UTC()
for _, allocation := range allocations {
if err := approveAllocation(ctx, tx, allocation, now); err != nil {
return err
}
if err := settleBillReservation(ctx, tx, allocation); err != nil {
return err
}
}
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionApplicationStatusApproved,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = constants.EmployeeCollectionApplicationStatusApproved
application.DecidedAt = &now
application.Updater = 0
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
approvedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "approved")
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: approvedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationApproved,
Summary: "企业微信审批通过,核销分摊转入已核销",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// applyClosed 处理最终驳回与渠道撤销、删除:释放全部预占并把申请置为对应终态。
func (h *ApprovalDecisionHandler) applyClosed(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
targetStatus int,
reason string,
) error {
if application.Status != constants.EmployeeCollectionApplicationStatusPending {
return nil
}
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
return err
}
now := time.Now().UTC()
for _, allocation := range allocations {
if err := releaseAllocation(ctx, tx, allocation, now); err != nil {
return err
}
if err := releaseBillReservation(ctx, tx, allocation); err != nil {
return err
}
}
terminalReason := ""
if targetStatus == constants.EmployeeCollectionApplicationStatusRevoked {
terminalReason = reason
}
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
Updates(map[string]any{
"status": targetStatus, "decided_at": now,
"terminal_reason": terminalReason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请终态失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = targetStatus
application.DecidedAt = &now
application.TerminalReason = terminalReason
application.Updater = 0
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
closedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, decisionEventSuffix(event.Decision))
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: closedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationRejected,
Summary: "企业微信审批未通过,核销申请预占已释放",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// applyRevoked 处理通过后撤销。
// 申请已通过:不回滚已核销金额,只转异常终态并禁止自动重提。
// 申请仍在审批中(渠道乱序投递):释放全部审批中预占并转异常终态,避免预占永久占用账单。
func (h *ApprovalDecisionHandler) applyRevoked(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
switch application.Status {
case constants.EmployeeCollectionApplicationStatusApproved:
return h.revokeApproved(ctx, tx, application, attempt, event)
case constants.EmployeeCollectionApplicationStatusPending:
return h.revokePending(ctx, tx, application, attempt, event)
default:
// 已驳回、已撤销等终态不再改变事实。
return nil
}
}
// revokeApproved 在已通过态撤销:保留已核销金额,仅转异常终态。
func (h *ApprovalDecisionHandler) revokeApproved(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
terminalReason := "企业微信通过后撤销"
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusApproved).
Updates(map[string]any{
"status": constants.EmployeeCollectionApplicationStatusRevoked,
"decided_at": now, "terminal_reason": terminalReason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请通过后撤销失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = constants.EmployeeCollectionApplicationStatusRevoked
application.DecidedAt = &now
application.TerminalReason = terminalReason
application.Updater = 0
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked")
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: revokedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked,
Summary: "企业微信通过后撤销,已核销金额不回滚",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// revokePending 在审批中态撤销:释放全部审批中预占并转异常终态。
func (h *ApprovalDecisionHandler) revokePending(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
return err
}
now := time.Now().UTC()
for _, allocation := range allocations {
if err := releaseAllocation(ctx, tx, allocation, now); err != nil {
return err
}
if err := releaseBillReservation(ctx, tx, allocation); err != nil {
return err
}
}
terminalReason := "企业微信通过后在本地审批中状态被撤销"
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionApplicationStatusRevoked,
"decided_at": now, "terminal_reason": terminalReason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请撤销失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = constants.EmployeeCollectionApplicationStatusRevoked
application.DecidedAt = &now
application.TerminalReason = terminalReason
application.Updater = 0
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked")
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: revokedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked,
Summary: "企业微信通过后撤销,申请仍在审批中,已释放预占",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// loadAttemptAllocations 按账单 ID 升序读取本次尝试的分摊,保证后续加锁与写入次序唯一。
func loadAttemptAllocations(ctx context.Context, tx *gorm.DB, attemptID uint) ([]*model.EmployeeCollectionApplicationAllocation, error) {
var allocations []model.EmployeeCollectionApplicationAllocation
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("attempt_id = ?", attemptID).Order("bill_id ASC, id ASC").Find(&allocations).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试分摊失败")
}
if len(allocations) == 0 {
return nil, errors.New(errors.CodeConflict, "核销审批尝试记录缺少分摊事实")
}
result := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(allocations))
for index := range allocations {
result = append(result, &allocations[index])
}
return result, nil
}
// allocationCommandsOf 提取分摊涉及的账单与金额,用于复用升序加锁函数。
func allocationCommandsOf(allocations []*model.EmployeeCollectionApplicationAllocation) []ApplicationAllocationCommand {
items := make([]ApplicationAllocationCommand, 0, len(allocations))
for _, allocation := range allocations {
items = append(items, ApplicationAllocationCommand{BillID: allocation.BillID, Amount: allocation.Amount})
}
return items
}
// approveAllocation 把分摊从审批中预占条件更新为已通过;重复处理时返回冲突。
func approveAllocation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
now time.Time,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionAllocationStatusApproved, "released_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销分摊已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销分摊状态已变化")
}
allocation.Status = constants.EmployeeCollectionAllocationStatusApproved
allocation.ReleasedAt = &now
return nil
}
// releaseAllocation 把分摊从审批中预占条件更新为已释放;重复处理时返回冲突。
func releaseAllocation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
now time.Time,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionAllocationStatusReleased, "released_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放核销分摊预占失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销分摊状态已变化")
}
allocation.Status = constants.EmployeeCollectionAllocationStatusReleased
allocation.ReleasedAt = &now
return nil
}
// settleBillReservation 将账单预占转为已核销并重算账单状态。
// 条件更新要求账单预占不小于分摊金额,并检查 RowsAffected避免并发下重复入账。
func settleBillReservation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount).
Updates(map[string]any{
"received_amount": gorm.Expr("received_amount + ?", allocation.Amount),
"reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount),
"status": gorm.Expr(
"CASE WHEN received_amount + ? >= receivable_amount THEN ?::smallint WHEN received_amount + ? > 0 THEN ?::smallint ELSE ?::smallint END",
allocation.Amount, constants.EmployeeCollectionBillStatusSettled,
allocation.Amount, constants.EmployeeCollectionBillStatusPartial,
constants.EmployeeCollectionBillStatusPending,
),
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "账单预占转入已核销失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "账单预占已变化,核销未入账")
}
return nil
}
// releaseBillReservation 释放账单预占金额,条件更新并检查 RowsAffected。
func releaseBillReservation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount).
Updates(map[string]any{
"reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount),
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放账单预占失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "账单预占已变化")
}
return nil
}
// reloadBills 重新读取受影响账单,保证审计快照反映终态金额。
// 账单行已在同一事务内持有排他锁,这里只做一次按 ID 升序的普通读取。
func reloadBills(
ctx context.Context,
tx *gorm.DB,
items []ApplicationAllocationCommand,
) ([]*model.EmployeeCollectionBill, error) {
billIDs := make([]uint, 0, len(items))
for _, item := range items {
billIDs = append(billIDs, item.BillID)
}
var bills []model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Where("id IN ?", billIDs).Order("id ASC").Find(&bills).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询受影响员工代收款账单失败")
}
result := make([]*model.EmployeeCollectionBill, 0, len(bills))
for index := range bills {
result = append(result, &bills[index])
}
return result, nil
}
// decisionEventID 生成审批终态审计事件的稳定标识,并约束在审计列宽内。
func decisionEventID(applicationID uint, attemptNo int, suffix string) (string, error) {
return composeAuditEventID(
"employee_collection", "application", uintText(applicationID), "attempt", intText(attemptNo), suffix)
}
// decisionEventSuffix 把渠道决策映射为审计事件后缀。
func decisionEventSuffix(decision string) string {
switch decision {
case constants.ApprovalDecisionRejected:
return "rejected"
case constants.ApprovalDecisionCancelled:
return "cancelled"
case constants.ApprovalDecisionDeleted:
return "deleted"
default:
return "closed"
}
}

View File

@@ -0,0 +1,31 @@
package employeecollection
import (
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// auditEventMaxLength 是统一审计事件标识的列宽上限,与 tb_audit_event.event_id 保持一致。
const auditEventMaxLength = 64
// composeAuditEventID 以冒号连接业务标识片段,生成确定性的审计事件标识。
// 超出审计列宽时返回稳定错误,避免写入时分段截断或事务被数据库拒绝。
func composeAuditEventID(parts ...string) (string, error) {
eventID := strings.Join(parts, ":")
if len(eventID) > auditEventMaxLength {
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
}
return eventID, nil
}
// uintText 将主键转为审计标识片段。
func uintText(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// intText 将序号转为审计标识片段。
func intText(value int) string {
return strconv.Itoa(value)
}

View File

@@ -0,0 +1,118 @@
package employeecollection
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BillCloseService 关闭员工代收款账单。
// 仅超级管理员可关闭,且只允许关闭仍待核销或部分核销、且不存在审批中分摊的账单。
type BillCloseService struct {
db *gorm.DB
audit BillAuditWriter
}
// NewBillCloseService 创建账单关闭事务脚本。
func NewBillCloseService(db *gorm.DB, audit BillAuditWriter) *BillCloseService {
return &BillCloseService{db: db, audit: audit}
}
// Close 关闭账单:作废未核销余额、保留已核销金额,并在同一事务内写关闭审计。
// 并发关闭通过行锁加 expected-status 条件更新兜底,状态已变化时返回冲突。
func (s *BillCloseService) Close(ctx context.Context, id uint, reason string) (*model.EmployeeCollectionBill, error) {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return nil, err
}
if s == nil || s.db == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "账单关闭能力尚未配置")
}
if id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
closeReason := strings.TrimSpace(reason)
var closed *model.EmployeeCollectionBill
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
bill, err := lockBill(ctx, tx, id)
if err != nil {
return err
}
var pendingAllocations int64
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
Where("bill_id = ? AND status = ?", id, constants.EmployeeCollectionAllocationStatusPending).
Count(&pendingAllocations).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "统计账单审批中分摊失败")
}
if err := employeecollectiondomain.ValidateBillClose(employeecollectiondomain.BillCloseInput{
Status: bill.Status, PendingAllocations: pendingAllocations, Reason: closeReason,
}); err != nil {
return err
}
before := *bill
closedAt := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND status = ?", bill.ID, bill.Status).
Updates(map[string]any{
"status": constants.EmployeeCollectionBillStatusClosed,
"closed_reason": closeReason,
"closed_at": closedAt,
"updater": operatorID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭员工代收款账单失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "账单状态已变化,请刷新后重试")
}
bill.Status = constants.EmployeeCollectionBillStatusClosed
bill.ClosedReason = closeReason
bill.ClosedAt = &closedAt
bill.Updater = operatorID
closeEventID, err := composeAuditEventID("employee_collection", "bill", uintText(bill.ID), "close")
if err != nil {
return err
}
if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
EventID: closeEventID,
ActionCode: constants.AuditActionEmployeeCollectionBillClosed, Summary: "关闭员工代收款账单",
Bill: bill,
BeforeData: map[string]any{
"status": before.Status, "closed_reason": before.ClosedReason,
},
AfterData: map[string]any{
"status": bill.Status, "closed_reason": bill.ClosedReason,
},
CorrelationID: bill.SourceNo,
}); err != nil {
return err
}
closed = bill
return nil
})
if err != nil {
return nil, err
}
return closed, nil
}
// lockBill 以行锁读取账单,未找到返回稳定不存在错误。
func lockBill(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionBill, error) {
var bill model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&bill, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败")
}
return &bill, nil
}

View File

@@ -0,0 +1,233 @@
package employeecollection
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BillAudit 描述员工代收款账单事实的实际变化。
type BillAudit struct {
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
EventID string
// ActionCode 是已注册的账单审计动作码。
ActionCode string
// Summary 是给人工阅读的中文摘要。
Summary string
// Bill 是本次动作后的账单事实。
Bill *model.EmployeeCollectionBill
// BeforeData 与 AfterData 是脱敏前后的字段快照。
BeforeData map[string]any
AfterData map[string]any
// CorrelationID 是来源业务链路标识。
CorrelationID string
}
// BillAuditWriter 在员工代收款业务事务内追加统一 Audit Event。
type BillAuditWriter interface {
WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change BillAudit) error
}
// BillCreationService 在来源成功事务内按来源唯一键幂等创建员工代收款账单。
// 建账只由来源成功事务携带的来源主键触发,不存在扫描历史订单或充值补建的路径。
type BillCreationService struct {
audit BillAuditWriter
}
// NewBillCreationService 创建员工代收款建账用例。
func NewBillCreationService(audit BillAuditWriter) *BillCreationService {
return &BillCreationService{audit: audit}
}
// CreateFromOrderInTx 在后台线下套餐订单激活事务内建账。
// 判据见 employeecollectiondomain.ShouldCreateBillForOrder不满足判据时返回 (nil, nil)。
// 重复订单事务、重放或重试都命中 source_key 唯一约束并返回既有账单,不使订单事务失败。
func (s *BillCreationService) CreateFromOrderInTx(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
hasGiftPackage bool,
) (*model.EmployeeCollectionBill, error) {
if s == nil || tx == nil || s.audit == nil {
return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置")
}
if order == nil || order.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源订单")
}
if !employeecollectiondomain.ShouldCreateBillForOrder(
employeecollectiondomain.OrderBillSubjectFromOrder(order, hasGiftPackage)) {
return nil, nil
}
if order.OperatorAccountID == nil || *order.OperatorAccountID == 0 {
return nil, errors.New(errors.CodeInternalError, "线下套餐订单缺少欠款人账号")
}
debtorAccountID := *order.OperatorAccountID
debtorSnapshot, err := marshalSnapshot(map[string]any{
"account_id": debtorAccountID, "account_name": order.OperatorAccountName,
"account_type": order.OperatorAccountType,
})
if err != nil {
return nil, err
}
// shop_id 与 seller_shop_id 同时写入:店铺筛选统一读 shop_idseller_shop_id 保留兼容口径。
customerSnapshot, err := marshalSnapshot(map[string]any{
"buyer_type": order.BuyerType, "buyer_id": order.BuyerID,
"buyer_nickname": order.BuyerNickname,
"shop_id": order.SellerShopID, "seller_shop_id": order.SellerShopID,
"asset_identifier": order.AssetIdentifier,
})
if err != nil {
return nil, err
}
sourceKey := employeecollectiondomain.OrderSourceKey(order.ID)
bill := &model.EmployeeCollectionBill{
SourceType: constants.EmployeeCollectionSourceTypeOrder,
SourceID: order.ID,
SourceKey: sourceKey,
SourceNo: order.OrderNo,
DebtorAccountID: debtorAccountID,
DebtorSnapshot: debtorSnapshot,
CustomerSnapshot: customerSnapshot,
ReceivableAmount: *order.ActualPaidAmount,
Status: constants.EmployeeCollectionBillStatusPending,
Creator: debtorAccountID,
Updater: debtorAccountID,
}
return s.persistInTx(ctx, tx, bill,
[]string{"employee_collection", "bill", "order", uintText(order.ID), "create"},
"后台线下套餐订单创建员工代收款账单", order.OrderNo)
}
// CreateFromRechargeInTx 在代理线下充值入账事务内建账。
// 判据见 employeecollectiondomain.ShouldCreateBillForRecharge欠款人为发起充值的后台账号。
// 覆盖企业微信终审通过入账与后台人工确认入账两条入口,重复入账不重复建账。
func (s *BillCreationService) CreateFromRechargeInTx(
ctx context.Context,
tx *gorm.DB,
record *model.AgentRechargeRecord,
) (*model.EmployeeCollectionBill, error) {
if s == nil || tx == nil || s.audit == nil {
return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置")
}
if record == nil || record.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源充值记录")
}
if !employeecollectiondomain.ShouldCreateBillForRecharge(employeecollectiondomain.RechargeBillSubject{
PaymentMethod: record.PaymentMethod, Amount: record.Amount,
}) {
return nil, nil
}
if record.UserID == 0 {
return nil, errors.New(errors.CodeInternalError, "线下充值记录缺少发起账号")
}
debtorName, err := rechargeAccountName(ctx, tx, record.UserID)
if err != nil {
return nil, err
}
debtorSnapshot, err := marshalSnapshot(map[string]any{
"account_id": record.UserID, "account_name": debtorName,
"account_type": model.OperatorAccountTypePlatform,
})
if err != nil {
return nil, err
}
customerSnapshot, err := marshalSnapshot(map[string]any{
"shop_id": record.ShopID, "agent_wallet_id": record.AgentWalletID,
"payment_method": record.PaymentMethod, "recharge_no": record.RechargeNo,
})
if err != nil {
return nil, err
}
sourceKey := employeecollectiondomain.RechargeSourceKey(record.ID)
bill := &model.EmployeeCollectionBill{
SourceType: constants.EmployeeCollectionSourceTypeRecharge,
SourceID: record.ID,
SourceKey: sourceKey,
SourceNo: record.RechargeNo,
DebtorAccountID: record.UserID,
DebtorSnapshot: debtorSnapshot,
CustomerSnapshot: customerSnapshot,
ReceivableAmount: record.Amount,
Status: constants.EmployeeCollectionBillStatusPending,
Creator: record.UserID,
Updater: record.UserID,
}
return s.persistInTx(ctx, tx, bill,
[]string{"employee_collection", "bill", "recharge", uintText(record.ID), "create"},
"代理线下充值入账创建员工代收款账单", record.RechargeNo)
}
// persistInTx 以来源唯一键幂等写入账单:已存在同一来源账单时返回既有事实且不重复审计。
func (s *BillCreationService) persistInTx(
ctx context.Context,
tx *gorm.DB,
bill *model.EmployeeCollectionBill,
eventIDParts []string,
summary string,
correlationID string,
) (*model.EmployeeCollectionBill, error) {
eventID, err := composeAuditEventID(eventIDParts...)
if err != nil {
return nil, err
}
result := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_key"}},
DoNothing: true,
}).Create(bill)
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "创建员工代收款账单失败")
}
if result.RowsAffected == 0 {
var existing model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Where("source_key = ?", bill.SourceKey).First(&existing).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取既有员工代收款账单失败")
}
return &existing, nil
}
if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
EventID: eventID, ActionCode: constants.AuditActionEmployeeCollectionBillCreated, Summary: summary,
Bill: bill, AfterData: billAuditSnapshot(bill), CorrelationID: correlationID,
}); err != nil {
return nil, err
}
return bill, nil
}
// marshalSnapshot 将只读业务快照序列化为 jsonb快照不得包含付款凭证或外部交易敏感内容。
func marshalSnapshot(snapshot map[string]any) ([]byte, error) {
payload, err := sonic.Marshal(snapshot)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化员工代收款账单快照失败")
}
return payload, nil
}
// rechargeAccountName 读取充值发起账号名称用于欠款人快照;账号已被删除时留空名称。
func rechargeAccountName(ctx context.Context, tx *gorm.DB, accountID uint) (string, error) {
var account model.Account
if err := tx.WithContext(ctx).Unscoped().First(&account, accountID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", nil
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询线下充值发起账号失败")
}
return account.Username, nil
}
// billAuditSnapshot 生成账单审计快照,只包含 ID、来源、金额与状态不含凭证内容。
func billAuditSnapshot(bill *model.EmployeeCollectionBill) map[string]any {
return map[string]any{
"id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID,
"source_key": bill.SourceKey, "source_no": bill.SourceNo,
"debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount,
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
"status": bill.Status,
}
}

View File

@@ -0,0 +1,346 @@
// Package employeecollection 收口员工代收款账单、核销申请与线下收款方式字典的写用例。
// 写用例在事务内保存业务事实与审计事实,读取由 internal/query 提供。
package employeecollection
import (
"context"
stdErrors "errors"
"strconv"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"gorm.io/gorm/clause"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// AuditWriter 接收员工代收款用例在业务事务内产生的配置审计事实。
type AuditWriter interface {
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit systemconfigapp.ChangeAudit) error
}
// PaymentMethodService 维护线下收款方式字典。
// 已启用的字典项由其稳定编码对外,被核销申请引用后只可停用,不允许物理删除或改编码。
type PaymentMethodService struct {
db *gorm.DB
audit AuditWriter
}
// NewPaymentMethodService 创建线下收款方式字典事务脚本。
func NewPaymentMethodService(db *gorm.DB, audit AuditWriter) *PaymentMethodService {
return &PaymentMethodService{db: db, audit: audit}
}
// Create 创建线下收款方式,并在同一事务内写入配置审计。
func (s *PaymentMethodService) Create(
ctx context.Context,
request dto.CreateEmployeeCollectionPaymentMethodRequest,
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return nil, err
}
if s == nil || s.db == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
}
status := constants.EmployeeCollectionPaymentMethodStatusDisabled
if request.Enabled != nil && *request.Enabled {
status = constants.EmployeeCollectionPaymentMethodStatusEnabled
}
var sortOrder int64
if request.Sort != nil {
sortOrder = *request.Sort
}
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
Code: request.Code, Name: request.Name, SortOrder: sortOrder, Status: status, Remark: request.Remark,
})
if err != nil {
return nil, err
}
paymentMethod := &model.EmployeeCollectionPaymentMethod{
Code: normalized.Code, Name: normalized.Name, SortOrder: normalized.SortOrder,
Status: normalized.Status, Remark: normalized.Remark,
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, 0); err != nil {
return err
}
if err := tx.WithContext(ctx).Create(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建线下收款方式失败")
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodCreate,
Description: "创建线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
AfterData: paymentMethodAuditSnapshot(paymentMethod), Result: constants.AuditResultSuccess,
})
})
if err != nil {
return nil, mapPaymentMethodCodeConflict(err)
}
return toPaymentMethodResponse(paymentMethod), nil
}
// Update 修改线下收款方式的名称、排序、启停与备注,并在未被引用时允许修改稳定编码。
func (s *PaymentMethodService) Update(
ctx context.Context,
id uint,
request dto.UpdateEmployeeCollectionPaymentMethodRequest,
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return nil, err
}
if s == nil || s.db == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
}
if id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
var updated *model.EmployeeCollectionPaymentMethod
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
if err != nil {
return err
}
before := *paymentMethod
beforeData := paymentMethodAuditSnapshot(&before)
if request.Code != nil {
code := *request.Code
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
Code: code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
})
if err != nil {
return err
}
if normalized.Code != paymentMethod.Code {
referenced, err := countPaymentMethodReferences(ctx, tx, id)
if err != nil {
return err
}
if referenced > 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced,
"线下收款方式已被核销申请或代理充值申请引用,不能修改稳定编码")
}
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, id); err != nil {
return err
}
}
paymentMethod.Code = normalized.Code
}
if request.Name != nil {
paymentMethod.Name = *request.Name
}
if request.Sort != nil {
paymentMethod.SortOrder = *request.Sort
}
if request.Enabled != nil {
if *request.Enabled {
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusEnabled
} else {
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusDisabled
}
}
if request.Remark != nil {
paymentMethod.Remark = *request.Remark
}
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
Code: paymentMethod.Code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
})
if err != nil {
return err
}
paymentMethod.Code = normalized.Code
paymentMethod.Name = normalized.Name
paymentMethod.SortOrder = normalized.SortOrder
paymentMethod.Status = normalized.Status
paymentMethod.Remark = normalized.Remark
paymentMethod.Updater = operatorID
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodUpdate,
Description: "更新线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
BeforeData: beforeData, AfterData: paymentMethodAuditSnapshot(paymentMethod),
Result: constants.AuditResultSuccess,
}); err != nil {
return err
}
updated = paymentMethod
return nil
})
if err != nil {
return nil, mapPaymentMethodCodeConflict(err)
}
return toPaymentMethodResponse(updated), nil
}
// Delete 物理删除未被任何核销申请引用的线下收款方式,并写入配置审计。
// 已被引用的字典项只允许停用,保证历史申请继续显示冻结名称。
func (s *PaymentMethodService) Delete(ctx context.Context, id uint) error {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return err
}
if s == nil || s.db == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
}
if id == 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
if err != nil {
return err
}
referenced, err := countPaymentMethodReferences(ctx, tx, id)
if err != nil {
return err
}
if referenced > 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced)
}
beforeData := paymentMethodAuditSnapshot(paymentMethod)
paymentMethod.Updater = operatorID
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
}
if err := tx.WithContext(ctx).Delete(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除线下收款方式失败")
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodDelete,
Description: "删除线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
BeforeData: beforeData, Result: constants.AuditResultSuccess,
})
})
}
// requireSuperAdmin 校验当前调用者是超级管理员,并返回其账号 ID。
// 字典维护不对外开放,未授权一律返回同一禁止访问错误。
func requireSuperAdmin(ctx context.Context) (uint, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized)
}
return operatorID, nil
}
// lockPaymentMethod 以行锁读取线下收款方式,未找到返回稳定不存在错误。
func lockPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var paymentMethod model.EmployeeCollectionPaymentMethod
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&paymentMethod, id).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
return &paymentMethod, nil
}
// ensurePaymentMethodCodeAvailable 校验稳定编码在未删除记录中唯一excludeID 用于更新自身。
func ensurePaymentMethodCodeAvailable(ctx context.Context, tx *gorm.DB, code string, excludeID uint) error {
query := tx.WithContext(ctx).Model(&model.EmployeeCollectionPaymentMethod{}).Where("code = ?", code)
if excludeID != 0 {
query = query.Where("id <> ?", excludeID)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下收款方式编码失败")
}
if count > 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
}
return nil
}
// countPaymentMethodReferences 统计引用该收款方式的核销申请与代理充值申请数量。
// 两类引用任一存在即禁止物理删除与改码,历史快照由各自记录冻结。
func countPaymentMethodReferences(ctx context.Context, tx *gorm.DB, id uint) (int64, error) {
var applicationCount int64
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("payment_method_id = ?", id).Count(&applicationCount).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败")
}
if applicationCount > 0 {
return applicationCount, nil
}
var rechargeCount int64
if err := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("offline_payment_method_id = ?", id).Count(&rechargeCount).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计代理充值线下收款方式引用失败")
}
return rechargeCount, nil
}
// mapPaymentMethodCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误。
// 并发创建或改码时唯一索引是最终裁决,避免把约束冲突暴露成内部错误。
func mapPaymentMethodCodeConflict(err error) error {
var pgErr *pgconn.PgError
if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
}
return err
}
// toPaymentMethodResponse 将字典项投影为对外响应。
func toPaymentMethodResponse(paymentMethod *model.EmployeeCollectionPaymentMethod) *dto.EmployeeCollectionPaymentMethodResponse {
if paymentMethod == nil {
return nil
}
return &dto.EmployeeCollectionPaymentMethodResponse{
ID: paymentMethod.ID, Code: paymentMethod.Code, Name: paymentMethod.Name,
Enabled: paymentMethod.Status == constants.EmployeeCollectionPaymentMethodStatusEnabled,
Sort: paymentMethod.SortOrder, Remark: paymentMethod.Remark,
CreatedAt: paymentMethod.CreatedAt, UpdatedAt: paymentMethod.UpdatedAt,
}
}
// paymentMethodAuditConfigKey 生成字典项的审计配置键。
func paymentMethodAuditConfigKey(id uint) string {
return constants.EmployeeCollectionAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
}
// paymentMethodAuditResourceID 生成字典项审计资源标识。
func paymentMethodAuditResourceID(id uint) *string {
value := strconv.FormatUint(uint64(id), 10)
return &value
}
// paymentMethodAuditIdentity 生成字典项审计身份快照,不含任何凭证内容。
func paymentMethodAuditIdentity(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
return map[string]any{
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder,
}
}
// paymentMethodAuditSnapshot 生成字典项审计前后值快照,不含任何凭证内容。
func paymentMethodAuditSnapshot(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
return map[string]any{
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder, "remark": paymentMethod.Remark,
}
}

View File

@@ -0,0 +1,161 @@
package employeecollection
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundOffsetSource 是来源订单退款成功的事实快照。
type RefundOffsetSource struct {
// RefundID 表示本次退款申请 ID。
RefundID uint
// OrderID 表示退款关联的来源订单 ID。
OrderID uint
// RefundAmount 表示本次退款成功金额(分),与退款入账使用的金额为同一实参。
RefundAmount int64
}
// RefundOffsetService 在既有退款成功事务内冲销或提示员工代收款账单。
// 只处理来源为后台线下套餐订单的账单,其他订单直接跳过,不阻断退款链路。
type RefundOffsetService struct {
audit BillAuditWriter
}
// NewRefundOffsetService 创建退款冲销用例。
func NewRefundOffsetService(audit BillAuditWriter) *RefundOffsetService {
return &RefundOffsetService{audit: audit}
}
// ApplyInTx 在既有退款成功事务内按来源唯一键 order:{id} 查找账单并幂等写入冲销事实。
// 同一退款对同一账单至多一条关联:重复投递时关联写入影响 0 行,不再冲减、不再写审计、
// 也不依赖退款事务的 changed 标志。
func (s *RefundOffsetService) ApplyInTx(ctx context.Context, tx *gorm.DB, source RefundOffsetSource) error {
if s == nil || tx == nil || s.audit == nil {
return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置")
}
if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "员工代收款退款冲销参数无效")
}
var bill model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("source_key = ?", employeecollectiondomain.OrderSourceKey(source.OrderID)).
First(&bill).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 来源订单未产生员工代收款账单,跳过而不阻断退款。
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定来源订单员工代收款账单失败")
}
decision, err := employeecollectiondomain.DecideRefundOffset(billAmounts(&bill), source.RefundAmount)
if err != nil {
return err
}
record := &model.EmployeeCollectionBillRefund{
BillID: bill.ID, RefundID: source.RefundID, SourceOrderID: source.OrderID,
RefundAmount: source.RefundAmount, BillReceivableAmount: bill.ReceivableAmount,
Outcome: decision.Outcome, ReducedAmount: decision.ReducedAmount,
}
result := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "bill_id"}, {Name: "refund_id"}},
DoNothing: true,
}).Create(record)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入员工代收款退款冲销关联失败")
}
if result.RowsAffected == 0 {
// 同一退款已冲销过同一账单,保留既有事实。
return nil
}
before := bill
if err := applyRefundOutcome(ctx, tx, &bill, decision); err != nil {
return err
}
offsetEventID, err := composeAuditEventID(
"employee_collection", "bill", "order", uintText(source.OrderID), "refund", uintText(source.RefundID))
if err != nil {
return err
}
return s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
EventID: offsetEventID,
ActionCode: constants.AuditActionEmployeeCollectionBillRefundOffseted,
Summary: constants.GetEmployeeCollectionRefundOutcomeName(decision.Outcome),
Bill: &bill,
BeforeData: map[string]any{
"receivable_amount": before.ReceivableAmount, "received_amount": before.ReceivedAmount,
"reserved_amount": before.ReservedAmount, "status": before.Status,
},
AfterData: map[string]any{
"receivable_amount": bill.ReceivableAmount, "received_amount": bill.ReceivedAmount,
"reserved_amount": bill.ReservedAmount, "status": bill.Status,
"refund_id": source.RefundID, "refund_amount": source.RefundAmount, "outcome": decision.Outcome,
},
CorrelationID: bill.SourceNo,
})
}
// applyRefundOutcome 按判定结果修改账单:全额退款关闭、部分冲减应收,提示结果不修改金额与状态。
// 关闭与冲减都使用 expected-status 条件更新并检查 RowsAffected避免并发覆盖。
func applyRefundOutcome(
ctx context.Context,
tx *gorm.DB,
bill *model.EmployeeCollectionBill,
decision employeecollectiondomain.RefundOffsetDecision,
) error {
expectedStatus := bill.Status
switch decision.Outcome {
case constants.EmployeeCollectionRefundOutcomeHintOnly:
return nil
case constants.EmployeeCollectionRefundOutcomeClosedFull:
closedAt := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND status = ?", bill.ID, expectedStatus).
Updates(map[string]any{
"status": constants.EmployeeCollectionBillStatusClosed,
"closed_reason": "来源订单全额退款",
"closed_at": closedAt,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭来源订单全额退款账单失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "员工代收款账单状态已变化,退款冲销未完成")
}
bill.Status = constants.EmployeeCollectionBillStatusClosed
bill.ClosedReason = "来源订单全额退款"
bill.ClosedAt = &closedAt
return nil
case constants.EmployeeCollectionRefundOutcomeReduced:
amounts, err := billAmounts(bill).ReduceReceivable(decision.ReducedAmount)
if err != nil {
return err
}
nextStatus := amounts.DerivedStatus()
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND status = ?", bill.ID, expectedStatus).
Updates(map[string]any{
"receivable_amount": amounts.Receivable,
"status": nextStatus,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冲减来源订单退款账单应收失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "员工代收款账单状态已变化,退款冲减未完成")
}
bill.ReceivableAmount = amounts.Receivable
bill.Status = nextStatus
return nil
default:
return errors.New(errors.CodeInternalError, "不支持的退款冲销处理结果")
}
}

View File

@@ -0,0 +1,721 @@
// Package merchantpayment provides merchant pool payment routing use cases.
package merchantpayment
import (
"context"
"reflect"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/bytedance/sonic"
)
// ManagementService 负责商户、商户池与授权配置写入。
type ManagementService struct {
db *gorm.DB
audit systemconfigapp.AuditWriter
}
// NewManagementService 创建商户配置用例。
func NewManagementService(db *gorm.DB, audit systemconfigapp.AuditWriter) *ManagementService {
return &ManagementService{db: db, audit: audit}
}
func requireManager(ctx context.Context) error {
kind := middleware.GetUserTypeFromContext(ctx)
if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限访问支付商户配置")
}
return nil
}
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
func validPaymentMethod(method string) bool {
return method == "wechat" || method == "alipay"
}
func validateMerchantConfiguration(paymentMethod, providerType, merchantIdentity string, credentials model.JSONB) error {
paymentMethod, providerType, merchantIdentity = strings.TrimSpace(paymentMethod), strings.TrimSpace(providerType), strings.TrimSpace(merchantIdentity)
if !validPaymentMethod(paymentMethod) || merchantIdentity == "" || len(credentials) == 0 {
return errors.New(errors.CodeInvalidParam, "支付商户配置不完整")
}
var config model.WechatConfig
raw, err := sonic.Marshal(credentials)
if err != nil || sonic.Unmarshal(raw, &config) != nil {
return errors.New(errors.CodeInvalidParam, "支付商户凭证格式无效")
}
switch paymentMethod {
case "wechat":
switch providerType {
case model.ProviderTypeWechat:
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV3Key) == "" || strings.TrimSpace(config.WxCertContent) == "" || strings.TrimSpace(config.WxKeyContent) == "" || strings.TrimSpace(config.WxSerialNo) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "微信直连商户凭证不完整或身份不一致")
}
case model.ProviderTypeWechatV2:
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV2Key) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "微信 v2 商户凭证不完整或身份不一致")
}
case model.ProviderTypeFuiou:
if config.FyMchntCd != merchantIdentity || strings.TrimSpace(config.FyInsCd) == "" || strings.TrimSpace(config.FyTermID) == "" || strings.TrimSpace(config.FyPrivateKey) == "" || strings.TrimSpace(config.FyPublicKey) == "" || strings.TrimSpace(config.FyAPIURL) == "" || strings.TrimSpace(config.FyNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "富友商户凭证不完整或身份不一致")
}
default:
return errors.New(errors.CodeInvalidParam, "微信支付服务商类型无效")
}
case "alipay":
if providerType != "alipay" || config.AliAppID != merchantIdentity || strings.TrimSpace(config.AliPrivateKey) == "" || strings.TrimSpace(config.AliPublicKey) == "" || strings.TrimSpace(config.AliNotifyURL) == "" || strings.TrimSpace(config.AliReturnURL) == "" {
return errors.New(errors.CodeInvalidParam, "支付宝商户凭证不完整或身份不一致")
}
}
return nil
}
func validatePoolRequest(req dto.PaymentMerchantPoolRequest) error {
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
return errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
switch req.Strategy {
case model.PaymentMerchantStrategyAmount:
if req.ThresholdAmount == nil || *req.ThresholdAmount <= 0 || req.ThresholdCount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
return errors.New(errors.CodeInvalidParam, "金额轮询策略参数不完整")
}
case model.PaymentMerchantStrategyCount:
if req.ThresholdCount == nil || *req.ThresholdCount <= 0 || req.ThresholdAmount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
return errors.New(errors.CodeInvalidParam, "笔数轮询策略参数不完整")
}
case model.PaymentMerchantStrategyTime:
if req.TimePeriodValue == nil || *req.TimePeriodValue < 1 || req.TimePeriodUnit == nil || !validTimeUnit(*req.TimePeriodUnit) || req.TimePeriodStartedAt == nil || req.ThresholdAmount != nil || req.ThresholdCount != nil || req.StatisticCycle != nil {
return errors.New(errors.CodeInvalidParam, "时间轮询策略参数不完整")
}
default:
return errors.New(errors.CodeInvalidParam, "不支持的商户池轮询策略")
}
return nil
}
func validStatisticCycle(value string) bool {
return value == "round" || value == "day" || value == "month"
}
func validTimeUnit(value string) bool { return value == "minute" || value == "hour" || value == "day" }
func (s *ManagementService) writeAudit(ctx context.Context, tx *gorm.DB, operation, description, key, name string, id uint, identity, before, after map[string]any) error {
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "支付商户管理审计接缝未配置")
}
resourceID := strconv.FormatUint(uint64(id), 10)
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: key, Module: "payment_merchant", ResourceID: &resourceID, DisplayName: name,
Identity: identity, BeforeData: before, AfterData: after, Result: constants.AuditResultSuccess,
})
}
func merchantAuditIdentity(m *model.PaymentMerchant) map[string]any {
return map[string]any{"id": m.ID, "name": m.Name, "payment_method": m.PaymentMethod, "provider_type": m.ProviderType, "merchant_identity": m.MerchantIdentity, "status": m.Status, "credential_version": m.CredentialVersion}
}
func poolAuditIdentity(p *model.PaymentMerchantPool) map[string]any {
return map[string]any{"id": p.ID, "name": p.Name, "payment_method": p.PaymentMethod, "strategy": p.Strategy, "status": p.Status, "routing_epoch": p.RoutingEpoch}
}
func authorizationAuditIdentity(a *model.WechatAuthorization) map[string]any {
return map[string]any{"id": a.ID, "status": a.Status, "credential_version": a.CredentialVersion, "oa_app_id": a.OaAppID, "miniapp_app_id": a.MiniappAppID}
}
// CreateMerchant 创建独立管理的支付商户。
func (s *ManagementService) CreateMerchant(ctx context.Context, req dto.PaymentMerchantRequest) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if s == nil || s.db == nil || strings.TrimSpace(req.Name) == "" || len(req.Credentials) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "商户参数或凭证不完整")
}
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
m := &model.PaymentMerchant{Name: strings.TrimSpace(req.Name), PaymentMethod: strings.TrimSpace(req.PaymentMethod), ProviderType: strings.TrimSpace(req.ProviderType), MerchantIdentity: strings.TrimSpace(req.MerchantIdentity), Credentials: req.Credentials, CredentialVersion: 1, Remark: strings.TrimSpace(req.Remark), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}}
if req.Enabled {
m.Status = model.PaymentMerchantStatusEnabled
}
if m.MerchantIdentity == "" || m.ProviderType == "" {
return nil, errors.New(errors.CodeInvalidParam, "商户身份或服务商类型不能为空")
}
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(m).Error; err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(m), nil, merchantAuditIdentity(m))
}); err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付商户失败")
}
return merchantResponse(m), nil
}
// ListMerchants returns the privileged configuration projection.
// ListPools returns one page of merchant pools and their ordered members without per-pool member queries.
func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMerchantPoolListRequest) ([]*dto.PaymentMerchantPoolResponse, int64, error) {
if err := requireManager(ctx); err != nil {
return nil, 0, err
}
page, size := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PaymentMerchantPool{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
}
var pools []model.PaymentMerchantPool
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
poolIDs := make([]uint, 0, len(pools))
for index := range pools {
poolIDs = append(poolIDs, pools[index].ID)
}
membersByPool := make(map[uint][]uint, len(pools))
if len(poolIDs) > 0 {
var members []model.PaymentMerchantPoolMember
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
for index := range members {
member := &members[index]
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
}
}
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
for index := range pools {
pool := &pools[index]
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
}
return result, total, nil
}
// GetPool 查询一个商户池及其有序成员。
func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var pool model.PaymentMerchantPool
if err := s.db.WithContext(ctx).First(&pool, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
return poolResponse(ctx, s.db, &pool)
}
// ListMerchants 分页查询特权商户配置。
func (s *ManagementService) ListMerchants(ctx context.Context, req dto.PaymentMerchantListRequest) ([]*dto.PaymentMerchantResponse, int64, error) {
if err := requireManager(ctx); err != nil {
return nil, 0, err
}
page, size := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PaymentMerchant{})
if req.PaymentMethod != nil {
query = query.Where("payment_method = ?", strings.TrimSpace(*req.PaymentMethod))
}
if req.Enabled != nil {
status := model.PaymentMerchantStatusDisabled
if *req.Enabled {
status = model.PaymentMerchantStatusEnabled
}
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
var rows []model.PaymentMerchant
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
result := make([]*dto.PaymentMerchantResponse, 0, len(rows))
for index := range rows {
result = append(result, merchantResponse(&rows[index]))
}
return result, total, nil
}
// GetMerchant 查询一个特权商户配置。
func (s *ManagementService) GetMerchant(ctx context.Context, id uint) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var m model.PaymentMerchant
if err := s.db.WithContext(ctx).First(&m, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
return merchantResponse(&m), nil
}
// UpdateMerchant 更新商户凭证和可变配置。
func (s *ManagementService) UpdateMerchant(ctx context.Context, id uint, req dto.PaymentMerchantUpdateRequest) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if req.PaymentMethod != nil && !validPaymentMethod(strings.TrimSpace(*req.PaymentMethod)) {
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
var m model.PaymentMerchant
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, id).Error; err != nil {
return err
}
before := merchantAuditIdentity(&m)
previous := m
var refs int64
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
return err
}
if refs > 0 && ((req.PaymentMethod != nil && *req.PaymentMethod != m.PaymentMethod) || (req.ProviderType != nil && *req.ProviderType != m.ProviderType) || (req.MerchantIdentity != nil && *req.MerchantIdentity != m.MerchantIdentity)) {
return errors.New(errors.CodeConflict, "已被支付单引用,不能修改收款身份")
}
if req.Name != nil {
m.Name = strings.TrimSpace(*req.Name)
}
if req.PaymentMethod != nil {
m.PaymentMethod = strings.TrimSpace(*req.PaymentMethod)
}
if req.ProviderType != nil {
m.ProviderType = strings.TrimSpace(*req.ProviderType)
}
if req.MerchantIdentity != nil {
m.MerchantIdentity = strings.TrimSpace(*req.MerchantIdentity)
}
if req.Remark != nil {
m.Remark = strings.TrimSpace(*req.Remark)
}
if req.Enabled != nil {
m.Status = model.PaymentMerchantStatusDisabled
if *req.Enabled {
m.Status = model.PaymentMerchantStatusEnabled
}
}
if req.Credentials != nil && !reflect.DeepEqual(m.Credentials, *req.Credentials) {
m.Credentials = *req.Credentials
}
if previous.Name != m.Name || previous.PaymentMethod != m.PaymentMethod || previous.ProviderType != m.ProviderType || previous.MerchantIdentity != m.MerchantIdentity || previous.Status != m.Status || !reflect.DeepEqual(previous.Credentials, m.Credentials) {
m.CredentialVersion++
}
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
return err
}
if err := tx.Save(&m).Error; err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(&m), before, merchantAuditIdentity(&m))
}); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新支付商户失败")
}
return merchantResponse(&m), nil
}
// DeleteMerchant 仅在未被引用且二次确认后删除商户。
func (s *ManagementService) DeleteMerchant(ctx context.Context, id uint, confirm bool) error {
if err := requireManager(ctx); err != nil {
return err
}
if !confirm {
return errors.New(errors.CodeInvalidParam, "删除商户必须二次确认")
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var refs, members int64
var merchant model.PaymentMerchant
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&merchant, id).Error; err != nil {
return err
}
before := merchantAuditIdentity(&merchant)
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
return err
}
if refs > 0 {
return errors.New(errors.CodeConflict, "已被支付单引用的商户不能删除")
}
if err := tx.Model(&model.PaymentMerchantPoolMember{}).Where("merchant_id = ?", id).Count(&members).Error; err != nil {
return err
}
if members > 0 {
return errors.New(errors.CodeConflict, "商户仍属于商户池")
}
r := tx.Delete(&model.PaymentMerchant{}, id)
if r.Error != nil {
return r.Error
}
if r.RowsAffected == 0 {
return errors.New(errors.CodeNotFound, "支付商户不存在")
}
if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付商户", "payment_merchant:"+strconv.FormatUint(uint64(merchant.ID), 10), merchant.Name, merchant.ID, before, before, nil); err != nil {
return err
}
return nil
})
}
// SavePool 创建或更新商户池,并原子替换有序成员。
func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.PaymentMerchantPoolRequest) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if err := validatePoolRequest(req); err != nil {
return nil, err
}
if len(req.MemberIDs) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "商户池至少需要一个商户")
}
var pool model.PaymentMerchantPool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
creating := id == 0
var previousMemberIDs []uint
if !creating {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
return err
}
var previousMembers []model.PaymentMerchantPoolMember
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&previousMembers).Error; err != nil {
return err
}
previousMemberIDs = make([]uint, 0, len(previousMembers))
for _, member := range previousMembers {
previousMemberIDs = append(previousMemberIDs, member.MerchantID)
}
} else {
pool.Creator = middleware.GetUserIDFromContext(ctx)
pool.RoutingEpoch = 1
}
before := poolAuditIdentity(&pool)
if err := validatePoolMembers(ctx, tx, req.PaymentMethod, req.MemberIDs); err != nil {
return err
}
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
pool.RoutingEpoch++
}
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
if req.Enabled {
var others int64
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
return err
}
if others > 0 {
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
}
}
pool.Status = model.PaymentMerchantStatusDisabled
if req.Enabled {
pool.Status = model.PaymentMerchantStatusEnabled
}
pool.Updater = middleware.GetUserIDFromContext(ctx)
if creating {
if err := tx.Create(&pool).Error; err != nil {
return err
}
} else if err := tx.Save(&pool).Error; err != nil {
return err
}
if err := tx.Where("pool_id = ?", pool.ID).Delete(&model.PaymentMerchantPoolMember{}).Error; err != nil {
return err
}
members := make([]model.PaymentMerchantPoolMember, 0, len(req.MemberIDs))
for i, merchantID := range req.MemberIDs {
members = append(members, model.PaymentMerchantPoolMember{PoolID: pool.ID, MerchantID: merchantID, SortOrder: int64(i), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}})
}
if err := tx.Create(&members).Error; err != nil {
return err
}
op := constants.AuditOperationPaymentConfigUpdate
summary := "更新商户池"
if creating {
op = constants.AuditOperationPaymentConfigCreate
summary = "创建商户池"
}
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
})
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
}
return poolResponse(ctx, s.db, &pool)
}
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled bool) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var pool model.PaymentMerchantPool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
return err
}
before := poolAuditIdentity(&pool)
if enabled {
var others int64
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
return err
}
if others > 0 {
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
}
var members []model.PaymentMerchantPoolMember
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
return err
}
ids := make([]uint, 0, len(members))
for _, member := range members {
ids = append(ids, member.MerchantID)
}
if err := validatePoolMembers(ctx, tx, pool.PaymentMethod, ids); err != nil {
return err
}
pool.Status = model.PaymentMerchantStatusEnabled
} else {
pool.Status = model.PaymentMerchantStatusDisabled
}
pool.Updater = middleware.GetUserIDFromContext(ctx)
if err := tx.Save(&pool).Error; err != nil {
return err
}
op := constants.AuditOperationPaymentConfigDeactivate
summary := "停用商户池"
if enabled {
op = constants.AuditOperationPaymentConfigActivate
summary = "启用商户池"
}
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
})
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
}
return poolResponse(ctx, s.db, &pool)
}
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
seen := map[uint]struct{}{}
for _, id := range ids {
if id == 0 {
return errors.New(errors.CodeInvalidParam, "商户ID无效")
}
if _, ok := seen[id]; ok {
return errors.New(errors.CodeInvalidParam, "商户池成员不能重复")
}
seen[id] = struct{}{}
}
var merchants []model.PaymentMerchant
if err := tx.WithContext(ctx).Where("id IN ? AND payment_method = ? AND status = ?", ids, method, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
return err
}
if len(merchants) != len(ids) {
return errors.New(errors.CodeConflict, "商户池成员必须存在、启用且支付方式一致")
}
for index := range merchants {
merchant := &merchants[index]
if err := validateMerchantConfiguration(merchant.PaymentMethod, merchant.ProviderType, merchant.MerchantIdentity, merchant.Credentials); err != nil {
return err
}
}
return nil
}
func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
if pool.PaymentMethod != strings.TrimSpace(request.PaymentMethod) ||
pool.Strategy != request.Strategy ||
!sameString(pool.StatisticCycle, request.StatisticCycle) ||
!sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
return true
}
if sameMemberOrder(previousMemberIDs, request.MemberIDs) {
return false
}
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
}
func sameMemberOrder(left, right []uint) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func sameMemberSet(left, right []uint) bool {
if len(left) != len(right) {
return false
}
seen := make(map[uint]struct{}, len(left))
for _, id := range left {
seen[id] = struct{}{}
}
for _, id := range right {
if _, ok := seen[id]; !ok {
return false
}
}
return true
}
func sameString(left, right *string) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func sameInt64(left, right *int64) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func sameTime(a, b *time.Time) bool {
if a == nil || b == nil {
return a == b
}
return a.Equal(*b)
}
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
}
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
var rows []model.PaymentMerchantPoolMember
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
return nil, err
}
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.MerchantID)
}
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
}
// GetAuthorization 查询特权全局授权配置。
func (s *ManagementService) GetAuthorization(ctx context.Context) (*dto.WechatAuthorizationResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var a model.WechatAuthorization
if err := s.db.WithContext(ctx).First(&a).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权配置失败")
}
return authorizationResponse(&a), nil
}
// SaveAuthorization 创建或更新唯一启用的授权配置。
func (s *ManagementService) SaveAuthorization(ctx context.Context, req dto.WechatAuthorizationRequest) (*dto.WechatAuthorizationResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "" || strings.TrimSpace(req.MiniappAppID) == "" || strings.TrimSpace(req.MiniappAppSecret) == "") {
return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号和小程序凭证必须完整")
}
var authorization model.WechatAuthorization
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&authorization).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
previousStatus := authorization.Status
creating := err == gorm.ErrRecordNotFound
before := authorizationAuditIdentity(&authorization)
if creating {
authorization.Creator = middleware.GetUserIDFromContext(ctx)
authorization.CredentialVersion = 1
}
changed := authorization.OaAppID != req.OaAppID || authorization.OaAppSecret != req.OaAppSecret || authorization.OaToken != req.OaToken || authorization.OaAesKey != req.OaAesKey || authorization.OaOAuthRedirectURL != req.OaOAuthRedirectURL || authorization.MiniappAppID != req.MiniappAppID || authorization.MiniappAppSecret != req.MiniappAppSecret
authorization.OaAppID, authorization.OaAppSecret, authorization.OaToken, authorization.OaAesKey, authorization.OaOAuthRedirectURL, authorization.MiniappAppID, authorization.MiniappAppSecret = req.OaAppID, req.OaAppSecret, req.OaToken, req.OaAesKey, req.OaOAuthRedirectURL, req.MiniappAppID, req.MiniappAppSecret
authorization.Status = model.PaymentMerchantStatusDisabled
if req.Enabled {
authorization.Status = model.PaymentMerchantStatusEnabled
}
if !creating && (changed || previousStatus != authorization.Status) {
authorization.CredentialVersion++
}
authorization.Updater = middleware.GetUserIDFromContext(ctx)
if creating {
if err := tx.Create(&authorization).Error; err != nil {
return err
}
} else {
if err := tx.Save(&authorization).Error; err != nil {
return err
}
}
op := constants.AuditOperationPaymentConfigUpdate
summary := "更新微信授权配置"
if creating {
op = constants.AuditOperationPaymentConfigCreate
summary = "创建微信授权配置"
}
return s.writeAudit(ctx, tx, op, summary, "wechat_authorization:"+strconv.FormatUint(uint64(authorization.ID), 10), "微信授权配置", authorization.ID, authorizationAuditIdentity(&authorization), before, authorizationAuditIdentity(&authorization))
})
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存微信授权配置失败")
}
return authorizationResponse(&authorization), nil
}
func authorizationResponse(a *model.WechatAuthorization) *dto.WechatAuthorizationResponse {
return &dto.WechatAuthorizationResponse{ID: a.ID, OaAppID: a.OaAppID, OaAppSecret: a.OaAppSecret, OaToken: a.OaToken, OaAesKey: a.OaAesKey, OaOAuthRedirectURL: a.OaOAuthRedirectURL, MiniappAppID: a.MiniappAppID, MiniappAppSecret: a.MiniappAppSecret, CredentialVersion: a.CredentialVersion, Enabled: a.Status == model.PaymentMerchantStatusEnabled, UpdatedAt: a.UpdatedAt}
}

View File

@@ -0,0 +1,386 @@
package merchantpayment
import (
"context"
"fmt"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RouteSelection is the non-sensitive route frozen onto a new payment.
type RouteSelection struct {
Merchant *model.PaymentMerchant
Pool *model.PaymentMerchantPool
}
// RuntimeLoader loads current merchant and authorization credentials by version.
type RuntimeLoader struct {
db *gorm.DB
redis *redis.Client
}
// merchantCachePayload is used only for the internal versioned Redis cache and deliberately includes credentials.
// It must never be used for DTOs, logs, audits, or payment snapshots.
type merchantCachePayload struct {
ID uint `json:"id"`
Name string `json:"name"`
PaymentMethod string `json:"payment_method"`
ProviderType string `json:"provider_type"`
MerchantIdentity string `json:"merchant_identity"`
Credentials model.JSONB `json:"credentials"`
CredentialVersion int64 `json:"credential_version"`
Status int `json:"status"`
Remark string `json:"remark"`
}
func merchantCachePayloadFrom(merchant *model.PaymentMerchant) merchantCachePayload {
return merchantCachePayload{ID: merchant.ID, Name: merchant.Name, PaymentMethod: merchant.PaymentMethod, ProviderType: merchant.ProviderType, MerchantIdentity: merchant.MerchantIdentity, Credentials: merchant.Credentials, CredentialVersion: merchant.CredentialVersion, Status: merchant.Status, Remark: merchant.Remark}
}
func (p merchantCachePayload) merchant() *model.PaymentMerchant {
return &model.PaymentMerchant{Model: gorm.Model{ID: p.ID}, Name: p.Name, PaymentMethod: p.PaymentMethod, ProviderType: p.ProviderType, MerchantIdentity: p.MerchantIdentity, Credentials: p.Credentials, CredentialVersion: p.CredentialVersion, Status: p.Status, Remark: p.Remark}
}
// authorizationCachePayload is used only for the internal versioned Redis cache and deliberately includes secrets.
// It must never be used for DTOs, logs, audits, or payment snapshots.
type authorizationCachePayload struct {
ID uint `json:"id"`
OaAppID string `json:"oa_app_id"`
OaAppSecret string `json:"oa_app_secret"`
OaToken string `json:"oa_token"`
OaAesKey string `json:"oa_aes_key"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url"`
MiniappAppID string `json:"miniapp_app_id"`
MiniappAppSecret string `json:"miniapp_app_secret"`
CredentialVersion int64 `json:"credential_version"`
Status int `json:"status"`
}
func authorizationCachePayloadFrom(authorization *model.WechatAuthorization) authorizationCachePayload {
return authorizationCachePayload{ID: authorization.ID, OaAppID: authorization.OaAppID, OaAppSecret: authorization.OaAppSecret, OaToken: authorization.OaToken, OaAesKey: authorization.OaAesKey, OaOAuthRedirectURL: authorization.OaOAuthRedirectURL, MiniappAppID: authorization.MiniappAppID, MiniappAppSecret: authorization.MiniappAppSecret, CredentialVersion: authorization.CredentialVersion, Status: authorization.Status}
}
func (p authorizationCachePayload) authorization() *model.WechatAuthorization {
return &model.WechatAuthorization{Model: gorm.Model{ID: p.ID}, OaAppID: p.OaAppID, OaAppSecret: p.OaAppSecret, OaToken: p.OaToken, OaAesKey: p.OaAesKey, OaOAuthRedirectURL: p.OaOAuthRedirectURL, MiniappAppID: p.MiniappAppID, MiniappAppSecret: p.MiniappAppSecret, CredentialVersion: p.CredentialVersion, Status: p.Status}
}
func NewRuntimeLoader(db *gorm.DB, redis *redis.Client) *RuntimeLoader {
return &RuntimeLoader{db: db, redis: redis}
}
// LoadMerchant first reads the current version from the primary database, then uses only that version's cache entry.
// Disabled merchants remain loadable for frozen historical payments.
func (l *RuntimeLoader) LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
}
return l.loadMerchant(ctx, l.db, id)
}
// loadMerchant 先从当前事务或主库读取版本,再仅命中该版本的缓存。
// 版本在凭证事务提交时递增,因此提交前遗留的旧缓存永远不会被新读取命中。
func (l *RuntimeLoader) loadMerchant(ctx context.Context, db *gorm.DB, id uint) (*model.PaymentMerchant, error) {
var current model.PaymentMerchant
if err := db.WithContext(ctx).First(&current, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取支付商户失败")
}
key := fmt.Sprintf("payment:merchant:%d:%d", current.ID, current.CredentialVersion)
if l.redis != nil {
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
var cached merchantCachePayload
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
return cached.merchant(), nil
}
}
}
if l.redis != nil {
if text, err := sonic.MarshalString(merchantCachePayloadFrom(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, nil
}
// LoadAuthorization first reads the current enabled version and only then resolves its versioned cache entry.
func (l *RuntimeLoader) LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置")
}
var current model.WechatAuthorization
if err := l.db.WithContext(ctx).Where("status = ?", model.PaymentMerchantStatusEnabled).First(&current).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取微信授权配置失败")
}
key := fmt.Sprintf("payment:wechat-authorization:%d:%d", current.ID, current.CredentialVersion)
if l.redis != nil {
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
var cached authorizationCachePayload
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
return cached.authorization(), nil
}
}
}
if l.redis != nil {
if text, err := sonic.MarshalString(authorizationCachePayloadFrom(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, nil
}
// MerchantConfig adapts the merchant credential payload to existing channel constructors without persisting credentials in a payment snapshot.
func MerchantConfig(merchant *model.PaymentMerchant, authorization *model.WechatAuthorization) (*model.WechatConfig, error) {
if merchant == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在")
}
if authorization == nil {
authorization = &model.WechatAuthorization{}
}
raw, err := sonic.Marshal(merchant.Credentials)
if err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
}
var cfg model.WechatConfig
if err := sonic.Unmarshal(raw, &cfg); err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
}
cfg.ID = merchant.ID
cfg.ProviderType = merchant.ProviderType
cfg.IsActive = true
if authorization != nil {
cfg.OaAppID = authorization.OaAppID
cfg.OaAppSecret = authorization.OaAppSecret
cfg.OaToken = authorization.OaToken
cfg.OaAesKey = authorization.OaAesKey
cfg.OaOAuthRedirectURL = authorization.OaOAuthRedirectURL
cfg.MiniappAppID = authorization.MiniappAppID
cfg.MiniappAppSecret = authorization.MiniappAppSecret
}
return &cfg, nil
}
// MerchantConfigWithAuthorization 在需要 AppID 的渠道实例前,按当前版本加载全局微信授权配置。
// 授权字段只进入内存中的渠道配置,绝不写入支付快照、普通 DTO、日志、审计或导出。
func (l *RuntimeLoader) MerchantConfigWithAuthorization(ctx context.Context, merchant *model.PaymentMerchant) (*model.WechatConfig, error) {
authorization, err := l.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
return MerchantConfig(merchant, authorization)
}
// SelectForNewPayment atomically reads the active pool and chooses its current eligible member.
func (l *RuntimeLoader) SelectForNewPayment(ctx context.Context, paymentMethod string, now time.Time) (*RouteSelection, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
var out *RouteSelection
err := l.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
out, err = l.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, now)
return err
})
if err != nil {
return nil, err
}
return out, nil
}
// SelectForNewPaymentWithTx chooses an eligible merchant while retaining the caller's business transaction.
func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.DB, paymentMethod string, now time.Time) (*RouteSelection, error) {
if l == nil || tx == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
var pool model.PaymentMerchantPool
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_method = ? AND status = ?", paymentMethod, model.PaymentMerchantStatusEnabled).First(&pool).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
return nil, err
}
var members []model.PaymentMerchantPoolMember
if err := tx.WithContext(ctx).Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
return nil, err
}
if len(members) == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
ids := make([]uint, 0, len(members))
for _, member := range members {
ids = append(ids, member.MerchantID)
}
var merchants []model.PaymentMerchant
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND payment_method = ? AND status = ?", ids, pool.PaymentMethod, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
return nil, err
}
byID := make(map[uint]*model.PaymentMerchant, len(merchants))
for i := range merchants {
byID[merchants[i].ID] = &merchants[i]
}
ordered := make([]*model.PaymentMerchant, 0, len(members))
for _, member := range members {
if merchant := byID[member.MerchantID]; merchant != nil {
ordered = append(ordered, merchant)
}
}
chosen, err := chooseMerchant(ctx, tx, &pool, ordered, now)
if err != nil {
return nil, err
}
// 新支付在冻结前也按“商户 ID + 当前版本”读取缓存;事务锁保证本次
// 选择与凭证版本属于同一提交边界,避免新建支付误用旧版本缓存。
chosen, err = l.loadMerchant(ctx, tx, chosen.ID)
if err != nil {
return nil, err
}
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
}
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
if len(merchants) == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
if pool.Strategy == model.PaymentMerchantStrategyTime {
return chooseTimedMerchant(pool, merchants, now)
}
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
}
if pool.StatisticCycle == nil {
return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
}
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited {
query = query.Where("paid_at >= ?", start)
}
var rows []model.PaymentMerchantRoutingSuccess
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
amounts := make(map[uint]int64, len(merchants))
counts := make(map[uint]int64, len(merchants))
for _, row := range rows {
amounts[row.MerchantID] += row.Amount
counts[row.MerchantID]++
}
for _, merchant := range merchants {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if pool.ThresholdAmount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
}
if amounts[merchant.ID] < *pool.ThresholdAmount {
return merchant, nil
}
continue
}
if pool.ThresholdCount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
}
if counts[merchant.ID] < *pool.ThresholdCount {
return merchant, nil
}
}
if *pool.StatisticCycle != "round" {
return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户")
}
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
return nil, err
}
return merchants[0], nil
}
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
if pool.TimePeriodStartedAt == nil || pool.TimePeriodValue == nil || pool.TimePeriodUnit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询配置不完整")
}
unit := time.Minute
switch *pool.TimePeriodUnit {
case "hour":
unit = time.Hour
case "day":
unit = 24 * time.Hour
case "minute":
default:
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询单位无效")
}
period := unit * time.Duration(*pool.TimePeriodValue)
if period <= 0 {
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询周期无效")
}
slot := now.Sub(*pool.TimePeriodStartedAt) / period
if slot < 0 {
slot = 0
}
return merchants[int(slot%time.Duration(len(merchants)))], nil
}
func routingWindowStart(cycle string, now time.Time) (time.Time, bool) {
local := now.In(now.Location())
switch cycle {
case "day":
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()), true
case "month":
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, local.Location()), true
default:
return time.Time{}, false
}
}
func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool) error {
next := pool.RoutingEpoch + 1
result := tx.WithContext(ctx).Model(&model.PaymentMerchantPool{}).Where("id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch).Update("routing_epoch", next)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "商户池统计世代已变化")
}
pool.RoutingEpoch = next
return nil
}
// FreezeRoute writes only non-sensitive route facts onto the payment.
func FreezeRoute(payment *model.Payment, route *RouteSelection) {
if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil {
return
}
payment.MerchantID = &route.Merchant.ID
payment.MerchantPoolID = &route.Pool.ID
payment.MerchantIdentity = route.Merchant.MerchantIdentity
payment.MerchantNameSnapshot = route.Merchant.Name
payment.MerchantPaymentMethodSnapshot = route.Merchant.PaymentMethod
payment.MerchantProviderTypeSnapshot = route.Merchant.ProviderType
payment.MerchantPoolNameSnapshot = route.Pool.Name
payment.RoutingStrategySnapshot = route.Pool.Strategy
epoch := route.Pool.RoutingEpoch
payment.RoutingEpoch = &epoch
}
// RecordFirstSuccess 在支付成功事务内写入支付不可变的路由事实。
func RecordFirstSuccess(ctx context.Context, tx *gorm.DB, payment *model.Payment, paidAt time.Time) error {
if payment == nil || payment.MerchantID == nil || payment.MerchantPoolID == nil || payment.RoutingEpoch == nil {
return nil
}
fact := model.PaymentMerchantRoutingSuccess{PaymentID: payment.ID, MerchantID: *payment.MerchantID, PoolID: *payment.MerchantPoolID, RoutingEpoch: *payment.RoutingEpoch, Amount: payment.Amount, PaidAt: paidAt}
if err := tx.WithContext(ctx).Create(&fact).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "写入商户池成功统计失败")
}
return nil
}

View File

@@ -4,9 +4,12 @@ package refundapproval
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -21,6 +24,8 @@ type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
// Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识。
Attempt *model.RefundRequestAttempt
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
@@ -29,6 +34,12 @@ type ApplicationAudit struct {
Order *model.Order
Approval *model.ApprovalInstance
Submitter *model.Account
// Attempt 非空时表示本次写入新增了一条审批尝试记录。
Attempt *model.RefundRequestAttempt
// Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定,
// 使同一次重提的审计事件在该尝试上保持幂等。
Action string
EventID string
}
// AuditWriter 接收退款申请事务内审计事实。
@@ -39,11 +50,16 @@ type AuditWriter interface {
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
SubmitterName string
ApprovalStatus int
}
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。
//
// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识,
// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示,
// 其既有 approval_instance_id 语义与唯一约束保持不变。
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
@@ -55,8 +71,8 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
return &CreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
@@ -91,12 +107,8 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account)
if err != nil {
return nil, err
}
var approvalStatus int
var result *CreateResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, refundID).Error; err != nil {
@@ -113,47 +125,61 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err := tx.WithContext(ctx).First(&currentOrder, current.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder)
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
return err
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: current.ID, SubmitterAccountID: current.Creator,
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
current.ApprovalInstanceID = &reference.InstanceID
refund = current
order = currentOrder
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account,
})
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account, Attempt: attempt,
}); err != nil {
return err
}
result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
return nil
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return result, nil
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
if command.Refund == nil || command.Order == nil || command.Attempt == nil ||
command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -179,33 +205,34 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
}
var activeCount int64
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
Where("order_id = ? AND status IN ?", command.Refund.OrderID, model.RefundActiveStatuses()).
Count(&activeCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
}
if activeCount > 0 {
return errors.New(errors.CodeConflict, "该订单已存在退款申请")
return errors.New(errors.CodeConflict, "该订单已存在活动退款申请")
}
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
}
command.Attempt.RefundID = command.Refund.ID
if err := tx.WithContext(ctx).Create(command.Attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
BusinessID: command.Attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
if err := attachAttemptInstance(ctx, tx, command.Attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := updateRefundLatest(ctx, tx, command.Refund, command.Attempt, reference.InstanceID); err != nil {
return err
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
@@ -214,13 +241,165 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: command.Attempt,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return &CreateResult{Refund: command.Refund, Attempt: command.Attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
// ResubmitCommand 描述重提时的材料变更。
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收),
// Attempt 是本次新增的不可变审批尝试记录。
type ResubmitCommand struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
}
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
//
// 仅已拒绝、已退回或原路退款失败且无审批异常的申请可重提;已成功、待审批、原路处理中或
// 存在审批异常的申请返回状态冲突。每次重提新增不可变尝试记录与独立审批实例,
// 历史材料与审批结果不被覆盖,退款单只更新为最新尝试引用。
func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command ResubmitCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if refundID == 0 || command.Refund == nil || command.Attempt == nil || command.Refund.Creator == 0 {
return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整")
}
account, err := s.loadSubmitter(ctx, command.Refund.Creator)
if err != nil {
return nil, err
}
var created *CreateResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(refundID)).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请重提边界失败")
}
var current model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, refundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if !isResubmittable(&current) {
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
var order model.Order
if err := tx.WithContext(ctx).First(&order, current.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
// 材料已在调用方校验,这里把新值并入当前事实后冻结快照。
current.Method = command.Refund.Method
current.RequestedRefundAmount = command.Refund.RequestedRefundAmount
current.FrozenActualReceivedAmount = command.Refund.FrozenActualReceivedAmount
current.RefundReason = command.Refund.RefundReason
current.RefundVoucherKey = command.Refund.RefundVoucherKey
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
attempt, err := buildAttempt(ctx, tx, &current, &order)
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: current.Creator,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
return err
}
// 同一事务内回写材料、回到待审批并创建新的审批实例。
updates := map[string]any{
"status": model.RefundStatusPending,
"method": current.Method,
"requested_refund_amount": current.RequestedRefundAmount,
"frozen_actual_received_amount": current.FrozenActualReceivedAmount,
"refund_reason": current.RefundReason,
"refund_voucher_key": current.RefundVoucherKey,
"customer_account_info": current.CustomerAccountInfo,
"failure_reason": "",
"failure_message": "",
"channel_refund_status": constants.RefundChannelStatusNone,
"reject_reason": "",
"processor_id": nil,
"processed_at": nil,
"updater": current.Creator,
"updated_at": time.Now().UTC(),
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态已变化")
}
current.Status = model.RefundStatusPending
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt,
Action: constants.AuditActionRefundResubmitted,
EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10),
}); err != nil {
return err
}
created = &CreateResult{Refund: &current, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。
func isResubmittable(refund *model.RefundRequest) bool {
if refund == nil || refund.AnomalyFlag != 0 {
return false
}
for _, status := range model.RefundResubmittableStatuses() {
if refund.Status == status {
return true
}
}
return false
}
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
@@ -234,6 +413,104 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
return &account, nil
}
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (*model.RefundRequestAttempt, error) {
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
if err != nil {
return nil, err
}
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order)
if err != nil {
return nil, err
}
return &model.RefundRequestAttempt{
RefundID: refund.ID,
AttemptNo: attemptNo,
Method: refund.Method,
RefundAmount: refund.RequestedRefundAmount,
FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount,
RefundReason: refund.RefundReason,
CustomerAccountInfo: refund.CustomerAccountInfo,
CustomerVoucherKeys: refund.RefundVoucherKey,
PackageUsageSnapshot: snapshot,
SubmittedByAccountID: refund.Creator,
}, nil
}
// nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。
func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("refund_id = ?", refundID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试序号失败")
}
return row.MaxAttemptNo + 1, nil
}
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况,作为企业微信审批判断材料。
// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (datatypes.JSON, error) {
snapshot := map[string]any{
"order_type": order.OrderType,
"asset_identifier": order.AssetIdentifier,
}
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
var usage model.PackageUsage
if err := tx.WithContext(ctx).First(&usage, *refund.PackageUsageID).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐使用记录失败")
}
} else {
snapshot["package_usage"] = map[string]any{
"id": usage.ID, "package_id": usage.PackageID, "package_name": usage.PackageName,
"usage_type": usage.UsageType, "status": usage.Status,
"data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB,
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt,
}
}
}
encoded, err := sonic.Marshal(snapshot)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款套餐使用快照失败")
}
return datatypes.JSON(encoded), nil
}
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.RefundRequestAttempt, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批尝试实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批尝试实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
// 既有 approval_instance_id 在该函数外单独回写,保持「首次接入企业微信审批的实例」语义不变。
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID,
"latest_approval_instance_id": instanceID,
"updated_at": time.Now().UTC(),
}
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", refund.ID).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新退款申请最新审批引用失败")
}
refund.LatestAttemptID = attempt.ID
refund.LatestApprovalInstanceID = instanceID
return nil
}
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
@@ -247,7 +524,7 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt
constants.ApprovalFieldOrderNo: refund.OrderNo,
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
constants.ApprovalFieldAssetType: refund.OrderType,
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount),
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
constants.ApprovalFieldRefundReason: refund.RefundReason,

View File

@@ -0,0 +1,101 @@
package refundapproval
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ResolveRefundInTx 按审批业务标识解析出退款申请与本次审批尝试记录。
//
// 退款审批的业务标识在审批尝试模式下取尝试记录主键;本能力上线前的存量申请取退款申请主键。
// 尝试记录与退款申请来自两个独立序列,必然存在同值,因此不能只按 businessID 判定归属:
// 必须同时匹配 approval_instance_id才能唯一确定是尝试记录还是退款申请。
//
// 解析顺序固定为「尝试记录优先、退款申请兜底」:
// 1. tb_refund_request_attempt 中 id = businessID 且 approval_instance_id = instanceID
// 2. tb_refund_request 中 id = businessID 且 approval_instance_id = instanceID
// 3. 两者均不匹配返回稳定冲突错误,绝不回落到任一候选业务单。
//
// attempt 在存量兼容路径下为 nil。
func ResolveRefundInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
if tx == nil || businessID == 0 || instanceID == 0 {
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
}
var attempt model.RefundRequestAttempt
err := tx.WithContext(ctx).
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
First(&attempt).Error
switch {
case err == nil:
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
return &refund, &attempt, nil
case err != gorm.ErrRecordNotFound:
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败")
}
var refund model.RefundRequest
err = tx.WithContext(ctx).
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
First(&refund).Error
switch {
case err == nil:
return &refund, nil, nil
case err == gorm.ErrRecordNotFound:
return nil, nil, errors.New(errors.CodeConflict, "退款申请的关联审批实例不一致")
default:
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
}
// ResolveRefundIDInTx 只解析退款申请标识,供审计资源构造与查询关联使用。
func ResolveRefundIDInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (uint, error) {
refund, _, err := ResolveRefundInTx(ctx, tx, businessID, instanceID)
if err != nil {
return 0, err
}
return refund.ID, nil
}
// ResolveRefundForApprovalRequestInTx 解析「审批申请已建立但审批实例尚未回写到业务记录」时刻的业务归属。
//
// 通用审批创建用例在同一事务内先写审批实例并写审批申请审计,业务侧随后才把实例 ID 回写到
// 审批尝试记录。该审计时刻尝试记录已存在但其 approval_instance_id 仍为空,因此按实例一致性
// 校验的常规解析必然不命中。本函数只承认这一种在途形态:
//
// attempt.id = businessID AND attempt.approval_instance_id IS NULL
//
// 其余情况一律返回不存在,由调用方按常规解析的错误失败关闭,不得放宽为任意未回写记录。
func ResolveRefundForApprovalRequestInTx(ctx context.Context, tx *gorm.DB, businessID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
if tx == nil || businessID == 0 {
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
}
var attempt model.RefundRequestAttempt
err := tx.WithContext(ctx).
Where("id = ? AND approval_instance_id IS NULL", businessID).
First(&attempt).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeNotFound, "退款审批尝试记录未回写审批实例")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询在途退款审批尝试记录失败")
}
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
return &refund, &attempt, nil
}

View File

@@ -0,0 +1,32 @@
package refundchannel
import (
"context"
stderrors "errors"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AuditWriter 写退款渠道调用与恢复的可审计事实。
// 实现必须与业务更新在同一事务内写入,且摘要不得包含凭证或渠道报文原文。
type AuditWriter interface {
WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action string, message string) error
}
// CompletionNotifier 在渠道明确退款成功时补写退款完成通知事实。
// 通知载荷由退款能力拥有,本包只负责在正确的时点与事务内触发。
type CompletionNotifier interface {
AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error
}
// appErrorCode 读取应用错误码;非应用错误返回 0。
func appErrorCode(err error) int {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return appErr.Code
}
return 0
}

View File

@@ -0,0 +1,75 @@
package refundchannel
import (
"context"
"strconv"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// EventRefundChannelRefund 是退款进入渠道原路处理中后的执行事件。
const EventRefundChannelRefund = "refund.channel.refund.requested"
// refundChannelPayloadVersion 是渠道原路退款事件的载荷版本。
const refundChannelPayloadVersion = 1
// Payload 是渠道原路退款事件的载荷。
type Payload struct {
RefundID uint `json:"refund_id"`
OrderID uint `json:"order_id"`
}
// AppendRefundChannelRefund 在企微通过事务内幂等写入渠道原路退款执行事件。
// 同一退款申请使用稳定事件 ID重复投递不会重复创建事实。
func AppendRefundChannelRefund(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, refundID, orderID uint) error {
if repository == nil {
return gorm.ErrInvalidDB
}
value := strconv.FormatUint(uint64(refundID), 10)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventRefundChannelRefund+":", value),
EventType: EventRefundChannelRefund,
PayloadVersion: refundChannelPayloadVersion,
AggregateType: "refund", AggregateID: value,
ResourceType: "refund", ResourceID: value,
BusinessKey: EventRefundChannelRefund + ":" + value,
Payload: Payload{RefundID: refundID, OrderID: orderID},
})
return err
}
// Consumer 把渠道原路退款事件转成一次性资金动作。
type Consumer struct {
service *Service
}
// NewConsumer 创建渠道原路退款事件消费者。
func NewConsumer(service *Service) *Consumer {
return &Consumer{service: service}
}
// Consume 幂等执行渠道原路退款;重复投递由退款申请状态与渠道请求号共同兜住。
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
var payload Payload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return outbox.Permanent(err)
}
if envelope.EventType != EventRefundChannelRefund ||
envelope.PayloadVersion != refundChannelPayloadVersion || payload.RefundID == 0 {
return outbox.Permanent(gorm.ErrInvalidData)
}
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款执行能力未配置")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
return c.service.Execute(ctx, payload.RefundID)
}
// 编译期断言:渠道原路退款消费者满足公共 Outbox 的消费边界。
var _ outbox.EventConsumer = (*Consumer)(nil)

View File

@@ -0,0 +1,71 @@
package refundchannel
import (
"crypto/rand"
"strconv"
"strings"
"time"
)
// 渠道退款请求号生成规则参数。
const (
// channelRefundRequestNoAlphabet 随机段字符集:大写字母与数字。
channelRefundRequestNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// channelRefundRequestNoRandomLen 随机段长度,取渠道规则上限 18 位。
channelRefundRequestNoRandomLen = 18
// channelRefundRequestNoLength 请求号总长:前缀 4 + 日期 8 + 随机段 18。
channelRefundRequestNoLength = 30
// channelRefundRequestNoPrefixLen 前缀固定长度,不足左侧补 0超过取前 4 位。
channelRefundRequestNoPrefixLen = 4
)
// shanghaiLocation 上海时区(东八区),用于按渠道规则生成日期段。
var shanghaiLocation = time.FixedZone("CST", 8*3600)
// BuildChannelRefundRequestNo 按三渠道共性规则生成渠道退款请求号。
//
// 规则与富友流水号完全一致(本包不引入渠道 SDK因此在此独立实现同一规则
// 前缀规整为 4 位(不足左侧补 0超过取前 4 位)+ 上海时区日期 yyyyMMdd + 18 位大写字母
// 数字随机段,总长 30。prefix 由调用方按冻结服务商类型传入:富友传机构码,其余渠道传
// 商户标识数字段。生成结果一经写入审批尝试记录即不可变,作为渠道幂等标识复用。
func BuildChannelRefundRequestNo(prefix string, now time.Time) string {
var builder strings.Builder
builder.Grow(channelRefundRequestNoLength)
builder.WriteString(normalizeChannelRefundPrefix(prefix))
builder.WriteString(now.In(shanghaiLocation).Format("20060102"))
buffer := make([]byte, channelRefundRequestNoRandomLen)
if _, err := rand.Read(buffer); err != nil {
// 随机源不可用时退回时间派生的同字符集随机段,保证结果仍满足格式与长度约束。
builder.WriteString(fallbackRandomSegment(now))
return builder.String()
}
for _, value := range buffer {
builder.WriteByte(channelRefundRequestNoAlphabet[int(value)%len(channelRefundRequestNoAlphabet)])
}
return builder.String()
}
// normalizeChannelRefundPrefix 将前缀规整为 4 位:不足左侧补 0超过取前 4 位。
func normalizeChannelRefundPrefix(prefix string) string {
normalized := strings.TrimSpace(prefix)
if len(normalized) >= channelRefundRequestNoPrefixLen {
return normalized[:channelRefundRequestNoPrefixLen]
}
return strings.Repeat("0", channelRefundRequestNoPrefixLen-len(normalized)) + normalized
}
// fallbackRandomSegment 生成 18 位大写字母数字随机段,仅用于随机源不可用时的兜底。
func fallbackRandomSegment(now time.Time) string {
segment := strings.ToUpper(strconv.FormatInt(now.UnixNano(), 36))
segment = strings.Map(func(char rune) rune {
if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') {
return char
}
return 'X'
}, segment)
if len(segment) >= channelRefundRequestNoRandomLen {
return segment[:channelRefundRequestNoRandomLen]
}
return segment + strings.Repeat("0", channelRefundRequestNoRandomLen-len(segment))
}

View File

@@ -0,0 +1,193 @@
package refundchannel
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Stats 是一次恢复扫描的可观察结果。
//
// Scanned 为本次扫描到的申请数Confirmed 为回填为渠道明确成功的申请数;
// Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口
// 超期后终止本次渠道执行Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败);
// Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。
type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int }
// ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) {
stats := Stats{}
if err := s.requireReady(); err != nil {
return stats, err
}
var refunds []model.RefundRequest
if err := s.db.WithContext(ctx).
// 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。
Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?",
model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0).
Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil {
return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败")
}
stats.Scanned = len(refunds)
if len(refunds) == 0 {
return stats, nil
}
payments, err := s.loadPaidPayments(ctx, refunds)
if err != nil {
return stats, err
}
now := s.now().UTC()
var firstErr error
for index := range refunds {
if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil {
stats.Skipped++
s.logger.Warn("渠道原路退款恢复单条处理失败",
zap.Uint("refund_id", refunds[index].ID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。
func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error {
target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID])
if err != nil {
return err
}
if failureReason != "" {
// 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。
stats.Pending++
s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询",
zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason))
return nil
}
if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window {
return s.flagQueryWindowExpired(ctx, refund, reason, now, stats)
}
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
defer cancel()
result, callErr := s.refunder.Query(callCtx, target)
if callErr != nil {
// 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。
stats.Pending++
return nil
}
applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now)
if err != nil {
return err
}
if !applied {
stats.Skipped++
return nil
}
switch result.State {
case StateSuccess:
stats.Confirmed++
case StateFailed:
stats.Failed++
default:
stats.Pending++
}
return nil
}
// queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。
// 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。
//
// - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束;
// - 微信 v2受理响应不含退款状态、渠道侧无查询时限此处按本地阈值放弃轮询并转人工
// 避免一笔未知结果被无限重试。
func queryWindowPolicy(providerType string) (time.Duration, string) {
switch providerType {
case model.ProviderTypeFuiou:
return fuiouQueryWindow, anomalyReasonFuiouQueryWindow
case model.ProviderTypeWechatV2:
return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow
default:
return 0, ""
}
}
// flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。
//
// 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的
// timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。
// 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定),
// 而 status 只表达该尝试的渠道路径已终止。
//
// 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次
// 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。
func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error {
stats.Failed++
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0",
refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing).
// UpdateColumns 不隐式推进 updated_at窗口起算点必须保留在进入原路处理中的时刻
// 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。
UpdateColumns(map[string]any{
"status": model.RefundStatusChannelFailed,
"channel_refund_status": constants.RefundChannelStatusFailed,
"failure_reason": constants.RefundFailureTimeoutUnknown,
"anomaly_flag": 1,
"anomaly_reason": reason,
})
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败")
}
if updated.RowsAffected != 1 {
return nil
}
refund.Status = model.RefundStatusChannelFailed
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
refund.FailureReason = constants.RefundFailureTimeoutUnknown
refund.AnomalyFlag = 1
refund.AnomalyReason = reason
return s.audit.WriteRefundChannelResult(ctx, tx, refund,
constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对")
})
if err != nil {
if appErrorCode(err) != 0 {
return err
}
return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败")
}
return nil
}
// refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。
// 结果未知的回写不会推进 updated_at因此窗口始终从进入原路处理中的时点起算。
func refundWindowStart(refund *model.RefundRequest) time.Time {
if refund.ChannelRefundedAt != nil {
return refund.ChannelRefundedAt.UTC()
}
return refund.UpdatedAt.UTC()
}
// loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。
func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) {
orderIDs := make([]uint, 0, len(refunds))
for index := range refunds {
orderIDs = append(orderIDs, refunds[index].OrderID)
}
var payments []model.Payment
if err := s.db.WithContext(ctx).
Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id ASC").Find(&payments).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败")
}
latest := make(map[uint]*model.Payment, len(payments))
for index := range payments {
latest[payments[index].OrderID] = &payments[index]
}
return latest, nil
}

View File

@@ -0,0 +1,701 @@
// Package refundchannel 执行与恢复渠道原路退款。
//
// 本包只编排渠道退款的资金动作与本地状态流转:请求号决定执行幂等、结果按条件更新回写、
// 失败按稳定分类终结、未知结果交由恢复扫描查询收敛。具体渠道协议由按服务商类型注入的
// Refunder 实现,本包不依赖任何渠道 SDK也绝不在数据库事务内发起渠道调用。
package refundchannel
import (
"context"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// State 是渠道调用的稳定结果状态。
type State string
const (
StateSuccess State = "success" // 渠道明确成功
StateFailed State = "failed" // 渠道明确失败
StateUnknown State = "unknown" // 超时或结果未确认,可恢复
)
// 渠道原路退款的固定运行参数。
const (
// recoveryBatchSize 是恢复扫描的单批上限,与既有批次扫描用例保持一致。
recoveryBatchSize = 50
// fuiouQueryWindow 是富友退款查询窗口:其退款查询接口只支持 3 日内的退款交易。
fuiouQueryWindow = 72 * time.Hour
// wechatV2QueryWindow 是微信 v2 退款结果的本地确认上限。
// 微信 v2 退款接口的受理响应不含退款状态,终态只能由退款查询确认;渠道侧没有查询时限,
// 因此这里只设本地的放弃阈值:超过该期限仍未确认即停止轮询并转人工核对,避免无限查询。
wechatV2QueryWindow = 7 * 24 * time.Hour
// channelCallTimeout 是单次渠道退款申请或查询调用的最长等待时间。
channelCallTimeout = 30 * time.Second
// fuiouOrderTypeWechat 是富友原交易的 order_type 当前唯一可达值(富友微信主扫)。
// 与 pkg/fuiou.OrderTypeWechat 取值一致;本包不引入渠道 SDK因此在此固定回传该冻结值。
fuiouOrderTypeWechat = "WECHAT"
// anomalyReasonFuiouQueryWindow 是富友退款查询窗口超期的异常原因。
anomalyReasonFuiouQueryWindow = "富友退款查询窗口已过,需人工核对"
// anomalyReasonWechatV2QueryWindow 是微信 v2 退款结果超过本地确认上限的异常原因。
anomalyReasonWechatV2QueryWindow = "微信 v2 退款超过 7 天未确认结果,需人工核对"
// failureMessageUnknown 是渠道退款调用结果未确认时的安全摘要。
failureMessageUnknown = "渠道退款调用结果未确认,等待查询恢复"
// failureMessagePaymentFact 是本地原支付事实不可用时的安全摘要。
failureMessagePaymentFact = "本地原支付事实不可用,未能发起渠道退款"
// failureMessageCredential 是商户退款必需凭证不完整时的安全摘要。
failureMessageCredential = "商户退款必需凭证不完整,未发起渠道退款"
// failureMessageNoRequestNo 是退款申请缺少渠道退款请求号时的安全摘要。
failureMessageNoRequestNo = "退款申请缺少渠道退款请求号,未发起渠道退款"
// failureMessageMaxRunes 是失败安全摘要的字符上限,与 failure_message 列宽约束一致。
failureMessageMaxRunes = 480
// providerTypeAlipay 是支付宝商户的 provider_type 取值model 未定义该常量,
// 取值与商户凭证管理保持的 "alipay" 完全一致。
providerTypeAlipay = "alipay"
)
// Target 是执行一次渠道原路退款所需的全部冻结事实。
type Target struct {
RefundID uint
RefundNo string
OrderID uint
OrderNo string
ProviderType string // model.ProviderType*
Config *model.WechatConfig // 商户当前凭证,绝不落库或记日志
PaymentNo string // 原支付单商户订单号(微信/支付宝 out_trade_no、富友 mchnt_order_no
ChannelTradeNo string // 原支付单渠道交易流水
ChannelOrderType string // 富友原交易 order_type
PaidAt *time.Time
PaidAmount int64 // 原支付单渠道订单总金额(分),渠道退款请求的 total_amt 必须回传该值
RefundAmount int64
FrozenActualReceivedAmount int64
ChannelRefundRequestNo string
}
// Result 是渠道调用或查询的映射结果。
type Result struct {
State State
ChannelRefundNo string // 渠道退款流水号
ChannelRefundAmount int64 // 渠道退款金额(分)
SettledAt string // 渠道结算日期原文,可空
FailureReason string // pkg/constants.RefundFailure* 稳定编码,仅 State!=StateSuccess 时有值
FailureMessage string // 安全摘要,不得含凭证或报文原文
}
// Refunder 是渠道原路退款 Port由基础设施层按服务商类型实现。
type Refunder interface {
// Refund 至多提交一次可确认的退款请求;请求号由 Target.ChannelRefundRequestNo 提供。
Refund(ctx context.Context, target Target) (Result, error)
// Query 只查询渠道退款状态,不得发起资金动作。
Query(ctx context.Context, target Target) (Result, error)
}
// MerchantLoader 按冻结商户 ID 加载商户当前凭证与渠道所需的全局授权配置。
type MerchantLoader interface {
LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error)
LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error)
}
// Service 执行与恢复原路退款。
type Service struct {
db *gorm.DB
loader MerchantLoader
refunder Refunder
audit AuditWriter
notifier CompletionNotifier
logger *zap.Logger
now func() time.Time
}
// NewService 创建渠道原路退款用例。
func NewService(db *gorm.DB, loader MerchantLoader, refunder Refunder, audit AuditWriter) *Service {
return &Service{db: db, loader: loader, refunder: refunder, audit: audit, logger: zap.NewNop(), now: time.Now}
}
// SetCompletionNotifier 注入退款完成通知写入能力;未注入时成功路径不写通知事实。
func (s *Service) SetCompletionNotifier(notifier CompletionNotifier) *Service {
if s == nil {
return s
}
s.notifier = notifier
return s
}
// SetLogger 注入渠道原路退款运行日志。
func (s *Service) SetLogger(logger *zap.Logger) *Service {
if s == nil {
return s
}
if logger == nil {
logger = zap.NewNop()
}
s.logger = logger
return s
}
// PrepareInTx 在企微通过事务内为原路方式生成请求号并把退款申请置为原路处理中。
//
// 请求号由提交或重提在不可变审批尝试记录上生成并冻结;尝试记录已带请求号时直接复用,
// 仅在缺失时防御性补生成。条件更新要求申请仍处于待审批,否则视为并发冲突。
func (s *Service) PrepareInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
if s == nil || tx == nil || refund == nil || refund.ID == 0 {
return errors.New(errors.CodeInvalidParam, "渠道原路退款准备参数无效")
}
if refund.Method != constants.RefundMethodOriginalRoute {
return nil
}
requestNo := ""
if attempt != nil {
requestNo = strings.TrimSpace(attempt.ChannelRefundRequestNo)
}
if requestNo == "" {
// 正常运行不会走到这里:请求号在提交/重提时已冻结到尝试记录上。
requestNo = BuildChannelRefundRequestNo(strconv.FormatUint(uint64(refund.ID), 10), s.now())
}
now := s.now().UTC()
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusChannelProcessing,
"channel_refund_status": constants.RefundChannelStatusProcessing,
"channel_refund_request_no": requestNo,
"updated_at": now,
})
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "进入渠道原路退款处理中失败")
}
if updated.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态不允许进入渠道原路退款处理中")
}
if attempt != nil && attempt.ID != 0 {
write := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
Where("id = ?", attempt.ID).
Update("channel_refund_request_no", requestNo)
if write.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, write.Error, "写入退款尝试渠道退款请求号失败")
}
if write.RowsAffected != 1 {
s.logger.Warn("退款尝试渠道退款请求号未写入", zap.Uint("refund_id", refund.ID), zap.Uint("attempt_id", attempt.ID))
}
attempt.ChannelRefundRequestNo = requestNo
}
if err := AppendRefundChannelRefund(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款事件失败")
}
refund.Status = model.RefundStatusChannelProcessing
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
refund.ChannelRefundRequestNo = requestNo
return nil
}
// Execute 幂等执行一次原路退款;已明确成功或已失败终结的申请直接返回 nil。
//
// 本地事实在只读事务内锁定读取。资金动作「至多提交一次」由提交认领保证:
// 提交前先以 channel_submitted_at IS NULL 条件认领,只有认领成功的执行才调用 Refund
// 认领失败表示该尝试已提交过渠道退款请求(例如 Outbox 事件被重复投递或人工重放),
// 此时只查询渠道结果并回填,绝不再次提交资金动作。
func (s *Service) Execute(ctx context.Context, refundID uint) error {
if err := s.requireReady(); err != nil {
return err
}
if refundID == 0 {
return errors.New(errors.CodeInvalidParam, "渠道原路退款缺少退款申请标识")
}
facts, proceed, err := s.loadExecutionFacts(ctx, refundID)
if err != nil {
return err
}
if !proceed {
return nil
}
payment, err := s.loadPaidPayment(ctx, facts.refund.OrderID)
if err != nil {
return err
}
target, failureReason, failureMessage, err := s.buildTarget(ctx, facts.refund, facts.attempt, payment)
if err != nil {
return err
}
now := s.now().UTC()
if failureReason != "" {
// 本地事实不可用时绝不调用渠道,按稳定失败分类终结本次原路退款。
if _, err := s.writeback(ctx, facts.refund, target, Result{
State: StateFailed, FailureReason: failureReason, FailureMessage: failureMessage,
}, constants.AuditActionRefundChannelCalled, now); err != nil {
return err
}
return nil
}
// 认领本次提交:认领成功才拥有提交权,失败则本次只做查询。
claimed, err := s.claimChannelSubmission(ctx, refundID, now)
if err != nil {
return err
}
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
defer cancel()
if !claimed {
// 已提交过:只查询渠道结果,绝不再次提交资金动作。
result, callErr := s.refunder.Query(callCtx, target)
if callErr != nil {
// 查询失败不能推断渠道结果,保持原路处理中等待恢复扫描。
return nil
}
s.logger.Info("渠道退款请求已提交过,本次仅查询结果",
zap.Uint("refund_id", refundID), zap.String("channel_refund_request_no", target.ChannelRefundRequestNo))
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelRecovered, now)
return err
}
result, callErr := s.refunder.Refund(callCtx, target)
if callErr != nil {
// 传输层错误不能推断渠道未受理,一律按结果未知保持可恢复。
result = Result{State: StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: failureMessageUnknown}
}
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelCalled, now)
return err
}
// claimChannelSubmission 以条件更新认领本次渠道退款提交权。
//
// 返回 true 表示调用方获得提交权、可以调用渠道退款接口false 表示该尝试在此之前
// 已提交过(重复投递或人工重放),调用方只能查询。认领与回写同以 status = 原路处理中
// 为谓词,因此并发执行也至多有一次认领成功。
func (s *Service) claimChannelSubmission(ctx context.Context, refundID uint, now time.Time) (bool, error) {
claimed := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND channel_submitted_at IS NULL",
refundID, model.RefundStatusChannelProcessing).
UpdateColumn("channel_submitted_at", now)
if claimed.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领渠道退款提交权失败")
}
return claimed.RowsAffected == 1, nil
}
// executionFacts 是一次渠道执行所需的本地冻结事实。
type executionFacts struct {
refund *model.RefundRequest
attempt *model.RefundRequestAttempt
}
// loadExecutionFacts 在只读事务内锁定退款申请并读取本次执行所需的尝试记录。
// proceed 为 false 表示申请已终结、方式不符或已由并发执行推进,调用方必须直接结束本次执行。
func (s *Service) loadExecutionFacts(ctx context.Context, refundID uint) (*executionFacts, bool, error) {
facts := &executionFacts{}
proceed := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var refund model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refundID).First(&refund).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
facts.refund = &refund
if refund.Method != constants.RefundMethodOriginalRoute {
s.logger.Warn("退款方式不是原路,跳过渠道退款", zap.Uint("refund_id", refund.ID), zap.String("method", refund.Method))
return nil
}
// 已通过或已失败终结的申请直接返回;渠道已明确成功的申请也不得再次调用渠道。
if refund.Status != model.RefundStatusChannelProcessing ||
refund.ChannelRefundStatus == constants.RefundChannelStatusSucceeded {
return nil
}
attempt, err := loadAttempt(ctx, tx, &refund)
if err != nil {
return err
}
facts.attempt = attempt
proceed = true
return nil
})
if err != nil {
return nil, false, err
}
return facts, proceed, nil
}
// loadAttempt 按申请冻结的最新尝试引用读取尝试记录;引用缺失时退回该申请的最大尝试序号。
func loadAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) (*model.RefundRequestAttempt, error) {
var attempt model.RefundRequestAttempt
query := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{})
if refund.LatestAttemptID != 0 {
if err := query.Where("id = ?", refund.LatestAttemptID).First(&attempt).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
}
return &attempt, nil
}
if err := query.Where("refund_id = ?", refund.ID).Order("attempt_no DESC").First(&attempt).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
}
return &attempt, nil
}
// loadPaidPayment 读取订单最近一笔已支付的套餐支付单,作为原路退款的原支付事实。
func (s *Service) loadPaidPayment(ctx context.Context, orderID uint) (*model.Payment, error) {
var payment model.Payment
if err := s.db.WithContext(ctx).
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id DESC").First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取原支付单失败")
}
return &payment, nil
}
// buildTarget 在事务外组装渠道调用目标。
// 返回非空 failureReason 表示本地事实不可用:调用方必须按该分类回写且绝不调用渠道。
func (s *Service) buildTarget(ctx context.Context, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, payment *model.Payment) (Target, string, string, error) {
target := Target{
RefundID: refund.ID, RefundNo: refund.RefundNo, OrderID: refund.OrderID, OrderNo: refund.OrderNo,
RefundAmount: resolveRefundAmount(refund, attempt),
FrozenActualReceivedAmount: resolveFrozenAmount(refund, attempt),
ChannelRefundRequestNo: resolveChannelRefundRequestNo(refund, attempt),
}
if target.ChannelRefundRequestNo == "" {
// 没有请求号就没有渠道幂等标识:本次尝试从未提交过资金动作,可按明确失败终结。
return target, constants.RefundFailurePaymentFactInvalid, failureMessageNoRequestNo, nil
}
if payment == nil {
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
}
target.PaymentNo = strings.TrimSpace(payment.PaymentNo)
target.ChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
target.PaidAt = payment.PaidAt
target.PaidAmount = payment.Amount
if target.RefundAmount <= 0 || target.FrozenActualReceivedAmount <= 0 ||
target.RefundAmount > target.FrozenActualReceivedAmount {
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
}
config, providerType, err := s.loadChannelConfig(ctx, payment)
if err != nil {
if !credentialFailure(err) {
return target, "", "", err
}
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
}
if !credentialComplete(providerType, config) {
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
}
target.ProviderType = providerType
target.Config = config
if providerType == model.ProviderTypeFuiou {
target.ChannelOrderType = fuiouOrderTypeWechat
}
return target, "", "", nil
}
// loadChannelConfig 加载原支付单实际收款商户的当前凭证。
// 新支付按冻结商户标识加载该商户当前凭证与全局微信授权merchant_id 为空仅表示数据留存期内的
// 历史支付,按其原支付配置读取,禁止按当前启用商户池推断历史商户。
func (s *Service) loadChannelConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, string, error) {
if payment.MerchantID != nil {
merchant, err := s.loader.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, "", err
}
if merchant == nil {
return nil, "", errors.New(errors.CodeNoPaymentConfig, "原支付收款商户不存在")
}
// 仅微信直连v3/v2需要全局微信授权配置中的 AppID其他服务商传 nil 避免无谓失败。
var authorization *model.WechatAuthorization
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.loader.LoadAuthorization(ctx)
if err != nil {
return nil, "", err
}
}
config, err := merchantpayment.MerchantConfig(merchant, authorization)
if err != nil {
return nil, "", err
}
return config, merchant.ProviderType, nil
}
if payment.PaymentConfigID == nil {
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
var legacy model.WechatConfig
if err := s.db.WithContext(ctx).Unscoped().Where("id = ?", *payment.PaymentConfigID).First(&legacy).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
}
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
}
return &legacy, legacy.ProviderType, nil
}
// credentialComplete 判断该服务商类型发起原路退款所需的凭证是否完整。
// 规则与本 Change 冻结的商户退款凭证要求一致,只判断必需字段非空,不新增任何凭证键。
// RefundCredentialIssue 返回该服务商类型的退款必需凭证缺失原因;凭证完整时返回空串。
//
// 这是退款能力的唯一判定入口:退款请求不向渠道传递任何通知地址,因此支付通知地址与
// 支付跳转地址都不是退款必需凭证。微信 v2 退款接口(/secapi/pay/refund请求需要双向
// 证书,因此其必需凭证包含 API 客户端证书;缺少该证书的 v2 商户按其凭证完整性判定为
// 不可用,补录证书后即可用。判定结果不提供人工开关。
func RefundCredentialIssue(providerType string, config *model.WechatConfig) string {
if config == nil {
return failureMessageCredential
}
switch providerType {
case model.ProviderTypeWechat:
if !completeFields(config.WxMchID, config.WxAPIV3Key, config.WxCertContent,
config.WxKeyContent, config.WxSerialNo) {
return "冻结微信商户退款凭证不完整"
}
case model.ProviderTypeWechatV2:
// v2 退款接口为双向证书接口:缺少 API 客户端证书时按其凭证完整性判定为不可用。
if !completeFields(config.WxMchID, config.WxAPIV2Key, config.WxClientCertContent, config.WxClientKeyContent) {
return "冻结微信 v2 商户退款凭证不完整(缺少 API 客户端证书)"
}
case model.ProviderTypeFuiou:
if !completeFields(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey,
config.FyPublicKey, config.FyAPIURL) {
return "冻结富友商户退款凭证不完整"
}
case providerTypeAlipay:
if !completeFields(config.AliAppID, config.AliPrivateKey, config.AliPublicKey) {
return "冻结支付宝商户退款凭证不完整"
}
default:
return "冻结商户不支持原路退款"
}
return ""
}
// credentialComplete 判断该服务商类型的退款必需凭证是否完整。
func credentialComplete(providerType string, config *model.WechatConfig) bool {
return RefundCredentialIssue(providerType, config) == ""
}
func completeFields(values ...string) bool {
for _, value := range values {
if strings.TrimSpace(value) == "" {
return false
}
}
return true
}
// credentialFailure 判断凭证加载错误属于渠道侧不可执行的凭证问题,而不是可重试的基础设施错误。
func credentialFailure(err error) bool {
switch appErrorCode(err) {
case errors.CodeNoPaymentConfig, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeWechatConfigUnavailable:
return true
default:
return false
}
}
// resolveChannelRefundRequestNo 取本次执行的渠道幂等标识。
// 尝试记录持有本次提交冻结的请求号,优先级高于退款单上的展示快照:重提会生成新请求号,
// 沿用旧快照会让渠道按旧请求号再次受理;两者一致时结果相同。
func resolveChannelRefundRequestNo(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) string {
if attempt != nil {
if requestNo := strings.TrimSpace(attempt.ChannelRefundRequestNo); requestNo != "" {
return requestNo
}
}
return strings.TrimSpace(refund.ChannelRefundRequestNo)
}
// resolveRefundAmount 取本次原路退款的权威金额:优先审批实际退款金额,其次尝试记录冻结金额。
func resolveRefundAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
if refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount > 0 {
return *refund.ApprovedRefundAmount
}
if refund.RequestedRefundAmount > 0 {
return refund.RequestedRefundAmount
}
if attempt != nil {
return attempt.RefundAmount
}
return 0
}
// resolveFrozenAmount 取本次原路退款的冻结实收金额。
func resolveFrozenAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
if refund.FrozenActualReceivedAmount > 0 {
return refund.FrozenActualReceivedAmount
}
if attempt != nil {
return attempt.FrozenActualReceivedAmount
}
return 0
}
// writeback 在独立事务内按渠道结果条件更新退款申请、订单与审计事实。
// applied 为 false 表示记录已被并发推进,本次不改动任何状态。
func (s *Service) writeback(ctx context.Context, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
applied := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
applied, err = s.applyResult(ctx, tx, refund, target, result, action, now)
return err
})
if err != nil {
return false, err
}
if !applied {
s.logger.Warn("渠道原路退款结果未回写,记录已被并发推进",
zap.Uint("refund_id", refund.ID), zap.String("action", action), zap.String("state", string(result.State)))
}
return applied, nil
}
// applyResult 按结果状态把渠道事实条件回写到退款申请,成功时同步把订单置为已退款。
// 所有状态流转都以 status = 原路处理中 为谓词RowsAffected 为 0 表示并发已推进该记录。
func (s *Service) applyResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
update := map[string]any{}
syncRefund := func() {}
orderRefunded := false
reason := result.FailureReason
message := ""
switch result.State {
case StateSuccess:
amount := result.ChannelRefundAmount
if amount <= 0 {
amount = target.RefundAmount
}
update["status"] = model.RefundStatusApproved
update["channel_refund_status"] = constants.RefundChannelStatusSucceeded
update["channel_refund_no"] = result.ChannelRefundNo
update["channel_refund_amount"] = amount
update["channel_refunded_at"] = now
update["processed_at"] = now
update["failure_reason"] = ""
update["failure_message"] = ""
update["updated_at"] = now
orderRefunded = true
message = "渠道原路退款明确成功"
syncRefund = func() {
refund.Status = model.RefundStatusApproved
refund.ChannelRefundStatus = constants.RefundChannelStatusSucceeded
refund.ChannelRefundNo = result.ChannelRefundNo
refund.ChannelRefundAmount = amount
refund.ChannelRefundedAt = &now
refund.ProcessedAt = &now
refund.FailureReason = ""
refund.FailureMessage = ""
}
case StateFailed:
if reason == "" {
reason = constants.RefundFailureChannelRejected
}
message = "渠道原路退款明确失败:" + constants.RefundFailureReasonName(reason)
failureMessage := safeMessage(result.FailureMessage, message)
update["status"] = model.RefundStatusChannelFailed
update["channel_refund_status"] = constants.RefundChannelStatusFailed
update["failure_reason"] = reason
update["failure_message"] = failureMessage
update["updated_at"] = now
syncRefund = func() {
refund.Status = model.RefundStatusChannelFailed
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
refund.FailureReason = reason
refund.FailureMessage = failureMessage
}
default:
// 超时或结果未确认:保持原路处理中,等待恢复扫描查询收敛。
// 不修改 updated_at使富友查询窗口从进入原路处理中的时点起算。
reason = constants.RefundFailureTimeoutUnknown
message = "渠道原路退款结果未确认,保持处理中"
failureMessage := safeMessage(result.FailureMessage, failureMessageUnknown)
update["channel_refund_status"] = constants.RefundChannelStatusProcessing
update["failure_reason"] = reason
update["failure_message"] = failureMessage
syncRefund = func() {
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
refund.FailureReason = reason
refund.FailureMessage = failureMessage
}
}
// UpdateColumns 不会隐式推进 updated_at结果未知时必须保留进入原路处理中的时点
// 富友 72 小时查询窗口正是以该时点起算;需要推进的分支已在 update 中显式写入。
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", refund.ID, model.RefundStatusChannelProcessing).
UpdateColumns(update)
if updated.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, updated.Error, "回写渠道原路退款结果失败")
}
if updated.RowsAffected != 1 {
return false, nil
}
syncRefund()
if orderRefunded {
if err := s.markOrderRefunded(ctx, tx, refund, now); err != nil {
return false, err
}
// 原路退款的完成时点是渠道明确成功,与客户收款信息退款在企微通过时完成的语义不同:
// 退款完成通知必须在同一事务内补写,否则该方式的店铺通知永远不会发出。
if s.notifier != nil {
if err := s.notifier.AppendCompletedNotification(ctx, tx, refund); err != nil {
return false, err
}
}
}
if err := s.audit.WriteRefundChannelResult(ctx, tx, refund, action, message); err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款审计失败")
}
return true, nil
}
// markOrderRefunded 在渠道明确成功后按方式把订单置为已退款。
// 条件更新命中 0 行时容忍订单已是已退款;其他状态只记录告警,不覆盖业务事实。
func (s *Service) markOrderRefunded(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, now time.Time) error {
updated := tx.WithContext(ctx).Model(&model.Order{}).
Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid).
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": now})
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "更新订单退款状态失败")
}
if updated.RowsAffected == 1 {
return nil
}
var order model.Order
if err := tx.WithContext(ctx).Select("id", "payment_status").Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取退款关联订单状态失败")
}
if order.PaymentStatus != model.PaymentStatusRefunded {
s.logger.Warn("订单支付状态未置为已退款",
zap.Uint("refund_id", refund.ID), zap.Uint("order_id", refund.OrderID), zap.Int("payment_status", order.PaymentStatus))
}
return nil
}
// safeMessage 生成失败安全摘要:裁剪空白、限定字符数,空值退回该状态的固定摘要。
func safeMessage(message, fallback string) string {
text := strings.TrimSpace(message)
if text == "" {
text = fallback
}
runes := []rune(text)
if len(runes) > failureMessageMaxRunes {
text = string(runes[:failureMessageMaxRunes])
}
return text
}
// requireReady 校验渠道原路退款的全部依赖已配置。
func (s *Service) requireReady() error {
if s == nil || s.db == nil || s.loader == nil || s.refunder == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款能力未配置")
}
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -112,8 +113,9 @@ func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest
}
shop.Creator = operatorID
shop.Updater = operatorID
if err := tx.Create(shop).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
// 分销码在创建时随机生成且唯一;冲突时重新生成并重试,不提供人工指定或编辑入口。
if err := CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
return nil, err
}
account := &model.Account{
@@ -201,6 +203,8 @@ func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest
func shopCreationData(shop *model.Shop) map[string]any {
data := shopProfileData(shop)
data["shop_code"] = shop.ShopCode
// 分销码是本 Change 新增的建店事实,按脱敏值记录,口径与审批建店路径一致。
data["distribution_code_masked"] = distributiondomain.MaskDistributionCode(shop.DistributionCode)
data["parent_id"] = shop.ParentID
data["level"] = shop.Level
return data
@@ -285,7 +289,8 @@ func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error
func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
return &dto.ShopResponse{
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
DistributionCode: shop.DistributionCode, ParentID: shop.ParentID,
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,

View File

@@ -0,0 +1,95 @@
package shop
import (
"context"
stderrors "errors"
"reflect"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// distributionCodeConstraint 是分销码条件唯一索引名,用于识别唯一冲突并重试。
const distributionCodeConstraint = "uk_shop_distribution_code"
// distributionCodeSavepoint 是分销码冲突重试使用的保存点名称。
const distributionCodeSavepoint = "shop_distribution_code_retry"
// CreateShopWithDistributionCode 在事务内为新店铺生成全局唯一随机分销码并创建店铺。
//
// 每次尝试都重新生成随机码Create 命中分销码唯一约束时重新生成并重试,
// 最多 constants.ShopDistributionCodeMaxAttempts 次。其他唯一冲突(店铺编号等)不重试,
// 直接返回数据库错误。
//
// 重试依赖真实保存点:每条 Create 包在 GORM 的嵌套事务中执行,冲突时 GORM 自动
// 回滚到内部保存点外层事务因此仍可用PostgreSQL 唯一冲突会中止整个事务,
// 不回滚到保存点则后续语句必然 25P02重试不可能生效。这里刻意不使用裸
// SavePoint/RollbackToGORM 的嵌套事务会自行处理 PrepareStmt 下的连接池切换。
func CreateShopWithDistributionCode(ctx context.Context, tx *gorm.DB, shop *model.Shop) error {
if tx == nil || shop == nil {
return errors.New(errors.CodeInvalidParam, "创建店铺参数无效")
}
// 失败关闭:必须在调用方的事务句柄内执行,否则嵌套事务会自行开启并提交一个新事务,
// 破坏调用方的原子性(建店事务与注册审批通过事务均满足该前提)。
if !inTransaction(tx) {
return errors.New(errors.CodeInvalidStatus, "创建店铺必须传入事务句柄")
}
for range constants.ShopDistributionCodeMaxAttempts {
code, err := distributiondomain.GenerateDistributionCode()
if err != nil {
return err
}
if occupied, err := distributionCodeOccupied(ctx, tx, code); err != nil {
return err
} else if occupied {
// 预检命中直接换码,避免把可预期的冲突交给数据库。
continue
}
shop.DistributionCode = code
shop.ID = 0
createErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return inner.Create(shop).Error
})
if createErr == nil {
return nil
}
if !isDistributionCodeConflict(createErr) {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建店铺失败")
}
// 分销码冲突GORM 已回滚到内部保存点,外层事务仍可继续,换码重试。
}
return errors.New(errors.CodeConflict, "生成分销码冲突,请重试")
}
// inTransaction 判断句柄是否为已开启的事务,与 GORM 自身识别嵌套事务的方式一致。
func inTransaction(tx *gorm.DB) bool {
if tx == nil || tx.Statement == nil {
return false
}
committer, ok := tx.Statement.ConnPool.(gorm.TxCommitter)
return ok && committer != nil && !reflect.ValueOf(committer).IsNil()
}
// distributionCodeOccupied 预检分销码是否已被未删除店铺占用。
func distributionCodeOccupied(ctx context.Context, tx *gorm.DB, code string) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(&model.Shop{}).
Where("distribution_code = ?", code).Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "校验分销码唯一性失败")
}
return count > 0, nil
}
// isDistributionCodeConflict 判断错误是否为分销码条件唯一索引冲突。
func isDistributionCodeConflict(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == distributionCodeConstraint
}

View File

@@ -18,6 +18,19 @@ import (
type UpdateService struct {
db *gorm.DB
audit accessauditapp.Writer
qualificationInvalidator WithdrawalQualificationInvalidator
}
// WithdrawalQualificationInvalidator 在店铺停用事务内联动失效提现资料资格。
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
type WithdrawalQualificationInvalidator interface {
InvalidateByShopDisable(ctx context.Context, tx *gorm.DB, shopID uint, reason string) error
}
// SetWithdrawalQualificationInvalidator 注入店铺停用联动的提现资料资格失效接缝。
// 未注入时停用不联动,用于不依赖该能力的旧装配路径。
func (s *UpdateService) SetWithdrawalQualificationInvalidator(invalidator WithdrawalQualificationInvalidator) {
s.qualificationInvalidator = invalidator
}
// NewUpdateService 创建店铺更新事务脚本。
@@ -81,6 +94,8 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
shop.Address = request.Address
shop.Status = request.Status
shop.Updater = operatorID
// 分销码创建后不可修改Save 写全列,这里显式保留加锁读取到的原值。
shop.DistributionCode = before.DistributionCode
if err := tx.Save(&shop).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败")
}
@@ -110,6 +125,14 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
return err
}
// 店铺停用必须使该店铺全部有效提现资料资格失效,且与停用同事务提交。
if before.Status != constants.ShopStatusDisabled && shop.Status == constants.ShopStatusDisabled &&
s.qualificationInvalidator != nil {
if err := s.qualificationInvalidator.InvalidateByShopDisable(
ctx, tx, shop.ID, "代理店铺已停用,提现资料资格自动失效"); err != nil {
return err
}
}
return nil
})
if err != nil {

View File

@@ -317,6 +317,10 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工提交线下代充值时填写的备注"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
{Code: constants.ApprovalFieldOfflinePaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式名称快照"},
{Code: constants.ApprovalFieldOfflinePaymentMethodCode, Name: "线下收款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式稳定编码快照"},
{Code: constants.ApprovalFieldExternalTransactionNo, Name: "交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验;与在线渠道交易号无关"},
{Code: constants.ApprovalFieldOtherVoucherKey, Name: "其他凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的其他凭证列表"},
}, true
case constants.ApprovalBusinessTypeRefund:
return []dto.WeComBusinessFieldResponse{
@@ -333,6 +337,70 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeEmployeeCollection:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldCollectionApplicationID, Name: "核销申请 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "员工代收款核销申请的系统 ID"},
{Code: constants.ApprovalFieldCollectionPaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款使用的线下收款方式名称快照"},
{Code: constants.ApprovalFieldCollectionPaidAmount, Name: "付款金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的人工确认付款金额"},
{Code: constants.ApprovalFieldCollectionPaidAmountCent, Name: "付款金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的人工确认付款金额整数"},
{Code: constants.ApprovalFieldCollectionPayerName, Name: "付款方", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款的付款方名称"},
{Code: constants.ApprovalFieldCollectionPaidAt, Name: "付款时间", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款时间RFC3339 格式"},
{Code: constants.ApprovalFieldCollectionExternalTransactionNo, Name: "外部交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验"},
{Code: constants.ApprovalFieldPaymentVoucherKey, Name: "付款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的付款凭证列表"},
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "申请人填写的核销备注"},
{Code: constants.ApprovalFieldCollectionBillCount, Name: "分摊账单数量", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次核销分摊的账单数量"},
{Code: constants.ApprovalFieldCollectionBillSummary, Name: "账单分摊摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次各账单应收金额与分摊金额摘要"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeAgentDistribution:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldDistributionCode, Name: "分销码", ValueType: constants.ApprovalFieldValueTypeString, Description: "注册使用的上级店铺分销码脱敏值,仅用于审批人核对来源,不代表新建店铺的码"},
{Code: constants.ApprovalFieldDistributionParentShopID, Name: "上级店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "分销码所属上级店铺的系统 ID"},
{Code: constants.ApprovalFieldDistributionParentShopName, Name: "上级店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "分销码所属上级店铺名称快照"},
{Code: constants.ApprovalFieldDistributionShopName, Name: "申请店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺名称快照"},
{Code: constants.ApprovalFieldDistributionShopCode, Name: "申请店铺编号", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺编号快照,通过时按既有唯一约束校验"},
{Code: constants.ApprovalFieldDistributionUsername, Name: "代理账号用户名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的代理主账号用户名快照"},
{Code: constants.ApprovalFieldDistributionPhoneMasked, Name: "注册手机号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的注册手机号,禁止写入完整手机号"},
{Code: constants.ApprovalFieldDistributionContactName, Name: "联系人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册填写的联系人姓名"},
{Code: constants.ApprovalFieldDistributionRegion, Name: "注册地址摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "省市区与详细地址拼接的注册地址摘要"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeWithdrawalQualification:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldQualificationShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "提现资料资格所属代理店铺的系统 ID"},
{Code: constants.ApprovalFieldQualificationShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "提现资料资格所属代理店铺名称快照"},
{Code: constants.ApprovalFieldQualificationSubjectType, Name: "签约主体类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "签约主体类型中文名:企业或个人"},
{Code: constants.ApprovalFieldQualificationSubjectCodeMasked, Name: "签约主体代码", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的统一社会信用代码或身份证号,禁止写入完整证件号"},
{Code: constants.ApprovalFieldQualificationLegalPersonMasked, Name: "法人身份证号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的法人身份证号,禁止写入完整证件号"},
{Code: constants.ApprovalFieldQualificationContractKey, Name: "合同附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "合同对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationIDCardFrontKey, Name: "法人身份证正面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证正面对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationIDCardBackKey, Name: "法人身份证反面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证反面对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationBusinessLicenseKey, Name: "营业执照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "营业执照对象存储 Key 列表(单个对象,可选)"},
{Code: constants.ApprovalFieldQualificationShopFrontKey, Name: "门头照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "门头照对象存储 Key 列表(单个对象,可选)"},
{Code: constants.ApprovalFieldQualificationInvoiceKey, Name: "发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "发票对象存储 Key 列表(单个对象,仅企业可选)"},
{Code: constants.ApprovalFieldQualificationInvoiceTitle, Name: "发票抬头", ValueType: constants.ApprovalFieldValueTypeString, Description: "发票抬头,仅企业填写且必须与合同主体一致"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldWithdrawalNo, Name: "提现单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提现申请单号"},
{Code: constants.ApprovalFieldWithdrawalAttemptNo, Name: "提交次序", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次为第几次提交,重提时递增"},
{Code: constants.ApprovalFieldWithdrawalShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "发起提现的代理店铺系统 ID"},
{Code: constants.ApprovalFieldWithdrawalShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "发起提现的代理店铺名称快照"},
{Code: constants.ApprovalFieldWithdrawalAmount, Name: "提现金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的提现金额"},
{Code: constants.ApprovalFieldWithdrawalAmountCent, Name: "提现金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的提现金额整数"},
{Code: constants.ApprovalFieldWithdrawalFee, Name: "手续费", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次手续费"},
{Code: constants.ApprovalFieldWithdrawalActualAmount, Name: "实际到账金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的实际到账金额"},
{Code: constants.ApprovalFieldWithdrawalMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款方式名称快照"},
{Code: constants.ApprovalFieldWithdrawalAccountName, Name: "收款人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款人姓名"},
{Code: constants.ApprovalFieldWithdrawalAccountNumber, Name: "收款账号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款账号,供审批人核验打款"},
{Code: constants.ApprovalFieldWithdrawalInvoiceKey, Name: "申请级发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "本次申请级发票对象存储 Key 列表,无发票时为空数组"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
default:
return nil, false
}
@@ -353,14 +421,36 @@ func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComCon
}
func validApprovalBusinessType(businessType string) bool {
return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge
switch businessType {
case constants.ApprovalBusinessTypeRefund,
constants.ApprovalBusinessTypeOfflineRecharge,
constants.ApprovalBusinessTypeEmployeeCollection,
constants.ApprovalBusinessTypeAgentDistribution,
constants.ApprovalBusinessTypeWithdrawalQualification,
constants.ApprovalBusinessTypeCommissionWithdrawal:
return true
default:
return false
}
}
func approvalBusinessTypeName(businessType string) string {
if businessType == constants.ApprovalBusinessTypeRefund {
switch businessType {
case constants.ApprovalBusinessTypeRefund:
return "退款审批"
}
case constants.ApprovalBusinessTypeOfflineRecharge:
return "员工线下代充值审批"
case constants.ApprovalBusinessTypeEmployeeCollection:
return "员工代收款核销审批"
case constants.ApprovalBusinessTypeAgentDistribution:
return "代理扫码分销注册审批"
case constants.ApprovalBusinessTypeWithdrawalQualification:
return "提现资料资格审批"
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return "佣金提现终审"
default:
return "未知审批业务类型"
}
}
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {

View File

@@ -1,6 +1,9 @@
package bootstrap
import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
@@ -21,6 +24,8 @@ import (
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
@@ -86,7 +91,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
svc.WechatConfig,
svc.Order,
packageSeriesStore,
shopSeriesAllocationStore,
@@ -108,9 +112,13 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis)
}
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
if svc.AgentRechargeOnline != nil {
svc.AgentRechargeOnline.SetPaymentMethodPolicy(agentrechargeApp.NewOnlinePaymentMethodPolicy(systemConfigReader))
}
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
clientOrderService.SetLegacyPaymentConfigService(svc.WechatConfig)
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigAudit := deps.SystemConfigAudit
if systemConfigAudit == nil {
@@ -173,7 +181,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return handler
}(),
ClientWallet: func() *app.ClientWalletHandler {
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
return handler
@@ -196,7 +204,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
updateService := shopApp.NewUpdateService(deps.DB, svc.AccessAudit)
// 店铺停用必须联动失效提现资料资格,接入点在同一停用事务内。
updateService.SetWithdrawalQualificationInvalidator(svc.WithdrawalQualification)
handler.SetUpdateService(updateService)
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
return handler
@@ -204,10 +215,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: func() *admin.ShopCommissionHandler {
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate)
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
handler.SetWithdrawalQuery(distributionwithdrawalQuery.NewQuery(deps.DB))
return handler
}(),
WithdrawalQualification: admin.NewWithdrawalQualificationHandler(
svc.WithdrawalQualification, distributionwithdrawalQuery.NewQuery(deps.DB), validate,
),
AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
@@ -285,10 +301,24 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)),
EmployeeCollection: func() *admin.EmployeeCollectionHandler {
handler := admin.NewEmployeeCollectionHandler(
employeecollectionApp.NewPaymentMethodService(deps.DB, svc.AccessAudit),
employeecollectionApp.NewBillCloseService(deps.DB, svc.AccessAudit),
employeecollectionApp.NewApplicationService(deps.DB, svc.Approval, svc.AccessAudit),
)
handler.SetPaymentMethodQuery(employeecollectionQuery.NewPaymentMethodQuery(deps.DB))
handler.SetBillQuery(employeecollectionQuery.NewBillQuery(deps.DB))
handler.SetApplicationQuery(employeecollectionQuery.NewApplicationQuery(deps.DB))
return handler
}(),
AgentRecharge: func() *admin.AgentRechargeHandler {
handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate)
handler.SetOnlineCreationService(svc.AgentRechargeOnline)
handler.SetPaymentStatusQuery(agentRechargeQuery.NewPaymentStatusQuery(deps.DB))
handler.SetPaymentVoucherOCRService(svc.AgentRechargeVoucherOCR)
handler.SetSystemConfigUpdateService(systemConfigUpdate)
return handler
}(),
Refund: admin.NewRefundHandler(svc.Refund),

View File

@@ -15,6 +15,7 @@ func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, log
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigPaymentAllowedCard, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "卡资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
{Key: constants.SystemConfigPaymentAllowedDevice, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "设备资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
{Key: constants.SystemConfigAgentSelfRechargeAllowedMethods, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeString, DefaultValue: constants.AgentSelfRechargeAllowedBoth, Description: "代理在线自充允许的支付方式范围", Control: "payment_methods", EnumValues: []string{constants.AgentSelfRechargeAllowedWechatOnly, constants.AgentSelfRechargeAllowedAlipayOnly, constants.AgentSelfRechargeAllowedBoth}},
}
for _, definition := range definitions {
if existing, exists := registry.Get(definition.Key); exists {

View File

@@ -8,8 +8,12 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
@@ -87,6 +91,9 @@ type services struct {
Shop *shopSvc.Service
Auth *authSvc.Service
ShopCommission *shopCommissionSvc.Service
DistributionRegistration *distributionwithdrawalApp.RegistrationService
WithdrawalQualification *distributionwithdrawalApp.QualificationService
WithdrawalApproval *distributionwithdrawalApp.WithdrawalService
CommissionWithdrawal *commissionWithdrawalSvc.Service
CommissionWithdrawalSetting *commissionWithdrawalSettingSvc.Service
CommissionCalculation *commissionCalculationSvc.Service
@@ -127,6 +134,7 @@ type services struct {
AgentRecharge *agentRechargeSvc.Service
AgentRechargeOnline *agentrechargeApp.OnlineCreationService
AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
AgentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
PackageActivation *packageSvc.ActivationService
Refund *refundSvc.Service
TrafficQuery *trafficSvc.QueryService
@@ -264,6 +272,10 @@ func initServices(s *stores, deps *Dependencies) *services {
packageSeriesService := packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package)
packageSeriesService.SetAccessAudit(deps.DB, auditWriter)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
// 员工代收款建账用例在订单、充值入账与退款冲销的事务内复用同一实例。
employeeCollectionBillCreation := employeecollectionApp.NewBillCreationService(auditWriter)
orderService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation)
orderService.SetResumeCallback(stopResumeService)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
@@ -286,6 +298,7 @@ func initServices(s *stores, deps *Dependencies) *services {
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
@@ -296,6 +309,11 @@ func initServices(s *stores, deps *Dependencies) *services {
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
auditWriter,
)
// 付款凭证识别需要对象存储与 Gateway任一缺失时不装配接口统一返回能力未配置。
var agentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
if deps.StorageService != nil && deps.GatewayClient != nil {
agentRechargeVoucherOCR = agentrechargeApp.NewPaymentVoucherOCRService(deps.StorageService.Provider(), deps.GatewayClient)
}
refundService := refundSvc.New(
deps.DB,
s.RefundRequest,
@@ -313,7 +331,18 @@ func initServices(s *stores, deps *Dependencies) *services {
)
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis))
refundService.SetLifecycleAudit(auditWriter)
// 渠道原路退款的登记与执行共用同一用例API 侧只登记待执行事实与可靠事件,
// 真正的渠道调用由 Worker 消费该事件执行。
refundService.SetChannelRefundService(
refundchannelApp.NewService(
deps.DB,
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger),
auditWriter,
).SetLogger(deps.Logger).SetCompletionNotifier(refundService),
)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
exchangeService.SetAccessAudit(auditWriter)
@@ -348,6 +377,10 @@ func initServices(s *stores, deps *Dependencies) *services {
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService, auditWriter),
)
agentRechargeService.SetRechargeAudit(auditWriter)
agentRechargeService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation)
refundService.SetEmployeeCollectionRefundOffset(
employeecollectionApp.NewRefundOffsetService(auditWriter),
)
refundService.SetRefundApprovalCreationService(
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter),
)
@@ -359,6 +392,18 @@ func initServices(s *stores, deps *Dependencies) *services {
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest)
commissionWithdrawal.SetAuditWriter(auditWriter)
// 代理分销注册、提现资格与提现终审共用同一审计 Writer 与通用审批创建接缝。
distributionRegistration := distributionwithdrawalApp.NewRegistrationService(
deps.DB, deps.VerificationService, approvalCreationService, auditWriter,
)
withdrawalQualification := distributionwithdrawalApp.NewQualificationService(
deps.DB, approvalCreationService, auditWriter,
)
withdrawalApproval := distributionwithdrawalApp.NewWithdrawalService(
deps.DB, approvalCreationService, auditWriter,
)
shopCommission.SetWithdrawalApprovalService(withdrawalApproval)
shopService.SetWithdrawalQualificationInvalidator(withdrawalQualification)
commissionCalculation := commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
@@ -413,6 +458,9 @@ func initServices(s *stores, deps *Dependencies) *services {
Shop: shopService,
Auth: authService,
ShopCommission: shopCommission,
DistributionRegistration: distributionRegistration,
WithdrawalQualification: withdrawalQualification,
WithdrawalApproval: withdrawalApproval,
CommissionWithdrawal: commissionWithdrawal,
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
CommissionCalculation: commissionCalculation,
@@ -453,6 +501,7 @@ func initServices(s *stores, deps *Dependencies) *services {
AgentRecharge: agentRechargeService,
AgentRechargeOnline: agentRechargeOnline,
AgentRechargePaymentConfirm: agentRechargePaymentConfirm,
AgentRechargeVoucherOCR: agentRechargeVoucherOCR,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,

View File

@@ -29,6 +29,8 @@ type Handlers struct {
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
ShopCommission *admin.ShopCommissionHandler
WithdrawalQualification *admin.WithdrawalQualificationHandler
AgentDistribution *app.AgentDistributionHandler
CommissionWithdrawal *admin.CommissionWithdrawalHandler
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
@@ -68,6 +70,8 @@ type Handlers struct {
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
PaymentMerchant *admin.PaymentMerchantHandler
EmployeeCollection *admin.EmployeeCollectionHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler

View File

@@ -32,6 +32,9 @@ type PaymentConfirmationFacts struct {
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
FrozenMerchant bool
FrozenMerchantPaymentMethod string
FrozenMerchantProviderType string
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
@@ -60,7 +63,20 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
if facts.FrozenMerchant {
frozenMethod := strings.TrimSpace(facts.FrozenMerchantPaymentMethod)
providerType := strings.TrimSpace(facts.FrozenMerchantProviderType)
if identity == "" || frozenMethod != method || providerType == "" {
return false, errors.New(errors.CodeConflict, "冻结商户支付事实不一致")
}
expectedChannel := method
if method == constants.RechargeMethodWechat && providerType == model.ProviderTypeFuiou {
expectedChannel = model.ProviderTypeFuiou
}
if channel != expectedChannel {
return false, errors.New(errors.CodeConflict, "支付渠道与冻结商户不一致")
}
} else if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")

View File

@@ -0,0 +1,188 @@
// Package distribution 收口代理分销注册、提现资格与提现审批的领域不变量。
// 本包不依赖 Fiber、GORM、Redis、Asynq 或具体第三方 SDK。
package distribution
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
const (
distributionCodeBytes = 16
phoneMaskedKeepPrefix = 3
phoneMaskedKeepSuffix = 4
codeMaskedKeepPrefix = 4
codeMaskedKeepSuffix = 4
)
// GenerateDistributionCode 生成 32 位十六进制随机分销码。
// 唯一性由数据库条件唯一索引兜底,调用方在冲突时重新生成。
func GenerateDistributionCode() (string, error) {
buf := make([]byte, distributionCodeBytes)
if _, err := rand.Read(buf); err != nil {
return "", errors.Wrap(errors.CodeInternalError, err, "生成分销码失败")
}
return hex.EncodeToString(buf), nil
}
// ValidateRegistrationInput 规范化并校验扫码注册输入。
// 手机号、用户名、店铺编号与店铺名称由公开接口必填;密码长度沿用账号体系既有下限。
func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, error) {
input.DistributionCode = strings.TrimSpace(input.DistributionCode)
input.Phone = strings.TrimSpace(input.Phone)
input.Username = strings.TrimSpace(input.Username)
input.ShopName = strings.TrimSpace(input.ShopName)
input.ShopCode = strings.TrimSpace(input.ShopCode)
input.ContactName = strings.TrimSpace(input.ContactName)
input.Province = strings.TrimSpace(input.Province)
input.City = strings.TrimSpace(input.City)
input.District = strings.TrimSpace(input.District)
input.Address = strings.TrimSpace(input.Address)
if input.DistributionCode == "" || input.Phone == "" || input.Username == "" ||
input.ShopName == "" || input.ShopCode == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
if len(input.Phone) != 11 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")
}
if len(input.Username) < 3 || len(input.Username) > 50 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名长度必须为 3 至 50 个字符")
}
if len(input.Password) < 6 || len(input.Password) > 64 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "密码长度必须为 6 至 64 个字符")
}
return input, nil
}
// ValidateQualificationInput 规范化并校验提现资料资格输入。
// 企业主体必须填写统一社会信用代码,个人主体必须填写法人身份证号;
// 发票仅企业可选,且抬头与统一社会信用代码必须与签约主体一致。
func ValidateQualificationInput(input QualificationInput) (QualificationInput, error) {
input.SubjectCode = strings.TrimSpace(input.SubjectCode)
input.LegalPersonIDCard = strings.TrimSpace(input.LegalPersonIDCard)
input.ContractFileKey = strings.TrimSpace(input.ContractFileKey)
input.IDCardFrontFileKey = strings.TrimSpace(input.IDCardFrontFileKey)
input.IDCardBackFileKey = strings.TrimSpace(input.IDCardBackFileKey)
input.BusinessLicenseFileKey = strings.TrimSpace(input.BusinessLicenseFileKey)
input.ShopFrontFileKey = strings.TrimSpace(input.ShopFrontFileKey)
input.InvoiceFileKey = strings.TrimSpace(input.InvoiceFileKey)
input.InvoiceTitle = strings.TrimSpace(input.InvoiceTitle)
input.InvoiceSubjectCode = strings.TrimSpace(input.InvoiceSubjectCode)
switch input.SubjectType {
case constants.WithdrawalQualificationSubjectTypeEnterprise:
if input.SubjectCode == "" || input.LegalPersonIDCard == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "企业主体必须填写统一社会信用代码与法人身份证号")
}
case constants.WithdrawalQualificationSubjectTypePersonal:
if input.LegalPersonIDCard == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体必须填写法人身份证号")
}
if input.SubjectCode == "" {
// 个人主体的签约主体代码即法人身份证号。
input.SubjectCode = input.LegalPersonIDCard
}
default:
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "签约主体类型无效")
}
if input.SubjectCode != input.LegalPersonIDCard && input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体的签约主体代码必须与法人身份证号一致")
}
if input.ContractFileKey == "" || input.IDCardFrontFileKey == "" || input.IDCardBackFileKey == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "合同与法人身份证正反面附件必须填写")
}
if input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal &&
(input.InvoiceFileKey != "" || input.InvoiceTitle != "" || input.InvoiceSubjectCode != "") {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票资料仅企业主体可提交")
}
if input.InvoiceFileKey != "" {
if input.InvoiceTitle == "" || input.InvoiceSubjectCode == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "提交发票时必须填写抬头与统一社会信用代码")
}
if input.InvoiceSubjectCode != input.SubjectCode {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票统一社会信用代码必须与合同主体一致")
}
}
// 附件上限由结构保证:资格只有合同、法人身份证正反面、营业执照、门头照、发票共 6 个
// 单对象键字段,天然不超过企业微信单张审批单 6 个附件上限,无需运行时计数校验。
return input, nil
}
// MaskPhone 生成脱敏手机号,仅保留前 3 位与后 4 位。
// 日志与审计不得记录完整手机号。
func MaskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < phoneMaskedKeepPrefix+phoneMaskedKeepSuffix {
return ""
}
return phone[:phoneMaskedKeepPrefix] + "****" + phone[len(phone)-phoneMaskedKeepSuffix:]
}
// MaskSubjectCode 生成脱敏证件号或统一社会信用代码,仅保留前 4 位与后 4 位。
// 日志与审计不得记录完整证件号。
func MaskSubjectCode(code string) string {
code = strings.TrimSpace(code)
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
return ""
}
return code[:codeMaskedKeepPrefix] + "**********" + code[len(code)-codeMaskedKeepSuffix:]
}
// MaskDistributionCode 生成脱敏分销码,仅保留首尾片段。
// 分销码是可枚举的公开入口标识,日志与审计只记录脱敏值。
func MaskDistributionCode(code string) string {
code = strings.TrimSpace(code)
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
return ""
}
return code[:codeMaskedKeepPrefix] + "****" + code[len(code)-codeMaskedKeepSuffix:]
}
// FormatCentYuan 将分金额格式化为两位小数的元字符串,仅用于审批表单与展示。
func FormatCentYuan(amount int64) string {
return strconv.FormatInt(amount/100, 10) + "." +
pad2(strconv.FormatInt(amount%100, 10))
}
// pad2 将 0 至 99 的十进制文本左补零到两位。
func pad2(value string) string {
if len(value) >= 2 {
return value
}
return "0" + value
}
// RegistrationInput 是公开扫码注册的规范化输入。
type RegistrationInput struct {
DistributionCode string
Phone string
Username string
Password string
ShopName string
ShopCode string
ContactName string
Province string
City string
District string
Address string
}
// QualificationInput 是提现资料资格的规范化输入。
type QualificationInput struct {
SubjectType string
SubjectCode string
LegalPersonIDCard string
ContractFileKey string
IDCardFrontFileKey string
IDCardBackFileKey string
BusinessLicenseFileKey string
ShopFrontFileKey string
InvoiceFileKey string
InvoiceTitle string
InvoiceSubjectCode string
}

View File

@@ -0,0 +1,58 @@
package employeecollection
import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AllocationCandidate 是一笔待校验的核销申请账单分摊候选。
type AllocationCandidate struct {
// BillID 表示目标账单ID。
BillID uint
// BillStatus 表示目标账单当前持久化状态。
BillStatus int
// Amount 表示本次分摊金额(分)。
Amount int64
// Available 表示目标账单当前可核销余额(分),已扣除已通过分摊与其他审批中预占。
Available int64
}
// ValidateAllocations 校验核销申请的账单分摊集合。
// 规则:付款金额为正、分摊数量在允许区间、账单不重复、账单已关闭时拒绝、
// 单笔分摊为正且不超过该账单可核销余额、分摊总额不超过本次付款金额。
func ValidateAllocations(paidAmount int64, candidates []AllocationCandidate) error {
if paidAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "付款金额必须大于零")
}
if len(candidates) == 0 {
return errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊")
}
if len(candidates) > constants.EmployeeCollectionAllocationMaxCount {
return errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制")
}
seen := make(map[uint]struct{}, len(candidates))
var total int64
for _, candidate := range candidates {
if candidate.BillID == 0 {
return errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单")
}
if _, exists := seen[candidate.BillID]; exists {
return errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊")
}
seen[candidate.BillID] = struct{}{}
if candidate.BillStatus == constants.EmployeeCollectionBillStatusClosed {
return errors.New(errors.CodeEmployeeCollectionBillClosed)
}
if candidate.Amount <= 0 {
return errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if candidate.Amount > candidate.Available {
return errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
}
total += candidate.Amount
}
if total > paidAmount {
return errors.New(errors.CodeEmployeeCollectionPaidAmountExceeded)
}
return nil
}

View File

@@ -0,0 +1,110 @@
package employeecollection
import (
"strings"
"time"
"unicode/utf8"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApplicationInput 是核销申请提交的领域输入;账单分摊由 ValidateAllocations 单独校验。
type ApplicationInput struct {
// PaidAmount 表示人工确认的付款金额(分)。
PaidAmount int64
// PayerName 表示付款方名称。
PayerName string
// PaidAt 表示付款时间。
PaidAt time.Time
// ExternalTransactionNo 表示人工确认的外部交易流水号。
ExternalTransactionNo string
// Remark 表示申请备注。
Remark string
// ActingReason 表示代办原因,仅代办提交时必填。
ActingReason string
// PaymentVoucherKeys 表示支付凭证对象存储键列表。
PaymentVoucherKeys []string
}
// NormalizedApplicationInput 是通过校验并去空格后的核销申请事实。
type NormalizedApplicationInput struct {
PaidAmount int64
PayerName string
PaidAt time.Time
ExternalTransactionNo string
Remark string
ActingReason string
PaymentVoucherKeys []string
}
// NormalizeApplicationInput 校验并规范化核销申请输入。
// acting 表示本次是否由超级管理员为他人代办:代办必须填写原因,本人办理不得填写原因。
func NormalizeApplicationInput(input ApplicationInput, acting bool) (NormalizedApplicationInput, error) {
if input.PaidAmount <= 0 {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款金额必须大于零")
}
if input.PaidAt.IsZero() {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款时间必填")
}
payerName := strings.TrimSpace(input.PayerName)
if payerName == "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款方名称必填")
}
if utf8.RuneCountInString(payerName) > constants.EmployeeCollectionPayerNameMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款方名称长度超出限制")
}
externalTransactionNo := strings.TrimSpace(input.ExternalTransactionNo)
if externalTransactionNo == "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "外部交易流水号必填")
}
if utf8.RuneCountInString(externalTransactionNo) > constants.EmployeeCollectionExternalTransactionNoMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "外部交易流水号长度超出限制")
}
remark := strings.TrimSpace(input.Remark)
if utf8.RuneCountInString(remark) > constants.EmployeeCollectionRemarkMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "核销申请备注长度超出限制")
}
actingReason := strings.TrimSpace(input.ActingReason)
if utf8.RuneCountInString(actingReason) > constants.EmployeeCollectionRemarkMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "代办原因长度超出限制")
}
if acting && actingReason == "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "超级管理员代办核销申请必须填写代办原因")
}
if !acting && actingReason != "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "本人办理核销申请不能填写代办原因")
}
vouchers, err := NormalizePaymentVouchers(input.PaymentVoucherKeys)
if err != nil {
return NormalizedApplicationInput{}, err
}
return NormalizedApplicationInput{
PaidAmount: input.PaidAmount, PayerName: payerName,
PaidAt: input.PaidAt.UTC(), ExternalTransactionNo: externalTransactionNo,
Remark: remark, ActingReason: actingReason, PaymentVoucherKeys: vouchers,
}, nil
}
// ValidateApplicationResubmit 校验申请当前状态允许修改并重提。
// 只有企业微信最终驳回的申请可以修改重提;已通过、审批中与异常终态一律拒绝。
func ValidateApplicationResubmit(status int) error {
if status == constants.EmployeeCollectionApplicationStatusRejected {
return nil
}
return errors.New(errors.CodeEmployeeCollectionApplicationStatusInvalid)
}
// MaskExternalTransactionNo 生成外部交易流水号的脱敏展示,用于审计与日志,不保留完整流水。
// 长度不超过 8 时整体掩码,否则保留首尾各 4 位。
func MaskExternalTransactionNo(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
if len(runes) <= 8 {
return "****"
}
return string(runes[:4]) + "****" + string(runes[len(runes)-4:])
}

View File

@@ -0,0 +1,51 @@
package employeecollection
import (
"strings"
"github.com/bytedance/sonic"
)
// ExtractApprovalOpinion 从通用审批实例的终态决策快照中提取审批意见文本。
// 快照来自渠道审批详情(`info` 对象),审批意见位于 `comments[].comment_content`
// 兼容 `content` 与 `text` 两种等价键。取最后一条非空意见作为最终审批意见。
// 无法解析或没有意见时返回空字符串:意见缺失不影响申请与账单事实。
func ExtractApprovalOpinion(snapshot []byte) string {
if len(snapshot) == 0 {
return ""
}
var payload map[string]any
if err := sonic.Unmarshal(snapshot, &payload); err != nil {
return ""
}
opinion := ""
if raw, ok := payload["comments"].([]any); ok {
for _, item := range raw {
comment, ok := item.(map[string]any)
if !ok {
continue
}
if content := commentText(comment); content != "" {
opinion = content
}
}
}
if opinion != "" {
return opinion
}
return commentText(payload)
}
// commentText 按优先顺序读取审批意见文本。
func commentText(container map[string]any) string {
for _, key := range []string{"comment_content", "content", "text"} {
value, ok := container[key].(string)
if !ok {
continue
}
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@@ -0,0 +1,173 @@
// Package employeecollection 收口员工代收款账单的金额、状态与预占不变量。
// 只依赖标准库、领域常量和稳定错误,不依赖传输、持久化或外部 SDK。
package employeecollection
import (
"strings"
"unicode/utf8"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BillAmounts 描述一张员工代收款账单的应收、已核销、审批中预占与关闭事实。
type BillAmounts struct {
// Receivable 表示应收金额(分),来源成功事务判定后不允许为负。
Receivable int64
// Received 表示企业微信最终通过后累计的已核销金额(分)。
Received int64
// Reserved 表示审批中分摊预占的金额(分)。
Reserved int64
// Closed 表示账单是否已关闭;已关闭账单的可核销余额为 0。
Closed bool
}
// NewBillAmounts 依据来源应收金额构造初始账单金额事实。
// 应收金额必须大于零,避免零元账单立即成为已核销。
func NewBillAmounts(receivable int64) (BillAmounts, error) {
amounts := BillAmounts{Receivable: receivable}
if err := amounts.Validate(); err != nil {
return BillAmounts{}, err
}
return amounts, nil
}
// Validate 校验账单金额不变量:应收为正,已核销与预占非负且合计不超过应收。
func (a BillAmounts) Validate() error {
if a.Receivable <= 0 {
return errors.New(errors.CodeInvalidParam, "账单应收金额必须大于零")
}
if a.Received < 0 || a.Reserved < 0 {
return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额不能为负")
}
if a.Received+a.Reserved > a.Receivable {
return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额合计不能超过应收金额")
}
return nil
}
// Available 返回账单当前可被新分摊占用的金额;已关闭账单始终返回 0。
func (a BillAmounts) Available() int64 {
if a.Closed {
return 0
}
available := a.Receivable - a.Received - a.Reserved
if available < 0 {
return 0
}
return available
}
// DerivedStatus 依据金额推导未关闭账单的核销状态。
// 调用方必须自行区分已关闭账单,关闭状态不可由金额推导。
func (a BillAmounts) DerivedStatus() int {
switch {
case a.Received <= 0:
return constants.EmployeeCollectionBillStatusPending
case a.Received >= a.Receivable:
return constants.EmployeeCollectionBillStatusSettled
default:
return constants.EmployeeCollectionBillStatusPartial
}
}
// Reserve 在审批中预占指定金额,返回预占后的新金额事实。
// 分摊金额必须大于零且不超过当前可核销余额。
func (a BillAmounts) Reserve(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if err := a.ensureSettleable(); err != nil {
return BillAmounts{}, err
}
if amount > a.Available() {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
}
a.Reserved += amount
return a, nil
}
// Release 释放指定金额的审批中预占,返回释放后的新金额事实。
func (a BillAmounts) Release(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if amount > a.Reserved {
return BillAmounts{}, errors.New(errors.CodeInternalError, "释放的预占金额超过账单当前预占")
}
a.Reserved -= amount
return a, nil
}
// Approve 将指定金额从审批中预占转入已核销,返回通过后的新金额事实。
func (a BillAmounts) Approve(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if amount > a.Reserved {
return BillAmounts{}, errors.New(errors.CodeInternalError, "通过的分摊金额超过账单当前预占")
}
a.Reserved -= amount
a.Received += amount
return a, nil
}
// ReduceReceivable 按来源订单退款金额冲减应收,仅在账单不存在任何已通过或审批中分摊时允许。
func (a BillAmounts) ReduceReceivable(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须大于零")
}
if a.Received > 0 || a.Reserved > 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "账单存在分摊,不能冲减应收")
}
if amount >= a.Receivable {
return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须小于账单应收金额")
}
a.Receivable -= amount
return a, nil
}
// ensureSettleable 校验账单允许产生新的审批中分摊。
func (a BillAmounts) ensureSettleable() error {
if a.Closed {
return errors.New(errors.CodeEmployeeCollectionBillClosed)
}
if a.DerivedStatus() == constants.EmployeeCollectionBillStatusSettled {
return errors.New(errors.CodeEmployeeCollectionBillNotSettleable)
}
return nil
}
// BillCloseInput 描述关闭一张账单前的事实。
type BillCloseInput struct {
// Status 表示账单当前持久化状态。
Status int
// PendingAllocations 表示账单上仍处于审批中(预占)的分摊数量。
PendingAllocations int64
// Reason 表示关闭原因,必填。
Reason string
}
// ValidateBillClose 校验关闭账单的前置条件。
// 已关闭账单返回账单已关闭,已核销账单不允许关闭,存在审批中分摊时拒绝关闭,关闭原因必填。
func ValidateBillClose(input BillCloseInput) error {
if input.Status == constants.EmployeeCollectionBillStatusClosed {
return errors.New(errors.CodeEmployeeCollectionBillClosed)
}
if input.Status == constants.EmployeeCollectionBillStatusSettled {
return errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "已核销账单没有未核销余额,不能关闭")
}
if input.Status != constants.EmployeeCollectionBillStatusPending &&
input.Status != constants.EmployeeCollectionBillStatusPartial {
return errors.New(errors.CodeConflict, "账单当前状态不允许关闭")
}
if input.PendingAllocations > 0 {
return errors.New(errors.CodeEmployeeCollectionApplicationPending)
}
if trimmed := strings.TrimSpace(input.Reason); trimmed == "" {
return errors.New(errors.CodeInvalidParam, "关闭原因必填")
} else if utf8.RuneCountInString(trimmed) > constants.EmployeeCollectionRemarkMaxLength {
return errors.New(errors.CodeInvalidParam, "关闭原因长度超出限制")
}
return nil
}

View File

@@ -0,0 +1,56 @@
package employeecollection
import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// OrderBillSubject 是判定后台线下套餐订单是否建账所需的来源事实。
// 四个条件必须同时成立,判定只由 ShouldCreateBillForOrder 一处实现。
type OrderBillSubject struct {
// PaymentMethod 表示订单支付方式快照。
PaymentMethod string
// OperatorAccountType 表示实际操作账号类型快照。
OperatorAccountType string
// ActualPaidAmount 表示订单实际支付金额(分),空表示来源未产生实收金额。
ActualPaidAmount *int64
// HasGiftPackage 表示订单是否包含赠送套餐。
HasGiftPackage bool
}
// OrderBillSubjectFromOrder 从已冻结的订单事实提取建账判据输入。
// 建账与创建时付款凭证放宽必须使用同一份输入,避免出现两套口径。
func OrderBillSubjectFromOrder(order *model.Order, hasGiftPackage bool) OrderBillSubject {
if order == nil {
return OrderBillSubject{}
}
return OrderBillSubject{
PaymentMethod: order.PaymentMethod,
OperatorAccountType: order.OperatorAccountType,
ActualPaidAmount: order.ActualPaidAmount,
HasGiftPackage: hasGiftPackage,
}
}
// ShouldCreateBillForOrder 判定后台线下套餐订单是否触发员工代收款建账。
// 判据:支付方式为线下、实际操作账号为平台账号、订单不含赠送套餐、实收金额大于零。
func ShouldCreateBillForOrder(subject OrderBillSubject) bool {
return subject.PaymentMethod == model.PaymentMethodOffline &&
subject.OperatorAccountType == model.OperatorAccountTypePlatform &&
!subject.HasGiftPackage &&
subject.ActualPaidAmount != nil && *subject.ActualPaidAmount > 0
}
// RechargeBillSubject 是判定代理线下充值入账是否建账所需的来源事实。
type RechargeBillSubject struct {
// PaymentMethod 表示充值记录支付方式。
PaymentMethod string
// Amount 表示充值记录金额(分)。
Amount int64
}
// ShouldCreateBillForRecharge 判定代理线下充值入账是否触发员工代收款建账。
// 判据:支付方式为线下且入账金额大于零;零金额无法形成正的应收金额。
func ShouldCreateBillForRecharge(subject RechargeBillSubject) bool {
return subject.PaymentMethod == constants.RechargeMethodOffline && subject.Amount > 0
}

View File

@@ -0,0 +1,69 @@
package employeecollection
import (
"strings"
"unicode"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PaymentMethodInput 是线下收款方式字典写入的领域输入。
type PaymentMethodInput struct {
// Code 表示稳定编码,创建后仅可在未被引用时修改。
Code string
// Name 表示收款方式名称。
Name string
// SortOrder 表示排序值,必须非负。
SortOrder int64
// Status 表示启停状态,取值见 constants.EmployeeCollectionPaymentMethodStatus*。
Status int
// Remark 表示备注。
Remark string
}
// NormalizedPaymentMethodInput 是通过校验并去空格后的字典写入事实。
type NormalizedPaymentMethodInput struct {
Code string
Name string
SortOrder int64
Status int
Remark string
}
// NormalizePaymentMethodInput 校验并规范化线下收款方式字典写入输入。
// 规则:编码 1 至 64 字符且不含空白或控制字符、名称 1 至 100 字符、
// 排序值非负、状态仅允许启用或停用、备注不超过 500 字符。
func NormalizePaymentMethodInput(input PaymentMethodInput) (NormalizedPaymentMethodInput, error) {
code := strings.TrimSpace(input.Code)
if code == "" {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码必填")
}
if len([]rune(code)) > constants.EmployeeCollectionCodeMaxLength {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码长度超出限制")
}
if strings.IndexFunc(code, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码不能包含空白或控制字符")
}
name := strings.TrimSpace(input.Name)
if name == "" {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式名称必填")
}
if len([]rune(name)) > constants.EmployeeCollectionNameMaxLength {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式名称长度超出限制")
}
if input.SortOrder < 0 {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式排序值不能为负")
}
if input.Status != constants.EmployeeCollectionPaymentMethodStatusDisabled &&
input.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式状态仅支持停用或启用")
}
remark := strings.TrimSpace(input.Remark)
if len([]rune(remark)) > constants.EmployeeCollectionRemarkMaxLength {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式备注长度超出限制")
}
return NormalizedPaymentMethodInput{
Code: code, Name: name, SortOrder: input.SortOrder, Status: input.Status, Remark: remark,
}, nil
}

View File

@@ -0,0 +1,37 @@
package employeecollection
import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundOffsetDecision 是来源订单退款成功对账单的处理判定结果。
type RefundOffsetDecision struct {
// Outcome 取值见 constants.EmployeeCollectionRefundOutcome*。
Outcome string
// ReducedAmount 是本次实际冲减的应收金额(分),仅 reduced 时大于零。
ReducedAmount int64
}
// DecideRefundOffset 判定来源订单本次退款成功金额对账单的处理方式。
// 判定顺序:已关闭账单与存在已通过或审批中分摊的账单只写退款关联提示,
// 其余按退款成功金额与账单应收比较,等于或超过应收时关闭账单,小于应收时按退款金额冲减。
// 已通过分摊体现为 received_amount > 0审批中分摊体现为 reserved_amount > 0
// 这两个金额只由本能力的条件更新维护,因此与「存在已通过或审批中分摊」等价。
func DecideRefundOffset(bill BillAmounts, refundAmount int64) (RefundOffsetDecision, error) {
if refundAmount <= 0 {
return RefundOffsetDecision{}, errors.New(errors.CodeInvalidParam, "退款成功金额必须大于零")
}
if err := bill.Validate(); err != nil {
return RefundOffsetDecision{}, err
}
if bill.Closed || bill.Received > 0 || bill.Reserved > 0 {
return RefundOffsetDecision{Outcome: constants.EmployeeCollectionRefundOutcomeHintOnly}, nil
}
if refundAmount >= bill.Receivable {
return RefundOffsetDecision{Outcome: constants.EmployeeCollectionRefundOutcomeClosedFull}, nil
}
return RefundOffsetDecision{
Outcome: constants.EmployeeCollectionRefundOutcomeReduced, ReducedAmount: refundAmount,
}, nil
}

View File

@@ -0,0 +1,14 @@
package employeecollection
import "strconv"
// OrderSourceKey 返回后台线下套餐订单来源的账单唯一键。
// 该键是 tb_employee_collection_bill.source_key 的持久化契约,同一来源至多一张账单。
func OrderSourceKey(orderID uint) string {
return "order:" + strconv.FormatUint(uint64(orderID), 10)
}
// RechargeSourceKey 返回代理线下充值来源的账单唯一键。
func RechargeSourceKey(rechargeID uint) string {
return "recharge:" + strconv.FormatUint(uint64(rechargeID), 10)
}

View File

@@ -0,0 +1,35 @@
package employeecollection
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// NormalizePaymentVouchers 校验并规范化支付凭证对象键列表。
// 规则:数量必须在 1 至 5 个之间、每个键去空格后非空且不超过长度上限、不允许重复。
// 只接受对象存储键引用,不接受内联内容,避免敏感付款材料进入业务事实。
func NormalizePaymentVouchers(keys []string) ([]string, error) {
if len(keys) < constants.EmployeeCollectionVoucherMinCount ||
len(keys) > constants.EmployeeCollectionVoucherMaxCount {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
normalized := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
if len([]rune(trimmed)) > constants.EmployeeCollectionVoucherKeyMaxLength {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
if _, exists := seen[trimmed]; exists {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
return normalized, nil
}

View File

@@ -2,6 +2,7 @@ package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
@@ -38,8 +39,11 @@ func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int,
// Headers 返回退款记录导出表头。
func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)",
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注",
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称",
"当前退款套餐已用量(MB)", "当前退款套餐总量(MB)", "原订单金额(元)",
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款方式",
"冻结实收金额(元)", "渠道退款状态", "渠道退款流水号", "渠道退款金额(元)", "失败分类", "异常标记",
"退款原因", "审批备注",
"审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证",
}, nil
}
@@ -61,6 +65,13 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
r.requested_refund_amount,
r.approved_refund_amount,
r.status,
r.method,
r.frozen_actual_received_amount,
r.channel_refund_status,
r.channel_refund_no,
r.channel_refund_amount,
r.failure_reason,
r.anomaly_flag,
r.refund_reason,
r.remark,
r.commission_deducted,
@@ -71,6 +82,10 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
o.total_amount AS original_amount,
o.actual_paid_amount AS refundable_amount,
COALESCE(pu.package_name, items.package_names, '') AS package_name,
-- 当前退款套餐用量:与展示口径一致,按冻结套餐记录 → 订单主套餐 → 订单任一套餐
-- 取唯一一条,且不按套餐状态过滤(退款后套餐已失效仍需展示其用量)。
COALESCE(usage.data_usage_mb, 0) AS refund_package_used_mb,
COALESCE(usage.data_limit_mb, 0) AS refund_package_total_mb,
COALESCE(ac.username, '') AS submitter_name,
ai.provider AS approval_provider,
ai.status AS approval_status,
@@ -87,6 +102,21 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
FROM tb_order_item AS oi
WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL
) AS items ON TRUE`).
Joins(`LEFT JOIN LATERAL (
SELECT candidate.data_usage_mb, candidate.data_limit_mb
FROM tb_package_usage AS candidate
WHERE candidate.deleted_at IS NULL
AND (
(r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id AND candidate.order_id = r.order_id)
OR (candidate.order_id = r.order_id)
)
ORDER BY
CASE WHEN r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id THEN 0
WHEN candidate.master_usage_id IS NULL THEN 1
ELSE 2 END,
candidate.id ASC
LIMIT 1
) AS usage ON TRUE`).
Order("r.id ASC").
Limit(limit).
Offset(offset)
@@ -103,12 +133,21 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
formatRefundAssetType(item.OrderType),
item.AssetIdentifier,
item.PackageName,
strconv.FormatInt(item.RefundPackageUsedMB, 10),
strconv.FormatInt(item.RefundPackageTotalMB, 10),
formatOptionalMoneyYuan(item.OriginalAmount),
formatMoneyYuan(item.ActualReceivedAmount),
formatOptionalMoneyYuan(item.RefundableAmount),
formatMoneyYuan(item.RequestedRefundAmount),
formatOptionalMoneyYuan(item.ApprovedRefundAmount),
constants.GetRefundStatusName(item.Status),
constants.RefundMethodName(item.Method),
formatMoneyYuan(item.FrozenActualReceivedAmount),
constants.RefundChannelStatusName(item.ChannelRefundStatus),
item.ChannelRefundNo,
formatMoneyYuan(item.ChannelRefundAmount),
constants.RefundFailureReasonName(item.FailureReason),
formatRefundAnomalyFlag(item.AnomalyFlag),
item.RefundReason,
item.Remark,
formatRefundApprovalSource(item.ApprovalProvider),
@@ -151,12 +190,21 @@ type refundExportRow struct {
OrderType string `gorm:"column:order_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
PackageName string `gorm:"column:package_name"`
RefundPackageUsedMB int64 `gorm:"column:refund_package_used_mb"`
RefundPackageTotalMB int64 `gorm:"column:refund_package_total_mb"`
OriginalAmount *int64 `gorm:"column:original_amount"`
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
RefundableAmount *int64 `gorm:"column:refundable_amount"`
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
Status int `gorm:"column:status"`
Method string `gorm:"column:method"`
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount"`
ChannelRefundStatus int `gorm:"column:channel_refund_status"`
ChannelRefundNo string `gorm:"column:channel_refund_no"`
ChannelRefundAmount int64 `gorm:"column:channel_refund_amount"`
FailureReason string `gorm:"column:failure_reason"`
AnomalyFlag int `gorm:"column:anomaly_flag"`
RefundReason string `gorm:"column:refund_reason"`
Remark string `gorm:"column:remark"`
ApprovalProvider *string `gorm:"column:approval_provider"`
@@ -200,7 +248,19 @@ func formatRefundProcessingStatus(status int, commissionDeducted, assetReset boo
return "已完成"
}
return "处理中"
case model.RefundStatusChannelProcessing:
return "原路退款处理中"
case model.RefundStatusChannelFailed:
return "原路退款失败待人工处理"
default:
return "未知"
}
}
// formatRefundAnomalyFlag 将异常标记转为导出用中文描述。
func formatRefundAnomalyFlag(flag int) string {
if flag == 0 {
return "无异常"
}
return "有异常"
}

View File

@@ -99,8 +99,6 @@ func (c *Client) WithRetry(maxRetries int) *Client {
// 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST带重试→ 解析响应 → 检查业务状态码
// params: 请求参数结构体,内部自动包装为 {"params": <JSON>} 格式
func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
startTime := time.Now()
// 将参数包装为 {"params": ...} 格式后序列化
wrapper := requestWrapper{Params: params}
dataBytes, err := sonic.Marshal(wrapper)
@@ -115,6 +113,28 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
return nil, err
}
return c.executeWithRetry(ctx, path, encryptedData, true)
}
// doRequestWithoutPayloadLog 执行 Gateway 请求,但不记录请求体与响应体。
// 仅用于载荷含敏感内容(如付款凭证图片)的能力;成功与失败都只记录路径、耗时与结果摘要。
func (c *Client) doRequestWithoutPayloadLog(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
dataBytes, err := sonic.Marshal(requestWrapper{Params: params})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
}
encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
if err != nil {
return nil, err
}
return c.executeWithRetry(ctx, path, encryptedData, false)
}
// executeWithRetry 按现有重试语义发送一次已加密请求。
// logPayload 为 false 时不记录响应体,只记录路径、耗时与结果字节数摘要。
func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData string, logPayload bool) (json.RawMessage, error) {
startTime := time.Now()
// 带重试的 HTTP 请求
var lastErr error
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
@@ -160,11 +180,19 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
// 成功
duration := time.Since(startTime)
if logPayload {
c.logger.Debug("Gateway 请求成功",
zap.String("path", path),
zap.Duration("duration", duration),
zap.Any("result", result),
)
} else {
c.logger.Debug("Gateway 请求成功",
zap.String("path", path),
zap.Duration("duration", duration),
zap.Int("result_bytes", len(result)),
)
}
return result, nil
}

View File

@@ -0,0 +1,60 @@
// Package gateway 提供付款凭证识别能力,仅用于交易流水号表单预填。
// 识别结果不是资金事实,也不代表系统已完成任何资金动作。
package gateway
import (
"context"
"reflect"
"strings"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// paymentVoucherRecognitionPath 是付款凭证识别接口路径。
const paymentVoucherRecognitionPath = "/ai/ocr/extract-payment"
// PaymentVoucherExtractionRequest 是付款凭证识别请求,只传图片内容。
type PaymentVoucherExtractionRequest struct {
// ImageBase64 是付款凭证图片的 base64 编码内容。
ImageBase64 string `json:"image_base64"`
}
// paymentVoucherExtraction 只解码本系统消费的支付单号。
// 识别响应还含 amount数值、payee、payment_method、payment_time 与 remark
// 这些字段刻意不声明、不解码:既不进入响应、不预填、不落库,也不被本进程持有,
// 同时避免上游字段类型漂移(实测 amount 为 JSON 数值)导致整条响应解析失败。
type paymentVoucherExtraction struct {
OrderNumber string `json:"order_number"`
}
// ExtractPaymentVoucherOrderNumber 识别付款凭证图片并只返回识别出的支付单号。
// 该能力刻意不使用记录完整请求体与响应体的泛型入口,日志只含路径、耗时与结果摘要;
// 返回空字符串表示识别服务未给出支付单号,由调用方决定失败口径。
func (c *Client) ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error) {
if strings.TrimSpace(imageBase64) == "" {
return "", errors.New(errors.CodeInvalidParam, "付款凭证图片内容不能为空")
}
data, err := c.doRequestWithoutPayloadLog(ctx, paymentVoucherRecognitionPath, PaymentVoucherExtractionRequest{
ImageBase64: imageBase64,
})
if err != nil {
return "", err
}
var extraction paymentVoucherExtraction
if err := sonic.Unmarshal(data, &extraction); err != nil {
// 刻意不记录 err.Error()sonic 的类型错误消息会内嵌响应 JSON 原文片段,
// 一旦进入日志或错误上下文就等于记录识别原始结果(金额、单号等)。
// 只记录可诊断且非敏感的摘要:路径、响应字节数与错误类型名,
// 足以区分语法错、类型错与空响应,又不携带任何载荷内容。
c.logger.Warn("付款凭证识别响应解析失败",
zap.String("path", paymentVoucherRecognitionPath),
zap.Int("result_bytes", len(data)),
zap.String("err_kind", reflect.TypeOf(err).String()),
)
return "", errors.New(errors.CodeGatewayInvalidResp, "解析付款凭证识别结果失败")
}
return strings.TrimSpace(extraction.OrderNumber), nil
}

View File

@@ -4,12 +4,14 @@ import (
"bytes"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
agentrechargequery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
@@ -24,6 +26,8 @@ type AgentRechargeHandler struct {
service *agentRechargeSvc.Service
online *agentrechargeapp.OnlineCreationService
status *agentrechargequery.PaymentStatusQuery
ocr *agentrechargeapp.PaymentVoucherOCRService
config *systemconfigapp.UpdateService
validator *validator.Validate
}
@@ -37,6 +41,16 @@ func (h *AgentRechargeHandler) SetPaymentStatusQuery(query *agentrechargequery.P
h.status = query
}
// SetPaymentVoucherOCRService 注入付款凭证识别用例。
func (h *AgentRechargeHandler) SetPaymentVoucherOCRService(service *agentrechargeapp.PaymentVoucherOCRService) {
h.ocr = service
}
// SetSystemConfigUpdateService 注入受控系统配置写服务,用于代理自充允许范围修改。
func (h *AgentRechargeHandler) SetSystemConfigUpdateService(service *systemconfigapp.UpdateService) {
h.config = service
}
// NewAgentRechargeHandler 创建代理预充值 Handler
func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *validator.Validate) *AgentRechargeHandler {
return &AgentRechargeHandler{service: service, validator: validator}
@@ -73,8 +87,9 @@ func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRec
if h.online == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || strings.TrimSpace(req.Remark) != "" {
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、支付凭证或运营备注")
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || len(req.OtherVoucherKey) > 0 ||
req.OfflinePaymentMethodID != 0 || strings.TrimSpace(req.ExternalTransactionNo) != "" || strings.TrimSpace(req.Remark) != "" {
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、收款方式、交易流水号、支付凭证或运营备注")
}
result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{
AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()),
@@ -108,6 +123,69 @@ func (h *AgentRechargeHandler) PaymentMethods(c *fiber.Ctx) error {
})
}
// SelfRechargePaymentMethods 查询代理自充实际可用支付方式。
// GET /api/admin/agent-self-recharge-payment-methods
// 响应只含交集结果,不含允许范围、商户身份或凭证。
func (h *AgentRechargeHandler) SelfRechargePaymentMethods(c *fiber.Ctx) error {
return h.PaymentMethods(c)
}
// UpdateSelfRechargePaymentMethods 修改代理在线自充允许范围。
// PUT /api/admin/agent-self-recharge-payment-methods
// 复用受控系统配置写服务,获得超级管理员限定、咨询锁串行与前后值审计。
func (h *AgentRechargeHandler) UpdateSelfRechargePaymentMethods(c *fiber.Ctx) error {
var req dto.AgentSelfRechargePaymentMethodsUpdateRequest
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.config == nil {
return errors.New(errors.CodeServiceUnavailable, "代理自充允许范围维护能力未配置")
}
item, err := h.config.Execute(c.UserContext(), constants.SystemConfigAgentSelfRechargeAllowedMethods,
dto.UpdateSystemConfigRequest{Value: req.AllowedMethods})
if err != nil {
return err
}
updatedAt := ""
if item.UpdatedAt != nil {
updatedAt = item.UpdatedAt.UTC().Format(time.RFC3339)
}
return response.Success(c, &dto.AgentSelfRechargePaymentMethodsUpdateResponse{
AllowedMethods: item.Value, AllowedMethodsName: constants.GetAgentSelfRechargeAllowedMethodsName(item.Value),
UpdatedAt: updatedAt,
})
}
// PaymentVoucherOCR 识别付款凭证,只返回交易流水号预填值。
// POST /api/admin/agent-recharges/payment-voucher-ocr
// 识别失败返回明确失败,不影响提交人人工填写交易流水号后创建申请。
func (h *AgentRechargeHandler) PaymentVoucherOCR(c *fiber.Ctx) error {
var req dto.AgentRechargePaymentVoucherOCRRequest
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.ocr == nil {
return errors.New(errors.CodeServiceUnavailable, "付款凭证识别能力未配置")
}
result, err := h.ocr.Recognize(c.UserContext(), req.PaymentVoucherKey)
if err != nil {
return err
}
return response.Success(c, &dto.AgentRechargePaymentVoucherOCRResponse{
ExternalTransactionNo: result.ExternalTransactionNo,
})
}
// List 查询代理充值订单列表
// GET /api/admin/agent-recharges
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {

View File

@@ -648,7 +648,7 @@ func (h *AssetHandler) resolveAssetPackageUsagePath(c *fiber.Ctx) (*dto.AssetRes
}
func ensureAssetPackageAdjuster(c *fiber.Ctx) error {
if middleware.GetUserIDFromContext(c.UserContext()) != 41 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
if middleware.GetUserIDFromContext(c.UserContext()) != 1124 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil

View File

@@ -0,0 +1,272 @@
package admin
import (
"strings"
"time"
"github.com/gofiber/fiber/v2"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
employeecollectionquery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// EmployeeCollectionHandler 处理员工代收款账单、核销申请与线下收款方式字典请求。
// 边界只做绑定、路径参数校验与统一响应,状态、金额与权限不变量由应用用例判断。
type EmployeeCollectionHandler struct {
paymentMethod *employeecollectionapp.PaymentMethodService
paymentMethodQuery *employeecollectionquery.PaymentMethodQuery
billClose *employeecollectionapp.BillCloseService
billQuery *employeecollectionquery.BillQuery
application *employeecollectionapp.ApplicationService
applicationQuery *employeecollectionquery.ApplicationQuery
}
// NewEmployeeCollectionHandler 创建员工代收款处理器。
func NewEmployeeCollectionHandler(
service *employeecollectionapp.PaymentMethodService,
billClose *employeecollectionapp.BillCloseService,
application *employeecollectionapp.ApplicationService,
) *EmployeeCollectionHandler {
return &EmployeeCollectionHandler{paymentMethod: service, billClose: billClose, application: application}
}
// SetApplicationQuery 注入核销申请只读投影。
func (h *EmployeeCollectionHandler) SetApplicationQuery(query *employeecollectionquery.ApplicationQuery) {
h.applicationQuery = query
}
// SetPaymentMethodQuery 注入线下收款方式字典只读投影。
func (h *EmployeeCollectionHandler) SetPaymentMethodQuery(query *employeecollectionquery.PaymentMethodQuery) {
h.paymentMethodQuery = query
}
// SetBillQuery 注入员工代收款账单只读投影。
func (h *EmployeeCollectionHandler) SetBillQuery(query *employeecollectionquery.BillQuery) {
h.billQuery = query
}
// CreatePaymentMethod 创建线下收款方式。
// POST /api/admin/employee-collection-payment-methods
func (h *EmployeeCollectionHandler) CreatePaymentMethod(c *fiber.Ctx) error {
var request dto.CreateEmployeeCollectionPaymentMethodRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.paymentMethod.Create(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdatePaymentMethod 更新线下收款方式,支持修改名称、排序、启停、备注与未被引用时的稳定编码。
// PUT /api/admin/employee-collection-payment-methods/:id
func (h *EmployeeCollectionHandler) UpdatePaymentMethod(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var request dto.UpdateEmployeeCollectionPaymentMethodRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.paymentMethod.Update(c.UserContext(), id, request)
if err != nil {
return err
}
return response.Success(c, result)
}
// DeletePaymentMethod 删除未被核销申请引用的线下收款方式。
// DELETE /api/admin/employee-collection-payment-methods/:id
func (h *EmployeeCollectionHandler) DeletePaymentMethod(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
if err := h.paymentMethod.Delete(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// ListPaymentMethods 分页查询线下收款方式。
// GET /api/admin/employee-collection-payment-methods
func (h *EmployeeCollectionHandler) ListPaymentMethods(c *fiber.Ctx) error {
var request dto.EmployeeCollectionPaymentMethodListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.paymentMethodQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// ListBills 分页查询员工代收款账单。
// GET /api/admin/employee-collection-bills
func (h *EmployeeCollectionHandler) ListBills(c *fiber.Ctx) error {
var request dto.EmployeeCollectionBillListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.billQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// StatisticsBills 汇总员工代收款账单金额与待处理数量。
// GET /api/admin/employee-collection-bills/statistics
func (h *EmployeeCollectionHandler) StatisticsBills(c *fiber.Ctx) error {
var request dto.EmployeeCollectionBillStatisticsRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.billQuery.Statistics(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetBill 查询员工代收款账单详情。
// GET /api/admin/employee-collection-bills/:id
func (h *EmployeeCollectionHandler) GetBill(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.billQuery.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CloseBill 关闭员工代收款账单,仅超级管理员可操作。
// POST /api/admin/employee-collection-bills/:id/close
func (h *EmployeeCollectionHandler) CloseBill(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var request dto.CloseEmployeeCollectionBillRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
bill, err := h.billClose.Close(c.UserContext(), id, request.Reason)
if err != nil {
return err
}
result, err := employeecollectionquery.ProjectBill(bill)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateApplication 为本人可见账单创建核销申请。
// POST /api/admin/employee-collection-applications
func (h *EmployeeCollectionHandler) CreateApplication(c *fiber.Ctx) error {
request, err := bindApplicationRequest(c)
if err != nil {
return err
}
result, err := h.application.Create(c.UserContext(), request)
if err != nil {
return err
}
projected, err := employeecollectionquery.ProjectApplicationSubmit(employeecollectionquery.ApplicationSubmitProjection{
Application: result.Application, Attempt: result.Attempt, Allocations: result.Allocations,
Bills: result.Bills, InstanceID: result.InstanceID, InstanceStatus: result.InstanceStatus,
})
if err != nil {
return err
}
return response.Success(c, projected)
}
// ResubmitApplication 修改并重提已驳回的核销申请。
// PUT /api/admin/employee-collection-applications/:id
func (h *EmployeeCollectionHandler) ResubmitApplication(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
request, err := bindApplicationRequest(c)
if err != nil {
return err
}
result, err := h.application.Resubmit(c.UserContext(), id, request)
if err != nil {
return err
}
projected, err := employeecollectionquery.ProjectApplicationSubmit(employeecollectionquery.ApplicationSubmitProjection{
Application: result.Application, Attempt: result.Attempt, Allocations: result.Allocations,
Bills: result.Bills, InstanceID: result.InstanceID, InstanceStatus: result.InstanceStatus,
})
if err != nil {
return err
}
return response.Success(c, projected)
}
// ListApplications 分页查询核销申请。
// GET /api/admin/employee-collection-applications
func (h *EmployeeCollectionHandler) ListApplications(c *fiber.Ctx) error {
var request dto.EmployeeCollectionApplicationListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.applicationQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// GetApplication 查询核销申请详情,含分摊与全部审批尝试历史。
// GET /api/admin/employee-collection-applications/:id
func (h *EmployeeCollectionHandler) GetApplication(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.applicationQuery.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// bindApplicationRequest 绑定并转换创建或重提核销申请请求。
// 付款时间按带时区的 RFC3339 解析;其余边界与业务校验由应用用例统一判断。
func bindApplicationRequest(c *fiber.Ctx) (employeecollectionapp.SubmitApplicationCommand, error) {
var request dto.SubmitEmployeeCollectionApplicationRequest
if err := c.BodyParser(&request); err != nil {
return employeecollectionapp.SubmitApplicationCommand{}, errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
paidAt, err := time.Parse(time.RFC3339, strings.TrimSpace(request.PaidAt))
if err != nil {
return employeecollectionapp.SubmitApplicationCommand{}, errors.New(errors.CodeInvalidParam, "付款时间必须为带时区的 RFC3339 格式")
}
allocations := make([]employeecollectionapp.ApplicationAllocationCommand, 0, len(request.Allocations))
for _, item := range request.Allocations {
allocations = append(allocations, employeecollectionapp.ApplicationAllocationCommand{
BillID: item.BillID, Amount: item.Amount,
})
}
return employeecollectionapp.SubmitApplicationCommand{
PaymentMethodID: request.PaymentMethodID, PaidAmount: request.PaidAmount,
PayerName: request.PayerName, PaidAt: paidAt,
ExternalTransactionNo: request.ExternalTransactionNo,
PaymentVoucherKeys: request.PaymentVoucherKeys, Remark: request.Remark,
ActingReason: request.ActingReason, Allocations: allocations,
}, nil
}

View File

@@ -0,0 +1,218 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PaymentMerchantHandler 处理特权商户与商户池配置请求。
type PaymentMerchantHandler struct {
service *merchantpayment.ManagementService
}
// NewPaymentMerchantHandler 创建商户池管理处理器。
func NewPaymentMerchantHandler(service *merchantpayment.ManagementService) *PaymentMerchantHandler {
return &PaymentMerchantHandler{service: service}
}
func pathID(c *fiber.Ctx) (uint, error) {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return 0, errors.New(errors.CodeInvalidParam, "无效的路径ID")
}
return uint(id), nil
}
// CreateMerchant 创建支付商户。
// POST /api/admin/payment-merchants
func (h *PaymentMerchantHandler) CreateMerchant(c *fiber.Ctx) error {
var r dto.PaymentMerchantRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.CreateMerchant(c.UserContext(), r)
if err != nil {
return err
}
return response.Success(c, v)
}
// ListMerchants 分页查询支付商户。
// GET /api/admin/payment-merchants
func (h *PaymentMerchantHandler) ListMerchants(c *fiber.Ctx) error {
var r dto.PaymentMerchantListRequest
if err := c.QueryParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, n, err := h.service.ListMerchants(c.UserContext(), r)
if err != nil {
return err
}
return response.SuccessWithPagination(c, v, n, r.Page, r.PageSize)
}
// GetMerchant 查询支付商户详情。
// GET /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) GetMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
v, err := h.service.GetMerchant(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, v)
}
// UpdateMerchant 更新支付商户。
// PUT /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) UpdateMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantUpdateRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.UpdateMerchant(c.UserContext(), id, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// DeleteMerchant 删除支付商户。
// DELETE /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) DeleteMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantDeleteRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.DeleteMerchant(c.UserContext(), id, r.Confirm); err != nil {
return err
}
return response.Success(c, nil)
}
// CreatePool 创建商户池。
// POST /api/admin/payment-merchant-pools
func (h *PaymentMerchantHandler) CreatePool(c *fiber.Ctx) error {
var r dto.PaymentMerchantPoolRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SavePool(c.UserContext(), 0, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// UpdatePool 更新商户池。
// PUT /api/admin/payment-merchant-pools/:id
func (h *PaymentMerchantHandler) UpdatePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantPoolRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SavePool(c.UserContext(), id, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// EnablePool 启用商户池。
// POST /api/admin/payment-merchant-pools/:id/enable
func (h *PaymentMerchantHandler) EnablePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.SetPoolEnabled(c.UserContext(), id, true)
if err != nil {
return err
}
return response.Success(c, result)
}
// DisablePool 停用商户池。
// POST /api/admin/payment-merchant-pools/:id/disable
func (h *PaymentMerchantHandler) DisablePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.SetPoolEnabled(c.UserContext(), id, false)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetAuthorization 获取微信授权配置。
// GET /api/admin/wechat-authorizations
func (h *PaymentMerchantHandler) GetAuthorization(c *fiber.Ctx) error {
v, err := h.service.GetAuthorization(c.UserContext())
if err != nil {
return err
}
return response.Success(c, v)
}
// ListPools 分页查询商户池。
// GET /api/admin/payment-merchant-pools
func (h *PaymentMerchantHandler) ListPools(c *fiber.Ctx) error {
var r dto.PaymentMerchantPoolListRequest
if err := c.QueryParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, total, err := h.service.ListPools(c.UserContext(), r)
if err != nil {
return err
}
return response.SuccessWithPagination(c, v, total, r.Page, r.PageSize)
}
// GetPool 查询商户池详情。
// GET /api/admin/payment-merchant-pools/:id
func (h *PaymentMerchantHandler) GetPool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
v, err := h.service.GetPool(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, v)
}
// SaveAuthorization 保存微信授权配置。
// PUT /api/admin/wechat-authorizations/current
func (h *PaymentMerchantHandler) SaveAuthorization(c *fiber.Ctx) error {
var r dto.WechatAuthorizationRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SaveAuthorization(c.UserContext(), r)
if err != nil {
return err
}
return response.Success(c, v)
}

View File

@@ -3,12 +3,15 @@ package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
@@ -16,11 +19,21 @@ import (
type ShopCommissionHandler struct {
service *shopCommissionService.Service
fundSummaryQuery *shopQuery.FundSummaryQuery
withdrawalQuery *distributionquery.Query
validator *validator.Validate
}
// NewShopCommissionHandler 创建代理商资金管理 Handler
func NewShopCommissionHandler(service *shopCommissionService.Service) *ShopCommissionHandler {
return &ShopCommissionHandler{service: service}
func NewShopCommissionHandler(
service *shopCommissionService.Service,
validate *validator.Validate,
) *ShopCommissionHandler {
return &ShopCommissionHandler{service: service, validator: validate}
}
// SetWithdrawalQuery 注入提现申请详情查询。
func (h *ShopCommissionHandler) SetWithdrawalQuery(query *distributionquery.Query) {
h.withdrawalQuery = query
}
// SetFundSummaryQuery 注入代理商资金概况 Query。
@@ -175,6 +188,62 @@ func (h *ShopCommissionHandler) CreateWithdrawal(c *fiber.Ctx) error {
return response.Success(c, result)
}
// ResubmitWithdrawal 重提被驳回的提现申请
// PUT /api/admin/shops/:shop_id/withdrawal-requests/:id
func (h *ShopCommissionHandler) ResubmitWithdrawal(c *fiber.Ctx) error {
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
requestID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || requestID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的提现申请 ID")
}
var req dto.ResubmitWithdrawalReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现重提校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "提现重提参数不合法")
}
result, err := h.service.ResubmitWithdrawalRequest(c.UserContext(), uint(shopID), uint(requestID), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// WithdrawalDetail 提现申请详情
// GET /api/admin/shops/:shop_id/withdrawal-requests/:id
// 仅返回当前账号数据范围内的申请;超出范围与不存在返回同一结果。
func (h *ShopCommissionHandler) WithdrawalDetail(c *fiber.Ctx) error {
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
requestID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || requestID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的提现申请 ID")
}
if h.withdrawalQuery == nil {
return errors.New(errors.CodeInternalError, "提现详情查询能力未配置")
}
if err := middleware.CanManageShop(c.UserContext(), uint(shopID)); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
detail, err := h.withdrawalQuery.WithdrawalDetail(c.UserContext(), uint(requestID))
if err != nil {
return err
}
if detail.ShopID != uint(shopID) {
return errors.New(errors.CodeNotFound, "提现申请不存在")
}
return response.Success(c, detail)
}
// ListMainWalletTransactions 预充值钱包流水列表
// GET /api/admin/shops/:shop_id/main-wallet/transactions
func (h *ShopCommissionHandler) ListMainWalletTransactions(c *fiber.Ctx) error {

View File

@@ -0,0 +1,129 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// WithdrawalQualificationHandler 提现资料资格后台 Handler。
type WithdrawalQualificationHandler struct {
service *distributionapp.QualificationService
query *distributionquery.Query
validator *validator.Validate
}
// NewWithdrawalQualificationHandler 创建提现资料资格后台 Handler。
func NewWithdrawalQualificationHandler(
service *distributionapp.QualificationService,
query *distributionquery.Query,
validate *validator.Validate,
) *WithdrawalQualificationHandler {
return &WithdrawalQualificationHandler{service: service, query: query, validator: validate}
}
// SubmitWithdrawalQualification 提交或替换提现资料资格
// POST /api/admin/shops/:shop_id/withdrawal-qualifications
// 仅本人代理店铺;替换合同或法人身份证时同一事务新增版本并使旧有效版本失效。
func (h *WithdrawalQualificationHandler) SubmitWithdrawalQualification(c *fiber.Ctx) error {
if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
var req dto.SubmitWithdrawalQualificationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "提现资料资格参数不合法")
}
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
result, err := h.service.Submit(c.UserContext(), uint(shopID), distributiondomain.QualificationInput{
SubjectType: req.SubjectType,
SubjectCode: req.SubjectCode,
LegalPersonIDCard: req.LegalPersonIDCard,
ContractFileKey: req.ContractFileKey,
IDCardFrontFileKey: req.IDCardFrontFileKey,
IDCardBackFileKey: req.IDCardBackFileKey,
BusinessLicenseFileKey: req.BusinessLicenseFileKey,
ShopFrontFileKey: req.ShopFrontFileKey,
InvoiceFileKey: req.InvoiceFileKey,
InvoiceTitle: req.InvoiceTitle,
InvoiceSubjectCode: req.InvoiceSubjectCode,
})
if err != nil {
return err
}
return response.Success(c, &dto.SubmitWithdrawalQualificationResp{
ID: result.QualificationID,
Status: result.Status,
StatusName: constants.GetWithdrawalQualificationStatusName(result.Status),
})
}
// VoidWithdrawalQualification 超级管理员作废有效提现资料资格
// POST /api/admin/withdrawal-qualifications/:id/void
func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ctx) error {
if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的资格 ID")
}
var req dto.VoidWithdrawalQualificationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
}
if err := h.service.Void(c.UserContext(), uint(id), req.Reason); err != nil {
return err
}
return response.Success(c, nil)
}
// ListWithdrawalQualifications 查询提现资料资格版本
// GET /api/admin/shops/:shop_id/withdrawal-qualifications
// 仅返回当前账号数据范围内的资料版本;证件号脱敏,附件只返回对象存储 Key。
func (h *WithdrawalQualificationHandler) ListWithdrawalQualifications(c *fiber.Ctx) error {
if h.query == nil {
return errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置")
}
var req dto.WithdrawalQualificationListReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 路由路径必带 shop_id数据范围由 CanManageShop 在业务边界强制。
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
if err := middleware.CanManageShop(c.UserContext(), uint(shopID)); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
result, err := h.query.ListQualifications(c.UserContext(), []uint{uint(shopID)}, &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}

View File

@@ -0,0 +1,68 @@
package app
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AgentDistributionHandler 代理分销扫码注册公开 Handler。
type AgentDistributionHandler struct {
service *distributionapp.RegistrationService
validator *validator.Validate
}
// NewAgentDistributionHandler 创建代理分销扫码注册公开 Handler。
func NewAgentDistributionHandler(
service *distributionapp.RegistrationService,
validate *validator.Validate,
) *AgentDistributionHandler {
return &AgentDistributionHandler{service: service, validator: validate}
}
// RegisterAgentDistribution 提交代理扫码注册
// POST /api/c/v1/agent-distribution-registrations
// 无需认证、JWT、角色或权限只创建待审批注册记录不返回任何账号凭证。
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库。
func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error {
if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
}
var req dto.CreateAgentDistributionRegistrationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "注册参数不合法")
}
result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{
DistributionCode: req.DistributionCode,
Phone: req.Phone,
Username: req.Username,
Password: req.Password,
ShopName: req.ShopName,
ShopCode: req.ShopCode,
ContactName: req.ContactName,
Province: req.Province,
City: req.City,
District: req.District,
Address: req.Address,
}, req.Code)
if err != nil {
return err
}
return response.Success(c, &dto.CreateAgentDistributionRegistrationResp{
ID: result.RegistrationID,
Status: result.Status,
StatusName: constants.GetAgentDistributionRegistrationStatusName(result.Status),
})
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
@@ -462,7 +463,7 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
return response.Success(c, &dto.AssetPackageListResponse{Packages: items})
}
// GetPackageHistory B3 资产套餐历史
// GetPackageHistory B3 资产套餐历史
// GET /api/c/v1/asset/package-history
func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
var req dto.AssetPackageHistoryRequest
@@ -485,55 +486,68 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
return err
}
query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}).
Where("generation = ?", resolved.Generation)
if resolved.Asset.AssetType == "card" {
query = query.Where("iot_card_id = ?", resolved.Asset.AssetID)
} else {
query = query.Where("device_id = ?", resolved.Asset.AssetID)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.PackageType != nil {
query = query.Where("package_id IN (?)",
h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败")
}
var usages []*model.PackageUsage
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
}
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages)
history, err := assetquery.NewPackageHistoryQuery(h.db).List(resolved.SkipPermissionCtx, assetquery.PackageHistoryInput{
AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID,
Generation: &resolved.Generation,
Status: req.Status,
PackageType: req.PackageType,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return err
}
list := make([]dto.AssetPackageResponse, 0, len(usages))
for _, usage := range usages {
pkg := packageMap[usage.PackageID]
metrics := usage.BuildTrafficMetrics()
pkgName := usage.PackageName
pkgType := ""
if pkg != nil {
if pkgName == "" {
pkgName = pkg.PackageName
}
pkgType = pkg.PackageType
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, collectPackageHistoryUsages(history.Items))
if err != nil {
return err
}
list = append(list, dto.AssetPackageResponse{
items := make([]*dto.ClientAssetPackageHistoryNode, 0, len(history.Items))
for _, node := range history.Items {
items = append(items, buildClientPackageHistoryNode(node, packageMap))
}
return response.SuccessWithPagination(c, items, history.Total, req.Page, req.PageSize)
}
func collectPackageHistoryUsages(items []*assetquery.PackageHistoryNode) []*model.PackageUsage {
usages := make([]*model.PackageUsage, 0)
var collect func(*assetquery.PackageHistoryNode)
collect = func(node *assetquery.PackageHistoryNode) {
if node == nil || node.Usage == nil {
return
}
usages = append(usages, node.Usage)
for _, child := range node.Children {
collect(child)
}
}
for _, item := range items {
collect(item)
}
return usages
}
func buildClientPackageHistoryNode(node *assetquery.PackageHistoryNode, packageMap map[uint]*model.Package) *dto.ClientAssetPackageHistoryNode {
usage := node.Usage
pkg := packageMap[usage.PackageID]
metrics := usage.BuildTrafficMetrics()
packageName := usage.PackageName
packageType := ""
if pkg != nil {
if packageName == "" {
packageName = pkg.PackageName
}
packageType = pkg.PackageType
}
item := &dto.ClientAssetPackageHistoryNode{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: pkgName,
PackageType: pkgType,
PackageName: packageName,
PackageType: packageType,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
@@ -548,10 +562,17 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,
})
Children: make([]*dto.ClientAssetPackageHistoryNode, 0, len(node.Children)),
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
if node.RelationshipStatus != "" {
item.RelationshipStatus = node.RelationshipStatus
item.RelationshipStatusName = "关联主套餐缺失"
}
for _, child := range node.Children {
item.Children = append(item.Children, buildClientPackageHistoryNode(child, packageMap))
}
item.ExpandByDefault = len(item.Children) > 0
return item
}
// RefreshAsset B4 资产刷新

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/middleware"
@@ -16,7 +17,7 @@ import (
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -41,7 +42,7 @@ type ClientWalletHandler struct {
paymentStore *postgres.PaymentStore
rechargeService *rechargeSvc.Service
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService *wechatConfigSvc.Service
merchantRuntime *merchantpayment.RuntimeLoader
redis *redis.Client
logger *zap.Logger
db *gorm.DB
@@ -73,7 +74,6 @@ func NewClientWalletHandler(
paymentStore *postgres.PaymentStore,
rechargeService *rechargeSvc.Service,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService *wechatConfigSvc.Service,
redisClient *redis.Client,
logger *zap.Logger,
db *gorm.DB,
@@ -89,7 +89,7 @@ func NewClientWalletHandler(
paymentStore: paymentStore,
rechargeService: rechargeService,
openIDStore: openIDStore,
wechatConfigService: wechatConfigService,
merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient),
redis: redisClient,
logger: logger,
db: db,
@@ -321,19 +321,11 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
// }
// }
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
if err != nil {
return err
}
if config == nil {
return errors.New(errors.CodeWechatConfigUnavailable)
}
switch req.PaymentMethod {
case constants.RechargeMethodAlipay:
return h.createAlipayRecharge(c, resolved, config, wallet, req)
return h.createAlipayRecharge(c, resolved, wallet, req)
case constants.RechargeMethodWechat:
return h.createWechatRecharge(c, resolved, config, wallet, req)
return h.createWechatRecharge(c, resolved, wallet, req)
default:
return errors.New(errors.CodePaymentMethodUnavailable)
}
@@ -343,15 +335,13 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
func (h *ClientWalletHandler) createWechatRecharge(
c *fiber.Ctx,
resolved *resolvedWalletAssetContext,
config *model.WechatConfig,
wallet *model.AssetWallet,
req dto.ClientCreateRechargeRequest,
) error {
appID, err := pickAppIDByType(config, req.AppType)
authorization, appID, err := h.loadWechatAuthorization(resolved.SkipPermissionCtx, req.AppType)
if err != nil {
return err
}
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
if err != nil {
return err
@@ -359,9 +349,47 @@ func (h *ClientWalletHandler) createWechatRecharge(
rechargeNo := generateClientRechargeNo()
paymentNo := generateClientPaymentNo()
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
}
var config *model.WechatConfig
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByWechat, authorization)
if err != nil {
return err
}
config = selectedConfig
merchantpayment.FreezeRoute(payment, route)
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount)
if err != nil {
return err
@@ -380,49 +408,15 @@ func (h *ClientWalletHandler) createWechatRecharge(
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil {
return completeErr
}
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
return updateErr
}
return err
}
if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return err
}
// 支付通道确认可用后,再创建充值订单和支付记录
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
PaymentConfigID: &config.ID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
}
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
return response.Success(c, &dto.ClientRechargeResponse{
Recharge: dto.ClientRechargeResult{
RechargeID: rechargeOrder.ID,
@@ -439,18 +433,11 @@ func (h *ClientWalletHandler) createWechatRecharge(
func (h *ClientWalletHandler) createAlipayRecharge(
c *fiber.Ctx,
resolved *resolvedWalletAssetContext,
config *model.WechatConfig,
wallet *model.AssetWallet,
req dto.ClientCreateRechargeRequest,
) error {
rechargeNo := generateClientRechargeNo()
paymentNo := generateClientPaymentNo()
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
@@ -459,7 +446,6 @@ func (h *ClientWalletHandler) createAlipayRecharge(
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
PaymentConfigID: &config.ID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
@@ -471,12 +457,22 @@ func (h *ClientWalletHandler) createAlipayRecharge(
PaymentMethod: model.PaymentByAlipay,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
ExpireAt: &expireAt,
}
// WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单
var config *model.WechatConfig
var expireAt time.Time
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByAlipay, nil)
if err != nil {
return err
}
expireMinutes := selectedConfig.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt = time.Now().Add(time.Duration(expireMinutes) * time.Minute)
config = selectedConfig
payment.ExpireAt = &expireAt
merchantpayment.FreezeRoute(payment, route)
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
@@ -505,7 +501,6 @@ func (h *ClientWalletHandler) createAlipayRecharge(
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", req.Amount),
zap.Time("expire_at", expireAt),
zap.Uint("config_id", config.ID),
)
expireStr := expireAt.Format(time.RFC3339)
@@ -738,7 +733,37 @@ func parseOptionalTime(value string) (*time.Time, error) {
return nil, fmt.Errorf("invalid time format")
}
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
func (h *ClientWalletHandler) loadWechatAuthorization(ctx context.Context, appType string) (*model.WechatAuthorization, string, error) {
if h.merchantRuntime == nil {
return nil, "", errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
authorization, err := h.merchantRuntime.LoadAuthorization(ctx)
if err != nil {
return nil, "", err
}
appID, err := pickAppIDByType(authorization, appType)
if err != nil {
return nil, "", err
}
return authorization, appID, nil
}
func (h *ClientWalletHandler) selectMerchantConfig(ctx context.Context, tx *gorm.DB, paymentMethod string, authorization *model.WechatAuthorization) (*merchantpayment.RouteSelection, *model.WechatConfig, error) {
if h.merchantRuntime == nil {
return nil, nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
route, err := h.merchantRuntime.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, time.Now())
if err != nil {
return nil, nil, err
}
config, err := merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return nil, nil, err
}
return route, config, nil
}
func pickAppIDByType(config *model.WechatAuthorization, appType string) (string, error) {
switch appType {
case "official_account":
if strings.TrimSpace(config.OaAppID) == "" {

View File

@@ -54,7 +54,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam)
}
wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext())
if err != nil {
return err
}
@@ -82,7 +82,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error {
// GetAppID 获取当前生效的公众号 AppID
// GET /api/c/v1/wechat/appid
func (h *ClientWechatHandler) GetAppID(c *fiber.Ctx) error {
wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext())
if err != nil {
return err
}

View File

@@ -38,6 +38,7 @@ type AgentRechargeServiceInterface interface {
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error)
GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, error)
}
type PaymentHandler struct {
@@ -98,7 +99,7 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
body := c.Body()
ctx := c.UserContext()
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
// 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置
orderNo, err := wechat.PeekOrderNo(body)
if err != nil {
h.logger.Error("微信回调:预解析订单号失败", zap.Error(err))
@@ -144,12 +145,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
}
case model.ProviderTypeWechat:
if h.wechatPayment == nil {
return errors.New(errors.CodeWechatCallbackInvalid, "微信 v3 支付未初始化")
paymentSvc, err := h.wechatConfigService.GetPaymentServiceForCallback(cfg)
if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "构建微信 v3 回调验签实例失败")
}
var httpReq http.Request
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
_, err = paymentSvc.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
if result.TradeState != "SUCCESS" {
return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
@@ -421,7 +423,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
// 按 payment_config_id 加载创建支付单时所用的配置(支持已停用配置)
// 按支付单冻结商户或历史 payment_config_id 双读加载配置(支持已停用配置)
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, outTradeNo)
if err != nil || cfg == nil {
h.logger.Error("支付宝回调:加载支付配置失败",
@@ -494,13 +496,14 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败")
}
// 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn
if payment.PaymentConfigID == nil {
// 商户支付单已由 GetConfigForCallback 按 merchant_id 绑定当前商户凭证;
// payment_config_id 只属于历史双读路径,不能用于否定新商户回调。
if payment.MerchantID == nil && payment.PaymentConfigID == nil {
h.logger.Warn("支付宝回调payment_config_id 为空,跳过配置 ID 校验(旧数据兼容)",
zap.String("out_trade_no", outTradeNo),
zap.Uint("config_id", cfg.ID),
)
} else if *payment.PaymentConfigID != cfg.ID {
} else if payment.MerchantID == nil && *payment.PaymentConfigID != cfg.ID {
h.logger.Error("支付宝回调payment_config_id 不匹配",
zap.String("out_trade_no", outTradeNo),
zap.Uint("payment_config_id", *payment.PaymentConfigID),
@@ -567,7 +570,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
ctx := c.UserContext()
c.Set("Content-Type", "text/plain; charset=utf-8")
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
// 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置
preNotify, err := fuiou.ParseNotify(body)
if err != nil {
h.logger.Error("富友回调:预解析失败",

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -54,7 +55,7 @@ func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.Audi
if err != nil {
return nil, err
}
resources = append(resources, business)
resources = append(resources, business...)
resources = append(resources, approvalSubmitterResource(change))
seenIntegrationIDs := make(map[string]struct{}, len(change.IntegrationIDs))
for _, integrationID := range change.IntegrationIDs {
@@ -82,30 +83,66 @@ func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.Audi
return resources, nil
}
func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) (ResourceInput, error) {
// approvalBusinessResource 构造审批关联的业务资源。
//
// 退款审批的业务标识在尝试模式下指向退款审批尝试记录、存量模式下指向退款申请本身,
// 因此该分支统一经 refundapproval.ResolveRefundInTx 解析业务归属:主资源固定为解析出的
// 退款单,尝试记录存在时再追加一条引用资源,使两种语义在同一审计事件内都可追溯。
func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) ([]ResourceInput, error) {
id := strconv.FormatUint(uint64(businessID), 10)
switch businessType {
case constants.ApprovalBusinessTypeRefund:
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联退款单失败")
refund, attempt, err := refundapproval.ResolveRefundInTx(ctx, tx, businessID, instanceID)
if err != nil {
// 提交事务内的「审批申请」审计先于 attachAttemptInstance 执行:此刻尝试记录已写入,
// 但 approval_instance_id 仍为空,共享解析器的实例一致性校验必然不命中。
// 这里只补一条尚未回写实例的尝试记录解析,其余不一致仍按解析器的冲突错误失败关闭。
refund, attempt, err = resolvePendingRefundAttempt(ctx, tx, businessID, err)
if err != nil {
return nil, err
}
return ResourceInput{
Type: constants.AuditResourceRefund, ID: &id, Key: refund.RefundNo, DisplayName: refund.RefundNo,
}
refundID := strconv.FormatUint(uint64(refund.ID), 10)
resources := []ResourceInput{{
Type: constants.AuditResourceRefund, ID: &refundID, Key: refund.RefundNo, DisplayName: refund.RefundNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "order_id": refund.OrderID, "order_no": refund.OrderNo,
"order_type": refund.OrderType, "asset_identifier": refund.AssetIdentifier, "shop_id": refund.ShopID,
"requested_refund_amount": refund.RequestedRefundAmount, "actual_received_amount": refund.ActualReceivedAmount,
"method": refund.Method, "frozen_actual_received_amount": refund.FrozenActualReceivedAmount,
"latest_attempt_id": refund.LatestAttemptID, "channel_refund_status": refund.ChannelRefundStatus,
"failure_reason": refund.FailureReason, "anomaly_flag": refund.AnomalyFlag,
"approval_instance_id": instanceID, "status": refund.Status,
},
}, nil
}}
if attempt == nil {
return resources, nil
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
// 客户收款信息是自由文本、凭证是对象存储标识,两者都不进审计快照,只记录存在性与凭证数量。
resources = append(resources, ResourceInput{
Type: constants.AuditResourceRefundAttempt, ID: &attemptID, Key: attemptID, DisplayName: "退款审批尝试 " + attemptID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "refund_id": attempt.RefundID, "attempt_no": attempt.AttemptNo,
"method": attempt.Method, "refund_amount": attempt.RefundAmount,
"frozen_actual_received_amount": attempt.FrozenActualReceivedAmount,
"approval_instance_id": instanceID,
"channel_refund_request_no": attempt.ChannelRefundRequestNo,
"submitted_by_account_id": attempt.SubmittedByAccountID,
"customer_account_info_present": attempt.CustomerAccountInfo != "",
"customer_voucher_count": len(attempt.CustomerVoucherKeys),
"created_at": attempt.CreatedAt,
},
})
return resources, nil
case constants.ApprovalBusinessTypeOfflineRecharge:
var recharge model.AgentRechargeRecord
if err := tx.WithContext(ctx).First(&recharge, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败")
}
return ResourceInput{
return []ResourceInput{{
Type: constants.AuditResourceAgentRecharge, ID: &id, Key: recharge.RechargeNo, DisplayName: recharge.RechargeNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
@@ -114,10 +151,81 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str
"payment_method": recharge.PaymentMethod, "payment_channel": recharge.PaymentChannel,
"approval_instance_id": instanceID, "status": recharge.Status,
},
}, nil
default:
return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源")
}}, nil
case constants.ApprovalBusinessTypeEmployeeCollection:
var attempt model.EmployeeCollectionApplicationAttempt
if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联核销审批尝试记录失败")
}
return []ResourceInput{{
Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &id,
Key: id, DisplayName: "审批尝试 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo,
"paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID,
},
}}, nil
case constants.ApprovalBusinessTypeAgentDistribution:
var registration model.AgentDistributionRegistration
if err := tx.WithContext(ctx).First(&registration, businessID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联扫码注册记录失败")
}
return []ResourceInput{{
Type: constants.AuditResourceAgentDistributionRegistration, ID: &id,
Key: id, DisplayName: "扫码注册记录 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
"status": registration.Status, "approval_instance_id": instanceID,
},
}}, nil
case constants.ApprovalBusinessTypeWithdrawalQualification:
var qualification model.WithdrawalQualification
if err := tx.WithContext(ctx).First(&qualification, businessID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现资料资格版本失败")
}
return []ResourceInput{{
Type: constants.AuditResourceWithdrawalQualification, ID: &id,
Key: id, DisplayName: "提现资料资格版本 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": qualification.ID, "shop_id": qualification.ShopID,
"subject_type": qualification.SubjectType, "status": qualification.Status,
"approval_instance_id": instanceID,
},
}}, nil
case constants.ApprovalBusinessTypeCommissionWithdrawal:
var attempt model.CommissionWithdrawalRequestAttempt
if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现审批尝试记录失败")
}
return []ResourceInput{{
Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id,
Key: id, DisplayName: "提现审批尝试 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo,
"amount": attempt.Amount, "approval_instance_id": instanceID,
},
}}, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源")
}
}
// resolvePendingRefundAttempt 解析尚未回写审批实例的退款审批尝试记录。
//
// 只在共享解析器返回冲突时调用:审批申请审计与「尝试记录回写审批实例」同事务,
// 但审计先执行,因此 business_id 指向的尝试记录此刻 approval_instance_id 仍为空。
// 该形态由 refundapproval.ResolveRefundForApprovalRequestInTx 单独承认,其余情况
// 原样返回解析器的冲突错误,不放宽为任意未回写记录。
func resolvePendingRefundAttempt(ctx context.Context, tx *gorm.DB, businessID uint, cause error) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
refund, attempt, err := refundapproval.ResolveRefundForApprovalRequestInTx(ctx, tx, businessID)
if err != nil {
return nil, nil, cause
}
return refund, attempt, nil
}
func approvalSubmitterResource(change approvalapp.AuditChange) ResourceInput {

View File

@@ -0,0 +1,243 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteDistributionWithdrawal 将分销注册、提现资格与提现审批事实写入统一 Audit Event。
// 审计只记录脱敏手机号与证件号、附件数量与对象键引用,绝不记录密码、完整证件号或附件内容。
func (w *Writer) WriteDistributionWithdrawal(
ctx context.Context,
tx *gorm.DB,
change distributionapp.AuditChange,
) error {
resources := make([]ResourceInput, 0, 6)
if change.Registration != nil && change.Registration.ID != 0 {
resources = append(resources, registrationResource(change))
}
if change.ParentShop != nil && change.ParentShop.ID != 0 {
resource := ShopResource(change.ParentShop, constants.AuditResourceRelationReference,
constants.AuditResourceRoleDistributionParentShop)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if change.Qualification != nil && change.Qualification.ID != 0 {
resources = append(resources, qualificationResource(change))
}
if change.Withdrawal != nil && change.Withdrawal.ID != 0 {
resources = append(resources, withdrawalResource(change))
}
if change.Attempt != nil && change.Attempt.ID != 0 {
resources = append(resources, withdrawalAttemptResource(change.Attempt))
}
if change.Shop != nil && change.Shop.ID != 0 {
resource := ShopResource(change.Shop, constants.AuditResourceRelationReference,
constants.AuditResourceRoleQualificationShop)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if change.CreatedShop != nil && change.CreatedShop.ID != 0 {
shop := change.CreatedShop
resource := ShopResource(shop, constants.AuditResourceRelationAffected,
constants.AuditResourceRoleShopTarget)
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = change.Summary
// 新建店铺自己的分销码:注册记录上的码是上级码快照,这里必须记录本次生成的店铺码,
// 否则「分销码生成」在审批建店路径没有审计事实,与后台建店路径不对称。
if shop.DistributionCode != "" {
resource.IdentitySnapshot["distribution_code_masked"] =
distributiondomain.MaskDistributionCode(shop.DistributionCode)
}
resource.AfterData = map[string]any{
"shop_name": shop.ShopName, "shop_code": shop.ShopCode,
"distribution_code_masked": distributiondomain.MaskDistributionCode(shop.DistributionCode),
"applied_distribution_code_masked": distributiondomain.MaskDistributionCode(change.AppliedDistributionCode),
"status": shop.Status,
}
resources = append(resources, resource)
}
if change.Wallet != nil && change.Wallet.ID != 0 {
resource := agentWalletAuditResource(change.Wallet, constants.AuditResourceRelationAffected,
constants.AuditResourceRoleWithdrawalWallet)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if change.Transaction != nil && change.Transaction.ID != 0 {
resource := agentWalletTransactionResource(change.Transaction)
resource.Relation = constants.AuditResourceRelationAffected
resource.Role = constants.AuditResourceRoleWithdrawalTransaction
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if len(resources) == 0 {
return errors.New(errors.CodeInvalidParam, "分销或提现审计缺少可追溯资源")
}
// 规范化关系:审计事件必须且恰好有一个主要资源。
// 失败/拒绝审计在业务事务回滚后重放此时业务对象可能尚未落库ID 为 0而被跳过
// 或变更只带店铺上下文,因此这里统一收敛:优先保留业务主资源,
// 没有业务主资源时用店铺承载主资源,其余资源一律降为引用。
primaryIndex := -1
for index := range resources {
if resources[index].Relation == constants.AuditResourceRelationPrimary {
primaryIndex = index
break
}
}
if primaryIndex == -1 {
for index := range resources {
if resources[index].Type == constants.AuditResourceShop {
resources[index].Relation = constants.AuditResourceRelationPrimary
primaryIndex = index
break
}
}
}
if primaryIndex == -1 {
return errors.New(errors.CodeInvalidParam, "分销或提现审计缺少可追溯资源")
}
for index := range resources {
if index != primaryIndex && resources[index].Relation == constants.AuditResourceRelationPrimary {
resources[index].Relation = constants.AuditResourceRelationReference
}
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
// 用 AppendAndGet 而非 Append分销注册、资格与提现终审的审计属于「要求成功必达」的事实
// 必须与业务事实同事务原子提交。Append 会吞掉错误,导致审计插入失败后事务被 PG 置为
// aborted最终只表现为难以定位的提交失败这里显式返回错误。
if _, err := w.AppendAndGet(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
CorrelationID: change.CorrelationID, Metadata: auditMetadata(change), Resources: resources,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入分销或提现审计失败")
}
return nil
}
// registrationResource 构造扫码注册记录审计资源,手机号与分销码只记录脱敏值。
func registrationResource(change distributionapp.AuditChange) ResourceInput {
registration := change.Registration
id := strconv.FormatUint(uint64(registration.ID), 10)
instanceID := uint(0)
if registration.ApprovalInstanceID != nil {
instanceID = *registration.ApprovalInstanceID
}
return ResourceInput{
Type: constants.AuditResourceAgentDistributionRegistration, ID: &id, Key: id,
DisplayName: "扫码注册记录 " + id,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleDistributionRegistration,
IdentitySnapshot: map[string]any{
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
"distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode),
"phone_masked": distributiondomain.MaskPhone(registration.Phone),
"username": registration.Username, "shop_code": registration.ShopCode,
"status": registration.Status, "approval_instance_id": instanceID,
"reject_reason": registration.RejectReason,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
}
// qualificationResource 构造提现资料资格版本审计资源,证件号只记录脱敏值。
func qualificationResource(change distributionapp.AuditChange) ResourceInput {
qualification := change.Qualification
id := strconv.FormatUint(uint64(qualification.ID), 10)
instanceID := uint(0)
if qualification.ApprovalInstanceID != nil {
instanceID = *qualification.ApprovalInstanceID
}
return ResourceInput{
Type: constants.AuditResourceWithdrawalQualification, ID: &id, Key: id,
DisplayName: "提现资料资格版本 " + id,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleQualificationTarget,
IdentitySnapshot: map[string]any{
"id": qualification.ID, "shop_id": qualification.ShopID,
"subject_type": qualification.SubjectType,
"subject_code_masked": distributiondomain.MaskSubjectCode(qualification.SubjectCode),
"status": qualification.Status, "approval_instance_id": instanceID,
"invalid_reason": qualification.InvalidReason,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
}
// withdrawalResource 构造提现单审计资源,并附加本次审批尝试记录的引用资源。
func withdrawalResource(change distributionapp.AuditChange) ResourceInput {
withdrawal := change.Withdrawal
resource := CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary,
constants.AuditResourceRoleWithdrawalTarget, change.BeforeData, change.AfterData)
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = change.Summary
return resource
}
// withdrawalAttemptResource 构造提现审批尝试记录审计资源,用于审批实例关联追踪。
func withdrawalAttemptResource(attempt *model.CommissionWithdrawalRequestAttempt) ResourceInput {
id := strconv.FormatUint(uint64(attempt.ID), 10)
instanceID := uint(0)
if attempt.ApprovalInstanceID != nil {
instanceID = *attempt.ApprovalInstanceID
}
return ResourceInput{
Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id, Key: id,
DisplayName: "提现审批尝试 " + id,
Relation: constants.AuditResourceRelationReference,
Role: constants.AuditResourceRoleWithdrawalAttempt,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo,
"amount": attempt.Amount, "fee": attempt.Fee, "actual_amount": attempt.ActualAmount,
"approval_instance_id": instanceID, "released_at": attempt.ReleasedAt,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
// auditMetadata 提取审计元数据,避免与资源明细重复。
func auditMetadata(change distributionapp.AuditChange) map[string]any {
metadata := map[string]any{}
if change.Registration != nil {
metadata["registration_status"] = change.Registration.Status
}
if change.Qualification != nil {
metadata["qualification_status"] = change.Qualification.Status
metadata["qualification_subject_type"] = change.Qualification.SubjectType
}
if change.Attempt != nil {
metadata["attempt_no"] = change.Attempt.AttemptNo
metadata["amount"] = change.Attempt.Amount
metadata["fee"] = change.Attempt.Fee
metadata["actual_amount"] = change.Attempt.ActualAmount
}
if change.Withdrawal != nil {
metadata["withdrawal_status"] = change.Withdrawal.Status
metadata["anomaly_flag"] = change.Withdrawal.AnomalyFlag
}
if change.CreatedShop != nil {
metadata["created_shop_id"] = change.CreatedShop.ID
if change.CreatedShop.DistributionCode != "" {
metadata["distribution_code_masked"] =
distributiondomain.MaskDistributionCode(change.CreatedShop.DistributionCode)
}
}
if len(metadata) == 0 {
return nil
}
return metadata
}

View File

@@ -0,0 +1,41 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
employeecollection "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteEmployeeCollectionBill 将员工代收款账单事实变化写入统一 Audit Event。
// 操作者与入口来自调用方审计上下文:订单建账为后台账号入口,充值入账为 Worker 或渠道回调入口。
func (w *Writer) WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change employeecollection.BillAudit) error {
if change.Bill == nil || change.Bill.ID == 0 || change.Bill.SourceKey == "" {
return errors.New(errors.CodeInvalidParam, "员工代收款账单审计资源不完整")
}
bill := change.Bill
resourceID := strconv.FormatUint(uint64(bill.ID), 10)
return w.Append(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.CorrelationID,
Resources: []ResourceInput{{
Type: constants.AuditResourceEmployeeCollectionBill, ID: &resourceID,
Key: bill.SourceKey, DisplayName: bill.SourceNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCollectionBill,
IdentitySnapshot: map[string]any{
"id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID,
"source_key": bill.SourceKey, "source_no": bill.SourceNo,
"debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount,
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
"status": bill.Status,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}},
})
}

View File

@@ -0,0 +1,85 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
employeecollection "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteEmployeeCollectionApplication 将核销申请、审批尝试记录与受影响账单写入统一 Audit Event。
// 申请与尝试的冻结快照含付款凭证对象键,审计只记录数量与外部流水号脱敏值,不复制敏感付款内容。
func (w *Writer) WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change employeecollection.ApplicationAudit) error {
if change.Application == nil || change.Application.ID == 0 {
return errors.New(errors.CodeInvalidParam, "核销申请审计资源不完整")
}
application := change.Application
applicationID := strconv.FormatUint(uint64(application.ID), 10)
resources := []ResourceInput{{
Type: constants.AuditResourceEmployeeCollectionApplication, ID: &applicationID,
Key: applicationID, DisplayName: "核销申请 " + applicationID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCollectionApplication,
IdentitySnapshot: map[string]any{
"id": application.ID, "applicant_account_id": application.ApplicantAccountID,
"acting_operator_id": application.ActingOperatorID,
"payment_method_id": application.PaymentMethodID, "paid_amount": application.PaidAmount,
"status": application.Status,
"latest_approval_instance_id": application.LatestApprovalInstanceID,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}}
if change.Attempt != nil && change.Attempt.ID != 0 {
attempt := change.Attempt
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
instanceID := uint(0)
if attempt.ApprovalInstanceID != nil {
instanceID = *attempt.ApprovalInstanceID
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &attemptID,
Key: attemptID, DisplayName: "审批尝试 " + attemptID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCollectionAttempt,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo,
"paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
})
}
for _, bill := range change.Bills {
if bill == nil || bill.ID == 0 {
continue
}
billID := strconv.FormatUint(uint64(bill.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEmployeeCollectionBill, ID: &billID,
Key: bill.SourceKey, DisplayName: bill.SourceNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleCollectionBillAffected,
IdentitySnapshot: map[string]any{
"id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID,
"source_key": bill.SourceKey, "debtor_account_id": bill.DebtorAccountID, "status": bill.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
})
}
return w.Append(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.CorrelationID,
Metadata: map[string]any{
"payment_method_code": application.PaymentMethodCode,
"payer_name": application.PayerName,
"external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo(
application.ExternalTransactionNo),
"voucher_count": len(application.PaymentVoucherKeys),
"bill_count": len(change.Bills),
},
Resources: resources,
})
}

View File

@@ -0,0 +1,105 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteRefundChannelResult 在调用方事务内写入渠道原路退款的调用、恢复与异常事实。
//
// 主资源固定为退款单,渠道原路退款事实作为引用资源;快照只记录业务标识、金额、状态与
// 结构化失败分类,不记录商户密钥、渠道报文或客户收款信息原文。摘要由调用方提供,
// 必须为不含凭证与渠道报文的简短中文说明。
//
// 渠道调用与恢复都由后台任务触发,因此操作者固定为系统任务;审计上下文提供了更具体的
// 操作者标识时沿用,避免任务未装配审计上下文时审计被拒绝并连带回滚业务事务。
func (w *Writer) WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action, message string) error {
if refund == nil || refund.ID == 0 || refund.RefundNo == "" {
return errors.New(errors.CodeInvalidParam, "退款渠道审计资源不完整")
}
summary := message
if summary == "" {
summary = "渠道原路退款结果更新"
}
refundID := strconv.FormatUint(uint64(refund.ID), 10)
actorID, actorName := constants.AuditActorIDRefundChannel, "退款渠道原路退款任务"
if linkage := auditcontext.From(ctx); linkage.ActorID != "" {
actorID = linkage.ActorID
if linkage.ActorName != "" {
actorName = linkage.ActorName
}
}
resources := refundChannelResources(refund, refundID, summary)
// 用 AppendAndGet 而非 Append渠道调用与恢复的审计属于「要求成功必达」的事实
// 必须与业务事实同事务原子提交,失败时向调用方显式返回错误。
if _, err := w.AppendAndGet(ctx, tx, AppendInput{
ActionCode: action, Summary: summary,
Actor: ActorInput{Kind: constants.AuditActorSystemTask, ID: actorID, Name: actorName},
Source: constants.AuditSourceWorker, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: refund.RefundNo, Resources: resources,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入退款渠道审计失败")
}
return nil
}
// refundChannelResources 构造退款渠道审计资源:退款单为主资源,渠道原路退款事实为引用资源。
func refundChannelResources(refund *model.RefundRequest, refundID, summary string) []ResourceInput {
return []ResourceInput{
{
Type: constants.AuditResourceRefund, ID: &refundID, Key: refund.RefundNo, DisplayName: refund.RefundNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRefundTarget,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "method": refund.Method,
"frozen_actual_received_amount": refund.FrozenActualReceivedAmount,
"requested_refund_amount": refund.RequestedRefundAmount,
"approved_refund_amount": refund.ApprovedRefundAmount,
"status": refund.Status,
"channel_refund_status": refund.ChannelRefundStatus,
"channel_refund_no": refund.ChannelRefundNo,
"channel_refund_request_no": refund.ChannelRefundRequestNo,
"channel_refund_amount": refund.ChannelRefundAmount,
"failure_reason": refund.FailureReason,
"anomaly_flag": refund.AnomalyFlag,
},
AfterData: channelRefundState(refund),
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
},
{
Type: constants.AuditResourceRefundChannelRefund, ID: &refundID, Key: refund.RefundNo,
DisplayName: "渠道原路退款 " + refund.RefundNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundChannelRefund,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo,
"channel_refund_status": refund.ChannelRefundStatus,
"channel_refund_no": refund.ChannelRefundNo,
"channel_refund_request_no": refund.ChannelRefundRequestNo,
"channel_refund_amount": refund.ChannelRefundAmount,
"failure_reason": refund.FailureReason,
"anomaly_flag": refund.AnomalyFlag,
},
},
}
}
// channelRefundState 构造渠道原路退款状态快照,只包含状态与结构化失败分类。
func channelRefundState(refund *model.RefundRequest) map[string]any {
state := map[string]any{
"status": refund.Status,
"channel_refund_status": refund.ChannelRefundStatus,
"channel_refund_amount": refund.ChannelRefundAmount,
"failure_reason": refund.FailureReason,
"anomaly_flag": refund.AnomalyFlag,
}
if refund.ChannelRefundedAt != nil {
state["channel_refunded_at"] = *refund.ChannelRefundedAt
}
return state
}

View File

@@ -179,6 +179,16 @@ func NewRegistry() *Registry {
wecomDefaultCreatorSaved := connectionConfigAction(constants.AuditActionWeComDefaultCreatorSaved, "保存企业微信默认审批发起人", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomMembersSynced := connectionConfigAction(constants.AuditActionWeComMembersSynced, "同步企业微信应用可见成员", constants.AuditResourceWeComApplication, constants.AuditRiskNormal)
wecomApprovalSceneSaved := connectionConfigAction(constants.AuditActionWeComApprovalSceneSaved, "保存企业微信审批场景配置", constants.AuditResourceWeComApprovalScene, constants.AuditRiskHigh)
employeeCollectionPaymentMethodCreated := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodCreated, "创建线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskNormal)
employeeCollectionPaymentMethodUpdated := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodUpdated, "更新线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskNormal)
employeeCollectionPaymentMethodDeleted := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodDeleted, "删除线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskHigh)
employeeCollectionBillCreated := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillCreated, "创建员工代收款账单", constants.AuditResourceEmployeeCollectionBill)
employeeCollectionBillClosed := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillClosed, "关闭员工代收款账单", constants.AuditResourceEmployeeCollectionBill)
employeeCollectionApplicationSubmitted := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationSubmitted, "提交员工代收款核销申请", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionApplicationApproved := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationApproved, "员工代收款核销申请企业微信通过", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionApplicationRejected := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationRejected, "员工代收款核销申请企业微信驳回或撤销", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionApplicationRevoked := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationRevoked, "员工代收款核销申请通过后撤销", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionBillRefundOffseted := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillRefundOffseted, "来源订单退款冲销员工代收款账单", constants.AuditResourceEmployeeCollectionBill)
outboxReplayed := outboxRecoveryAction(
constants.AuditActionOutboxReplayed,
"人工重放 Outbox 事件",
@@ -300,6 +310,14 @@ func NewRegistry() *Registry {
refundResubmitted := refundAction(constants.AuditActionRefundResubmitted, "重新提交退款申请", false)
refundCommissionInvalidated := refundSystemAction(constants.AuditActionRefundCommissionInvalidated, "退款失效佣金")
refundAssetProcessed := refundSystemAction(constants.AuditActionRefundAssetProcessed, "完成退款资产后处理")
// 退款审批尝试由后台账号提交与重提;终态与异常标记由企业微信审批消费任务写入(复用 refundAction 的 Worker 入口)。
refundAttemptSubmitted := refundAction(constants.AuditActionRefundAttemptSubmitted, "提交退款审批尝试", false)
refundAttemptApproved := refundAction(constants.AuditActionRefundAttemptApproved, "通过退款审批尝试", true)
refundAttemptClosed := refundAction(constants.AuditActionRefundAttemptClosed, "关闭退款审批尝试", true)
refundAnomalyFlagged := refundAction(constants.AuditActionRefundAnomalyFlagged, "标记退款审批异常", true)
// 渠道原路退款的调用与恢复都由 Worker 触发,与既有的退款系统动作入口一致。
refundChannelCalled := refundSystemAction(constants.AuditActionRefundChannelCalled, "发起渠道原路退款")
refundChannelRecovered := refundSystemAction(constants.AuditActionRefundChannelRecovered, "恢复渠道原路退款结果")
approvalRequested := approvalAction(constants.AuditActionApprovalRequested, "提交通用审批申请", []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
})
@@ -347,6 +365,36 @@ func NewRegistry() *Registry {
withdrawalRequested := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRequested, "提交佣金提现申请")
withdrawalApproved := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请")
withdrawalRejected := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请")
// 提现审批尝试的提交由代理后台入口触发account/admin_api
// 终态由 Outbox 消费任务触发system_task/worker因此必须使用含两类入口的 distributionAction
// 而不是仅允许后台账号入口的 commissionWithdrawalAction。
withdrawalAttemptSubmitted := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptSubmitted, "提交佣金提现审批尝试", constants.AuditResourceCommissionWithdrawal)
withdrawalAttemptApproved := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptApproved, "企业微信通过佣金提现", constants.AuditResourceCommissionWithdrawal)
withdrawalAttemptClosed := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptClosed, "企业微信驳回或撤销佣金提现", constants.AuditResourceCommissionWithdrawal)
withdrawalAnomalyFlagged := distributionAction(
constants.AuditActionCommissionWithdrawalAnomalyFlagged, "企微通过后撤销提现异常标记", constants.AuditResourceCommissionWithdrawal)
distributionRegistrationApproved := distributionAction(
constants.AuditActionAgentDistributionRegistrationApproved, "企业微信通过代理扫码注册", constants.AuditResourceAgentDistributionRegistration)
distributionRegistrationRejected := distributionAction(
constants.AuditActionAgentDistributionRegistrationRejected, "企业微信驳回代理扫码注册", constants.AuditResourceAgentDistributionRegistration)
qualificationSubmitted := distributionAction(
constants.AuditActionWithdrawalQualificationSubmitted, "提交提现资料资格", constants.AuditResourceWithdrawalQualification)
qualificationApproved := distributionAction(
constants.AuditActionWithdrawalQualificationApproved, "提现资料资格企业微信通过", constants.AuditResourceWithdrawalQualification)
qualificationRejected := distributionAction(
constants.AuditActionWithdrawalQualificationRejected, "提现资料资格企业微信驳回", constants.AuditResourceWithdrawalQualification)
qualificationVoided := distributionAction(
constants.AuditActionWithdrawalQualificationVoided, "作废提现资料资格", constants.AuditResourceWithdrawalQualification)
qualificationInvalidated := distributionAction(
constants.AuditActionWithdrawalQualificationInvalidated, "代理停用联动失效提现资料资格", constants.AuditResourceWithdrawalQualification)
// 创建前拒绝:没有业务实体可作主要资源,以店铺为主要资源,保证拒绝事实可追溯且主资源唯一。
withdrawalAttemptRejected := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptRejected, "提现提交被拒绝", constants.AuditResourceShop)
qualificationSubmitRejected := distributionAction(
constants.AuditActionWithdrawalQualificationSubmitRejected, "资格提交被拒绝", constants.AuditResourceShop)
return &Registry{
actionsByOperation: map[string]ActionDefinition{
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
@@ -363,6 +411,9 @@ func NewRegistry() *Registry {
constants.AuditOperationWeComDefaultCreatorSave: wecomDefaultCreatorSaved,
constants.AuditOperationWeComMembersSync: wecomMembersSynced,
constants.AuditOperationWeComApprovalSceneSave: wecomApprovalSceneSaved,
constants.AuditOperationEmployeeCollectionPaymentMethodCreate: employeeCollectionPaymentMethodCreated,
constants.AuditOperationEmployeeCollectionPaymentMethodUpdate: employeeCollectionPaymentMethodUpdated,
constants.AuditOperationEmployeeCollectionPaymentMethodDelete: employeeCollectionPaymentMethodDeleted,
constants.AuditOperationOutboxReplay: outboxReplayed,
constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased,
},
@@ -475,6 +526,16 @@ func NewRegistry() *Registry {
constants.AuditActionCarrierUpdated: carrierUpdated,
constants.AuditActionCarrierDeleted: carrierDeleted,
constants.AuditActionCarrierStatusUpdated: carrierStatusUpdated,
constants.AuditActionEmployeeCollectionPaymentMethodCreated: employeeCollectionPaymentMethodCreated,
constants.AuditActionEmployeeCollectionPaymentMethodUpdated: employeeCollectionPaymentMethodUpdated,
constants.AuditActionEmployeeCollectionPaymentMethodDeleted: employeeCollectionPaymentMethodDeleted,
constants.AuditActionEmployeeCollectionBillCreated: employeeCollectionBillCreated,
constants.AuditActionEmployeeCollectionBillClosed: employeeCollectionBillClosed,
constants.AuditActionEmployeeCollectionApplicationSubmitted: employeeCollectionApplicationSubmitted,
constants.AuditActionEmployeeCollectionApplicationApproved: employeeCollectionApplicationApproved,
constants.AuditActionEmployeeCollectionApplicationRejected: employeeCollectionApplicationRejected,
constants.AuditActionEmployeeCollectionApplicationRevoked: employeeCollectionApplicationRevoked,
constants.AuditActionEmployeeCollectionBillRefundOffseted: employeeCollectionBillRefundOffseted,
constants.AuditActionWeComApplicationSaved: wecomApplicationSaved,
constants.AuditActionWeComDefaultCreatorSaved: wecomDefaultCreatorSaved,
constants.AuditActionWeComMembersSynced: wecomMembersSynced,
@@ -578,6 +639,12 @@ func NewRegistry() *Registry {
constants.AuditActionRefundResubmitted: refundResubmitted,
constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated,
constants.AuditActionRefundAssetProcessed: refundAssetProcessed,
constants.AuditActionRefundAttemptSubmitted: refundAttemptSubmitted,
constants.AuditActionRefundAttemptApproved: refundAttemptApproved,
constants.AuditActionRefundAttemptClosed: refundAttemptClosed,
constants.AuditActionRefundAnomalyFlagged: refundAnomalyFlagged,
constants.AuditActionRefundChannelCalled: refundChannelCalled,
constants.AuditActionRefundChannelRecovered: refundChannelRecovered,
constants.AuditActionApprovalRequested: approvalRequested,
constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced,
constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered,
@@ -588,6 +655,19 @@ func NewRegistry() *Registry {
constants.AuditActionCommissionWithdrawalRequested: withdrawalRequested,
constants.AuditActionCommissionWithdrawalApproved: withdrawalApproved,
constants.AuditActionCommissionWithdrawalRejected: withdrawalRejected,
constants.AuditActionCommissionWithdrawalAttemptSubmitted: withdrawalAttemptSubmitted,
constants.AuditActionCommissionWithdrawalAttemptApproved: withdrawalAttemptApproved,
constants.AuditActionCommissionWithdrawalAttemptClosed: withdrawalAttemptClosed,
constants.AuditActionCommissionWithdrawalAnomalyFlagged: withdrawalAnomalyFlagged,
constants.AuditActionAgentDistributionRegistrationApproved: distributionRegistrationApproved,
constants.AuditActionAgentDistributionRegistrationRejected: distributionRegistrationRejected,
constants.AuditActionWithdrawalQualificationSubmitted: qualificationSubmitted,
constants.AuditActionWithdrawalQualificationApproved: qualificationApproved,
constants.AuditActionWithdrawalQualificationRejected: qualificationRejected,
constants.AuditActionWithdrawalQualificationVoided: qualificationVoided,
constants.AuditActionWithdrawalQualificationInvalidated: qualificationInvalidated,
constants.AuditActionCommissionWithdrawalAttemptRejected: withdrawalAttemptRejected,
constants.AuditActionWithdrawalQualificationSubmitRejected: qualificationSubmitRejected,
},
resources: map[string]ResourceDefinition{
constants.AuditResourceAccount: {
@@ -614,6 +694,25 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceCarrier, Name: "运营商配置",
IdentityFields: []string{"id", "carrier_code", "carrier_name", "carrier_type", "status"},
},
constants.AuditResourceEmployeeCollectionPaymentMethod: {
Type: constants.AuditResourceEmployeeCollectionPaymentMethod, Name: "线下收款方式",
IdentityFields: []string{"id", "code", "name", "status", "sort"},
},
constants.AuditResourceEmployeeCollectionBill: {
Type: constants.AuditResourceEmployeeCollectionBill, Name: "员工代收款账单",
IdentityFields: []string{"id", "source_type", "source_id", "source_key", "debtor_account_id", "status"},
},
constants.AuditResourceEmployeeCollectionApplication: {
Type: constants.AuditResourceEmployeeCollectionApplication, Name: "员工代收款核销申请",
IdentityFields: []string{
"id", "applicant_account_id", "acting_operator_id", "payment_method_id",
"paid_amount", "status", "latest_approval_instance_id",
},
},
constants.AuditResourceEmployeeCollectionAttempt: {
Type: constants.AuditResourceEmployeeCollectionAttempt, Name: "员工代收款核销审批尝试记录",
IdentityFields: []string{"id", "application_id", "attempt_no", "paid_amount", "approval_instance_id"},
},
constants.AuditResourceWeComApprovalScene: {
Type: constants.AuditResourceWeComApprovalScene, Name: "企业微信审批场景配置",
IdentityFields: []string{"id", "business_type", "application_id", "template_id", "template_name", "status"},
@@ -706,7 +805,16 @@ func NewRegistry() *Registry {
},
constants.AuditResourceRefund: {
Type: constants.AuditResourceRefund, Name: "退款单",
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "approval_instance_id", "status", "commission_deducted", "asset_reset"},
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "method", "frozen_actual_received_amount", "latest_attempt_id", "channel_refund_status", "channel_refund_no", "channel_refund_request_no", "channel_refund_amount", "failure_reason", "anomaly_flag", "approval_instance_id", "status", "commission_deducted", "asset_reset"},
},
// 退款审批尝试记录只登记冻结金额、方式与凭证数量,客户收款信息原文与凭证内容不进审计快照。
constants.AuditResourceRefundAttempt: {
Type: constants.AuditResourceRefundAttempt, Name: "退款审批尝试记录",
IdentityFields: []string{"id", "refund_id", "attempt_no", "method", "refund_amount", "frozen_actual_received_amount", "approval_instance_id", "channel_refund_request_no", "submitted_by_account_id", "customer_account_info_present", "customer_voucher_count", "created_at"},
},
constants.AuditResourceRefundChannelRefund: {
Type: constants.AuditResourceRefundChannelRefund, Name: "渠道原路退款事实",
IdentityFields: []string{"id", "refund_no", "channel_refund_status", "channel_refund_no", "channel_refund_request_no", "channel_refund_amount", "failure_reason", "anomaly_flag"},
},
constants.AuditResourceEnterprise: {
Type: constants.AuditResourceEnterprise, Name: "企业",
@@ -796,6 +904,18 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceCommissionWithdrawal, Name: "佣金提现单",
IdentityFields: []string{"id", "withdrawal_no", "shop_id", "applicant_id", "amount", "fee", "fee_rate", "actual_amount", "withdrawal_method", "payment_type", "status", "processor_id", "processed_at", "paid_at"},
},
constants.AuditResourceCommissionWithdrawalAttempt: {
Type: constants.AuditResourceCommissionWithdrawalAttempt, Name: "提现审批尝试记录",
IdentityFields: []string{"id", "request_id", "attempt_no", "amount", "fee", "actual_amount", "approval_instance_id", "released_at"},
},
constants.AuditResourceAgentDistributionRegistration: {
Type: constants.AuditResourceAgentDistributionRegistration, Name: "代理扫码注册记录",
IdentityFields: []string{"id", "parent_shop_id", "distribution_code_masked", "phone_masked", "username", "shop_code", "status", "approval_instance_id", "reject_reason"},
},
constants.AuditResourceWithdrawalQualification: {
Type: constants.AuditResourceWithdrawalQualification, Name: "提现资料资格版本",
IdentityFields: []string{"id", "shop_id", "subject_type", "subject_code_masked", "status", "approval_instance_id", "invalid_reason"},
},
constants.AuditResourceWeComApplication: {
Type: constants.AuditResourceWeComApplication, Name: "企业微信应用配置",
IdentityFields: []string{"id", "corp_id", "agent_id", "name", "status", "credentials_configured"},
@@ -1057,6 +1177,9 @@ func commissionWithdrawalAction(code, name string) ActionDefinition {
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
SubjectFields: []string{"amount", "fee", "actual_amount", "withdrawal_method", "payment_type", "status"},
// 同一动作还由退款佣金回扣的自动拒绝提现路径发出Worker 消费任务system_task/worker
// 因此必须同时允许该入口,否则该审计会以「操作者或入口不符合动作注册规则」被丢弃。
AllowedOrigins: []ActionOrigin{{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}},
}
}
@@ -1227,6 +1350,40 @@ func connectionConfigAction(code, name, resourceType, risk string) ActionDefinit
}
}
// employeeCollectionAction 定义员工代收款账单与核销申请动作;建账发生在来源成功事务内,
// 订单来源由后台账号触发,充值入账与审批终态由 Outbox 消费、渠道回调或恢复任务触发。
func employeeCollectionAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: primaryResource, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
// distributionAction 定义分销注册、提现资格与提现终审动作;提交由后台账号或公开个人接口触发,
// 终态由 Outbox 消费、渠道回调或恢复任务触发,均要求与业务事实同事务。
func distributionAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: primaryResource, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
// Action 返回已注册动作定义。
func (r *Registry) Action(code string) (ActionDefinition, bool) {
if r == nil {

View File

@@ -0,0 +1,462 @@
package payment
import (
"context"
"strconv"
"strings"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
"go.uber.org/zap"
refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// fuiouShanghaiLocation 富友要求按上海时区回传原交易日期。
var fuiouShanghaiLocation = time.FixedZone("CST", 8*3600)
// RefundAdapter 按冻结商户的服务商类型分派渠道原路退款与退款查询。
//
// 本适配器只做渠道原始调用与结果映射:不生成请求号、不写数据库、不写审计。
// 渠道适配按官方契约实现,且不向任何渠道传递退款结果通知地址——退款终态只由
// 同步响应与主动退款查询确认。
type RefundAdapter struct {
cache kernel.CacheInterface
logger *zap.Logger
}
// NewRefundAdapter 创建渠道原路退款适配器。
func NewRefundAdapter(cache kernel.CacheInterface, logger *zap.Logger) *RefundAdapter {
return &RefundAdapter{cache: cache, logger: logger}
}
var _ refundchannel.Refunder = (*RefundAdapter)(nil)
// Refund 按服务商类型提交一次渠道退款请求。
// 凭证取自 Target.Config冻结商户的当前凭证只用于构造渠道客户端绝不记录或持久化。
// 渠道明确表态时返回结果且不报错;传输或解析失败返回 error由调用方按结果未知处理。
func (a *RefundAdapter) Refund(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
switch target.ProviderType {
case model.ProviderTypeWechat:
return a.refundWechatV3(ctx, target)
case model.ProviderTypeWechatV2:
return a.refundWechatV2(ctx, target)
case model.ProviderTypeFuiou:
return a.refundFuiou(target)
case "alipay":
return a.refundAlipay(ctx, target)
default:
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureCredentialInvalid,
FailureMessage: "该商户支付渠道的退款凭证不完整,无法执行原路退款",
}, nil
}
}
// Query 按服务商类型查询渠道退款状态,绝不发起资金动作。
func (a *RefundAdapter) Query(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
switch target.ProviderType {
case model.ProviderTypeWechat:
service, err := a.wechatService(target.Config)
if err != nil {
return refundchannel.Result{}, err
}
result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo)
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatRefundResult(result), nil
case model.ProviderTypeWechatV2:
service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger)
if err != nil {
return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款查询配置不可用")
}
result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo)
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatV2RefundResult(result, target.RefundAmount), nil
case model.ProviderTypeFuiou:
client, err := newFuiouClient(target.Config, a.logger)
if err != nil {
return refundchannel.Result{}, err
}
resp, err := client.RefundQuery(target.ChannelRefundRequestNo)
if err != nil {
return refundchannel.Result{}, err
}
return mapFuiouRefundQuery(resp), nil
case "alipay":
result, err := alipay.QueryRefund(ctx, target.Config, alipay.RefundRequest{
OutTradeNo: target.PaymentNo,
TradeNo: target.ChannelTradeNo,
OutRequestNo: target.ChannelRefundRequestNo,
RefundAmount: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapAlipayRefundResult(result, target.RefundAmount), nil
default:
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureCredentialInvalid,
FailureMessage: "该商户支付渠道的退款凭证不完整,无法查询退款结果",
}, nil
}
}
// refundWechatV3 执行微信支付 v3 原路退款。
func (a *RefundAdapter) refundWechatV3(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
service, err := a.wechatService(target.Config)
if err != nil {
return refundchannel.Result{}, err
}
result, err := service.RefundOrder(ctx, wechatpay.RefundOrderRequest{
OutTradeNo: target.PaymentNo,
OutRefundNo: target.ChannelRefundRequestNo,
Amount: target.FrozenActualReceivedAmount,
Refund: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatRefundResult(result), nil
}
// wechatService 用商户当前凭证构造微信 v3 支付服务。
func (a *RefundAdapter) wechatService(config *model.WechatConfig) (*wechatpay.PaymentService, error) {
if config == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用")
}
app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信支付退款配置不可用")
}
return wechatpay.NewPaymentService(app, a.logger), nil
}
// refundFuiou 执行富友原路退款。
// 回传原交易日期才能覆盖 30 天以上的原交易;不回传仅支持 30 天内,因此始终回传。
func (a *RefundAdapter) refundFuiou(target refundchannel.Target) (refundchannel.Result, error) {
client, err := newFuiouClient(target.Config, a.logger)
if err != nil {
return refundchannel.Result{}, err
}
origiDt := ""
if target.PaidAt != nil {
origiDt = target.PaidAt.In(fuiouShanghaiLocation).Format("20060102")
}
resp, err := client.CommonRefund(&fuiou.CommonRefundRequest{
OrderType: target.ChannelOrderType,
MchntOrderNo: target.PaymentNo,
RefundOrderNo: target.ChannelRefundRequestNo,
TotalAmt: strconv.FormatInt(target.FrozenActualReceivedAmount, 10),
RefundAmt: strconv.FormatInt(target.RefundAmount, 10),
ReservedOrigiDt: origiDt,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapFuiouRefundResponse(resp), nil
}
// refundAlipay 执行支付宝原路退款。
func (a *RefundAdapter) refundAlipay(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
result, err := alipay.Refund(ctx, target.Config, alipay.RefundRequest{
OutTradeNo: target.PaymentNo,
TradeNo: target.ChannelTradeNo,
OutRequestNo: target.ChannelRefundRequestNo,
RefundAmount: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapAlipayRefundResult(result, target.RefundAmount), nil
}
// refundWechatV2 执行微信支付 v2 原路退款(双向证书接口)。
//
// 渠道契约规定申请接口的返回仅代表受理情况,退款是否成功必须由退款查询确认,
// 因此受理成功在此按「结果未知」返回,由恢复任务查询收敛;渠道明确拒绝时按失败分类返回。
func (a *RefundAdapter) refundWechatV2(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger)
if err != nil {
return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款配置不可用")
}
result, err := service.RefundOrderV2(ctx, wechatpay.V2RefundRequest{
OutTradeNo: target.PaymentNo,
OutRefundNo: target.ChannelRefundRequestNo,
TotalFee: target.FrozenActualReceivedAmount,
RefundFee: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatV2RefundResult(result, target.RefundAmount), nil
}
// mapWechatV2RefundResult 映射微信 v2 退款申请或退款查询结果。
// Accepted 为真表示渠道已受理但结果待查询确认,按结果未知处理;受理接口不返回退款状态。
func mapWechatV2RefundResult(result *wechatpay.V2RefundResult, requestedAmount int64) refundchannel.Result {
if result == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if result.Success {
amount := result.RefundFee
if amount <= 0 {
amount = requestedAmount
}
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: result.RefundID,
ChannelRefundAmount: amount,
}
}
if result.Accepted {
// 渠道已受理:终态必须由退款查询确认,不得在此标记成功。
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "微信 v2 已受理退款申请,等待退款查询确认结果",
}
}
if result.ErrCode != "" {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyWechatRejection(result.ErrCode),
FailureMessage: safeChannelMessage(result.ErrCode + " " + result.Message),
}
}
switch strings.ToUpper(strings.TrimSpace(result.Status)) {
case "REFUNDCLOSE", "CHANGE":
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureChannelRejected,
FailureMessage: safeChannelMessage(result.Message),
}
default:
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: safeChannelMessage(result.Message),
}
}
}
// mapWechatRefundResult 映射微信 v3 退款结果。
// 渠道业务错误码表示退款单未被受理,按错误码分类;处理中或状态为空表示渠道尚未给出终态,
// 保持结果未知交由恢复任务查询。
func mapWechatRefundResult(result *wechatpay.RefundOrderResult) refundchannel.Result {
if result == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if result.Success {
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: result.RefundID,
ChannelRefundAmount: result.RefundFee,
}
}
if result.ChannelCode != "" {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyWechatRejection(result.ChannelCode),
FailureMessage: safeChannelMessage(result.Message),
}
}
switch strings.ToUpper(strings.TrimSpace(result.Status)) {
case "CLOSED", "ABNORMAL":
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureChannelRejected,
FailureMessage: safeChannelMessage(result.Message),
}
default:
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: safeChannelMessage(result.Message),
}
}
}
// classifyWechatRejection 把微信拒绝类错误码映射为稳定失败分类。
// 系统异常与限频不代表渠道已明确拒绝,保持结果未知交恢复任务查询。
func classifyWechatRejection(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "SYSTEM_ERROR", "FREQUENCY_LIMITED", "RATELIMIT_EXCEED":
return constants.RefundFailureTimeoutUnknown
case "NOT_ENOUGH", "BALANCE_NOT_ENOUGH":
return constants.RefundFailureInsufficientBalance
case "NO_AUTH", "SIGN_ERROR":
return constants.RefundFailureCredentialInvalid
default:
return constants.RefundFailureChannelRejected
}
}
// mapFuiouRefundResponse 映射富友退款申请响应。
func mapFuiouRefundResponse(resp *fuiou.CommonRefundResponse) refundchannel.Result {
if resp == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if resp.ResultCode != fuiou.ResultCodeSuccess {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyFuiouRejection(resp.ResultMsg),
FailureMessage: safeChannelMessage(resp.ResultMsg),
}
}
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: resp.RefundId,
ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt),
SettledAt: resp.ReservedFySettleDt,
}
}
// mapFuiouRefundQuery 映射富友退款查询响应。
// trans_stat 只取 SUCCESS 或 PAYERRORPAYERROR 视为渠道明确失败,其余保持未知。
func mapFuiouRefundQuery(resp *fuiou.RefundQueryResponse) refundchannel.Result {
if resp == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if resp.ResultCode != fuiou.ResultCodeSuccess {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyFuiouRejection(resp.ResultMsg),
FailureMessage: safeChannelMessage(resp.ResultMsg),
}
}
switch strings.ToUpper(strings.TrimSpace(resp.TransStat)) {
case fuiou.TransStatSuccess:
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: resp.RefundId,
ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt),
SettledAt: resp.ReservedFySettleDt,
}
case fuiou.TransStatPayError:
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureChannelRejected,
FailureMessage: "富友退款交易状态为失败",
}
default:
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "富友退款交易仍在办理中",
}
}
}
// classifyFuiouRejection 按富友错误文案粗分类;无法识别时归为渠道明确拒绝。
func classifyFuiouRejection(message string) string {
text := strings.TrimSpace(message)
switch {
case strings.Contains(text, "余额") || strings.Contains(text, "不足"):
return constants.RefundFailureInsufficientBalance
case strings.Contains(text, "签名") || strings.Contains(text, "验签") || strings.Contains(text, "密钥"):
return constants.RefundFailureCredentialInvalid
default:
return constants.RefundFailureChannelRejected
}
}
// mapAlipayRefundResult 映射支付宝退款或退款查询结果。
func mapAlipayRefundResult(result *alipay.RefundResult, requestedAmount int64) refundchannel.Result {
if result == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if result.Success {
amount := result.RefundFee
if amount == 0 {
amount = requestedAmount
}
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: result.TradeNo,
ChannelRefundAmount: amount,
}
}
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyAlipayRejection(result.Message),
FailureMessage: safeChannelMessage(result.Message),
}
}
// classifyAlipayRejection 按支付宝错误码粗分类;无法识别时归为渠道明确拒绝。
func classifyAlipayRejection(message string) string {
text := strings.ToUpper(strings.TrimSpace(message))
switch {
case strings.Contains(text, "BALANCE_NOT_ENOUGH") || strings.Contains(text, "余额不足"):
return constants.RefundFailureInsufficientBalance
case strings.Contains(text, "SIGN") || strings.Contains(text, "AUTH"):
return constants.RefundFailureCredentialInvalid
case strings.Contains(text, "SYSTEM_ERROR"):
return constants.RefundFailureTimeoutUnknown
default:
return constants.RefundFailureChannelRejected
}
}
// safeChannelMessage 截断渠道文案并去掉换行,避免把渠道报文原文写入失败摘要。
func safeChannelMessage(message string) string {
text := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(message, "\n", " "), "\r", " "))
if len(text) > 200 {
return text[:200]
}
return text
}
// parseFuiouAmount 把富友返回的金额字符串精确转换为分;空值或不可解析时按 0 处理。
func parseFuiouAmount(amount string) int64 {
text := strings.TrimSpace(amount)
if text == "" {
return 0
}
value, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return 0
}
return value
}
// newFuiouClient 用商户当前凭证构造富友客户端。
func newFuiouClient(config *model.WechatConfig, logger *zap.Logger) (*fuiou.Client, error) {
if config == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用")
}
return fuiou.NewClient(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyAPIURL,
config.FyNotifyURL, config.FyPrivateKey, config.FyPublicKey, logger)
}

View File

@@ -0,0 +1,39 @@
package payment
import (
"context"
"github.com/hibiken/asynq"
refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundChannelRecoveryTaskHandler 执行渠道原路退款结果恢复任务。
type RefundChannelRecoveryTaskHandler struct {
service *refundchannel.Service
}
// NewRefundChannelRecoveryTaskHandler 创建渠道原路退款结果恢复任务 Handler。
func NewRefundChannelRecoveryTaskHandler(service *refundchannel.Service) *RefundChannelRecoveryTaskHandler {
return &RefundChannelRecoveryTaskHandler{service: service}
}
// Handle 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
func (h *RefundChannelRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款恢复任务未配置")
}
taskType := constants.TaskTypeRefundChannelRecovery
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "渠道原路退款结果恢复计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.ProcessBatch(ctx)
return err
}

View File

@@ -0,0 +1,29 @@
// Package shop 提供店铺相关的无状态基础设施适配。
package shop
import (
"context"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// SubordinateCache 负责清理店铺下级集合缓存。
// 新下级只在审批通过事务提交后出现,必须清理上级缓存,否则缓存有效期内新下级不可见。
type SubordinateCache struct {
redis *redis.Client
}
// NewSubordinateCache 创建店铺下级集合缓存适配器。
func NewSubordinateCache(client *redis.Client) *SubordinateCache {
return &SubordinateCache{redis: client}
}
// InvalidateSubordinateCache 清理指定店铺的下级集合缓存;缓存未配置时安全跳过。
func (c *SubordinateCache) InvalidateSubordinateCache(ctx context.Context, shopID uint) {
if c == nil || c.redis == nil || shopID == 0 {
return
}
_ = c.redis.Del(ctx, constants.RedisShopSubordinatesKey(shopID)).Err()
}

View File

@@ -0,0 +1,34 @@
package model
import "time"
// AgentDistributionRegistration 是代理扫码注册的待审批记录。
// 审批通过前不创建店铺、账号、钱包或上下级归属;同一手机号驳回后再次扫码是新记录。
type AgentDistributionRegistration struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
DistributionCode string `gorm:"column:distribution_code;type:varchar(32);not null;comment:上级店铺分销码快照" json:"distribution_code"`
ParentShopID uint `gorm:"column:parent_shop_id;not null;comment:上级店铺ID" json:"parent_shop_id"`
Phone string `gorm:"column:phone;type:varchar(20);not null;comment:注册手机号" json:"phone"`
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;comment:bcrypt 密码哈希" json:"-"`
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:申请店铺名称快照" json:"shop_name"`
ShopCode string `gorm:"column:shop_code;type:varchar(50);not null;comment:申请店铺编号快照" json:"shop_code"`
Username string `gorm:"column:username;type:varchar(50);not null;comment:申请代理主账号用户名快照" json:"username"`
ContactName string `gorm:"column:contact_name;type:varchar(50);not null;default:'';comment:联系人姓名快照" json:"contact_name"`
Province string `gorm:"column:province;type:varchar(50);not null;default:'';comment:省份快照" json:"province"`
City string `gorm:"column:city;type:varchar(50);not null;default:'';comment:城市快照" json:"city"`
District string `gorm:"column:district;type:varchar(50);not null;default:'';comment:区县快照" json:"district"`
Address string `gorm:"column:address;type:varchar(255);not null;default:'';comment:详细地址快照" json:"address"`
Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待审批 1-已通过 2-已驳回" json:"status"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的通用审批实例ID" json:"approval_instance_id,omitempty"`
RejectReason string `gorm:"column:reject_reason;type:varchar(500);not null;default:'';comment:企业微信驳回原因" json:"reject_reason"`
DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"`
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 指定代理扫码注册记录表名。
func (AgentDistributionRegistration) TableName() string {
return "tb_agent_distribution_registration"
}

View File

@@ -83,6 +83,11 @@ type AgentRechargeRecord struct {
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
ExternalTransactionNo *string `gorm:"column:external_transaction_no;type:varchar(128);comment:线下充值交易流水号(人工确认,独立于在线渠道第三方交易号)" json:"external_transaction_no,omitempty"`
OfflinePaymentMethodID *uint `gorm:"column:offline_payment_method_id;index;comment:线下收款方式字典ID(关联tb_employee_collection_payment_method.id)" json:"offline_payment_method_id,omitempty"`
OfflinePaymentMethodCode *string `gorm:"column:offline_payment_method_code;type:varchar(64);comment:线下收款方式稳定编码快照" json:"offline_payment_method_code,omitempty"`
OfflinePaymentMethodName *string `gorm:"column:offline_payment_method_name;type:varchar(100);comment:线下收款方式名称快照" json:"offline_payment_method_name,omitempty"`
OtherVoucherKeys StringJSONBArray `gorm:"column:other_voucher_keys;type:jsonb;comment:其他凭证对象存储Key列表(可选最多5个)" json:"other_voucher_keys"`
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回)" json:"status"`
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表线下支付时必填最多5个微信支付时为空" json:"payment_voucher_key"`

View File

@@ -0,0 +1,149 @@
package dto
// CreateAgentDistributionRegistrationReq 公开扫码注册请求(无需认证)。
type CreateAgentDistributionRegistrationReq struct {
DistributionCode string `json:"distribution_code" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"上级代理店铺的分销码"`
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"注册手机号,必须已完成短信验证码校验"`
Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"短信验证码,校验成功即消费"`
Password string `json:"password" validate:"required,min=6,max=64" required:"true" minLength:"6" maximum:"64" description:"代理主账号登录密码"`
Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"代理主账号用户名"`
ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"申请店铺名称"`
ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"申请店铺编号"`
ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"`
Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"`
City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"`
District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"`
Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"`
}
// CreateAgentDistributionRegistrationResp 公开扫码注册响应。
// 仅返回注册记录标识与待审批状态,不返回任何账号凭证或审批实例明细。
type CreateAgentDistributionRegistrationResp struct {
ID uint `json:"id" description:"待审批注册记录ID"`
Status int `json:"status" description:"注册记录状态 (0:待审批, 1:已通过, 2:已驳回)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
}
// SubmitWithdrawalQualificationReq 提交或替换提现资料资格请求。
type SubmitWithdrawalQualificationReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
SubjectType string `json:"subject_type" validate:"required,oneof=enterprise personal" required:"true" enum:"enterprise,personal" description:"签约主体类型 (enterprise:企业, personal:个人)"`
SubjectCode string `json:"subject_code" validate:"omitempty,max=64" maxLength:"64" description:"签约主体代码:企业填统一社会信用代码;个人留空并按法人身份证号取值"`
LegalPersonIDCard string `json:"legal_person_id_card" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"法人身份证号"`
ContractFileKey string `json:"contract_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"合同附件对象存储 Key单个对象"`
IDCardFrontFileKey string `json:"id_card_front_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"法人身份证正面附件对象存储 Key单个对象"`
IDCardBackFileKey string `json:"id_card_back_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"法人身份证反面附件对象存储 Key单个对象"`
BusinessLicenseFileKey string `json:"business_license_file_key" validate:"omitempty,max=255" maxLength:"255" description:"营业执照附件对象存储 Key单个对象可选"`
ShopFrontFileKey string `json:"shop_front_file_key" validate:"omitempty,max=255" maxLength:"255" description:"门头照附件对象存储 Key单个对象可选"`
InvoiceFileKey string `json:"invoice_file_key" validate:"omitempty,max=255" maxLength:"255" description:"发票附件对象存储 Key单个对象仅企业可选"`
InvoiceTitle string `json:"invoice_title" validate:"omitempty,max=200" maxLength:"200" description:"发票抬头,仅企业填写,必须与合同主体一致"`
InvoiceSubjectCode string `json:"invoice_subject_code" validate:"omitempty,max=64" maxLength:"64" description:"发票统一社会信用代码,必须与签约主体代码一致"`
}
// SubmitWithdrawalQualificationResp 提现资料资格提交响应。
type SubmitWithdrawalQualificationResp struct {
ID uint `json:"id" description:"资料版本ID"`
Status int `json:"status" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
}
// VoidWithdrawalQualificationReq 超级管理员作废提现资料资格请求。
type VoidWithdrawalQualificationReq struct {
Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"作废原因,必填且可在资格详情查询"`
}
// VoidWithdrawalQualificationParams 作废提现资料资格路径与请求参数。
type VoidWithdrawalQualificationParams struct {
IDReq
VoidWithdrawalQualificationReq
}
// WithdrawalQualificationListReq 提现资料资格列表查询请求。
type WithdrawalQualificationListReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" required:"true" description:"店铺ID路径参数必填查询前校验当前账号对该店铺的数据范围"`
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
Status *int `query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"`
}
// WithdrawalQualificationItem 提现资料资格版本项。
// 证件号按脱敏值返回,附件仅返回对象存储 Key 引用。
type WithdrawalQualificationItem struct {
ID uint `json:"id" description:"资料版本ID"`
ShopID uint `json:"shop_id" description:"所属店铺ID"`
ShopName string `json:"shop_name" description:"所属店铺名称"`
SubjectType string `json:"subject_type" description:"签约主体类型 (enterprise:企业, personal:个人)"`
SubjectTypeName string `json:"subject_type_name" description:"签约主体类型名称(中文)"`
SubjectCodeMasked string `json:"subject_code_masked" description:"脱敏后的签约主体代码"`
LegalPersonIDCardMask string `json:"legal_person_id_card_masked" description:"脱敏后的法人身份证号"`
ContractFileKey string `json:"contract_file_key" description:"合同附件对象存储 Key"`
IDCardFrontFileKey string `json:"id_card_front_file_key" description:"法人身份证正面附件对象存储 Key"`
IDCardBackFileKey string `json:"id_card_back_file_key" description:"法人身份证反面附件对象存储 Key"`
BusinessLicenseFileKey string `json:"business_license_file_key,omitempty" description:"营业执照附件对象存储 Key"`
ShopFrontFileKey string `json:"shop_front_file_key,omitempty" description:"门头照附件对象存储 Key"`
InvoiceFileKey string `json:"invoice_file_key,omitempty" description:"发票附件对象存储 Key"`
InvoiceTitle string `json:"invoice_title,omitempty" description:"发票抬头"`
Status int `json:"status" description:"状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
InvalidReason string `json:"invalid_reason,omitempty" description:"失效或作废原因"`
InvalidatedAt string `json:"invalidated_at,omitempty" description:"失效时间"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"关联的通用审批实例ID0 表示尚未关联"`
ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称(中文)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"最近更新时间"`
}
// WithdrawalQualificationPageResult 提现资料资格分页响应。
type WithdrawalQualificationPageResult struct {
Items []WithdrawalQualificationItem `json:"items" description:"资料版本列表(按版本从新到旧)"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// ResubmitWithdrawalReq 驳回后重提提现申请请求。
type ResubmitWithdrawalReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"提现申请ID"`
Amount int64 `json:"amount" validate:"required,min=1" required:"true" minimum:"1" description:"提现金额(分)"`
WithdrawalMethod string `json:"withdrawal_method" validate:"required,oneof=alipay" required:"true" enum:"alipay" description:"收款类型"`
AccountName string `json:"account_name" validate:"required,max=50" required:"true" maximum:"50" description:"收款人姓名"`
AccountNumber string `json:"account_number" validate:"required,max=100" required:"true" maximum:"100" description:"收款账号"`
InvoiceKeys []string `json:"invoice_keys" description:"本次申请级发票对象存储 Key 列表;仅企业主体有效资格可提交"`
}
// ShopWithdrawalRequestDetailReq 提现申请详情路径参数。
type ShopWithdrawalRequestDetailReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"提现申请ID"`
}
// WithdrawalRequestAttemptItem 提现审批尝试记录项。
type WithdrawalRequestAttemptItem struct {
ID uint `json:"id" description:"审批尝试记录ID"`
AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"`
Amount int64 `json:"amount" description:"本次提现金额(分)"`
Fee int64 `json:"fee" description:"本次手续费(分)"`
FeeRate int64 `json:"fee_rate" description:"本次手续费率基点100=1%"`
ActualAmount int64 `json:"actual_amount" description:"本次实际到账金额(分)"`
WithdrawalMethod string `json:"withdrawal_method" description:"本次收款方式"`
SubmittedByID uint `json:"submitted_by_account_id" description:"本次提交账号ID"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"本次尝试关联的通用审批实例ID0 表示尚未关联"`
ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称(中文)"`
ReleasedAt string `json:"released_at,omitempty" description:"本次冻结释放时间,空表示冻结仍未结算"`
CreatedAt string `json:"created_at" description:"创建时间"`
}
// ShopWithdrawalRequestDetailResp 提现申请详情响应。
type ShopWithdrawalRequestDetailResp struct {
ShopWithdrawalRequestItem
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID0 表示尚未接入企业微信审批"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID0 表示尚未接入"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"首次接入企业微信审批的审批实例ID0 表示存量申请仍走本地人工终审"`
AnomalyFlag int `json:"anomaly_flag" description:"正交异常标记 (0:无异常, 1:通过后撤销)"`
AnomalyName string `json:"anomaly_name" description:"异常名称(中文)"`
AnomalyReason string `json:"anomaly_reason,omitempty" description:"异常原因,供人工处理"`
Attempts []WithdrawalRequestAttemptItem `json:"attempts" description:"审批尝试记录,按提交次序倒序;历史尝试与审批结果不被覆盖"`
}

View File

@@ -6,7 +6,10 @@ type CreateAgentRechargeRequest struct {
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额范围1分~100万元"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"`
RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表payment_method=offline 时至少1个最多5个微信支付时忽略"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表payment_method=offline 时至少1个最多5个在线支付时禁止传入"`
OtherVoucherKey []string `json:"other_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"其他凭证对象存储Key列表线下代充可选最多5个在线支付时禁止传入"`
OfflinePaymentMethodID uint `json:"offline_payment_method_id" description:"线下收款方式字典IDpayment_method=offline 时必填,须取自启用中的线下收款方式字典)"`
ExternalTransactionNo string `json:"external_transaction_no" validate:"omitempty,max=128" maxLength:"128" description:"交易流水号payment_method=offline 时必填,由付款凭证识别预填并经人工确认;系统不做跨记录去重)"`
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
}
@@ -45,6 +48,29 @@ type AgentRechargePaymentStatusResponse struct {
CompletedAt *string `json:"completed_at" description:"钱包入账完成时间"`
}
// AgentSelfRechargePaymentMethodsUpdateRequest 修改代理在线自充允许范围请求。
// 枚举取值必须在 description 与 enum 标签两处与 pkg/constants 的 AgentSelfRechargeAllowed* 保持一致。
type AgentSelfRechargePaymentMethodsUpdateRequest struct {
AllowedMethods string `json:"allowed_methods" validate:"required,oneof=wechat_only alipay_only both" required:"true" enum:"wechat_only,alipay_only,both" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"`
}
// AgentSelfRechargePaymentMethodsUpdateResponse 修改代理在线自充允许范围响应。
type AgentSelfRechargePaymentMethodsUpdateResponse struct {
AllowedMethods string `json:"allowed_methods" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"`
AllowedMethodsName string `json:"allowed_methods_name" description:"允许范围名称(中文)"`
UpdatedAt string `json:"updated_at" description:"最近更新时间,带时区 RFC3339 格式"`
}
// AgentRechargePaymentVoucherOCRRequest 付款凭证识别请求。
type AgentRechargePaymentVoucherOCRRequest struct {
PaymentVoucherKey string `json:"payment_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"付款凭证对象存储Key必须指向已上传的图片类型附件"`
}
// AgentRechargePaymentVoucherOCRResponse 付款凭证识别响应,只返回交易流水号预填值。
type AgentRechargePaymentVoucherOCRResponse struct {
ExternalTransactionNo string `json:"external_transaction_no" description:"识别出的交易流水号预填值,必须经人工确认或更正后提交;金额、备注、付款人、支付方式与支付时间一律不返回"`
}
// AgentOfflinePayRequest 代理线下充值确认请求
type AgentOfflinePayRequest struct {
OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"`
@@ -69,7 +95,13 @@ type AgentRechargeResponse struct {
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID线下充值为null"`
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号(在线渠道权威值,仅在线支付有值)"`
// ExternalTransactionNo 是线下充值人工确认的交易流水号,独立于在线渠道第三方交易号。
ExternalTransactionNo string `json:"external_transaction_no" description:"交易流水号(线下充值人工确认值,独立于在线渠道第三方交易号)"`
OfflinePaymentMethodID uint `json:"offline_payment_method_id,omitempty" description:"线下收款方式字典ID仅线下充值有值"`
OfflinePaymentMethodCode string `json:"offline_payment_method_code,omitempty" description:"线下收款方式稳定编码快照,仅线下充值有值"`
OfflinePaymentMethodName string `json:"offline_payment_method_name,omitempty" description:"线下收款方式名称快照,仅线下充值有值"`
OtherVoucherKey []string `json:"other_voucher_key" description:"其他凭证对象存储Key列表仅线下充值有值不返回凭证内容"`
PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表线下支付时存在最多5个"`
Remark string `json:"remark,omitempty" description:"运营备注"`
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`

View File

@@ -160,12 +160,45 @@ type AssetPackageResponse struct {
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// AssetPackagesResult 套餐列表分页结果
// AssetPackageHistoryNode 后台资产套餐历史层级节点。
type AssetPackageHistoryNode struct {
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型formal/addon"`
ExpiryBase string `json:"expiry_base,omitempty" description:"到期时间基准"`
OrderID uint `json:"order_id" description:"关联订单ID无订单分配时为0"`
OrderNo string `json:"order_no,omitempty" description:"订单号快照"`
RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照"`
RefundNo string `json:"refund_no,omitempty" description:"退款单号快照"`
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" description:"状态名称"`
RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"`
RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"`
VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例"`
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"`
MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID普通主项为null"`
Priority int `json:"priority" description:"优先级"`
PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买成本价(分),仅平台账号可见"`
RetailAmount *int64 `json:"retail_amount,omitempty" description:"购买零售价(分)"`
CreatedAt time.Time `json:"created_at" description:"购买创建时间"`
Children []*AssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"`
ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"`
RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态master_missing"`
RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"`
}
// AssetPackagesResult 后台资产套餐历史分页结果。
type AssetPackagesResult struct {
Total int64 `json:"total" description:"总数"`
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页条数"`
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
PageSize int `json:"page_size" description:"每页顶层关系组数量"`
Items []*AssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
}
// AssetResolveRequest 资产解析请求

View File

@@ -183,12 +183,39 @@ type AssetPackageHistoryRequest struct {
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// AssetPackageHistoryResponse B3 资产套餐历史响应
// ClientAssetPackageHistoryNode H5 资产套餐历史层级节点。
type ClientAssetPackageHistoryNode struct {
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
OrderID uint `json:"order_id" description:"历史兼容字段本接口不填充真实订单ID零值仍输出为0"`
PackageType string `json:"package_type" description:"套餐类型formal/addon"`
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" description:"状态名称"`
RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"`
RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"`
VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例"`
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"`
MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID普通主项为null"`
Priority int `json:"priority" description:"优先级"`
CreatedAt time.Time `json:"created_at" description:"购买创建时间"`
Children []*ClientAssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"`
ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"`
RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态master_missing"`
RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"`
}
// AssetPackageHistoryResponse B3 资产套餐历史层级响应。
type AssetPackageHistoryResponse struct {
List []AssetPackageResponse `json:"items" description:"套餐历史列表"`
Total int64 `json:"total" description:"总数"`
List []*ClientAssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"size" description:"每页数量"`
PageSize int `json:"size" description:"每页顶层关系组数量"`
}
// ========================================

View File

@@ -0,0 +1,305 @@
package dto
import "time"
// CreateEmployeeCollectionPaymentMethodRequest 创建线下收款方式请求
// 枚举说明enabled 对应 constants.EmployeeCollectionPaymentMethodStatusEnabled/Disabled1 启用、0 停用)。
type CreateEmployeeCollectionPaymentMethodRequest struct {
Code string `json:"code" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"收款方式稳定编码1-64 字符,未删除记录中唯一,被核销申请引用后不可修改"`
Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"收款方式名称1-100 字符"`
Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数,默认 0"`
Enabled *bool `json:"enabled" description:"是否启用,默认启用;停用后新核销申请不可选择该方式"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"`
}
// UpdateEmployeeCollectionPaymentMethodRequest 更新线下收款方式请求
// 全部字段可选:仅传入的字段被修改;已启用即被核销申请引用的记录不允许修改稳定编码。
type UpdateEmployeeCollectionPaymentMethodRequest struct {
Code *string `json:"code" validate:"omitempty,min=1,max=64" minLength:"1" maxLength:"64" description:"收款方式稳定编码,仅未被核销申请引用时可修改"`
Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"收款方式名称"`
Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数"`
Enabled *bool `json:"enabled" description:"是否启用;停用后新核销申请不可选择该方式,历史申请仍展示冻结名称"`
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"`
}
// EmployeeCollectionPaymentMethodListRequest 查询线下收款方式列表请求
type EmployeeCollectionPaymentMethodListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
Enabled *bool `json:"enabled" query:"enabled" description:"按启用状态过滤;超级管理员可查询全部,其他后台账号仅返回启用项"`
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按稳定编码或名称模糊搜索,最多 100 字符"`
}
// EmployeeCollectionPaymentMethodResponse 线下收款方式响应
// 对外契约字段固定为 id/code/name/enabled其余字段为维护与展示补充。
type EmployeeCollectionPaymentMethodResponse struct {
ID uint `json:"id" description:"收款方式ID"`
Code string `json:"code" description:"收款方式稳定编码"`
Name string `json:"name" description:"收款方式名称"`
Enabled bool `json:"enabled" description:"是否启用;停用后新核销申请不可选择该方式"`
Sort int64 `json:"sort" description:"排序值"`
Remark string `json:"remark" description:"备注"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// EmployeeCollectionPaymentMethodListResponse 线下收款方式列表响应
type EmployeeCollectionPaymentMethodListResponse struct {
List []*EmployeeCollectionPaymentMethodResponse `json:"items" description:"收款方式列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"size" description:"每页条数"`
}
// EmployeeCollectionBillListRequest 查询员工代收款账单列表请求。
// 枚举说明source_type 对应 constants.EmployeeCollectionSourceTypeOrder/Recharge
// status 对应 constants.EmployeeCollectionBillStatusPending/Partial/Settled/Closed。
type EmployeeCollectionBillListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤非超级管理员固定为当前账号该参数被忽略"`
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤匹配来源订单买家或充值归属店铺"`
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始YYYY-MM-DD含当日 00:00:00"`
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止YYYY-MM-DD含当日 23:59:59"`
}
// EmployeeCollectionBillStatisticsRequest 查询员工代收款账单统计请求。
// 与账单列表使用同一批筛选字段与可见性范围,但不接受分页参数。
type EmployeeCollectionBillStatisticsRequest struct {
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤非超级管理员固定为当前账号该参数被忽略"`
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤匹配来源订单买家或充值归属店铺"`
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始YYYY-MM-DD含当日 00:00:00"`
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止YYYY-MM-DD含当日 23:59:59"`
}
// EmployeeCollectionBillResponse 员工代收款账单行响应。
type EmployeeCollectionBillResponse struct {
ID uint `json:"id" description:"账单ID"`
SourceType string `json:"source_type" description:"来源类型 (order:后台线下套餐订单, recharge:代理线下充值)"`
SourceTypeName string `json:"source_type_name" description:"来源类型中文名称"`
SourceID uint `json:"source_id" description:"来源业务主键ID"`
SourceNo string `json:"source_no" description:"来源单号"`
DebtorAccountID uint `json:"debtor_account_id" description:"欠款人后台账号ID"`
DebtorSnapshot map[string]any `json:"debtor_snapshot" description:"欠款人账号只读快照账号ID、名称、类型"`
CustomerSnapshot map[string]any `json:"customer_snapshot" description:"来源客户或店铺只读快照,不含付款凭证内容"`
ReceivableAmount int64 `json:"receivable_amount" description:"应收金额(分)"`
ReceivedAmount int64 `json:"received_amount" description:"已核销金额(分),仅企业微信最终通过的分摊计入"`
ReservedAmount int64 `json:"reserved_amount" description:"审批中预占金额(分)"`
RemainingAmount int64 `json:"remaining_amount" description:"剩余可核销金额(分),已关闭账单为 0"`
Status int `json:"status" description:"账单状态 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
StatusName string `json:"status_name" description:"账单状态中文名称"`
ApprovalPending bool `json:"approval_pending" description:"是否存在审批中分摊(预占大于零)"`
ClosedReason string `json:"closed_reason" description:"关闭原因,仅已关闭账单有值"`
ClosedAt *time.Time `json:"closed_at,omitempty" description:"关闭时间"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
}
// EmployeeCollectionBillListResponse 员工代收款账单列表响应。
type EmployeeCollectionBillListResponse struct {
List []*EmployeeCollectionBillResponse `json:"items" description:"账单列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"size" description:"每页条数"`
}
// EmployeeCollectionBillStatisticsResponse 员工代收款账单统计响应。
// 与账单列表使用同一筛选条件与可见性范围。
type EmployeeCollectionBillStatisticsResponse struct {
ReceivableTotal int64 `json:"receivable_total" description:"应收金额合计(分)"`
ReceivedTotal int64 `json:"received_total" description:"已核销金额合计(分)"`
UnsettledTotal int64 `json:"unsettled_total" description:"未核销金额合计(分),已关闭账单未核销余额按 0 计入"`
PendingBillCount int64 `json:"pending_bill_count" description:"待处理账单数,即待核销或部分核销账单数量"`
}
// EmployeeCollectionBillRefundResponse 账单退款冲销关联响应。
type EmployeeCollectionBillRefundResponse struct {
ID uint `json:"id" description:"冲销关联ID"`
RefundID uint `json:"refund_id" description:"退款申请ID"`
SourceOrderID uint `json:"source_order_id" description:"账单来源订单ID"`
RefundAmount int64 `json:"refund_amount" description:"本次退款成功金额(分)"`
BillReceivableAmount int64 `json:"bill_receivable_amount" description:"冲销前账单应收金额快照(分)"`
Outcome string `json:"outcome" description:"处理结果 (closed_full:来源订单全额退款关闭, reduced:按退款金额冲减应收, hint_only:仅记录退款关联提示)"`
OutcomeName string `json:"outcome_name" description:"处理结果中文名称"`
ReducedAmount int64 `json:"reduced_amount" description:"实际冲减应收金额(分)"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionBillAllocationResponse 账单分摊响应。
type EmployeeCollectionBillAllocationResponse struct {
ID uint `json:"id" description:"分摊ID"`
ApplicationID uint `json:"application_id" description:"核销申请ID"`
ApplicationStatus int `json:"application_status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
ApplicationStatusName string `json:"application_status_name" description:"申请状态中文名称"`
AttemptID uint `json:"attempt_id" description:"所属审批尝试记录ID"`
Amount int64 `json:"amount" description:"本次分摊金额(分)"`
Status int `json:"status" description:"分摊状态 (0:审批中预占, 1:已通过, 2:已驳回或已释放)"`
StatusName string `json:"status_name" description:"分摊状态中文名称"`
ReleasedAt *time.Time `json:"released_at,omitempty" description:"预占释放时间,审批中为空"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionBillAttemptResponse 申请审批尝试记录响应,材料为本次提交的冻结快照。
type EmployeeCollectionBillAttemptResponse struct {
ID uint `json:"id" description:"审批尝试记录ID同时是通用审批业务ID"`
AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"`
PaymentMethodID uint `json:"payment_method_id" description:"本次冻结的收款方式字典ID"`
PaymentMethodCode string `json:"payment_method_code" description:"本次冻结的收款方式稳定编码"`
PaymentMethodName string `json:"payment_method_name" description:"本次冻结的收款方式名称"`
PaidAmount int64 `json:"paid_amount" description:"本次冻结的付款金额(分)"`
PayerName string `json:"payer_name" description:"付款方名称"`
PaidAt time.Time `json:"paid_at" description:"付款时间"`
ExternalTransactionNo string `json:"external_transaction_no" description:"人工确认的外部交易流水号,申请人可原样核对与重用"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表仅返回对象键引用"`
Remark string `json:"remark" description:"本次提交备注"`
SubmittedByAccountID uint `json:"submitted_by_account_id" description:"本次实际提交账号ID代办时为超级管理员"`
ActingReason string `json:"acting_reason" description:"本次代办原因,非代办为空"`
AllocationSnapshot []map[string]any `json:"allocation_snapshot" description:"本次冻结的账单分摊快照"`
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"本次尝试关联的通用审批实例ID"`
ApprovalStatus *int `json:"approval_status,omitempty" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"`
ApprovalOpinion string `json:"approval_opinion" description:"渠道审批意见文本,取自通用审批实例终态决策快照;无意见时为空"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionBillApplicationResponse 账单关联的核销申请响应,含审批历史。
type EmployeeCollectionBillApplicationResponse struct {
ID uint `json:"id" description:"核销申请ID"`
ApplicantAccountID uint `json:"applicant_account_id" description:"申请人后台账号ID"`
ActingOperatorID uint `json:"acting_operator_id" description:"实际代办的超级管理员账号ID0 表示本人办理"`
ActingReason string `json:"acting_reason" description:"代办原因,非代办为空"`
PaymentMethodID uint `json:"payment_method_id" description:"线下收款方式字典ID"`
PaymentMethodCode string `json:"payment_method_code" description:"收款方式稳定编码快照"`
PaymentMethodName string `json:"payment_method_name" description:"收款方式名称快照"`
PaidAmount int64 `json:"paid_amount" description:"人工确认的付款金额(分)"`
PayerName string `json:"payer_name" description:"付款方名称"`
PaidAt time.Time `json:"paid_at" description:"付款时间"`
ExternalTransactionNo string `json:"external_transaction_no" description:"外部交易流水号"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表仅返回对象键引用"`
Remark string `json:"remark" description:"申请备注"`
Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
StatusName string `json:"status_name" description:"申请状态中文名称"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID仅用于展示"`
DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"`
TerminalReason string `json:"terminal_reason" description:"异常终态说明,如企业微信通过后撤销"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
Attempts []*EmployeeCollectionBillAttemptResponse `json:"attempts" description:"该申请的审批尝试记录,按提交顺序排列"`
}
// EmployeeCollectionBillDetailResponse 员工代收款账单详情响应。
type EmployeeCollectionBillDetailResponse struct {
Bill *EmployeeCollectionBillResponse `json:"bill" description:"账单事实"`
Refunds []*EmployeeCollectionBillRefundResponse `json:"refunds" description:"来源订单退款冲销关联"`
Allocations []*EmployeeCollectionBillAllocationResponse `json:"allocations" description:"该账单的核销分摊"`
Applications []*EmployeeCollectionBillApplicationResponse `json:"applications" description:"涉及该账单的核销申请与审批历史"`
}
// CloseEmployeeCollectionBillRequest 关闭员工代收款账单请求
type CloseEmployeeCollectionBillRequest struct {
Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"关闭原因,必填,最多 500 字符"`
}
// EmployeeCollectionApplicationAllocationRequest 核销申请中的单张账单分摊。
type EmployeeCollectionApplicationAllocationRequest struct {
BillID uint `json:"bill_id" validate:"required" required:"true" description:"目标员工代收款账单ID"`
Amount int64 `json:"amount" validate:"required,min=1" required:"true" minimum:"1" description:"本次分摊金额(分),必须大于零且不超过该账单可核销余额"`
}
// SubmitEmployeeCollectionApplicationRequest 创建或重提核销申请请求。
// 枚举说明allocations 由申请人提交,服务端按账单可核销余额与付款金额严格校验。
type SubmitEmployeeCollectionApplicationRequest struct {
PaymentMethodID uint `json:"payment_method_id" validate:"required" required:"true" description:"线下收款方式字典ID必须是启用中的字典项"`
PaidAmount int64 `json:"paid_amount" validate:"required,min=1" required:"true" minimum:"1" description:"人工确认的付款金额(分),必须大于零"`
PayerName string `json:"payer_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"付款方名称1-100 字符"`
PaidAt string `json:"paid_at" validate:"required" required:"true" description:"付款时间,带时区的 RFC3339 格式,如 2026-08-31T10:00:00+08:00"`
ExternalTransactionNo string `json:"external_transaction_no" validate:"required,min=1,max=128" required:"true" minLength:"1" maxLength:"128" description:"经人工确认的外部交易流水号OCR 结果必须人工更正后提交"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" validate:"required,min=1,max=5,dive,min=1,max=512" required:"true" minItems:"1" maxItems:"5" description:"支付凭证对象存储Key列表1-5 个既有对象键引用"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"申请备注,最多 500 字符"`
ActingReason string `json:"acting_reason" validate:"omitempty,max=500" maxLength:"500" description:"超级管理员代办原因1-500 字符;代办时必填,本人办理不得填写"`
Allocations []EmployeeCollectionApplicationAllocationRequest `json:"allocations" validate:"required,min=1,max=50,dive" required:"true" minItems:"1" maxItems:"50" description:"账单分摊列表,至少一条;每张账单本次只能出现一次"`
}
// EmployeeCollectionApplicationListRequest 查询核销申请列表请求。
type EmployeeCollectionApplicationListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按申请状态过滤 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
ApplicantAccountID *uint `json:"applicant_account_id" query:"applicant_account_id" description:"按申请人账号ID过滤非超级管理员固定为当前账号该参数被忽略"`
PaymentMethodID *uint `json:"payment_method_id" query:"payment_method_id" description:"按线下收款方式字典ID过滤"`
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始YYYY-MM-DD含当日 00:00:00"`
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止YYYY-MM-DD含当日 23:59:59"`
}
// EmployeeCollectionApplicationResponse 核销申请行响应。
type EmployeeCollectionApplicationResponse struct {
ID uint `json:"id" description:"核销申请ID"`
ApplicantAccountID uint `json:"applicant_account_id" description:"申请人后台账号ID"`
ActingOperatorID uint `json:"acting_operator_id" description:"实际代办的超级管理员账号ID0 表示本人办理"`
ActingReason string `json:"acting_reason" description:"代办原因,非代办为空"`
PaymentMethodID uint `json:"payment_method_id" description:"线下收款方式字典ID"`
PaymentMethodCode string `json:"payment_method_code" description:"收款方式稳定编码快照"`
PaymentMethodName string `json:"payment_method_name" description:"收款方式名称快照,字典改名不影响历史申请"`
PaidAmount int64 `json:"paid_amount" description:"人工确认的付款金额(分)"`
PayerName string `json:"payer_name" description:"付款方名称"`
PaidAt time.Time `json:"paid_at" description:"付款时间"`
ExternalTransactionNo string `json:"external_transaction_no" description:"人工确认的外部交易流水号,申请人可原样核对与重用"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表仅返回对象键引用"`
Remark string `json:"remark" description:"申请备注"`
Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
StatusName string `json:"status_name" description:"申请状态中文名称"`
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID仅用于展示"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID仅用于展示"`
DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"`
TerminalReason string `json:"terminal_reason" description:"异常终态说明,如企业微信通过后撤销"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
}
// EmployeeCollectionApplicationListResponse 核销申请列表响应。
type EmployeeCollectionApplicationListResponse struct {
List []*EmployeeCollectionApplicationResponse `json:"items" description:"核销申请列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"size" description:"每页条数"`
}
// EmployeeCollectionApplicationAllocationResponse 核销申请分摊响应,含目标账单只读摘要。
type EmployeeCollectionApplicationAllocationResponse struct {
ID uint `json:"id" description:"分摊ID"`
BillID uint `json:"bill_id" description:"目标账单ID"`
Amount int64 `json:"amount" description:"本次分摊金额(分)"`
Status int `json:"status" description:"分摊状态 (0:审批中预占, 1:已通过, 2:已驳回或已释放)"`
StatusName string `json:"status_name" description:"分摊状态中文名称"`
ReleasedAt *time.Time `json:"released_at,omitempty" description:"预占释放时间,审批中为空"`
BillSourceType string `json:"bill_source_type" description:"账单来源类型 (order:后台线下套餐订单, recharge:代理线下充值)"`
BillSourceNo string `json:"bill_source_no" description:"账单来源单号"`
BillReceivableAmount int64 `json:"bill_receivable_amount" description:"账单应收金额(分)"`
BillReceivedAmount int64 `json:"bill_received_amount" description:"账单已核销金额(分)"`
BillReservedAmount int64 `json:"bill_reserved_amount" description:"账单审批中预占金额(分)"`
BillStatus int `json:"bill_status" description:"账单状态 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
BillStatusName string `json:"bill_status_name" description:"账单状态中文名称"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionApplicationSubmitResponse 创建或重提核销申请响应。
type EmployeeCollectionApplicationSubmitResponse struct {
Application *EmployeeCollectionApplicationResponse `json:"application" description:"核销申请事实"`
Attempt *EmployeeCollectionBillAttemptResponse `json:"attempt" description:"本次新增的审批尝试记录与冻结快照"`
Allocations []*EmployeeCollectionApplicationAllocationResponse `json:"allocations" description:"本次账单分摊与预占结果"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"本次创建的企业微信通用审批实例ID"`
ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"`
}
// EmployeeCollectionApplicationDetailResponse 核销申请详情响应。
type EmployeeCollectionApplicationDetailResponse struct {
Application *EmployeeCollectionApplicationResponse `json:"application" description:"核销申请事实与最新审批引用"`
Allocations []*EmployeeCollectionApplicationAllocationResponse `json:"allocations" description:"该申请当前的账单分摊"`
Attempts []*EmployeeCollectionBillAttemptResponse `json:"attempts" description:"全部审批尝试记录,按提交顺序排列,历史材料不被覆盖"`
}

View File

@@ -15,7 +15,7 @@ type CreateAdminOrderRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(卡支持 ICCID设备支持 VirtualNo、IMEI 或 SN"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表payment_method=offline时至少1个最多5个通过/storage/upload-url上传图片后获得"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表最多5个通过/storage/upload-url上传图片后获得;线下订单需付款凭证,但实际收款金额大于 0 且由平台账号操作的非赠送线下订单会生成员工代收款账单,该场景凭证由核销申请环节提供,创建订单时可为空"`
}
type OrderListRequest struct {

View File

@@ -0,0 +1,124 @@
package dto
import (
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// PaymentMerchantRequest 是受控商户管理写入请求。
type PaymentMerchantRequest struct {
Name string `json:"name" validate:"required,min=1,max=100" description:"商户名称"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType string `json:"provider_type" validate:"required,max=30" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou支付宝为 alipay"`
MerchantIdentity string `json:"merchant_identity" validate:"required,max=100" description:"商户号或应用标识"`
Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口传输"`
Enabled bool `json:"enabled" description:"是否启用"`
Remark string `json:"remark" validate:"max=1000" description:"备注"`
}
// PaymentMerchantUpdateRequest 是受控商户管理更新请求。
type PaymentMerchantUpdateRequest struct {
Name *string `json:"name" description:"商户名称"`
PaymentMethod *string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType *string `json:"provider_type" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou支付宝为 alipay"`
MerchantIdentity *string `json:"merchant_identity" description:"商户号或应用标识"`
Credentials *model.JSONB `json:"credentials" description:"商户受控凭证"`
Enabled *bool `json:"enabled" description:"是否启用"`
Remark *string `json:"remark" description:"备注"`
}
// PaymentMerchantDeleteRequest 是受控商户删除二次确认请求。
type PaymentMerchantDeleteRequest struct {
Confirm bool `json:"confirm" validate:"required" description:"确认删除必须为true"`
}
// PaymentMerchantListRequest 是支付商户分页查询条件。
type PaymentMerchantListRequest struct {
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量"`
PaymentMethod *string `query:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled *bool `query:"enabled" description:"是否启用"`
}
// PaymentMerchantResponse 是受控商户管理响应。
type PaymentMerchantResponse struct {
ID uint `json:"id" description:"商户ID"`
Name string `json:"name" description:"商户名称"`
PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType string `json:"provider_type" description:"服务商类型"`
MerchantIdentity string `json:"merchant_identity" description:"商户号或应用标识"`
Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口返回"`
CredentialVersion int64 `json:"credential_version" description:"凭证版本"`
Enabled bool `json:"enabled" description:"是否启用"`
Remark string `json:"remark" description:"备注"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// PaymentMerchantPoolRequest 是商户池创建或更新请求。
type PaymentMerchantPoolRequest struct {
Name string `json:"name" validate:"required,min=1,max=100" description:"商户池名称"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled bool `json:"enabled" description:"是否启用"`
Strategy string `json:"strategy" validate:"required,oneof=amount count time" enum:"amount,count,time" description:"轮询策略amount=金额阈值count=笔数阈值time=按时间段轮换"`
ThresholdAmount *int64 `json:"threshold_amount" description:"金额轮询阈值,单位分;仅 amount 策略"`
ThresholdCount *int64 `json:"threshold_count" description:"笔数轮询阈值;仅 count 策略"`
StatisticCycle *string `json:"statistic_cycle" enum:"round,day,month" description:"金额/笔数统计周期round=每轮day=自然日month=自然月"`
TimePeriodValue *int64 `json:"time_period_value" description:"时间轮询周期数值;仅 time 策略,最小 1"`
TimePeriodUnit *string `json:"time_period_unit" enum:"minute,hour,day" description:"时间轮询单位minute=分钟hour=小时day=天"`
TimePeriodStartedAt *time.Time `json:"time_period_started_at" description:"时间轮询起始时间;仅 time 策略"`
MemberIDs []uint `json:"member_ids" validate:"required,min=1" description:"有序商户ID列表"`
Remark string `json:"remark" description:"备注"`
}
// PaymentMerchantPoolListRequest 是商户池分页查询条件。
type PaymentMerchantPoolListRequest struct {
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量"`
}
// PaymentMerchantPoolResponse 是商户池管理响应。
type PaymentMerchantPoolResponse struct {
ID uint `json:"id" description:"商户池ID"`
Name string `json:"name" description:"商户池名称"`
PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled bool `json:"enabled" description:"是否启用"`
Strategy string `json:"strategy" enum:"amount,count,time" description:"轮询策略amount=金额阈值count=笔数阈值time=按时间段轮换"`
ThresholdAmount *int64 `json:"threshold_amount,omitempty" description:"金额阈值,分"`
ThresholdCount *int64 `json:"threshold_count,omitempty" description:"笔数阈值"`
StatisticCycle *string `json:"statistic_cycle,omitempty" enum:"round,day,month" description:"统计周期round=每轮day=自然日month=自然月"`
TimePeriodValue *int64 `json:"time_period_value,omitempty" description:"时间周期数值"`
TimePeriodUnit *string `json:"time_period_unit,omitempty" enum:"minute,hour,day" description:"时间单位minute=分钟hour=小时day=天"`
TimePeriodStartedAt *time.Time `json:"time_period_started_at,omitempty" description:"时间轮询起始时间"`
RoutingEpoch int64 `json:"routing_epoch" description:"当前路由统计世代"`
MemberIDs []uint `json:"member_ids" description:"有序商户ID列表"`
Remark string `json:"remark" description:"备注"`
}
// WechatAuthorizationRequest 是微信授权配置管理写入请求。
type WechatAuthorizationRequest struct {
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret"`
OaToken string `json:"oa_token" description:"公众号Token"`
OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret"`
Enabled bool `json:"enabled" description:"是否启用"`
}
// WechatAuthorizationResponse 是微信授权配置管理响应。
type WechatAuthorizationResponse struct {
ID uint `json:"id" description:"授权配置ID"`
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret仅专用管理接口返回"`
OaToken string `json:"oa_token" description:"公众号Token仅专用管理接口返回"`
OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥仅专用管理接口返回"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret仅专用管理接口返回"`
CredentialVersion int64 `json:"credential_version" description:"凭证版本"`
Enabled bool `json:"enabled" description:"是否启用"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}

View File

@@ -3,9 +3,11 @@ package dto
// CreateRefundRequest 创建退款申请请求
type CreateRefundRequest struct {
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"`
Method string `json:"method" validate:"required,oneof=original_route customer_account asset_wallet agent_wallet" required:"true" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"`
CustomerAccountInfo string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表至少1个最多5个通过/storage/upload-url上传图片后获得"`
RefundVoucherKey []string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表最多5个,仅客户收款信息退款方式必填,通过/storage/upload-url上传图片后获得"`
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID可选"`
}
@@ -25,9 +27,11 @@ type RejectRefundRequest struct {
// 退款单被退回后,可修改部分字段后重新提交
type ResubmitRefundRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"`
Method *string `json:"method" validate:"omitempty,oneof=original_route customer_account asset_wallet agent_wallet" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),不填沿用原有方式"`
CustomerAccountInfo *string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表重新提交时可替换历史记录缺失时必填最多5个"`
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表重新提交时可替换客户收款信息退款方式必填最多5个"`
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
}
@@ -48,7 +52,7 @@ type ReturnRefundRequest struct {
type RefundListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=6" minimum:"1" maximum:"6" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"`
OrderID *uint `json:"order_id" query:"order_id" validate:"omitempty" description:"关联订单ID"`
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)"`
@@ -65,6 +69,10 @@ type RefundResponse struct {
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
// 当前退款套餐用量:按冻结套餐使用记录 → 订单主套餐 → 订单任一套餐的优先级解析,
// 只用于展示、查询与导出,不参与退款金额校验、套餐失效或佣金回溯。
RefundPackageUsedMB int64 `json:"refund_package_used_mb" description:"当前退款套餐已用量MB真实流量解析不到套餐时为0"`
RefundPackageTotalMB int64 `json:"refund_package_total_mb" description:"当前退款套餐总量MB真实流量解析不到套餐时为0"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
@@ -72,8 +80,26 @@ type RefundResponse struct {
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表最多5个"`
RefundReason string `json:"refund_reason" description:"退款原因"`
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
Method string `json:"method" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),空表示未接入方式的存量申请"`
MethodName string `json:"method_name" description:"退款方式中文名称"`
FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"系统派生并冻结的权威实收金额(分),作为可退金额上限"`
CustomerAccountInfo string `json:"customer_account_info" description:"客户收款信息自由文本快照,仅客户收款信息退款方式有值"`
ChannelRefundStatus int `json:"channel_refund_status" description:"渠道原路退款状态 (0:未发起, 1:处理中, 2:已成功, 3:已失败)0 表示未发起渠道退款或不适用该退款方式"`
ChannelRefundStatusName string `json:"channel_refund_status_name" description:"渠道原路退款状态中文名称"`
ChannelRefundNo string `json:"channel_refund_no" description:"渠道退款流水号,渠道明确成功或失败后回填"`
ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"渠道退款请求号快照,用于幂等与对账"`
ChannelRefundAmount int64 `json:"channel_refund_amount" description:"提交渠道的退款金额快照(分)"`
ChannelRefundedAt string `json:"channel_refunded_at,omitempty" description:"渠道明确退款成功时间"`
FailureReason string `json:"failure_reason" description:"结构化失败分类稳定编码 (channel_rejected:渠道明确拒绝, credential_invalid:渠道凭证失效, insufficient_balance:渠道余额不足, timeout_unknown:超时或结果未知, approval_rejected:企业微信驳回或关闭, revoked_after_approved:企业微信通过后撤销, payment_fact_invalid:本地原支付事实不可用),空表示无失败"`
FailureReasonName string `json:"failure_reason_name" description:"失败分类中文名称"`
FailureMessage string `json:"failure_message" description:"失败安全摘要,供人工排查;不含渠道凭证等敏感内容"`
AnomalyFlag int `json:"anomaly_flag" description:"异常标记 (0:无异常, 1:有异常,需人工处理)"`
AnomalyReason string `json:"anomaly_reason" description:"异常原因说明,无异常时为空"`
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID仅用于展示"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID仅用于展示"`
Attempts []RefundAttemptResponse `json:"attempts" description:"审批尝试记录,按提交顺序排列,历史材料不被覆盖;无尝试记录时为空数组"`
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
@@ -99,3 +125,22 @@ type RefundListResponse struct {
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// RefundAttemptResponse 退款审批尝试响应,材料为本次提交的冻结快照。
type RefundAttemptResponse struct {
ID uint `json:"id" description:"审批尝试记录ID同时是通用审批业务ID"`
AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"`
Method string `json:"method" description:"本次冻结的退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"`
MethodName string `json:"method_name" description:"本次冻结的退款方式中文名称"`
RefundAmount int64 `json:"refund_amount" description:"本次提交冻结的申请退款金额(分)"`
FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"本次提交冻结的权威实收金额(分)"`
RefundReason string `json:"refund_reason" description:"本次提交的退款原因"`
CustomerAccountInfo string `json:"customer_account_info" description:"本次冻结的客户收款信息快照,非客户收款信息退款方式为空"`
CustomerVoucherKey []string `json:"customer_voucher_key" description:"客户收款凭证对象存储file_key列表仅返回对象键引用"`
ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"本次提交使用的渠道退款请求号快照,用于幂等与对账"`
SubmittedByAccountID uint `json:"submitted_by_account_id" description:"本次实际提交账号ID"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"本次尝试关联的通用审批实例ID0 表示未关联"`
ApprovalStatus *int `json:"approval_status,omitempty" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"`
CreatedAt string `json:"created_at" description:"创建时间"`
}

Some files were not shown because too many files have changed in this diff Show More