55 Commits

Author SHA1 Message Date
41722760b1 docs(H5弹窗): AUG26-007 归档变更并同步 h5-popup-notification 主 Spec 与证据链
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m31s
2026-09-15 16:29:56 +08:00
333ba4b647 feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
新增 000225 迁移:运营弹窗配置表 tb_h5_popup_configuration(页面/范围/优先级/频率/受控动作/启停/有效期/版本)
与 tb_notification 可空 JSONB 列 popup_snapshot。

新增通知直建窄接口 DirectWriter.CreateOrGetPersonal:与 Outbox 消费共用 prepareDelivery 的渲染、
展示期与 CreateIdempotent 规则,冲突时回查返回既有行;同步扩展个人通知查询与已读两处类型白名单,
并按个人客户入口补齐投递审计来源。

新增 H5 候选与风险换卡:GET /api/c/v1/popup-candidates 先判风险资格(广电卡 + 风险停机 +
无活动物流换货单),命中只返回风险候选;未命中再按时间/启停/页面/店铺/设备类型/卡类型范围/频率
匹配运营配置。POST /api/c/v1/risk-exchanges/:asset_id/address 锁资产行后幂等创建待发货物流换货单,
首次地址锁定,不沿用资产级群发通知。

新增后台运营弹窗配置 CRUD 与启停(仅超级管理员与平台账号),更新递增版本并刷新最近更新时间,
标题与正文统一拒绝 URL 与前端路由,全部写操作记录操作者、前后值、版本与时间。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)与参数校验中文提示共用实现。
2026-09-15 15:23:52 +08:00
70e680eb0a feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
2026-09-15 11:54:56 +08:00
93e072e1e2 feat(换货): AUG26-005 换货业务数据迁移状态与失败恢复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m42s
- 新增成对迁移 000222:tb_exchange_order 增加非空 migration_status 与
  migration_failure_reason,按既有 migrate_data/migration_completed 回填历史,
  并加四值 CHECK 约束,不新增索引
- 模型与常量定义四种迁移状态及中文名称,保留既有布尔字段兼容语义
- 物流换货创建恒 not_migrated,发货按请求落 pending/not_migrated,
  完成成功写 migrated/not_migrated 并清空失败原因、同步兼容字段
- 直接换货创建即完成,任一步失败整体回滚,不持久化换货单、不产生 failed
- 迁移失败回滚全部业务修改后,在独立短事务内条件更新 failed 与安全失败原因
  并写失败审计,RowsAffected 为 0 时跳过状态写入但仍写审计
- failed 物流单重试仅限超级管理员或平台用户,授权以锁内 FOR UPDATE 判定为准,
  重试从钱包余额起整表重跑;非 failed 单沿用既有完成门禁
- 列表与详情返回迁移状态与中文名称,仅 failed 返回失败原因;既有三字段保持兼容
- 换货导出在「状态」列后新增中文「迁移状态」列,不导出失败原因
- 同步 order-refund-exchange 主 spec 与验证证据,归档本 Change
- 登记 KNOWN-ISSUE-001:既有标签复制 OnConflict 未声明部分索引谓词(42P10),
  旧资产带标签时迁移最后一步失败,待另立变更修复
2026-09-14 18:32:26 +08:00
c7f9e005af feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
- 迁移 000221:新增 tb_business_user_group、tb_business_user_group_member、tb_shop_business_owner_import_task,成员一账号一行由部分唯一索引保证,店铺所属组按当前负责人实时推导,不回填历史分组。
- 用户组 CRUD、成员改组/清空归属、店铺批量交接(原子失败不部分写入)。
- 店铺负责人 CSV 导入任务:逐行独立事务、逐行明细、任务级与行级失败分离。
- 读侧推导与筛选:未分组、业务线、停用组可筛出并带停用标记。
- 补齐操作审计动作与资源、openapi 清单、发布门禁巡检表清单。
- 归档 add-shop-salesperson-groups 变更并同步 openspec/specs/business-user-group,补齐 AUG26-003 验证证据链。
2026-09-14 16:51:44 +08:00
957a235585 fix(提现): 修复路径参数未回填导致的参数校验恒失败并给出字段级提示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m37s
提现资料资格提交对任何请求都返回 1001。根因是 ShopID 为 json:"-" 的路径字段,
Handler 在 c.Params 解析前就执行 validator.Struct,required 校验恒失败;
提现重提与提现驳回存在同一缺陷。

- 路径参数在解析后、校验前回填 DTO(资格提交 shop_id、资格作废 id、重提 shop_id/id、驳回 id)
- 校验失败改用 validationMessage 输出首个失败字段与规则,字段名取 DTO 中文 description,不拼接底层错误文本、不回显字段值
- 工程约束新增 ENG-ERR-002 固化上述规则

验证:驱动真实 Handler 与全局 ErrorHandler,原始请求体已通过校验;
缺附件、非法主体类型、超长身份证号、缺作废原因等均返回可定位提示。
2026-09-14 15:36:47 +08:00
18796b16ff 归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m32s
2026-09-14 14:25:03 +08:00
1aa4eacee2 feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
用 PRD 2.14 语义整体替换退款佣金「整单全额失效」实现:原佣金保持已发放不变,
回溯事实落在新表 tb_commission_clawback_record 的负数、不可提现明细上。

- 新增成对迁移 000220 建 tb_commission_clawback_record,唯一约束
  (refund_id, original_commission_id) 为权威幂等键,附店铺+时间/原佣金/订单索引。
- 回溯用例(internal/service/refund/clawback.go):准入仅由退款申请状态、审批异常
  标记与退款方式决定;金额按分整数计算,分母取冻结实收(缺失回落审批尝试)、
  分子原路取渠道成功金额,乘法用 math/big 中间量,舍入差自末条起向前补差;
  终态判据要求订单佣金已离开待计算且不存在 status IN (1,2,99) 的记录。
- 三层幂等:唯一约束兜底、佣金行行锁 + 钱包乐观锁、commission_deducted 仅作投影
  并带 WHERE commission_deducted = false 条件置位;闭合三结果为已回溯、无需回溯、
  审批异常转人工。
- 事务内顺序固定:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 →
  扣 balance(允许为负)→ 写负数流水 → 审计;删除旧全额失效写入与其两个审计调用点,
  refund.invalidate_commission 仅保留常量与注册供历史审计读取。
- 读侧:佣金明细列表 status 筛选透传,两表 UNION ALL 合并分页并以 source ASC 作
  末位次序键;新增佣金明细详情接口并同步路由与 OpenAPI 装配。
- 导出:新增 commission_record 场景(白名单、exporter 注册、DTO oneof、DataSource
  与列定义),粒度为佣金记录,原佣金与回溯各一行,金额保持分且可为负。
- 新增退款佣金回溯周期补偿任务(@every 1m / MaxRetry(3) / Timeout(10m) /
  Unique(10m),独立队列),保留启动时补偿扫描,判据与既有实现一致。

Refs: AUG26-012
2026-09-14 13:40:34 +08:00
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
c7c2b17d78 归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 54s
2026-09-07 11:34:23 +08:00
3a093ecd6b 清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m36s
2026-09-07 10:09:51 +08:00
5424751993 修复停复机忽略免实名策略并归档变更
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-07 10:06:17 +08:00
370fd3e67f update
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m49s
2026-09-03 09:28:28 +08:00
dbfeeee253 缓解io压力 2026-09-02 16:14:27 +08:00
395e5fb47c 修复套餐接续停机竞态与轮询兜底
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m13s
2026-08-29 16:27:48 +08:00
62f3d25e81 修复批量订购 2026-08-26 14:54:26 +08:00
5797fd0e94 修复企微兜底机制
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m40s
2026-08-20 18:06:45 +08:00
ba677a35e1 Update deploy.yaml
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 28h53m35s
2026-08-20 12:06:58 +08:00
22b95db2f9 每日清理 2026-08-20 12:03:17 +08:00
143df60485 修复 2026-08-18 17:32:15 +08:00
656a921ff0 富友支付支持 2026-08-18 17:13:20 +08:00
46c8e819df 修改相关证据 2026-08-18 16:30:16 +08:00
247d7d9f6e 新增接口 2026-08-18 16:15:46 +08:00
d256f6d176 合并七月迭代分支 2026-08-18 14:53:29 +08:00
7029104e5c 让迁移套餐恢复月流量重置调度
缺少 next_reset_at 时,轮询根据已有激活时间或到期时间与套餐天数推算下一重置点;已有值通过查询条件和条件更新双重保护,不会被覆盖。

Constraint: 兼容迁移套餐缺少 activated_at 与 next_reset_at 的历史数据
Rejected: 单次 SQL 人工回填 | 后续迁移数据仍可能再次遗漏
Confidence: high
Scope-risk: narrow
Directive: 保持 next_reset_at 非空记录不可覆盖
Not-tested: 按用户要求未运行测试
2026-08-05 14:33:16 +08:00
a0de08d789 避免套餐过期后排队权益永久失联
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m6s
线上保持现有纯 Asynq 架构,以公平孤儿扫描和提交后投递消除永久饥饿及事务可见性竞态。

Constraint: 线上保持现有纯 Asynq 架构,不引入 Outbox、迁移或新任务基础设施。

Rejected: 事务内投递或扩大扫描 LIMIT | 无法消除竞态和永久饥饿。

Confidence: high

Scope-risk: narrow

Directive: 后续分支整合时按目标分支的套餐接续架构独立处理,不混用本热修实现。

Tested: go build ./...(退出码 0);git diff --check;openspec validate fix-main-package-activation-starvation --strict。

Not-tested: 按用户要求未新增、修改或运行自动化测试;线上 SQL、查询计划和日志待部署后核验。
2026-08-03 09:58:05 +08:00
1efb665619 fix: 修正排队顺延套餐激活时错误按下单时间计算生效日期
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m57s
activatePendingUsage 被"前一个主套餐到期后顺延激活下一个待生效套餐"和
"等待实名认证后激活"两种场景共用,但其中 ExpiryBase=from_purchase 计时
基准分支(REALNAME-04)本来只为后者设计,却被无差别套用到前者。

导致主套餐配置为 from_purchase 且需要排队等待前一个套餐到期才能生效的
套餐,激活时错误地把生效时间算成下单时间,而不是真正开始生效的那一刻,
使到期时间提前了排队等待的天数,客户少享受了相应天数的服务。

现改为只有当 usage.PendingRealnameActivation 为 true(确实是在等实名)
时才按 ExpiryBase 选择计时基准,纯排队顺延场景一律使用当前时刻,即顺延
语义。
2026-07-20 11:56:25 +09:00
567 changed files with 55295 additions and 19555 deletions

View File

@@ -3,9 +3,7 @@ name: 构建并部署到测试环境(无 SSH
on:
push:
branches:
- Iteration/7-11
- dev
- test
- Iteration/8-11
env:
REGISTRY: registry.boss160.cn
@@ -30,15 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/Iteration/7-11" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/test" ]; then
echo "tag=test" >> $GITHUB_OUTPUT
else
echo "tag=unknown" >> $GITHUB_OUTPUT
fi
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: 登录 Docker Registry
run: |
@@ -47,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: 部署到本地(仅 Iteration/7-11 分支)
if: github.ref == 'refs/heads/Iteration/7-11'
- 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'

2
.gitignore vendored
View File

@@ -111,6 +111,4 @@ scripts/batch_package_purchase/assets.example_购买结果_20260715_115804.csv
scripts/batch_package_purchase/assets.example_购买结果_20260715_115814.csv
scripts/migration/output
# LongHorizon 本地执行证据
.lh-harness/
.scratch/go-build-cache

File diff suppressed because it is too large Load Diff

1757
111.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,10 +38,10 @@
- 行为变化必须通过独立 OpenSpec Change主 Specs 只描述当前行为。
- 当前 Bug 与兼容行为按实际结果记录,禁止在基线任务中顺手修复。
- 不修改既有迁移;新 Schema 变化使用新的成对迁移。
- 不访问生产服务、真实支付渠道或外部审批系统进行自动验证。
- 生产环境为 systemd 管理的手工二进制发布,和仓库 Docker/CI 测试环境不同;生产发布、迁移与回滚事实见 [`docs/deployment/production-runbook.md`](docs/deployment/production-runbook.md)。Agent 不连接生产主机或数据库,生产操作由维护者执行并提供结果。
- 不访问真实支付渠道或外部审批系统进行自动验证。
- 生产环境为 systemd 管理的手工二进制发布,和仓库 Docker/CI 测试环境不同;生产发布、迁移与回滚事实见 [`docs/deployment/production-runbook.md`](docs/deployment/production-runbook.md)。Agent 可仅通过 dbhub 对生产数据库执行只读诊断查询;不连接生产主机,生产发布、迁移、回滚及其他写操作由维护者执行并提供结果,除非明确要求
- 不把密钥、Token、证书或个人敏感数据写入代码、文档和日志。
- `.lh-harness/` 仅保存本地执行证据,不是事实源且不得纳入 Git
- `docs/verification/context-reset/` 仅保存上下文健康检查证据,不是业务事实源;证据应随项目文档维护
- 自动化测试当前为 N/A用户决策不恢复旧测试也不写虚假测试入口。
## 架构选择
@@ -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

@@ -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: 运行阶段

1047
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
@@ -27,8 +28,16 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, 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

@@ -1,4 +1,4 @@
// Command audit-retention-simulate 在测试环境演练完整自然月归档与清理边界。
// Command audit-retention-simulate 在测试环境演练逐日归档与清理边界。
package main
import (
@@ -6,22 +6,21 @@ import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
auditarchive "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
taskapp "github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/database"
logpkg "github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
@@ -67,6 +66,11 @@ func run(ctx context.Context) error {
return fmt.Errorf("仅允许显式确认的测试数据库,当前数据库为 %q", cfg.Database.DBName)
}
logger := zap.NewNop()
if err := os.MkdirAll(filepath.Dir(cfg.Logging.RetentionLog.Filename), 0755); err != nil {
return err
}
retentionLogger, syncRetentionLogger := logpkg.NewRetentionLogger(cfg.Logging.Level, logpkg.LogRotationConfig{Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize, MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress})
defer func() { _ = syncRetentionLogger() }()
db, err := database.InitPostgreSQL(&cfg.Database, logger)
if err != nil {
return err
@@ -81,8 +85,7 @@ func run(ctx context.Context) error {
if err != nil {
return err
}
auditWriter := auditinfra.NewWriter(auditinfra.NewRegistry(), nil)
service, err := auditarchive.NewService(db, provider, simulationInstance, auditWriter)
service, err := auditarchive.NewService(db, provider, simulationInstance)
if err != nil {
return err
}
@@ -108,14 +111,17 @@ func run(ctx context.Context) error {
}
}
payload, _ := sonic.Marshal(taskapp.AuditMonthlyRetentionPayload{ArchiveMonth: simulationMonth})
dryRunTask := asynq.NewTask(constants.TaskTypeAuditMonthlyRetention, payload)
if err := taskapp.NewAuditMonthlyRetentionHandler(service, logger, false).Handle(ctx, dryRunTask); err != nil {
return fmt.Errorf("月度只读演练失败: %w", err)
}
dryRun, err := service.ValidateMonth(ctx, monthStart)
if err != nil {
return err
var dryRun auditarchive.RetentionResult
for date := monthStart; date.Before(monthEnd); date = date.AddDate(0, 0, 1) {
result, retainErr := service.RetainDate(ctx, date, false)
if retainErr != nil {
return fmt.Errorf("逐日只读演练失败: %w", retainErr)
}
retentionLogger.Info("日留存仿真只读校验通过", zap.String("archive_date", result.ArchiveDate))
dryRun.EventCount += result.EventCount
dryRun.ResourceCount += result.ResourceCount
dryRun.IntegrationCount += result.IntegrationCount
dryRun.EstimatedBatches += result.EstimatedBatches
}
summary, err := collectBeforeCleanup(ctx, db, monthStart, monthEnd, dryRun)
if err != nil {
@@ -125,16 +131,39 @@ func run(ctx context.Context) error {
return fmt.Errorf("只归档模式写入了 %d 个清理断点", summary.CleanupMarkersBefore)
}
cleanupTask := asynq.NewTask(constants.TaskTypeAuditMonthlyRetention, payload)
if err := taskapp.NewAuditMonthlyRetentionHandler(service, logger, true).Handle(ctx, cleanupTask); err != nil {
return fmt.Errorf("隔离测试库物理清理演练失败: %w", err)
for date := monthStart; date.Before(monthEnd); date = date.AddDate(0, 0, 1) {
result, retainErr := service.RetainDate(ctx, date, true)
if retainErr != nil {
return fmt.Errorf("隔离测试库逐日物理清理演练失败: %w", retainErr)
}
retentionLogger.Info("日留存仿真物理清理完成", zap.String("archive_date", result.ArchiveDate))
}
if err := collectAfterCleanup(ctx, db, monthStart, monthEnd, &summary); err != nil {
return err
}
if summary.TargetRowsAfterCleanup != 0 || summary.BoundaryRowsAfterCleanup != 6 || summary.CleanupMarkersAfter != int64(summary.Days*2) || !summary.RetentionAuditRecorded {
return fmt.Errorf("清理范围复核失败: target=%d boundary=%d markers=%d audit=%t",
summary.TargetRowsAfterCleanup, summary.BoundaryRowsAfterCleanup, summary.CleanupMarkersAfter, summary.RetentionAuditRecorded)
if summary.TargetRowsAfterCleanup != 1 || summary.BoundaryRowsAfterCleanup != 6 || summary.CleanupMarkersAfter != int64(summary.Days*2) {
return fmt.Errorf("清理范围复核失败: target=%d boundary=%d markers=%d", summary.TargetRowsAfterCleanup, summary.BoundaryRowsAfterCleanup, summary.CleanupMarkersAfter)
}
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("integration_id = ?", "int_"+simulationPrefix+"-"+monthStart.Format("20060102")).
Updates(map[string]any{"result": constants.IntegrationResultSuccess, "updated_at": time.Now()}).Error; err != nil {
return err
}
if _, err := service.RetainDate(ctx, monthStart, true); err != nil {
return fmt.Errorf("pending 终结后的续跑清理演练失败: %w", err)
}
var remaining int64
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).Where("created_at >= ? AND created_at < ?", monthStart, monthEnd).Count(&remaining).Error; err != nil {
return err
}
if remaining != 0 {
return fmt.Errorf("pending 终结后的续跑清理未完成,剩余 %d 条记录", remaining)
}
if err := syncRetentionLogger(); err != nil {
return fmt.Errorf("刷新日留存仿真日志失败: %w", err)
}
if _, err := os.Stat(cfg.Logging.RetentionLog.Filename); err != nil {
return fmt.Errorf("独立日留存日志未生成: %w", err)
}
encoded, _ := sonic.MarshalIndent(summary, "", " ")
fmt.Println(string(encoded))
@@ -256,12 +285,7 @@ func archiveMonth(ctx context.Context, db *gorm.DB, service *auditarchive.Servic
if err := service.ArchiveDate(ctx, start); err != nil {
return fmt.Errorf("Audit 故障重试演练失败: %w", err)
}
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("integration_id = ?", "int_"+simulationPrefix+"-"+start.Format("20060102")).
Updates(map[string]any{"result": constants.IntegrationResultSuccess, "updated_at": time.Now()}).Error; err != nil {
return err
}
return service.FinalizeIntegrationMonth(ctx, start)
return nil
}
func collectBeforeCleanup(ctx context.Context, db *gorm.DB, start, end time.Time, dryRun auditarchive.RetentionResult) (simulationSummary, error) {
@@ -311,10 +335,5 @@ func collectAfterCleanup(ctx context.Context, db *gorm.DB, start, end time.Time,
Count(&summary.CleanupMarkersAfter).Error; err != nil {
return err
}
var auditCount int64
if err := db.WithContext(ctx).Model(&model.AuditEvent{}).Where("event_id = ?", "evt_retention_"+strings.ReplaceAll(simulationMonth, "-", "_")).Count(&auditCount).Error; err != nil {
return err
}
summary.RetentionAuditRecorded = auditCount == 1
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
@@ -36,8 +37,16 @@ func generateAdminDocs(outputPath string) error {
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, 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() {
@@ -122,6 +129,11 @@ func runWorker(cfg *config.Config) {
defer func() {
_ = logger.Sync() // 忽略 sync 错误
}()
retentionLogger, syncRetentionLogger := logger.NewRetentionLogger(cfg.Logging.Level, logger.LogRotationConfig{
Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize,
MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress,
})
defer func() { _ = syncRetentionLogger() }()
appLogger := logger.GetAppLogger()
ctx, cancel := context.WithCancel(context.Background())
@@ -148,7 +160,9 @@ func runWorker(cfg *config.Config) {
taskHandler.RegisterHandlers()
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerRefundCommissionRecoveryTask(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)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
@@ -281,6 +295,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingIotCardStore,
appLogger,
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
@@ -370,7 +385,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))
}
@@ -384,8 +415,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.ApprovalBusinessTypeRefund: refundService,
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,
@@ -442,8 +488,10 @@ 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),
confirm,
runtime.workerResult.Services.PaymentAudit,
)
@@ -452,6 +500,29 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
appLogger.Info("注册代理在线充值支付恢复任务处理器", zap.String("task_type", constants.TaskTypeAgentRechargeRecovery))
}
// registerRefundCommissionRecoveryTask 注册退款佣金回溯后处理的周期性补偿任务。
// 该任务只重投稳定的退款后处理 Outbox 事件,绝不直接改动资金;重复执行由消费端幂等兜底。
func registerRefundCommissionRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.db == nil {
appLogger.Fatal("退款佣金回溯补偿任务缺少数据库依赖")
}
handler := commissionDelivery.NewRefundRecoveryTaskHandler(runtime.db, outbox.NewRepository(), appLogger)
mux.HandleFunc(constants.TaskTypeRefundCommissionRecovery, handler.Handle)
appLogger.Info("注册退款佣金回溯补偿任务处理器", zap.String("task_type", constants.TaskTypeRefundCommissionRecovery))
}
// 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)
@@ -687,7 +758,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
&asynq.SchedulerOpts{Location: time.Local},
)
if err := registerAsynqScheduleTasks(asynqScheduler); err != nil {
if err := registerAsynqScheduleTasks(asynqScheduler, cfg.Worker.AuditArchiveTasksEnabled); err != nil {
appLogger.Fatal("注册 Asynq 定时任务失败", zap.Error(err))
}
@@ -698,12 +769,14 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
}()
appLogger.Info("Asynq Scheduler 已启动",
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled))
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled),
zap.Bool("audit_archive_tasks_enabled", cfg.Worker.AuditArchiveTasksEnabled),
zap.Int("polling_total_max_concurrency", cfg.Worker.PollingTotalMaxConcurrency))
return asynqScheduler
}
// registerAsynqScheduleTasks 注册 Worker 入口需要的全部定时任务。
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEnabled bool) error {
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeAgentRechargeRecovery,
nil,
@@ -714,6 +787,26 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); 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.TaskTypeRefundCommissionRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundCommissionRecovery)),
)); err != nil {
return fmt.Errorf("注册退款佣金回溯补偿定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeOrderExpire,
nil,
@@ -773,6 +866,9 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
); err != nil {
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
}
if !auditArchiveEnabled {
return nil
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 4 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyArchive,
nil,
@@ -793,59 +889,46 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil {
return fmt.Errorf("注册 Integration Log 每日冷归档定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 1 * *", asynq.NewTask(
constants.TaskTypeIntegrationMonthlyFinalize,
nil,
asynq.MaxRetry(10),
asynq.Timeout(6*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationMonthlyFinalize)),
)); err != nil {
return fmt.Errorf("注册 Integration Log 月度最终版本复核任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 1 * *", asynq.NewTask(
constants.TaskTypeAuditMonthlyRetention,
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyRetention,
nil,
asynq.MaxRetry(10),
asynq.Timeout(12*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditMonthlyRetention)),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditDailyRetention)),
)); err != nil {
return fmt.Errorf("注册月度日志留存演练或清理任务失败: %w", err)
return fmt.Errorf("注册日志留存演练或清理任务失败: %w", err)
}
return nil
}
// registerAuditArchiveTask 注册 Audit 与 Integration 冷归档任务处理器。
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled bool, appLogger *zap.Logger) {
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled, enabled bool, appLogger, retentionLogger *zap.Logger) {
if runtime.storageSvc == nil {
appLogger.Warn("对象存储未配置,审计归档任务将在执行时重试")
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger)
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger, enabled).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger, enabled)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(nil, appLogger, cleanupEnabled).Handle)
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(nil, retentionLogger, cleanupEnabled, enabled).Handle)
return
}
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
if !ok || auditWriter == nil {
appLogger.Fatal("初始化月度日志留存清理失败:统一审计 Writer 未配置")
appLogger.Fatal("初始化日志留存清理失败:统一审计 Writer 未配置")
}
service, err := auditArchiveApp.NewService(runtime.db, runtime.storageSvc.Provider(), constants.AuditArchiveInstanceID, auditWriter)
if err != nil {
appLogger.Fatal("初始化统一审计归档服务失败", zap.Error(err))
}
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger)
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger, enabled).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger, enabled)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(service, appLogger, cleanupEnabled).Handle)
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(service, retentionLogger, cleanupEnabled, enabled).Handle)
appLogger.Info("注册审计归档任务处理器",
zap.String("audit_task_type", constants.TaskTypeAuditDailyArchive),
zap.String("integration_daily_task_type", constants.TaskTypeIntegrationDailyArchive),
zap.String("integration_monthly_task_type", constants.TaskTypeIntegrationMonthlyFinalize),
zap.String("retention_task_type", constants.TaskTypeAuditMonthlyRetention),
zap.Bool("retention_cleanup_enabled", cleanupEnabled))
zap.String("retention_task_type", constants.TaskTypeAuditDailyRetention),
zap.Bool("retention_cleanup_enabled", cleanupEnabled), zap.Bool("archive_tasks_enabled", enabled))
}
// createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。
@@ -869,6 +952,44 @@ func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Han
func rescuePendingImportTasks(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
rescuePendingIotCardImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
rescuePendingDeviceImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
rescuePendingShopBusinessOwnerImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
rescuePendingPhoneAssetUnbindImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
}
// rescuePendingShopBusinessOwnerImportTasks 补偿仍停留在待处理状态的店铺负责人导入任务。
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与设备导入补偿互不影响。
func rescuePendingShopBusinessOwnerImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
var importTasks []model.ShopBusinessOwnerImportTask
if err := db.WithContext(ctx).
Where("status = ?", model.ImportTaskStatusPending).
Limit(importRescueLimit).
Find(&importTasks).Error; err != nil {
appLogger.Warn("扫描待补偿店铺负责人导入任务失败", zap.Error(err))
return
}
for _, importTask := range importTasks {
payload := task.ShopBusinessOwnerImportPayload{TaskID: importTask.ID}
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypeShopBusinessOwnerImport, payload, importTask.ID, appLogger)
}
}
// rescuePendingPhoneAssetUnbindImportTasks 补偿仍停留在待处理状态的手机号资产解绑导入任务。
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与其他导入补偿互不影响。
func rescuePendingPhoneAssetUnbindImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
var importTasks []model.PhoneAssetUnbindImportTask
if err := db.WithContext(ctx).
Where("status = ?", model.ImportTaskStatusPending).
Limit(importRescueLimit).
Find(&importTasks).Error; err != nil {
appLogger.Warn("扫描待补偿手机号资产解绑导入任务失败", zap.Error(err))
return
}
for _, importTask := range importTasks {
payload := task.PhoneAssetUnbindImportPayload{TaskID: importTask.ID}
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypePhoneAssetUnbindImport, payload, importTask.ID, appLogger)
}
}
// rescuePendingIotCardImportTasks 补偿仍停留在待处理状态的 IoT 卡导入任务。

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

@@ -8,7 +8,7 @@
- Owner生产维护者。
- 最后核验2026-08-13基于维护者提供的信息未连接生产环境
- 更新触发Unit、目录、进程数、Worker 角色、发布顺序、迁移方式或回滚方式变化。
- 验证:由维护者在服务器执行本文列出的只读核对命令,并回填结果Agent 不连接生产环境
- 验证:Agent 可通过 dbhub 对生产数据库执行只读诊断查询;服务器上的只读核对、生产发布、迁移、回滚及其他写操作由维护者执行并回填结果Agent 不连接生产主机
## 与测试环境的边界
@@ -52,7 +52,7 @@ GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/worker ./cmd/worker
- 二进制只从嵌入默认配置和 `JUNHONG_` 环境变量读取;`.env.prod` 必须由 systemd Unit 显式加载,或由 Unit 启动脚本 `source` 后启动。需要以 Unit 内容核实实际方式。
- API 与 Worker 共享数据库、Redis、日志、JWT、对象存储、Gateway、短信、支付等基础配置只记录键名不将实际凭据写入仓库文档。
- 七月新增的 API/Worker 共用配置:`JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED``JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED``JUNHONG_WECOM_BASE_URL``JUNHONG_WECOM_TIMEOUT`
- 七月新增的 Worker 配置:`JUNHONG_WORKER_ROLE``JUNHONG_WORKER_INSTANCE_NAME``JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED`
- 本次新增的 Worker 配置:`JUNHONG_WORKER_ROLE``JUNHONG_WORKER_INSTANCE_NAME``JUNHONG_WORKER_POLLING_TOTAL_MAX_CONCURRENCY``JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED``JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED`。轮询总并发必须保持在 1-1000初始生产值建议 100归档/留存总开关默认关闭
- 首发要求:退款人工入口、线下充值人工确认、企微审批、运营商实名回调、微信/支付宝在线充值均按维护者决定启用;审计物理清理保持关闭。
- 企微应用凭据由后台配置写入数据库明文字段;这不是启动环境变量。本文不记录其值。
@@ -74,6 +74,35 @@ 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`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:
1. 退款单 `RF20260820170954264700` 已通过,关联订单支付状态为已退款。
2. 该退款没有代理主钱包或资产钱包的零金额回款流水。
### 锁的含义与发布影响
`000171` 会对 `tb_agent_wallet``000178` 会对 `tb_iot_card` 使用 PostgreSQL `ACCESS EXCLUSIVE` 锁。该锁执行期间会阻塞该表的读写及其他 DDL直到迁移事务提交或回滚若有未结束业务查询/事务,它也会等待。因此必须在 API 和全部 Worker 停止后执行,并在迁移前检查没有长事务。锁持续时间取决于表数据量、索引创建和等待中的旧事务;不能从仓库估算具体秒数。
@@ -88,3 +117,12 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
## 已确认数据库备份
每日凌晨 02:00 自动备份 `junhong_cmp_prod`:数据库运行在 Docker 容器 `postgres` 中,备份脚本执行 `pg_dump -Fc -Z 6`,写入 `/data/backups/postgresql/<库名>_<时间>.dump`,同时生成 MD5 文件并以 `pg_restore --list` 校验结构;保留 30 天。发布前仍须人工新建一次备份并确认校验通过,不能只依赖凌晨的最近备份。恢复命令待维护者实际演练或确认后补充。
## 日审计日志留存启用与恢复
归档与日留存由调度 Worker 受控处理 Asia/Shanghai 的已结束自然日;`JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 时不注册调度,已入队任务也安全跳过、不扫描在线日志表。开启后每次最多推进一个日期。`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=false` 时仅验证归档、对象、manifest、数据库数量和日期连续性不写清理断点、不删除在线数据。Integration 的 `pending` 记录保留在线但不会阻断同日终态日志、Audit 数据或后续日期的清理;若 pending 后续终结Worker 会重建该日 revision 后续跑删除。
1. 发布新 Worker 后保持 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 和物理清理开关关闭;轮询总并发先设为 100。维护者先核对 `tb_log_archive_run` 中 Audit 与 Integration 两个来源从历史最早在线日期起没有缺失账本;缺失日期必须先受控补归档,不能跳过失败日。
2. 以关闭开关的 Worker 完成只读演练,观察 `/opt/junhong_cmp/worker/logs/audit-retention.log`:每个日期应有来源计数和耗时;若出现日期、来源、失败分类或安全错误摘要,先修复该来源的归档或校验问题后再演练。仅有 pending 不需要人工终结才可继续。
3. 低峰期先将调度 Worker 的 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=true`,重启该 Worker确认单日归档稳定后再将 `JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=true`,并持续观察上述独立日志、`tb_log_archive_run.cleanup_started_at` / `cleaned_at` 断点及表大小。DELETE 释放的 PostgreSQL 页面会供后续写入复用,表文件不会立即缩小;维护者按既有运维窗口评估 VACUUMWorker 不执行 VACUUM 或表重写。
4. 异常时立即将开关改回 `false` 并重启调度 Worker。已清理日期按已验证的对象和 manifest 执行归档恢复;尚未清理或阻断的日期仍保留在线,无需数据库恢复。恢复后先重新执行只读演练,再决定是否重新开启清理。

View File

@@ -41,6 +41,19 @@
- **最后验证日期**2026-08-07
- **更新触发条件**:错误系统或 ErrorHandler 变化
## ENG-ERR-002
- **状态**:生效
- **适用范围**:请求 DTO 中来自 URL 路径的字段,以及 Handler 的参数校验失败响应
- **规则**:路径来源字段 MUST 在 Handler 内由 `c.Params` 解析后回填,再执行 `validator.Struct`DTO MUST NOT 依赖 `validate:"required"` 覆盖路径字段而不回填。新增或修改的参数校验点 MUST 让校验失败返回 1001 且在 `msg` 中说明首个失败字段与规则,字段名取自该字段的中文 `description`;未触碰的既有 Handler 的通用提示按 As-Is 保留。
- **理由**`json:"-"` 的路径字段不参与 Body/Query 绑定,不回填则 `required` 恒失败,接口对任何合法请求都返回“参数不合法”,且原提示不指出字段,无法定位。
- **最小正例**`shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)``req.ShopID = uint(shopID)``validator.Struct(&req)`;失败时 `errors.New(errors.CodeInvalidParam, validationMessage("提现资料资格参数不合法", &req, err))` 产出“提现资料资格参数不合法:合同附件对象存储 Key 不能为空”。
- **最小反例**`c.BodyParser(&req)` 后直接 `validator.Struct(&req)` 并返回无字段信息的“XX参数不合法”。
- **机械检查/人工原因**:对每个被 `validator.Struct` 校验的 DTO核对携带 `path:"..."``validate``required` 的字段是否在调用点赋值;`go build ./cmd/api`。全仓同类 DTO 中存在未被校验的路径字段,不能只靠 grep 判定违规。
- **例外条件**:路径字段不带 `validate:"required"` 且调用方显式回填的 DTO 不受本规则约束;未纳入本次触碰范围的 Handler 通用提示不要求整改。
- **Owner**API 负责人
- **最后验证日期**2026-09-14
- **更新触发条件**DTO 绑定方式、校验消息约定或请求绑定工具变化
## ENG-RESP-001
- **状态**:生效
- **适用范围**HTTP Handler
@@ -244,9 +257,9 @@
- **最小正例**:事务写事实和 Audit Writer提交后由 Outbox 外发。
- **最小反例**:事务中等待第三方网络后再提交。
- **机械检查/人工原因**:逐用例人工核对 Transaction 闭包、Audit Writer 和外部调用位置。
- **例外条件**:业务回滚后的 failed/denied 审计使用独立短事务
- **例外条件**:业务回滚后的 failed/denied 审计,以及随之记录的回滚后失败状态事实,使用独立短事务;该短事务 MUST NOT 与已回滚的主事务共用连接或事务,且 MUST 以业务单仍处于允许该失败事实的状态为条件更新
- **Owner**:架构与审计负责人
- **最后验证日期**2026-08-07
- **最后验证日期**2026-09-14
- **更新触发条件**:高风险写或外部调用变化
## ENG-AUDIT-001
@@ -300,3 +313,28 @@
- **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、部署分支、测试主机或验证授权变化
## KNOWN-ISSUE-001
- **状态**:已知缺陷,待修复(当前不阻塞归档;标签功能未启用时无实际影响)
- **适用范围**`internal/service/exchange/migration.go` 的标签复制步骤(换货业务数据迁移的「资产标签」迁移项)
- **问题**:标签复制使用 `clause.OnConflict{Columns: [resource_type, resource_id, tag_id], DoNothing: true}`,未声明 `tb_resource_tag` 上部分唯一索引 `idx_resource_tag_unique``... WHERE deleted_at IS NULL`的谓词PostgreSQL 返回 `42P10`
- **理由**:旧资产存在任意 `tb_resource_tag` 行且换货请求要求迁移时,标签步骤必然失败,导致换货完成整体回滚、迁移状态落 `failed`,「已迁移」在该情形不可达。记录于此以便由独立变更修复,避免在其它任务中顺手改动迁移项。
- **证据**2026-09-14 在 `junhong_cmp_test``tb_audit_event` 实测 4 条 `action_code=exchange.card.complete``result=failed``error_code=1206``error_summary``复制资产标签失败 ... SQLSTATE 42P10``tb_resource_tag` 当前 0 行,故静态库状态下不可观测。该文件自 `add-exchange-data-migration-status` 起未修改md5 与 `git show HEAD` 一致)。
- **例外条件**:标签功能未启用(`tb_resource_tag` 为空)时无实际影响;不影响钱包余额、有效套餐使用记录、累计充值字段、资产归属与个人客户—资产绑定,也不影响无标签资产的换货完成。
- **修复方式**:为该 `OnConflict` 声明部分索引谓词(或调整索引),须另立 OpenSpec Change当前按维护者决策暂不修复仅登记待办。
- **Owner**:数据负责人
- **最后验证日期**2026-09-14
- **更新触发条件**:标签功能启用、换货迁移项变更或该缺陷修复

View File

@@ -0,0 +1,44 @@
# Main 分支套餐接续饥饿热修
## 修复边界
本热修仅适用于 `main` 的纯 Asynq 套餐接续链路,不引入 Outbox、数据库迁移、新任务类型或新依赖。
- 孤儿扫描先在 PostgreSQL 中按卡或设备选择队首套餐,并排除仍有 `status IN (1,2)` 占位主套餐的载体,最后取 100 个真实孤儿。
- 旧主套餐及加油包状态事务提交后,再投递现有 `package:queue:activation` 任务。
- Redis 激活锁冲突返回套餐激活冲突错误,由现有 `MaxRetry(3)` 重试。
- 只有套餐实际从待生效推进为生效中时Handler 才记录“套餐激活成功”。
## 部署观察
部署 Worker 后至少观察两个套餐轮询周期:
1. `孤儿套餐扫描完成``orphan_count` 应能覆盖真实无占位套餐,不再固定被同一批占位载体挡住。
2. `已提交套餐激活任务` 后应出现实际激活、明确跳过或可重试错误,不再出现未改状态却打印成功。
3. Redis 锁冲突应进入 Asynq 重试,不应确认任务成功。
可使用以下只读 SQL 检查仍未恢复的真实孤儿数量:
```sql
SELECT COUNT(*) AS orphan_pending_count
FROM tb_package_usage AS pending
WHERE pending.status = 0
AND pending.master_usage_id IS NULL
AND pending.deleted_at IS NULL
AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0)
AND NOT EXISTS (
SELECT 1
FROM tb_package_usage AS occupied
WHERE occupied.status IN (1, 2)
AND occupied.master_usage_id IS NULL
AND occupied.deleted_at IS NULL
AND (
(COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id)
OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id)
)
);
```
## 回滚
本次无数据库迁移。回滚热修提交并重新部署 Worker 即可;已经正确激活的套餐属于有效业务事实,不执行反向 SQL。

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

@@ -0,0 +1,224 @@
# 新卡管 2026 年 8 月迭代 PRD讨论稿
> 状态:讨论中
>
> 本文是本轮需求澄清的唯一决策记录。确认后的需求将重编稳定需求 ID每项实施前再以独立 OpenSpec Change 固化可观察行为、任务和验收。
>
> 来源:业务提供的《新卡管 8 月迭代需求》2026-08-11。
## 0. 已确认的产品边界
1. 本文覆盖一个需求池,不承诺整体一次上线。完成完整 PRD 后,按影响范围由小到大拆分为独立 Change 实施。
2. 原文编号存在重复和漂移。讨论稿将建立新的稳定需求 ID原编号仅用于追溯来源。
3. 审批类业务必须接入企业微信。企业微信是唯一终审来源;审批节点、审批人、条件和流转规则完全由企业微信模板配置,本系统不读取、校验或固化这些规则。系统只保存本地审批实例、业务状态、审计和幂等闭环,并按企业微信最终通过或驳回结果推进业务;不提供本地人工通过或拒绝来绕过企业微信。企业微信回调延迟、提交失败或结果未知时,使用既有兜底轮询查询渠道状态并同步本地实例。所有审批业务均须将企业微信审批单号返回至卡管并在详情展示;详情仅展示本地或企业微信实际可取得的字段,不展示无法返回、无值或无可靠来源的字段。
4. OCR 是已有的外部能力但本期仅作为交易流水号的预填来源。申请人或审核人必须能更正并最终确认OCR 识别结果不是资金事实。OCR 协议、字段能力、失败和重试规则等待外部契约文档。
## 0.1 稳定需求 ID 与原文映射
| 稳定 ID | 能力 | 原文来源 |
| --- | --- | --- |
| AUG26-001 | 员工代收款账单与核销 | PRD-08-001、PRD-08-013核销字段 |
| AUG26-002 | 支付商户池与微信授权配置 | PRD-08-002 |
| AUG26-003 | 店铺业务员与业务用户组 | PRD-08-004、PRD-08-005 |
| AUG26-004 | 套餐真流量预警 | PRD-08-006 |
| AUG26-005 | 换货业务数据迁移展示 | PRD-08-007 |
| AUG26-006 | 套餐退款与原路退款 | PRD-08-008、PRD-08-013退款字段 |
| AUG26-007 | H5 风险换卡与运营通知 | PRD-08-009 |
| AUG26-008 | 代理分销码、资料资格与佣金提现 | PRD-08-010、PRD-08-013提现/分销审批字段) |
| AUG26-009 | 手机号—资产关联 | PRD-08-011 |
| AUG26-010 | 自动续费 | PRD-08-012 |
| AUG26-011 | 运营商通道流量阈值 | PRD-08-017 |
| AUG26-012 | 佣金回溯明细 | PRD-08-015 |
| AUG26-013 | 资产套餐层级展示 | PRD-08-016资产详情 |
| AUG26-014 | 导出与统一时间筛选 | PRD-08-014、PRD-08-020 |
| AUG26-015 | 报表管理 | PRD-08-016报表原编号重复 |
| AUG26-016 | 优先轮询通道 | PRD-08-019 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021、PRD-08-013预存款审批字段 |
## 1. 已确认的领域语言
### 1.1 员工代收款账单
员工代客户购买套餐、充值等业务时,系统按来源业务生成的待核销记录。它表示员工经办业务形成的暂挂欠款;员工提交客户付款凭证,企业微信审批人员可通过该凭证在外部第三方收款记录中核验支付,且企业微信审批通过后,欠款相应减少或结清。客户款项可支付至公司微信、银行卡或其他由业务维护并认可的线下收款方式,不要求必须支付给员工或由员工另行回款。
代理线下预存款/主钱包充值仍按既有企业微信审批决定是否入账;该审批通过并完成入账后,系统再创建相应员工代收款账单,使员工欠款增加。后续账单核销是独立于充值审批的业务流程。
员工代收款账单的欠款人固定为实际发起线下套餐订单或线下代理充值的后台账号,创建后不可修改;平台业务员和超级管理员实际经办时均生成本人账单。员工离职或账号禁用后,其欠款人身份不变;仅超级管理员可代办创建、修改或重新提交核销申请,且必须记录实际代办人及代办原因。
已确认:支持一笔外部支付记录分摊核销多张账单,也支持一张账单由多笔支付记录分次核销。员工应按一笔外部付款创建一张核销申请,在申请中上传该笔付款的凭证、填写交易流水号,并选择多张账单及各自分摊金额;一张申请只产生一个企业微信审批实例。每笔分摊必须独立保留账单、外部支付记录标识、本次核销金额、凭证、审批实例和剩余未核销金额。
员工代收款账单的核销状态独立于核销申请审批状态:账单状态为待核销、部分核销、已核销或已关闭;申请状态为审批中、已通过、已驳回、已撤销/已关闭。只有企业微信审批通过的分摊才减少员工欠款。账单列表需同时展示账单核销状态和是否存在审批中申请,避免部分核销与审批中互相覆盖。
企业微信最终驳回的核销申请允许员工在原申请上修改后重新提交;系统为同一申请保留每一次企业微信审批实例及其当次业务快照,历史材料和审批结果不得被覆盖。已驳回申请可修改全部申请内容:收款账户、外部付款信息、附件、备注、勾选账单和分摊金额;已通过的分摊不可修改。
超级管理员可关闭待核销、已驳回或部分核销账单,关闭即作废当时未核销余额且不再计入员工欠款;已核销金额保留。关闭必须填写原因、保留操作审计,且存在审批中核销申请时不得关闭。
已确认:员工代收款账单按业务场景确定公司应收金额。
- 代理代购套餐:取订单 `actual_paid_amount`(代理实际结算/成本金额。对会生成员工代收款账单的后台线下套餐订单创建订单时不再强制上传付款凭证订单仍按当前规则立即激活并同时创建待核销账单。付款凭证、OCR 与外部交易流水号只在后续核销申请提交。赠送套餐等不产生员工账单的线下订单继续保持创建时上传凭证的当前规则。
- 无代理归属的自营 C 端套餐购买:取订单 `actual_paid_amount`。当前实现该值等于 `total_amount`;未来优惠券等价格优惠也应使其表示实际收款金额。
- 代理线下充值预存款/主钱包:既有充值审批通过并入账后创建员工代收款账单,金额取 `tb_agent_recharge_record.amount`
- 不支持平台代理 C 端客户充值资产钱包;原需求中对此类场景不纳入本期。
- “其他”来源第一版不支持手工创建,必须先定义可追溯来源单和金额口径。
> 待决:`actual_paid_amount` 在未来优惠场景的写入时机与权威性;外部支付记录在本地的最小留存字段、收款账户目录的范围和退款时的冲销规则。
## 2. 当前发现的需求结构问题
| 问题 | 处理原则 |
| --- | --- |
| `PRD-08-013``014``015``016` 在范围表与正文含义不一致 | 后续以新稳定 ID 重编,保留来源编号。 |
| 审批字段在第 17 节集中出现,但相关主需求中存在不同口径 | 先定义每个审批业务的业务单、状态、资金生效时点和企业微信模板,再定义页面字段。 |
| OCR 被写入字段说明但缺少外部契约 | OCR 仅预填,待提供契约后再定义使用范围。 |
| 收款方式被写为固定示例(如某人微信/支付宝) | 收款方式由业务字典维护,不得将名称硬编码为支付方式枚举;是否还需独立收款账户目录待确认。 |
| 多处要求“字典维护”,但当前系统没有业务字典 | 新建通用业务字典模块;字典分类由研发固定注册,业务只维护分类下的字典项。已被业务单引用的字典项不得物理删除,只能停用并保留历史名称快照。 |
| 自动续费含“有余额自动续费,不停机” | 必须先区分续费订单、钱包扣款、运营商停复机和轮询同步,不能将它们描述为同一动作。 |
| 运营商通道阈值、套餐流量预警都使用“阈值” | 前者是通道控制规则,后者是套餐预警规则;二者的流量口径、周期和触发后果必须独立定义。 |
## 2.1 已确认的外部付款分摊边界
1. 同一笔外部付款可覆盖多张员工代收款账单,也可由多个员工的多张核销申请引用。
2. 本期不对同一外部付款在多张申请中的累计分摊金额做系统防重或金额上限校验;企业微信审批人员以外部第三方记录为准核验。
3. 系统仍须逐核销申请保存所选线下收款方式、交易流水号、付款金额、付款方、付款时间、支付凭证、其他凭证、备注及分摊明细,供超级管理员和企业微信审批追溯。交易流水号和付款金额可由 OCR 预填,但必须允许人工确认或更正。
4. 员工通过勾选账单创建分摊明细,不手填账单编号、订单、客户或资产字段;系统自动带出这些只读信息。系统按账单产生时间由早至晚,使用本次外部付款金额自动填充分摊,最后一张填剩余金额;员工可以修改本次核销金额。对同一账单的审批中分摊应预占可核销余额,避免并发申请超额核销。
5. 本期只做一套固定分类的线下收款方式字典。超级管理员维护字典项名称、稳定编码、排序、启停、备注;核销申请和预存款审批选择字典项并冻结名称快照。不额外建设收款账户目录。
## 2.2 已确认的历史数据边界
1. 员工代收款账单仅对功能上线后新创建的符合条件线下套餐订单、功能上线后审批通过并入账的线下代理预存款/主钱包充值自动生成。
2. 不回填、不补建任何上线前历史业务;上线前业务不纳入员工账单和欠款统计。
## 2.6 已确认的商户池覆盖范围
1. C 端套餐购买、C 端资产钱包充值、代理在线预存款充值三类当前线上收款入口,均应按支付方式通过商户池选择实际收款商户;后台钱包/线下套餐订单不经过商户池。
2. 平台范围内每种支付方式最多一个启用商户池;可保留停用历史池,但不得同时启用多个相同支付方式的商户池。未来需要多池时,必须先定义订单/店铺/资产等路由维度。
3. 新建独立商户管理模块,收款凭证与商户支付能力从现有综合支付配置中分离,由商户池选择商户;不复用现有支付配置记录作为商户。一个商户仅对应一种支付方式:微信商户和支付宝商户分别建档、分别加入对应商户池。
4. 新建独立微信授权配置,平台范围内最多一个启用配置,保存 C 端公众号 H5 登录/JSSDK 和小程序登录所需参数C 端微信登录、AppID、JSSDK 与 OpenID 只使用该全局配置。微信商户仅保存微信支付或富友收款凭证;商户池命中的微信商户统一使用全局授权配置的 AppID 发起支付,微信侧 AppID 与商户的绑定/授权由业务保证。微信直连商户和富友商户都属于微信支付商户,可混合加入唯一启用的微信商户池;系统按命中商户自身服务商凭证执行支付、回调验签和退款。
5. 商户停用后仅对新支付单自动跳过;已命中该商户的历史支付单不换商户,支付回调、状态查询及后续原路退款仍使用该商户凭证。被历史支付单引用的商户不得物理删除,只能停用。商户原路退款能力不提供人工开关,系统仅按服务商类型及退款所需凭证是否完整判定;服务商实现不支持退款的商户固定视为不支持。商户池没有可用商户或商户池停用时,创建支付单失败且不得回退到旧全局支付配置。
6. 上线时从当前生效综合支付配置一次性自动迁移:创建一条全局微信授权配置、具备完整凭证的微信/支付宝商户及各自包含一个商户的启用商户池。新订单仅走商户池;旧综合支付配置和历史订单引用保留,仅服务历史回调、查询和退款,不自动改写。缺少完整凭证的支付方式不创建商户池,新支付单按暂无可用商户失败。迁移仅在数据库内复制密钥/证书,不得在日志、审计详情或接口响应暴露敏感值。
7. 商户保存名称、支付方式、服务商类型、商户号/应用标识、敏感凭证、状态和备注。每笔新支付单保存实际商户 ID 并冻结名称、支付方式、服务商类型及商户号/应用标识快照,不复制敏感凭证。被支付单引用后,支付方式、服务商类型和商户号/应用标识不得修改;名称、备注、状态及凭证可更新。历史支付单和退款单优先展示快照,回调和退款读取商户当前有效凭证。
8. 超级管理员和平台用户均可创建、修改、启用、停用商户、商户池和微信授权配置;其他角色没有管理入口。为使配置人员可核对完整配置,管理 API 的列表和详情向上述已认证角色返回商户及微信授权配置的完整密钥、证书和私钥字段,不做脱敏;不得将这些字段写入日志、审计快照、错误信息或普通业务单据。未被任何支付单命中的商户,超级管理员或平台用户可先将其移出商户池后经二次确认删除;删除审计不得含敏感凭证。被支付单引用的商户一律不得删除。
9. 每笔通过商户池创建的支付单,除实际商户 ID 和商户身份快照外,还须保存商户池 ID、商户池名称快照和轮询方式快照不另建逐次支付路由日志表。
10. 金额阈值、笔数阈值和时间周期均为商户池统一配置,池内所有商户共用,不支持成员单独配置。
6. 金额/笔数轮询创建池时必须配置统计周期,支持每轮累计、自然日累计、自然月累计。每轮累计在商户重新被轮到时清零;自然日/自然月累计在周期边界重置,达到阈值的商户在当期自动跳过。当前周期所有商户都达到阈值时,创建支付单失败并提示当前周期暂无可用商户。
7. 时间轮询创建池时必须配置周期数值、分钟/小时/天单位和起始时间。自起始时间起按固定周期、商户列表顺序轮换;进入新时段时仅在有新支付单时选择并记录商户。当前商户停用时即时跳到下一个可用商户,时间段不重置;修改时间周期、起始时间或商户顺序后,保存成功时间成为新起点并从列表第一项重新计算。时间周期最小为 1 分钟。
8. 金额/笔数轮询仅修改阈值时保留当前统计周期内的成功收款累计并立即按新阈值判断;修改统计周期或在金额/笔数两种方式间切换时,保存成功即开始新统计周期、所有商户从零累计。新增商户从零开始;移除/停用商户不再参与新订单选择。调整商户排序时,自然日/自然月累计保留未移除商户的当期累计;每轮累计从新列表第一项开启新一轮、所有商户从零累计。
9. 商户预下单失败时不自动切换商户或重试;任一失败按当前错误处理返回,客户再次发起支付时重新按商户池选择。失败订单不计入金额/笔数成功累计。
10. 金额/笔数轮询严格仅统计支付成功结果,不在预下单时预占商户额度或笔数;并发预下单可同时命中当前商户,已创建支付单不因后续轮询切换而改挂商户。
11. 支付成功后发生的全额或部分退款不回冲商户池金额/笔数成功累计;退款是独立后续资金动作。
## 2.3 已确认的退款完成规则
1. 客户提供收款信息的退款以企业微信最终通过为退款完成时点;系统不感知、也不以外部线下实际打款、交易流水号或付款凭证作为退款状态条件。企业微信审批通过即标记已退款。
2. 原路退款经企业微信最终通过后,系统必须以原实际收款商户自动调用渠道退款接口,超级管理员不得改选其他商户。只有渠道明确退款成功后退款单才标记已退款并保存渠道退款流水号;调用失败、超时或结果未知时,退款单保持原路退款处理中/失败,保留可恢复事实且不得重复退款。需改为凭证退款时,必须重新提交申请并重新走企业微信审批。
3. 原路退款在选择方式、提交申请和实际执行前均重新校验原支付单、原实际收款商户、原渠道交易流水号、商户退款能力/凭证以及可退金额。商户停用不阻断历史订单原路退款。渠道实际调用是退款资格的最终判断;渠道明确拒绝、超期、凭证失效或余额不足时不标记成功、保留失败原因。系统不额外提供退款凭证预探测接口。
4. 一笔订单最多只允许一张最终退款成功的退款申请;该申请可部分退款,成功后订单剩余未退款金额不再允许申请退款。
5. 退款申请提交金额即企业微信审批授权金额和最终允许退款金额。原路退款按此金额调用渠道;凭证退款实际转出此金额后才可成功。金额需变更时不得继续执行原申请,必须重新提交并重新走企业微信审批。
6. 一笔订单同一时间最多一张审批中、原路退款处理中或原路退款失败的退款申请。企业微信驳回、申请撤销/关闭或原路退款明确失败后,允许修改未成功退款申请并重提;可修改金额、原因、退款方式、客户收款信息和附件。每次重提必须新建企业微信审批实例及业务快照,历史材料不可覆盖。任一退款方式最终成功后,申请和订单均不得再发起退款。
7. 企业微信最终通过即按当前规则使退款关联套餐失效、接续下一套餐并在必要时停机;原路退款渠道或线下实际打款的后续结果不影响套餐失效,也不恢复权益。
8. 凭证退款申请必须提供客户收款账户信息和客户提供的收款凭证,作为企业微信审批材料;不要求、也不提供公司实际线下付款后的交易流水号或付款凭证回填。
9. 超级管理员、平台用户和代理均可在各自数据权限内对已支付套餐订单发起退款申请;企业微信是唯一终审来源,本地不提供人工通过、拒绝或退回操作。任一具有该订单数据权限的上述账号,均可修改并重新提交未成功退款申请。
10. 凭证退款的客户收款账户信息使用一个必填自由文本字段留存,客户提供的收款凭证附件必填;不新增退款收款账户类型字典、账户表,且不得复用公司线下收款方式字典。
11. 退款申请的实收金额必须由系统从来源订单/原支付记录自动带出并冻结,提交人不可填写或修改。线上支付取原成功支付记录金额;资产钱包、代理预存款和后台线下订单取来源订单的实际收款或实际扣款金额。退款金额不得超过冻结实收金额;无法确定权威实收金额时拒绝创建申请。
12. 本期不按套餐已用流量自动计算退款金额;提交人填写申请金额,系统仅校验不超过冻结实收金额,套餐使用情况作为退款原因、凭证和企业微信审批判断材料。
13. 企业微信在本地收到通过前发生驳回、撤销或删除时,退款申请进入审批未通过/已关闭,未发生退款且可修改重提。企业微信通过后撤销时,不回滚已失效套餐、已发起原路退款或已完成凭证退款;申请标记审批异常、保留审计、禁止自动重提,由超级管理员线下处理。企业微信提交失败或结果未知时,退款申请仍为在途,不允许另建或重提,使用既有查询/重试闭环确认渠道是否受理。
14. 对线上支付订单,系统在申请退款时预检可本地确定的原路退款条件:条件满足时同时提供原路退款和凭证退款;条件不满足时禁用原路退款并说明原因,只允许凭证退款。后端在提交及企微通过后的实际执行前均重复校验;后续条件失效时不得强行原路退款。钱包、预存款和后台线下订单不展示不适用的退款方式。
15. 所有退款申请必须填写退款原因;退款原因冻结在当次企业微信审批快照中。
16. 当前业务实际上限制一张订单仅购买一个套餐;虽数据模型预留多套餐订单能力,但本期退款申请不提供套餐选择,系统自动带出订单唯一关联套餐及其使用情况。企业微信通过后失效该套餐;若为主套餐,仍按现有规则连带失效其加油包。退款上限只按订单冻结实收金额校验。
## 2.3.1 退款管理补充字段
退款管理列表、详情及导出新增“当前退款套餐已用量”和“当前退款套餐总量”。当前退款套餐为退款订单自动关联的唯一套餐;两个字段读取该套餐使用记录当前可取得的真流量已用量和套餐总量,仅用于展示、查询和导出,不改变既定的退款金额校验、套餐失效或佣金回溯规则。
## 2.4 已确认的退款方式矩阵
| 来源订单的实际支付方式 | 本期允许退款方式 |
| --- | --- |
| 微信/支付宝等线上支付套餐订单 | 原路退款、客户提供收款信息退款 |
| C 端资产钱包余额支付套餐订单 | 仅自动退回原资产钱包 |
| 代理预存款/主钱包支付套餐订单 | 仅自动退回原代理预存款钱包 |
| 后台线下套餐订单 / 员工代收款套餐订单 | 仅客户提供收款信息退款 |
| 代理充值预存款业务单本身 | 当前退款模块不覆盖,需另立需求 |
## 2.5 已确认的退款联动
1. 来源订单全额退款且账单从未核销时,系统自动关闭账单,关闭原因记为来源订单全额退款。
2. 来源订单部分退款且账单从未核销时,系统按退款金额冲减账单应收金额,并保留来源订单退款冲销记录。
3. 账单存在任一已通过核销分摊时,系统不得自动冲销该账单;后续由超级管理员人工处理。
4. 已核销账单的来源订单后续退款不恢复员工欠款;账单详情仅提示来源订单已退款并保留退款关联。正常订单退款不等于员工欠款重新产生。
## 2.8 已确认的手机号绑定基线
当前系统已有个人客户手机号绑定和全局 H5 强制绑定开关;是否强制绑定由全局配置决定,不存在、也不新增按资产导入批次设置“是否需要绑定手机号”的能力。原 PRD 第 15.4 节与当前行为冲突,不能作为本期新增功能依据。本期保留既有全局强制绑定逻辑,并新增已验证手机号与资产的关联记录:同一手机号最多关联 10 个当前有效资产资产详情展示关联手机号后台支持按资产解绑、批量解绑及操作记录。全局强制绑定开启时客户首次登录一项尚未关联该手机号的资产必须再次完成短信验证码校验后才建立关联已关联该手机号的资产不再重复验证。全局强制绑定关闭时H5 登录不要求短信验证码且不新增手机号—资产关联,已有关系保留并可后台查看、解绑。客户更换手机号并完成旧、新手机号验证码校验后,系统原子迁移旧手机号全部有效资产关联至新手机号;新手机号现有关联数加待迁移数超过 10 时整次换绑失败,原关系不变。资产详情列出全部当前关联手机号,单个解绑须明确选择一条资产手机号关系;批量解绑和 Excel 按资产标识导入解绑时,解除每项资产全部当前有效手机号关联,必须二次确认、填写原因并逐条记录实际解除关系。批量解绑逐资产独立执行:有权限且已绑定的资产成功解绑,其余资产失败;任务返回成功数、失败数和逐行失败明细,无权限项使用统一失败文案。具备资产数据权限的后台账号在资产详情、列表、导出和批量任务结果中均展示完整关联手机号,不做脱敏;操作日志不得记录完整手机号。仅超级管理员和平台用户可在资产数据权限范围内执行单个解绑、批量解绑和 Excel 导入解绑;代理、企业和个人客户没有后台解绑能力。手机号—资产关联只能由 H5 短信验证建立,后台不提供补录,后台仅查看和解绑。上线时不回填既有个人客户手机号与资产关系;功能上线后,既有客户首次登录每项资产仍须重新短信验证建立关联,超过 10 项时阻断本次新关联。换货不迁移手机号—资产关联,新资产首次访问时按全局开关重新验证。
## 2.7 已确认的业务用户组
1. 业务用户组用于标记平台用户所属业务,既不是后台权限角色,也不是代理店铺分组。
2. 每个启用的平台用户最多属于一个启用的业务用户组;用户组不改变后台角色权限、数据范围或店铺具体业务员归属。
3. 店铺所属用户组由当前绑定的平台业务员所属用户组实时推导;更换店铺负责人或负责人更换用户组后,店铺所属组随之变化。店铺未绑定负责人、负责人未分组或所属组已停用时,店铺所属组为空或显示已停用。店铺列表、详情和筛选均支持展示及按该推导用户组查询。
4. 用户组停用后不得新增成员;现有成员关系保留,用户及其负责店铺均显示该组已停用。停用不影响用户登录、权限、数据范围和店铺负责人,管理员可后续改组或清空成员。
5. 仅无成员的用户组可由超级管理员或平台用户二次确认删除;仍有成员时只能停用或先移走成员。
6. 超级管理员和平台用户可勾选多个平台用户,批量设置为某个启用用户组或批量清空所属组;设置会直接替换原所属组,停用组不得作为批量目标。
7. 超级管理员和平台用户可勾选多家有数据权限的店铺,批量设置为某个启用的平台业务员或批量清空业务员;店铺所属用户组随负责人实时推导更新。批量操作先校验全部目标店铺,任一店铺无权、不存在、已删除或目标业务员无效时整批不修改并统一失败。
8. 用户组维护名称、稳定编码、排序、启用状态和备注;不设上级组、层级或组管理员。编码创建时必填、当前未删除用户组内唯一且创建后不可修改;名称、排序、状态和备注可修改。
## 2.10 已确认的 H5 风险换卡弹窗
广电风险停机自动弹窗只在当前 H5 登录并访问的资产同时满足以下条件时展示资产为广电卡、运营商回传扩展状态为风险停机、且不存在待填写收货信息、待发货、已发货待确认或已完成的物流换货单。命中后向当前客户展示换卡提醒。客户提交地址后自动创建一张关联该旧资产的物流换货单不另建风险换卡待处理记录。首次提交地址即锁定客户后续不得修改只能联系后台处理。风险换卡自动创建的物流换货单不在客户提交地址时预设业务数据迁移后台发货并选择新资产时仍由操作人按既有换货流程决定是否迁移。同一客户、同一资产的风险换卡弹窗每天最多展示一次客户点击稍后处理后当天不再展示次日仍命中条件时可再次展示。运营主动弹窗按店铺、设备类型、卡类型等已配置维度同时匹配同一维度多选满足任意一个即可未配置任何适用范围则面向全量客户。主动弹窗配置必须设置优先级后端按请求条件仅返回优先级最高的一条优先级相同取最近更新时间最新的一条风险换卡通知固定高于主动弹窗。H5 弹窗复用个人客户站内通知记录,投放后同时出现在 H5 通知列表供查看历史内容。后端创建或返回弹窗候选时通知保持未读H5 在客户关闭弹窗、点击操作按钮或进入通知详情后调用现有已读接口。运营主动弹窗仅在客户请求配置页面的候选弹窗时实时匹配并创建或复用通知,未访问 H5 的客户不预生成通知。后台固定支持首页、资产详情、套餐购买、资产钱包充值四种展示页面。频率仅支持每个客户对每条配置仅一次或每天一次。运营配置修改标题、内容、范围、页面、频率或有效期只影响后续投放,既有通知保留原快照;已修改配置作为新版本,对原“仅一次”配置的命中客户可重新投放一次。运营弹窗可不设操作,或设置一个受控按钮,目标仅可为套餐购买或资产钱包充值;不得配置任意 URL。H5 请求候选必须携带当前页面资产标识,店铺、设备类型和卡类型均按该资产匹配,首页由 H5 传当前选中的资产。运营弹窗配置到期或停用后停止新投放,既有通知在通知中心保留 90 天。风险换卡地址提交后停止新投放,既有风险换卡通知保留 90 天。风险换卡收货信息保留收货人姓名、收货手机号和一个完整地址文本三项;地址不拆分省、市、区及详细地址字段。超级管理员和平台用户可管理全局 H5 弹窗配置,代理、企业和个人客户不可管理。
## 2.9 已确认的换货迁移展示
换货列表和详情使用“业务数据迁移”及状态:不迁移、待迁移、已迁移、迁移失败;迁移失败可查看失败原因。详情明确迁移范围仅包括资产钱包余额、有效套餐使用记录、累计充值字段和资产标签。资产归属及个人客户—资产绑定属于换货完成固有动作;手机号—资产关联不属于迁移范围,新资产首次访问时按全局开关重新验证。迁移失败时换货单保持原可完成状态,记录最近一次失败原因;超级管理员或平台用户修复条件后可再次确认完成,整套迁移重新原子执行。
## 2.11 已确认的自动续费基线
自动续费仅扣待续费同一资产的资产钱包可用余额;对当前有效主套餐续购同一套餐商品,价格按执行时当前渠道可售续费价计算。第一版仅按最终到期前 N 天触发,不按流量阈值触发。平台设置一个总开关,并配置全部主套餐或指定主套餐及统一的到期前 N 天;进入触发窗口后,每项资产每天最多尝试一次,成功即停止,套餐到期后不再自动尝试。余额不足或套餐不可续费时,向当前个人客户及资产所属店铺当时有效业务员创建站内通知;同一资产同一天不重复通知。自动续费成功后,仅当资产处于可恢复停机状态且运营商状态不是风险停机或已销户时,自动调用既有复机;复机失败不回滚已成功的续费和钱包扣款,记录失败并通知客户和业务员。自动任务与手动续购并发时,手动续购优先;自动任务加锁重读后发现人工已完成续购即跳过,避免重复扣款。客户需要额外购买第二个周期时,须在首笔手动订单成功后再次主动下单。
## 2.12 已确认的代理分销码与提现资料基线
每个代理店铺创建时系统生成不可修改、全局唯一的随机分销码;二维码仅编码 H5 注册入口和该码。新代理扫码后以手机号短信验证码注册、自行设置密码,并创建待企业微信审批的下级代理及店铺,审批通过后启用。审批通过时新店铺设为分销码所属店铺的直接下级,并复制上级当时业务员为初始业务员,后续双方可独立调整。代理停用后其分销码立即不可注册,既有下级代理和既有佣金关系不受级联影响。
代理首次提现前提交提现资料资格申请,合同与法人身份证必填,企业微信审批通过且资料未过期才有效;资料变更或过期必须重审。代理提交提现申请时冻结可提现余额、金额、手续费、收款信息和可选发票快照,并自动创建企业微信提现审批,本地不提供人工通过或驳回。合同与法人身份证通过后长期有效,仅资料被代理替换、被超级管理员作废或代理停用时失效。法人身份证正、反面附件均必传。企业代理必须填写统一社会信用代码,个人代理填写法人身份证号;可选发票仅企业代理可上传并校验统一社会信用代码。营业执照、门头照、发票均为可选。合同资格申请必须填写签约主体统一社会信用代码或身份证号;上传发票时由代理填写发票抬头和统一社会信用代码,系统校验其与合同主体代码一致,企业微信审批人员核验附件真实性。企业微信驳回提现申请后,解冻该申请冻结的佣金余额;代理可修改金额、收款信息及本次可选发票后重新提交,每次创建新的企业微信审批实例,资料资格仍有效。企业微信通过即视为代理提现已到账,系统不登记或等待实际线下打款。发票是每笔提现申请的可选材料,如上传则按当前有效合同主体代码校验,并冻结至当次提现企业微信审批快照。
## 2.13 已确认的流量预警与通道阈值基线
套餐真流量预警使用套餐实际真流量,阈值为 1%100% 的小数百分比;每个套餐商品最多一条当前规则,修改覆盖当前值,既有预警冻结阈值快照。按同一资产全部当前有效套餐的真流量用量和总量汇总计算使用比例,并使用主套餐规则判断;同一套餐使用记录与命中阈值只创建一条预警。以实际消耗流量的套餐记录关联资产为准,插拔卡场景同时展示卡和当前关联设备但不汇总多张卡。达到阈值时仅通知资产所属店铺当时有效业务员。规则停用后停止新触发且保留历史;启用或降低阈值后,下次扫描发现已有有效套餐达到阈值即补建预警。
运营商通道阈值默认关闭开启时按运营商回传的卡当前计费周期累计流量判断。通道下每张卡独立判断达量后系统创建可靠停机任务并自动调用运营商停机达阈值即写入本地通道阈值停机锁当前周期内拒绝复机停机调用通过可靠重试或人工恢复确认最终结果。每个通道配置计费周期起始日128上海时区。新周期开始后仅对仍持有通道阈值停机锁、存在有效主套餐且不存在风险停机、销户或其他停机锁的卡自动复机不符合条件只解除通道锁不调用运营商复机。自动复机失败记录结果并按既有可靠机制处理。
## 2.14 已确认的佣金回溯与套餐层级展示基线
套餐退款佣金回溯后,原佣金记录保持不变,另建关联原佣金记录及退款单的负数佣金明细,佣金明细新增不可提现的“回溯”终态;回溯明细冻结原订单号及其他原佣金字段。部分退款按本次退款金额与订单冻结实收金额的比例,对每条原佣金等比例回溯,按分向下取整、最后一条补足舍入差,累计不超过原佣金。退款发生时佣金计算尚未完成的,等待其终态后再生成全部应有回溯明细;确认无佣金才标记无需回溯。同一退款业务幂等,不重复生成回溯明细。本期只处理套餐退款回溯,换货回溯待独立定义。佣金回溯时直接扣减佣金钱包,允许余额为负;先拒绝并释放待审核提现,再生成回溯明细和扣款流水。
后台资产详情及 H5 资产套餐历史均返回主套餐及关联加油包的层级结构,直接依据现有 `PackageUsage.master_usage_id` 分组,不新增关联表。加油包按生效时间正序排列,待生效包按购买创建时间正序并排在已生效包之后。无论主套餐或加油包已失效、过期或用尽,均保留层级关系;仅关联主套餐物理缺失时以“关联主套餐缺失”的异常独立项展示。
## 2.15 已确认的优先轮询定位
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
## 2.16 已确认的导出、时间筛选与代理自充收款方式基线
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
所有要求时间筛选的页面使用统一“开始时间—结束时间”组件和 `start_time/end_time` 参数,支持带时区的 RFC3339 秒级时间闭区间任一端可不传。IoT/设备任务、换货、分配、订单、代理充值、佣金、提现和导出按创建或申请时间筛选;授权按授权发生时间;临期列表按套餐最终到期时间。导出必须复用当前页面全部筛选条件和创建时数据权限快照。
代理自充方式由超级管理员维护允许范围(仅微信、仅支付宝、同时支持);代理实际可用方式取该允许范围与当前可用商户池方式的交集,交集为空时拒绝创建在线充值单。平台用户和代理只可查询实际可用方式。配置变更不影响已创建未支付充值单的支付方式及商户快照,只影响后续新单。代理充值记录新增交易流水号字段,用于保存该笔充值对应的交易流水号。
## 2.17 已确认的报表基线
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。功能上线后每日生成稳定日报快照;上线前日期不提供报表或明确显示无快照数据,不回填历史。累计激活设备严格采用任一当前关联卡已实名的口径;每日快照中的累计在网设备为已实名且存在有效主套餐的设备,活跃设备为该套餐周期内任一卡真流量大于零的设备,用量为设备当前套餐周期内全部关联卡真流量之和。报表可选择设备名称、型号、制造商、用户组、代理、店铺、业务员中的一个分组维度;未选择时汇总为一行。套餐续费按资产去重,统计期内有主套餐到期的资产计一次到期,至少成功续购一次主套餐计一次续费,续费率不超过 100%。日报快照冻结当天店铺、业务员及用户组归属。后端提供日/月趋势汇总数据和异步导出,图表渲染由前端负责。
## 2.18 已确认的店铺批量换绑 Excel 基线
店铺列表勾选批量换绑保持全量预校验、任一项失败整批不更新。Excel 导入复用现有统一导入任务处理方式,逐行执行:成功行提交,失败行不影响其他行,并输出成功数、失败数和逐行失败明细;不设 1000 行硬上限。两种入口均保留。Excel 使用店铺编码唯一定位店铺;换绑时以目标平台用户登录账号唯一定位业务员。每家实际变更店铺复用现有统一审计,记录原业务员、新业务员、操作人、时间和 Excel 行备注;批量任务同时保留汇总结果。
## 3. 当前阻塞与待后续独立需求
以下不是本轮继续扩展的产品设计题:
1. **OCR 外部契约**:仅可作为交易流水号、付款金额等字段的预填能力;接口、字段置信度、失败和重试规则须以外部契约为准。
2. **支付渠道契约**:微信直连、富友和支付宝的原路退款接口、超时查询及可恢复错误处理,须以各渠道实际契约为准。
3. **运营商契约**:通道计费周期起始日、停复机实际能力及结果未知后的查询/恢复,以运营商接口及业务提供的通道政策为准。
4. **换货佣金回溯**:本期只处理套餐退款;“不同资产换货”何时、按何金额回溯佣金未定义,后续如需实施须单独提出需求。
除上述依赖及独立后续需求外,本轮原始需求的业务规则已收口;后续工作是重编稳定需求 ID、建立原编号映射并按影响范围拆分独立 OpenSpec Change不直接开始编码。

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 和已记录工程命令已覆盖用户授权的完成门槛;其余尚未执行的运行时场景保持如实记录。

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,7 @@ import (
"context"
stderrors "errors"
"strconv"
"time"
"gorm.io/gorm"
@@ -42,6 +43,7 @@ type ChangeAudit struct {
PersonalOpenIDs []PersonalCustomerOpenIDChange
PersonalDevices []PersonalCustomerDeviceChange
PersonalICCIDs []PersonalCustomerICCIDChange
PhoneAssociations []PhoneAssetAssociationChange
Role *model.Role
Roles []RoleChange
Permissions []PermissionChange
@@ -59,6 +61,24 @@ type PersonalCustomerPhoneChange struct {
AfterData map[string]any
}
// PhoneAssetAssociationChange 保存手机号—资产关联资源的前后变化。
// 手机号一律传入脱敏值关联用例不得把完整手机号写入审计ENG-LOG-001
// 关联指向的资产以资产类型与资产 ID 声明,由 Writer 组装为参考资源。
type PhoneAssetAssociationChange struct {
AssociationID uint
PhoneMasked string
AssetType string
AssetID uint
AssetDisplayName string
Status int
Source string
InvalidatedAt *time.Time
InvalidationMethod string
InvalidationReason string
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
type PersonalCustomerOpenIDChange struct {
OpenID *model.PersonalCustomerOpenID

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"
@@ -17,14 +18,20 @@ import (
// ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。
type ApprovalDecisionHandler struct {
db *gorm.DB
posting *walletapp.PostingService
audit RechargeAuditWriter
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,7 +194,9 @@ 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,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
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),
RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo,

View File

@@ -8,6 +8,7 @@ import (
"github.com/bytedance/sonic"
"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"
@@ -23,7 +24,12 @@ type CreateOfflineCommand struct {
RechargeNo string
Amount int64
PaymentVoucherKeys []string
Remark string
OtherVoucherKeys []string
// OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。
OfflinePaymentMethodID uint
// ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。
ExternalTransactionNo string
Remark string
}
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
@@ -46,6 +52,106 @@ func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit Rec
return &OfflineCreationService{db: db, approval: approval, audit: audit}
}
// TriggerHistorical 为历史待审批线下代充值补发一次企业微信审批。
func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID uint) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || recordID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
var record model.AgentRechargeRecord
if err := s.db.WithContext(ctx).First(&record, recordID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值申请失败")
}
if record.PaymentMethod != constants.RechargeMethodOffline || record.Status != constants.RechargeStatusPending || record.ApprovalInstanceID != nil {
return nil, errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
}
account, shop, wallet, err := s.loadHistoricalFacts(ctx, &record)
if err != nil {
return nil, err
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: record.UserID,
CorrelationID: record.RechargeNo,
})
if err != nil {
return nil, err
}
command := CreateOfflineCommand{
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),
}
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
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, recordID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "充值记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史线下代充值申请失败")
}
if current.PaymentMethod != constants.RechargeMethodOffline || current.Status != constants.RechargeStatusPending || current.ApprovalInstanceID != nil {
return errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge,
BusinessID: current.ID, SubmitterAccountID: current.UserID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RechargeNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND payment_method = ? AND status = ? AND approval_instance_id IS NULL", current.ID, constants.RechargeMethodOffline, constants.RechargeStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联线下代充值审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值审批实例关联已变化")
}
current.ApprovalInstanceID = &reference.InstanceID
record = current
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.WriteAgentRecharge(ctx, tx, RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "补发员工线下代充值审批",
Record: &current, Approval: &instance, Wallet: wallet,
AfterData: map[string]any{"status": current.Status, "approval_instance_id": current.ApprovalInstanceID},
})
})
if err != nil {
return nil, err
}
return &CreateOfflineResult{
Record: &record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus,
}, nil
}
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
@@ -65,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{
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),
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
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,
}
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
}
@@ -129,17 +246,103 @@ 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) {
if record == nil || record.UserID == 0 || record.ShopID == 0 || record.AgentWalletID == 0 {
return nil, nil, nil, errors.New(errors.CodeInvalidParam)
}
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", record.UserID, constants.StatusEnabled).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeForbidden, "原创建账号不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值创建人失败")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ?", record.ShopID).First(&shop).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值目标店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).
Where("id = ? AND shop_id = ? AND wallet_type = ? AND status = ?", record.AgentWalletID, record.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "原充值主钱包不存在或不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值主钱包失败")
}
return &account, &shop, &wallet, nil
}
func (s *OfflineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOfflineCommand,
@@ -170,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,
@@ -179,15 +382,19 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
constants.ApprovalFieldShopID: command.ShopID,
constants.ApprovalFieldShopName: shopName,
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
constants.ApprovalFieldAmountCent: command.Amount,
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
constants.ApprovalFieldSubmitterName: submitterName,
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
constants.ApprovalFieldShopID: command.ShopID,
constants.ApprovalFieldShopName: shopName,
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
constants.ApprovalFieldAmountCent: command.Amount,
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
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"
@@ -46,20 +47,28 @@ type AvailablePaymentMethodsResult struct {
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
audit PaymentAuditWriter
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 OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, 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.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)
@@ -78,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
@@ -124,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, "仅代理或平台账号可以查询在线支付方式")
}
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
}
}
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
if s.wechat.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
}
return result, nil
}
@@ -151,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)
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(
@@ -194,36 +227,58 @@ 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
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := command.PaymentMethod, command.RequestID
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,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
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,
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 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record = &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
Status: constants.RechargeStatusPending, RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment = &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, Amount: command.Amount,
Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
}
merchantpayment.FreezeRoute(payment, route)
if err := tx.Create(record).Error; err != nil {
return err
}
@@ -237,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 nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {
@@ -247,6 +306,9 @@ func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) s
return ""
}
if paymentMethod == constants.RechargeMethodWechat {
if config.ProviderType == model.ProviderTypeFuiou {
return config.FyMchntCd
}
return config.WxMchID
}
if paymentMethod == constants.RechargeMethodAlipay {
@@ -255,6 +317,14 @@ func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) s
return ""
}
// paymentChannel 返回实际支付渠道:富友配置下微信业务方式落库为 fuiou其余与业务方式一致。
func paymentChannel(paymentMethod string, config *model.WechatConfig) string {
if paymentMethod == constants.RechargeMethodWechat && config != nil && config.ProviderType == model.ProviderTypeFuiou {
return model.ProviderTypeFuiou
}
return paymentMethod
}
func (s *OnlineCreationService) loadReplay(
ctx context.Context,
command CreateOnlineCommand,
@@ -316,8 +386,11 @@ func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result
})
}
func (s *OnlineCreationService) adapter(paymentMethod string) OnlinePaymentPort {
func (s *OnlineCreationService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.fuiou
}
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {

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,21 +15,23 @@ import (
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
confirm *ConfirmOnlinePaymentService
audit PaymentAuditWriter
now func() time.Time
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, 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.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()
@@ -51,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, "待恢复支付单缺少充值单或创建配置")
@@ -70,7 +79,7 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
}
func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error {
adapter := s.adapter(payment.PaymentMethod)
adapter := s.adapter(payment.PaymentMethod, config)
if adapter == nil {
return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用")
}
@@ -140,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)
}
}
@@ -150,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))
@@ -165,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
}
// 仅微信直连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)
}
return configs[*payment.PaymentConfigID]
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 {
@@ -200,8 +227,11 @@ func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment
})
}
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort {
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.fuiou
}
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {

View File

@@ -57,26 +57,9 @@ func (s *Service) ArchiveIntegrationDate(ctx context.Context, archiveDate time.T
return s.archiveIntegrationDate(ctx, archiveDate, false)
}
// FinalizePreviousIntegrationMonth 复核并终结上一个完整自然月的 Integration Log 归档
func (s *Service) FinalizePreviousIntegrationMonth(ctx context.Context) error {
now := time.Now().In(s.location)
return s.FinalizeIntegrationMonth(ctx, now.AddDate(0, -1, 0))
}
// FinalizeIntegrationMonth 逐日复核指定完整自然月,并为变化内容创建最终 revision。
func (s *Service) FinalizeIntegrationMonth(ctx context.Context, month time.Time) error {
monthStart := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
currentMonth := time.Now().In(s.location)
currentMonthStart := time.Date(currentMonth.Year(), currentMonth.Month(), 1, 0, 0, 0, 0, s.location)
if !monthStart.Before(currentMonthStart) {
return fmt.Errorf("只能终结已经结束的 Integration Log 完整自然月")
}
for date := monthStart; date.Before(monthStart.AddDate(0, 1, 0)); date = date.AddDate(0, 0, 1) {
if err := s.archiveIntegrationDate(ctx, date, true); err != nil {
return fmt.Errorf("终结 %s Integration Log 归档失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
// FinalizeIntegrationDate 为指定已结束自然日形成 Integration Log 最终归档版本
func (s *Service) FinalizeIntegrationDate(ctx context.Context, archiveDate time.Time) error {
return s.archiveIntegrationDate(ctx, archiveDate, true)
}
func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.Time, final bool) error {
@@ -94,22 +77,20 @@ func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.T
if err != nil {
return err
}
if final && run.Status == constants.ArchiveStatusSuccess && run.IsFinal && run.CleanedAt != nil {
terminalCount, countErr := s.integrationTerminalCount(ctx, start, end)
if countErr != nil {
return countErr
}
if terminalCount == 0 {
return nil
}
}
file, err := s.buildIntegrationArchiveFile(ctx, start, end)
if err != nil {
return err
}
defer os.Remove(file.path)
if final {
pending, pendingErr := s.integrationPendingCount(ctx, start, end)
if pendingErr != nil {
return pendingErr
}
if pending > 0 {
_ = s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).
Updates(map[string]any{"is_final": false, "error_summary": "存在 pending Integration Log无法形成最终归档", "updated_at": time.Now()}).Error
return fmt.Errorf("仍有 %d 条 pending Integration Log无法形成最终归档", pending)
}
}
if run.Status == constants.ArchiveStatusSuccess && run.RecordCount == file.recordCount && run.SHA256 == file.sha256 {
valid, validateErr := s.validateIntegrationRun(ctx, run)
if validateErr == nil && valid && (!final || run.IsFinal) {
@@ -165,7 +146,8 @@ func (s *Service) acquireIntegrationRun(ctx context.Context, run *model.LogArchi
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, now.Add(-3*time.Hour)).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision, "is_final": false,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil, "updated_at": now,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil,
"cleanup_started_at": nil, "cleaned_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定 Integration Log 归档任务失败: %w", result.Error)
@@ -320,16 +302,6 @@ func (s *Service) integrationRecordCount(ctx context.Context, start, end time.Ti
return count, nil
}
func (s *Service) integrationPendingCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ? AND result = ?", start, end, constants.IntegrationResultPending).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 pending Integration Log 失败: %w", err)
}
return count, nil
}
func integrationObjectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("integration-logs-%s-r%d", date.Format(time.DateOnly), revision)

View File

@@ -7,11 +7,9 @@ import (
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -19,25 +17,18 @@ import (
const maxManifestBytes = 1024 * 1024
// RetentionAudit 描述月度物理清理的统一审计事实
// RetentionAudit 保留系统审计 Writer 的输入兼容类型
type RetentionAudit struct {
EventID string
Month string
Summary string
Result string
ErrorSummary string
RangeStart time.Time
RangeEnd time.Time
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
DurationMS int64
EventID, Month, Summary, Result, ErrorSummary string
RangeStart, RangeEnd time.Time
EventCount, ResourceCount, IntegrationCount int64
ManifestKeys []string
DurationMS int64
}
// RetentionResult 是月度留存清理的结构化执行结果。
// RetentionResult 是单日留存处理结果。
type RetentionResult struct {
Month string
ArchiveDate string
EventCount int64
ResourceCount int64
IntegrationCount int64
@@ -46,191 +37,150 @@ type RetentionResult struct {
Duration time.Duration
}
type retentionRuns struct {
audit []*model.LogArchiveRun
integration []*model.LogArchiveRun
// RetentionBlockedError 描述阻断日期推进的安全上下文。
type RetentionBlockedError struct {
ArchiveDate string
Source string
Err error
}
// CleanupPreviousMonth 校验并物理清理上一个完整自然月的在线审计日志。
func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
func (e *RetentionBlockedError) Error() string {
return e.ArchiveDate + " " + e.Source + ": " + e.Err.Error()
}
func (e *RetentionBlockedError) Unwrap() error { return e.Err }
// ValidatePreviousMonth 只读校验上一个完整自然月的归档与清理门禁
func (s *Service) ValidatePreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.ValidateMonth(ctx, now.AddDate(0, -1, 0))
}
// ValidateMonth 只读校验指定完整自然月,不写清理断点且不删除在线数据。
func (s *Service) ValidateMonth(ctx context.Context, month time.Time) (result RetentionResult, err error) {
// RetainPendingDays 每次最多处理一个最早待处理的上海自然日,避免单任务跨历史日期长时间占用数据库
func (s *Service) RetainPendingDays(ctx context.Context, cleanup bool) ([]RetentionResult, error) {
if s.db == nil || s.store == nil {
return result, fmt.Errorf("日志留存演练数据库或对象存储未配置")
return nil, fmt.Errorf("日志留存数据库或对象存储未配置")
}
start, end, err := s.retentionMonthRange(month)
start, end, err := s.pendingRetentionRange(ctx)
if err != nil {
return result, err
return nil, err
}
if !start.Before(end) {
return nil, nil
}
result, err := s.retainDate(ctx, start, cleanup)
if err != nil {
return nil, &RetentionBlockedError{ArchiveDate: start.Format(time.DateOnly), Source: constants.AuditArchiveSource, Err: err}
}
return []RetentionResult{result}, nil
}
// RetainDate 校验并按需清理指定已结束自然日,供受控演练使用。
func (s *Service) RetainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
return s.retainDate(ctx, date, cleanup)
}
func (s *Service) pendingRetentionRange(ctx context.Context) (time.Time, time.Time, error) {
today := time.Now().In(s.location)
end := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)
var earliest *time.Time
for _, item := range []struct{ table, column, condition string }{
{"tb_audit_event", "created_at", ""},
{"tb_integration_log", "created_at", "result <> 'pending'"},
} {
query := s.db.WithContext(ctx).Table(item.table).Select("MIN(" + item.column + ")")
if item.condition != "" {
query = query.Where(item.condition)
}
var value *time.Time
if err := query.Scan(&value).Error; err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("查询日留存起点失败: %w", err)
}
if value != nil && (earliest == nil || value.Before(*earliest)) {
local := value.In(s.location)
earliest = &local
}
}
if earliest == nil {
return end, end, nil
}
start := time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, s.location)
return start, end, nil
}
func (s *Service) retainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
startedAt := time.Now()
result.Month = start.Format("2006-01")
runs, err := s.loadRetentionRuns(ctx, start, end)
auditResult, err := s.retainAuditDate(ctx, date, cleanup)
if err != nil {
return result, err
return RetentionResult{}, err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return result, err
integrationResult, err := s.retainIntegrationDate(ctx, date, cleanup)
if err != nil {
return RetentionResult{}, err
}
summarizeRetentionRuns(runs, &result)
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: auditResult.EventCount, ResourceCount: auditResult.ResourceCount, IntegrationCount: integrationResult.IntegrationCount, ManifestKeys: append(auditResult.ManifestKeys, integrationResult.ManifestKeys...)}
result.EstimatedBatches = estimatedRetentionBatches(result)
result.Duration = time.Since(startedAt)
return result, nil
}
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
if s.db == nil || s.store == nil || s.audit == nil {
return result, fmt.Errorf("日志留存清理数据库、对象存储或审计 Writer 未配置")
}
start, end, err := s.retentionMonthRange(month)
func (s *Service) retainAuditDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
run, err := s.retentionRun(ctx, date, constants.AuditArchiveSource)
if err != nil {
return result, err
return RetentionResult{}, err
}
startedAt := time.Now()
result.Month = start.Format("2006-01")
cleanupErr = s.executeRetention(ctx, start, end, &result)
result.Duration = time.Since(startedAt)
if auditErr := s.recordRetentionAudit(ctx, start, end, result, cleanupErr); auditErr != nil {
if cleanupErr != nil {
return result, fmt.Errorf("%w记录留存清理失败审计失败: %v", cleanupErr, auditErr)
if run == nil || run.Status != constants.ArchiveStatusSuccess {
if err := s.ArchiveDate(ctx, date); err != nil {
return RetentionResult{}, fmt.Errorf("Audit 归档失败: %w", err)
}
run, err = s.retentionRun(ctx, date, constants.AuditArchiveSource)
if err != nil {
return RetentionResult{}, err
}
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
}
return result, cleanupErr
if err := s.validateAuditRetentionDay(ctx, date, run); err != nil {
return RetentionResult{}, fmt.Errorf("Audit 完整性校验失败: %w", err)
}
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: run.EventCount, ResourceCount: run.ResourceCount, ManifestKeys: []string{run.ManifestKey}}
if cleanup {
if err := s.cleanupAuditDate(ctx, date, run); err != nil {
return result, err
}
}
return result, nil
}
func (s *Service) retentionMonthRange(month time.Time) (time.Time, time.Time, error) {
start := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
end := start.AddDate(0, 1, 0)
now := time.Now().In(s.location)
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, s.location)
if !end.Before(currentMonth) && !end.Equal(currentMonth) {
return time.Time{}, time.Time{}, fmt.Errorf("只能清理已经结束的完整自然月")
}
return start, end, nil
}
func (s *Service) executeRetention(ctx context.Context, start, end time.Time, result *RetentionResult) error {
started, err := s.retentionCleanupStarted(ctx, start, end)
func (s *Service) retainIntegrationDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
run, err := s.retentionRun(ctx, date, constants.IntegrationArchiveSource)
if err != nil {
return err
return RetentionResult{}, err
}
if !started {
lastDay := end.AddDate(0, 0, -1)
if err := s.ArchiveDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Audit 归档失败: %w", err)
}
if err := s.ArchiveIntegrationDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Integration Log 归档失败: %w", err)
}
if err := s.FinalizeIntegrationMonth(ctx, start); err != nil {
return err
if run == nil || run.Status != constants.ArchiveStatusSuccess {
if err := s.ArchiveIntegrationDate(ctx, date); err != nil {
return RetentionResult{}, fmt.Errorf("Integration Log 归档失败: %w", err)
}
}
runs, err := s.loadRetentionRuns(ctx, start, end)
if err := s.FinalizeIntegrationDate(ctx, date); err != nil {
return RetentionResult{}, fmt.Errorf("Integration Log 最终归档失败: %w", err)
}
run, err = s.retentionRun(ctx, date, constants.IntegrationArchiveSource)
if err != nil {
return err
return RetentionResult{}, err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return err
if err := s.validateIntegrationRetentionDay(ctx, date, run); err != nil {
return RetentionResult{}, fmt.Errorf("Integration Log 完整性校验失败: %w", err)
}
summarizeRetentionRuns(runs, result)
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
return err
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), IntegrationCount: run.RecordCount, ManifestKeys: []string{run.ManifestKey}}
if cleanup {
if err := s.cleanupIntegrationDate(ctx, date, run); err != nil {
return result, err
}
}
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
return result, nil
}
func (s *Service) retentionCleanupStarted(ctx context.Context, start, end time.Time) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("读取月度清理断点失败: %w", err)
func (s *Service) retentionRun(ctx context.Context, date time.Time, source string) (*model.LogArchiveRun, error) {
var runs []model.LogArchiveRun
if err := s.db.WithContext(ctx).Where("source = ? AND archive_date = ? AND instance_id = ?", source, date.Format(time.DateOnly), s.instanceID).Find(&runs).Error; err != nil {
return nil, fmt.Errorf("读取日归档账本失败: %w", err)
}
return count > 0, nil
}
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
var rows []model.LogArchiveRun
err := s.db.WithContext(ctx).Where(
"source IN ? AND archive_date >= ? AND archive_date < ? AND instance_id = ?",
[]string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID,
).Order("archive_date ASC, source ASC").Find(&rows).Error
if err != nil {
return retentionRuns{}, fmt.Errorf("读取月度归档账本失败: %w", err)
if len(runs) == 0 {
return nil, nil
}
days := int(end.Sub(start).Hours() / 24)
if len(rows) != days*2 {
return retentionRuns{}, fmt.Errorf("月度归档账本缺日:期望 %d 条,实际 %d 条", days*2, len(rows))
}
runs := retentionRuns{audit: make([]*model.LogArchiveRun, 0, days), integration: make([]*model.LogArchiveRun, 0, days)}
for index := range rows {
run := &rows[index]
switch run.Source {
case constants.AuditArchiveSource:
runs.audit = append(runs.audit, run)
case constants.IntegrationArchiveSource:
runs.integration = append(runs.integration, run)
}
}
if len(runs.audit) != days || len(runs.integration) != days {
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
}
return runs, nil
}
func (s *Service) validateRetentionRuns(ctx context.Context, start, end time.Time, runs retentionRuns) error {
if err := validateCleanupLedgerState(runs.audit); err != nil {
return fmt.Errorf("Audit 清理断点非法: %w", err)
}
if err := validateCleanupLedgerState(runs.integration); err != nil {
return fmt.Errorf("Integration 清理断点非法: %w", err)
}
for index := range runs.audit {
date := start.AddDate(0, 0, index)
if err := s.validateAuditRetentionDay(ctx, date, runs.audit[index]); err != nil {
return fmt.Errorf("%s Audit 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration[index]); err != nil {
return fmt.Errorf("%s Integration 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func validateCleanupLedgerState(runs []*model.LogArchiveRun) error {
started, cleaned := 0, 0
for _, run := range runs {
if run.CleanupStartedAt != nil {
started++
}
if run.CleanedAt != nil {
cleaned++
}
}
if started != 0 && started != len(runs) {
return fmt.Errorf("清理开始断点不是整月原子状态")
}
if cleaned != 0 && cleaned != len(runs) {
return fmt.Errorf("清理完成断点不是整月原子状态")
}
if cleaned > 0 && started == 0 {
return fmt.Errorf("清理完成但缺少开始断点")
}
return nil
return &runs[0], nil
}
func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
@@ -258,25 +208,25 @@ func (s *Service) validateIntegrationRetentionDay(ctx context.Context, date time
if err != nil {
return err
}
if run.CleanedAt != nil {
if count != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
}
return nil
}
if run.CleanupStartedAt != nil {
if count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
return nil
}
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
terminalCount, err := s.integrationTerminalCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
if run.CleanedAt != nil && terminalCount != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条终态记录", terminalCount)
}
if run.CleanupStartedAt != nil && count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
if terminalCount > 0 && run.CleanupStartedAt == nil {
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
}
}
return nil
}
@@ -285,8 +235,7 @@ func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, fi
if run.Status != constants.ArchiveStatusSuccess || run.SchemaVersion != schema {
return fmt.Errorf("归档状态或 schema version 不符合清理要求")
}
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) ||
!run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) || !run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
return fmt.Errorf("归档日期或半开时间范围不一致")
}
if final && !run.IsFinal {
@@ -297,21 +246,14 @@ func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, fi
}
return nil
}
func validateRemainingCounts(run *model.LogArchiveRun, events, resources int64) error {
if run.CleanedAt != nil {
if events != 0 || resources != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
return nil
if run.CleanedAt != nil && (events != 0 || resources != 0) {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
if run.CleanupStartedAt != nil {
if events > run.EventCount || resources > run.ResourceCount {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
return nil
if run.CleanupStartedAt != nil && (events > run.EventCount || resources > run.ResourceCount) {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
if events != run.EventCount || resources != run.ResourceCount {
if run.CleanupStartedAt == nil && (events != run.EventCount || resources != run.ResourceCount) {
return fmt.Errorf("数据库事件或资源数量与 manifest 不一致")
}
return nil
@@ -322,43 +264,27 @@ func (s *Service) validateAuditManifest(ctx context.Context, run *model.LogArchi
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount ||
manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey ||
manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Audit manifest 与 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}); err != nil {
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, false)
}
func (s *Service) validateIntegrationManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest integrationArchiveManifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes ||
manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Integration manifest 与最终 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": "true",
}); err != nil {
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision), "final": "true"}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, true)
}
func (s *Service) readManifest(ctx context.Context, key string, target any) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
@@ -384,13 +310,8 @@ func (s *Service) readManifest(ctx context.Context, key string, target any) erro
}
return nil
}
func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchiveRun, final bool) error {
metadata := map[string]string{
"schema-version": run.SchemaVersion, "source": run.Source,
"archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone,
"sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
metadata := map[string]string{"schema-version": run.SchemaVersion, "source": run.Source, "archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone, "sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)}
if run.Source == constants.AuditArchiveSource {
metadata["event-count"] = strconv.FormatInt(run.EventCount, 10)
metadata["resource-count"] = strconv.FormatInt(run.ResourceCount, 10)
@@ -419,53 +340,39 @@ func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchi
}
return nil
}
func summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
result.ManifestKeys = make([]string, 0, len(runs.audit)+len(runs.integration))
for _, run := range runs.audit {
result.EventCount += run.EventCount
result.ResourceCount += run.ResourceCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
for _, run := range runs.integration {
result.IntegrationCount += run.RecordCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
}
func estimatedRetentionBatches(result RetentionResult) int64 {
batchSize := int64(constants.AuditRetentionDeleteBatchSize)
return (result.EventCount+batchSize-1)/batchSize +
(result.ResourceCount+batchSize-1)/batchSize +
(result.IntegrationCount+batchSize-1)/batchSize
batch := int64(constants.AuditRetentionDeleteBatchSize)
return (result.EventCount+batch-1)/batch + (result.ResourceCount+batch-1)/batch + (result.IntegrationCount+batch-1)/batch
}
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
func (s *Service) cleanupAuditDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if run.CleanedAt != nil {
return nil
}
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, start, end); err != nil {
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, date); err != nil {
return err
}
if err := s.deleteAuditResources(ctx, start, end); err != nil {
if err := s.deleteAuditResources(ctx, date, date.AddDate(0, 0, 1)); err != nil {
return err
}
if err := s.deleteAuditEvents(ctx, start, end); err != nil {
if err := s.deleteAuditEvents(ctx, date, date.AddDate(0, 0, 1)); err != nil {
return err
}
return s.markCleaned(ctx, constants.AuditArchiveSource, start, end)
return s.markCleaned(ctx, constants.AuditArchiveSource, date)
}
func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
func (s *Service) cleanupIntegrationDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
terminalCount, err := s.integrationTerminalCount(ctx, date, date.AddDate(0, 0, 1))
if err != nil {
return err
}
if run.CleanedAt != nil && terminalCount == 0 {
return nil
}
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, start, end); err != nil {
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, date); err != nil {
return err
}
end := date.AddDate(0, 0, 1)
for {
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").Where("created_at >= ? AND created_at < ? AND result <> ?", date, end, constants.IntegrationResultPending).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.IntegrationLog{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error)
@@ -474,15 +381,21 @@ func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.T
break
}
}
return s.markCleaned(ctx, constants.IntegrationArchiveSource, start, end)
return s.markCleaned(ctx, constants.IntegrationArchiveSource, date)
}
func (s *Service) integrationTerminalCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ? AND result <> ?", start, end, constants.IntegrationResultPending).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计终态 Integration Log 失败: %w", err)
}
return count, nil
}
func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").
Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").
Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).
Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEventResource{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event Resource 失败: %w", deleted.Error)
@@ -492,11 +405,9 @@ func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time
}
}
}
func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEvent{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEvent{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event 失败: %w", deleted.Error)
@@ -506,75 +417,19 @@ func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) e
}
}
}
func (s *Service) markCleanupStarted(ctx context.Context, source string, start, end time.Time) error {
func (s *Service) markCleanupStarted(ctx context.Context, source string, date time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理开始断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleanup_started_at IS NOT NULL", "开始")
}
func (s *Service) markCleaned(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleaned_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理完成断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleaned_at IS NOT NULL", "完成")
}
func (s *Service) validateCleanupMarkerCount(ctx context.Context, source string, start, end time.Time, marker, label string) error {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND "+marker,
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return fmt.Errorf("复核月度清理%s断点失败: %w", label, err)
}
expected := int64(end.Sub(start).Hours() / 24)
if count != expected {
return fmt.Errorf("月度清理%s断点不完整期望 %d 条,实际 %d 条", label, expected, count)
return fmt.Errorf("记录清理开始断点失败: %w", result.Error)
}
return nil
}
func allRunsCleaned(runs []*model.LogArchiveRun) bool {
return len(runs) > 0 && runs[0].CleanedAt != nil
}
func (s *Service) recordRetentionAudit(ctx context.Context, start, end time.Time, result RetentionResult, cleanupErr error) error {
audit := RetentionAudit{
Month: result.Month, RangeStart: start, RangeEnd: end,
EventCount: result.EventCount, ResourceCount: result.ResourceCount,
IntegrationCount: result.IntegrationCount, ManifestKeys: result.ManifestKeys,
DurationMS: result.Duration.Milliseconds(), Result: constants.AuditResultSuccess,
Summary: "完成已归档在线日志月度物理清理",
EventID: "evt_retention_" + strings.ReplaceAll(result.Month, "-", "_"),
func (s *Service) markCleaned(ctx context.Context, source string, date time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NOT NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleaned_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录日清理完成断点失败: %w", result.Error)
}
if cleanupErr != nil {
audit.EventID = ""
audit.Result = constants.AuditResultFailed
audit.Summary = "已归档在线日志月度物理清理失败"
audit.ErrorSummary = truncateRetentionError(cleanupErr)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRetentionCleanup(ctx, tx, audit)
})
}
func truncateRetentionError(err error) string {
value := []rune(err.Error())
if len(value) > 500 {
value = value[:500]
}
return string(value)
return nil
}

View File

@@ -0,0 +1,493 @@
// Package businessusergroup 收口业务用户组、成员归属与店铺负责人批量交接的写用例。
// 组只描述平台用户的业务分类,不改变后台角色、登录、权限或数据范围;
// 店铺所属组始终由当前负责人实时推导,因此本包不写任何店铺组字段。
package businessusergroup
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
)
// Service 业务用户组与成员归属的简单写事务脚本。
type Service struct {
db *gorm.DB
groupStore *postgres.BusinessUserGroupStore
auditWriter *audit.Writer
}
// New 创建业务用户组事务脚本。
func New(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore, auditWriters ...*audit.Writer) *Service {
service := &Service{db: db, groupStore: groupStore}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Create 创建业务用户组并在同一事务写入审计。
func (s *Service) Create(ctx context.Context, request *dto.CreateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) {
operatorID, err := s.requireOperator(ctx)
if err != nil {
return nil, err
}
code := strings.TrimSpace(request.Code)
name := strings.TrimSpace(request.Name)
if code == "" || name == "" {
return nil, errors.New(errors.CodeInvalidParam, "业务用户组编码与名称不能为空")
}
if !constants.IsValidBusinessLine(request.BusinessLine) {
return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法")
}
group := &model.BusinessUserGroup{
Code: code, Name: name, BusinessLine: request.BusinessLine,
SortOrder: sortValue(request.Sort), Status: statusValue(request.Enabled),
Remark: request.Remark, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
}
var response *dto.BusinessUserGroupResponse
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.groupStore.WithTx(tx)
exists, err := store.ExistsCode(ctx, code, 0)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验业务用户组编码失败")
}
if exists {
return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在")
}
if err := store.Create(ctx, group); err != nil {
return mapCodeConflict(err)
}
if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupCreated, "创建业务用户组", group, operatorID, nil, groupSnapshot(group)); err != nil {
return err
}
response = toGroupResponse(group)
return nil
}); err != nil {
return nil, err
}
return response, nil
}
// Update 更新业务用户组名称、业务线、排序、启停与备注;稳定编码永不允许修改。
func (s *Service) Update(ctx context.Context, groupID uint, request *dto.UpdateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) {
operatorID, err := s.requireOperator(ctx)
if err != nil {
return nil, err
}
if groupID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
if request.BusinessLine != nil && !constants.IsValidBusinessLine(*request.BusinessLine) {
return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法")
}
if request.Name != nil && strings.TrimSpace(*request.Name) == "" {
return nil, errors.New(errors.CodeInvalidParam, "业务用户组名称不能为空")
}
var response *dto.BusinessUserGroupResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.groupStore.WithTx(tx)
group, err := store.LockByID(ctx, groupID)
if err != nil {
return groupLookupError(err)
}
before := groupSnapshot(group)
if request.Name != nil {
group.Name = strings.TrimSpace(*request.Name)
}
if request.BusinessLine != nil {
group.BusinessLine = *request.BusinessLine
}
if request.Sort != nil {
group.SortOrder = *request.Sort
}
// 停用保留成员关系:已有成员继续显示已停用,只是不得新增成员或作为批量目标。
if request.Enabled != nil {
group.Status = statusValue(request.Enabled)
}
if request.Remark != nil {
group.Remark = *request.Remark
}
if err := store.Update(ctx, group, operatorID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新业务用户组失败")
}
after := groupSnapshot(group)
// 启停是独立状态事实,与资料变更分开记录,保证审计动作可被独立检索。
if before["status"] != after["status"] {
action, summary := constants.AuditActionBusinessUserGroupEnabled, "启用业务用户组"
if group.Status != constants.StatusEnabled {
action, summary = constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组"
}
if err := s.appendGroupAudit(ctx, tx, action, summary, group, operatorID,
map[string]any{"status": before["status"]}, map[string]any{"status": after["status"]}); err != nil {
return err
}
}
if groupProfileChanged(before, after) {
if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupUpdated, "更新业务用户组", group, operatorID, before, after); err != nil {
return err
}
}
response = toGroupResponse(group)
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
// Delete 删除无成员的业务用户组;有成员时只能停用或先移走成员。
func (s *Service) Delete(ctx context.Context, groupID uint) error {
operatorID, err := s.requireOperator(ctx)
if err != nil {
return err
}
if groupID == 0 {
return errors.New(errors.CodeInvalidParam)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.groupStore.WithTx(tx)
group, err := store.LockByID(ctx, groupID)
if err != nil {
return groupLookupError(err)
}
count, err := store.CountMembers(ctx, group.ID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "统计业务用户组成员失败")
}
if count > 0 {
return errors.New(errors.CodeInvalidStatus, "用户组仍有成员,只能停用或先移走成员")
}
before := groupSnapshot(group)
if err := store.Delete(ctx, group.ID, operatorID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除业务用户组失败")
}
return s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", group, operatorID, before, nil)
})
}
// SetMembers 把多个启用平台用户批量设置到指定启用组,直接替换每个账号的原归属。
// 任一账号无效则整批不修改,成员前后值审计与业务事实同事务。
func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.SetBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
operatorID, err := s.requireOperator(ctx)
if err != nil {
return nil, err
}
accountIDs, err := normalizeAccountIDs(request.AccountIDs)
if err != nil {
return nil, err
}
if groupID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
result := &dto.BusinessUserGroupMembersResult{GroupID: groupID, AccountIDs: accountIDs}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.groupStore.WithTx(tx)
group, err := store.LockByID(ctx, groupID)
if err != nil {
return groupLookupError(err)
}
if group.Status != constants.StatusEnabled {
return errors.New(errors.CodeInvalidStatus, "目标用户组已停用,不能作为成员归属目标")
}
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
return err
}
// 按 id 升序锁账号行:账号行锁保证同一账号串行化,
// 同时消除「清空时无成员行导致锁不到行」的幻读与「多账号相反顺序」的死锁。
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
}
before, err := store.MembersByAccountIDs(ctx, accountIDs)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
}
if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil {
return mapMemberWriteError(err)
}
return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID)
})
if err != nil {
return nil, err
}
return result, nil
}
// ClearMembers 清空指定启用平台用户的业务用户组归属,任一账号无效则整批不修改。
func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
operatorID, err := s.requireOperator(ctx)
if err != nil {
return nil, err
}
accountIDs, err := normalizeAccountIDs(request.AccountIDs)
if err != nil {
return nil, err
}
result := &dto.BusinessUserGroupMembersResult{AccountIDs: accountIDs}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.groupStore.WithTx(tx)
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
return err
}
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
}
before, err := store.MembersByAccountIDs(ctx, accountIDs)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
}
if err := store.ClearMembers(ctx, accountIDs); err != nil {
return mapMemberWriteError(err)
}
return s.appendMemberAudits(ctx, tx, nil, accountIDs, before, operatorID)
})
if err != nil {
return nil, err
}
return result, nil
}
// mapMemberWriteError 把成员关系写入失败收敛为稳定业务错误。
// 并发为同一账号新增成员关系时唯一索引是最终裁决,不能把约束冲突暴露成 500。
func mapMemberWriteError(err error) error {
if err == nil {
return nil
}
if postgres.IsAccountMemberConflict(err) {
return errors.New(errors.CodeConflict, "平台用户分组归属已被并发修改,请重试")
}
return errors.Wrap(errors.CodeDatabaseError, err, "更新平台用户分组失败")
}
// requireOperator 校验调用者具备平台维护入口身份,并返回其账号 ID。
func (s *Service) requireOperator(ctx context.Context) (uint, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized)
}
return operatorID, nil
}
// normalizeAccountIDs 去重并保持首次出现顺序,空集合视为非法参数。
func normalizeAccountIDs(values []uint) ([]uint, error) {
if len(values) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "账号列表不能为空")
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
return nil, errors.New(errors.CodeInvalidParam, "账号ID非法")
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result, nil
}
// ensureEnabledPlatformAccounts 校验全部账号都是当前启用的平台用户,任一不满足即整批失败。
// 账号有效性统一走共享谓词,避免各入口对「平台 + 启用 + 未软删」出现口径分叉。
func ensureEnabledPlatformAccounts(ctx context.Context, tx *gorm.DB, accountIDs []uint) error {
var accounts []model.Account
if err := tx.WithContext(ctx).Model(&model.Account{}).
Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验平台用户失败")
}
valid := 0
for _, account := range accounts {
if constants.IsAvailablePlatformBusinessOwner(account.UserType, account.Status, account.DeletedAt.Valid) {
valid++
}
}
if valid != len(accountIDs) {
return errors.New(errors.CodeInvalidParam, "存在无效或非启用的平台用户账号,整批未修改")
}
return nil
}
func groupLookupError(err error) error {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "业务用户组不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
}
// mapCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误,并发创建以唯一索引为最终裁决。
func mapCodeConflict(err error) error {
if err == nil {
return nil
}
if strings.Contains(strings.ToLower(err.Error()), "uk_business_user_group_code") {
return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建业务用户组失败")
}
func sortValue(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func statusValue(enabled *bool) int {
if enabled == nil || *enabled {
return constants.StatusEnabled
}
return constants.StatusDisabled
}
func toGroupResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse {
return &dto.BusinessUserGroupResponse{
ID: group.ID, Code: group.Code, Name: group.Name,
BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine),
Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark,
CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339),
}
}
// groupSnapshot 生成业务用户组的前后值快照,不含任何凭证或敏感信息。
func groupSnapshot(group *model.BusinessUserGroup) map[string]any {
if group == nil {
return nil
}
return map[string]any{
"id": group.ID, "code": group.Code, "name": group.Name,
"business_line": group.BusinessLine, "sort_order": group.SortOrder, "status": group.Status,
"remark": group.Remark,
}
}
// groupProfileChanged 判断除启停外的可维护字段是否发生变化;编码不可修改,不参与比较。
func groupProfileChanged(before, after map[string]any) bool {
for _, field := range []string{"name", "business_line", "sort_order", "remark"} {
if before[field] != after[field] {
return true
}
}
return false
}
// businessUserGroupKey 返回业务用户组审计资源的稳定 Key。
func businessUserGroupKey(group *model.BusinessUserGroup) string {
if group == nil {
return ""
}
if group.Code != "" {
return group.Code
}
return strconv.FormatUint(uint64(group.ID), 10)
}
// businessUserGroupIdentity 返回业务用户组审计身份快照,字段必须落在注册表白名单内。
func businessUserGroupIdentity(group *model.BusinessUserGroup) map[string]any {
if group == nil {
return nil
}
return map[string]any{
"id": group.ID, "code": group.Code, "name": group.Name,
"business_line": group.BusinessLine, "status": group.Status,
}
}
// appendGroupAudit 在业务事务内追加业务用户组事件。
func (s *Service) appendGroupAudit(ctx context.Context, tx *gorm.DB, action, summary string, group *model.BusinessUserGroup, operatorID uint, before, after map[string]any) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
}
var resourceID *string
if group.ID != 0 {
value := strconv.FormatUint(uint64(group.ID), 10)
resourceID = &value
}
s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceBusinessUserGroup, ID: resourceID,
Key: businessUserGroupKey(group), DisplayName: group.Name,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBusinessUserGroupTarget,
IdentitySnapshot: businessUserGroupIdentity(group), BeforeData: before, AfterData: after,
}},
})
return nil
}
// appendMemberAudits 在业务事务内为每个账号追加一条成员归属事件。
// 账号是实际被替换归属的资源,因此作为主要资源;目标组仅作引用,清空操作没有目标组。
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
}
var accounts []model.Account
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询成员审计账号失败")
}
accountByID := make(map[uint]model.Account, len(accounts))
for _, account := range accounts {
accountByID[account.ID] = account
}
summary := "清空平台用户业务用户组归属"
afterGroupID := any(nil)
if group != nil {
summary = "设置平台用户业务用户组归属"
afterGroupID = group.ID
}
for _, accountID := range accountIDs {
account, exists := accountByID[accountID]
if !exists {
continue
}
beforeGroupID := any(nil)
if member, ok := before[accountID]; ok {
beforeGroupID = member.BusinessUserGroupID
}
resource := audit.AccountResource(&account, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleAccountTarget)
resource.BeforeData = map[string]any{"business_user_group_id": beforeGroupID}
resource.AfterData = map[string]any{"business_user_group_id": afterGroupID}
resources := []audit.ResourceInput{resource}
if group != nil {
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceBusinessUserGroup, ID: optionalID(group.ID),
Key: businessUserGroupKey(group), DisplayName: group.Name,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleBusinessUserGroupTarget,
IdentitySnapshot: businessUserGroupIdentity(group), SortOrder: 1,
})
}
s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionBusinessUserGroupMembersUpdated, Summary: summary,
Result: constants.AuditResultSuccess,
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Resources: resources,
})
}
return nil
}
func optionalID(id uint) *string {
if id == 0 {
return nil
}
value := strconv.FormatUint(uint64(id), 10)
return &value
}

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,171 @@
// Package h5popup 提供 H5 风险换卡与运营弹窗的候选投放、风险地址提交与运营配置维护用例。
//
// 候选查询会创建或复用个人客户通知并保持未读,即 GET 有副作用,这是产品契约的一部分:
// 运营弹窗只在客户请求页面时实时匹配、不预生成通知,而投放事实又必须与「客户确实访问过」对齐。
package h5popup
import (
"context"
stderrors "errors"
"strings"
"time"
"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"
)
// shanghaiLocation 是每日去重键使用的上海自然日时区。
// 与 internal/query/packageexpiry 保持同一口径,避免跨自然日重投判定漂移。
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// AssetOwnership 校验当前个人客户是否持有指定资产的有效绑定。
// 归属判定必须使用权威实现 customer_binding.OwnsAsset换货服务内部只查设备绑定虚拟号的判定
// 对无虚拟号卡恒为假,直接复用会让无虚拟号的广电卡永远无法自助换卡。
type AssetOwnership interface {
OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error)
}
// assetFacts 是候选匹配与风险资格判定依赖的当前资产事实。
type assetFacts struct {
AssetType string
AssetID uint
Identifier string
ShopID *uint
CarrierType string
DeviceType string
// RiskStopped 只在卡资产上可能为真:运营商为广电且运营商扩展状态严格等于风险停机常量。
// 已销户不参与该判定,两者合并会把已销户卡一并当作风险换卡对象。
RiskStopped bool
}
// shanghaiDate 返回上海自然日的 yyyymmdd 文本。
func shanghaiDate(now time.Time) string {
return now.In(shanghaiLocation).Format("20060102")
}
// invisibleAssetError 统一「资产不存在」与「资产不属于当前客户」的返回,避免形成可枚举差异。
func invisibleAssetError() error {
return errors.New(errors.CodeAssetNotFound)
}
// isAssetNotFound 判断错误是否表示资产不存在或不可见(归属校验失败与资产不存在同态)。
func isAssetNotFound(err error) bool {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return appErr.Code == errors.CodeAssetNotFound
}
return false
}
// isRecordNotFound 判断错误是否为 GORM 未命中记录。
func isRecordNotFound(err error) bool {
return stderrors.Is(err, gorm.ErrRecordNotFound)
}
// resolveAssetIdentity 按客户端提交的 identifier 定位资产:(资产类型, 资产ID)。
// 复用既有解析口径:先查全局标识注册表,再按设备与卡的既有标识回退;
// 卡标识由 IotCardStore.GetByIdentifier 统一处理virtual_no/iccid/msisdn/iccid_19/iccid_20
// 与资产详情解析保持一致,避免自实现查询漏掉 iccid_19/iccid_20 造成静默不投放。
// 未命中返回空类型,由调用方按不可见处理。
func (s *CandidateService) resolveAssetIdentity(ctx context.Context, identifier string) (string, uint, error) {
record, err := s.identifiers.FindByIdentifier(ctx, identifier)
if err != nil {
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产标识失败")
}
if record != nil {
return record.AssetType, record.AssetID, nil
}
device, err := s.devices.GetByIdentifier(ctx, identifier)
if err == nil && device != nil {
return constants.AssetTypeDevice, device.ID, nil
}
if err != nil && !isRecordNotFound(err) {
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
card, err := s.cards.GetByIdentifier(ctx, identifier)
if err == nil && card != nil {
return constants.AssetTypeIotCard, card.ID, nil
}
if err != nil && !isRecordNotFound(err) {
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败")
}
return "", 0, nil
}
// loadAssetFacts 读取候选匹配与风险资格判定所需的资产事实。
func (s *CandidateService) loadAssetFacts(ctx context.Context, assetType string, assetID uint) (*assetFacts, error) {
switch assetType {
case constants.AssetTypeIotCard:
card, err := s.cards.GetByID(ctx, assetID)
if err != nil {
if isRecordNotFound(err) {
return nil, invisibleAssetError()
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡资产失败")
}
facts := &assetFacts{
AssetType: constants.AssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID,
ShopID: card.ShopID, CarrierType: card.CarrierType,
RiskStopped: card.CarrierType == constants.CarrierTypeCBN &&
strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop,
}
deviceType, err := s.boundDeviceType(ctx, card.ID)
if err != nil {
return nil, err
}
facts.DeviceType = deviceType
return facts, nil
case constants.AssetTypeDevice:
device, err := s.devices.GetByID(ctx, assetID)
if err != nil {
if isRecordNotFound(err) {
return nil, invisibleAssetError()
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备资产失败")
}
return &assetFacts{
AssetType: constants.AssetTypeDevice, AssetID: device.ID,
Identifier: deviceIdentifier(device), ShopID: device.ShopID, DeviceType: device.DeviceType,
}, nil
default:
return nil, invisibleAssetError()
}
}
// boundDeviceType 经卡—设备绑定推导设备类型快照。
// 独立卡或未绑定设备时该维度为空;空值不匹配任何已配置范围,只有「未配置范围」表示全量。
func (s *CandidateService) boundDeviceType(ctx context.Context, cardID uint) (string, error) {
var device model.Device
err := s.db.WithContext(ctx).
Table("tb_device AS d").
Joins("JOIN tb_device_sim_binding AS b ON b.device_id = d.id").
Where("b.iot_card_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL AND d.deleted_at IS NULL",
cardID, constants.BindStatusBound).
Order("b.is_current DESC, b.id DESC").
Select("d.*").
Take(&device).Error
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
}
return device.DeviceType, nil
}
// deviceIdentifier 按虚拟号、IMEI、SN 的稳定优先级生成设备标识快照。
func deviceIdentifier(device *model.Device) string {
if device == nil {
return ""
}
if device.VirtualNo != "" {
return device.VirtualNo
}
if device.IMEI != "" {
return device.IMEI
}
return device.SN
}

View File

@@ -0,0 +1,305 @@
package h5popup
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
)
// activeShippingExchangeStatuses 是压制风险候选的物流换货单状态集合。
// 含已完成的 4已完成物流换货单说明风险换卡已走完流程此时必须停止新投放
// 必须同时限定 flow_type=shipping直接换货单创建即已完成不限定会永久压制风险候选。
var activeShippingExchangeStatuses = []int{
constants.ExchangeStatusPendingInfo,
constants.ExchangeStatusPendingShip,
constants.ExchangeStatusShipped,
constants.ExchangeStatusCompleted,
}
// CandidateService 按当前资产事实投放风险换卡或运营弹窗候选。
// 查询会创建或复用通知并保持未读,即 GET 有副作用:运营弹窗只在客户请求页面时实时匹配、不预生成。
type CandidateService struct {
db *gorm.DB
identifiers *postgres.AssetIdentifierStore
cards *postgres.IotCardStore
devices *postgres.DeviceStore
ownership AssetOwnership
notifications notificationapp.DirectWriter
now func() time.Time
}
// NewCandidateService 创建 H5 弹窗候选投放用例。
// 资产标识解析复用既有 Store 方法,保证口径与资产详情、换货等入口一致。
func NewCandidateService(
db *gorm.DB,
identifiers *postgres.AssetIdentifierStore,
cards *postgres.IotCardStore,
devices *postgres.DeviceStore,
ownership AssetOwnership,
notifications notificationapp.DirectWriter,
) *CandidateService {
return &CandidateService{
db: db, identifiers: identifiers, cards: cards, devices: devices,
ownership: ownership, notifications: notifications, now: time.Now,
}
}
// GetCandidate 返回当前页面与当前资产的唯一弹窗候选;没有可投放弹窗时 candidate 为空。
// 顺序固定:先判风险换卡资格,命中则只处理风险分支;未命中再匹配运营配置。
func (s *CandidateService) GetCandidate(ctx context.Context, customerID uint, request dto.PopupCandidateRequest) (*dto.PopupCandidateResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
identifier := strings.TrimSpace(request.Identifier)
if !constants.IsH5PopupPage(request.Page) || identifier == "" {
return nil, errors.New(errors.CodeInvalidParam, "弹窗候选参数不合法")
}
if s == nil || s.db == nil || s.identifiers == nil || s.cards == nil || s.devices == nil ||
s.ownership == nil || s.notifications == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "弹窗投放能力尚未配置")
}
assetType, assetID, err := s.resolveAssetIdentity(ctx, identifier)
if err != nil {
return nil, err
}
if assetType == "" {
return nil, invisibleAssetError()
}
owned, err := s.ownership.OwnsAsset(ctx, customerID, assetType, assetID)
if err != nil {
if isAssetNotFound(err) {
return nil, invisibleAssetError()
}
return nil, err
}
if !owned {
return nil, invisibleAssetError()
}
facts, err := s.loadAssetFacts(ctx, assetType, assetID)
if err != nil {
return nil, err
}
now := s.now().UTC()
if facts.RiskStopped {
blocked, err := findActiveShippingExchange(ctx, s.db, facts.AssetType, facts.AssetID)
if err != nil {
return nil, err
}
if blocked == nil {
candidate, err := s.deliverRiskCandidate(ctx, customerID, facts, now)
if err != nil {
return nil, err
}
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
}
}
candidate, err := s.deliverOperationCandidate(ctx, customerID, request.Page, facts, now)
if err != nil {
return nil, err
}
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
}
// deliverRiskCandidate 创建或复用「客户+资产+上海自然日」的风险换卡通知。
// 当日通知已存在且未读时返回同一通知;已被客户关闭(已读)时当日不再返回候选,次日条件成立会创建新通知。
func (s *CandidateService) deliverRiskCandidate(ctx context.Context, customerID uint, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
notification, err := s.notifications.CreateOrGetPersonal(ctx, riskEventKey(customerID, facts, now), customerID, notificationapp.PersonalDirectRequest{
NotificationType: constants.NotificationTypeH5PopupRiskExchange,
RefType: constants.NotificationRefTypeAsset,
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
RefKey: facts.Identifier,
ExpiresAt: popupExpiresAt(now),
PopupSnapshot: &model.NotificationPopupSnapshot{
AssetType: facts.AssetType, AssetID: facts.AssetID,
},
})
if err != nil {
return nil, err
}
if notification.IsRead {
return nil, nil
}
return toCandidateItem(notification), nil
}
// deliverOperationCandidate 匹配运营配置并按频率创建或复用运营弹窗通知。
// 只返回优先级最高一条;同优先级取最近更新时间最新,启停同样刷新该时间。
func (s *CandidateService) deliverOperationCandidate(ctx context.Context, customerID uint, page string, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
config, err := s.matchOperationConfig(ctx, page, facts, now)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
notification, err := s.notifications.CreateOrGetPersonal(ctx, operationEventKey(customerID, config, now), customerID, notificationapp.PersonalDirectRequest{
NotificationType: constants.NotificationTypeH5PopupOperation,
TemplateData: map[string]string{"title": config.Title, "content": config.Content},
RefType: constants.NotificationRefTypeAsset,
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
RefKey: facts.Identifier,
ExpiresAt: popupExpiresAt(now),
PopupSnapshot: &model.NotificationPopupSnapshot{
ConfigID: config.ID, ConfigVersion: config.Version,
AssetType: facts.AssetType, AssetID: facts.AssetID, ActionType: config.ActionType,
},
})
if err != nil {
return nil, err
}
if notification.IsRead {
return nil, nil
}
return toCandidateItem(notification), nil
}
// matchOperationConfig 按时间、启停、页面、店铺、设备类型、卡类型范围匹配运营配置。
// 范围同一维度多选取任一命中;未配置该维度即全量;已配置而资产该维度无值时该配置不命中。
func (s *CandidateService) matchOperationConfig(ctx context.Context, page string, facts *assetFacts, now time.Time) (*model.H5PopupConfiguration, error) {
pageJSON, err := jsonbScalar(page)
if err != nil {
return nil, err
}
var shopID *string
if facts.ShopID != nil {
text := strconv.FormatUint(uint64(*facts.ShopID), 10)
shopID = &text
}
shopJSON, err := jsonbScalarPointer(shopID)
if err != nil {
return nil, err
}
deviceJSON, err := jsonbScalar(facts.DeviceType)
if err != nil {
return nil, err
}
cardJSON, err := jsonbScalar(facts.CarrierType)
if err != nil {
return nil, err
}
var config model.H5PopupConfiguration
err = s.db.WithContext(ctx).Model(&model.H5PopupConfiguration{}).
Where("enabled = ?", constants.H5PopupStatusEnabled).
Where("starts_at <= ? AND ends_at >= ?", now, now).
Where("?::jsonb <@ pages", pageJSON).
Where("(jsonb_array_length(shop_ids) = 0 OR ?::jsonb <@ shop_ids)", shopJSON).
Where("(jsonb_array_length(device_types) = 0 OR ?::jsonb <@ device_types)", deviceJSON).
Where("(jsonb_array_length(card_types) = 0 OR ?::jsonb <@ card_types)", cardJSON).
Order("priority DESC, updated_at DESC, id DESC").
Take(&config).Error
if err != nil {
if isRecordNotFound(err) {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配运营弹窗配置失败")
}
return &config, nil
}
// findActiveShippingExchange 查询指定资产是否已存在活动物流换货单。
// 取 flow_type=shipping 且状态属于待填写、待发货、已发货待确认、已完成,任一命中即视为已处理。
func findActiveShippingExchange(ctx context.Context, db *gorm.DB, assetType string, assetID uint) (*model.ExchangeOrder, error) {
var order model.ExchangeOrder
err := db.WithContext(ctx).
Where("old_asset_type = ? AND old_asset_id = ? AND flow_type = ?", assetType, assetID, constants.ExchangeFlowTypeShipping).
Where("status IN ?", activeShippingExchangeStatuses).
Order("id DESC").
Take(&order).Error
if err != nil {
if isRecordNotFound(err) {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动物流换货单失败")
}
return &order, nil
}
// riskEventKey 生成风险换卡通知事件键:客户 + 资产 + 上海自然日,复用通知唯一约束保证一天一条。
func riskEventKey(customerID uint, facts *assetFacts, now time.Time) string {
return popupEventKey(constants.H5PopupRiskEventKeyPrefix+"."+shanghaiDate(now),
strconv.FormatUint(uint64(customerID), 10), facts.AssetType, strconv.FormatUint(uint64(facts.AssetID), 10))
}
// operationEventKey 生成运营弹窗通知事件键:客户 + 配置 + 版本daily 频率再追加上海自然日。
// 频率口径按「每客户每配置支持仅一次或每天一次」,因此键内不含资产,客户换资产不会额外获得投放。
func operationEventKey(customerID uint, config *model.H5PopupConfiguration, now time.Time) string {
prefix := constants.H5PopupOperationOnceEventKeyPrefix
if config.Frequency == constants.H5PopupFrequencyDaily {
prefix = constants.H5PopupOperationDailyEventKeyPrefix + "." + shanghaiDate(now)
}
return popupEventKey(prefix,
strconv.FormatUint(uint64(customerID), 10), strconv.FormatUint(uint64(config.ID), 10), strconv.FormatInt(config.Version, 10))
}
// popupEventKey 生成固定长度的通知事件键:前缀 + 身份摘要。
// tb_notification.event_id 为 varchar(64),身份部分用 sha256 前 12 字节十六进制压缩,
// 保证资产与客户 ID 位数增长后仍不超长,同时保持确定性以便复用既有唯一约束去重。
func popupEventKey(prefix string, parts ...string) string {
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
return prefix + "." + hex.EncodeToString(sum[:12])
}
// popupExpiresAt 返回弹窗投放通知的展示截止时间:投放时间 + 90 天。
// 弹窗类别沿用 system展示上限 365 天90 天在其内,事实物理保留仍按系统类别的 365 天。
func popupExpiresAt(now time.Time) *time.Time {
expiresAt := now.AddDate(0, 0, constants.H5PopupDisplayDays)
return &expiresAt
}
// jsonbScalar 将字符串编码为可直接参与 jsonb 包含判断的 JSON 标量。
func jsonbScalar(value string) (string, error) {
encoded, err := sonic.Marshal(value)
if err != nil {
return "", errors.Wrap(errors.CodeInternalError, err, "编码弹窗匹配值失败")
}
return string(encoded), nil
}
// jsonbScalarPointer 将可空字符串编码为 JSON 标量nil 编码为 JSON null任何已配置范围都不命中。
func jsonbScalarPointer(value *string) (string, error) {
if value == nil {
return "null", nil
}
return jsonbScalar(*value)
}
// toCandidateItem 将冻结的通知投影为客户端候选;配置标识与受控动作取通知快照
// 而不是当前配置,保证配置修改后旧通知与旧快照不被改写。
func toCandidateItem(notification *model.Notification) *dto.PopupCandidateItem {
if notification == nil {
return nil
}
item := &dto.PopupCandidateItem{
NotificationID: notification.ID, NotificationType: notification.Type,
Title: notification.Title, Body: notification.Body,
ExpiresAt: notification.ExpiresAt, CreatedAt: notification.CreatedAt,
}
if notification.Type == constants.NotificationTypeH5PopupRiskExchange {
item.PopupType = constants.H5PopupCandidateTypeRiskExchange
} else {
item.PopupType = constants.H5PopupCandidateTypeOperation
}
if snapshot := notification.PopupSnapshot; snapshot != nil {
item.AssetType = snapshot.AssetType
item.AssetID = snapshot.AssetID
item.ConfigID = snapshot.ConfigID
item.ConfigVersion = snapshot.ConfigVersion
item.ActionType = snapshot.ActionType
}
return item
}

View File

@@ -0,0 +1,490 @@
package h5popup
import (
"context"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"gorm.io/gorm"
"gorm.io/gorm/clause"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
var (
// popupURLPattern 匹配任意 URL 形态带协议的绝对地址、www 前缀或站点域名。
// 弹窗只允许受控动作,前端按 action_type 白名单映射页面,不接受运营配置下发跳转目标。
popupURLPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.\-]*://|www\.|\.(com|cn|net|org)(/|$|\s))`)
// popupRoutePattern 匹配前端路由形态:以 / 开头的路径片段或 /#/ 哈希路由。
popupRoutePattern = regexp.MustCompile(`(^|[\s(])/[A-Za-z#]`)
)
// ConfigurationService 维护 H5 运营弹窗配置。
// 配置只决定后续投放:更新在事务内递增版本,启停只改启停位并刷新最近更新时间,两者都记录前后值与版本。
type ConfigurationService struct {
db *gorm.DB
audit *audit.Writer
}
// NewConfigurationService 创建运营弹窗配置事务脚本。
func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationService {
return &ConfigurationService{db: db, audit: audit}
}
// configurationInput 是校验后的配置值,创建与更新共用同一套归一化规则。
type configurationInput struct {
Title string
Content string
Pages []string
ShopIDs []uint
DeviceTypes []string
CardTypes []string
Priority int
Frequency string
ActionType string
Enabled int
StartsAt time.Time
EndsAt time.Time
}
// Create 创建运营弹窗配置,初始版本为 1并在同一事务内写入配置审计。
func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5PopupConfigurationRequest) (uint, error) {
operatorID, err := requirePlatformOperator(ctx)
if err != nil {
return 0, err
}
if err = s.ensureConfigured(); err != nil {
return 0, err
}
enabled := constants.H5PopupStatusDisabled
if request.Enabled != nil && *request.Enabled {
enabled = constants.H5PopupStatusEnabled
}
priority := 0
if request.Priority != nil {
priority = *request.Priority
}
actionType := ""
if request.ActionType != nil {
actionType = *request.ActionType
}
normalized, err := normalizeConfigurationInput(configurationInput{
Title: request.Title, Content: request.Content, Pages: request.Pages,
ShopIDs: request.ShopIDs, DeviceTypes: request.DeviceTypes, CardTypes: request.CardTypes,
Priority: priority, Frequency: request.Frequency, ActionType: actionType,
Enabled: enabled, StartsAt: request.StartsAt, EndsAt: request.EndsAt,
})
if err != nil {
return 0, err
}
now := time.Now().UTC()
record := &model.H5PopupConfiguration{
Title: normalized.Title, Content: normalized.Content,
Pages: model.StringJSONBArray(normalized.Pages), ShopIDs: toJSONBStrings(normalized.ShopIDs),
DeviceTypes: model.StringJSONBArray(normalized.DeviceTypes), CardTypes: model.StringJSONBArray(normalized.CardTypes),
Priority: normalized.Priority, Frequency: normalized.Frequency, ActionType: normalized.ActionType,
Enabled: normalized.Enabled, StartsAt: normalized.StartsAt, EndsAt: normalized.EndsAt,
Version: 1, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
CreatedAt: now, UpdatedAt: now,
}
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, "创建运营弹窗配置失败")
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationCreate,
Description: "创建运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
})
})
if err != nil {
return 0, err
}
return record.ID, nil
}
// Update 更新运营弹窗配置:合并入参后整体校验,事务内递增版本并刷新最近更新时间。
// 旧版本已投放通知的内容与快照不被改写,新版本可向原命中客户按频率重新投放。
func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.UpdateH5PopupConfigurationRequest) error {
operatorID, err := requirePlatformOperator(ctx)
if err != nil {
return err
}
if err = s.ensureConfigured(); err != nil {
return err
}
if id == 0 {
return errors.New(errors.CodeH5PopupConfigurationNotFound)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
record, err := lockConfiguration(ctx, tx, id)
if err != nil {
return err
}
before := *record
beforeData := configurationAuditSnapshot(&before)
merged := configurationInput{
Title: record.Title, Content: record.Content, Pages: storePages(record),
ShopIDs: storeShopIDs(record), DeviceTypes: storeDeviceTypes(record), CardTypes: storeCardTypes(record),
Priority: record.Priority, Frequency: record.Frequency, ActionType: record.ActionType,
Enabled: record.Enabled, StartsAt: record.StartsAt, EndsAt: record.EndsAt,
}
if request.Title != nil {
merged.Title = *request.Title
}
if request.Content != nil {
merged.Content = *request.Content
}
if request.Pages != nil {
merged.Pages = *request.Pages
}
if request.ShopIDs != nil {
merged.ShopIDs = *request.ShopIDs
}
if request.DeviceTypes != nil {
merged.DeviceTypes = *request.DeviceTypes
}
if request.CardTypes != nil {
merged.CardTypes = *request.CardTypes
}
if request.Priority != nil {
merged.Priority = *request.Priority
}
if request.Frequency != nil {
merged.Frequency = *request.Frequency
}
if request.ActionType != nil {
merged.ActionType = *request.ActionType
}
if request.Enabled != nil {
merged.Enabled = enabledStatus(*request.Enabled)
}
if request.StartsAt != nil {
merged.StartsAt = *request.StartsAt
}
if request.EndsAt != nil {
merged.EndsAt = *request.EndsAt
}
normalized, err := normalizeConfigurationInput(merged)
if err != nil {
return err
}
now := time.Now().UTC()
record.Title = normalized.Title
record.Content = normalized.Content
record.Pages = model.StringJSONBArray(normalized.Pages)
record.ShopIDs = toJSONBStrings(normalized.ShopIDs)
record.DeviceTypes = model.StringJSONBArray(normalized.DeviceTypes)
record.CardTypes = model.StringJSONBArray(normalized.CardTypes)
record.Priority = normalized.Priority
record.Frequency = normalized.Frequency
record.ActionType = normalized.ActionType
record.Enabled = normalized.Enabled
record.StartsAt = normalized.StartsAt
record.EndsAt = normalized.EndsAt
record.Version++
record.Updater = operatorID
record.UpdatedAt = now
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置失败")
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationUpdate,
Description: "更新运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
}); err != nil {
return err
}
return nil
})
}
// SetEnabled 启停运营弹窗配置,只影响后续候选,并必须刷新最近更新时间。
// 启停不递增版本:版本表达配置内容变化,频率去重键因此保持不变,已投放通知不会被再次投放。
func (s *ConfigurationService) SetEnabled(ctx context.Context, id uint, enabled bool) error {
operatorID, err := requirePlatformOperator(ctx)
if err != nil {
return err
}
if err = s.ensureConfigured(); err != nil {
return err
}
if id == 0 {
return errors.New(errors.CodeH5PopupConfigurationNotFound)
}
operationType := constants.AuditOperationH5PopupConfigurationDisable
description := "停用运营弹窗配置"
if enabled {
operationType = constants.AuditOperationH5PopupConfigurationEnable
description = "启用运营弹窗配置"
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
record, err := lockConfiguration(ctx, tx, id)
if err != nil {
return err
}
beforeData := configurationAuditSnapshot(record)
now := time.Now().UTC()
record.Enabled = enabledStatus(enabled)
record.Updater = operatorID
record.UpdatedAt = now
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置启停失败")
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: operationType,
Description: description, ConfigKey: configurationAuditKey(record.ID),
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
}); err != nil {
return err
}
return nil
})
}
func (s *ConfigurationService) ensureConfigured() error {
if s == nil || s.db == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
return nil
}
// requirePlatformOperator 校验当前调用者仅限超级管理员与平台账号,并返回其账号 ID。
// 非上述身份与资源不存在返回同一禁止访问错误,避免形成可枚举差异。
func requirePlatformOperator(ctx context.Context) (uint, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized)
}
return operatorID, nil
}
// lockConfiguration 以行锁读取运营弹窗配置,未找到返回稳定不存在错误。
func lockConfiguration(ctx context.Context, tx *gorm.DB, id uint) (*model.H5PopupConfiguration, error) {
var record model.H5PopupConfiguration
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Take(&record).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeH5PopupConfigurationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置失败")
}
return &record, nil
}
// normalizeConfigurationInput 归一化并校验配置,创建与更新共用同一套规则。
// 拒绝任意 URL 与前端路由是应用层第一道保险,通知渲染的 URL 拦截是第二道。
func normalizeConfigurationInput(input configurationInput) (configurationInput, error) {
normalized := input
normalized.Title = strings.TrimSpace(input.Title)
if runes := utf8.RuneCountInString(normalized.Title); runes < 1 || runes > 100 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题长度必须在 1100 字符之间")
}
// 标题与正文同一口径:两者都会冻结进通知并参与渲染,任一都不接受 URL 或前端路由。
if popupURLPattern.MatchString(normalized.Title) || popupRoutePattern.MatchString(normalized.Title) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题不接受 URL 或前端路由,只能使用受控动作")
}
normalized.Content = strings.TrimSpace(input.Content)
if runes := utf8.RuneCountInString(normalized.Content); runes < 1 || runes > 2000 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文长度必须在 12000 字符之间")
}
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
}
normalized.Pages = dedupeStrings(input.Pages)
if len(normalized.Pages) == 0 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗至少需要一个命中页面")
}
if len(normalized.Pages) > 4 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面超出受控范围")
}
for _, page := range normalized.Pages {
if !constants.IsH5PopupPage(page) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面不在受控白名单内")
}
}
normalized.ShopIDs = dedupeShopIDs(input.ShopIDs)
if len(normalized.ShopIDs) > 200 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗店铺范围超过 200 项")
}
normalized.DeviceTypes = dedupeStrings(input.DeviceTypes)
if len(normalized.DeviceTypes) > 100 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型范围超过 100 项")
}
for _, deviceType := range normalized.DeviceTypes {
if utf8.RuneCountInString(deviceType) > 50 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型超过 50 字符")
}
}
// 卡类型是与 tb_iot_card.carrier_type 直接比较的受控枚举,统一大写后再校验。
normalized.CardTypes = dedupeStrings(upperStrings(input.CardTypes))
if len(normalized.CardTypes) > 4 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型范围超过受控取值数量")
}
for _, cardType := range normalized.CardTypes {
if !constants.IsCarrierType(cardType) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型不在受控白名单内")
}
}
if normalized.Priority < 0 || normalized.Priority > 1000000 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗优先级必须在 01000000 之间")
}
if !constants.IsH5PopupFrequency(normalized.Frequency) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗投放频率不在受控白名单内")
}
if !constants.IsH5PopupActionType(normalized.ActionType) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗受控动作不在受控白名单内")
}
if normalized.Enabled != constants.H5PopupStatusEnabled && normalized.Enabled != constants.H5PopupStatusDisabled {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗启停状态不合法")
}
if normalized.StartsAt.IsZero() || normalized.EndsAt.IsZero() {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗必须同时提供生效开始与结束时间")
}
normalized.StartsAt = normalized.StartsAt.UTC()
normalized.EndsAt = normalized.EndsAt.UTC()
if normalized.EndsAt.Before(normalized.StartsAt) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗结束时间不得早于开始时间")
}
return normalized, nil
}
// enabledStatus 把布尔启停转换为 0/1 状态。
func enabledStatus(enabled bool) int {
if enabled {
return constants.H5PopupStatusEnabled
}
return constants.H5PopupStatusDisabled
}
// dedupeStrings 去空白并按出现顺序去重,保留原始大小写。
func dedupeStrings(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
// upperStrings 去空白并统一大写,供受控枚举范围使用;空白项不保留。
func upperStrings(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
result = append(result, strings.ToUpper(trimmed))
}
return result
}
// dedupeShopIDs 去重店铺 ID 并丢弃非法值。
func dedupeShopIDs(values []uint) []uint {
result := make([]uint, 0, len(values))
seen := make(map[uint]struct{}, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
// toJSONBStrings 将店铺 ID 编码为 JSONB 文本数组,与范围匹配的文本比较口径一致。
func toJSONBStrings(values []uint) model.StringJSONBArray {
encoded := make(model.StringJSONBArray, 0, len(values))
for _, value := range values {
encoded = append(encoded, strconv.FormatUint(uint64(value), 10))
}
return encoded
}
func storePages(record *model.H5PopupConfiguration) []string {
return append([]string{}, record.Pages...)
}
func storeDeviceTypes(record *model.H5PopupConfiguration) []string {
return append([]string{}, record.DeviceTypes...)
}
func storeCardTypes(record *model.H5PopupConfiguration) []string {
return append([]string{}, record.CardTypes...)
}
// storeShopIDs 将 JSONB 店铺范围还原为 ID 列表用于合并更新。
func storeShopIDs(record *model.H5PopupConfiguration) []uint {
shopIDs := make([]uint, 0, len(record.ShopIDs))
for _, value := range record.ShopIDs {
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 {
continue
}
shopIDs = append(shopIDs, uint(parsed))
}
return shopIDs
}
func configurationAuditKey(id uint) string {
return constants.H5PopupAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
}
func configurationAuditResourceID(id uint) *string {
value := strconv.FormatUint(uint64(id), 10)
return &value
}
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
return map[string]any{
"id": record.ID, "title": record.Title, "pages": storePages(record),
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
"enabled": record.Enabled, "version": record.Version,
}
}
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖范围、优先级、频率、受控动作、启停、有效期与版本。
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
return map[string]any{
"id": record.ID, "title": record.Title, "content": record.Content,
"pages": storePages(record), "shop_ids": storeShopIDs(record),
"device_types": storeDeviceTypes(record), "card_types": storeCardTypes(record),
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
"enabled": record.Enabled, "starts_at": record.StartsAt, "ends_at": record.EndsAt,
"version": record.Version, "updated_at": record.UpdatedAt,
}
}

View File

@@ -0,0 +1,170 @@
package h5popup
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
// RiskExchangeService 处理个人客户自助风险换卡的地址提交。
// 幂等靠「锁定旧资产行 + 去重查询既有活动物流换货单」实现,不引入数据库唯一约束:
// 资产实例同一时刻只属于一个客户,锁资产行即可覆盖重复提交与并发提交。
type RiskExchangeService struct {
db *gorm.DB
ownership AssetOwnership
auditWriter *audit.Writer
}
// NewRiskExchangeService 创建风险换卡地址提交事务脚本。
func NewRiskExchangeService(db *gorm.DB, ownership AssetOwnership, auditWriter *audit.Writer) *RiskExchangeService {
return &RiskExchangeService{db: db, ownership: ownership, auditWriter: auditWriter}
}
// Submit 幂等提交风险换卡收货地址,创建关联旧资产的物流换货单。
// 事务内顺序固定为:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才插入。
// 重复提交返回首次创建的换货单与首次地址,不覆盖既有地址。
func (s *RiskExchangeService) Submit(ctx context.Context, customerID, assetID uint, request dto.ClientRiskExchangeAddressParams) (*dto.ClientRiskExchangeResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if assetID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法")
}
if s == nil || s.db == nil || s.ownership == nil || s.auditWriter == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置")
}
// 归属校验必须使用权威实现;资产不存在与归属失败返回同态不可见结果。
owned, err := s.ownership.OwnsAsset(ctx, customerID, constants.AssetTypeIotCard, assetID)
if err != nil {
if isAssetNotFound(err) {
return nil, invisibleAssetError()
}
return nil, err
}
if !owned {
return nil, invisibleAssetError()
}
var result *dto.ClientRiskExchangeResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", assetID).Take(&card).Error; err != nil {
if isRecordNotFound(err) {
return invisibleAssetError()
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定换卡资产失败")
}
// 锁内复核风险资格:持锁前的判定可能已被并发状态同步改变。
if card.CarrierType != constants.CarrierTypeCBN ||
strings.TrimSpace(card.GatewayExtend) != constants.GatewayCardExtendRiskStop {
return errors.New(errors.CodeH5PopupRiskNotEligible)
}
existing, err := findActiveShippingExchange(ctx, tx, constants.AssetTypeIotCard, card.ID)
if err != nil {
return err
}
if existing != nil {
result = toRiskExchangeResponse(existing)
return nil
}
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeShipping,
OldAssetType: constants.AssetTypeIotCard,
OldAssetID: card.ID,
OldAssetIdentifier: card.ICCID,
RecipientName: request.RecipientName,
RecipientPhone: request.RecipientPhone,
RecipientAddress: request.RecipientAddress,
ShopID: card.ShopID,
ExchangeReason: constants.H5PopupRiskExchangeReason,
// 客户已提交收货信息,因此创建即待发货;不预设业务数据迁移,发货选新资产时仍由后台按既有流程决定。
Status: constants.ExchangeStatusPendingShip,
MigrateData: false,
MigrationStatus: constants.ExchangeMigrationStatusNotMigrated,
// H5 客户上下文没有后台账号 ID置 0 表示由客户自助发起,不冒用任何后台账号身份。
BaseModel: model.BaseModel{Creator: 0, Updater: 0},
}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建风险换卡单失败")
}
result = toRiskExchangeResponse(order)
return s.appendRiskExchangeAudit(ctx, tx, customerID, order, &card)
})
if err != nil {
return nil, err
}
return result, nil
}
// appendRiskExchangeAudit 在同一事务内记录客户自助换卡的状态事实与旧卡引用。
func (s *RiskExchangeService) appendRiskExchangeAudit(ctx context.Context, tx *gorm.DB, customerID uint, order *model.ExchangeOrder, card *model.IotCard) error {
orderID := strconv.FormatUint(uint64(order.ID), 10)
cardID := strconv.FormatUint(uint64(card.ID), 10)
customerText := strconv.FormatUint(uint64(customerID), 10)
summary := "客户自助提交风险换卡地址"
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionCardRiskExchangeRequested, Summary: summary,
Actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: customerText},
Source: constants.AuditSourcePersonalAPI,
// 个人客户本人业务范围;不使用 platform避免把客户自助事实记成后台操作。
ScopeType: constants.AuditScopePersonalCustomer, ScopeID: customerText,
Result: constants.AuditResultSuccess,
Metadata: map[string]any{"flow_type": constants.ExchangeFlowTypeShipping, "migrate_data": false},
Resources: []audit.ResourceInput{
{
Type: constants.AuditResourceExchangeOrder, ID: &orderID, Key: order.ExchangeNo, DisplayName: order.ExchangeNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
IdentitySnapshot: map[string]any{
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": order.FlowType,
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID,
"old_asset_identifier": order.OldAssetIdentifier, "shop_id": order.ShopID, "status": order.Status,
},
AfterData: map[string]any{
"status": order.Status, "migrate_data": order.MigrateData, "migration_status": order.MigrationStatus,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
},
{
Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeOldCard,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
},
},
})
}
// toRiskExchangeResponse 将换货单投影为地址提交结果。
// 地址取记录中的既有值:重复提交返回首次地址,不做任何覆盖。
func toRiskExchangeResponse(order *model.ExchangeOrder) *dto.ClientRiskExchangeResponse {
if order == nil {
return nil
}
return &dto.ClientRiskExchangeResponse{
ID: order.ID, ExchangeNo: order.ExchangeNo,
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status),
FlowType: order.FlowType,
OldAssetType: order.OldAssetType,
OldAssetID: order.OldAssetID,
OldAssetIdentifier: order.OldAssetIdentifier,
RecipientName: order.RecipientName,
RecipientPhone: order.RecipientPhone,
RecipientAddress: order.RecipientAddress,
MigrateData: order.MigrateData,
MigrationStatus: order.MigrationStatus,
MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus),
ExchangeReason: order.ExchangeReason,
CreatedAt: order.CreatedAt,
}
}

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

@@ -51,6 +51,7 @@ type deliveryRequest struct {
refID string
refKey string
expiresAt *time.Time
popupSnapshot *model.NotificationPopupSnapshot
}
// DeliveryService 校验接收人并幂等生成站内通知。
@@ -123,6 +124,7 @@ func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.De
notificationType: payload.NotificationType, templateData: payload.TemplateData,
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
}
// 载荷校验必须先于接收人解析:无效事件不应触发接收人查询。
if err := validateDeliveryRequest(request); err != nil {
return err
}
@@ -153,19 +155,45 @@ func validateDeliveryRequest(request deliveryRequest) error {
if request.refType != "" && request.refID == "" && request.refKey == "" {
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
}
// 投放快照与弹窗类型必须成对出现:非弹窗类型不得写快照,弹窗类型不得缺少快照。
isPopup := constants.IsH5PopupNotificationType(request.notificationType)
if request.popupSnapshot != nil && !isPopup {
return errors.New(errors.CodeInvalidParam, "投放快照只允许用于弹窗通知类型")
}
if isPopup && request.popupSnapshot == nil {
return errors.New(errors.CodeInvalidParam, "弹窗通知缺少投放快照")
}
return nil
}
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
// preparedDelivery 是一次事件共享的渲染结果、展示期与审计来源,与接收人数量无关。
type preparedDelivery struct {
rendered notificationinfra.Rendered
now time.Time
expiresAt *time.Time
origin deliveryOrigin
}
// deliveryOrigin 是投递审计的操作者与入口。
// Outbox 消费路径留空,由统一审计从任务上下文补齐(与既有 worker 入口一致);
// API 直投路径必须显式提供,因为个人客户请求上下文不携带审计上下文。
type deliveryOrigin struct {
actor audit.ActorInput
source string
}
// prepareDelivery 渲染模板并计算展示期;同一事件只计算一次,不随接收人重复计算。
// 审计接缝缺失在此一次性判空:与既有行为一致,渲染之前就失败,而不是按接收人重复判断。
func (s *DeliveryService) prepareDelivery(eventID, recipientKind string, request deliveryRequest, origin deliveryOrigin) (*preparedDelivery, error) {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
return nil, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
if err != nil {
s.logger.Error("站内通知模板校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "template"))
return errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
return nil, errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
}
now := s.now().UTC()
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
@@ -173,46 +201,23 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
s.logger.Error("站内通知展示期限校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "display_policy"))
return nil, err
}
return &preparedDelivery{rendered: rendered, now: now, expiresAt: expiresAt, origin: origin}, nil
}
// deliver 对每个接收人执行同一套单接收人投放规则;接收人不可用时跳过,不影响其他接收人。
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
prepared, err := s.prepareDelivery(eventID, recipientKind, request, deliveryOrigin{})
if err != nil {
return err
}
for _, recipientID := range recipientIDs {
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
notification, created, err := s.deliverOne(ctx, eventID, recipientKind, recipientID, request, prepared)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
return err
}
if !active {
s.logger.Info("站内通知接收人不可用,已跳过",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
continue
}
notification := &model.Notification{
EventID: eventID, RecipientKind: recipientKind,
RecipientID: recipientID, Category: rendered.Category, Type: rendered.Type,
Severity: rendered.Severity, Title: rendered.Title, Body: rendered.Body,
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
ExpiresAt: expiresAt, CreatedAt: now,
}
created := false
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var createErr error
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
if createErr != nil || !created {
return createErr
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Metadata: map[string]any{"outbox_event_id": eventID},
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
nil, map[string]any{"created": true, "is_read": false})},
})
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
}
if !created {
if notification != nil && !created {
s.logger.Info("站内通知重复事件已幂等忽略",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
}
@@ -220,6 +225,60 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
return nil
}
// deliverOne 校验接收人并在单事务内幂等写入一条通知。
// 事件键与接收人已存在时不重复投放回查并返回既有行created=false接收人不可用时返回 (nil, false, nil)。
// Outbox 消费与候选查询直投共用本方法,落库规则只有一处。
func (s *DeliveryService) deliverOne(ctx context.Context, eventID, recipientKind string, recipientID uint, request deliveryRequest, prepared *preparedDelivery) (*model.Notification, bool, error) {
if eventID == "" || recipientID == 0 || prepared == nil {
return nil, false, errors.New(errors.CodeInvalidParam, "通知事件或接收人不完整")
}
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
if err != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
}
if !active {
s.logger.Info("站内通知接收人不可用,已跳过",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
return nil, false, nil
}
notification := &model.Notification{
EventID: eventID, RecipientKind: recipientKind,
RecipientID: recipientID, Category: prepared.rendered.Category, Type: prepared.rendered.Type,
Severity: prepared.rendered.Severity, Title: prepared.rendered.Title, Body: prepared.rendered.Body,
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
ExpiresAt: prepared.expiresAt, CreatedAt: prepared.now, PopupSnapshot: request.popupSnapshot,
}
created := false
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var createErr error
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
if createErr != nil || !created {
return createErr
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
Actor: prepared.origin.actor, Source: prepared.origin.source,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Metadata: map[string]any{"outbox_event_id": eventID},
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
nil, map[string]any{"created": true, "is_read": false})},
})
})
if err != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
}
if created {
return notification, true, nil
}
existing, err := s.repository.FindByEventRecipient(ctx, eventID, recipientKind, recipientID)
if err != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "回查既有站内通知失败")
}
return existing, false, nil
}
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
switch category {
case constants.NotificationCategoryApproval:

View File

@@ -0,0 +1,63 @@
package notification
import (
"context"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PersonalDirectRequest 是当次直投或复用个人客户通知的请求参数。
// PopupSnapshot 只允许弹窗投放类型携带,其余类型必须为空。
type PersonalDirectRequest struct {
NotificationType string
TemplateData map[string]string
RefType string
RefID string
RefKey string
ExpiresAt *time.Time
PopupSnapshot *model.NotificationPopupSnapshot
}
// DirectWriter 是「当次创建或复用个人客户通知」的窄接口。
// 候选查询必须当次拿到可用通知标识,不能依赖 Outbox 消费延迟,因此需要这条同步入口。
type DirectWriter interface {
CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error)
}
// CreateOrGetPersonal 当次渲染并幂等写入个人客户通知;事件键已存在时不重复投放,回查并返回既有行。
// 与 Outbox 消费共用同一渲染、展示期与幂等写入规则,避免两条链路规则漂移。
func (s *DeliveryService) CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error) {
if customerID == 0 || eventID == "" {
return nil, errors.New(errors.CodeInvalidParam, "个人客户通知参数不完整")
}
delivery := deliveryRequest{
notificationType: request.NotificationType, templateData: request.TemplateData,
refType: request.RefType, refID: request.RefID, refKey: request.RefKey,
expiresAt: request.ExpiresAt, popupSnapshot: request.PopupSnapshot,
}
if err := validateDeliveryRequest(delivery); err != nil {
return nil, err
}
// API 直投不经过 Outbox 消费,自行提供渲染结果与展示期,但仍复用同一落库规则。
// 个人客户请求上下文不携带审计上下文,直投必须显式声明操作者与入口,否则投递审计会被入口规则拒绝并静默降级。
prepared, err := s.prepareDelivery(eventID, constants.NotificationRecipientKindPersonalCustomer, delivery, deliveryOrigin{
actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: strconv.FormatUint(uint64(customerID), 10)},
source: constants.AuditSourcePersonalAPI,
})
if err != nil {
return nil, err
}
notification, _, err := s.deliverOne(ctx, eventID, constants.NotificationRecipientKindPersonalCustomer, customerID, delivery, prepared)
if err != nil {
return nil, err
}
if notification == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户通知接收人不可用")
}
return notification, nil
}

View File

@@ -214,7 +214,12 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
constants.NotificationRecipientKindPersonalCustomer,
customerID,
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
[]string{
constants.NotificationTypePackageExpiring,
constants.NotificationTypeExchangeShippingCreated,
constants.NotificationTypeH5PopupRiskExchange,
constants.NotificationTypeH5PopupOperation,
},
now,
)
}

View File

@@ -4,10 +4,14 @@ package refundapproval
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"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"
@@ -20,6 +24,8 @@ type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
// Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识。
Attempt *model.RefundRequestAttempt
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
@@ -28,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 接收退款申请事务内审计事实。
@@ -38,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
@@ -54,12 +71,115 @@ 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, "退款审批能力未配置")
}
var refund model.RefundRequest
if err := s.db.WithContext(ctx).First(&refund, refundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史退款申请失败")
}
if refund.Status != model.RefundStatusPending || refund.ApprovalInstanceID != nil {
return nil, errors.New(errors.CodeConflict, "退款申请状态不允许补发审批")
}
account, err := s.loadSubmitter(ctx, refund.Creator)
if err != nil {
return nil, err
}
var order model.Order
if err := s.db.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "退款关联订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: refund.Creator,
CorrelationID: refund.RefundNo,
})
if err != nil {
return nil, err
}
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 {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史退款申请失败")
}
if current.Status != model.RefundStatusPending || current.ApprovalInstanceID != nil {
return errors.New(errors.CodeConflict, "退款申请状态不允许补发审批")
}
var currentOrder model.Order
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: 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
}
current.ApprovalInstanceID = &reference.InstanceID
refund = current
order = currentOrder
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: &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 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)
}
@@ -85,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
@@ -120,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) {
@@ -140,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,
@@ -153,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

@@ -0,0 +1,252 @@
package shop
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// BusinessOwnerBatchChange 描述一家店铺在批量交接中的负责人前后事实。
// 账号快照用于审计引用资源,已软删账号同样保留历史事实。
type BusinessOwnerBatchChange struct {
Shop *model.Shop
BeforeOwnerID *uint
AfterOwnerID *uint
PreviousOwner *model.Account
Owner *model.Account
}
// BusinessOwnerBatchAudit 描述一次批量交接的批次根事实与逐店子事实。
// Result 为空表示成功批次,由实现写批次根事件与逐店子事件;
// 非空表示业务回滚后的失败或拒绝事实,此时只写批次根事件。
type BusinessOwnerBatchAudit struct {
BatchKey string
Operation string
Result string
OperatorID uint
Total int
Owner *model.Account
Changes []BusinessOwnerBatchChange
}
// BusinessOwnerBatchAuditWriter 接收店铺负责人批量交接受理事务内的审计事实。
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
type BusinessOwnerBatchAuditWriter interface {
WriteBusinessOwnerBatch(ctx context.Context, tx *gorm.DB, batch BusinessOwnerBatchAudit) error
}
// SetBatchBusinessOwnerAudit 注入批量交接的批次审计接缝。
func (s *BatchBusinessOwnerService) SetBatchBusinessOwnerAudit(writer BusinessOwnerBatchAuditWriter) {
s.batchAudit = writer
}
// BatchBusinessOwnerService 收口勾选店铺批量设置或清空平台业务员负责人的事务脚本。
// 全量预校验通过后在同一事务内统一更新并逐店写审计;任一项失败整批不修改,
// 且失败文案不区分无权、不存在与已删除。
type BatchBusinessOwnerService struct {
db *gorm.DB
batchAudit BusinessOwnerBatchAuditWriter
}
// NewBatchBusinessOwnerService 创建店铺负责人批量交接事务脚本。
func NewBatchBusinessOwnerService(db *gorm.DB) *BatchBusinessOwnerService {
return &BatchBusinessOwnerService{db: db}
}
// Execute 批量设置或清空店铺负责人。
func (s *BatchBusinessOwnerService) Execute(ctx context.Context, request *dto.BatchUpdateShopBusinessOwnerRequest) (*dto.BatchUpdateShopBusinessOwnerResult, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if !request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeInvalidParam, "必须显式提交业务员归属字段null 表示清空")
}
shopIDs, err := normalizeShopIDs(request.ShopIDs)
if err != nil {
return nil, err
}
if s.batchAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺负责人批量交接统一审计接缝未配置")
}
operation := "clear"
if request.BusinessOwnerAccountID != nil {
operation = "assign"
}
batchKey := batchEventPrefix + uuid.NewString()
var result *dto.BatchUpdateShopBusinessOwnerResult
txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedShops, err := lockManageableShops(ctx, tx, shopIDs)
if err != nil {
return err
}
// 命中数不等于请求数即失败,不区分越权、不存在与已删除,避免泄露店铺存在性。
if len(lockedShops) != len(shopIDs) {
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
var owner *uint
var ownerAccount *model.Account
if request.BusinessOwnerAccountID != nil {
account, err := validateBatchBusinessOwner(ctx, tx, *request.BusinessOwnerAccountID)
if err != nil {
return err
}
ownerID := account.ID
owner, ownerAccount = &ownerID, account
}
update := tx.WithContext(ctx).Model(&model.Shop{}).Where("id IN ?", shopIDs).
Updates(map[string]any{
"business_owner_account_id": owner, "updater": operatorID, "updated_at": time.Now(),
})
if update.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, update.Error, "批量更新店铺负责人失败")
}
if int(update.RowsAffected) != len(shopIDs) {
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
if err := s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
BatchKey: batchKey, Operation: operation, OperatorID: operatorID,
Total: len(shopIDs), Owner: ownerAccount,
Changes: collectBatchChanges(ctx, tx, lockedShops, owner, ownerAccount),
}); err != nil {
return err
}
result = &dto.BatchUpdateShopBusinessOwnerResult{
BatchKey: batchKey, ShopCount: len(shopIDs), Cleared: owner == nil, BusinessOwnerAccountID: owner,
}
return nil
})
if txErr != nil {
s.recordFailure(ctx, batchKey, operation, operatorID, shopIDs, txErr)
return nil, txErr
}
return result, nil
}
// collectBatchChanges 装配逐店审计事实:锁定的店铺携带变更前负责人,
// 原负责人账号按一次批量查询载入,目标账号快照由调用方复用,避免 N+1。
func collectBatchChanges(ctx context.Context, tx *gorm.DB, shops []*model.Shop, owner *uint, ownerAccount *model.Account) []BusinessOwnerBatchChange {
previousIDs := make([]uint, 0, len(shops))
seen := make(map[uint]struct{}, len(shops))
for _, shop := range shops {
if shop.BusinessOwnerAccountID == nil {
continue
}
id := *shop.BusinessOwnerAccountID
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
previousIDs = append(previousIDs, id)
}
previous := make(map[uint]*model.Account, len(previousIDs))
if len(previousIDs) > 0 {
var accounts []*model.Account
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", previousIDs).Find(&accounts).Error; err == nil {
for _, account := range accounts {
previous[account.ID] = account
}
}
}
changes := make([]BusinessOwnerBatchChange, 0, len(shops))
for _, shop := range shops {
change := BusinessOwnerBatchChange{Shop: shop, BeforeOwnerID: shop.BusinessOwnerAccountID, AfterOwnerID: owner, Owner: ownerAccount}
if shop.BusinessOwnerAccountID != nil {
change.PreviousOwner = previous[*shop.BusinessOwnerAccountID]
}
changes = append(changes, change)
}
return changes
}
// recordFailure 在业务回滚后使用独立短事务记录批次失败或拒绝事实。
// 二次写入失败不能静默丢弃,按 pkg/auditfailure 既有先例上报为关键级失败。
func (s *BatchBusinessOwnerService) recordFailure(ctx context.Context, batchKey, operation string, operatorID uint, shopIDs []uint, originalErr error) {
writeErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
BatchKey: batchKey, Operation: operation, Result: shopAuditFailureResult(originalErr),
OperatorID: operatorID, Total: len(shopIDs),
})
})
if writeErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionShopBusinessOwnerBatchUpdated,
batchKey, "", batchKey, strconv.Itoa(errorCodeOf(originalErr)), writeErr)
}
}
// errorCodeOf 返回稳定错误的编码文本,非稳定错误归入内部错误码。
func errorCodeOf(err error) int {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return appErr.Code
}
return errors.CodeInternalError
}
// batchBusinessOwnerFailureMessage 复用平台维护入口的统一失败文案,不区分无权、不存在与已删除。
const batchBusinessOwnerFailureMessage = constants.PlatformManagementForbiddenMessage
// batchEventPrefix 是批次根事件标识前缀,与随机后缀共同保证稳定且不超审计列宽。
const batchEventPrefix = "shop-owner-batch:"
// normalizeShopIDs 去重并保持首次出现顺序,空集合视为非法参数。
func normalizeShopIDs(values []uint) ([]uint, error) {
if len(values) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "店铺ID列表不能为空")
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
return nil, errors.New(errors.CodeInvalidParam, "店铺ID非法")
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result, nil
}
// lockManageableShops 在数据范围约束下按主键加行锁读取全部目标店铺。
func lockManageableShops(ctx context.Context, tx *gorm.DB, shopIDs []uint) ([]*model.Shop, error) {
query := middleware.ApplyShopIDFilter(ctx, tx.WithContext(ctx).Model(&model.Shop{}))
var shops []*model.Shop
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定批量交接目标店铺失败")
}
return shops, nil
}
// validateBatchBusinessOwner 校验目标账号是当前启用的平台业务员。
func validateBatchBusinessOwner(ctx context.Context, tx *gorm.DB, accountID uint) (*model.Account, error) {
if accountID == 0 {
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
var account model.Account
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "SHARE"}).
Where("id = ? AND user_type = ? AND status = ?", accountID, constants.UserTypePlatform, constants.StatusEnabled).
First(&account).Error; err != nil {
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
return &account, 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

@@ -16,8 +16,21 @@ import (
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
type UpdateService struct {
db *gorm.DB
audit accessauditapp.Writer
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 "未知审批业务类型"
}
return "员工线下代充值审批"
}
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {

View File

@@ -1,6 +1,11 @@
package bootstrap
import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
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"
@@ -15,13 +20,18 @@ import (
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
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"
businessUserGroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup"
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"
h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -56,6 +66,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB)
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB)
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
businessUserGroupStore := postgres.NewBusinessUserGroupStore(deps.DB)
carrierStore := postgres.NewCarrierStore(deps.DB)
rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis)
paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis)
@@ -86,7 +97,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
svc.WechatConfig,
svc.Order,
packageSeriesStore,
shopSeriesAllocationStore,
@@ -108,9 +118,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 {
@@ -154,6 +168,24 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
))
svc.Account.SetWeComMemberFinder(wecomMembers)
// H5 弹窗候选必须当次返回可用通知标识,因此 API 进程直接复用 Outbox 消费的同一套渲染、展示期与幂等写入规则。
notificationAudit := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
notificationDirectWriter := notificationApp.NewDeliveryService(
notificationInfra.NewRepository(deps.DB), notificationInfra.NewRegistry(), nil, deps.Logger, notificationAudit,
)
// 资产标识解析复用既有 Store 方法,保证与资产详情、换货入口同一口径。
candidateService := h5PopupApp.NewCandidateService(
deps.DB,
postgres.NewAssetIdentifierStore(deps.DB),
postgres.NewIotCardStore(deps.DB, deps.Redis),
postgres.NewDeviceStore(deps.DB, deps.Redis),
svc.CustomerBinding,
notificationDirectWriter,
)
riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit)
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
@@ -173,7 +205,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
@@ -192,22 +224,44 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
ClientPopup: app.NewClientPopupHandler(candidateService, riskExchangeService, validate),
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
}(),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: func() *admin.ShopCommissionHandler {
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
ShopRole: admin.NewShopRoleHandler(svc.Shop),
BusinessUserGroup: func() *admin.BusinessUserGroupHandler {
handler := admin.NewBusinessUserGroupHandler(
businessUserGroupApp.New(deps.DB, businessUserGroupStore, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)),
validate,
)
handler.SetQuery(businessUserGroupQuery.NewQuery(deps.DB, businessUserGroupStore))
batchService := shopApp.NewBatchBusinessOwnerService(deps.DB)
batchService.SetBatchBusinessOwnerAudit(auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
handler.SetBatchService(batchService)
return handler
}(),
ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(svc.ShopBusinessOwnerImport),
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(svc.PhoneAssetAssociation, validate),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: func() *admin.ShopCommissionHandler {
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),
@@ -218,7 +272,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(popupConfigurationService, popupConfigurationQuery, validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
@@ -284,11 +339,25 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetAssetService(svc.Asset)
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
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"
@@ -65,8 +69,10 @@ import (
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
phoneAssetAssociationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
shopBusinessOwnerImportSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
@@ -87,6 +93,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 +136,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
@@ -135,6 +145,8 @@ type services struct {
CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service
ShopBusinessOwnerImport *shopBusinessOwnerImportSvc.Service
PhoneAssetAssociation *phoneAssetAssociationSvc.Service
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService
@@ -235,6 +247,7 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
iotCard.SetEnterpriseCardAuthStore(s.EnterpriseCardAuthorization)
iotCard.SetEnterpriseStore(s.Enterprise)
iotCard.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
iotCard.SetRedisClient(deps.Redis)
device := deviceSvc.New(
deps.DB,
@@ -253,6 +266,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.Enterprise,
)
device.SetAccessAudit(auditWriter)
device.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
@@ -264,6 +278,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,8 +304,10 @@ 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),
auditWriter,
)
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(
@@ -295,6 +315,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,
@@ -312,14 +337,25 @@ 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)
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder)
assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
assetService.SetAccessAudit(auditWriter)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB)
wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB)
@@ -348,6 +384,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 +399,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,
@@ -400,6 +452,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PersonalCustomerOpenID,
s.PersonalCustomer,
s.PersonalCustomerPhone,
s.PhoneAssetAssociation,
s.IotCard,
s.Device,
wechatConfig,
@@ -413,6 +466,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 +509,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,
@@ -461,8 +518,13 @@ func initServices(s *stores, deps *Dependencies) *services {
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
ShopBusinessOwnerImport: shopBusinessOwnerImportSvc.New(s.ShopBusinessOwnerImportTask, deps.QueueClient, auditWriter),
PhoneAssetAssociation: phoneAssetAssociationSvc.New(
deps.DB, s.PhoneAssetAssociation, s.PhoneAssetUnbindImportTask,
s.AssetIdentifier, s.IotCard, s.Device, deps.QueueClient, auditWriter,
),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
}
}

View File

@@ -17,6 +17,7 @@ type stores struct {
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
CommissionRecord *postgres.CommissionRecordStore
CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore
@@ -69,6 +70,11 @@ type stores struct {
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
// 资产套餐批量订购任务
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
// 业务用户组与成员归属
BusinessUserGroup *postgres.BusinessUserGroupStore
// 店铺负责人 CSV 导入任务
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
// 流量系统
CardDailyUsage *postgres.CardDailyUsageStore
// 资产标识符注册表
@@ -89,6 +95,7 @@ func initStores(deps *Dependencies) *stores {
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB),
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis),
@@ -128,15 +135,18 @@ func initStores(deps *Dependencies) *stores {
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
// 资产钱包系统
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB),
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
}
}

View File

@@ -25,10 +25,13 @@ type Handlers struct {
ClientDevice *app.ClientDeviceHandler
ClientRechargeOrder *app.ClientRechargeOrderHandler
ClientNotification *app.ClientNotificationHandler
ClientPopup *app.ClientPopupHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
ShopCommission *admin.ShopCommissionHandler
WithdrawalQualification *admin.WithdrawalQualificationHandler
AgentDistribution *app.AgentDistributionHandler
CommissionWithdrawal *admin.CommissionWithdrawalHandler
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
@@ -39,6 +42,7 @@ type Handlers struct {
IotCardImport *admin.IotCardImportHandler
ExportTask *admin.ExportTaskHandler
Notification *admin.NotificationHandler
H5PopupConfiguration *admin.H5PopupConfigurationHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler
@@ -68,10 +72,15 @@ 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
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
BusinessUserGroup *admin.BusinessUserGroupHandler
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler

View File

@@ -106,6 +106,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.GatewayClient, deps.Logger,
)
iotCardAuditService.SetAccessAudit(auditWriter)
iotCardAuditService.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
cardObservationService.SetStateAuditWriter(iotCardAuditService)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
@@ -171,6 +172,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation,
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, nil, nil,
)
deviceBatchAllocator.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
return &queue.WorkerServices{
PaymentAudit: auditWriter,

View File

@@ -6,105 +6,114 @@ import (
)
type workerStores struct {
AssetAllocationRecord *postgres.AssetAllocationRecordStore
IotCardImportTask *postgres.IotCardImportTaskStore
IotCard *postgres.IotCardStore
DeviceImportTask *postgres.DeviceImportTaskStore
ExportTask *postgres.ExportTaskStore
ExportShardTask *postgres.ExportShardTaskStore
Device *postgres.DeviceStore
DeviceSimBinding *postgres.DeviceSimBindingStore
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
ShopPackageAllocation *postgres.ShopPackageAllocationStore
CommissionRecord *postgres.CommissionRecordStore
Shop *postgres.ShopStore
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
PackageSeries *postgres.PackageSeriesStore
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
Package *postgres.PackageStore
PackageUsage *postgres.PackageUsageStore
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
PollingAlertRule *postgres.PollingAlertRuleStore
PollingAlertHistory *postgres.PollingAlertHistoryStore
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AssetWallet *postgres.AssetWalletStore
AssetIdentifier *postgres.AssetIdentifierStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
AssetAllocationRecord *postgres.AssetAllocationRecordStore
IotCardImportTask *postgres.IotCardImportTaskStore
IotCard *postgres.IotCardStore
DeviceImportTask *postgres.DeviceImportTaskStore
ExportTask *postgres.ExportTaskStore
ExportShardTask *postgres.ExportShardTaskStore
Device *postgres.DeviceStore
DeviceSimBinding *postgres.DeviceSimBindingStore
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
ShopPackageAllocation *postgres.ShopPackageAllocationStore
CommissionRecord *postgres.CommissionRecordStore
Shop *postgres.ShopStore
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
PackageSeries *postgres.PackageSeriesStore
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
Package *postgres.PackageStore
PackageUsage *postgres.PackageUsageStore
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
PollingAlertRule *postgres.PollingAlertRuleStore
PollingAlertHistory *postgres.PollingAlertHistoryStore
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AssetWallet *postgres.AssetWalletStore
AssetIdentifier *postgres.AssetIdentifierStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
}
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
stores := &workerStores{
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Package: postgres.NewPackageStore(deps.DB),
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Package: postgres.NewPackageStore(deps.DB),
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
}
return &queue.WorkerStores{
AssetAllocationRecord: stores.AssetAllocationRecord,
IotCardImportTask: stores.IotCardImportTask,
IotCard: stores.IotCard,
DeviceImportTask: stores.DeviceImportTask,
ExportTask: stores.ExportTask,
ExportShardTask: stores.ExportShardTask,
Device: stores.Device,
DeviceSimBinding: stores.DeviceSimBinding,
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
ShopPackageAllocation: stores.ShopPackageAllocation,
CommissionRecord: stores.CommissionRecord,
Shop: stores.Shop,
ShopSeriesAllocation: stores.ShopSeriesAllocation,
PackageSeries: stores.PackageSeries,
Order: stores.Order,
OrderItem: stores.OrderItem,
Package: stores.Package,
PackageUsage: stores.PackageUsage,
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
PollingAlertRule: stores.PollingAlertRule,
PollingAlertHistory: stores.PollingAlertHistory,
DataCleanupConfig: stores.DataCleanupConfig,
DataCleanupLog: stores.DataCleanupLog,
AgentWallet: stores.AgentWallet,
AgentWalletTransaction: stores.AgentWalletTransaction,
AssetWallet: stores.AssetWallet,
AssetIdentifier: stores.AssetIdentifier,
PersonalCustomer: stores.PersonalCustomer,
PersonalCustomerPhone: stores.PersonalCustomerPhone,
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
AssetAllocationRecord: stores.AssetAllocationRecord,
IotCardImportTask: stores.IotCardImportTask,
IotCard: stores.IotCard,
DeviceImportTask: stores.DeviceImportTask,
ExportTask: stores.ExportTask,
ExportShardTask: stores.ExportShardTask,
Device: stores.Device,
DeviceSimBinding: stores.DeviceSimBinding,
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
ShopPackageAllocation: stores.ShopPackageAllocation,
CommissionRecord: stores.CommissionRecord,
Shop: stores.Shop,
ShopSeriesAllocation: stores.ShopSeriesAllocation,
PackageSeries: stores.PackageSeries,
Order: stores.Order,
OrderItem: stores.OrderItem,
Package: stores.Package,
PackageUsage: stores.PackageUsage,
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
PollingAlertRule: stores.PollingAlertRule,
PollingAlertHistory: stores.PollingAlertHistory,
DataCleanupConfig: stores.DataCleanupConfig,
DataCleanupLog: stores.DataCleanupLog,
AgentWallet: stores.AgentWallet,
AgentWalletTransaction: stores.AgentWalletTransaction,
AssetWallet: stores.AssetWallet,
AssetIdentifier: stores.AssetIdentifier,
PersonalCustomer: stores.PersonalCustomer,
PersonalCustomerPhone: stores.PersonalCustomerPhone,
PhoneAssetAssociation: stores.PhoneAssetAssociation,
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
ShopBusinessOwnerImportTask: stores.ShopBusinessOwnerImportTask,
PhoneAssetUnbindImportTask: stores.PhoneAssetUnbindImportTask,
}
}

View File

@@ -3,6 +3,7 @@ package agentrecharge
import (
"strings"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -23,25 +24,28 @@ const (
// PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。
type PaymentConfirmationFacts struct {
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
FrozenMerchant bool
FrozenMerchantPaymentMethod string
FrozenMerchantProviderType string
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
}
// ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。
@@ -51,12 +55,28 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付单与代理充值单关联不一致")
}
method := strings.TrimSpace(facts.PaymentMethod)
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) ||
method != strings.TrimSpace(facts.RechargePaymentChannel) {
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
channel := strings.TrimSpace(facts.RechargePaymentChannel)
if channel != method && !(method == constants.RechargeMethodWechat && channel == model.ProviderTypeFuiou) {
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

@@ -0,0 +1,40 @@
package exporter
import (
"context"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// loadAssociatedPhonesByAsset 按本批资产集合一次 IN 批量读取当前有效关联手机号。
// 两类导出都必须走本方法:逐资产查询会形成 N+1且导出是分片批处理批量读是唯一可行口径。
// 同一资产存在多项有效关系时按关系创建顺序以「、」连接为单个单元格;无关联时返回空串。
func loadAssociatedPhonesByAsset(ctx context.Context, db *gorm.DB, assetType string, assetIDs []uint) (map[uint]string, error) {
cells := make(map[uint]string, len(assetIDs))
if len(assetIDs) == 0 {
return cells, nil
}
var rows []struct {
AssetID uint `gorm:"column:asset_id"`
Phone string `gorm:"column:phone"`
}
if err := db.WithContext(ctx).Table("tb_phone_asset_association").
Select("asset_id", "phone").
Where("asset_type = ? AND status = ? AND asset_id IN ?",
assetType, constants.PhoneAssetAssociationStatusValid, assetIDs).
Order("asset_id ASC, id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
grouped := make(map[uint][]string, len(rows))
for _, row := range rows {
grouped[row.AssetID] = append(grouped[row.AssetID], row.Phone)
}
for assetID, phones := range grouped {
cells[assetID] = strings.Join(phones, "、")
}
return cells, nil
}

View File

@@ -0,0 +1,213 @@
package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// CommissionRecordDataSource 佣金明细导出数据源。
// 粒度为佣金记录:原佣金与回溯明细各占一行,金额保持分并在展示层转元,
// 负数金额与可为负的余额原样导出,不因符号或余额不足被裁剪。
type CommissionRecordDataSource struct {
db *gorm.DB
}
// NewCommissionRecordDataSource 创建佣金明细导出数据源。
func NewCommissionRecordDataSource(db *gorm.DB) *CommissionRecordDataSource {
return &CommissionRecordDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *CommissionRecordDataSource) Scene() string {
return constants.ExportTaskSceneCommissionRecord
}
// Count 统计原佣金与回溯明细的合并行数。
func (s *CommissionRecordDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var originalTotal int64
if err := s.originalBranch(ctx, params).Count(&originalTotal).Error; err != nil {
return 0, err
}
var clawbackTotal int64
if err := s.clawbackBranch(ctx, params).Count(&clawbackTotal).Error; err != nil {
return 0, err
}
return int(originalTotal + clawbackTotal), nil
}
// Headers 返回佣金明细导出表头。
func (s *CommissionRecordDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"记录来源", "记录ID", "代理店铺名称", "关联订单号", "资产标识", "佣金来源",
"金额(元)", "是否可提现", "状态", "回溯后佣金余额(元)",
"原佣金记录ID", "来源退款单号", "佣金入账时间", "生成时间",
}, nil
}
// Fetch 按 offset/limit 查询合并后的佣金明细导出数据。
func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
union := s.db.WithContext(ctx).
Raw("SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback",
s.originalBranch(ctx, params), s.clawbackBranch(ctx, params))
var items []commissionRecordExportRow
query := s.db.WithContext(ctx).Table("(?) AS ledger", union).
Select(`
ledger.source,
ledger.id,
COALESCE(sh.shop_name, '') AS shop_name,
ledger.order_no,
COALESCE(NULLIF(ledger.iccid, ''), ledger.virtual_no, '') AS asset_identifier,
ledger.commission_source,
ledger.amount,
ledger.withdrawable,
ledger.status,
ledger.balance_after,
ledger.original_commission_id,
ledger.refund_no,
ledger.released_at,
ledger.created_at
`).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = ledger.shop_id").
// 合并后统一排序并分页,保证两类记录落在同一结果集,任一条不缺失也不重复。
Order("ledger.created_at DESC").Order("ledger.id DESC").Order("ledger.source ASC").
Limit(limit).Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
formatCommissionLedgerSource(item.Source),
strconv.FormatUint(uint64(item.ID), 10),
item.ShopName,
item.OrderNo,
item.AssetIdentifier,
formatCommissionSource(item.CommissionSource),
formatMoneyYuan(item.Amount),
formatCommissionWithdrawable(item.Source, item.Withdrawable),
constants.GetCommissionRecordStatusName(item.Status),
formatMoneyYuan(item.BalanceAfter),
formatOptionalUint(item.OriginalCommissionID),
item.RefundNo,
formatOptionalTime(item.ReleasedAt),
item.CreatedAt.Format(exportTimeLayout),
})
}
return rows, nil
}
// originalBranch 构造原佣金导出分支:自带场景筛选与数据范围。
func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params ExportParams) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_commission_record AS c").
Where("c.deleted_at IS NULL").
Joins("LEFT JOIN tb_order o ON c.order_id = o.id AND o.deleted_at IS NULL").
Joins("LEFT JOIN tb_iot_card ic ON c.iot_card_id = ic.id AND ic.deleted_at IS NULL").
Joins("LEFT JOIN tb_device d ON c.device_id = d.id AND d.deleted_at IS NULL").
Select(`'` + sourceOriginal + `' AS source, c.id, c.shop_id, c.order_id, o.order_no, ` +
`ic.iccid, d.virtual_no, c.commission_source, c.amount, c.balance_after, c.status, ` +
`c.released_at, c.created_at, NULL::bigint AS original_commission_id, ''::varchar AS refund_no, ` +
`NULL::boolean AS withdrawable`)
query = applyExportShopScope(query, params, "c.shop_id")
return applyCommissionExportFilters(query, params, "c.shop_id", "c.commission_source", "c.status", "o.order_no")
}
// clawbackBranch 构造回溯明细导出分支:资产维度取原佣金关联的卡或设备,保持与原佣金同一口径。
func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params ExportParams) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_commission_clawback_record AS g").
Joins("LEFT JOIN tb_commission_record oc ON oc.id = g.original_commission_id").
Joins("LEFT JOIN tb_order o ON g.order_id = o.id AND o.deleted_at IS NULL").
Joins("LEFT JOIN tb_iot_card ic ON oc.iot_card_id = ic.id AND ic.deleted_at IS NULL").
Joins("LEFT JOIN tb_device d ON oc.device_id = d.id AND oc.deleted_at IS NULL").
Select(`'` + sourceClawback + `' AS source, g.id, g.shop_id, g.order_id, ` +
`COALESCE(NULLIF(g.order_no, ''), o.order_no) AS order_no, ic.iccid, d.virtual_no, ` +
`g.commission_source, g.amount, g.balance_after, g.status, ` +
`NULL::timestamp AS released_at, g.created_at, g.original_commission_id, g.refund_no, g.withdrawable`)
query = applyExportShopScope(query, params, "g.shop_id")
return applyCommissionExportFilters(query, params, "g.shop_id", "g.commission_source", "g.status", "g.order_no")
}
// 导出分支来源标识与后台列表保持一致,便于导出结果与列表逐行核对。
const (
sourceOriginal = "original"
sourceClawback = "clawback"
)
// applyCommissionExportFilters 把佣金明细导出的筛选条件应用到单个分支。
func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColumn, sourceColumn, statusColumn, orderNoColumn string) *gorm.DB {
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where(shopColumn+" = ?", shopID)
}
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where(statusColumn+" = ?", status)
}
if source, ok := filterString(params.Filters, "commission_source"); ok {
query = query.Where(sourceColumn+" = ?", source)
}
if orderNo, ok := filterString(params.Filters, "order_no"); ok {
query = query.Where(orderNoColumn+" = ?", orderNo)
}
return query
}
// commissionRecordExportRow 是佣金明细导出的合并行投影,金额一律保持分。
type commissionRecordExportRow struct {
Source string `gorm:"column:source"`
ID uint `gorm:"column:id"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
AssetIdentifier string `gorm:"column:asset_identifier"`
CommissionSource string `gorm:"column:commission_source"`
Amount int64 `gorm:"column:amount"`
Withdrawable *bool `gorm:"column:withdrawable"`
Status int `gorm:"column:status"`
BalanceAfter int64 `gorm:"column:balance_after"`
OriginalCommissionID *uint `gorm:"column:original_commission_id"`
RefundNo string `gorm:"column:refund_no"`
ReleasedAt *time.Time `gorm:"column:released_at"`
CreatedAt time.Time `gorm:"column:created_at"`
}
// formatCommissionLedgerSource 把记录来源转为导出用中文描述。
func formatCommissionLedgerSource(source string) string {
if source == sourceClawback {
return "回溯明细"
}
return "原佣金"
}
// formatCommissionSource 把佣金来源转为导出用中文描述。
func formatCommissionSource(source string) string {
switch source {
case model.CommissionSourceCostDiff:
return "成本价差"
case model.CommissionSourceOneTime:
return "一次性佣金"
case "":
return ""
default:
return source
}
}
// formatCommissionWithdrawable 把可提现标识转为导出用中文描述。
// 原佣金不参与可提现判定,留空;回溯明细恒为不可提现。
func formatCommissionWithdrawable(source string, withdrawable *bool) string {
if source != sourceClawback || withdrawable == nil {
return ""
}
if *withdrawable {
return "可提现"
}
return "不可提现"
}

View File

@@ -14,8 +14,13 @@ import (
const (
deviceExportBaseHeaderCount = 6
deviceExportCardGroupSize = 5
deviceExportTailHeaderCount = 5
deviceExportMinCardGroups = 1
// deviceExportTailHeaderCount 是当前尾部固定列数(含新增的「关联手机号」列)。
deviceExportTailHeaderCount = 6
// deviceExportLegacyTailHeaderCount 是新增尾部列之前的固定列数,仅供历史任务表头反解回退使用。
deviceExportLegacyTailHeaderCount = 5
deviceExportMinCardGroups = 1
// deviceExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。
deviceExportAssociatedPhoneHeader = "关联手机号"
)
// DeviceDataSource 设备导出数据源。
@@ -89,9 +94,14 @@ func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offse
return nil, err
}
associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeDevice, deviceIDs)
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(devices))
for _, item := range devices {
rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], cardGroups))
rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], associatedPhones[item.ID], cardGroups))
}
return rows, nil
}
@@ -375,11 +385,12 @@ func buildDeviceExportHeaders(cardGroups int) []string {
"套餐的到期时间",
"当前套餐",
"钱包余额",
deviceExportAssociatedPhoneHeader,
)
return headers
}
func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, cardGroups int) []string {
func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, associatedPhone string, cardGroups int) []string {
if cardGroups < deviceExportMinCardGroups {
cardGroups = deviceExportMinCardGroups
}
@@ -420,15 +431,27 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg
formatOptionalTime(pkg.ExpiresAt),
pkg.PackageName,
formatMoneyYuan(item.WalletBalance),
associatedPhone,
)
return row
}
// cardGroupCountFromHeaders 从已持久化的表头反解卡组列数。
// 先按当前尾列数判定;不整除时回退到新增尾部列之前的尾列数再判定,
// 否则历史任务凭 ResolvedHeaders 重导出时列组数会被算成 0缺失全部卡列。
func cardGroupCountFromHeaders(headers []string) int {
if len(headers) < deviceExportBaseHeaderCount+deviceExportTailHeaderCount {
if count := cardGroupCountWithTail(headers, deviceExportTailHeaderCount); count > 0 {
return count
}
return cardGroupCountWithTail(headers, deviceExportLegacyTailHeaderCount)
}
// cardGroupCountWithTail 按指定尾部固定列数反解卡组列数,不整除即无法确定列组。
func cardGroupCountWithTail(headers []string, tailHeaderCount int) int {
if len(headers) < deviceExportBaseHeaderCount+tailHeaderCount {
return 0
}
cardColumnCount := len(headers) - deviceExportBaseHeaderCount - deviceExportTailHeaderCount
cardColumnCount := len(headers) - deviceExportBaseHeaderCount - tailHeaderCount
if cardColumnCount <= 0 || cardColumnCount%deviceExportCardGroupSize != 0 {
return 0
}

View File

@@ -38,7 +38,7 @@ func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (in
func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符",
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间",
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "迁移状态", "创建人", "创建时间",
}, nil
}
@@ -64,6 +64,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
e.express_company,
e.express_no,
e.status,
e.migration_status,
e.created_at,
COALESCE(ac.username, '') AS creator_name
`).
@@ -91,6 +92,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
item.ExpressCompany,
item.ExpressNo,
constants.GetExchangeStatusName(item.Status),
constants.GetExchangeMigrationStatusName(item.MigrationStatus),
item.CreatorName,
item.CreatedAt.Format(exportTimeLayout),
})
@@ -171,6 +173,7 @@ type exchangeExportRow struct {
ExpressCompany string `gorm:"column:express_company"`
ExpressNo string `gorm:"column:express_no"`
Status int `gorm:"column:status"`
MigrationStatus string `gorm:"column:migration_status"`
CreatorName string `gorm:"column:creator_name"`
CreatedAt time.Time `gorm:"column:created_at"`
}

View File

@@ -37,7 +37,8 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int
// Headers 返回 IoT 卡导出表头。
func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)"}, nil
// 「关联手机号」固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader}, nil
}
// Fetch 按 offset/limit 查询 IoT 卡导出数据。
@@ -49,6 +50,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
var items []iotCardExportRow
query := s.applyFilters(ctx, s.baseQuery(ctx), params).
Select(`
c.id,
c.iccid,
c.msisdn,
c.device_virtual_no,
@@ -69,6 +71,15 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
return nil, err
}
cardIDs := make([]uint, 0, len(items))
for _, item := range items {
cardIDs = append(cardIDs, item.ID)
}
associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeIotCard, cardIDs)
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
@@ -84,6 +95,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
item.PackageName,
strconv.FormatInt(item.DataUsageMB, 10),
strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10),
associatedPhones[item.ID],
})
}
return rows, nil
@@ -219,7 +231,11 @@ func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, pa
return query
}
// iotCardExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。
const iotCardExportAssociatedPhoneHeader = "关联手机号"
type iotCardExportRow struct {
ID uint `gorm:"column:id"`
ICCID string `gorm:"column:iccid"`
MSISDN string `gorm:"column:msisdn"`
DeviceVirtualNo string `gorm:"column:device_virtual_no"`

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),
@@ -145,28 +184,37 @@ func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *go
}
type refundExportRow struct {
RefundNo string `gorm:"column:refund_no"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
OrderType string `gorm:"column:order_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
PackageName string `gorm:"column:package_name"`
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"`
RefundReason string `gorm:"column:refund_reason"`
Remark string `gorm:"column:remark"`
ApprovalProvider *string `gorm:"column:approval_provider"`
ApprovalStatus *int `gorm:"column:approval_status"`
CommissionDeducted bool `gorm:"column:commission_deducted"`
AssetReset bool `gorm:"column:asset_reset"`
CreatedAt time.Time `gorm:"column:created_at"`
ProcessedAt *time.Time `gorm:"column:processed_at"`
SubmitterName string `gorm:"column:submitter_name"`
VoucherKeys string `gorm:"column:voucher_keys"`
RefundNo string `gorm:"column:refund_no"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
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"`
ApprovalStatus *int `gorm:"column:approval_status"`
CommissionDeducted bool `gorm:"column:commission_deducted"`
AssetReset bool `gorm:"column:asset_reset"`
CreatedAt time.Time `gorm:"column:created_at"`
ProcessedAt *time.Time `gorm:"column:processed_at"`
SubmitterName string `gorm:"column:submitter_name"`
VoucherKeys string `gorm:"column:voucher_keys"`
}
func formatRefundAssetType(orderType string) string {
@@ -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

@@ -36,6 +36,7 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewAgentRechargeDataSource(db),
NewRefundDataSource(db),
NewExchangeDataSource(db),
NewCommissionRecordDataSource(db),
)
}
@@ -71,7 +72,8 @@ func IsSupportedScene(scene string) bool {
constants.ExportTaskSceneAgentWalletTransaction,
constants.ExportTaskSceneAgentRecharge,
constants.ExportTaskSceneRefund,
constants.ExportTaskSceneExchange:
constants.ExportTaskSceneExchange,
constants.ExportTaskSceneCommissionRecord:
return true
default:
return false

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)
c.logger.Debug("Gateway 请求成功",
zap.String("path", path),
zap.Duration("duration", duration),
zap.Any("result", result),
)
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 {
@@ -143,6 +221,20 @@ func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
return response.Success(c, result)
}
// TriggerApproval 主动补发历史线下代理充值审批。
// POST /api/admin/agent-recharges/:id/trigger-approval
func (h *AgentRechargeHandler) TriggerApproval(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
}
result, err := h.service.TriggerApproval(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// PaymentStatus 查询代理充值本地支付与到账状态。
// GET /api/admin/agent-recharges/:id/payment-status
func (h *AgentRechargeHandler) PaymentStatus(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,231 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
businessusergroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
businessusergroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup"
"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"
)
// BusinessUserGroupHandler 业务用户组与店铺负责人批量交接处理器。
type BusinessUserGroupHandler struct {
service *businessusergroupApp.Service
query *businessusergroupQuery.Query
batchService *shopApp.BatchBusinessOwnerService
validator *validator.Validate
}
// NewBusinessUserGroupHandler 创建业务用户组处理器。
func NewBusinessUserGroupHandler(service *businessusergroupApp.Service, validator *validator.Validate) *BusinessUserGroupHandler {
return &BusinessUserGroupHandler{service: service, validator: validator}
}
// SetQuery 注入业务用户组读取投影。
func (h *BusinessUserGroupHandler) SetQuery(query *businessusergroupQuery.Query) {
h.query = query
}
// SetBatchService 注入店铺负责人批量交接事务脚本。
func (h *BusinessUserGroupHandler) SetBatchService(service *shopApp.BatchBusinessOwnerService) {
h.batchService = service
}
// Create 创建业务用户组。
// POST /api/admin/business-user-groups
func (h *BusinessUserGroupHandler) Create(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.CreateBusinessUserGroupRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.Create(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询业务用户组列表。
// GET /api/admin/business-user-groups
func (h *BusinessUserGroupHandler) List(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.BusinessUserGroupListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.query == nil {
return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置")
}
result, err := h.query.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// Detail 查询业务用户组详情。
// GET /api/admin/business-user-groups/:id
func (h *BusinessUserGroupHandler) Detail(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
if h.query == nil {
return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置")
}
result, err := h.query.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// Update 更新业务用户组的名称、业务线、排序、启停与备注;编码不可修改。
// PUT /api/admin/business-user-groups/:id
func (h *BusinessUserGroupHandler) Update(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
var request dto.UpdateBusinessUserGroupRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.Update(c.UserContext(), id, &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// Delete 二次确认删除无成员的业务用户组。
// DELETE /api/admin/business-user-groups/:id
func (h *BusinessUserGroupHandler) Delete(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
var request dto.BusinessUserGroupDeleteRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if !request.Confirm {
return errors.New(errors.CodeInvalidParam, "删除业务用户组必须二次确认")
}
if err := h.service.Delete(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// SetMembers 批量设置平台用户的业务用户组归属,直接替换原归属。
// PUT /api/admin/business-user-groups/:id/members
func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
var request dto.SetBusinessUserGroupMembersRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.SetMembers(c.UserContext(), id, &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// ClearMembers 批量清空平台用户的业务用户组归属。
// DELETE /api/admin/business-user-groups/members
func (h *BusinessUserGroupHandler) ClearMembers(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.ClearBusinessUserGroupMembersRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.ClearMembers(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// BatchUpdateShopBusinessOwner 勾选店铺批量设置或清空平台业务员负责人。
// PUT /api/admin/shops/business-owner/batch
func (h *BusinessUserGroupHandler) BatchUpdateShopBusinessOwner(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.BatchUpdateShopBusinessOwnerRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.batchService == nil {
return errors.New(errors.CodeInternalError, "店铺负责人批量交接服务尚未配置")
}
result, err := h.batchService.Execute(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *BusinessUserGroupHandler) validate(request any) error {
if h.validator == nil {
return errors.New(errors.CodeInternalError)
}
return h.validator.Struct(request)
}
// requirePlatformManagement 校验调用者仅限超级管理员与平台账号,代理与企业统一返回 403。
func requirePlatformManagement(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return nil
}

View File

@@ -66,8 +66,10 @@ func (h *CommissionWithdrawalHandler) RejectWithdrawal(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// id 只来自路径,必须在校验前回填,否则 ID 的 required 恒失败。
req.ID = uint(id)
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
return errors.New(errors.CodeInvalidParam, validationMessage("提现驳回参数不合法", &req, err))
}
result, err := h.service.Reject(c.UserContext(), uint(id), &req)

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
}

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