48 Commits

Author SHA1 Message Date
6333f4ad13 fix(代理分销注册): 校验手机号/用户名/店铺编号唯一性并支持驳回后重注册
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m57s
- 提交写事务内先取事务级 advisory lock,再校验既有未删除账号/店铺与其它待审批申请:
  手机号 1014、用户名 1013、店铺编号 1031、待审批占用 1007,冲突不落库且不消费短信验证码
- 已驳回(含通过后撤销)与已通过的终态记录不阻塞重新注册,形成新记录与新审批实例
- 并发同关键字段提交串行裁决,同一关键字段至多一条待审批记录
- 审批通过建店建号前复检关键字段,冲突返回可定位错误并整体回滚,不再以裸数据库错误收场
- 归档 Change fix-agent-distribution-registration-duplicate-guard 并同步主 Spec

验证:junhong_cmp_test + Redis DB 6 受控脚手架 37 项通过 / 0 项失败(含 6 路并发仅 1 条落库、
审批冲突回滚与无冲突建店回归),清理后 fixture 残留 0;gofmt/go build/go vet 全绿;
openspec validate --all 35 项通过、doctor healthy、context-health 通过
2026-09-17 19:02:14 +08:00
62419d4b17 feat(导出时间筛选): AUG26-014 统一时间筛选与临期导出,归档并同步主 Spec 与证据链
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。

端点改造(13 个入口):IoT 卡导入任务、设备导入任务、导出任务列表、订单列表参数名不变仅收紧解析;换货、分配记录、代理充值、临期列表改名 start_time/end_time(旧参数名显式拒绝);提现记录两处删除解析失败静默跳过,非法参数一律 1001;授权记录由起始闭结束开改为闭区间含两端;临期列表改按当前生效主套餐最终到期时刻比较,保留剩余天数上下限与既有粗放窗口。

临期导出新建:新场景 expiring_asset 与受控入口 POST /api/admin/expiring-assets/export,复用列表候选预筛与最终到期推算,一行一资产、加油包不单独成行,列序与 111 §18.1 逐列一致,店铺/业务员/用户组按执行时当前归属补充且不超出创建时冻结范围。

佣金明细导出新增按创建时间闭区间筛选(原佣金与回溯两条分支各自创建时间列),记录粒度、列定义与余额口径不变。

冻结与遗留任务:创建期把筛选与时间边界规范化为 UTC RFC3339 秒级串写入既有 query_json,无新列无迁移;执行期只按冻结值严格解析,非法冻结值在任何分片与文件动作前落任务失败并写安全摘要,不放行全量;重试沿用原快照。达量预警导出执行期同样纳入严格解析(其入口契约、列定义与触发快照口径不变)。

归档 add-export-time-filter-standards 并新建主 Spec openspec/specs/export-time-filter/spec.md,同步 requirement-evidence.json 与入口能力矩阵,README 导出场景清单更新为 11 个场景。

验证:junhong_cmp_test + 本地隔离 Redis(DB7,测试部署共享队列 DB6 未被占用)实跑 85 PASS / 0 FAIL(接受/拒绝集合、区间与顺序语义、列表与导出同筛选行集一致、代理 HTTP 全链路与范围冻结、遗留旧格式任务安全失败、列与余额口径回归、表头逐字),门禁 gofmt/go build/gendocs 两次一致/openspec validate/doctor/context-health 全绿;无 Schema 变更、无迁移、无运行时开关。
2026-09-17 18:37:02 +08:00
e8ab1f471e fix(代理分销注册): 验证码改为落库成功后消费并细化失败原因
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m54s
公开扫码注册原先在审批准备前就消费短信验证码,落库前的任何失败都会烧掉验证码,
客户重试只能得到统一的“分销码不可用”,掩盖了真实失败原因。

- 验证码校验与消费拆分为 CheckCode 与 ConsumeCode,注册记录与审批实例落库成功后才原子消费;
  校验不消费、消费一次性,落库前失败时同一验证码可直接重试
- 分销码无效、所属店铺已停用、上级店铺缺少启用的主账号、验证码错误分别返回各自错误码与提示
- 注册必填字段缺失与请求参数校验失败提示定位到具体字段
- 同步公开接口描述与 agent-distribution-withdrawal 主 Spec 行为契约
2026-09-17 18:01:47 +08:00
398a5e4282 fix(路由): 修正套餐真流量预警与资产自动续费的超管/平台 gate 作用域
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
Fiber 组中间件按路径前缀生效,两处 gate 挂在空路径组上实际落到 /api/admin 前缀,
注册顺序在其之后的后台接口对代理与企业账号一律返回 403(含代理充值、代理自充
支付方式与订单等)。gate 改为挂在各自功能路径组上,代理与企业仅被拒绝这两组功能入口。
2026-09-17 17:49:33 +08:00
d52be16802 feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
2026-09-17 16:39:26 +08:00
70e6b186df fix(退款): 修复创建退款申请插入审批尝试记录失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m23s
创建路径把审批尝试记录在事务外预构造后原样插入,缺失只能在事务内生成的
attempt_no 与 package_usage_snapshot,触发 NOT NULL 与 CHECK 约束,
接口统一返回 2002 数据库错误(5xx 脱敏掩盖了具体消息)。

- 尝试记录收敛为事务内唯一构造点 buildAttempt,CreateCommand 与
  ResubmitCommand 只传按冻结商户派生的渠道退款请求号
- TriggerHistorical 补上缺失的尝试记录插入,此前 attempt.ID 恒为 0,
  必然以「关联已变化」冲突收场
- 按「首次接入企业微信审批的实例」语义回写
  tb_refund_request.approval_instance_id,恢复本地人工终审的 IS NULL
  防重保护与退款导出投影
2026-09-17 15:49:04 +08:00
aab56a6998 feat(轮询优先队列): AUG26-016 卡轮询优先队列、人工入队与读侧接口,归档并同步主 Spec 与证据矩阵
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m13s
新增 000228 成对迁移 tb_polling_priority_item:卡、任务类型、状态、触发类型、来源订单/套餐使用记录、
触发次数与来源集合、尝试次数、失败原因、人工原因与操作者、店铺快照与各时间列;以活动项部分唯一索引
uq_polling_priority_item_active(仅 deleted_at IS NULL AND status IN ('pending','processing') 占键位)
表达「同卡同任务类型至多一条活动项」,另有状态/时间索引与全列注释;down 守卫在存在活动项或未终态行时
拒绝回滚并给出中文原因。

新增优先轮询请求可靠事件 polling.priority.requested(载荷版本 v1、事件键前缀 prio:)与消费者:只在原
业务事务内追加、幂等键稳定;消费者按卡 × 纳入任务类型(realname/carddata/card_status/package)逐条
建项并在提交后下发执行提示,重复投递只合并触发次数、来源集合与最近触发时间,不新建行也不重复调用。
触发点为四类自动场景 purchase_activated / renewal_activated(按同载体更早套餐使用记录判定)/
queue_activated / addon_activated 与「无有效套餐」no_valid_package(仅在普通套餐轮询来源且存在待生效
套餐使用记录时追加;事件通道显式拒绝 manual_trigger);入队对象恒为卡,绑定设备资产在触发事务内冻结
在用卡快照逐卡建项,不使用设备当前卡槽口径。

轮询共享基类新增认领接缝:四个 Handler(realname/carddata/card_status/package)在并发信号量之后、调用
上游之前探测活动项——待执行条件认领、执行中且 90 秒租约未到期则跳过并延后、无活动项时行为与既有完全
等价;超租约允许相邻执行接管,尝试次数只在真正发起执行后累加,未达上限(3)回到活动态按既有间隔重排,
达上限或业务校验类失败进入失败终态并保留可安全展示原因;执行前校验卡自身与绑定设备的轮询开关。未引入
通用卡级锁与 Redis 活动标记,分片队列的出队、入队与移除路径未改动。

提示通道按任务类型独立键(polling:priority:{taskType}),与既有手动触发队列分离;调度器在同一周期内先
排空优先提示、再排空手动触发队列,提示排空不受分片背压跳过影响;未新建调度设施或异步任务类型。

新增人工优先入队与只读查询三条路由 POST /api/admin/polling-priority-items、
GET /api/admin/polling-priority-items、GET /api/admin/polling-priority-items/:id:人工入队复用既有轮询
权限判定(抽取为同包共享函数),原因必填,不受每日 500 次上限与 24 小时去重约束,重复抑制由活动项合并
承担;读侧按店铺快照下推数据范围,越权与不存在不可区分,不提供优先级分级、有效期或人工重触发入口。
新增 7 个审计动作(enqueue/claim/fail/retry/complete/dequeue/manual_denied)与资源
polling_priority_item,并按(操作者类型,来源)注册,人工侧与 Worker 侧均通过来源校验。

同步 OpenAPI 文档装配三处与路由注册;归档 Change 至
openspec/changes/archive/2026-09-17-add-priority-polling-queue/ 并同步主 Spec(新增
priority-polling-queue、polling-operations 追加单次执行互斥 Requirement 与三条路由索引)与上下文健康
证据(requirement-evidence 150 行、入口矩阵 http 403 / async 56)。

本机验证:junhong_cmp_test 与隔离 Redis DB 15,未连生产、未启动 Worker/API、未调用运营商上游;迁移
up/down/up 与 down 守卫实测(含 dirty=true 记账口径与 force 恢复),A–F 批 94 PASS、接缝 63 PASS、
提示通道 12 PASS、清理零残留 20 PASS。成功路径 Complete、真并发互斥、尝试上限第 3 次判定、HTTP 层权限
矩阵、通道阈值持锁复机边界与三类生效触发点生产集成留待测试部署验证(见
docs/verification/add-priority-polling-queue-verification.md 第 4 节)。自动化测试按项目决策为 N/A,
未新增 *_test.go。
2026-09-17 14:29:56 +08:00
e7b93e4634 docs(通道流量阈值): AUG26-011 归档变更并同步 carrier-channel-traffic-threshold 与 package-lifecycle 主 Spec 及证据链
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m48s
2026-09-16 17:33:11 +08:00
33826c3443 feat(套餐真流量预警): AUG26-004 真流量预警规则、达量扫描通知与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m13s
新增 000226 迁移:规则表 tb_package_traffic_alert_rule(每套餐商品至多一条,无软删除,package_id
非部分唯一约束)、达量预警快照表 tb_package_traffic_alert(以主套餐使用记录 + 阈值快照为唯一键,
触发时冻结用量、额度、比例、阈值、到期时间、归属与资产快照),并为 tb_package_usage 新增扫描
范围部分索引 idx_package_usage_alert_scope;down 在预警表存在数据时阻断回滚。

新增规则维护接口 GET/POST/PUT /api/admin/package-traffic-alert-rules(仅超级管理员与平台账号):
创建校验套餐存在且真流量额度大于零,阈值为 1%~100% 的两位小数;修改只影响后续扫描,不回填也
不改写既有预警快照;全部写操作记录操作者、前后值与时间。

新增每日 06:00(Asia/Shanghai)扫描任务 package:traffic:alert:scan,与套餐临期扫描共用 data_cleanup
队列:按资产汇总当前有效套餐的真流量,分子取使用记录真已用量、分母取使用记录真总量快照,命中
主套餐规则阈值时在同一事务创建预警与可靠通知事件;重复执行以唯一冲突视为已处理,不重复投递,
不建停机锁、不调用运营商。

新增预警列表、详情与异步导出 GET /api/admin/package-traffic-alerts、GET /api/admin/package-traffic-alerts/:id、
POST /api/admin/package-traffic-alerts/export,列表与详情一律读冻结快照;新增通知类型
package.traffic.alert 与受控目标 package_traffic_alert_detail,目标解析仅对超级管理员与平台账号
返回可跳转,越权与不存在统一按资源不可见处理。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)、审计动作与资源注册、上下文
健康检查证据;归档变更并同步 package-traffic-alert 主 Spec。
2026-09-16 17:07:34 +08:00
d5bcda94fe feat(套餐真流量预警): AUG26-004 真流量预警规则、达量扫描通知与导出
新增 000228 迁移:规则表 tb_package_traffic_alert_rule(每套餐商品至多一条,无软删除,package_id
非部分唯一约束)、达量预警快照表 tb_package_traffic_alert(以主套餐使用记录 + 阈值快照为唯一键,
触发时冻结用量、额度、比例、阈值、到期时间、归属与资产快照),并为 tb_package_usage 新增扫描
范围部分索引 idx_package_usage_alert_scope;down 在预警表存在数据时阻断回滚。

新增规则维护接口 GET/POST/PUT /api/admin/package-traffic-alert-rules(仅超级管理员与平台账号):
创建校验套餐存在且真流量额度大于零,阈值为 1%~100% 的两位小数;修改只影响后续扫描,不回填也
不改写既有预警快照;全部写操作记录操作者、前后值与时间。

新增每日 06:00(Asia/Shanghai)扫描任务 package:traffic:alert:scan,与套餐临期扫描共用 data_cleanup
队列:按资产汇总当前有效套餐的真流量,分子取使用记录真已用量、分母取使用记录真总量快照,命中
主套餐规则阈值时在同一事务创建预警与可靠通知事件;重复执行以唯一冲突视为已处理,不重复投递,
不建停机锁、不调用运营商。

新增预警列表、详情与异步导出 GET /api/admin/package-traffic-alerts、GET /api/admin/package-traffic-alerts/:id、
POST /api/admin/package-traffic-alerts/export,列表与详情一律读冻结快照;新增通知类型
package.traffic.alert 与受控目标 package_traffic_alert_detail,目标解析仅对超级管理员与平台账号
返回可跳转,越权与不存在统一按资源不可见处理。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)、审计动作与资源注册、上下文
健康检查证据;归档变更并同步 package-traffic-alert 主 Spec。
2026-09-16 17:05:55 +08:00
ef4d3696d4 fix(通道流量阈值): AUG26-011 修复周期处理连接池自锁并补齐根池句柄验证与文档
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-16 16:59:32 +08:00
15bbb953db fix(通道流量阈值): AUG26-011 修复失败/未知结果收敛、锁定 carrier 缺失出路与审计回归
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m29s
2026-09-16 16:41:25 +08:00
59b3df868a feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s
2026-09-16 15:54:49 +08:00
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
601 changed files with 57712 additions and 4264 deletions

View File

@@ -28,7 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
echo "tag=latest" >> $GITHUB_OUTPUT
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: 登录 Docker Registry
run: |
@@ -37,19 +37,15 @@ 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: 部署到测试环境(仅八月迭代分支)
if: github.ref == 'refs/heads/Iteration/8-11'
@@ -59,6 +55,7 @@ jobs:
umask 077
{
printf 'IMAGE_TAG=%s\n' '${{ github.sha }}'
printf 'JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true\n'
printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n'
printf 'JUNHONG_WORKER_ROLE=all\n'

View File

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

View File

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

View File

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

View File

@@ -224,7 +224,7 @@ default:
- **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护
- **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
- **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card``format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 功能总结 与 验收记录
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接。当前导出场景共 11 个:`device``iot_card``order``package``agent_wallet_transaction``agent_recharge``refund``exchange``commission_record``package_traffic_alert``expiring_asset`;其中 `exchange``agent_recharge``order``commission_record``expiring_asset` 的时间筛选固定为 `filters.start_time`/`filters.end_time`(带显式时区的 RFC3339 秒级时间,闭区间含两端),创建时规范化为 UTC 秒级字符串后冻结,执行期只按冻结值严格解析;临期资产导出另有受控入口 `POST /api/admin/expiring-assets/export`
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 功能总结、接口回放示例 与 SQL 验收脚本
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 功能总结、使用指南 和 权限检查使用指南)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 使用指南 和 API 文档)
@@ -246,14 +246,23 @@ default:
### 导出任务接口示例
```bash
# 创建导出任务scene 支持 device / iot_card
# 创建导出任务scene 支持 device / iot_card / order / package / agent_wallet_transaction /
# agent_recharge / refund / exchange / commission_record / package_traffic_alert / expiring_asset
# exchange / agent_recharge / order / commission_record 的时间筛选固定写在 query.filters 下,
# 取值必须为带显式时区的 RFC3339 秒级时间,闭区间含两端,任一端可省略
curl -X POST '/api/admin/export-tasks' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer <token>' \\
-d '{\"scene\":\"device\",\"format\":\"xlsx\"}'
-d '{"scene":"order","format":"xlsx","query":{"filters":{"start_time":"2026-09-01T00:00:00+08:00","end_time":"2026-09-30T23:59:59+08:00"}}}'
# 查询导出任务列表(支持 scene/status/time 过滤
curl '/api/admin/export-tasks?page=1&page_size=20&scene=device' \\
# 创建临期资产导出任务(受控入口,请求体与临期资产列表筛选一致
curl -X POST '/api/admin/expiring-assets/export' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer <token>' \\
-d '{"format":"xlsx","days_max":7,"start_time":"2026-09-01T00:00:00+08:00"}'
# 查询导出任务列表(支持 scene/status/start_time/end_time 过滤)
curl '/api/admin/export-tasks?page=1&page_size=20&scene=order&start_time=2026-09-01T00:00:00%2B08:00' \\
-H 'Authorization: Bearer <token>'
```
@@ -719,6 +728,8 @@ KeyAuthToken 缺失
| `jwt.secret_key` | `JUNHONG_JWT_SECRET_KEY` |
| `logging.level` | `JUNHONG_LOGGING_LEVEL` |
三类新线上支付C 端套餐购买、C 端资产钱包充值、代理在线预存款充值)始终从对应启用商户池选择并冻结商户路由;商户池、成员缺失或停用时明确返回“暂无可用商户”,绝不回退旧综合支付配置。`merchant_id` 为空只代表留存期内的历史支付,其回调、查单和既有退款路径仍按 `payment_config_id` 双读,直到独立 Change 删除旧读取路径。
### 必填配置
以下配置项必须通过环境变量设置(无默认值或需要覆盖):

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,20 @@ 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)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(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

@@ -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,20 @@ 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)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(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

@@ -16,20 +16,29 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
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"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
commissionDelivery "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop"
walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
@@ -82,6 +91,13 @@ type workerRuntime struct {
pollingIotCardStore *postgres.IotCardStore
pollingBase *task.PollingBase
lifecycleSvc *polling.PollingLifecycleService
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
refundChannelService *refundchannelApp.Service
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
carrierThresholdService *carrierThresholdApp.Service
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
assetAutoRenewalService *assetAutoRenewalApp.Service
}
func main() {
@@ -153,6 +169,10 @@ func runWorker(cfg *config.Config) {
taskHandler.RegisterHandlers()
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger)
registerAssetAutoRenewalTasks(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)
@@ -279,16 +299,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
pollingIotCardStore := postgres.NewIotCardStore(db, redisClient)
pollingBase := task.NewPollingBase(
redisClient,
pollingQueueMgr,
pollingConfigMgr,
pollingIotCardStore,
appLogger,
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
lifecycleSvc := polling.NewPollingLifecycleService(
@@ -299,9 +309,25 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingDeviceStore,
appLogger,
)
pollingBase := task.NewPollingBase(
redisClient,
pollingQueueMgr,
pollingConfigMgr,
pollingIotCardStore,
lifecycleSvc,
postgres.NewPollingPriorityItemStore(db),
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
db,
appLogger,
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
if workerResult.Services.CarrierThreshold == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
runtime := &workerRuntime{
redisAddr: redisAddr,
@@ -319,14 +345,69 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingIotCardStore: pollingIotCardStore,
pollingBase: pollingBase,
lifecycleSvc: lifecycleSvc,
// 通道阈值停复机复用既有停复机服务作为唯一执行事实源重试、Integration Log、统一审计与既有判定
carrierThresholdService: workerResult.Services.CarrierThreshold,
// 自动续费的复机执行同样复用既有停复机单一事实源。
assetAutoRenewalService: workerResult.Services.AssetAutoRenewal,
}
registerNotificationOutboxConsumer(runtime, appLogger)
registerWalletOutboxConsumer(runtime, appLogger)
registerCardObservationOutboxConsumer(runtime, appLogger)
registerPriorityPollingOutboxConsumer(runtime, appLogger)
registerCarrierThresholdOutboxConsumer(runtime, appLogger)
registerAssetAutoRenewalOutboxConsumer(runtime, appLogger)
registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger)
return runtime
}
// registerPriorityPollingOutboxConsumer 注册卡轮询优先队列请求事件消费者。
// 消费者按卡 × 纳入任务类型建立或合并优先项并下发执行提示;重复投递只合并。
func registerPriorityPollingOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
priorityStore := postgres.NewPollingPriorityItemStore(runtime.db)
consumer := prioritypollingInfra.NewPriorityRequestedConsumer(
runtime.db,
priorityStore,
runtime.pollingQueueMgr,
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
appLogger,
)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypePollingPriorityRequested, consumer); err != nil {
appLogger.Fatal("注册卡轮询优先队列 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypePollingPriorityRequested), zap.Error(err))
}
}
// registerCarrierThresholdOutboxConsumer 注册运营商通道流量阈值停复机事件消费者。
// 两个事件类型共用同一消费者实现:停机与复机各自由锁行认领字段保证至多一次外部调用。
func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.carrierThresholdService == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
consumer := carrierThresholdApp.NewConsumer(runtime.carrierThresholdService)
for _, eventType := range []string{
carrierThresholdApp.EventCarrierThresholdStop,
carrierThresholdApp.EventCarrierThresholdResume,
} {
if err := runtime.outboxConsumers.Register(eventType, consumer); err != nil {
appLogger.Fatal("注册运营商通道流量阈值 Outbox 消费者失败",
zap.String("event_type", eventType), zap.Error(err))
}
}
}
// registerAssetAutoRenewalOutboxConsumer 注册资产钱包自动续费的复机请求事件消费者。
// 消费者按尝试记录认领执行权后执行复机,重复投递不会产生第二次外部调用。
func registerAssetAutoRenewalOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.assetAutoRenewalService == nil {
appLogger.Fatal("资产钱包自动续费用例未配置")
}
consumer := assetAutoRenewalApp.NewResumeConsumer(runtime.assetAutoRenewalService)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAssetAutoRenewalResumeRequested, consumer); err != nil {
appLogger.Fatal("注册资产钱包自动续费 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAssetAutoRenewalResumeRequested), zap.Error(err))
}
}
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
@@ -376,7 +457,23 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
)
refundService.SetNotificationOutbox(outbox.NewRepository())
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient))
refundService.SetLifecycleAudit(auditWriter)
// 员工代收款退款冲销与建账共用同一审计 Writer接入点仅在企微退款成功事务内。
refundService.SetEmployeeCollectionRefundOffset(
employeecollectionApp.NewRefundOffsetService(auditWriter),
)
refundChannelService := refundchannelApp.NewService(
runtime.db,
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(runtime.redisClient), appLogger),
auditWriter,
).SetLogger(appLogger).SetCompletionNotifier(refundService)
refundService.SetChannelRefundService(refundChannelService)
runtime.refundChannelService = refundChannelService
if err := runtime.outboxConsumers.Register(refundchannelApp.EventRefundChannelRefund, refundchannelApp.NewConsumer(refundChannelService)); err != nil {
appLogger.Fatal("注册渠道原路退款 Outbox 消费者失败", zap.Error(err))
}
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
}
@@ -390,8 +487,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,
@@ -448,6 +560,7 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
)
recovery := agentrechargeApp.NewRecoverOnlinePaymentService(
runtime.db,
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
paymentInfra.NewFuiouScanAdapter(integration, appLogger),
@@ -459,6 +572,57 @@ 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))
}
// registerCarrierThresholdTasks 注册运营商通道流量阈值的周期处理与结果恢复任务。
// 两个任务共用同一用例实例:周期处理负责跨期解锁与条件复机,恢复扫描只查询状态回填,绝不重复发起停复机。
func registerCarrierThresholdTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.carrierThresholdService == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
cycleHandler := carrierThresholdInfra.NewCycleTaskHandler(runtime.carrierThresholdService)
mux.HandleFunc(constants.TaskTypeCarrierThresholdCycle, cycleHandler.Handle)
appLogger.Info("注册运营商通道流量阈值周期处理任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdCycle))
recoveryHandler := carrierThresholdInfra.NewRecoveryTaskHandler(runtime.carrierThresholdService)
mux.HandleFunc(constants.TaskTypeCarrierThresholdRecovery, recoveryHandler.Handle)
appLogger.Info("注册运营商通道流量阈值结果恢复任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdRecovery))
}
// registerAssetAutoRenewalTasks 注册资产钱包自动续费的每日扫描与复机结果恢复任务。
// 两个任务共用同一用例实例:每日扫描负责终态收敛与续购执行,恢复扫描只查询状态回填,绝不重复发起复机。
func registerAssetAutoRenewalTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.assetAutoRenewalService == nil {
appLogger.Fatal("资产钱包自动续费用例未配置")
}
scanHandler := assetAutoRenewalInfra.NewDailyScanTaskHandler(runtime.assetAutoRenewalService)
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalScan, scanHandler.Handle)
appLogger.Info("注册资产钱包自动续费每日扫描任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalScan))
recoveryHandler := assetAutoRenewalInfra.NewRecoveryTaskHandler(runtime.assetAutoRenewalService)
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalRecovery, recoveryHandler.Handle)
appLogger.Info("注册资产钱包自动续费复机结果恢复任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalRecovery))
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
@@ -723,6 +887,46 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册代理在线充值支付恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeRefundChannelRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundChannelRecovery)),
)); err != nil {
return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeCarrierThresholdCycle,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdCycle)),
)); err != nil {
return fmt.Errorf("注册运营商通道流量阈值周期处理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeCarrierThresholdRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdRecovery)),
)); 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,
@@ -770,6 +974,34 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 * * *", asynq.NewTask(
constants.TaskTypePackageTrafficAlertScan,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageTrafficAlertScan)),
)); err != nil {
return fmt.Errorf("注册每日套餐真流量达量预警扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 7 * * *", asynq.NewTask(
constants.TaskTypeAssetAutoRenewalScan,
nil,
asynq.MaxRetry(3),
asynq.Timeout(30*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalScan)),
)); err != nil {
return fmt.Errorf("注册每日资产钱包自动续费扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 5m", asynq.NewTask(
constants.TaskTypeAssetAutoRenewalRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(5*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalRecovery)),
)); err != nil {
return fmt.Errorf("注册资产钱包自动续费复机结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register(
"0 2 * * *",
asynq.NewTask(
@@ -868,6 +1100,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

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

View File

@@ -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,43 @@
- **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
- **更新触发条件**:标签功能启用、换货迁移项变更或该缺陷修复
## KNOWN-ISSUE-002
- **状态**:已登记的边界,非缺陷(当前不阻塞归档)
- **适用范围**:卡轮询优先队列的三处实现边界:①「资产无有效套餐」的触发范围;②认领接缝探测失败的失败方向;③未被接缝使用的批量探测方法
- **问题**
- 边界一(无有效套餐触发范围):`no_valid_package` 只在既有判定点——`internal/service/iot_card/stop_resume_service.go:354` 的「条件B无有效套餐」被加入停机原因列表时——才会追加触发调用点 `internal/service/iot_card/stop_resume_service.go:123`,实现 `:209`)。已经处于停机状态的卡走 `EvaluateAndAct` 的离线分支,不再重新判定「无有效套餐」,因此停机卡不会被该场景加急。
- 边界二(认领接缝探测失败的失败方向):认领接缝的 `FindActive` 探测失败采用 fail-closed——四个轮询 Handler 按既有失败分支记为轮询失败并按既有间隔延后(`internal/task/polling_priority_claim.go:56` 返回错误;`internal/task/polling_realname_handler.go:47``internal/task/polling_carddata_handler.go:55``internal/task/polling_cardstatus_handler.go:56``internal/task/polling_package_handler.go:59` 各对应分支carddata 连同放弃当轮流量同步)。
- 边界三(未被使用的批量探测方法):`internal/store/postgres/polling_priority_item_store.go:148-149``ListActiveByCards` 当前没有任何调用方(`grep -rn "ListActiveByCards" internal/` 只命中定义本身)。
- **理由**:边界一——行为契约的 WHEN 以「普通套餐轮询判定资产无有效套餐」为前提(该 Scenario 见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md:31`停机卡不发生该判定故当前实现不违反契约把它扩展到停机卡属于新的产品口径。边界二——fail-open 会在数据库抖动时让同一卡同一任务类型同时出现两次上游调用,破坏本能力「至多一次上游调用」的 MUST失败方向必须偏保守。边界三——`tasks.md` 1.4 的执行契约要求存储层提供「按卡批量活动项查询」,先交付后使用。
- **证据**:见上述各边界的 `文件:行`;边界三的调用方检索当时结果为空(仅定义处命中)。
- **例外条件**:边界一——若产品要求停机卡也因「无有效套餐」获得加急,须先由产品确认收窄该 Scenario 的措辞,再以独立 Change 扩展触发范围。边界二——数据库连接正常时不会触发该失败方向,普通轮询的并发上限与无限重入队策略不受影响。
- **修复方式**:边界一——产品确认后另立 Change在此之前不改判定点。边界二——不修复刻意设计如未来引入可信的「探测能力不可用」信号可在该信号下单独放行并接受重复调用风险须重新评审。边界三——若认领接缝改为批量探测则启用否则按归档后的清理流程删除不长期保留未使用的方法。
- **Owner**:异步任务负责人
- **最后验证日期**2026-09-16
- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测

View File

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

View File

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

View File

@@ -33,7 +33,7 @@
| AUG26-014 | 导出与统一时间筛选 | PRD-08-014、PRD-08-020 |
| AUG26-015 | 报表管理 | PRD-08-016报表原编号重复 |
| AUG26-016 | 优先轮询通道 | PRD-08-019 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021、PRD-08-013预存款审批字段 |
## 1. 已确认的领域语言
@@ -196,6 +196,8 @@
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
> **AUG26-016 实施口径标注2026-09-16**:上文本条中的「普通轮询异常补偿」**本版不实现**。现状该场景没有任何可判定条件(轮询失败按配置间隔无限重入队,无失败上限、无补偿入口),实现等于自造判据;且渠道故障时全量失败卡涌入优先通道、提示通道不受分片背压约束,风险不对称。本版只实现其余四类场景,该场景登记为后续独立 Change需产品确认后另行实施依据见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md` 的「后续候选」)。本条其余文字与其余场景的确认内容不变。
## 2.16 已确认的导出、时间筛选与代理自充收款方式基线
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。

View File

@@ -0,0 +1,542 @@
# AUG26-014 统一时间筛选与临期导出add-export-time-filter-standards范围、契约与前端同步清单
本文件记录 OpenSpec Change `add-export-time-filter-standards` 的实施范围、端点改造对照、DTO 与 OpenAPI 变更清单以及前端同步清单。
行为契约以 [`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md) 与变更产物
[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/) 为准;本文件是实施记录与同步清单,不是行为契约。
本文的实测证据节按 T7 分工由独立验证 worker 追加;本文不写未经实跑的结论。
## 1. 范围与依据
- 迭代编号:`AUG26-014`。需求依据:`docs/product/2026-08-迭代-PRD-讨论稿.md` §2.16(时间筛选与导出快照两段);导出列定义依据 `111.md` §18。
- 执行契约:变更产物 `proposal.md``design.md``tasks.md``specs/export-time-filter/spec.md`;冲突时以 spec delta 为准。
- 统一契约:被覆盖的列表与导出入口使用可选的 `start_time`/`end_time`,取值 MUST 为带显式时区的 RFC3339 秒级时间,解析结果归一为 UTC 瞬时,筛选为闭区间含两端;任一端可缺省,未传与空串同义;开始晚于结束拒绝。
- 共享实现落点:`pkg/utils/time_range.go``ParseTimeRange`),创建期 handler 与执行期 `internal/exporter` 共用同一份实现。
- 本次边界(不做):不新增迁移、不改既有迁移、不改数据库 Schema、不新增运行时开关、不删除全局宽松解析器`cmd/api/main.go``registerTimeParserCompat`)、不新增 `*_test.go`、不重构未列入本次的端点与其导出、不做跨度上限、不改任何导出的记录粒度/列定义/余额口径、不修佣金统计类接口的裸串直传。
- 未列入本次的端点保持既有参数名、既有格式接受范围与既有筛选结果,逐项登记见 `design.md`「已知差异登记(本次不修)」;其中达量预警列表与导出本期一律不改(依据端点表「已符合」行)。
- 数据承载:无 Schema 变更,冻结值写入既有 `tb_export_task.query_json` / `scope_shop_ids` 与既有操作者、文件、行数、`error_message` 列,详见第 5 节。
## 2. 端点改造清单
### 2.1 列表端点
| 端点 | 改造前参数与格式 | 改造后 | 筛选依据业务时间 |
| --- | --- | --- | --- |
| `GET /api/admin/iot-cards/import-tasks` | `start_time`/`end_time``*time.Time`,经全局宽松解析:兼容 RFC3339、无时区、date-only | 参数名不变,`string` + 严格解析 | 导入任务创建时间 |
| `GET /api/admin/devices/import/tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导入任务创建时间 |
| `GET /api/admin/export-tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导出任务创建时间 |
| `GET /api/admin/orders` | 同上 | 参数名不变,`string` + 严格解析 | 订单创建时间 |
| `GET /api/admin/exchanges` | `created_at_start`/`created_at_end``*time.Time`,宽松) | 改名 `start_time`/`end_time``string` + 严格解析;旧参数名不再生效 | 换货单创建时间 |
| `GET /api/admin/asset-allocation-records` | `created_at_start`/`created_at_end``*time.Time`,宽松) | 改名 `start_time`/`end_time``string` + 严格解析 | 分配记录创建时间 |
| `GET /api/admin/agent-recharges` | `start_date`/`end_date`(无格式约束 `string`,按日拼接当日 `00:00:00`/`23:59:59` | 改名 `start_time`/`end_time``string` + 严格解析;删除按日补齐 | 代理充值记录创建时间 |
| `GET /api/admin/shops/{shop_id}/commission-records` | 无时间参数 | 新增 `start_time`/`end_time`,闭区间 | 佣金明细创建时间(原佣金与回溯两条分支各自的 `created_at` |
| `GET /api/admin/commission/withdrawal-requests` | `start_time`/`end_time`(无时区 `2006-01-02 15:04:05`,解析失败静默忽略该条件) | 参数名不变,严格解析;删除静默跳过,非法参数一律 1001 | 提现申请创建时间 |
| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 | 提现申请创建时间 |
| `GET /api/admin/authorizations` | `start_time`/`end_time`date-only `2006-01-02`;起始闭、结束开且结束日加一天) | 参数名不变,严格解析;改为闭区间含两端 | 授权发生时间 |
| `GET /api/admin/expiring-assets` | `expires_from`/`expires_to`date-only按上海自然日比较 | 改名 `start_time`/`end_time`,按最终到期**时刻**闭区间比较;保留既有剩余天数上下限筛选 | 当前生效主套餐最终到期时刻 |
### 2.2 导出场景
| 导出场景 / 入口 | 改造前筛选键 | 改造后 | 口径来源 |
| --- | --- | --- | --- |
| `exchange`(经 `POST /api/admin/export-tasks` | `created_at_start`/`created_at_end`(宽松多格式解析) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 换货单创建时间 |
| `agent_recharge`(同上) | `start_date`/`end_date`(宽松、按日补齐当日末秒) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 代理充值记录创建时间 |
| `order`(同上) | `start_time`/`end_time`(宽松) | 参数名不变,共享严格解析器 | 订单创建时间 |
| `commission_record`(同上) | 无时间筛选 | 新增 `start_time`/`end_time`,两条分支各自的创建时间列闭区间 | 佣金明细创建时间 |
| 临期资产(`POST /api/admin/expiring-assets/export` | 场景此前不存在 | 本次新建场景 `expiring_asset` 与受控入口 | 当前生效主套餐最终到期时刻 |
| `package_traffic_alert``POST /api/admin/package-traffic-alerts/export` | `start_time`/`end_time`RFC3339 秒级,创建时冻结) | 入口参数名与 `*time.Time` 契约、列表 `GET /api/admin/package-traffic-alerts` 行为、列定义与触发快照口径**保持不动****执行期改为只按冻结值严格解析**(冻结值非法则任务落失败,依据 Requirement 4 | 预警触发时间 |
### 2.3 未改造项确认(逐一核对未改动)
- 端点保持原参数名与宽松解析、本期未新增时间参数:资产钱包流水、客户钱包流水、设备资产列表(`created_at_start`/`created_at_end`)、代理主钱包流水(`start_date`/`end_date`)、手机号—资产关联列表、佣金统计类接口(`commission-stats``commission-daily-stats`、审计类列表、员工代收款账单、轮询告警历史、企业卡与企业设备授权列表、IoT 卡资产列表。
- 导出场景入口保持原筛选键与宽松接受面:`device``iot_card``package``agent_wallet_transaction``refund`;达量预警导出(`package_traffic_alert`)入口与列表同样保持宽松接受面,仅执行期改为严格解析(见 2.2 行与 Requirement 4
- 全局宽松解析器 `cmd/api/main.go``registerTimeParserCompat` 未改动,未列入端点继续由其服务。
- 受影响列表端点的旧参数名现已显式拒绝(`created_at_start`/`created_at_end``start_date`/`end_date``expires_from`/`expires_to` 出现且非空即返回 1001空值仍按未传处理因此这些名字不会以「被忽略」的形式留在受影响端点。
- 生成文档核对:`docs/admin-openapi.yaml``created_at_start``start_date` 仅残留于未改造端点与未改造的请求 DTO`DtoBatchSetDeviceSeriesBindngRequest``/api/admin/devices``/api/admin/phone-asset-associations``commission-daily-stats`、主钱包流水与 `/api/open/v1/wallet/transactions``expires_from`/`expires_to` 在代码中只作为 `internal/handler/admin/asset.go` 的旧参数拒绝清单字符串出现并作为改造前参数名记录在本次契约与文档中生成文档中已完全不再出现13 个受影响端点均只暴露 `start_time`/`end_time`
## 3. DTO 与 OpenAPI 变更清单
统一说明:受影响端点的时间字段 Go 类型一律由 `*time.Time`(或裸 `string`)改为 `string`,由 handler 调用 `pkg/utils.ParseTimeRange` 严格解析;`*time.Time` 类型会触发全局宽松解析器,因此必须字符串化才能给出指明字段的中文错误消息。错误语义:格式非法与顺序错误统一返回既有参数非法错误码 `1001`,消息固定为
`<字段> 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00``start_time 不能晚于 end_time`,不新增结构化字段级错误载荷。
| DTO | 字段json / query tag | 类型变更 | description 变更 |
| --- | --- | --- | --- |
| `dto.ListImportTaskRequest` | `start_time` / `end_time` | `*time.Time``string` | 改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 |
| `dto.ListDeviceImportTaskRequest` | `start_time` / `end_time` | `*time.Time``string` | 同上 |
| `dto.ListExportTaskRequest` | `start_time` / `end_time` | `*time.Time``string` | 同上 |
| `dto.OrderListRequest` | `start_time` / `end_time` | `*time.Time``string` | 同上 |
| `dto.ExchangeListRequest` | `created_at_start`/`created_at_end``start_time`/`end_time` | `*time.Time``string`(参数改名) | 同上 |
| `dto.ListAssetAllocationRecordRequest` | `created_at_start`/`created_at_end``start_time`/`end_time` | `*time.Time``string`(参数改名) | 同上 |
| `dto.AgentRechargeListRequest` | `start_date`/`end_date``start_time`/`end_time` | `string``string`(参数改名,取值受限) | 由「YYYY-MM-DD」改为「带时区的 RFC3339 秒级时间」 |
| `dto.ShopCommissionRecordListReq` | 新增 `start_time` / `end_time` | 新增 `string` | 新增:佣金明细创建时间闭区间 |
| `dto.WithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「2006-01-02 15:04:05」改为「带时区的 RFC3339 秒级时间」 |
| `dto.ShopWithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 同上 |
| `dto.AuthorizationListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「格式2006-01-02」改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 |
| `dto.ExpiringAssetListRequest` | `expires_from`/`expires_to``start_time`/`end_time` | `string``string`(参数改名,取值受限) | 由「上海自然日 YYYY-MM-DD」改为「按最终到期时刻的闭区间」 |
| `dto.ExportExpiringAssetRequest` | 新增(`format``asset_type``keyword``shop_id``package_id``days_min``days_max``start_time``end_time` | 新增 `string` 时间字段 | 新增受控导出入口请求体 |
| `dto.CreateExportTaskRequest` | `scene` 白名单与 `query` 说明 | 不变 | 白名单新增 `expiring_asset`;说明补充时间筛选键与取值要求 |
| `dto.ListExportTaskRequest` | `scene` 白名单 | 不变 | 白名单新增 `expiring_asset` |
OpenAPI`docs/admin-openapi.yaml` 已重新生成(`go run cmd/gendocs/main.go`),新增 `POST /api/admin/expiring-assets/export`13 个受影响端点的时间参数已按上表更新。
## 4. 前端同步清单
无兼容期、无运行时开关:前端 MUST 与后端同迭代上线。
### 4.1 改参数名的端点与导出
| 位置 | 旧参数 | 新参数 |
| --- | --- | --- |
| `GET /api/admin/exchanges` 与换货导出(场景 `exchange` | `created_at_start``created_at_end` | `start_time``end_time` |
| `GET /api/admin/asset-allocation-records` | `created_at_start``created_at_end` | `start_time``end_time` |
| `GET /api/admin/agent-recharges` 与代理充值导出(场景 `agent_recharge` | `start_date``end_date` | `start_time``end_time` |
| `GET /api/admin/expiring-assets` | `expires_from``expires_to` | `start_time``end_time` |
旧参数名在受影响端点不再生效(换货导出与代理充值导出的旧筛选键在创建期直接返回 1001
### 4.2 改传值格式的端点与导出
格式要求:带显式时区的 RFC3339 秒级时间UTC 偏移写作 `Z``+08:00`;整日区间用 `T00:00:00+08:00``T23:59:59+08:00` 表达。拒绝 date-only、空格分隔、无时区、小数秒`.000`)、`±hhmm` 与未补零写法。
| 位置 | 旧传值示例 | 新传值示例 |
| --- | --- | --- |
| `GET /api/admin/iot-cards/import-tasks` | `2026-09-01``2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00` |
| `GET /api/admin/devices/import/tasks` | 同上 | 同上 |
| `GET /api/admin/export-tasks` | 同上 | 同上 |
| `GET /api/admin/orders` 与订单导出(场景 `order` | 同上 | 同上 |
| `GET /api/admin/authorizations` | `2026-09-01`(结束日按次日开区间) | `2026-09-01T00:00:00+08:00`(结束时间为闭区间,整日需显式写 `23:59:59` |
| `GET /api/admin/commission/withdrawal-requests` | `2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00`;非法值由「忽略该条件返回全量」改为 1001 |
| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 |
| `GET /api/admin/expiring-assets` 与临期导出 | `2026-09-01`(上海自然日) | `2026-09-01T00:00:00+08:00`(按最终到期时刻闭区间) |
| 换货导出、代理充值导出 | 旧格式同上 | 同上 |
注意:受影响端点的时间参数不再接受无时区写法,前端不得沿用「按浏览器本地时区补 00:00:00」的旧拼接方式必须显式带偏移。受影响列表端点换货、分配记录、代理充值、临期列表携带旧参数名`created_at_start`/`created_at_end``start_date`/`end_date``expires_from`/`expires_to`)且非空时现在返回 1001消息`<旧字段名> 已废弃,请改用 start_time 与 end_time`),前端必须同迭代改名,不能依赖旧参数被忽略。
**达量预警导出经通用入口的传值收紧(`POST /api/admin/export-tasks``scene=package_traffic_alert`**
- 位置:通用导出入口 `POST /api/admin/export-tasks``scene=package_traffic_alert`(达量预警导出场景)。
- 变更:`query.filters.start_time` / `query.filters.end_time` 现要求带显式时区的 RFC3339 秒级时间与本节上表同一格式要求宽松写法date-only、空格分隔、无时区、小数秒含 `.000``±hhmm`、未补零、前后空白)由变更前的「创建成功 + 执行期静默忽略该条件(等于不加该条件,可能导出全量)」改为**创建期直接返回 1001 参数非法**,消息与其他受影响端点同一套固定中文文案:`start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00``end_time` 同构),开始晚于结束为 `start_time 不能晚于 end_time`
- 边界:达量预警的**专用入口** `POST /api/admin/package-traffic-alerts/export` 与其列表 `GET /api/admin/package-traffic-alerts` 的参数名、类型与宽松接受面**本次不变**(仍为 `*time.Time` + 全局兼容解析器,创建期归一为 UTC RFC3339 秒级后冻结);只有**执行期**改为只按冻结值严格解析,冻结值非法时任务落失败并写固定摘要 `导出筛选的时间边界非法,任务已终止`,不放行全量。
- 依据:变更 delta 与主 Spec 的「三类异步导出的创建期冻结与不扩大范围」需求;`design.md` 已知差异登记第 5 条(无时区列那条为第 6 条、临期导出取舍为第 7 条)。
### 4.3 新增参数的端点与导出
| 位置 | 新增参数 | 说明 |
| --- | --- | --- |
| `GET /api/admin/shops/{shop_id}/commission-records` | `start_time``end_time` | 按佣金明细创建时间闭区间,覆盖原佣金与回溯两条分支 |
| 佣金明细导出(场景 `commission_record` | `query.filters.start_time``query.filters.end_time` | 同上;经 `POST /api/admin/export-tasks` 提交 |
| 临期导出 `POST /api/admin/expiring-assets/export` | 整个入口为新增 | 请求体:`format`(必填)、`asset_type``keyword``shop_id``package_id``days_min``days_max``start_time``end_time` |
## 5. 无 Schema 变更确认T6.6
- 本变更未新增迁移文件、未修改任何既有迁移、未改变数据库 Schema因此迁移 `up/down/up` 为**不适用**而非跳过。
- 冻结值承载于既有列:`tb_export_task.query_json`jsonb写入规范化后的 UTC RFC3339 秒级字符串)、`scope_shop_ids`jsonb、操作者四元组与 `creator`/`updater``file_key`/`file_size`/`total_rows`/`error_message`。表头冻结沿用既有 `query_json.resolved_headers`
- 失败摘要沿用既有 `error_message` 通道,新增固定摘要文案 `导出筛选的时间边界非法,任务已终止`
- 该严格解析失败即落失败的规则同样适用于达量预警导出(场景 `package_traffic_alert`):其入口与列表的宽松接受面不变,执行期冻结值非法时同样落失败,不放行全量。
## 6. 实测证据T7 实跑)
本节由独立验证 worker 实跑写入,全部结论均有命令与原始输出支撑;未实跑项在 §6.5 逐条列明。
### 6.0 环境、工作区指纹与执行方式
| 项 | 值 |
| --- | --- |
| HEAD | `e8ab1f471e9cbf27acd89ccdcfb5073b4fc7baa1`(含维护者提交 `398a5e42`「fix(路由): 修正套餐真流量预警与资产自动续费的超管/平台 gate 作用域」与其后的 `e8ab1f47`;本 Change 的实现改动仍在未提交工作区) |
| 工作区内容指纹(本批) | `git diff` 与全部未跟踪文件内容的合并 SHA1`37d7d69021f264be6f04e31062a18e8aa695c8a0` |
| 工作区内容指纹(上一批) | `51c619f8a3791e5c6e3de8878af3be5105dd73f``NAMES_FP=d1aa8d512dfb5d43e17b2ded30ae761aaca2578c`63 项) |
| 代码状态 | 未提交工作区(本 Change 实现已落盘);维护者的路由门禁修复以提交形式位于 HEAD |
| PostgreSQL | `junhong_cmp_test`(参数取自 `.env`),库内诊断只读走 dbhub MCP写入一律经临时 Go 工具用仓库自身 GORM/配置执行(遵循 ENG-DB-002 |
| Redis | `cxd.whcxd.cn:16299` **DB 7**`.env.local` 默认值,与 `scripts/run-local.sh` 一致) |
| API | `go run ./cmd/api``http://127.0.0.1:3000`,就绪日志「服务器已启动」 |
| Worker | `go run ./cmd/worker`就绪日志「Worker 服务器启动中」 |
| 鉴权 | 超管:`POST /api/auth/login`(口令取自 README不回显。代理视角**第二批起改用 fixture 代理账号的真实登录**`tb_account` 自建 `AUG26014AGENT*`user_type=3口令为 bcrypt 哈希由工具写入,不回显);手工注入 DB 7 的等价令牌仅用于第一批的根因定位对照,并已在 §6.6 明确其 `user_type` 来源 |
**与 ENG-TEST-001 的两处偏离(均已在执行中记录):**
1. **Redis 用 DB 7 而非 DB 6**DB 6 是 `Iteration/8-11` 测试部署的共享队列,本地 worker 若连 DB 6 会抢占真实测试任务并消费掉它们;`.env.local` 本身即配置 DB 7`scripts/run-local.sh` 的实际行为一致。导出链路的冻结、分片、合并、上传语义与队列 DB 号无关,因此该偏离不影响本次结论。
2. **启动命令用 `go run ./cmd/api` 而非 `./scripts/run-local.sh api`**`scripts/run-local.sh` 执行的是 `go run cmd/api/main.go`(单文件),而 `generateOpenAPIDocs` 定义在同包的另一文件 `cmd/api/docs.go`,单文件编译必然报 `cmd/api/main.go:101:2: undefined: generateOpenAPIDocs` 并退出。这是**既有仓库脚本/README 缺陷**`scripts/run-local.sh``cmd/api/main.go``cmd/api/docs.go` 均不在本 Change 改动范围内),本 Change 未引入也未加重;改用包路径 `go run ./cmd/api` 后正常启动。建议另行登记修复。
### 6.1 逐项结果汇总
| # | tasks.md 项 | 断言数 | 结果 |
| --- | --- | --- | --- |
| 1 | 7.11/7.12 门禁命令gofmt/build/gendocs/openspec/doctor/context-health | 7 | **PASS 7 / FAIL 0** |
| 2 | 7.1 接受集合与拒绝集合逐条实跑 | 20 | **PASS 20 / FAIL 0** |
| 3 | 7.2 区间边界(仅一端/两端/两端缺省/空串/开始等于结束/空结果集) | 5 + 6 | **PASS 11 / FAIL 0** |
| 4 | 7.3 顺序校验(开始晚于结束) | 1 | **PASS 1 / FAIL 0** |
| 5 | 7.4 未改造端点保持既有宽松行为 | 4 | **PASS 4 / FAIL 0** |
| 6 | 7.5 提现记录非法参数不再返回全量 | 5 | **PASS 5 / FAIL 0** |
| 7 | 7.5 授权记录闭区间含两端 | 3 | **PASS 3 / FAIL 0** |
| 8 | 7.6 佣金明细列表与其导出行集一致 | 2 | **PASS 2 / FAIL 0** |
| 9 | 7.6/7.7 临期列表与其导出一致、表头、加油包不单独成行 | 5 | **PASS 5 / FAIL 0** |
| 10 | 受影响端点旧参数名拒绝 + 空值按未传F1 修复后重跑) | 8 | **PASS 8 / FAIL 0** |
| 11 | 7.9 遗留旧格式冻结任务安全失败 | 1 | **PASS 1 / FAIL 0** |
| 12 | 7.7 达量预警导出回归(列表宽松、导出严格、表头逐字) | 5 | **PASS 5 / FAIL 0** |
| 13 | 7.10 佣金明细导出列定义与金额/余额口径回归 | 1 | **PASS 1 / FAIL 0** |
| 14 | 7.7 创建后归属变化不扩大数据集(冻结 scope 执行期生效) | 3 | **PASS 3 / FAIL 0** |
| 15 | 7.8 代理账号经 HTTP 访问临期列表/导出(第一批:修复前 403/1005 | 2 | 第一批 **FAIL**;根因定位为既有路由门禁作用域缺陷,已由维护者提交 `398a5e42` 修复 → 第二批重跑 **PASS**(见第 16 行) |
| 16 | 7.8 代理 HTTP 全链路(真实登录):可见范围过滤 / 导出创建与执行 / 空可见范围被拒 / 越权与不存在统一不可见 | 6 | **PASS 6 / FAIL 0** |
| 17 | 7.6 达量预警列表与导出同筛选行集一致(缺口补齐) | 1 | **PASS 1 / FAIL 0** |
| — | 合计 | **85 PASS / 0 FAIL** | |
### 6.2 关键原始输出
**1门禁命令**
> 下列 7 条在第二批HEAD=`e8ab1f47`已原样重跑输出与本处记录逐字一致T7 实现内容与第一批相同,仅有维护者的路由门禁修复提交进入 HEAD。
```
$ gofmt -l <63 个改动/新增 .go 文件> # 无输出
$ go build ./cmd/api ./cmd/worker # 退出码 0
$ go run cmd/gendocs/main.go连续两次 # cmp -s 一致gendocs_idempotent=PASS
$ openspec validate add-export-time-filter-standards --strict
Change 'add-export-time-filter-standards' is valid # 退出码 0
$ openspec validate --all
Totals: 36 passed, 0 failed (36 items) # 退出码 0
$ openspec doctor --json
healthy= True status= [] # 退出码 0
$ ./scripts/context-health.sh
Context 健康检查通过 # 退出码 0
```
**2接受集合与拒绝集合`GET /api/admin/export-tasks`**
接受HTTP 200 / code 0`2026-09-01T00:00:00Z``2026-09-01T00:00:00+08:00``2026-09-01T00:00:00-05:30`、同瞬时两种偏移组合、仅 `end_time`、空串两端、两端缺省。
拒绝HTTP 400 / code 1001消息逐条一致
```
start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00
```
| 拒绝输入 | 归类 |
| --- | --- |
| `2026-09-01T00:00:00.000Z``2026-09-01T00:00:00.5+08:00` | 小数秒 |
| `2026-09-01T00:00:00` | 无时区 |
| `2026-09-01` | date-only |
| `2026-09-01 00:00:00` | 空格分隔 |
| `2026-09-01T00:00:00+0800` | `±hhmm` 偏移 |
| `2026-9-01T00:00:00Z` | 未补零日期 |
| `2026-09-01T0:00:00Z` | 未补零时间 |
| `2026-02-30T00:00:00+08:00` | 非法日历日期 |
| `2026-09-01T00:00:00+24:00``+23:60``+00:60``-24:00` | 偏移越界F3 收紧后) |
| `␣2026-09-01T00:00:00+08:00␣`、纯空白串、`\t` | 空白包裹/纯空白F3 收紧后) |
顺序校验:
```
start_time=2026-09-02T00:00:00Z & end_time=2026-09-01T00:00:00Z
→ HTTP 400 code=1001 msg=start_time 不能晚于 end_time
```
**3区间边界`GET /api/admin/export-tasks`**
```
两端缺省 total=55 | 空串两端 total=55与缺省一致
仅 start total=3 | 仅 end total=52 | 空结果集 total=0HTTP 200不报错
```
**4受影响端点旧参数名拒绝F1 修复后重跑)**
| 请求 | 结果 |
| --- | --- |
| `GET /api/admin/exchanges?created_at_start=2026-09-01T00:00:00+08:00` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` |
| `GET /api/admin/asset-allocation-records?created_at_start=...` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` |
| `GET /api/admin/agent-recharges?start_date=...` | 1001 `start_date 已废弃,请改用 start_time 与 end_time` |
| `GET /api/admin/expiring-assets?expires_from=...` | 1001 `expires_from 已废弃,请改用 start_time 与 end_time` |
| 上述四个端点的旧参数**空值**`?start_date=` | HTTP 200 / code 0按未传处理 |
**5未改造端点保持宽松HTTP 200 / code 0total 与缺省一致)**
```
GET /api/admin/devices?created_at_start=2026-01-01 total=6
GET /api/admin/devices?created_at_start=2026-01-01T00:00:00 total=6
GET /api/admin/shops/1/main-wallet/transactions?start_date=2026-01-01&end_date=2026-12-31 total=10
GET /api/admin/audit/events total=2734186
```
**6授权记录闭区间`GET /api/admin/authorizations``iccid` 过滤隔离标记行)**
```
start=2026-09-17T10:00:00+08:00 & end=2026-09-17T11:00:00+08:00 → total=2恰好命中 02:00:00 与 03:00:00 两行)
start=end=2026-09-17T10:00:00+08:00 → total=1闭区间含 start 端)
start=end=2026-09-17T16:00:00+08:00 → total=0区间外
```
旧 date-only 值被拒:`GET /api/admin/authorizations?start_time=2026-09-17` → 1001。
**7提现记录非法参数不再返回全量**
```
GET /api/admin/commission/withdrawal-requests缺省 total=5
GET /api/admin/commission/withdrawal-requests?start_time=<合法区间> total=2
GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001非全量
GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 → 1001
GET /api/admin/shops/{shop_id}/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001
GET /api/admin/shops/{shop_id}/withdrawal-requests缺省 total=3
```
**8佣金明细列表 vs 导出(同筛选行集一致)**
筛选 `start_time=2026-09-17T10:00:00+08:00``end_time=2026-09-17T14:00:00+08:00`fixture 含「start1s / ==start / start+1s / start+2h / ==end / end+1s / 8 小时漂移探针」七行原佣金 + 一行区间内回溯。
```
列表 GET /api/admin/shops/{shop_id}/commission-records total=5
命中02:00:00、02:00:01、04:00:00、06:00:00原佣金+ 03:00:00回溯
排除01:59:59start1s、06:00:01end+1s、08:00:008 小时漂移探针)
导出 POST /api/admin/export-tasks{scene=commission_record} status=3 total_rows=5
```
导出产物(`task 61`UTF-8 BOM CSV
```
记录来源,记录ID,代理店铺名称,关联订单号,资产标识,佣金来源,金额(元),是否可提现,状态,回溯后佣金余额(元),原佣金记录ID,来源退款单号,佣金入账时间,生成时间
原佣金,463,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 06:00:00
原佣金,462,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 04:00:00
回溯明细,9,...,AUG26014ORD005,AUG26014CARD001,成本价差,-10.00,不可提现,回溯,40.00,460,AUG26014REF001,,2026-09-17 03:00:00
原佣金,461,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:01
原佣金,460,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:00
```
结论:**列表与导出在同一筛选下行集完全一致5 = 5无 8 小时量级的边界漂移**(漂移探针行在两端行为一致均被排除);负数金额原样输出(`-10.00`)、入账后余额原样输出(`40.00`),表头 14 列与 `internal/exporter/commission_record_scene.go:53-59` 逐字一致。
**9临期资产列表 vs 导出、表头、加油包**
fixture3 个资产2 个同一最终到期时刻、1 个第 15 天窗口)+ 1 个挂主套餐的加油包。
```
列表 GET /api/admin/expiring-assets total=3
AUG26014CARD001 days=4 final=2026-09-21T16:00:00+08:00
AUG26014CARD002 days=4 final=2026-09-21T16:00:00+08:00
AUG26014CARD003 days=14 final=2026-10-01T16:00:00+08:00
闭区间 start=end=2026-09-21T16:00:00+08:00 → total=2同一时刻的两个资产都返回
区间外 start=end=2026-09-22T16:00:00+08:00 → total=0
同瞬时 Z 写法 start=end=2026-09-21T08:00:00Z → total=2
导出 POST /api/admin/expiring-assets/export{days_min:0,days_max:15} status=3 total_rows=3
```
导出产物(`task 62`
```
店铺,业务员,用户组,资产类型,设备类型,设备型号,资产标识,当前套餐,到期时间,剩余天数
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD001,AUG26014-临期套餐,2026-09-21 16:00:00,4
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD002,AUG26014-临期套餐,2026-09-21 16:00:00,4
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD003,AUG26014-临期套餐,2026-10-01 16:00:00,14
```
结论:**表头 10 列与 `111.md` §18.1 逐列同序一致****加油包不单独成行**4 条 `tb_package_usage` 行 → 3 条导出行导出与列表同筛选下行集一致3 = 3
**10遗留旧格式冻结任务安全失败7.9**
fixture`tb_export_task` 插入 `status=1``scene=order``query_json={"filters":{"start_time":"2026-09-01","end_time":"2026-09-02"}}`date-only 旧格式),经 `export:dispatch` 投递后由本地 worker 执行:
```
status= 4已失败
error_message= '导出筛选的时间边界非法,任务已终止' # 与 constants.ExportTaskInvalidTimeFilterMessage 逐字一致
file_key=None total_rows=0 total_shards=0 failed_shards=0 download_url=None
```
结论:**任务落失败、错误摘要为规定安全文案、未产出全量文件(无 file_key、无分片行**。
**11达量预警回归F2 修复后重跑)**
```
列表 GET /api/admin/package-traffic-alerts 缺省 / RFC3339+08:00 / date-only / 空格分隔 → 均 HTTP 200 code=0宽松契约不变
导出 POST /api/admin/export-tasks{scene=package_traffic_alert, filters={start_time,end_time 为带时区 RFC3339}}
status=3 total_rows=0测试库 tb_package_traffic_alert 当前 0 行)
表头=资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果
```
结论:**表头 18 列与改动前逐字一致**`internal/exporter/package_traffic_alert_scene.go:50-56` 未变更,`git diff` 仅改时间筛选的执行期解析);记录粒度与触发快照口径未动;该场景无金额列,金额/余额口径回归由佣金明细导出覆盖。
**12创建后归属变化不扩大数据集7.7 范围冻结)**
作为 §6.2 第 13 项(代理 HTTP 全链路)之外的执行期补充,本项以「按代理创建语义直接落入 `scope_shop_ids` 的冻结任务 + 真实 worker 执行」验证归属变化对已冻结任务的影响:
| 阶段 | 任务 | 冻结 scope | 资产位置 | 结果 |
| --- | --- | --- | --- | --- |
| 1 | `AUG26014-SCOPE-1`id 65 | `[1,1527]` | 在 1527 | `total_rows=3` |
| 2 | `AUG26014-SCOPE-1`id 66重置重跑 | `[1,1527]` | 已移出至 shop 2 | `total_rows=0` |
| 对照 | 超管无范围任务id 67 | 空(不限制) | 已移出至 shop 2 | `total_rows=3` |
结论:**执行期只按创建时冻结的 `scope_shop_ids` 取数;资产归属变化后行集缩小而非扩大**;对照组证明归属变更确实生效(超管看到 3 行)。
**13代理账号经 HTTP 全链路(第二批补充;真实 `POST /api/auth/login` 登录,非注入令牌)**
fixture可见店铺 `1528``parent_id=1`)内 3 个临期资产 + 不可见店铺 `shop 2` 内 2 个临期资产(`AUG26014OUT001/002`);代理账号 `AUG26014AGENT``user_type=3``shop_id=1`)与本工具写入的 bcrypt 口令**真实登录**取得令牌。
```
AGENT(有店铺) login ok user_type= 3 shop_id= 1
AGENT(无店铺) login ok user_type= 3 shop_id= None
```
1列表只返回可见店铺
```
GET /api/admin/expiring-assets 代理令牌http=200 code=0 total=3
iot_card AUG26014CARD001 shop_id=1528 days=4
iot_card AUG26014CARD002 shop_id=1528 days=4
iot_card AUG26014CARD003 shop_id=1528 days=14
summary={"card_count":3,"device_count":0,"total_count":3,"window_days":15}
GET /api/admin/expiring-assets 超管对照total=5
identifiers=[CARD001, CARD002, OUT001, OUT002, CARD003]
```
即:**代理看不到 `shop 2` 的 2 个范围外资产,超管能看到全部 5 个**(逐个资产标识与期望一致)。
2代理创建并执行临期导出行集 ⊆ 创建时冻结的可见店铺范围):
```
POST /api/admin/expiring-assets/export {format:csv,days_min:0,days_max:15}
http=200 code=0 → task_id=68 task_no=EXP-20260917-960000 status=1
task 68 → status=3 total_rows=3与代理列表 total=3 一致)
库内冻结行长dbhub 只读):
id=68 scene=expiring_asset creator_user_id=683 creator_user_type=3 creator_shop_id=1
scope_shop_ids=[1, 6, 1528] # 代理 1 号店的下级树快照
query_json={"filters":{"days_max":15,"days_min":0},"resolved_headers":["店铺","业务员","用户组","资产类型","设备类型","设备型号","资产标识","当前套餐","到期时间","剩余天数"]}
导出产物3 行,资产标识为 CARD001/CARD002/CARD003不含 OUT001/OUT002
```
3空可见范围代理无店铺创建被拒
```
GET /api/admin/expiring-assets shop_id=NULL 的 fixture 代理http=200 code=0 total=0
POST /api/admin/expiring-assets/export (同上) http=403 code=1005 msg=代理账号缺少店铺信息
```
4越权与不存在统一不可见同一代理令牌
```
GET /api/admin/export-tasks/1 → http=403 code=1005 msg=无权限操作该资源或资源不存在
GET /api/admin/export-tasks/999999 → http=403 code=1005 msg=无权限操作该资源或资源不存在
一致: True
```
**14达量预警列表 vs 导出同筛选行集一致7.6 缺口补齐)**
fixture 直接写入 `tb_package_traffic_alert` 3 条快照2 条 `triggered_at``[2026-09-01T00:00:00Z, 2026-09-30T23:59:59Z]` 内、1 条在 2026-10-05 窗外),同筛选分别调列表与导出:
```
GET /api/admin/package-traffic-alerts?start_time=2026-09-01T00:00:00Z&end_time=2026-09-30T23:59:59Z total=2
POST /api/admin/export-tasks{scene:package_traffic_alert, filters 同上} → task 69 status=3 total_rows=2
(库内冻结行 69scope_shop_ids=[]query_json.filters 已规范化为 UTC RFC3339
导出产物:
资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果
物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,80.00,,,2026-09-20 20:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递
物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,70.00,,,2026-09-15 08:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递
```
结论:**列表 total=2 == 导出 total_rows=2窗外那条被两端一致排除**;触发快照列(用量/额度/比例/阈值)原样输出,未被执行期改动影响。
### 6.3 fixture 清单与清理结果
第一批§6.2 第 112 项):
| 表 | 标记方式 | 造行数 | 用途 |
| --- | --- | --- | --- |
| `tb_shop` | `shop_name LIKE 'AUG26014%'` | 1 | 临期资产归属店铺(`parent_id=1`,供代理可见链路) |
| `tb_iot_card` | `iccid LIKE 'AUG26014%'` | 3 | 临期资产 |
| `tb_package_usage` | `package_name LIKE 'AUG26014%'` | 43 主套餐 + 1 加油包) | 最终到期推算 / 加油包不单独成行 |
| `tb_order` | `order_no LIKE 'AUG26014%'` | 6 | 佣金明细关联订单号与 `(order_id,package_id)` 唯一索引避让 |
| `tb_commission_record` | `remark LIKE 'AUG26014%'` | 7 | 佣金闭区间两端 + 1 秒内 + 4 小时内 + 8 小时漂移探针 |
| `tb_commission_clawback_record` | `refund_no LIKE 'AUG26014%'` | 1 | 回溯分支负金额口径 |
| `tb_enterprise_card_authorization` | `remark LIKE 'AUG26014%'` | 3 | 授权闭区间三态(等于 start / 等于 end / 区间外,整秒) |
| `tb_commission_withdrawal_request` | `withdrawal_no LIKE 'AUG26014%'` | 3 | 提现闭区间三态(整秒) |
| `tb_export_task` / `tb_export_shard_task` | `task_no LIKE 'AUG26014%'` | 遗留任务 1 + 冻结范围任务 1 | 7.9 遗留安全、范围冻结 |
第二批§6.2 第 1314 项):
| 表 | 标记方式 | 造行数 | 用途 |
| --- | --- | --- | --- |
| `tb_iot_card`(范围外) | `iccid LIKE 'AUG26014OUT%'``shop_id=2` | 2 | 代理可见范围对照(不得出现在代理列表/导出) |
| `tb_order`(范围外) | `order_no LIKE 'AUG26014OUTORD%'` | 2 | 同上(订单与套餐使用的外键上下文) |
| `tb_package_usage`(范围外) | `package_name LIKE 'AUG26014-范围外套餐'` | 2 | 同上 |
| `tb_account` | `username LIKE 'AUG26014%'` | 2 | fixture 代理账号:`AUG26014AGENT``shop_id=1`)、`AUG26014AGENTNOSHOP``shop_id=NULL`);真实登录用,口令 bcrypt 由工具写入且不回显 |
| `tb_package_traffic_alert` | `notification_event_id LIKE 'AUG26014%'` | 32 条窗内 + 1 条窗外) | 达量预警列表与导出同筛选行集一致 |
**清理结果**`cleanup` 按标记删除 + `cleanup-tasks` 删除本次经 HTTP 创建的无标记导出任务):
```
CLEANUP_DONE
COUNT tb_shop=0 COUNT tb_iot_card=0 COUNT tb_package_usage=0
COUNT tb_order=0 COUNT tb_account=0 COUNT tb_package_traffic_alert=0
COUNT tb_export_task=0标记任务 COUNT tb_export_shard_task=0标记分片
DELETED_TASKS=6 ids=58,59,61,62,64,67 # 第一批
DELETED_TASKS=2 ids=68,69 # 第二批(代理导出、达量预警导出)
```
清理后库内基线回到验证前状态dbhub 只读复核):
```
tb_export_task = 52 tb_export_shard_task = 48 id > 52 的导出任务 = 0
AUG26014 标记tb_shop / tb_iot_card / tb_package_usage / tb_order / tb_account /
tb_package_traffic_alert / tb_export_task 计数全部 = 0
```
### 6.4 证据与代码版本的对应
| 证据批次 | 时间 | HEAD / 指纹 | 说明 |
| --- | --- | --- | --- |
| 预演T1/T2/T3/T7 首轮) | 17:2617:37 | `d52be16` / 修复前二进制 | 仅作预演记录;本文件引用的有效证据全部为修复后批次 |
| 第一批§6.2 第 112 项) | 17:3917:47 | `d52be16` / `51c619f8…` | T7 实现尚未提交;当时 HEAD 尚不含维护者的路由门禁修复,故代理 HTTP 项为 403/1005 |
| 第二批§6.2 第 1314 项、§6.1 第 1517 行) | 18:0x18:1x | `e8ab1f47`(含 `398a5e42`/ `37d7d690…` | 仅多出维护者对 `internal/routes/{package_traffic_alert,asset_auto_renewal}.go` 的门禁作用域修复提交T7 实现内容与第一批一致 |
### 6.5 未验证项(环境限制)
1. **「代理可见店铺为空列表」这一极端分支**:未单独构造。`GetSubordinateShopIDs` 恒包含账号自身店铺(`internal/store/postgres/shop_store.go:162-192``ids := []uint{shopID}` 起始),因此「非零 shop_id 且可见项为空」在正常数据下不可达;已用 `shop_id=NULL` 的 fixture 代理账号覆盖最近的等价分支(创建导出被拒 `403 / 1005 代理账号缺少店铺信息`,见 §6.2 第 13 项第 3 段)。
2. **达量预警「真实扫描链路」产生的行级口径**:本批以 fixture 直接写入 `tb_package_traffic_alert` 快照行验证了列表与导出同筛选行集一致2=2与快照列原样输出未跑真实的每日扫描任务需有效套餐 + 真流量累计 + 通知链路,代价高且与本 Change 无关)。
3. **`tb_export_task` 中「旧格式冻结值」的历史存量**:清理前全库实测 0 条(`count(*) WHERE query_json::text ~ '[0-9]{4}-[0-9]{2}-[0-9]{2}[ T]'` = 07.9 因此用自建 fixture 覆盖。
4. **迁移 `up/down/up`**:本变更无 Schema 变更§5按设计为「不适用」。
### 6.6 既有缺陷 D1路由门禁作用域已定位已由维护者提交 `398a5e42` 修复)
**现象第一批HEAD 尚为 `d52be16`、不含修复)**:代理账号访问 `/api/admin/expiring-assets` 与新增的 `/api/admin/expiring-assets/export` 一律返回 403 / `1005 无权限操作该资源或资源不存在`,与路由注释「后台和代理共用」及路由描述「企业账号禁止调用」矛盾。原始输出:
```
$ curl -s -H "Authorization: Bearer <agent-token>" 'http://127.0.0.1:3000/api/admin/expiring-assets?page=1&page_size=1'
{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"}
$ curl -s -X POST -H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
-d '{"format":"csv"}' 'http://127.0.0.1:3000/api/admin/expiring-assets/export'
{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"}
```
定位过程中的门禁键隔离实验(**注入令牌的 `user_type` 由验证者显式写入 Redis 值决定**,非账号真实类型):同一 `user_id=144``user_type=2` 注入 → 200`user_type=3` 注入 → 1005`user_id=148`(真实代理)以 `user_type=3` 注入 → 1005平台 `user_id=149`**平台账号,`user_type=2`,非代理**)以 `user_type=2` 注入 → 200。旁证`user_type=3` 的令牌访问任一不存在的 `/api/admin/*` 路径同样 1005超管/平台为 `1006 资源未找到`),说明拦截发生在其后的路由之前。
**根因(已由维护者提交证实)**`internal/routes/package_traffic_alert.go``internal/routes/asset_auto_renewal.go` 原以 `router.Group("", gate)` 在管理端根组注册「仅超管/平台」门禁。Fiber 的 `Group(prefix, handlers...)` 会把该处理器注册为 `path=/api/admin` 的 USE 处理器并匹配**整个 `/api/admin` 前缀**,且按注册顺序先于其后注册的路由执行,因此 `internal/routes/admin.go` 中位于 `:107`package-traffic-alert`:110`asset-auto-renewal之后注册的全部后台接口`:125` admin exchanges、`:149` `/assets/*``:150` `/expiring-assets` 与新增 `/expiring-assets/export``:165` agent-recharges以及 `:122` admin orders对代理/企业账号一律 403-1005早于该点的 `:36/:52/:67/:71/:74/:83/:89/:101` 不受影响——与实测 200/1005 的分界完全吻合。
**修复(维护者提交 `398a5e42`2026-09-17 17:49:33不在本 Change 改动内)**`git show 398a5e42` 将两处门禁改为挂在各自功能前缀组上(`asset_auto_renewal.go``router.Group("/asset-auto-renewal-config", …)``package_traffic_alert.go``router.Group("/package-traffic-alert-rules", gate)``router.Group("/package-traffic-alerts", gate)`并在注释中写明「gate 必须挂在功能路径组上Fiber 的组中间件按路径前缀生效,挂在空路径组上会落到 /api/admin 前缀」。该修复与本文件定位的机制一致。
**修复后复验第二批HEAD=`e8ab1f47`**代理账号经真实登录访问同一端点已可达列表只返回可见店铺3 行,不含范围外 2 个资产),导出创建并执行为 3 行,详见 §6.2 第 13 项;本 Change 的代理视角验收因此在本批全部通过。
**归类**:既有实现缺陷(**非本 Change 引入****已由维护者在 HEAD 修复**;本 Change 无需改动,亦未被该缺陷遗留影响。验证的第一批 2 个 FAIL`§6.1` 第 15 行)为修复前版本的照实记录,已保留。
## 7. 实现期发现(需 director 决策,未在本次改动)
`tb_export_task``tb_commission_record``tb_commission_withdrawal_request``tb_agent_recharge_record``tb_enterprise_card_authorization` 等列的时间列为 PostgreSQL `timestamp without time zone`,其中保存的是应用本地时区(部署容器 `TZ=Asia/Shanghai`)的**挂钟时间**`tb_order``tb_exchange_order``tb_asset_allocation_record``timestamp with time zone`。测试库实测dbhub 只读查询):
```sql
SELECT o.created_at AS order_created_tz, c.created_at AS commission_created_naive, (c.created_at - o.created_at) AS delta
FROM tb_commission_record c JOIN tb_order o ON o.id = c.order_id ORDER BY c.id DESC LIMIT 1;
-- 2026-08-25 08:08:15.00218+00 | 2026-08-25 16:08:20.158216 | {"hours": 8, "seconds": 5, ...}
```
即同一笔业务事实在无时区列上的挂钟时间比带时区列的快照早 8 小时。驱动在绑定 `time.Time` 到无时区列时会丢弃时区(仅保留挂钟字段),因此统一按「归一后的 UTC 瞬时」绑定后,无时区列上的有效边界为该瞬时的 UTC 挂钟值。
本次实现按 spec「解析结果 MUST 归一为 UTC 瞬时」与唯一合规先例(`internal/exporter/package_traffic_alert_scene.go``startTime.UTC()`原样绑定未引入额外的时区换算列表与导出使用同一解析结果因此同筛选下行集一致。无时区列上的「用户本地整日区间」与「UTC 挂钟边界」之间的差值属本变更范围外的既有事实。
director 决定:
- 该事实**已登记**为本变更产物的已知差异,见 [`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md) 第 6 条「无时区列的时间锚定(本次不改)」。
- 本次**不改**:统一按 spec 的「归一为 UTC 瞬时」绑定,列表与导出同向同量、有效边界一致,已由本文件 §6.2 第 8 项(含 8 小时漂移探针)实测确认。
- 若产品期望「按真实发生瞬时的上海自然日」筛选,属**另立 Change** 的范围(涉及 8 张无时区列的口径与历史数据)。
## 8. 相关文件
- 变更产物:[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/)
- 行为契约:[`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md)
- 上下文证据:[`./context-reset/requirement-evidence.json`](./context-reset/requirement-evidence.json)、[`./context-reset/entry-capability-requirement-matrix.json`](./context-reset/entry-capability-requirement-matrix.json)
- 工程门禁:[`../engineering/工程约束.md`](../engineering/工程约束.md)
- 同类体例参考:[`./add-priority-polling-queue-verification.md`](./add-priority-polling-queue-verification.md)

View File

@@ -0,0 +1,207 @@
# AUG26-016 卡轮询优先队列add-priority-polling-queue实施与验证记录
本文件记录 OpenSpec Change `add-priority-polling-queue` 的实施范围、已实测证据与未验证清单。
所有实测均在测试库 `junhong_cmp_test` 与**隔离 Redis DB 15** 上执行;未连接生产、未启动 Worker/API 进程、未调用运营商上游接口。
## 0. 结论概览
| 验证项 | 断言计数 | 结论 |
| --- | --- | --- |
| 9.1 迁移 up/down/up + down 守卫 | 结构断言 4 组 + 探针 6 条 | 通过(含记账 `dirty=true` 的实测结论) |
| 9.29.7 本机可安全验证部分AF + H5 批) | **94 PASS / 0 FAIL** | 通过 |
| 接缝执行(认领/跳过/接管/范围校验,非导出接缝经真实 `Handle` 驱动) | **63 PASS / 0 FAIL** | 通过 |
| 提示通道优先键与手动键、FIFO、`RemoveFromAllQueues` 边界、分片队列快照) | **12 PASS / 0 FAIL** | 通过 |
| 清理与零残留PG 六类计数 + Redis 键 + 越界 DB 只读复核) | **20 PASS / 0 FAIL** | 通过 |
| 成功路径 `Complete`、真并发、尝试上限第 3 次判定、package handler 接缝、HTTP 层、通道阈值持锁、三类生效触发点生产集成 | — | **未验证**(见第 4 节) |
## 1. 环境与命令
- 目标库:`junhong_cmp_test``cxd.whcxd.cn:16159`);迁移经 `scripts/migrate.sh`(读取 `.env``DB_*`)。
- 隔离 Redis`cxd.whcxd.cn:16299` **DB 15**(部署库为 DB 6、本地沙盒为 DB 7二者对所有写入被显式拒绝
- 环境装载:`set -a; . ./.env; . ./.env.local; set +a``DB_*` 只在 `.env`Redis 参数只在 `.env.local`)。
- 构建:`go build -o /tmp/pf ./cmd/priority-fixture-tmp`
- 只读诊断dbhub MCP `mcp__postgres_execute_sql_main`(仅 SELECTRedis 只读用 `redis-cli -n <db> EXISTS|DBSIZE|SCAN`
- 临时工具子命令:`setup|a-store|b-consumer|c-trigger|d-read|e-enqueue|f-audit|g-h5|seam-verify|hint-verify|purge-stale-audits|cleanup|count|insert-fixture|delete-fixture|count-rows|index-probe`
该工具带有安全栏:`DB_NAME` 必须为 `junhong_cmp_test`,所有自建行带 `AUG26016` 标记,清理只按本批 ID 集合或标记删除。
- 日志:每场景独立带时间戳文件 `run<N>-<步骤>-<TS>.log`(不覆盖);本文只摘录关键原始行。
## 2. 已验证清单(含点时间与期望/实际)
### 2.1 任务 9.1:迁移 up / down / up 与 down 守卫2026-09-16
| 步骤 | 期望 | 实际(原文摘录) |
| --- | --- | --- |
| 迁移前版本 | 227 | `当前迁移版本: 227` |
| `./scripts/migrate.sh up` | 应用到 228exit=0 | `228/u add_polling_priority_queue (344.53875ms)``✓ 迁移操作完成` |
| 结构 | 23 列全注释、5 索引含活动项部分唯一索引、9 CHECK、0 外键 | `column_count=23``commented_columns=23``index_count=5``c:9`+`p:1``fk_count=0` |
| 活动项索引谓词 | 仅 `deleted_at IS NULL AND status IN ('pending','processing')` 占键位 | `CREATE UNIQUE INDEX uq_polling_priority_item_active ... WHERE ((deleted_at IS NULL) AND ((status)::text = ANY ((ARRAY['pending','processing'])::text[])))` |
| 造 1 条活动 fixture 后 `./scripts/migrate.sh down` | 守卫拒绝回滚且**结构零损伤** | `pq: 存在卡轮询优先队列活动项,拒绝回滚以避免丢失在途加急事实与认领租约状态``down_exit=1`;随后 `table_regclass=tb_polling_priority_item``row_count=1``column_count=23``index_count=5` |
| 守卫后的记账标记 | —(实测为工具行为) | `{"version":"227","dirty":true}` |
| 记账修复 | `force <真实版本>` 只改一行、不动 DDL | `./scripts/migrate.sh force 228``{"version":"228","dirty":false}` |
| 清 fixture 后 `down` | 表/索引/relation 全部消失exit=0 | `228/d add_polling_priority_queue (221.332417ms)``{"table_regclass":null,"index_count":"0","relation_leftovers":"0"}` |
| 再次 `up` | 回到 228 且结构完整 | `{"version":"228","dirty":false,"column_count":"23","index_count":"5","check_count":"9","fk_count":"0"}` |
**实测结论(`dirty=true` 的口径)**golang-migrate 在运行某个迁移文件之前先写入目标版本并标 `dirty=true`,成功后写回 `dirty=false``down 228` 的目标版本是 227守卫 `RAISE EXCEPTION` 后库被留在 `227, dirty=true`
这是**迁移工具的记账行为,不是本 Change 的实现缺陷**;恢复方式是 `./scripts/migrate.sh force <当前真实版本>`(本例 228该命令只修一行记账、不触发 DDL 或数据变更。验证 down 守卫的正确预期应写成「守卫拒绝 + 结构零损伤 + 由维护者 `force` 回当前真实版本并确认 `dirty=false`」。
### 2.2 部分唯一索引语义与约束探针2026-09-16
```
[成功] P1 插入活动行 Aid=1 card_id=6012 task_type=carddata status=pending
[报错] P2 重复活动行(同卡同任务类型) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
[成功] P3 软删 A 后插入活动行 Bid=3→ 软删行不占用活动项键位
[报错] P4 插入活动行 CB 仍活动) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
[成功] P5 把 B 转终态completed/success后插入活动行 Did=5→ 终态行不占用活动项键位
[报错] P6 终态与结果一致性completed 但 result 为空) → SQLSTATE=23514 constraint=ck_polling_priority_item_terminal_result
索引与约束探针全部符合预期 exit=0
```
### 2.3 AF 批次Store / 消费者 / 触发分类 / 读侧 / 人工入队 / 审计——94 PASS / 0 FAIL
时间2026-09-17 09:46:49+08日志 `run5-<场景>-20260917T094649.log`(另有一轮同结果运行 `run-e2e-*-20260917T092447.log`)。
| 场景 | 断言 | 期望 → 实际(摘录) |
| --- | --- | --- |
| A Store 生命周期 | 20 PASS | `A1 首次认领命中…→ status=processing claimed_at=2026-09-17 09:24:49+08``A2 超租约(120s>90s)允许接管 → taken=true``A2 租约内(10s<90s)不允许接管 → taken=false``A3 两次 IncrementAttempt 后 attempt_count → 期望=2 实际=2``A3 MarkFailed未累加尝试次数等同 FailFinal 路径)→ attempt_count=0``A3 尝试上限路径 → attempt_count=1``A4 分页归一化 Page=0/PageSize=999 → total=29 returned=20` |
| B 消费者 | 15 PASS | `B5 设备载体在事务内冻结的绑定卡数 → 3``B5 活动项行数3 卡 × 4 任务类型) → 12``B6 重复投递只合并:行数不变、触发次数+1、最近触发时间刷新、触发类型集合去重``B7 自动no_valid_package+ 人工manual_trigger合并为同一行``B8 已终态后重复追加同一 event_idoutbox 仍只 1 行``B9 同一 event_id 重投不产生重复审计行EventID 去重) → 1` |
| C 触发分类与前置条件 | 13 PASS | `C10 → renewal_activated` / `purchase_activated``C11 存在 status=0 → HasPendingPackageUsage=true``C12 观测抑制为真 → 不写事件0 行)``C12 无有效套餐 event_id ≤ 48 且主体与载体可区分 → prio:nvp:d:990016999:… len=31` |
| D 读侧权限与范围 | 12 PASS | `D13 超管/平台 → total=18/18``D13 代理 → total=16`(平台卡与范围外卡各 1 行不可见);`D13 企业 → code=1005``D14 详情越权 vs 不存在 → 同一码与文案` |
| E 人工入队用例 | 14 PASS | `E15 一次入队覆盖全部纳入类型 → created=4 merged=0``E16 24h 内再次入队 → created=0 merged=4``E17 原因缺失/超长 → CodeInvalidParam``E17 拒绝审计 → 5 条 manual_denied``E17 被拒绝的请求不产生优先项变更 → 仍 4 行活动项` |
| F 审计来源匹配 | 6 PASS | `F polling_priority.enqueue/claim/manual_denied 注册 → primary=polling_priority_itemorigins 含 account/admin_api 与 system_task/worker``F 消费者审计 Actor=system_task / Source=worker` |
| count残留计数 | 10 PASS | 卡/设备/绑定/套餐使用/优先项/审计/outbox/两类孤儿审计资源 **均 = 0**,且 `本批全部自建行已清理干净` |
### 2.4 H5 加固:事件通道拒绝 `manual_trigger`2026-09-17 09:24:47
`internal/infrastructure/prioritypolling/event.go``AppendPriorityRequested` 在资源类型校验后显式拒绝
`trigger_type='manual_trigger'`(人工入队由 `internal/service/polling/priority_enqueue_service.go` 直写事实表,不经事件通道)。
```
[PASS] H5-1 经事件通道投递 manual_trigger 返回参数错误 → code=1001 err=优先轮询事件不接受人工入队触发类型 manual_trigger人工入队请调用人工入队用例
[PASS] H5-3 该 event_id 在 tb_outbox_event 新增 0 行 → 期望=0 实际=0
[PASS] H5-4 tb_outbox_event 总行数不变(拒绝发生在写入前) → 期望=19453 实际=19453
```
### 2.5 接缝执行seam-verify——63 PASS / 0 FAIL
时间2026-09-17 10:16:34+08隔离 Redis DB 15打印 `db=15`**未执行 `FlushDB`**。
驱动方式:不启动 Worker/Asynq/HTTP`task.NewPollingBase` + `asynq.NewTask` 直接调用
`PollingRealnameHandler` / `PollingCarddataHandler` / `PollingCardStatusHandler` 的真实 `Handle`
`integration` 注入真实仓库(非 nil使「零上游」由真实计数断言证明。
每个任务类型realname / carddata / card_status依次断言 (a)(e)
| 断言组 | 期望 → 实际(摘录) |
| --- | --- |
| 计数器真对照 | `写入 1 行 tb_integration_log 后按夹具卡统计 +1 → 期望=1 实际=1``对照行已删除:计数回到原值 → 期望=0 实际=0` |
| (a) 无活动项 = 基线等价 | `Handle 正常返回 → err=<nil>``不产生优先项行 → 0``零上游调用 → 0``仍按既有路径重入队(分片 ZSET 命中) → key=polling:shard:0:queue:polling:realname member=11696` |
| (b) pending = 本次领取 | `claim 审计 +1 → delta=1``status=failed claimed_at=2026-09-17 10:16:41+08 attempt=0 reason="流量查询能力未配置"``fail+dequeue 各 +1``零上游调用 → 0` |
| (c) 租约内 = 跳过且零上游 | `状态/认领时间/尝试次数/失败原因全部不变 → processingclaimed_at 前后相同``claim=0 fail=0 dequeue=0``零上游调用 → 0``重新入队ZSET 命中)` |
| (d) 超租约 = 接管 | `claimed_at=2026-09-17 10:16:42+08`(距运行 <30s已刷新`claim delta=1``零上游调用 → 0` |
| (e) 卡不在轮询范围 | `status=failed reason="卡已不在轮询范围内" attempt=0``零上游调用 → 0` |
收尾(同一进程内):
```
[清理] 隔离 Redis 已删除本套件使用的 12 个键(未执行 FlushDB
[清理] 审计事实(按本批 ID 集合精确删除):事件=27资源行=27
[清理] 本功能动作码审计保留条数非本批写入未删除0
[清理] 优先项=0Outbox(prio:)=0套餐使用=0绑定=3卡=6设备=1
[PASS] 清理后计数 … 应为 09 条)+ 本批全部自建行已清理干净
结果:全部断言通过
```
### 2.6 提示通道hint-verify——12 PASS / 0 FAIL
时间2026-09-17 10:16:34+08隔离 Redis DB 15、未 `FlushDB`、全程前后 `polling:shard:*` 快照 `{}`
```
[PASS] ① EnqueuePriority 只写优先提示键LLEN → 期望=3 实际=3
[PASS] ① 手动触发键未被 EnqueuePriority 触碰LLEN → 期望=0 实际=0
[PASS] ① EnqueueManual 只写手动触发键LLEN → 期望=1 实际=1
[PASS] ① 优先提示键未被 EnqueueManual 触碰LLEN → 期望=3 实际=3
[PASS] ② RPush 3 元素后 LPopCount 弹出顺序为先进先出 → 期望=[990016101 990016102 990016103] 实际=[990016101 990016102 990016103]
[PASS] ③ 边界登记RemoveFromAllQueues 不清理优先提示键LLEN 保持 1
[PASS] ③ 边界登记RemoveFromAllQueues 不清理手动触发键LLEN 保持 1
[PASS] ④ 本子命令前后 polling:shard:* 键集合与成员完全一致diff 为空) → before={} after={}
[PASS] 收尾 删除本子命令使用的键数 → 2两个键 EXISTS=0未残留
```
### 2.7 清理与零残留PG + Redis 只读复核2026-09-17 10:17 起)
| 指标 | 运行前 | 运行后 |
| --- | --- | --- |
| `tb_polling_priority_item` | 0 | **0** |
| 标记夹具卡 / 设备(`AUG26016%` | 0 / 0 | **0 / 0** |
| `polling_priority.%` 审计 | 0 | **0** |
| `prio:` outbox | 0 | **0** |
| 孤儿审计资源(`polling_priority_item` / `iot_card+polling_card` | 0 / 0 | **0 / 0** |
| `schema_migrations` | 228 / dirty=false | **228 / dirty=false** |
| Redis DB 15 `polling:*` 键数 | 0 | **0** |
| Redis DB 15 本套件 12 键 `EXISTS` | — | **0** |
| Redis DB 6 夹具卡专属键(`polling:card:11696``traffic:sync:lock:card:11696` | — | **0 / 0**(无本 Change 痕迹) |
库内全量孤儿审计资源另有 88 行,分组为 `employee_collection_bill=37``employee_collection_application=16`
`employee_collection_application_attempt=16``package_traffic_alert=16``package_traffic_alert_rule=3`——
**全部属于其它能力**,本 Change 两类均为 0。
### 2.8 一次真实失败与其修复(如实记录)
首次接缝运行2026-09-17 10:11:34`run6-seam-verify-20260917T101134.log`)接缝断言 61/61 通过,
但收尾残留检查 2 条 FAIL`polling_priority.*` 审计残留 27 条claim 9 / fail 9 / dequeue 9
`created_at` 2026-09-17T02:11:37Z02:11:49Z
根因是**验证工具自身的记账缺陷**:场景逐个任务类型收尾会删除优先项行,而审计清理依赖按优先项 ID 反查,删行后即漏删。
修复(仅临时工具,未放宽任何断言):运行期采集优先项 ID 并在清理时作为额外 ID 集合传入;另加带时间窗护栏的
`purge-stale-audits` 兜底子命令(必须显式给 `AUG26016_PURGE_SINCE`,窗口外的行一律拒绝删除并列出)。
修复后重跑 63/63 全绿27 条残留按兜底路径精确删除(`事件=27资源行=27删除后残留=0`)。
## 3. 工程门禁收尾复跑脚手架删除后2026-09-17 10:26
| 命令 | 原始结果 | 退出码 |
| --- | --- | --- |
| `gofmt -l .` | 仅列出 6 个**历史未格式化**文件:`internal/model/dto/package_dto.go``internal/model/order_package_invalidate_task.go``internal/model/personal_customer_device.go``internal/model/personal_customer_iccid.go``internal/model/personal_customer_phone.go``internal/query/h5popup/query.go`;本 Change 改动文件(`internal/infrastructure/prioritypolling/event.go``internal/task/*``internal/polling/*``internal/service/*``pkg/constants/*``migrations/000228_*` 等)**均未出现** → 无新增未格式化文件 | 0 |
| `go build ./cmd/api ./cmd/worker` | 无输出(仅 Go 模块缓存 stat 写入诊断,不影响构建) | 0 |
| `go vet ./...` | 无诊断输出 | 0 |
| `go run cmd/gendocs/main.go`(连续两次) | 两次均输出「成功在以下位置生成 OpenAPI 文档」;`md5(docs/admin-openapi.yaml)` = `696dba47834f93faacba93c745353fdd`(两次一致;该文件被 `.gitignore` 忽略,是本地生成产物) | 0 / 0 |
| `openspec validate add-priority-polling-queue --strict` | `Change 'add-priority-polling-queue' is valid` | 0 |
| `openspec doctor --json` | `"root": {… "healthy": true, "status": []}``"status": []` | 0 |
| `./scripts/context-health.sh` | `Context 健康检查通过` | 0 |
自动化测试按项目决策为 N/A未创建任何 `*_test.go``context-health.sh` 亦校验仓库无 `*_test.go`)。
## 4. 未验证清单(明确未覆盖,不得据本文推断)
| 项 | 未验证原因 |
| --- | --- |
| 优先项成功路径 `Complete``processing → completed` | 需真实上游调用成功;本轮所有执行都停在「能力未配置」失败分支,未制造成功上游响应 |
| 真并发下的认领互斥 | 无并发执行环境;本轮以「租约内执行中 → 跳过且零上游」的单线程等价路径覆盖,未做同卡同类型真并发竞态 |
| 尝试上限第 3 次判定 | 需稳定失败的真实上游;本轮止于 `FailFinal`(尝试次数不累加),未覆盖 `FailRetryable` 连续 3 次的收敛 |
| `package` handler 的接缝 | 其 `Handle` 依赖 `t.ResultWriter().TaskID()`,本地 `asynq.Task` 会 panic未驱动 |
| HTTP 层权限矩阵 | 未启动 API权限判定仅覆盖服务层与查询层读侧范围、越权=不存在同一响应),未取得真实状态码/`msg` |
| 9.7 通道阈值持锁边界 | 需 `tb_carrier_traffic_threshold_lock` 活动锁与真实停复机评估路径;本轮未造锁、未驱动停复机 |
| 三类生效触发点的生产集成 | 新购/续购/加油包/排队顺延的触发分类与事件构造已单测C 场景),但未经真实订单/支付/激活链路端到端验证 |
| `queue_activated` / `addon_activated` 正向落库 `trigger_type` | C 场景只对 `purchase_activated`/`renewal_activated` 做了正向落库;排队顺延与加油包仅验证了事件构造 |
| 调度器排空顺序与分片背压 | 需 Worker + 既有部署环境;本文只覆盖提示通道的生产/消费语义 |
| 迁移 down 守卫的自动化 | 未引入自动化测试(项目决策 N/A仅为手工实测记录 |
## 5. 边界声明(避免误读)
1. **`tb_integration_log` 全表计数会被在跑的测试部署推高**:观测窗口内该表由 5,905,167 增至 5,905,619+452
来源是并发运行的既有测试部署DB 6 心跳/并发/27 卡在跑),**不是本套件产生**;本套件在该表只插入并删除 1 行对照行(复查 = 0
2. **「零上游调用」的判据是夹具卡维度**`tb_integration_log WHERE resource_type='iot_card' AND resource_id='<夹具卡ID>'`
每条场景运行前后均为 015 条独立断言),并由 `counterControl` 对照证明该计数口径能发现写入。
这**不等于**「整库无上游调用」——测试部署的正常上游调用一直在发生。
3. **隔离 Redis DB 15 并非本套件独占**:其中仍有他人 7 个键(`auth:refresh:…``auth:user:127:tokens`
`auth:token:482e6fdc-…``asynq:queues``asynq:{export:*}×3`)。因此验证工具**默认不执行 `FlushDB`**
只按清单删除自己使用的键DB 0/6/7 在所有写入路径上被显式拒绝DB 6/7 仅做过 `EXISTS`/`DBSIZE`/`SCAN` 只读探测)。
4. **成功路径与并发结论不得外推**:本轮所有执行都走「能力未配置」分支,因此「零上游」「状态收敛」的结论仅适用于该分支;
成功路径 `Complete`、真并发互斥、尝试上限收敛见第 4 节未验证清单。
## 6. 收尾处置
- 验证脚手架 `cmd/priority-fixture-tmp/`main.go / verify.go / seam.go / hint.go / purge.go为一次性工具
验证完成后**整目录删除**`rm -rf cmd/priority-fixture-tmp`DELETED_EXIT=0
删除确认:`ls cmd/` 只剩 `api/audit-coverage/audit-retention-simulate/foundation-check/gendocs/migration-finalize/worker`
`find . -name 'priority-fixture-tmp*' -not -path './.git/*'` 无输出(仓库根目录与全仓均无同名二进制);
`git status --porcelain | grep -i fixture` 无输出(工作区已不含该目录)。
- 测试库 `tb_polling_priority_item` 与本 Change 动作码审计、`prio:` outbox、标记夹具均已清零见 2.7
本文引用的逐场景日志保存在 `/tmp/aug26-016/`(会话级临时目录,非仓库产物)。
- 未提交、未 push。2026-09-17 归档Change 目录移至 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/`(含 `.openspec.yaml`delta spec 已同步进主 Specs新增 `openspec/specs/priority-polling-queue/spec.md`,并在 `openspec/specs/polling-operations/spec.md` 追加「优先轮询项与普通轮询的单次执行互斥」与三条优先队列路由);`tasks.md` 的 9.19.7 按用户口径统一勾选,第 4 节未验证清单不因勾选而改变。

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

@@ -24,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 返回已原子保存的业务申请和初始审批状态。
@@ -78,8 +83,20 @@ func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID
SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID,
RechargeNo: record.RechargeNo, Amount: record.Amount,
PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark,
OtherVoucherKeys: []string(record.OtherVoucherKeys),
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if record.ExternalTransactionNo != nil {
command.ExternalTransactionNo = *record.ExternalTransactionNo
}
// 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。
var frozenCode, frozenName string
if record.OfflinePaymentMethodCode != nil {
frozenCode = *record.OfflinePaymentMethodCode
}
if record.OfflinePaymentMethodName != nil {
frozenName = *record.OfflinePaymentMethodName
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName)
if err != nil {
return nil, err
}
@@ -154,20 +171,31 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
paymentChannel := constants.RechargeMethodOffline
record := &model.AgentRechargeRecord{
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, "创建员工线下代充值申请失败")
}
@@ -218,17 +246,71 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
if command.OfflinePaymentMethodID == 0 {
return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
}
for _, key := range command.PaymentVoucherKeys {
if strings.TrimSpace(key) == "" {
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
}
if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil {
return err
}
if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil {
return err
}
return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个")
}
// validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。
func validateRechargeTransactionNo(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号")
}
if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength {
return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制")
}
return nil
}
// validateVoucherKeys 校验凭证对象键数量与内容minCount 为 0 时允许为空。
func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error {
if len(keys) < minCount || len(keys) > maxCount {
return errors.New(errors.CodeInvalidParam, message)
}
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空")
}
if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制")
}
if _, exists := seen[trimmed]; exists {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复")
}
seen[trimmed] = struct{}{}
}
return nil
}
// loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。
// 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。
func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
}
var paymentMethod model.EmployeeCollectionPaymentMethod
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &paymentMethod, nil
}
func (s *OfflineCreationService) loadHistoricalFacts(
ctx context.Context, record *model.AgentRechargeRecord,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
@@ -291,7 +373,7 @@ func (s *OfflineCreationService) loadCreationFacts(
return &account, &shop, &wallet, nil
}
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": command.SubmitterAccountID, "account_name": submitterName,
"user_type": command.SubmitterUserType,
@@ -300,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,21 +47,28 @@ type AvailablePaymentMethodsResult struct {
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou 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, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
// SetPaymentMethodPolicy 注入代理在线自充允许范围策略
func (s *OnlineCreationService) SetPaymentMethodPolicy(policy *OnlinePaymentMethodPolicy) {
s.policy = policy
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入运行时路由和三个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil || s.policy == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
@@ -79,14 +87,19 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
}
// 幂等回放先于允许范围门禁:同一 request_id 的重试属于既有单,不是新单,
// 不因允许范围变更被拒绝;允许范围只拦截会真正新建充值单与支付单的路径。
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil {
return nil, err
}
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
result, config, adapter, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet)
if err != nil {
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
return replay, replayErr
@@ -125,26 +138,56 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
return result, nil
}
// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。
// AvailablePaymentMethods 按允许范围与可用商户池交集返回在线支付方式。
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
result := AvailablePaymentMethodsResult{
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
}
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "代理账号可以查询在线支付方式")
if s == nil || s.db == nil || s.runtime == nil {
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
return result, apperrors.New(apperrors.CodeForbidden, "仅代理或平台账号可以查询在线支付方式")
}
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) || s.fuiou.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
}
return result, nil
}
@@ -152,40 +195,29 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
return nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
adapter := s.adapter(command.PaymentMethod, &config)
if adapter == nil || !adapter.Available(&config) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return &account, &shop, &wallet, &config, adapter, nil
return &account, &shop, &wallet, nil
}
func (s *OnlineCreationService) createLocalFacts(
@@ -195,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 := 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,
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
}
@@ -238,9 +292,13 @@ func (s *OnlineCreationService) createLocalFacts(
})
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return nil, nil, nil, err
}
return 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 {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,226 @@
package assetautorenewal
import (
"context"
"sort"
"time"
"go.uber.org/zap"
"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/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ConfigView 是自动续费配置的读取视图。
type ConfigView struct {
Enabled int `json:"enabled"`
Scope string `json:"scope"`
PackageIDs []uint `json:"package_ids"`
DaysBeforeExpiry int `json:"days_before_expiry"`
ConfigVersion int64 `json:"config_version"`
Updater uint `json:"updater"`
UpdatedAt time.Time `json:"updated_at"`
}
// ConfigRequest 是保存自动续费配置的请求。
type ConfigRequest struct {
Enabled int `json:"enabled"`
Scope string `json:"scope"`
PackageIDs []uint `json:"package_ids"`
DaysBeforeExpiry int `json:"days_before_expiry"`
}
// GetConfig 读取唯一的自动续费配置;仅超级管理员与平台账号可见。
func (s *Service) GetConfig(ctx context.Context) (*ConfigView, error) {
if _, err := requirePlatformOperator(ctx); err != nil {
return nil, err
}
config, err := s.configStore.Get(ctx)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
}
return toConfigView(config), nil
}
// SaveConfig 保存自动续费配置:单行事务锁串行化、事务内自增配置版本,并与审计同事务写入。
//
// 保存只影响后续扫描:已产生的尝试记录保留触发时的配置版本快照,不重算。
func (s *Service) SaveConfig(ctx context.Context, request ConfigRequest) (*ConfigView, error) {
operatorID, err := requirePlatformOperator(ctx)
if err != nil {
return nil, err
}
packageIDs, err := normalizeConfigRequest(&request)
if err != nil {
return nil, err
}
if len(packageIDs) > 0 {
if err := s.validateSellableMainPackages(ctx, packageIDs); err != nil {
return nil, err
}
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "自动续费配置审计接缝未配置")
}
saved := &model.AssetAutoRenewalConfig{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
current, lockErr := s.configStore.LockInTx(ctx, tx)
if lockErr != nil {
if lockErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定自动续费配置失败")
}
before := configSnapshot(current)
saved.Enabled = request.Enabled
saved.Scope = request.Scope
saved.PackageIDs = model.UintJSONBArray(packageIDs)
saved.DaysBeforeExpiry = request.DaysBeforeExpiry
saved.ConfigVersion = current.ConfigVersion + 1
saved.Creator = current.Creator
saved.Updater = operatorID
if saveErr := s.configStore.SaveInTx(ctx, tx, saved, operatorID); saveErr != nil {
return errors.Wrap(errors.CodeDatabaseError, saveErr, "保存自动续费配置失败")
}
if auditErr := s.auditWriter.WriteAssetAutoRenewalConfigChange(ctx, tx, audit.AssetAutoRenewalConfigAudit{
OperatorID: operatorID,
OperationType: constants.AuditOperationAssetAutoRenewalConfigUpdate,
Description: "保存资产钱包自动续费配置",
BeforeData: before,
AfterData: configSnapshot(saved),
RequestID: derefString(middleware.GetRequestIDFromContext(ctx)),
CorrelationID: derefString(middleware.GetRequestIDFromContext(ctx)),
}); auditErr != nil {
return auditErr
}
return nil
})
if err != nil {
return nil, err
}
view := &ConfigView{
Enabled: saved.Enabled, Scope: saved.Scope, PackageIDs: packageIDs,
DaysBeforeExpiry: saved.DaysBeforeExpiry, ConfigVersion: saved.ConfigVersion,
Updater: saved.Updater, UpdatedAt: s.now(),
}
s.logger.Info("资产钱包自动续费配置已保存",
zap.Int("enabled", view.Enabled), zap.String("scope", view.Scope),
zap.Int("days_before_expiry", view.DaysBeforeExpiry), zap.Int64("config_version", view.ConfigVersion))
return view, nil
}
// derefString 安全解引用可空字符串,供审计上下文可选字段复用。
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
// requirePlatformOperator 复核调用者仅限超级管理员与平台账号,并返回其账号 ID。
//
// 路由组已做粗粒度门禁这里在业务边界再复核一次账号类型ENG-AUTHZ-001
// 代理、企业与个人客户一律按「无权限或不存在」统一拒绝,不形成可枚举差异。
func requirePlatformOperator(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.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return operatorID, nil
}
// normalizeConfigRequest 归一化并校验保存请求,返回去重升序的指定套餐集合。
func normalizeConfigRequest(request *ConfigRequest) ([]uint, error) {
if request.Enabled != constants.AssetAutoRenewalConfigEnabledOff &&
request.Enabled != constants.AssetAutoRenewalConfigEnabledOn {
return nil, errors.New(errors.CodeInvalidParam, "自动续费总开关取值非法")
}
if request.Scope != constants.AssetAutoRenewalScopeAll && request.Scope != constants.AssetAutoRenewalScopeSpecified {
return nil, errors.New(errors.CodeInvalidParam, "自动续费适用范围取值非法")
}
if request.DaysBeforeExpiry < constants.AssetAutoRenewalMinDaysBeforeExpiry ||
request.DaysBeforeExpiry > constants.AssetAutoRenewalMaxDaysBeforeExpiry {
return nil, errors.New(errors.CodeInvalidParam, "自动续费到期前天数必须在 1 至 90 之间")
}
seen := make(map[uint]struct{}, len(request.PackageIDs))
packageIDs := make([]uint, 0, len(request.PackageIDs))
for _, packageID := range request.PackageIDs {
if packageID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "自动续费指定套餐包含无效 ID")
}
if _, exists := seen[packageID]; exists {
continue
}
seen[packageID] = struct{}{}
packageIDs = append(packageIDs, packageID)
}
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
if request.Scope == constants.AssetAutoRenewalScopeSpecified && len(packageIDs) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "指定范围必须至少选择一个主套餐")
}
if request.Scope == constants.AssetAutoRenewalScopeAll {
packageIDs = nil
}
return packageIDs, nil
}
// validateSellableMainPackages 校验指定集合只能选择当前可售主套餐。
//
// 可售口径与购买校验的平台分支一致:套餐为正式套餐、全局启用且上架。
// 运行时不因后来下架而拒绝(交由续费豁免判定),因此下架只在此处拦截配置保存。
func (s *Service) validateSellableMainPackages(ctx context.Context, packageIDs []uint) error {
packages, err := s.loadPackagesByIDs(ctx, packageIDs)
if err != nil {
return err
}
for _, packageID := range packageIDs {
pkg, exists := packages[packageID]
if !exists {
return errors.New(errors.CodeInvalidParam, "指定套餐不存在")
}
if pkg.PackageType != constants.PackageTypeFormal {
return errors.New(errors.CodeInvalidParam, "指定范围只能选择主套餐")
}
if pkg.Status != constants.StatusEnabled {
return errors.New(errors.CodeInvalidParam, "指定套餐已禁用")
}
if pkg.ShelfStatus != constants.ShelfStatusOn {
return errors.New(errors.CodeInvalidParam, "指定套餐已下架")
}
}
return nil
}
// configSnapshot 生成配置前后值快照,字段口径固定,便于审计比对。
func configSnapshot(config *model.AssetAutoRenewalConfig) map[string]any {
return map[string]any{
"enabled": config.Enabled,
"scope": config.Scope,
"package_ids": []uint(config.PackageIDs),
"days_before_expiry": config.DaysBeforeExpiry,
"config_version": config.ConfigVersion,
}
}
func toConfigView(config *model.AssetAutoRenewalConfig) *ConfigView {
return &ConfigView{
Enabled: config.Enabled,
Scope: config.Scope,
PackageIDs: []uint(config.PackageIDs),
DaysBeforeExpiry: config.DaysBeforeExpiry,
ConfigVersion: config.ConfigVersion,
Updater: config.Updater,
UpdatedAt: config.UpdatedAt,
}
}

View File

@@ -0,0 +1,222 @@
package assetautorenewal
import (
"context"
"strconv"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// assetAutoRenewalResumePayloadVersion 是自动续费复机事件的载荷版本。
const assetAutoRenewalResumePayloadVersion = 1
// resumePayload 是自动续费复机事件的载荷,只携带尝试与资产标识,消费者按尝试 ID 认领执行权。
type resumePayload struct {
AttemptID uint `json:"attempt_id"`
AssetType string `json:"asset_type"`
AssetID uint `json:"asset_id"`
}
// AppendResumeRequested 在续费事务内幂等写入复机事件。
//
// 事件 ID 由尝试记录 ID 派生:续费成功与复机状态同事务写入,重复投递不会创建第二个事件
// ENG-OUTBOX-001。调用方必须已确认可复机条件成立本函数不做条件判定。
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, attempt *model.AssetAutoRenewalAttempt) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if attempt == nil || attempt.ID == 0 {
return gorm.ErrInvalidData
}
value := strconv.FormatUint(uint64(attempt.ID), 10)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(constants.OutboxEventTypeAssetAutoRenewalResumeRequested+":", value),
EventType: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
PayloadVersion: assetAutoRenewalResumePayloadVersion,
AggregateType: "asset_auto_renewal_attempt",
AggregateID: value,
ResourceType: attempt.AssetType,
ResourceID: strconv.FormatUint(uint64(attempt.AssetID), 10),
BusinessKey: constants.OutboxEventTypeAssetAutoRenewalResumeRequested + ":" + value,
Payload: resumePayload{
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
},
})
return err
}
// ResumeConsumer 把自动续费复机事件转成一次复机动作。
type ResumeConsumer struct {
service *Service
}
// NewResumeConsumer 创建自动续费复机事件消费者。
func NewResumeConsumer(service *Service) *ResumeConsumer {
return &ResumeConsumer{service: service}
}
// Consume 按尝试记录认领执行权后执行复机;重复投递由认领字段兜住,不会产生第二次外部调用。
func (c *ResumeConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置")
}
if envelope.EventType != constants.OutboxEventTypeAssetAutoRenewalResumeRequested {
return outbox.Permanent(gorm.ErrInvalidData)
}
if envelope.PayloadVersion != assetAutoRenewalResumePayloadVersion {
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷版本不受支持"))
}
var payload resumePayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return outbox.Permanent(errors.Wrap(errors.CodeInvalidParam, err, "自动续费复机事件载荷格式错误"))
}
if payload.AttemptID == 0 {
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷不完整"))
}
// 消费者不经过计划任务入口必须自带操作者与来源否则失败审计会因审计上下文缺失被拒fail-closed
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
ActorName: "资产钱包自动续费复机结果消费者", Source: constants.AuditSourceWorker,
CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID,
})
return c.service.ExecuteResume(ctx, payload.AttemptID)
}
// ExecuteResume 认领并执行一次自动续费复机,回写尝试记录的复机状态、外部交互号与失败原因。
//
// 「回写复机终态 + 投递失败通知 + 失败审计」在同一个短事务内闭合:任一失败整体回滚,
// 记录退回「已投递且已提交」,由恢复扫描按只读查询继续收敛,因此通知不会因一次写入抖动而永久丢失。
// 复机失败或结果未知时绝不回滚续费事实:订单、套餐生效与钱包扣款保持已提交状态。
func (s *Service) ExecuteResume(ctx context.Context, attemptID uint) error {
if s.resume == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
}
attempt, err := s.attemptStore.Load(ctx, attemptID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费尝试记录失败")
}
if attempt.ResumeStatus != constants.AssetAutoRenewalResumeStatusRequested {
// 已收敛或未投递复机:重复投递与非复机尝试都按幂等结束。
return nil
}
claimed, err := s.attemptStore.ClaimResumeSubmission(ctx, attemptID, s.now())
if err != nil {
return err
}
if !claimed {
// 认领已被占用:可能是并发重复投递,也可能是上次「已调用但未回写」的进程中断。
// 两种情况都不得再次调用运营商,留给恢复扫描按只读查询收敛。
s.logger.Info("自动续费复机已被并发执行,跳过重复调用", zap.Uint("attempt_id", attemptID))
return nil
}
outcome, resumeErr := s.resume.ResumeAssetForAutoRenewal(ctx, attempt.AssetType, attempt.AssetID)
status := constants.AssetAutoRenewalResumeStatusUnknown
reason := outcome.SafeReason
switch {
case !outcome.Applied:
// 判定在执行时已不成立:按跳过记录,不通知,也不改写任何续费事实。
status = constants.AssetAutoRenewalResumeStatusSkipped
reason = ""
case outcome.Result == constants.AuditResultSuccess:
status = constants.AssetAutoRenewalResumeStatusSucceeded
reason = ""
case outcome.Result == constants.AuditResultFailed:
status = constants.AssetAutoRenewalResumeStatusFailed
reason = resumeFailureDetail(outcome.SafeReason)
default:
status = constants.AssetAutoRenewalResumeStatusUnknown
reason = resumeFailureDetail(outcome.SafeReason)
}
if err := s.finalizeResumeOutcome(ctx, attempt, status, reason, outcome.IntegrationID); err != nil {
return err
}
if resumeErr != nil {
s.logger.Warn("自动续费复机执行未确认完成",
zap.Uint("attempt_id", attemptID), zap.String("result", outcome.Result), zap.Error(resumeErr))
}
return nil
}
// finalizeResumeOutcome 在同一短事务内回写复机终态;确认失败时同事务投递通知并写失败审计。
func (s *Service) finalizeResumeOutcome(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, status int, reason, integrationID string) error {
expected := []int{constants.AssetAutoRenewalResumeStatusRequested}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated, err := s.attemptStore.MarkResumeOutcomeInTx(ctx, tx, attempt.ID, expected, status, integrationID, reason)
if err != nil {
return err
}
if !updated {
// 已被并发收敛:不重复投递通知与审计。
s.logger.Info("自动续费复机结果已被并发收敛,跳过通知与审计", zap.Uint("attempt_id", attempt.ID))
return nil
}
if status != constants.AssetAutoRenewalResumeStatusFailed {
return nil
}
if err := s.appendFailureNotifications(ctx, tx, failureNotification{
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
TriggerDate: attempt.TriggerDate, Reason: constants.AssetAutoRenewalFailureResumeFailed,
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
}); err != nil {
return err
}
return s.appendResumeFailureAudit(ctx, tx, attempt, reason)
})
}
// appendResumeFailureAudit 在复机失败终态事务内写统一审计,主资源为本次尝试记录。
func (s *Service) appendResumeFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason string) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费复机失败",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
ErrorSummary: reason,
CorrelationID: attemptID,
Metadata: map[string]any{
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"failure_kind": constants.AssetAutoRenewalFailureResumeFailed,
},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
DisplayName: "自动续费尝试 " + attemptID,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"resume_status": constants.AssetAutoRenewalResumeStatusFailed,
"failure_reason": constants.AssetAutoRenewalFailureResumeFailed,
},
BeforeData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusRequested},
AfterData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusFailed},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}
// resumeFailureDetail 组装可安全展示的复机失败原因,不写渠道报文原文。
func resumeFailureDetail(safeReason string) string {
if safeReason == "" {
return "复机结果确认为失败"
}
return safeReason
}

View File

@@ -0,0 +1,136 @@
package assetautorenewal
import (
"context"
"fmt"
"strconv"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// failureNotification 是一条失败通知事件所需冻结的事实。
type failureNotification struct {
AttemptID uint
AssetType string
AssetID uint
Identifier string
ShopID *uint
CustomerID uint
TriggerDate time.Time
Reason string
PackageName string
FinalExpiresAt *time.Time
}
// appendFailureNotifications 在调用方事务内为当前个人客户与资产所属店铺各写一条幂等通知事件。
//
// 每日至多一条由上锁的两个条件推出:同一资产同一自然日至多一次尝试,且幂等键内嵌资产类型与资产 ID、
// 上海自然日、原因类型与接收人。资产所属店铺当时无有效业务员时不阻断:店铺接收人由既有店铺解析
// 在投递期完成,解析为空列表即正常结束,不影响续费事实与尝试记录;资产无店铺归属时只创建客户通知。
// 非通知原因(如占位中断收敛)一律不投递,未登记原因按 fail-closed 处理。
func (s *Service) appendFailureNotifications(ctx context.Context, tx *gorm.DB, request failureNotification) error {
if s.outbox == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费通知 Outbox 未配置")
}
if !constants.IsAssetAutoRenewalNotifiableFailureReason(request.Reason) {
s.logger.Warn("自动续费失败原因不属于通知口径,已跳过通知投递",
zap.Uint("attempt_id", request.AttemptID), zap.String("reason", request.Reason))
return nil
}
templateData := map[string]string{
"asset_identifier": request.Identifier,
"package_name": request.PackageName,
"failure_reason": constants.GetAssetAutoRenewalFailureReasonName(request.Reason),
"expiry_date": formatShanghaiDate(request.FinalExpiresAt, s.now()),
}
assetIDText := strconv.FormatUint(uint64(request.AssetID), 10)
// 资源引用按资产类型选择既有可跳转目标:卡用 iot_card 详情、设备用 device 详情
// (两者都在 internal/query/notification/target.go 的目标定义里idTarget + 可用性复核),
// 使店铺/业务员点开通知能进入对应资产详情,而不是落到无目标类型。
refType := assetRefType(request.AssetType)
expiresAt := request.FinalExpiresAt
if expiresAt == nil {
fallback := s.now().UTC()
expiresAt = &fallback
}
if request.CustomerID > 0 {
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "c", request.CustomerID)
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
Payload: notificationapp.PersonalCustomerDirectPayload{
RecipientID: request.CustomerID, NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
TemplateData: templateData, RefType: refType, RefID: assetIDText,
ExpiresAt: expiresAt,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费客户通知事件失败")
}
}
if request.ShopID == nil || *request.ShopID == 0 {
return nil
}
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "shop", *request.ShopID)
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindShop, TargetID: *request.ShopID,
NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
TemplateData: templateData, RefType: refType, RefID: assetIDText,
ExpiresAt: expiresAt,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费店铺通知事件失败")
}
return nil
}
// assetRefType 把资产类型映射为可跳转的通知引用类型(卡片详情 / 设备详情)。
func assetRefType(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return constants.NotificationRefTypeDevice
}
return constants.NotificationRefTypeIotCard
}
// failureEventID 构造失败通知的稳定幂等键。
//
// 键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人复机失败沿用该次尝试的日期键
// 因此同一尝试只通知一次且不跨日新增。超长时由 outboxid.Stable 追加稳定摘要,仍保持唯一。
func failureEventID(assetType string, assetID uint, triggerDate time.Time, reason, recipientKind string, recipientID uint) string {
dateKey := triggerDate.In(shanghaiLocation).Format("20060102")
return outboxid.Stable("aar:", fmt.Sprintf("%s:%d:%s:%s:%s:%d",
assetCode(assetType), assetID, dateKey, reason, recipientKind, recipientID))
}
// assetCode 把资产类型压缩为单字母代码,只为把幂等键长度压进 Outbox 预算。
func assetCode(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return "d"
}
return "c"
}
// formatShanghaiDate 把业务到期时间格式化为上海自然日文本,供通知模板与展示期使用。
func formatShanghaiDate(value *time.Time, fallback time.Time) string {
target := fallback
if value != nil {
target = *value
}
return target.In(shanghaiLocation).Format("2006-01-02")
}

View File

@@ -0,0 +1,108 @@
package assetautorenewal
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecoveryResult 是一次复机结果恢复扫描的可观察结果。
//
// Scanned 为扫到的未收敛尝试数Confirmed 为本次回填为已确认结果的尝试数;
// Pending 为结果仍未确认、等待下次扫描的尝试数Anomaly 为超过查询窗口仍不可确认、
// 本次标记转人工的尝试数。
type RecoveryResult struct {
Scanned int
Confirmed int
Pending int
Anomaly int
}
// RecoverResumeResults 扫描未收敛的复机子结果:只查询运营商状态回填,绝不重复发起复机调用。
//
// 收敛口径与既有停复机恢复一致internal/application/carrierthreshold/cycle.go:246-296
// - 查询确认已复机 → 回填成功;
// - 「已知但未复机」或不可判定 → 仍算未确认,等到下一次扫描;
// - 自提交起超过查询窗口仍不可确认 → 标记异常并退出自动扫描转人工核对。
//
// 恢复扫描**不**据此判定「复机失败」:续购后新主套餐多为待生效,卡在此期间本就可能仍处于停机,
// 把「未复机」当失败会发出误报通知。复机失败只由消费者在网关明确返回失败时确认(「仅确认失败才通知」)。
// 单条失败不中断整批,但会作为首个错误返回,交既有任务重试。
func (s *Service) RecoverResumeResults(ctx context.Context) (RecoveryResult, error) {
result := RecoveryResult{}
if s.resume == nil {
return result, errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
}
now := s.now()
attempts, err := s.attemptStore.ScanUnresolvedResumes(ctx, now, constants.AssetAutoRenewalRecoveryBatchSize)
if err != nil {
return result, err
}
result.Scanned = len(attempts)
var firstErr error
for index := range attempts {
if err := s.recoverResumeResult(ctx, &attempts[index], now, &result); err != nil {
s.logger.Warn("自动续费复机结果恢复单条失败",
zap.Uint("attempt_id", attempts[index].ID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
s.logger.Info("自动续费复机结果恢复扫描完成",
zap.Int("scanned", result.Scanned), zap.Int("confirmed", result.Confirmed),
zap.Int("pending", result.Pending), zap.Int("anomaly", result.Anomaly))
return result, firstErr
}
// recoverResumeResult 处理单条未收敛的复机子结果。
func (s *Service) recoverResumeResult(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, now time.Time, result *RecoveryResult) error {
online, known, integrationID, err := s.resume.QueryAutoRenewalResumeState(ctx, attempt.AssetType, attempt.AssetID)
if err != nil || !known || !online {
// 查询失败、状态不可判定、或已知仍未复机:一律按「仍未确认」处理,
// 绝不误判为失败终态,也绝不据此发出失败通知。
result.Pending++
if !expiredResumeQueryWindow(attempt.ResumeSubmittedAt, now) {
return nil
}
marked, markErr := s.attemptStore.MarkResumeAnomaly(ctx, attempt.ID,
"复机结果超过确认窗口仍不可查,请人工核对")
if markErr != nil {
return markErr
}
if marked {
result.Anomaly++
s.logger.Warn("自动续费复机结果超期不可确认,已标记异常转人工",
zap.Uint("attempt_id", attempt.ID), zap.String("asset_type", attempt.AssetType),
zap.Uint("asset_id", attempt.AssetID))
}
return nil
}
expected := []int{
constants.AssetAutoRenewalResumeStatusRequested,
constants.AssetAutoRenewalResumeStatusUnknown,
}
marked, markErr := s.attemptStore.MarkResumeOutcome(ctx, attempt.ID, expected,
constants.AssetAutoRenewalResumeStatusSucceeded, integrationID, "")
if markErr != nil {
return markErr
}
if marked {
result.Confirmed++
}
return nil
}
// expiredResumeQueryWindow 判断复机子任务自提交起是否已超过自动查询窗口。
// 未提交(提交认领时刻为空)表示尚未发起复机,不算超期。
func expiredResumeQueryWindow(submittedAt *time.Time, now time.Time) bool {
if submittedAt == nil {
return false
}
return now.Sub(*submittedAt) >= constants.AssetAutoRenewalResumeQueryWindow
}

View File

@@ -0,0 +1,809 @@
package assetautorenewal
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/model"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// renewalHalt 是执行事务内重读资格事实后必须终止本次尝试的可安全记录原因。
//
// 它作为事务闭包的返回错误使已取得行锁的续费事务整体回滚(此时尚未写入任何资金与订单事实),
// 再由调用方在独立短事务中落终态、投递通知与写审计。
// FailureReason 与 SkipReason 恰有一个非空:非空失败原因触发通知,跳过原因不触发通知。
type renewalHalt struct {
FailureReason string
SkipReason string
Detail string
}
// Error 实现 error使事务闭包能把终止信号回传给调用方。
func (h *renewalHalt) Error() string {
if h.SkipReason != "" {
return "自动续费跳过:" + constants.GetAssetAutoRenewalSkipReasonName(h.SkipReason)
}
return "自动续费未执行:" + constants.GetAssetAutoRenewalFailureReasonName(h.FailureReason)
}
// renewalFacts 是一次续费执行成功后用于运行日志的关键事实。
type renewalFacts struct {
RenewPrice int64
OrderID uint
OrderNo string
}
// executeRenewal 在单个事务内闭合一次续购:先锁资产钱包行、后锁资产载体行,锁后重读全部资格事实,
// 再扣可用余额、建订单与明细、写已支付支付记录、写钱包流水、激活套餐、写佣金与观测 Outbox、
// 更新尝试记录为成功并写成功审计。任一步失败整体回滚,不存在部分成功状态。
//
// windowDays 是本次扫描使用的配置窗口,必须传入实际配置值:窗口是触发条件而不是资格不变式,
// 用常量上限会让「人工已把最终到期推远」被误判为失败。
//
// 返回 halt 表示重读后应落失败或跳过终态(事务已回滚且未写入任何事实);返回 err 表示事务失败。
func (s *Service) executeRenewal(ctx context.Context, candidate assetquery.Candidate, attempt *model.AssetAutoRenewalAttempt, windowDays int) (*renewalFacts, *renewalHalt, error) {
if s.outbox == nil {
return nil, nil, errors.New(errors.CodeInvalidStatus, "自动续费 Outbox 未配置")
}
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, candidate.AssetType, candidate.AssetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "资产钱包不存在,无法以可用余额续购",
}, nil
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "读取资产钱包失败")
}
facts := &renewalFacts{}
var halt *renewalHalt
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 锁序固定为「先资产钱包行、后资产载体行」,与人工路径的「先冻结钱包、后激活套餐」一致,
// 避免与人工事务的锁序反转形成死锁。
lockedWallet, lockErr := s.assetWalletStore.LockByIDWithTx(ctx, tx, wallet.ID)
if lockErr != nil {
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定资产钱包失败")
}
if carrierErr := s.lockCarrier(ctx, tx, candidate.AssetType, candidate.AssetID); carrierErr != nil {
return carrierErr
}
// 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额都以重读结果为准。
execution, execErr := s.rereadUnderLock(ctx, tx, candidate, lockedWallet, windowDays)
if execErr != nil {
var halted *renewalHalt
if asRenewalHalt(execErr, &halted) {
halt = halted
return execErr
}
return execErr
}
if err := s.writeRenewalFacts(ctx, tx, execution, attempt, facts); err != nil {
return err
}
return nil
})
if err != nil {
if halt != nil {
return nil, halt, nil
}
return nil, nil, err
}
return facts, nil, nil
}
// executionPlan 是锁后重读得到的执行输入。
type executionPlan struct {
candidate assetquery.Candidate
asset *assetSnapshot
wallet *model.AssetWallet
pkg *model.Package
sellerShop *uint
price int64
costPrice int64
}
// assetSnapshot 是执行事务内锁定的资产事实。
type assetSnapshot struct {
assetType string
assetID uint
identifier string
shopID *uint
seriesID *uint
generation int
}
// rereadUnderLock 在行锁内重读全部资格事实,并给出可执行或必须终止的判断。
//
// 判定顺序体现「资格不变式先于触发条件」:
// 1. 先判资格不变式——已存在待生效主套餐即「人工已完成续购 / 不叠加周期」,无论最终到期被推到多远
// 都 MUST 跳过(规格 Requirement 8绝不退化为「不可续费」失败与错误通知
// 2. 再判窗口与推算(触发条件)——不在窗口或推算不再明确,才是「当前条件不允许自动续购」。
//
// 之后依次判在途人工订单、钱包状态、可售续费价与可用余额。
func (s *Service) rereadUnderLock(ctx context.Context, tx *gorm.DB, candidate assetquery.Candidate, wallet *model.AssetWallet, windowDays int) (*executionPlan, error) {
asset, err := s.lockAndSnapshotAsset(ctx, tx, candidate.AssetType, candidate.AssetID)
if err != nil {
return nil, err
}
inTxQuery := s.candidates.WithDB(tx)
// 第 1 步:资格不变式(先于窗口判定)。
state, err := inTxQuery.MainUsageStateOf(ctx, candidate.AssetType, candidate.AssetID)
if err != nil {
return nil, err
}
if state.HasPendingMainPackage {
return nil, &renewalHalt{
SkipReason: constants.AssetAutoRenewalSkipManualRenewed,
Detail: "锁后重读发现该资产已存在待生效主套餐",
}
}
if state.CurrentPackageID == 0 {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "锁后重读未找到当前主套餐商品",
}
}
// 第 2 步:触发条件(窗口与推算口径),窗口取本次扫描的配置值。
current, err := inTxQuery.Candidate(ctx, candidate.AssetType, candidate.AssetID, windowDays)
if err != nil {
return nil, err
}
if current == nil {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "锁后重读最终到期已不在触发窗口或推算结果不再明确",
}
}
inFlight, err := inTxQuery.OpenManualMainPackageOrder(ctx, wallet.ID, candidate.AssetType, candidate.AssetID)
if err != nil {
return nil, err
}
if inFlight {
return nil, &renewalHalt{
SkipReason: constants.AssetAutoRenewalSkipManualOrderPending,
Detail: "锁后重读发现该资产存在未关闭的个人资产钱包主套餐订单",
}
}
if wallet.Status != constants.AssetWalletStatusNormal {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "资产钱包当前不可用于扣款",
}
}
price, pkg, sellerShop, costPrice, err := s.resolveExecutablePrice(ctx, candidate.AssetType, candidate.AssetID, current.CurrentPackageID)
if err != nil {
var halted *renewalHalt
if asRenewalHalt(err, &halted) {
return nil, halted
}
return nil, err
}
if wallet.GetAvailableBalance() < price {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureInsufficientBalance,
Detail: "资产钱包可用余额小于执行时当前可售续费价",
}
}
return &executionPlan{
candidate: *current, asset: asset, wallet: wallet, pkg: pkg,
sellerShop: sellerShop, price: price, costPrice: costPrice,
}, nil
}
// resolveExecutablePrice 复用应用层购买校验与价格策略取得可续费判定与执行时续费价。
//
// 校验入口是个人卡/设备购买校验(含续费豁免下架与生效零售价、成本价比较),
// 绝不依赖 handler 层续费价实现;任何校验失败都归一为「不可续费」并保留可安全记录的说明。
func (s *Service) resolveExecutablePrice(ctx context.Context, assetType string, assetID, renewPackageID uint) (int64, *model.Package, *uint, int64, error) {
if s.purchaseValidation == nil {
return 0, nil, nil, 0, errors.New(errors.CodeServiceUnavailable, "购买校验能力未配置")
}
packageIDs := []uint{renewPackageID}
var result *purchase_validation.PurchaseValidationResult
var err error
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
result, err = s.purchaseValidation.ValidatePersonalCardPurchase(ctx, assetID, packageIDs)
case constants.AssetWalletResourceTypeDevice:
result, err = s.purchaseValidation.ValidatePersonalDevicePurchase(ctx, assetID, packageIDs)
default:
return 0, nil, nil, 0, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
if err != nil {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:" + purchaseValidationReason(err),
}
}
if len(result.Packages) == 0 {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:未解析到可售续费套餐",
}
}
var sellerShop uint
if result.Card != nil && result.Card.ShopID != nil {
sellerShop = *result.Card.ShopID
}
if result.Device != nil && result.Device.ShopID != nil {
sellerShop = *result.Device.ShopID
}
costPrice := int64(0)
if sellerShop > 0 {
resolved, costErr := s.purchaseValidation.GetCostPrice(ctx, result.Packages[0], sellerShop)
if costErr != nil {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:渠道成本价不可读",
}
}
costPrice = resolved
}
if result.TotalPrice <= 0 {
return 0, nil, nil, 0, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "当前条件不允许自动续购:生效续费价异常",
}
}
var sellerShopPtr *uint
if sellerShop > 0 {
sellerShopPtr = &sellerShop
}
return result.TotalPrice, result.Packages[0], sellerShopPtr, costPrice, nil
}
// writeRenewalFacts 在同一事务内闭合扣款、订单、支付、钱包流水、套餐生效、可靠事件、尝试记录与审计。
func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *executionPlan, attempt *model.AssetAutoRenewalAttempt, facts *renewalFacts) error {
now := s.now()
wallet := plan.wallet
if err := s.assetWalletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, plan.price, wallet.Version); err != nil {
return errors.Wrap(errors.CodeConflict, err, "资产钱包扣款失败")
}
order, item, err := s.buildRenewalOrder(ctx, tx, plan, now)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单失败")
}
item.OrderID = order.ID
if err := tx.WithContext(ctx).Create(item).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单明细失败")
}
payment := &model.Payment{
PaymentNo: order.OrderNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWallet,
Amount: plan.price,
Status: model.PaymentRecordStatusPaid,
}
if err := tx.WithContext(ctx).Create(payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费支付记录失败")
}
referenceType := constants.ReferenceTypeOrder
walletTransaction := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
UserID: plan.candidate.CustomerID,
TransactionType: constants.AssetTransactionTypeDeduct,
Amount: -plan.price,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - plan.price,
Status: constants.TransactionStatusSuccess,
ReferenceType: &referenceType,
ReferenceNo: &order.OrderNo,
Creator: plan.candidate.CustomerID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.walletTransactionStore.CreateWithTx(ctx, tx, walletTransaction); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费钱包流水失败")
}
usage, err := s.activateMainPackage(ctx, tx, order, plan, now)
if err != nil {
return err
}
if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil {
return err
}
if s.observationEvents == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费观测 Outbox 未配置")
}
observationID := "asset-auto-renewal:" + strconv.FormatUint(uint64(attempt.ID), 10)
if err := s.observationEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: outboxEventID(observationID), Scene: constants.CardObservationScenePackageChanged,
ResourceType: observationResourceType(plan.asset.assetType), ResourceID: plan.asset.assetID,
SyncTypes: []string{
constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork,
},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now.UTC(),
RequestID: observationID, CorrelationID: observationID,
}); err != nil {
return err
}
updates := map[string]any{
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
"failure_reason": "",
"failure_detail": "",
"skip_reason": "",
"final_expires_at": plan.candidate.FinalExpiresAt,
"current_usage_id": plan.candidate.CurrentUsageID,
"current_package_id": plan.candidate.CurrentPackageID,
"renew_package_id": plan.pkg.ID,
"renew_price": plan.price,
"wallet_id": wallet.ID,
"wallet_transaction_id": walletTransaction.ID,
"deduct_amount": plan.price,
"balance_before": wallet.Balance,
"balance_after": wallet.Balance - plan.price,
"order_id": order.ID,
"order_no": order.OrderNo,
}
// current_usage_id / current_package_id 保留**触发时**解析到的当前主套餐快照(规格 Requirement 7
// 要求「触发时解析」),不覆盖为本次新生成的套餐使用记录;新记录通过 order_id / order_no 追溯,
// 「续购后处于待生效」也可由 usage_after_success 断言直接观察。
resumeReady, resumeReason, err := s.evaluateResumeGate(ctx, plan)
if err != nil {
return err
}
if resumeReady {
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusRequested
updates["resume_failure_reason"] = ""
} else {
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusSkipped
updates["resume_failure_reason"] = resumeReason
}
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, updates)
if err != nil {
return err
}
if !updated {
return errors.Wrap(errors.CodeConflict, gorm.ErrInvalidData, "续费尝试已非处理中,拒绝重复成功")
}
facts.RenewPrice = plan.price
facts.OrderID = order.ID
facts.OrderNo = order.OrderNo
if resumeReady {
attempt.ResumeStatus = constants.AssetAutoRenewalResumeStatusRequested
if err := AppendResumeRequested(ctx, tx, s.outbox, attempt); err != nil {
return err
}
}
return s.appendRenewalAudit(ctx, tx, plan, attempt, order, payment, wallet, walletTransaction, usage, updates)
}
// evaluateResumeGate 在同一事务内按可复机判定给出复机去向。
//
// 判定只做数据库读取、不持有任何外部 I/O因此可以安全地留在资金事务闭包内ENG-TX-001
// 它也不对资产钱包行或载体行加锁,因此不会与已持有的行锁形成等待。
func (s *Service) evaluateResumeGate(ctx context.Context, plan *executionPlan) (bool, string, error) {
if s.resume == nil {
return false, "", errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
}
ready, reason, err := s.resume.AutoRenewalResumeReady(ctx, plan.asset.assetType, plan.asset.assetID)
if err != nil {
return false, "", err
}
return ready, reason, nil
}
// buildRenewalOrder 组装续购订单与唯一明细:买家恒为当前个人客户,金额为执行时当前可售续费价。
func (s *Service) buildRenewalOrder(ctx context.Context, tx *gorm.DB, plan *executionPlan, now time.Time) (*model.Order, *model.OrderItem, error) {
orderType := model.OrderTypeSingleCard
var iotCardID, deviceID *uint
if plan.asset.assetType == constants.AssetWalletResourceTypeDevice {
orderType = model.OrderTypeDevice
deviceID = &plan.asset.assetID
} else {
iotCardID = &plan.asset.assetID
}
generation := plan.asset.generation
if generation <= 0 {
generation = 1
}
paidAmount := plan.price
operatorAccountID, operatorAccountName := s.personalCustomerOperatorSnapshot(ctx, plan.candidate.CustomerID)
order := &model.Order{
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
OrderNo: s.orderStore.GenerateOrderNo(), OrderType: orderType,
BuyerType: model.BuyerTypePersonal, BuyerID: plan.candidate.CustomerID,
IotCardID: iotCardID, DeviceID: deviceID, AssetIdentifier: plan.asset.identifier,
TotalAmount: plan.price, PaymentMethod: model.PaymentMethodWallet,
PaymentStatus: model.PaymentStatusPaid, PaidAt: &now,
CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0,
Source: constants.OrderSourceClient, Generation: generation, ActualPaidAmount: &paidAmount,
OperatorAccountID: operatorAccountID, OperatorAccountType: model.OperatorAccountTypePersonalCustomer,
OperatorAccountName: operatorAccountName, SellerShopID: plan.sellerShop,
SeriesID: plan.asset.seriesID, SellerCostPrice: plan.costPrice,
}
item := &model.OrderItem{
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, Quantity: 1,
UnitPrice: plan.price, Amount: plan.price,
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
}
return order, item, nil
}
// personalCustomerOperatorSnapshot 读取个人客户昵称作为订单操作者名称快照。
func (s *Service) personalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) {
if customerID == 0 {
return nil, ""
}
customer, err := s.personalCustomerStore.GetByID(ctx, customerID)
if err != nil {
return &customerID, ""
}
return &customerID, customer.Nickname
}
// activateMainPackage 在同一事务内激活续购的主套餐:按既有排队规则决定待生效或立即生效。
//
// 资格前置保证本次执行前不存在待生效主套餐,因此续购最多领先一个周期;
// 只有当当前主套餐在执行前刚好过期时新记录才立即生效,此时按既有规则追加套餐生效优先轮询请求。
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, plan *executionPlan, now time.Time) (*model.PackageUsage, error) {
terms, err := packagepkg.ResolveTermsFromTx(ctx, tx, plan.pkg, order.SellerShopID)
if err != nil {
return nil, err
}
hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), plan.asset.assetType, plan.asset.assetID, now)
if err != nil {
return nil, err
}
var status, priority int
var activatedAt, expiresAt time.Time
var nextResetAt *time.Time
pendingRealnameActivation := false
if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
realnamed, realnameErr := s.isCarrierRealnamed(ctx, tx, plan.asset.assetType, plan.asset.assetID)
if realnameErr != nil {
return nil, realnameErr
}
pendingRealnameActivation = !realnamed
}
if hasCurrentMain {
status = constants.PackageUsageStatusPending
var maxPriority int
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where(carrierColumn(plan.asset.assetType)+" = ?", plan.asset.assetID).
Select("COALESCE(MAX(priority), 0)").Scan(&maxPriority).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐排队优先级失败")
}
priority = maxPriority + 1
} else {
priority = 1
if pendingRealnameActivation {
status = constants.PackageUsageStatusPending
} else {
status = constants.PackageUsageStatusActive
activatedAt = now
expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
nextResetAt = packagepkg.CalculateNextResetTime(plan.pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
}
}
virtualTotalMB, displayGainRatio, enableVirtualData := model.BuildPackageUsageSnapshotValues(plan.pkg)
retailAmount := order.TotalAmount
usage := &model.PackageUsage{
BaseModel: model.BaseModel{Creator: order.Creator, Updater: order.Creator},
OrderID: order.ID, OrderNo: order.OrderNo,
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, UsageType: order.OrderType,
DataLimitMB: plan.pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMB, DisplayGainRatioSnapshot: displayGainRatio,
EnableVirtualDataSnapshot: enableVirtualData, Status: status, Priority: priority,
DataResetCycle: plan.pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation,
Generation: order.Generation, PaidAmount: &order.SellerCostPrice, RetailAmount: &retailAmount,
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
}
terms.Apply(usage)
if plan.asset.assetType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = plan.asset.assetID
} else {
usage.DeviceID = plan.asset.assetID
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
}
if err := tx.WithContext(ctx).Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写入续费套餐使用记录失败")
}
if err := tx.WithContext(ctx).Model(usage).Updates(map[string]any{
"status": usage.Status, "pending_realname_activation": usage.PendingRealnameActivation,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写回续费套餐使用记录状态失败")
}
if status != constants.PackageUsageStatusActive {
return usage, nil
}
triggerType, err := packagepkg.ResolveActivationTriggerType(ctx, tx, plan.asset.assetType, plan.asset.assetID, usage.ID)
if err != nil {
return nil, err
}
if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage,
plan.asset.assetType, plan.asset.assetID, triggerType, activatedAt); err != nil {
return nil, err
}
return usage, nil
}
// isCarrierRealnamed 判断载体是否已满足实名激活条件,口径与既有自动购包一致。
func (s *Service) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (bool, error) {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).Select("real_name_status").First(&card, assetID).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "读取卡实名状态失败")
}
return card.RealNameStatus == constants.RealNameStatusVerified, nil
case constants.AssetWalletResourceTypeDevice:
var count int64
subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("iot_card_id").Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound)
if err := tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified).
Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "统计设备实名卡失败")
}
return count > 0, nil
default:
return false, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// lockCarrier 在事务内按资产类型对载体行加行锁,作为与人工路径共享的序列化点。
func (s *Service) lockCarrier(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, assetID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
}
return nil
case constants.AssetWalletResourceTypeDevice:
var device model.Device
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, assetID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
}
return nil
default:
return errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// lockAndSnapshotAsset 在已有行锁的事务内读取资产快照。
func (s *Service) lockAndSnapshotAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*assetSnapshot, error) {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费卡事实失败")
}
if !card.IsStandalone {
return nil, &renewalHalt{
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
Detail: "该卡已绑定设备,独立卡维度不执行自动续费",
}
}
return &assetSnapshot{
assetType: constants.AssetWalletResourceTypeIotCard, assetID: card.ID,
identifier: card.ICCID, shopID: card.ShopID, seriesID: card.SeriesID, generation: card.Generation,
}, nil
case constants.AssetWalletResourceTypeDevice:
var device model.Device
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费设备事实失败")
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
return &assetSnapshot{
assetType: constants.AssetWalletResourceTypeDevice, assetID: device.ID,
identifier: identifier, shopID: device.ShopID, seriesID: device.SeriesID, generation: device.Generation,
}, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// carrierColumn 返回套餐使用记录上的资产外键列名。
func carrierColumn(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return "device_id"
}
return "iot_card_id"
}
// observationResourceType 把资产类型映射为观测序列的资源类型。
func observationResourceType(assetType string) string {
if assetType == constants.AssetWalletResourceTypeDevice {
return constants.CardObservationResourceTypeDevice
}
return constants.CardObservationResourceTypeCard
}
// asRenewalHalt 从错误中取出终止信号,非终止信号返回 false。
func asRenewalHalt(err error, target **renewalHalt) bool {
halt, ok := err.(*renewalHalt)
if !ok {
return false
}
*target = halt
return true
}
// appendRenewalAudit 在续费事务内写成功审计,资源覆盖尝试记录、订单、钱包、流水与套餐使用记录。
func (s *Service) appendRenewalAudit(
ctx context.Context,
tx *gorm.DB,
plan *executionPlan,
attempt *model.AssetAutoRenewalAttempt,
order *model.Order,
payment *model.Payment,
wallet *model.AssetWallet,
walletTransaction *model.AssetWalletTransaction,
usage *model.PackageUsage,
updates map[string]any,
) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
DisplayName: "自动续费尝试 " + attemptID,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
"renew_package_id": plan.pkg.ID, "renew_price": plan.price,
"wallet_id": wallet.ID, "wallet_transaction_id": walletTransaction.ID,
"deduct_amount": plan.price, "balance_before": wallet.Balance,
"balance_after": wallet.Balance - plan.price,
"order_id": order.ID, "order_no": order.OrderNo,
"resume_status": updates["resume_status"],
},
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusSucceeded, "failure_reason": ""},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}}
orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleAssetAutoRenewalOrder)
resources = append(resources, orderResource)
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAutoRenewalWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID,
},
BeforeData: map[string]any{"balance": walletTransaction.BalanceBefore},
AfterData: map[string]any{"balance": walletTransaction.BalanceAfter},
})
walletTxID := strconv.FormatUint(uint64(walletTransaction.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID,
DisplayName: "资产钱包流水 " + walletTxID,
Relation: constants.AuditResourceRelationAffected,
Role: constants.AuditResourceRoleAssetAutoRenewalWalletTransaction,
IdentitySnapshot: map[string]any{
"id": walletTransaction.ID, "asset_wallet_id": walletTransaction.AssetWalletID,
"resource_type": walletTransaction.ResourceType, "resource_id": walletTransaction.ResourceID,
"transaction_type": walletTransaction.TransactionType,
"reference_no": walletTransaction.ReferenceNo, "status": walletTransaction.Status,
},
AfterData: map[string]any{
"amount": walletTransaction.Amount, "balance_before": walletTransaction.BalanceBefore,
"balance_after": walletTransaction.BalanceAfter,
},
})
resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil))
if usage != nil {
resources = append(resources, audit.PackageUsageResource(usage,
constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil))
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetAutoRenewalRenewed, Summary: "资产钱包自动续费完成",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: order.OrderNo,
Metadata: map[string]any{
"asset_type": plan.asset.assetType, "asset_id": plan.asset.assetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
},
Resources: resources,
})
}
// appendFailureAudit 在独立短事务内写失败审计跳过终态不写审计由尝试记录本身承载Domain Ledger
func (s *Service) appendFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费失败",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
ErrorCode: strconv.Itoa(reasonCode(reason)), ErrorSummary: detail,
CorrelationID: attemptID,
Metadata: map[string]any{
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "failure_reason": reason,
},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
DisplayName: "自动续费尝试 " + attemptID,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"status": constants.AssetAutoRenewalAttemptStatusFailed,
"failure_reason": reason,
},
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}
// reasonCode 把失败归类映射为审计错误码位,便于按原因检索失败事件。
func reasonCode(reason string) int {
switch reason {
case constants.AssetAutoRenewalFailureInsufficientBalance:
return 1
case constants.AssetAutoRenewalFailureNotRenewable:
return 2
case constants.AssetAutoRenewalFailureOrderFailed:
return 3
default:
return 0
}
}
// purchaseValidationReason 从购买校验错误中提取可安全记录的说明,不写底层错误细节。
func purchaseValidationReason(err error) string {
if err == nil {
return ""
}
message := err.Error()
switch {
case strings.Contains(message, "套餐已禁用"):
return "套餐商品被禁用"
case strings.Contains(message, "套餐已下架"):
return "当前渠道下架且不满足续费豁免"
case strings.Contains(message, "价格配置异常"):
return "生效零售价低于成本价"
case strings.Contains(message, "可购买范围"), strings.Contains(message, "未关联套餐系列"),
strings.Contains(message, "绑定设备"):
return "不在可购买范围或资产未关联套餐系列"
case strings.Contains(message, "赠送套餐"):
return "赠送套餐不参与自动续购"
default:
return "当前条件不允许自动续购"
}
}
// outboxEventID 把观测事件标识裁剪进 Outbox 的事件 ID 长度预算。
func outboxEventID(value string) string {
return outboxid.Stable("card-observation:", value)
}

View File

@@ -0,0 +1,245 @@
package assetautorenewal
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ScanResult 汇总一次每日扫描的可观察结果。
type ScanResult struct {
Converged int64
Candidates int
Attempted int
Succeeded int
Failed int
Skipped int
Duplicated int
}
// RunDailyScan 执行一次每日自动续费扫描:先收敛历史非终态尝试,再按资产扫描候选并逐项执行。
//
// 只有扫描级失败(配置读取、候选读取、数据库不可用)返回错误交既有任务重试;单个资产执行失败
// 在用例内捕获并落终态后继续处理其余资产。总开关关闭时不创建任何新尝试与订单,既有记录保留。
func (s *Service) RunDailyScan(ctx context.Context) (ScanResult, error) {
result := ScanResult{}
if s.db == nil {
return result, errors.New(errors.CodeServiceUnavailable, "自动续费用例未配置")
}
converged, err := s.attemptStore.ConvergeUnfinished(ctx, s.today())
if err != nil {
return result, err
}
result.Converged = converged
if converged > 0 {
s.logger.Info("自动续费历史非终态尝试已收敛", zap.Int64("converged", converged))
}
config, err := s.configStore.Get(ctx)
if err != nil {
if err == gorm.ErrRecordNotFound {
return result, errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return result, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
}
if config.Enabled != 1 {
s.logger.Info("自动续费总开关关闭,本次扫描不创建尝试与订单")
return result, nil
}
if config.Scope != constants.AssetAutoRenewalScopeAll && config.Scope != constants.AssetAutoRenewalScopeSpecified {
return result, errors.New(errors.CodeInvalidStatus, "自动续费配置范围取值非法")
}
candidates, err := s.candidates.Candidates(ctx, config.DaysBeforeExpiry)
if err != nil {
return result, err
}
result.Candidates = len(candidates)
scope := newScopeMatcher(config)
for _, candidate := range candidates {
if !scope.matches(candidate.CurrentPackageID) {
continue
}
outcome, processErr := s.processCandidate(ctx, candidate, config)
if processErr != nil {
// 单资产失败已落终态,继续处理其余资产;扫描任务本身不因该资产失败而失败。
s.logger.Error("自动续费单资产执行失败,继续处理其余资产",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
zap.Error(processErr))
result.Failed++
continue
}
switch outcome {
case candidateSucceeded:
result.Attempted++
result.Succeeded++
case candidateFailed:
result.Attempted++
result.Failed++
case candidateSkipped:
result.Attempted++
result.Skipped++
default:
result.Duplicated++
}
}
s.logger.Info("自动续费每日扫描完成",
zap.Int64("converged", result.Converged), zap.Int("candidates", result.Candidates),
zap.Int("succeeded", result.Succeeded), zap.Int("failed", result.Failed),
zap.Int("skipped", result.Skipped), zap.Int("duplicated", result.Duplicated))
return result, nil
}
// scanOutcome 是一次候选处理的终态归属,用于汇总扫描结果。
type scanOutcome int
const (
candidateDuplicated scanOutcome = iota
candidateSucceeded
candidateFailed
candidateSkipped
)
// scopeMatcher 表达配置的适用范围:全部主套餐,或指定主套餐集合。
//
// 运行时只按当前主套餐商品是否在集合内判定,不因后来下架而拒绝——下架交给续费豁免判定。
type scopeMatcher struct {
all bool
packageIDs map[uint]struct{}
}
func newScopeMatcher(config *model.AssetAutoRenewalConfig) scopeMatcher {
if config.Scope == constants.AssetAutoRenewalScopeAll {
return scopeMatcher{all: true}
}
ids := make(map[uint]struct{}, len(config.PackageIDs))
for _, packageID := range config.PackageIDs {
ids[packageID] = struct{}{}
}
return scopeMatcher{packageIDs: ids}
}
func (m scopeMatcher) matches(packageID uint) bool {
if m.all {
return true
}
_, exists := m.packageIDs[packageID]
return exists
}
// processCandidate 处理单个候选:占位写入、执行、落终态与通知。
//
// 占位冲突表示该资产当日已尝试,直接跳过且不重复扣款;执行阶段的失败与跳过各以独立短事务落终态。
func (s *Service) processCandidate(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig) (scanOutcome, error) {
triggerDate := s.today()
attempt, err := s.buildAttempt(ctx, candidate, config, triggerDate)
if err != nil {
return candidateFailed, err
}
created, err := s.attemptStore.CreatePlaceholder(ctx, attempt)
if err != nil {
return candidateFailed, err
}
if !created {
s.logger.Info("该资产当日已存在自动续费尝试,跳过",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID))
return candidateDuplicated, nil
}
facts, halt, err := s.executeRenewal(ctx, candidate, attempt, config.DaysBeforeExpiry)
if halt != nil {
if halt.SkipReason != "" {
return candidateSkipped, s.finalizeSkip(ctx, attempt, halt)
}
return candidateFailed, s.finalizeFailure(ctx, attempt, halt.FailureReason, halt.Detail)
}
if err != nil {
s.logger.Error("自动续费事务失败并已整体回滚",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Error(err))
return candidateFailed, s.finalizeFailure(ctx, attempt, constants.AssetAutoRenewalFailureOrderFailed,
"续购事务执行失败并已整体回滚,未产生订单、扣款与套餐事实")
}
s.logger.Info("自动续费续购成功",
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
zap.Uint("order_id", facts.OrderID), zap.String("order_no", facts.OrderNo),
zap.Int64("renew_price", facts.RenewPrice))
return candidateSucceeded, nil
}
// buildAttempt 组装占位尝试记录,冻结触发时的客户、店铺、配置窗口与套餐快照。
func (s *Service) buildAttempt(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig, triggerDate time.Time) (*model.AssetAutoRenewalAttempt, error) {
sequence, err := s.attemptStore.CountByAsset(ctx, candidate.AssetType, candidate.AssetID, triggerDate)
if err != nil {
return nil, err
}
finalExpiresAt := candidate.FinalExpiresAt
attempt := &model.AssetAutoRenewalAttempt{
AssetType: candidate.AssetType, AssetID: candidate.AssetID, TriggerDate: triggerDate,
Status: constants.AssetAutoRenewalAttemptStatusProcessing,
CustomerID: candidate.CustomerID,
ShopID: candidate.ShopID,
ConfigVersion: config.ConfigVersion, WindowDays: config.DaysBeforeExpiry,
FinalExpiresAt: &finalExpiresAt,
CurrentUsageID: candidate.CurrentUsageID,
CurrentPackageID: candidate.CurrentPackageID,
RenewPackageID: candidate.CurrentPackageID,
OperatorType: constants.AssetAutoRenewalOperatorTypeSystemTask,
OperatorID: constants.TaskTypeAssetAutoRenewalScan,
AttemptSeq: int(sequence) + 1,
}
return attempt, nil
}
// finalizeSkip 以独立短事务落跳过终态:不扣款、不建订单、不发送通知。
func (s *Service) finalizeSkip(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, halt *renewalHalt) error {
_, err := s.attemptStore.Finalize(ctx, attempt.ID, map[string]any{
"status": constants.AssetAutoRenewalAttemptStatusSkipped,
"skip_reason": halt.SkipReason,
"failure_reason": "",
"failure_detail": halt.Detail,
})
if err != nil {
return err
}
s.logger.Info("自动续费跳过该资产",
zap.String("asset_type", attempt.AssetType), zap.Uint("asset_id", attempt.AssetID),
zap.String("skip_reason", halt.SkipReason))
return nil
}
// finalizeFailure 以独立短事务落失败终态,并在同一事务内投递通知与写失败审计。
//
// 该短事务与已回滚的续费事务不共用连接或事务ENG-TX-001 例外),条件更新依据尝试记录仍非终态;
// 已被并发收敛时不再投递通知与审计。中断收敛interrupted不属于通知口径不会被通知。
func (s *Service) finalizeFailure(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, map[string]any{
"status": constants.AssetAutoRenewalAttemptStatusFailed,
"failure_reason": reason,
"failure_detail": detail,
"skip_reason": "",
})
if err != nil {
return err
}
if !updated {
s.logger.Info("自动续费尝试已被并发收敛,跳过通知与审计",
zap.Uint("attempt_id", attempt.ID))
return nil
}
if notifyErr := s.appendFailureNotifications(ctx, tx, failureNotification{
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
TriggerDate: attempt.TriggerDate, Reason: reason,
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
}); notifyErr != nil {
return notifyErr
}
return s.appendFailureAudit(ctx, tx, attempt, reason, detail)
})
}

View File

@@ -0,0 +1,178 @@
// Package assetautorenewal 编排资产钱包自动续费:受控配置维护、每日扫描与尝试、续费事务闭合、
// 失败通知与复机可靠投递。
//
// 本包不调用任何支付渠道或运营商接口:续购价格与可售判定复用应用层购买校验与价格策略,
// 复机执行通过 ResumeCommander 端口复用既有停复机单一事实源,通知与复机都通过公共 Outbox
// 在业务事务内写出事件ENG-OUTBOX-001。资金、订单、套餐与成功审计在同一事务内闭合ENG-TX-001
package assetautorenewal
import (
"context"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"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"
)
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ResumeOutcome 是一次复机调用可安全记录的结果摘要。
//
// Applied 为 false 表示本次未满足可复机条件、没有发起任何复机调用Result 取值
// constants.AuditResultSuccess / Failed / Unknown。
type ResumeOutcome struct {
Applied bool
IntegrationID string
Result string
SafeReason string
}
// ResumeCommander 是自动续费成功后复机的执行边界。
//
// 实现必须复用既有停复机单一事实源重试、Integration Log、统一审计、观测序列与既有
// 套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
type ResumeCommander interface {
// AutoRenewalResumeReady 判断该资产当前是否满足自动复机条件;不满足时返回可安全记录的原因。
AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error)
// ResumeAssetForAutoRenewal 执行复机并返回结果分类。
ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (ResumeOutcome, error)
// QueryAutoRenewalResumeState 只查询运营商状态回填复机结果,绝不重复发起复机。
QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (online bool, known bool, integrationID string, err error)
}
// Dependencies 汇总自动续费用例的装配依赖。
//
// DB 与 Redis 用于构造本用例独占的资产钱包、流水、订单、套餐与资产 Store
// 其余依赖是配置、价格、候选、复机与可靠事件的能力边界。
type Dependencies struct {
DB *gorm.DB
Redis *redis.Client
Logger *zap.Logger
Outbox *outbox.Repository
AuditWriter *audit.Writer
PurchaseValidation *purchase_validation.Service
Candidates *assetquery.Query
Resume ResumeCommander
ObservationEvents cardObservationApp.SeriesEventWriter
PriorityEvents priorityapp.PriorityEventWriter
}
// Service 执行资产钱包自动续费的配置维护、每日扫描、续费事务与终止态收敛。
type Service struct {
db *gorm.DB
configStore *postgres.AssetAutoRenewalConfigStore
attemptStore *postgres.AssetAutoRenewalAttemptStore
assetWalletStore *postgres.AssetWalletStore
walletTransactionStore *postgres.AssetWalletTransactionStore
orderStore *postgres.OrderStore
packageUsageStore *postgres.PackageUsageStore
packageStore *postgres.PackageStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
personalCustomerStore *postgres.PersonalCustomerStore
candidates *assetquery.Query
purchaseValidation *purchase_validation.Service
outbox *outbox.Repository
auditWriter *audit.Writer
resume ResumeCommander
observationEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
logger *zap.Logger
now func() time.Time
}
// NewService 创建资产钱包自动续费用例。
func NewService(deps Dependencies) *Service {
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
return &Service{
db: deps.DB,
configStore: postgres.NewAssetAutoRenewalConfigStore(deps.DB),
attemptStore: postgres.NewAssetAutoRenewalAttemptStore(deps.DB),
assetWalletStore: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
walletTransactionStore: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
orderStore: postgres.NewOrderStore(deps.DB, deps.Redis),
packageUsageStore: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
packageStore: postgres.NewPackageStore(deps.DB),
iotCardStore: postgres.NewIotCardStore(deps.DB, deps.Redis),
deviceStore: postgres.NewDeviceStore(deps.DB, deps.Redis),
personalCustomerStore: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
candidates: deps.Candidates,
purchaseValidation: deps.PurchaseValidation,
outbox: deps.Outbox,
auditWriter: deps.AuditWriter,
resume: deps.Resume,
observationEvents: deps.ObservationEvents,
priorityEvents: deps.PriorityEvents,
logger: logger,
now: time.Now,
}
}
// today 返回当前上海自然日,作为触发日期与每日唯一键的统一口径。
func (s *Service) today() time.Time {
return assetquery.Today(s.now())
}
// assetIdentifier 读取资产对外的可读标识,取不到时回退为资产 ID 文本。
// 通知模板要求标识非空,因此绝不返回空串。
func (s *Service) assetIdentifier(ctx context.Context, assetType string, assetID uint) string {
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
if card, err := s.iotCardStore.GetByID(ctx, assetID); err == nil && card.ICCID != "" {
return card.ICCID
}
case constants.AssetWalletResourceTypeDevice:
if device, err := s.deviceStore.GetByID(ctx, assetID); err == nil {
if device.VirtualNo != "" {
return device.VirtualNo
}
if device.IMEI != "" {
return device.IMEI
}
}
}
return strconv.FormatUint(uint64(assetID), 10)
}
// packageName 读取套餐商品名称,取不到时回退为套餐 ID 文本。
func (s *Service) packageName(ctx context.Context, packageID uint) string {
if packageID == 0 {
return "未知套餐"
}
if pkg, err := s.packageStore.GetByID(ctx, packageID); err == nil && pkg.PackageName != "" {
return pkg.PackageName
}
return strconv.FormatUint(uint64(packageID), 10)
}
// loadPackagesByIDs 批量读取套餐商品,用于一次性取价与快照。
func (s *Service) loadPackagesByIDs(ctx context.Context, packageIDs []uint) (map[uint]*model.Package, error) {
result := make(map[uint]*model.Package, len(packageIDs))
if len(packageIDs) == 0 {
return result, nil
}
var packages []*model.Package
if err := s.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费套餐商品失败")
}
for _, pkg := range packages {
result[pkg.ID] = pkg
}
return result, 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

@@ -7,6 +7,7 @@ import (
"time"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -42,6 +43,12 @@ type CacheInvalidator interface {
Invalidate(ctx context.Context, cardID uint)
}
// ChannelThresholdEvaluator 在卡流量事务内判定运营商通道流量阈值。
// 实现必须在调用方事务内写周期锁与停机事件,使锁事实与流量事实同事务提交。
type ChannelThresholdEvaluator interface {
EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation carrierthresholddomain.Evaluation) error
}
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
type StateAudit struct {
ActionCode string
@@ -64,6 +71,8 @@ type Service struct {
eventWriter EventWriter
cache CacheInvalidator
auditWriter StateAuditWriter
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
channelThreshold ChannelThresholdEvaluator
}
// NewService 创建卡实名观测应用服务。
@@ -71,6 +80,11 @@ func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *S
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// SetChannelThresholdEvaluator 注入运营商通道流量阈值达量判定能力。
func (s *Service) SetChannelThresholdEvaluator(evaluator ChannelThresholdEvaluator) {
s.channelThreshold = evaluator
}
// SetStateAuditWriter 注入卡状态统一审计 Writer。
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
s.auditWriter = writer

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm/clause"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -80,6 +81,16 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
return err
}
}
if decision.ReadingAccepted && s.channelThreshold != nil {
// 达量判定只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定;
// 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
CardID: card.ID, CarrierID: card.CarrierID,
ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt,
}); err != nil {
return err
}
}
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {

View File

@@ -0,0 +1,307 @@
package carrierthreshold
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// cycleBatchSize 是单次周期处理扫描的锁行上限。
const cycleBatchSize = 200
// CycleStats 是一次周期处理的可观察结果。
//
// Scanned 为扫到的待处理持锁数Unlocked 为本次认领解锁成功的锁数Resumed 为同时写出复机事件的锁数;
// Anomaly 为因运营商配置缺失或重置日非法而解锁并转人工的锁数Skipped 为本地事实缺失、被并发推进
// 或判定失败而未改动的锁数。
type CycleStats struct{ Scanned, Unlocked, Resumed, Anomaly, Skipped int }
// ProcessDueLocks 扫描待处理的持锁锁行:认领解锁,并在新周期条件满足时同事务写复机事件。
//
// 过期判断按锁行自身运营商的 data_reset_day支持换运营商后旧锁仍按其归属处理。解锁与复机事件
// 在同一事务提交:事务失败则解锁一起回滚,下一分钟重新处理,不会出现「已解锁但没有复机任务」的中间态。
// 任一复机条件不满足时只解锁、不调运营商,并把可观察原因写入锁行。
//
// 锁行引用的运营商已不存在或重置日非法时无法计算周期归属:这类行按「新周期对仍持锁卡解除通道锁」
// 的语义解锁并标记异常转人工,绝不写复机事件、不调运营商,也绝不静默跳过(否则持锁卡会永久禁止复机)。
func (s *Service) ProcessDueLocks(ctx context.Context, now time.Time) (CycleStats, error) {
stats := CycleStats{}
if s == nil || s.db == nil || s.repository == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值周期处理能力未配置")
}
due, err := s.ScanDueLocks(ctx, now, cycleBatchSize)
if err != nil {
return stats, err
}
stats.Scanned = len(due)
if len(due) == 0 {
return stats, nil
}
if s.commander == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
}
var firstErr error
for index := range due {
item := due[index]
if err := s.processDueLock(ctx, &item, &stats); err != nil {
stats.Skipped++
s.logger.Warn("通道阈值跨期处理单条失败",
zap.Uint("lock_id", item.Lock.ID), zap.Uint("card_id", item.Lock.CardID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// processDueLock 处理单条待处理锁行:周期归属不可判定时解锁并转人工,已跨期时认领解锁并条件复机。
func (s *Service) processDueLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
if item.Kind == DueLockUnresolvable {
return s.processUnresolvableLock(ctx, item, stats)
}
lock := &item.Lock
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
stats.Skipped++
s.logger.Warn("通道阈值跨期锁对应卡不存在,本次不处理",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
return nil
}
// 复机条件复用既有单一事实源(有效主套餐 + 流量未耗尽 + 实名满足 + 非风险扩展 + 无其他停因)。
ready, reason, err := s.commander.ResumeReady(ctx, lock.CardID)
if err != nil {
// 判定失败时不解锁:保留锁与拒绝复机的语义,下一分钟重试。
return err
}
unlocked := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason)
if unlockErr != nil {
return unlockErr
}
if !claimed {
return nil
}
unlocked = true
if !ready {
return nil
}
return AppendResumeRequested(ctx, tx, s.repository, lock)
})
if err != nil {
return err
}
if !unlocked {
stats.Skipped++
s.logger.Info("通道阈值跨期锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
return nil
}
stats.Unlocked++
if !ready {
s.logger.Info("通道阈值新周期仅解锁,不调用运营商复机",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.String("reason", reason))
return nil
}
stats.Resumed++
s.logger.Info("通道阈值新周期条件满足,已写复机事件",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
return nil
}
// processUnresolvableLock 处理周期归属不可判定的锁行:同事务认领解锁并标记异常转人工。
//
// 只解锁不写复机事件、不调运营商:配置缺失时无法判断是否已跨期,解除通道锁交由既有复机链路
// 与人工决定anomaly_flag 与失败原因使运维可见并转人工核对。解锁与异常标记都是条件更新,
// 重复执行不会产生第二次副作用。
// 两者 MUST 共用同一个事务句柄:解锁已持有该行锁,若异常标记改走服务自身连接池,另一条连接
// 会等待本事务的行锁(自锁),处理将挂死到语句超时并使该 cron 每分钟空转。
func (s *Service) processUnresolvableLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
lock := &item.Lock
s.logger.Warn("通道阈值锁行周期归属不可判定,解锁并转人工核对",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Uint("carrier_id", lock.CarrierID),
zap.String("reason", item.Reason))
unlocked := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason)
if unlockErr != nil {
return unlockErr
}
if !claimed {
return nil
}
unlocked = true
_, anomalyErr := s.markAnomalyInTx(ctx, tx, lock.ID, item.Reason)
return anomalyErr
})
if err != nil {
return err
}
if !unlocked {
stats.Skipped++
s.logger.Info("通道阈值不可判定锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
return nil
}
stats.Unlocked++
stats.Anomaly++
return nil
}
// recoveryBatchSize 是单次恢复扫描的锁行上限。
const recoveryBatchSize = 200
// RecoveryStats 是一次恢复扫描的可观察结果。
//
// Scanned 为扫到的待确认锁数Confirmed 为本次回填为运营商已确认的锁数Pending 为结果仍未知、
// 等待下次扫描的锁数Anomaly 为超过查询窗口仍不可确认、本次标记转人工的锁数;
// Skipped 为本地事实缺失或已被并发推进而未改动状态的锁数。
type RecoveryStats struct{ Scanned, Confirmed, Pending, Anomaly, Skipped int }
// RecoverSubmitted 扫描存在已提交子任务的锁:只查询运营商状态回填,绝不重复发起停复机。
//
// 每个子任务独立判断:查询确认到达目标状态即回填 confirmed 并补写卡状态(覆盖运营商调用成功但
// 本地回写失败的场景);仍不可确认则等到下一次扫描;自提交起超过查询窗口仍不可确认时标记异常
// 并退出自动扫描转人工,锁行与历史结果一律保留,绝不自动删除。
func (s *Service) RecoverSubmitted(ctx context.Context, now time.Time) (RecoveryStats, error) {
stats := RecoveryStats{}
if s == nil || s.db == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值恢复扫描能力未配置")
}
locks, err := s.ScanSubmittedLocks(ctx, recoveryBatchSize)
if err != nil {
return stats, err
}
stats.Scanned = len(locks)
if len(locks) == 0 {
return stats, nil
}
if s.commander == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
}
var firstErr error
for index := range locks {
lock := locks[index]
if err := s.recoverSubmittedLock(ctx, &lock, now, &stats); err != nil {
stats.Skipped++
s.logger.Warn("通道阈值恢复扫描单条失败",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// recoverSubmittedLock 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。
//
// 入口只处理未决子任务submitted/unknown/failed调用失败或结果未知同样可能已在运营商侧生效
// 必须继续收敛confirmed 是终态pending 表示从未对外调用,都不在本扫描范围。
func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, now time.Time, stats *RecoveryStats) error {
if !domain.IsUnresolvedTaskStatus(lock.StopStatus) && !domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
status, known := constants.NetworkStatusOffline, false
if card != nil {
// 查询失败按「仍未确认」处理,绝不误判为失败终态。
status, known, _, err = s.commander.CardNetworkStatus(ctx, lock.CardID)
if err != nil {
s.logger.Warn("查询运营商卡状态失败,按仍未确认处理",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
known = false
}
}
confirmed := 0
unconfirmed := 0
if domain.IsUnresolvedTaskStatus(lock.StopStatus) {
switch {
case known && status == constants.NetworkStatusOffline:
confirmed++
if err := s.confirmStop(ctx, lock, card); err != nil {
return err
}
default:
unconfirmed++
}
}
if domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
switch {
case known && status == constants.NetworkStatusOnline:
confirmed++
if err := s.confirmResume(ctx, lock, card); err != nil {
return err
}
default:
unconfirmed++
}
}
if confirmed == 0 {
stats.Pending++
} else {
stats.Confirmed++
}
if unconfirmed == 0 {
return nil
}
// 仍有子任务不可确认:窗口内继续等待,超期标记异常并退出自动扫描。
if !domain.SubmissionExpired(lock.StopSubmittedAt, now) && !domain.SubmissionExpired(lock.ResumeSubmittedAt, now) {
return nil
}
marked, err := s.markAnomaly(ctx, lock.ID, "运营商停复机结果超过确认窗口仍不可查,请人工核对")
if err != nil {
return err
}
if marked {
stats.Anomaly++
s.logger.Warn("通道阈值停复机结果超期不可确认,已标记异常转人工",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID),
zap.Time("stop_submitted_at", valueOrZero(lock.StopSubmittedAt)),
zap.Time("resume_submitted_at", valueOrZero(lock.ResumeSubmittedAt)))
}
return nil
}
// confirmStop 停机已被运营商确认:先补写卡停机状态,再把子任务回填为已确认。
// 顺序不可颠倒:先写卡状态才能保证「已确认」的锁不会掩盖未回写的卡事实。
func (s *Service) confirmStop(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
if card != nil && card.NetworkStatus != constants.NetworkStatusOffline {
if err := s.commander.ConfirmCardState(ctx, lock.CardID, true); err != nil {
return err
}
}
_, err := s.markTaskConfirmed(ctx, lock.ID, stopTask, lock.StopIntegrationID)
return err
}
// confirmResume 复机已被运营商确认:先补写卡在线状态,再把子任务回填为已确认。
func (s *Service) confirmResume(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
if card != nil && card.NetworkStatus != constants.NetworkStatusOnline {
if err := s.commander.ConfirmCardState(ctx, lock.CardID, false); err != nil {
return err
}
}
_, err := s.markTaskConfirmed(ctx, lock.ID, resumeTask, lock.ResumeIntegrationID)
return err
}
// valueOrZero 在日志中安全展开可空的提交时刻。
func valueOrZero(value *time.Time) time.Time {
if value == nil {
return time.Time{}
}
return *value
}

View File

@@ -0,0 +1,107 @@
package carrierthreshold
import (
"context"
"strconv"
"go.uber.org/zap"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// EvaluateInTx 在流量观测事务内判定通道阈值,达量时写周期锁并同事务写停机事件。
//
// 判定前提由调用方保证只有「读数被接受」ReadingAccepted的观测才进入本方法
// 异常下降保护命中的观测不参与判定。重复达量观测由部分唯一索引的 23505 识别为
// 「该周期已处理」并幂等跳过:不重复建锁、不重复写停机事件,该冲突与流量基线 CAS 的
// CodeConflict 语义不同,绝不能混流。返回错误表示必须整体回滚(与既有流量事实同事务)。
func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation domain.Evaluation) error {
if s == nil || s.db == nil || s.repository == nil {
return errors.New(errors.CodeInternalError, "通道流量阈值判定能力未完整配置")
}
if tx == nil {
return errors.New(errors.CodeInvalidStatus, "通道流量阈值判定必须传入事务句柄")
}
if evaluation.CardID == 0 || evaluation.CarrierID == 0 {
return nil
}
var carrier model.Carrier
// 只取判定所需列:周期起点必须来自该运营商的 data_reset_day漏取会让周期判定失去依据。
if err := tx.WithContext(ctx).Select("id", "data_reset_day", "traffic_threshold_enabled", "traffic_threshold_value", "traffic_threshold_unit").
Where("id = ?", evaluation.CarrierID).First(&carrier).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 卡引用的运营商已不存在:不判定、不建锁,保持与既有卡事实不一致的现状。
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询运营商通道流量阈值配置失败")
}
threshold := thresholdOf(carrier)
if !threshold.Enabled {
return nil
}
if !threshold.Valid() {
// 配置半残(启用但无数值/单位,或单位未知)时绝不按 0 判定,跳过并留可观测日志。
s.logger.Warn("运营商通道流量阈值配置不完整,跳过达量判定",
zap.Uint("carrier_id", carrier.ID), zap.String("unit", threshold.Unit), zap.Float64("value", threshold.Value))
return nil
}
reached, err := threshold.Reached(evaluation.ReadingMB)
if err != nil {
return err
}
if !reached {
return nil
}
periodStart, err := domain.PeriodStart(evaluation.ObservedAt, carrier.DataResetDay)
if err != nil {
s.logger.Warn("运营商上游流量重置日非法,跳过达量判定",
zap.Uint("carrier_id", carrier.ID), zap.Int("data_reset_day", carrier.DataResetDay))
return nil
}
lock := &model.CarrierTrafficThresholdLock{
CarrierID: evaluation.CarrierID,
CardID: evaluation.CardID,
PeriodStart: periodStart,
Status: domain.LockStatusLocked,
StopStatus: domain.TaskStatusPending,
ResumeStatus: domain.TaskStatusPending,
}
// 唯一冲突(该周期已处理)在 createLockInTx 内以保存点隔离:不重复建锁、不重复写停机事件。
created, err := s.createLockInTx(ctx, tx, lock)
if err != nil {
return err
}
if !created {
s.logger.Info("该计费周期已存在通道阈值停机锁,跳过重复判定",
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID))
return nil
}
if err := AppendStopRequested(ctx, tx, s.repository, lock); err != nil {
return err
}
s.logger.Info("卡流量达到运营商通道阈值,已写周期锁与停机事件",
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID),
zap.Uint("lock_id", lock.ID), zap.Float64("reading_mb", evaluation.ReadingMB),
zap.Time("period_start", periodStart))
return nil
}
// thresholdOf 把运营商持久化列转换为领域阈值配置。
func thresholdOf(carrier model.Carrier) domain.Threshold {
threshold := domain.Threshold{
Enabled: carrier.TrafficThresholdEnabled == 1,
Unit: carrier.TrafficThresholdUnit,
}
if carrier.TrafficThresholdValue != nil {
threshold.Value = *carrier.TrafficThresholdValue
}
return threshold
}
// lockKeyValue 返回周期锁在 Outbox 事件中的稳定字符串标识。
func lockKeyValue(lockID uint) string {
return strconv.FormatUint(uint64(lockID), 10)
}

View File

@@ -0,0 +1,159 @@
package carrierthreshold
import (
"context"
stderrors "errors"
"github.com/bytedance/sonic"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"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/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// EventCarrierThresholdStop 是卡流量达到通道阈值后的可靠停机事件。
const EventCarrierThresholdStop = "carrier.threshold.stop.requested"
// EventCarrierThresholdResume 是通道阈值跨期解锁且条件满足后的可靠复机事件。
const EventCarrierThresholdResume = "carrier.threshold.resume.requested"
// carrierThresholdPayloadVersion 是通道阈值事件的载荷版本。
const carrierThresholdPayloadVersion = 1
// periodLockConstraint 是周期锁部分唯一索引名,用于把 23505 精确识别为该周期已处理。
const periodLockConstraint = "uq_carrier_traffic_threshold_lock_key"
// StopPayload 是通道阈值停机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
type StopPayload struct {
LockID uint `json:"lock_id"`
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
}
// AppendStopRequested 在达量判定事务内幂等写入通道阈值停机事件。
// 事件 ID 由锁 ID 派生同一周期锁重复投递不会创建第二个事件ENG-OUTBOX-001
func AppendStopRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if lock == nil || lock.ID == 0 {
return gorm.ErrInvalidData
}
value := lockKeyValue(lock.ID)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventCarrierThresholdStop+":", value),
EventType: EventCarrierThresholdStop,
PayloadVersion: carrierThresholdPayloadVersion,
AggregateType: "carrier_traffic_threshold_lock",
AggregateID: value,
ResourceType: "iot_card",
ResourceID: lockKeyValue(lock.CardID),
BusinessKey: EventCarrierThresholdStop + ":" + value,
Payload: StopPayload{
LockID: lock.ID,
CardID: lock.CardID,
CarrierID: lock.CarrierID,
},
})
return err
}
// ResumePayload 是通道阈值复机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
type ResumePayload struct {
LockID uint `json:"lock_id"`
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
}
// AppendResumeRequested 在周期处理事务内幂等写入通道阈值复机事件。
// 事件 ID 由锁 ID 派生解锁认领与复机事件同事务写入重复投递不会创建第二个事件ENG-OUTBOX-001
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if lock == nil || lock.ID == 0 {
return gorm.ErrInvalidData
}
value := lockKeyValue(lock.ID)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventCarrierThresholdResume+":", value),
EventType: EventCarrierThresholdResume,
PayloadVersion: carrierThresholdPayloadVersion,
AggregateType: "carrier_traffic_threshold_lock",
AggregateID: value,
ResourceType: "iot_card",
ResourceID: lockKeyValue(lock.CardID),
BusinessKey: EventCarrierThresholdResume + ":" + value,
Payload: ResumePayload{
LockID: lock.ID,
CardID: lock.CardID,
CarrierID: lock.CarrierID,
},
})
return err
}
// isPeriodLockConflict 判断错误是否为周期锁唯一键冲突,即「该周期已处理」。
// 该冲突必须与流量基线 CAS 冲突CodeConflict区分前者幂等跳过后者由调用方重放重试。
func isPeriodLockConflict(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == periodLockConstraint
}
// 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 {
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行能力未配置")
}
lockID, validationErr := decodeThresholdPayload(envelope)
if validationErr != nil {
return validationErr
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
switch envelope.EventType {
case EventCarrierThresholdStop:
return c.service.ExecuteStop(ctx, lockID)
case EventCarrierThresholdResume:
return c.service.ExecuteResume(ctx, lockID)
default:
return outbox.Permanent(gorm.ErrInvalidData)
}
}
// decodeThresholdPayload 校验事件类型与载荷版本并取出锁 ID。
// 载荷不合法属永久失败:重复投递不会改变结果,必须直接终结而不是重试。
func decodeThresholdPayload(envelope outbox.DeliveryEnvelope) (uint, error) {
if envelope.PayloadVersion != carrierThresholdPayloadVersion {
return 0, outbox.Permanent(gorm.ErrInvalidData)
}
var payload struct {
LockID uint `json:"lock_id"`
}
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return 0, outbox.Permanent(err)
}
if payload.LockID == 0 {
return 0, outbox.Permanent(gorm.ErrInvalidData)
}
return payload.LockID, nil
}
// 编译期断言:通道阈值停复机消费者满足公共 Outbox 的消费边界。
var _ outbox.EventConsumer = (*Consumer)(nil)

View File

@@ -0,0 +1,199 @@
package carrierthreshold
import (
"context"
"go.uber.org/zap"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 无法从运营商确认结果时的可安全展示原因(不含内部细节与渠道报文)。
const (
failedStopReason = "运营商停机调用失败,等待状态查询确认"
unknownStopReason = "运营商停机结果未知,等待状态查询确认"
failedResumeReason = "运营商复机调用失败,等待状态查询确认"
unknownResumeReason = "运营商复机结果未知,等待状态查询确认"
)
// ExecuteStop 执行一次通道阈值达量停机,保证至多一次外部调用。
//
// 流程提交认领stop_submitted_at IS NULL 且仍持锁)→ 卡已停机则直接确认成功,不调运营商 →
// 否则复用既有停机重试、Integration Log 与统一审计执行停机。认领失败表示该锁已提交过
// (事件重复投递或人工重放),本次只结束,绝不重复调用;结果由恢复扫描查询收敛。
// 任何分支都不删除锁行、不解锁。
func (s *Service) ExecuteStop(ctx context.Context, lockID uint) error {
if err := s.requireExecution(); err != nil {
return err
}
lock, err := s.loadLock(ctx, lockID)
if err != nil {
return err
}
if lock == nil {
s.logger.Info("通道阈值停机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
return nil
}
claimed, err := s.ClaimStopSubmission(ctx, lockID, s.now())
if err != nil {
return err
}
if !claimed {
s.logger.Info("通道阈值停机已提交过,只等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.String("stop_status", lock.StopStatus))
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
// 卡事实不存在时无法调运营商,保留 submitted 由恢复扫描按窗口标记异常转人工。
s.logger.Warn("通道阈值停机锁对应卡不存在,等待恢复扫描处理",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
if card.NetworkStatus == constants.NetworkStatusOffline {
// 其他停因已先行停机:不重复调用运营商,直接确认本次停机目标已达成。
if _, err := s.markTaskConfirmed(ctx, lockID, stopTask, ""); err != nil {
return err
}
s.logger.Info("卡已停机,通道阈值停机直接确认成功",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.String("stop_reason", card.StopReason))
return nil
}
outcome, callErr := s.commander.StopCardForThreshold(ctx, lock.CardID)
if callErr != nil && outcome.Result == "" {
// 基础设施故障(读卡、写审计或写卡状态失败)导致结果无法判定:保留 submitted
// 交由恢复扫描查询确认,本次不判定终态、不重复调用。
s.logger.Error("通道阈值停机执行失败,等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
return nil
}
if callErr != nil {
s.logger.Warn("通道阈值停机运营商调用未成功,已按结果分类回填",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.Error(callErr))
}
_, err = s.markTaskOutcome(ctx, lockID, stopTask, []string{domain.TaskStatusSubmitted},
taskResultOf(outcome), outcome.IntegrationID, stopFailureReason(outcome))
if err != nil {
return err
}
s.logger.Info("通道阈值停机任务已回填结果",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
return nil
}
// ExecuteResume 执行一次通道阈值新周期复机,保证至多一次外部调用。
//
// 流程提交认领resume_submitted_at IS NULL→ 卡已在线则直接确认成功,不调运营商 →
// 否则复用既有复机重试、Integration Log 与统一审计执行复机(成功时同一事务写回卡状态,
// 且只在停因为通道阈值时清除停因)。认领失败表示已提交过,绝不重复调用。
func (s *Service) ExecuteResume(ctx context.Context, lockID uint) error {
if err := s.requireExecution(); err != nil {
return err
}
lock, err := s.loadLock(ctx, lockID)
if err != nil {
return err
}
if lock == nil {
s.logger.Info("通道阈值复机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
return nil
}
claimed, err := s.ClaimResumeSubmission(ctx, lockID, s.now())
if err != nil {
return err
}
if !claimed {
s.logger.Info("通道阈值复机已提交过,只等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.String("resume_status", lock.ResumeStatus))
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
s.logger.Warn("通道阈值复机锁对应卡不存在,等待恢复扫描处理",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
if card.NetworkStatus == constants.NetworkStatusOnline {
if _, err := s.markTaskConfirmed(ctx, lockID, resumeTask, ""); err != nil {
return err
}
s.logger.Info("卡已在线,通道阈值复机直接确认成功",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
outcome, callErr := s.commander.ResumeCardForThreshold(ctx, lock.CardID)
if callErr != nil && outcome.Result == "" {
s.logger.Error("通道阈值复机执行失败,等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
return nil
}
if callErr != nil {
s.logger.Warn("通道阈值复机运营商调用未成功,已按结果分类回填",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.Error(callErr))
}
_, err = s.markTaskOutcome(ctx, lockID, resumeTask, []string{domain.TaskStatusSubmitted},
taskResultOf(outcome), outcome.IntegrationID, resumeFailureReason(outcome))
if err != nil {
return err
}
s.logger.Info("通道阈值复机任务已回填结果",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
return nil
}
// requireExecution 校验消费者与周期处理所需的端口已配置。
func (s *Service) requireExecution() error {
if s == nil || s.db == nil || s.repository == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值执行能力未完整配置")
}
if s.commander == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
}
return nil
}
// taskResultOf 把运营商调用结果映射为子任务终态。
func taskResultOf(outcome domain.CommandOutcome) string {
switch outcome.Result {
case constants.AuditResultSuccess:
return domain.TaskStatusConfirmed
case constants.AuditResultUnknown:
return domain.TaskStatusUnknown
default:
return domain.TaskStatusFailed
}
}
// stopFailureReason 生成停机子任务的可安全失败原因,成功时为空。
func stopFailureReason(outcome domain.CommandOutcome) string {
if outcome.Confirmed() {
return ""
}
if outcome.Unresolved() {
return unknownStopReason
}
return failedStopReason
}
// resumeFailureReason 生成复机子任务的可安全失败原因,成功时为空。
func resumeFailureReason(outcome domain.CommandOutcome) string {
if outcome.Confirmed() {
return ""
}
if outcome.Unresolved() {
return unknownResumeReason
}
return failedResumeReason
}

View File

@@ -0,0 +1,361 @@
package carrierthreshold
import (
"context"
"strings"
"time"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// activeLockScanLimit 是「按卡取当前周期锁」的候选扫描上限。
// 一张卡正常情况下只有当前周期的锁与历史周期锁,取最近若干条足以覆盖换运营商后的场景。
const activeLockScanLimit = 8
// createLockInTx 在调用方事务内插入周期锁,返回 created=false 表示该周期已处理(唯一键冲突)。
func (s *Service) createLockInTx(ctx context.Context, tx *gorm.DB, lock *model.CarrierTrafficThresholdLock) (bool, error) {
// 唯一冲突在 PostgreSQL 中会中止整个事务,因此插入必须隔离在保存点内:
// GORM 对已开启事务的嵌套 Transaction 使用 SAVEPOINT冲突只回滚本次插入。
insertErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return inner.Create(lock).Error
})
if insertErr == nil {
return true, nil
}
if isPeriodLockConflict(insertErr) {
return false, nil
}
return false, errors.Wrap(errors.CodeDatabaseError, insertErr, "写入通道流量阈值周期锁失败")
}
// FindLock 按唯一键读取某卡在某运营商某计费周期内的锁;不存在返回 nil。
func (s *Service) FindLock(ctx context.Context, carrierID, cardID uint, periodStart time.Time) (*model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil || carrierID == 0 || cardID == 0 {
return nil, nil
}
var lock model.CarrierTrafficThresholdLock
err := s.db.WithContext(ctx).
Where("carrier_id = ? AND card_id = ? AND period_start = ?", carrierID, cardID, periodStart).
First(&lock).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道流量阈值周期锁失败")
}
return &lock, nil
}
// ActiveLock 返回该卡在 now 所属计费周期内仍然生效的通道阈值锁;无锁返回 nil。
//
// 周期归属按锁行自身运营商的 data_reset_day 判断,因此换运营商后旧周期锁不会误判为当前周期。
// 锁行引用的运营商已不存在时无法计算周期归属,此时按「仍可能属于当前周期」处理并返回该锁:
// 持有通道阈值锁的卡在周期内必须拒绝一切复机,不能因为配置缺失放开复机,只能由周期处理/人工核销。
func (s *Service) ActiveLock(ctx context.Context, cardID uint, now time.Time) (*model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil || cardID == 0 {
return nil, nil
}
var locks []model.CarrierTrafficThresholdLock
if err := s.db.WithContext(ctx).
Where("card_id = ? AND status = ?", cardID, domain.LockStatusLocked).
Order("period_start DESC").Limit(activeLockScanLimit).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前周期通道流量阈值锁失败")
}
if len(locks) == 0 {
return nil, nil
}
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
if err != nil {
return nil, err
}
for index := range locks {
resetDay, ok := resetDays[locks[index].CarrierID]
if !ok {
return &locks[index], nil
}
periodStart, err := domain.PeriodStart(now, resetDay)
if err != nil {
// 重置日非法时同样无法判定周期归属,按仍生效处理,避免放开复机。
return &locks[index], nil
}
if locks[index].PeriodStart.Equal(periodStart) {
return &locks[index], nil
}
}
return nil, nil
}
// ClaimStopSubmission 以 stop_submitted_at IS NULL 条件更新认领停机提交权。
//
// 返回 true 表示调用方获得提交权、可以调用停机接口false 表示该锁已提交过(事件重复投递或
// 人工重放),调用方只能查询结果。谓词同时要求锁仍处于 locked已跨期解锁的锁不再停机。
func (s *Service) ClaimStopSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
if s == nil || s.db == nil || lockID == 0 {
return false, nil
}
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND stop_submitted_at IS NULL AND status = ?", lockID, domain.LockStatusLocked).
Updates(map[string]any{"stop_submitted_at": now, "stop_status": domain.TaskStatusSubmitted})
if claimed.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值停机提交权失败")
}
return claimed.RowsAffected == 1, nil
}
// ClaimResumeSubmission 以 resume_submitted_at IS NULL 条件更新认领复机提交权。
//
// 复机发生在周期处理解锁之后,此时锁已是 unlocked因此谓词只要求未提交过。
func (s *Service) ClaimResumeSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
if s == nil || s.db == nil || lockID == 0 {
return false, nil
}
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND resume_submitted_at IS NULL", lockID).
Updates(map[string]any{"resume_submitted_at": now, "resume_status": domain.TaskStatusSubmitted})
if claimed.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值复机提交权失败")
}
return claimed.RowsAffected == 1, nil
}
// DueLockKind 描述一条仍在持锁的锁行的跨期判定结果。
type DueLockKind string
const (
// DueLockExpired 表示已跨期:锁行 period_start 早于该锁行自身运营商按 data_reset_day 算出的当前周期起点。
DueLockExpired DueLockKind = "expired"
// DueLockUnresolvable 表示周期归属不可判定:锁行引用的运营商已不存在(含软删)或重置日非法。
DueLockUnresolvable DueLockKind = "unresolvable"
)
// unresolvableCarrierReason 是周期归属不可判定时写入锁行的可安全原因。
const unresolvableCarrierReason = "锁行引用的运营商已不存在或上游流量重置日非法,已按跨期解除通道锁并转人工核对"
// DueLock 是周期处理扫描到的一条待处理锁行。
type DueLock struct {
// Lock 是持锁锁行本身。
Lock model.CarrierTrafficThresholdLock
// Kind 是跨期判定结果:已跨期或周期归属不可判定。
Kind DueLockKind
// Reason 是周期归属不可判定时的可安全原因Kind 为 DueLockExpired 时为空)。
Reason string
}
// ScanDueLocks 扫描需要周期处理的持锁锁行,供周期处理解锁与条件复机。
//
// 过期判断按锁行自身运营商的 data_reset_day 计算其当前周期起点,与锁行 period_start 不一致即已跨期,
// 因此换运营商后的旧锁仍按其旧 carrier 的归属被正确识别。锁行引用的运营商已不存在或重置日非法时
// 无法计算周期归属,这类行以 DueLockUnresolvable 返回:周期处理必须给出出路(按跨期语义解锁并转人工),
// 绝不能让持锁卡因配置缺失而永久禁止复机。
func (s *Service) ScanDueLocks(ctx context.Context, now time.Time, limit int) ([]DueLock, error) {
if s == nil || s.db == nil {
return nil, nil
}
var locks []model.CarrierTrafficThresholdLock
if err := s.db.WithContext(ctx).
Where("status = ?", domain.LockStatusLocked).
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描跨期通道流量阈值锁失败")
}
if len(locks) == 0 {
return nil, nil
}
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
if err != nil {
return nil, err
}
due := make([]DueLock, 0, len(locks))
for index := range locks {
lock := locks[index]
resetDay, ok := resetDays[lock.CarrierID]
if !ok {
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
continue
}
periodStart, periodErr := domain.PeriodStart(now, resetDay)
if periodErr != nil {
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
continue
}
if lock.PeriodStart.Before(periodStart) {
due = append(due, DueLock{Lock: lock, Kind: DueLockExpired})
}
}
return due, nil
}
// ScanSubmittedLocks 扫描存在未决子任务的锁行,供恢复扫描查询运营商状态回填。
//
// 未决集合为 {submitted, unknown, failed}domain.UnresolvedTaskStatuses调用失败或结果未知的行
// 仍可能已在运营商侧生效,因此必须继续用只读状态查询收敛,不得退出链路。
// 已标记异常(转人工)的锁必须退出扫描,否则每次扫描都会重复查询同一笔无法收敛的结果。
func (s *Service) ScanSubmittedLocks(ctx context.Context, limit int) ([]model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil {
return nil, nil
}
unresolved := domain.UnresolvedTaskStatuses()
var locks []model.CarrierTrafficThresholdLock
if err := s.db.WithContext(ctx).
Where("anomaly_flag = ? AND (stop_status IN ? OR resume_status IN ?)",
domain.AnomalyFlagNone, unresolved, unresolved).
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描待确认通道流量阈值任务失败")
}
return locks, nil
}
// lockTask 标识锁行上的停复机子任务。
type lockTask struct {
// statusColumn 是子任务状态列名。
statusColumn string
// integrationColumn 是该子任务对应的 Integration Log 标识列名。
integrationColumn string
}
var (
// stopTask 是停机子任务。
stopTask = lockTask{statusColumn: "stop_status", integrationColumn: "stop_integration_id"}
// resumeTask 是复机子任务。
resumeTask = lockTask{statusColumn: "resume_status", integrationColumn: "resume_integration_id"}
)
// failureReasonMaxRunes 与 tb_carrier_traffic_threshold_lock.failure_reason 的长度上限一致。
const failureReasonMaxRunes = 500
// safeFailureReason 裁剪可安全展示的失败原因,超长截断,绝不写入内部细节。
func safeFailureReason(reason string) string {
trimmed := strings.TrimSpace(reason)
runes := []rune(trimmed)
if len(runes) <= failureReasonMaxRunes {
return trimmed
}
return string(runes[:failureReasonMaxRunes])
}
// loadLock 按 ID 读取锁行;不存在返回 nil软删行不可见
func (s *Service) loadLock(ctx context.Context, lockID uint) (*model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil || lockID == 0 {
return nil, nil
}
var lock model.CarrierTrafficThresholdLock
err := s.db.WithContext(ctx).Where("id = ?", lockID).First(&lock).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道流量阈值周期锁失败")
}
return &lock, nil
}
// loadCard 按 ID 读取卡事实;不存在返回 nil。
func (s *Service) loadCard(ctx context.Context, cardID uint) (*model.IotCard, error) {
if s == nil || s.db == nil || cardID == 0 {
return nil, nil
}
var card model.IotCard
err := s.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值卡事实失败")
}
return &card, nil
}
// markTaskOutcome 按 expected 状态集合条件更新把子任务推进到终态ENG-CONC-001
// 返回 false 表示记录已被并发推进或已处于终态,调用方必须按幂等处理,不再重复执行外部动作。
func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTask, expected []string, result, integrationID, failureReason string) (bool, error) {
if s == nil || s.db == nil || lockID == 0 || len(expected) == 0 {
return false, nil
}
updates := map[string]any{task.statusColumn: result}
if integrationID != "" {
updates[task.integrationColumn] = integrationID
}
if failureReason != "" {
updates["failure_reason"] = safeFailureReason(failureReason)
}
result_ := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND "+task.statusColumn+" IN ?", lockID, expected).
Updates(updates)
if result_.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result_.Error, "回填通道阈值子任务状态失败")
}
return result_.RowsAffected == 1, nil
}
// markTaskConfirmed 由恢复扫描在运营商状态确认成功后把子任务回填为已确认。
// expected 取未决集合 {submitted, unknown, failed}:失败与结果未知同样可能已在运营商侧生效,
// 必须允许收敛为已确认confirmed 不在集合内,因此确认只会写入一次。
func (s *Service) markTaskConfirmed(ctx context.Context, lockID uint, task lockTask, integrationID string) (bool, error) {
return s.markTaskOutcome(ctx, lockID, task, domain.UnresolvedTaskStatuses(), domain.TaskStatusConfirmed, integrationID, "")
}
// markAnomaly 把锁标记为需人工核对并退出自动扫描(使用服务自身连接池,调用方不得已持有该行锁)。
// 只应在窗口超期且结果无法确认时调用;已标记的锁不再重复查询。
func (s *Service) markAnomaly(ctx context.Context, lockID uint, reason string) (bool, error) {
if s == nil || s.db == nil {
return false, nil
}
return s.markAnomalyInTx(ctx, s.db, lockID, reason)
}
// markAnomalyInTx 在调用方事务内把锁标记为需人工核对anomaly_flag=0 → 1 条件更新)。
//
// 调用方已在同一事务内写过该行时 MUST 使用本方法:改用服务自身连接池会让另一条连接
// 等待本事务持有的行锁(自锁),表现为处理挂死到语句超时。返回 false 表示已被并发标记,
// 调用方按幂等处理。
func (s *Service) markAnomalyInTx(ctx context.Context, tx *gorm.DB, lockID uint, reason string) (bool, error) {
if tx == nil || lockID == 0 {
return false, nil
}
result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND anomaly_flag = ?", lockID, domain.AnomalyFlagNone).
Updates(map[string]any{
"anomaly_flag": domain.AnomalyFlagManual,
"failure_reason": safeFailureReason(reason),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记通道阈值锁异常失败")
}
return result.RowsAffected == 1, nil
}
// unlockInTx 在调用方事务内按 status=locked 条件更新认领解锁,返回 false 表示已被并发推进。
// 未满足复机条件时同事务写入可观察原因;锁行与历史任务结果一律保留,绝不删除。
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason string) (bool, error) {
if tx == nil {
return false, errors.New(errors.CodeInvalidStatus, "通道阈值解锁必须传入事务句柄")
}
updates := map[string]any{"status": domain.LockStatusUnlocked}
if failureReason != "" {
updates["failure_reason"] = safeFailureReason(failureReason)
}
result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND status = ?", lockID, domain.LockStatusLocked).
Updates(updates)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "解除通道流量阈值周期锁失败")
}
return result.RowsAffected == 1, nil
}
// lockCarrierIDs 收集锁行引用的运营商 ID 去重集合用于显式批量查询ENG-MODEL-001
func lockCarrierIDs(locks []model.CarrierTrafficThresholdLock) []uint {
seen := make(map[uint]struct{}, len(locks))
ids := make([]uint, 0, len(locks))
for index := range locks {
if _, ok := seen[locks[index].CarrierID]; ok {
continue
}
seen[locks[index].CarrierID] = struct{}{}
ids = append(ids, locks[index].CarrierID)
}
return ids
}

View File

@@ -0,0 +1,107 @@
// Package carrierthreshold 编排运营商通道流量阈值的达量判定与周期锁事实。
//
// 本包拥有 tb_carrier_traffic_threshold_lock 的全部读写:达量判定在流量观测事务内写入周期锁
// 与可靠停机事件,消费者以提交认领字段取得至多一次的外部调用权,周期处理与恢复扫描只查询锁行。
// 本包不调用任何运营商接口,也不在数据库事务内发起外部 I/O停复机执行通过 CardCommander
// 端口复用既有停复机单一事实源。
package carrierthreshold
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"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/errors"
)
// CardCommander 是通道阈值停复机对卡与运营商接口的能力边界。
//
// 实现必须复用既有停复机单一事实源重试、Integration Log、统一审计、卡状态观测序列
// 既有套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
type CardCommander interface {
// StopCardForThreshold 执行通道阈值达量停机,成功时写回卡停机状态与停因。
StopCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
// ResumeCardForThreshold 执行新周期复机,成功时写回卡在线状态(只清除通道阈值停因)。
ResumeCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
// ResumeReady 判断解锁后的卡是否满足自动复机条件;不满足时返回可安全记录的原因。
ResumeReady(ctx context.Context, cardID uint) (ready bool, reason string, err error)
// CardNetworkStatus 只查询运营商卡状态并映射为本地网络状态known 为 false 表示状态不可判定。
CardNetworkStatus(ctx context.Context, cardID uint) (status int, known bool, integrationID string, err error)
// ConfirmCardState 按已确认的运营商结果补写卡状态,覆盖 Gateway 成功但 DB 更新失败的场景。
ConfirmCardState(ctx context.Context, cardID uint, offline bool) error
}
// Service 执行通道阈值达量判定并维护周期锁事实。
type Service struct {
db *gorm.DB
// repository 是公共 Outbox 仓储达量停机事件与周期复机事件必须与锁事实在同一事务写入ENG-OUTBOX-001
repository *outbox.Repository
logger *zap.Logger
// commander 是停复机执行端口;未注入时消费者与 cron 拒绝执行外部调用。
commander CardCommander
now func() time.Time
}
// NewService 创建通道阈值用例repository 决定达量判定能否写出可靠停复机事件。
func NewService(db *gorm.DB, repository *outbox.Repository) *Service {
return &Service{db: db, repository: repository, logger: zap.NewNop(), now: time.Now}
}
// SetLogger 注入通道阈值运行日志。
func (s *Service) SetLogger(logger *zap.Logger) *Service {
if s == nil {
return s
}
if logger == nil {
s.logger = zap.NewNop()
return s
}
s.logger = logger
return s
}
// SetCommander 注入停复机执行端口(消费者与周期处理必需)。
func (s *Service) SetCommander(commander CardCommander) *Service {
if s == nil {
return s
}
s.commander = commander
return s
}
// ChannelThresholdLocked 判断该卡当前计费周期是否持有通道阈值停机锁。
//
// 供复机入口前置拒绝复用:持锁即拒绝,判定口径与锁生效口径完全一致(按锁行自身运营商的
// data_reset_day 判断周期归属),不另立一套判定。
func (s *Service) ChannelThresholdLocked(ctx context.Context, cardID uint) (bool, error) {
lock, err := s.ActiveLock(ctx, cardID, s.now())
if err != nil {
return false, err
}
return lock != nil, nil
}
// carrierResetDays 按运营商 ID 集合显式查询上游流量重置日ENG-MODEL-001不使用关联标签
// 返回结果只包含仍然存在的运营商,缺失 ID 由调用方按各自语义处理。
func (s *Service) carrierResetDays(ctx context.Context, carrierIDs []uint) (map[uint]int, error) {
resetDays := make(map[uint]int, len(carrierIDs))
if len(carrierIDs) == 0 {
return resetDays, nil
}
var carriers []model.Carrier
if err := s.db.WithContext(ctx).
Select("id", "data_reset_day").
Where("id IN ?", carrierIDs).
Find(&carriers).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商上游流量重置日失败")
}
for index := range carriers {
resetDays[carriers[index].ID] = carriers[index].DataResetDay
}
return resetDays, nil
}

View File

@@ -0,0 +1,157 @@
// 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 是公开扫码注册复用的短信验证码接缝。
// CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费,
// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。
type VerificationCodeVerifier interface {
CheckCode(ctx context.Context, phone string, code string) error
ConsumeCode(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,365 @@
package distributionwithdrawal
import (
"context"
stderrors "errors"
"slices"
"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 创建待审批注册记录。
// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。
// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码;
// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。
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, "密码哈希失败")
}
parent, err := s.findDistributionParent(ctx, normalized.DistributionCode)
if err != nil {
return nil, err
}
// 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码。
if err := s.verifier.CheckCode(ctx, normalized.Phone, code); err != nil {
return nil, verificationFailure(err)
}
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 {
// 关键字段门禁与注册记录插入必须同处一个串行化区间:先取 advisory lock
// 再在同一事务内校验并落库,避免并发提交落下两条指向同一手机号/用户名/店铺编号的待审批申请。
if err := lockRegistrationKeyScopes(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
return err
}
if err := ensureRegistrationKeysAvailable(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
return err
}
if err := ensureNoPendingRegistration(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
return err
}
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
}
// 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。
// 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。
_ = s.verifier.ConsumeCode(ctx, normalized.Phone, code)
return result, nil
}
// registrationKeyScopePrefix 是注册关键字段串行化点的键前缀,与其它用例的 advisory lock 键空间隔离。
const registrationKeyScopePrefix = "agent-distribution-registration:"
// lockRegistrationKeyScopes 在事务内为注册关键字段(手机号、用户名、店铺编号)取稳定串行化点。
// 目标关键字段的待审批记录可能尚不存在,行锁无法覆盖「首次并发提交」,
// 因此按 key 字符串升序取事务级 advisory lock升序保证并发提交不会形成 A→B / B→A 死锁环。
// 锁随本次事务提交或回滚自动释放。
func lockRegistrationKeyScopes(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
keys := []string{
registrationKeyScopePrefix + "phone:" + phone,
registrationKeyScopePrefix + "username:" + username,
registrationKeyScopePrefix + "shop_code:" + shopCode,
}
slices.Sort(keys)
for _, key := range keys {
if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定注册关键字段串行化点失败")
}
}
return nil
}
// ensureRegistrationKeysAvailable 校验注册关键字段未被既有账号或店铺占用。
// 手机号与用户名对应 tb_account 的条件唯一索引,店铺编号对应 tb_shop 的条件唯一索引;
// 查询沿用 GORM 默认软删除范围,软删除账号或店铺占用的关键字段可被重新注册。
func ensureRegistrationKeysAvailable(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "phone", phone); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册手机号失败")
} else if exists {
return errors.New(errors.CodePhoneExists, "手机号已被使用")
}
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "username", username); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册用户名失败")
} else if exists {
return errors.New(errors.CodeUsernameExists, "用户名已存在")
}
if exists, err := registrationKeyTaken(ctx, tx, &model.Shop{}, "shop_code", shopCode); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册店铺编号失败")
} else if exists {
return errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
}
return nil
}
// registrationKeyTaken 判断目标表(默认软删除范围)是否已存在占用该关键字段的记录。
func registrationKeyTaken(ctx context.Context, tx *gorm.DB, target any, column, value string) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(target).Where(column+" = ?", value).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// ensureNoPendingRegistration 校验关键字段没有正在等待审批的注册申请。
// 已通过或已驳回的终态记录不阻塞重新注册:资料填错后重新扫码必须能形成新的申请与新审批实例。
func ensureNoPendingRegistration(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
var pending model.AgentDistributionRegistration
err := tx.WithContext(ctx).
Where("status = ? AND (phone = ? OR username = ? OR shop_code = ?)",
constants.AgentDistributionRegistrationStatusPending, phone, username, shopCode).
Order("id ASC").First(&pending).Error
switch {
case err == nil:
return pendingKeyConflict(&pending, phone, username)
case stderrors.Is(err, gorm.ErrRecordNotFound):
return nil
default:
return errors.Wrap(errors.CodeDatabaseError, err, "校验待审批注册申请失败")
}
}
// pendingKeyConflict 把命中的待审批记录映射为指明冲突字段的冲突错误。
// 查询条件保证三个关键字段至少一个命中,店铺编号作为兜底分支。
func pendingKeyConflict(pending *model.AgentDistributionRegistration, phone, username string) error {
switch {
case pending.Phone == phone:
return errors.New(errors.CodeConflict, "该手机号已有待审批的注册申请,请等待审批结果")
case pending.Username == username:
return errors.New(errors.CodeConflict, "该用户名已有待审批的注册申请,请等待审批结果")
default:
return errors.New(errors.CodeConflict, "该店铺编号已有待审批的注册申请,请等待审批结果")
}
}
// findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。
// 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。
func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) {
var parent model.Shop
if err := s.db.WithContext(ctx).
Where("distribution_code = ?", distributionCode).
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码无效")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
}
if parent.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "分销码所属店铺已停用")
}
return &parent, nil
}
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
func verificationFailure(err error) error {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
}
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
// 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.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理")
}
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,287 @@
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 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
// 上级店铺必须仍然存在且启用;手机号、用户名或店铺编号已被既有账号/店铺占用时整体回滚,不留半套实体。
// 审批路径不取提交侧的关键字段 advisory lock提交侧的校验与插入同处一个串行化区间
// 且账号/店铺写入与注册记录状态推进同事务提交,因此提交侧只会看到「已提交的账号/店铺」或「仍待审批的冲突记录」,
// 两种情况都会拒绝。
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 级")
}
// 提交时的关键字段门禁可能已被此后的并发事实占用(平台手工建店、历史待审批记录):
// 此处复检把裸唯一索引错误换成可定位错误码,仍整体回滚,注册记录保持待审批。
if err := ensureRegistrationKeysAvailable(ctx, tx, registration.Phone, registration.Username, registration.ShopCode); err != nil {
return err
}
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,13 @@ 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,
constants.NotificationTypeAssetAutoRenewalFailed,
},
now,
)
}

View File

@@ -0,0 +1,299 @@
// Package packagetrafficalert 收口套餐真流量预警的规则维护事务脚本与每日扫描用例。
//
// 规则维护是简单写Handler → Application 事务脚本 → Persistence事实与审计同事务。
// 每日扫描是复杂写Application 编排 → Domain 判定 → Port/Infrastructure 原子写入预警事实、
// 可靠通知事件与审计;判定只使用套餐使用记录的真流量快照,不读取虚流量、展示量、卡级累计或通道累计。
package packagetrafficalert
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
"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"
)
// RuleService 套餐真流量预警规则维护事务脚本。
type RuleService struct {
db *gorm.DB
store *postgres.PackageTrafficAlertStore
auditWriter *audit.Writer
}
// NewRuleService 创建套餐真流量预警规则事务脚本。
func NewRuleService(db *gorm.DB, store *postgres.PackageTrafficAlertStore, auditWriters ...*audit.Writer) *RuleService {
service := &RuleService{db: db, store: store}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Create 为套餐商品创建唯一预警规则。
// 创建一律校验套餐存在且商品真流量额度大于零:商品 real_data_mb 只用于配置合法性,
// 不作为扫描分母(分母取使用记录的真总量快照)。
func (s *RuleService) Create(ctx context.Context, request *dto.CreatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
operatorID, err := requireOperator(ctx)
if err != nil {
return nil, err
}
if request == nil || request.PackageID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "套餐商品ID不能为空")
}
if !packagetrafficalert.IsValidThresholdPercent(request.ThresholdPercent) {
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100允许两位小数")
}
enabled := constants.StatusEnabled
if request.Enabled != nil && !*request.Enabled {
enabled = constants.StatusDisabled
}
rule := &model.PackageTrafficAlertRule{
PackageID: request.PackageID,
ThresholdPercent: packagetrafficalert.NormalizeThresholdPercent(request.ThresholdPercent),
Enabled: enabled,
Remark: request.Remark,
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
}
var response *dto.PackageTrafficAlertRuleItem
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
pkg, loadErr := s.loadPackage(ctx, tx, request.PackageID)
if loadErr != nil {
return loadErr
}
if pkg.RealDataMB <= 0 {
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
}
store := s.store.WithTx(tx)
exists, existsErr := store.ExistsRuleByPackageID(ctx, request.PackageID)
if existsErr != nil {
return errors.Wrap(errors.CodeDatabaseError, existsErr, "校验套餐预警规则失败")
}
if exists {
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
}
if createErr := store.CreateRule(ctx, rule); createErr != nil {
if isDuplicateKey(createErr) {
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
}
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐真流量预警规则失败")
}
item := toRuleItem(rule, pkg.PackageName, pkg.RealDataMB)
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleCreated,
"创建套餐真流量预警规则", rule, pkg.PackageName, nil, ruleAuditSnapshot(rule, pkg.PackageName)); auditErr != nil {
return auditErr
}
response = item
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
// Update 修改阈值、启停与备注。
// 修改不回填既有预警,也不改写已冻结的预警快照;结果状态为启用时同样校验商品真流量额度大于零。
func (s *RuleService) Update(ctx context.Context, ruleID uint, request *dto.UpdatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
operatorID, err := requireOperator(ctx)
if err != nil {
return nil, err
}
if request == nil || ruleID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "预警规则ID不能为空")
}
if request.ThresholdPercent != nil && !packagetrafficalert.IsValidThresholdPercent(*request.ThresholdPercent) {
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100允许两位小数")
}
var response *dto.PackageTrafficAlertRuleItem
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.store.WithTx(tx)
rule, lockErr := store.LockRuleByID(ctx, ruleID)
if lockErr != nil {
return ruleLookupError(lockErr)
}
before := ruleAuditSnapshot(rule, "")
if request.ThresholdPercent != nil {
rule.ThresholdPercent = packagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent)
}
if request.Enabled != nil {
rule.Enabled = constants.StatusEnabled
if !*request.Enabled {
rule.Enabled = constants.StatusDisabled
}
}
if request.Remark != nil {
rule.Remark = *request.Remark
}
pkg, pkgErr := s.loadPackage(ctx, tx, rule.PackageID)
if pkgErr != nil {
return pkgErr
}
if rule.Enabled == constants.StatusEnabled && pkg.RealDataMB <= 0 {
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
}
packageName := pkg.PackageName
if updateErr := store.UpdateRule(ctx, rule, operatorID); updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐真流量预警规则失败")
}
after := ruleAuditSnapshot(rule, packageName)
if before["enabled"] != after["enabled"] {
action, summary := constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则"
if rule.Enabled != constants.StatusEnabled {
action, summary = constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则"
}
if auditErr := s.appendRuleAudit(ctx, tx, action, summary, rule, packageName,
map[string]any{"enabled": before["enabled"]}, map[string]any{"enabled": after["enabled"]}); auditErr != nil {
return auditErr
}
}
if before["threshold_percent"] != after["threshold_percent"] || before["remark"] != after["remark"] {
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleUpdated,
"更新套餐真流量预警规则", rule, packageName, before, after); auditErr != nil {
return auditErr
}
}
response = toRuleItem(rule, packageName, pkg.RealDataMB)
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
// loadPackage 查询套餐商品;不存在时按参数错误返回。
func (s *RuleService) loadPackage(ctx context.Context, tx *gorm.DB, packageID uint) (*model.Package, error) {
var pkg model.Package
if err := tx.WithContext(ctx).Where("id = ?", packageID).First(&pkg).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "套餐商品不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐商品失败")
}
return &pkg, nil
}
// appendRuleAudit 在业务事务内追加预警规则事件。
func (s *RuleService) appendRuleAudit(ctx context.Context, tx *gorm.DB, action, summary string,
rule *model.PackageTrafficAlertRule, packageName string, before, after map[string]any) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "套餐真流量预警规则统一审计接缝未配置")
}
var resourceID *string
if rule.ID != 0 {
value := strconv.FormatUint(uint64(rule.ID), 10)
resourceID = &value
}
displayName := packageName
if displayName == "" {
displayName = "套餐 " + strconv.FormatUint(uint64(rule.PackageID), 10)
}
// 使用 AppendAndGet预警规则属于关键配置「要求成功必达」的审计失败必须回滚事务ENG-TX-001
if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10)},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourcePackageTrafficAlertRule, ID: resourceID,
Key: strconv.FormatUint(uint64(rule.PackageID), 10), DisplayName: displayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageTrafficAlertRuleTarget,
IdentitySnapshot: ruleAuditIdentity(rule, packageName), BeforeData: before, AfterData: after,
}},
}); err != nil {
return err
}
return nil
}
// requireOperator 要求调用方已通过后台鉴权,否则拒绝写入。
func requireOperator(ctx context.Context) (uint, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized)
}
return operatorID, nil
}
// ruleLookupError 把规则不存在映射为统一资源不可见错误。
func ruleLookupError(err error) error {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则失败")
}
// isDuplicateKey 判断数据库错误是否为唯一键冲突。
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
text := err.Error()
return strings.Contains(text, "23505") || strings.Contains(text, "duplicate key") || strings.Contains(text, "SQLSTATE 23505")
}
// ruleAuditSnapshot 返回预警规则可审计的可变字段快照。
func ruleAuditSnapshot(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
if rule == nil {
return nil
}
snapshot := map[string]any{
"package_id": rule.PackageID,
"threshold_percent": rule.ThresholdPercent,
"enabled": rule.Enabled,
"remark": rule.Remark,
}
if packageName != "" {
snapshot["package_name"] = packageName
}
return snapshot
}
// ruleAuditIdentity 返回预警规则审计身份快照,字段落在注册表白名单内。
func ruleAuditIdentity(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
if rule == nil {
return nil
}
identity := map[string]any{
"id": rule.ID, "package_id": rule.PackageID, "threshold_percent": rule.ThresholdPercent,
"enabled": rule.Enabled, "remark": rule.Remark,
}
if packageName != "" {
identity["package_name"] = packageName
}
return identity
}
// toRuleItem 把规则投影为对外响应项。
func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string, realDataMB int64) *dto.PackageTrafficAlertRuleItem {
if rule == nil {
return nil
}
return &dto.PackageTrafficAlertRuleItem{
ID: rule.ID,
PackageID: rule.PackageID,
PackageName: packageName,
RealDataMB: realDataMB,
ThresholdPercent: rule.ThresholdPercent,
Enabled: rule.Enabled == constants.StatusEnabled,
EnabledName: enabledName(rule.Enabled),
Remark: rule.Remark,
UpdatedAt: rule.UpdatedAt,
}
}
// enabledName 返回启停状态的中文名称。
func enabledName(enabled int) string {
if enabled == constants.StatusEnabled {
return "启用"
}
return "停用"
}

View File

@@ -0,0 +1,302 @@
package packagetrafficalert
import (
"context"
"fmt"
"sort"
"strconv"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AssetKey 是扫描的资产聚合键:卡按 iot_card_id、设备按 device_id二者互斥非零。
type AssetKey struct {
AssetType string
AssetID uint
}
// AssetAggregate 是同一资产全部当前有效套餐使用记录的真流量汇总。
// UsedMB 汇总真已用量LimitMB 汇总真总量快照;虚流量、展示量、卡级累计与通道累计一律不参与。
type AssetAggregate struct {
Key AssetKey
UsedMB int64
LimitMB int64
}
// MainUsage 是资产的主套餐使用记录(阈值来源与预警锚点)。
type MainUsage struct {
PackageUsageID uint
PackageID uint
PackageName string
ExpiresAt *time.Time
}
// EnabledRule 是主套餐对应的当前启用预警规则。
type EnabledRule struct {
ID uint
PackageID uint
ThresholdPercent float64
}
// AssetFacts 是触发时必须冻结的资产与归属展示事实。
// 资产标识、卡标识、对端标识、设备类型与型号取自触发时的卡与设备绑定;
// 归属只包含触发时店铺与「仅业务员」解析出的有效平台业务员。
type AssetFacts struct {
AssetIdentifier string
CardIdentifier string
CounterpartIdentifier string
DeviceType string
DeviceModel string
ShopID uint
ShopName string
BusinessOwnerID *uint
BusinessOwnerName string
}
// ScanReader 读取扫描所需的只读事实。
type ScanReader interface {
// LoadAssetAggregates 按资产汇总当前有效套餐的真已用量与真总量快照。
LoadAssetAggregates(ctx context.Context) ([]AssetAggregate, error)
// LoadMainUsages 批量读取每个资产的主套餐使用记录master_usage_id 为空,按优先级/生效时间/编号取第一条)。
LoadMainUsages(ctx context.Context, keys []AssetKey) (map[AssetKey]MainUsage, error)
// LoadEnabledRules 批量读取套餐商品当前启用的预警规则。
LoadEnabledRules(ctx context.Context, packageIDs []uint) (map[uint]EnabledRule, error)
// LoadAssetFacts 批量读取资产展示事实与触发时归属。
LoadAssetFacts(ctx context.Context, keys []AssetKey) (map[AssetKey]AssetFacts, error)
}
// AlertCandidate 是一次命中要原子落库的完整事实。
type AlertCandidate struct {
Alert model.PackageTrafficAlert
// Notification 为空表示触发时店铺无有效业务员或到期时间不可推算,只保存预警不写通知事件。
Notification *NotificationRequest
}
// NotificationRequest 是一次可靠通知事件的最小输入。
// 接收人是触发时冻结的业务员账号,投递期不再重新解析店铺业务员,避免向未来业务员补发。
type NotificationRequest struct {
RecipientAccountID uint
ShopID uint
TemplateData map[string]string
ExpiresAt time.Time
}
// AlertWriter 在同一事务内写入预警事实、可靠通知事件与审计。
type AlertWriter interface {
// SaveAlert 幂等创建预警;返回 false 表示唯一键冲突(视为已处理,不写事件与审计)。
SaveAlert(ctx context.Context, candidate AlertCandidate) (bool, error)
}
// ScanService 执行每日套餐真流量达量扫描。
type ScanService struct {
reader ScanReader
writer AlertWriter
logger *zap.Logger
// now 可在验证时替换,默认使用系统时间。
now func() time.Time
}
// NewScanService 创建套餐真流量达量扫描用例。
func NewScanService(reader ScanReader, writer AlertWriter, logger *zap.Logger) *ScanService {
return &ScanService{reader: reader, writer: writer, logger: logger, now: func() time.Time { return time.Now().UTC() }}
}
// ScanResult 汇总一次扫描的可观察结果。
type ScanResult struct {
Assets int
Hits int
Created int
Duplicates int
Skipped int
}
// Run 执行一次可重跑扫描:按资产汇总真流量,按主套餐规则阈值判定,命中即原子落库。
func (s *ScanService) Run(ctx context.Context) error {
if s == nil || s.reader == nil || s.writer == nil {
return errors.New(errors.CodeInternalError, "套餐真流量达量扫描用例未配置")
}
aggregates, err := s.reader.LoadAssetAggregates(ctx)
if err != nil {
return err
}
result := &ScanResult{Assets: len(aggregates)}
if len(aggregates) == 0 {
s.logScan(result)
return nil
}
keys := make([]AssetKey, 0, len(aggregates))
for _, aggregate := range aggregates {
keys = append(keys, aggregate.Key)
}
mainUsages, err := s.reader.LoadMainUsages(ctx, keys)
if err != nil {
return err
}
rules, err := s.loadRulesForUsages(ctx, mainUsages)
if err != nil {
return err
}
facts, err := s.reader.LoadAssetFacts(ctx, keys)
if err != nil {
return err
}
triggeredAt := s.now()
for _, aggregate := range aggregates {
main, hasMain := mainUsages[aggregate.Key]
if !hasMain {
// 全是加油包、没有主套餐的资产没有阈值来源,直接跳过。
result.Skipped++
continue
}
rule, hasRule := rules[main.PackageID]
if !hasRule {
result.Skipped++
continue
}
if aggregate.LimitMB <= 0 {
// 汇总分母不是正数的资产不可判定,跳过而不是写入不可用的预警。
result.Skipped++
continue
}
thresholdBasisPoints := packagetrafficalert.ThresholdBasisPoints(rule.ThresholdPercent)
hit, ratioBasisPoints := packagetrafficalert.Decide(aggregate.UsedMB, aggregate.LimitMB, thresholdBasisPoints)
if !hit {
result.Skipped++
continue
}
result.Hits++
candidate := s.buildCandidate(aggregate, main, rule, ratioBasisPoints, facts[aggregate.Key], triggeredAt)
created, saveErr := s.writer.SaveAlert(ctx, candidate)
if saveErr != nil {
s.logger.Error("套餐真流量达量预警写入失败",
zap.String("asset_type", aggregate.Key.AssetType),
zap.Uint("asset_id", aggregate.Key.AssetID),
zap.Error(saveErr))
return saveErr
}
if created {
result.Created++
} else {
result.Duplicates++
}
}
s.logScan(result)
return nil
}
// loadRulesForUsages 批量读取主套餐对应的启用规则。
func (s *ScanService) loadRulesForUsages(ctx context.Context, usages map[AssetKey]MainUsage) (map[uint]EnabledRule, error) {
seen := make(map[uint]struct{}, len(usages))
packageIDs := make([]uint, 0, len(usages))
for _, usage := range usages {
if usage.PackageID == 0 {
continue
}
if _, ok := seen[usage.PackageID]; ok {
continue
}
seen[usage.PackageID] = struct{}{}
packageIDs = append(packageIDs, usage.PackageID)
}
if len(packageIDs) == 0 {
return map[uint]EnabledRule{}, nil
}
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
return s.reader.LoadEnabledRules(ctx, packageIDs)
}
// buildCandidate 组装唯一的资产级预警事实与可选通知请求。
func (s *ScanService) buildCandidate(aggregate AssetAggregate, main MainUsage, rule EnabledRule,
ratioBasisPoints int64, facts AssetFacts, triggeredAt time.Time) AlertCandidate {
packageName := main.PackageName
if packageName == "" {
packageName = "套餐#" + strconv.FormatUint(uint64(main.PackageID), 10)
}
assetIdentifier := facts.AssetIdentifier
if assetIdentifier == "" {
// 回落值同步写入快照,保证快照、列表与通知正文一致。
assetIdentifier = "资产#" + strconv.FormatUint(uint64(aggregate.Key.AssetID), 10)
}
alert := model.PackageTrafficAlert{
PackageUsageID: main.PackageUsageID,
PackageID: main.PackageID,
RuleID: rule.ID,
AssetType: aggregate.Key.AssetType,
AssetID: aggregate.Key.AssetID,
AssetIdentifierSnapshot: assetIdentifier,
CardIdentifierSnapshot: facts.CardIdentifier,
CounterpartIdentifierSnapshot: facts.CounterpartIdentifier,
DeviceTypeSnapshot: facts.DeviceType,
DeviceModelSnapshot: facts.DeviceModel,
PackageNameSnapshot: packageName,
UsedMBSnapshot: aggregate.UsedMB,
LimitMBSnapshot: aggregate.LimitMB,
UsagePercentSnapshot: packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints),
ThresholdPercentSnapshot: packagetrafficalert.NormalizeThresholdPercent(rule.ThresholdPercent),
ExpiresAtSnapshot: main.ExpiresAt,
TriggeredAt: triggeredAt,
ShopIDSnapshot: facts.ShopID,
ShopNameSnapshot: facts.ShopName,
BusinessOwnerAccountIDSnapshot: facts.BusinessOwnerID,
BusinessOwnerNameSnapshot: facts.BusinessOwnerName,
}
candidate := AlertCandidate{Alert: alert}
if facts.BusinessOwnerID == nil || *facts.BusinessOwnerID == 0 {
// 无有效业务员:只保存预警,不写通知事件,也不在未来补发。
return candidate
}
candidate.Notification = &NotificationRequest{
RecipientAccountID: *facts.BusinessOwnerID,
ShopID: facts.ShopID,
ExpiresAt: notificationExpiresAt(main.ExpiresAt, triggeredAt),
TemplateData: map[string]string{
"asset_identifier": assetIdentifier,
"package_name": packageName,
"usage_percent": formatPercent(packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints)),
"threshold_percent": formatPercent(alert.ThresholdPercentSnapshot),
},
}
return candidate
}
// notificationExpiresAt 计算站内通知的展示期结束时间。
// 优先使用主套餐到期时间快照;快照为空时沿用既有默认展示期常量兜底,
// 预警行的到期时间快照保持为空,不伪造业务到期时间。
func notificationExpiresAt(snapshot *time.Time, triggeredAt time.Time) time.Time {
if snapshot != nil {
return snapshot.UTC()
}
return triggeredAt.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays).UTC()
}
// formatPercent 把百分比格式化为最多两位小数、去掉无意义尾零的展示文本。
func formatPercent(value float64) string {
return strconv.FormatFloat(packagetrafficalert.NormalizeThresholdPercent(value), 'f', -1, 64)
}
// logScan 输出一次扫描的结构化结果,供维护者按日志核对。
func (s *ScanService) logScan(result *ScanResult) {
if s.logger == nil {
return
}
s.logger.Info("套餐真流量达量扫描完成",
zap.Int("assets", result.Assets),
zap.Int("hits", result.Hits),
zap.Int("created", result.Created),
zap.Int("duplicates", result.Duplicates),
zap.Int("skipped", result.Skipped))
}
// EventIDFor 返回预警通知事件的稳定ID内嵌主套餐使用记录与阈值快照万分比
func EventIDFor(packageUsageID uint, thresholdPercent float64) string {
return fmt.Sprintf("%s:%d:%d", constants.PackageTrafficAlertEventIDPrefix, packageUsageID,
packagetrafficalert.ThresholdBasisPoints(thresholdPercent))
}

View File

@@ -0,0 +1,62 @@
// Package prioritypolling 定义卡轮询优先队列的应用层契约。
//
// 本包只承载业务事实的形状与执行语义,不依赖 Fiber、GORM、Redis、Asynq 或具体外部 SDK
// Outbox 写入、消费者展开与执行接缝适配分别位于基础设施与任务层。
package prioritypolling
import (
"context"
"time"
"gorm.io/gorm"
)
// PriorityRequestedEvent 是业务成功边界可靠请求「为该资源的卡建立优先轮询项」的事实。
//
// 载荷沿用既有业务观测事件的形状(资源类型 / 资源 ID / 资源 ID 列表),并在业务事务内冻结卡快照:
// 独立卡为自身ResourceIDs 为空),绑定设备的资产为绑定状态有效的全部在用卡(写入 ResourceIDs
// TriggerType 取值与 pkg/constants.PollingPriorityTrigger* 一致;本轮要逐类型建项的任务类型集合
// 由代码常量给出pkg/constants.PollingPriorityTaskTypes不由事件载荷决定避免同一决策出现两个权威。
// 来源订单与来源套餐使用记录只保存 ID由消费者与读侧显式查询。
type PriorityRequestedEvent struct {
EventID string `json:"event_id"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
ResourceIDs []uint `json:"resource_ids,omitempty"`
TriggerType string `json:"trigger_type"`
SourceOrderID uint `json:"source_order_id,omitempty"`
SourcePackageUsageID uint `json:"source_package_usage_id,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// PromptPublisher 把优先执行提示下发到既有轮询提示通道。
//
// 提示通道不是权威:丢失或缓存服务重启时库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行,
// 只退化为延迟一个普通轮询周期;调用方不得以提示的下发成功作为入队事实成立的条件。
type PromptPublisher interface {
EnqueuePriority(ctx context.Context, cardID uint, taskType string) error
}
// PriorityEventWriter 在原业务事务中追加优先轮询请求事件。
type PriorityEventWriter interface {
AppendPriorityRequested(ctx context.Context, tx *gorm.DB, event PriorityRequestedEvent) error
}
type pollingPriorityTriggerKey struct{}
// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。
//
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、
// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求,
// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。
func WithPollingPriorityTrigger(ctx context.Context) context.Context {
return context.WithValue(ctx, pollingPriorityTriggerKey{}, true)
}
// IsPollingPriorityTrigger 判断当前业务评估是否来自普通套餐轮询。
func IsPollingPriorityTrigger(ctx context.Context) bool {
value, _ := ctx.Value(pollingPriorityTriggerKey{}).(bool)
return value
}

View File

@@ -4,9 +4,12 @@ package refundapproval
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -17,10 +20,15 @@ import (
)
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
//
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号。
type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空。
ChannelRefundRequestNo string
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
@@ -29,6 +37,12 @@ type ApplicationAudit struct {
Order *model.Order
Approval *model.ApprovalInstance
Submitter *model.Account
// Attempt 非空时表示本次写入新增了一条审批尝试记录。
Attempt *model.RefundRequestAttempt
// Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定,
// 使同一次重提的审计事件在该尝试上保持幂等。
Action string
EventID string
}
// AuditWriter 接收退款申请事务内审计事实。
@@ -39,11 +53,16 @@ type AuditWriter interface {
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
SubmitterName string
ApprovalStatus int
}
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。
//
// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识,
// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示,
// 其既有 approval_instance_id 语义与唯一约束保持不变。
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
@@ -55,8 +74,8 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
return &CreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
@@ -91,12 +110,8 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account)
if err != nil {
return nil, err
}
var approvalStatus int
var result *CreateResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, refundID).Error; err != nil {
@@ -113,47 +128,66 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err := tx.WithContext(ctx).First(&currentOrder, current.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder, "")
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
return err
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: current.ID, SubmitterAccountID: current.Creator,
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := attachRefundFirstInstance(ctx, tx, &current, reference.InstanceID); err != nil {
return err
}
current.ApprovalInstanceID = &reference.InstanceID
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
refund = current
order = currentOrder
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account,
})
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account, Attempt: attempt,
}); err != nil {
return err
}
result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
return nil
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return result, nil
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
if command.Refund == nil || command.Order == nil ||
command.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)
}
@@ -173,54 +207,214 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
return nil, err
}
var approvalStatus int
var attempt *model.RefundRequestAttempt
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
}
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, "创建退款申请失败")
}
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo)
if err != nil {
return err
}
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: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
BusinessID: 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, attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := attachRefundFirstInstance(ctx, tx, command.Refund, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, command.Refund, attempt, reference.InstanceID); err != nil {
return err
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
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: attempt,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return &CreateResult{Refund: command.Refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
// ResubmitCommand 描述重提时的材料变更。
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收);
// 本次新增的不可变审批尝试记录由本用例在同一事务内构造。
type ResubmitCommand struct {
Refund *model.RefundRequest
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
ChannelRefundRequestNo string
}
// 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.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, command.ChannelRefundRequestNo)
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: current.Creator,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
return err
}
// 同一事务内回写材料、回到待审批并创建新的审批实例。
updates := map[string]any{
"status": model.RefundStatusPending,
"method": current.Method,
"requested_refund_amount": current.RequestedRefundAmount,
"frozen_actual_received_amount": current.FrozenActualReceivedAmount,
"refund_reason": current.RefundReason,
"refund_voucher_key": current.RefundVoucherKey,
"customer_account_info": current.CustomerAccountInfo,
"failure_reason": "",
"failure_message": "",
"channel_refund_status": constants.RefundChannelStatusNone,
"reject_reason": "",
"processor_id": nil,
"processed_at": nil,
"updater": current.Creator,
"updated_at": time.Now().UTC(),
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态已变化")
}
current.Status = model.RefundStatusPending
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt,
Action: constants.AuditActionRefundResubmitted,
EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10),
}); err != nil {
return err
}
created = &CreateResult{Refund: &current, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。
func isResubmittable(refund *model.RefundRequest) bool {
if refund == nil || refund.AnomalyFlag != 0 {
return false
}
for _, status := range model.RefundResubmittableStatuses() {
if refund.Status == status {
return true
}
}
return false
}
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
@@ -234,6 +428,123 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
return &account, nil
}
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, channelRefundRequestNo string) (*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,
ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo),
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
}
// attachRefundFirstInstance 把审批实例回写到退款申请的首次接入引用。
// 既有 approval_instance_id 保持「首次接入企业微信审批的实例」语义:条件更新在引用为空时才写入,
// 因此重提只新增尝试引用,不会改写首次接入事实,也不会破坏其部分唯一索引。
func attachRefundFirstInstance(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", refund.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, "退款申请首次审批实例已变化")
}
refund.ApprovalInstanceID = &instanceID
return nil
}
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
// 既有 approval_instance_id 由 attachRefundFirstInstance 单独回写,保持「首次接入企业微信审批的实例」语义不变。
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID,
"latest_approval_instance_id": instanceID,
"updated_at": time.Now().UTC(),
}
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", refund.ID).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新退款申请最新审批引用失败")
}
refund.LatestAttemptID = attempt.ID
refund.LatestApprovalInstanceID = instanceID
return nil
}
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
@@ -247,7 +558,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,16 +20,23 @@ 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"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
@@ -56,7 +68,9 @@ 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)
pollingPriorityItemStore := postgres.NewPollingPriorityItemStore(deps.DB)
rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis)
paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis)
commissionRecordStore := postgres.NewCommissionRecordStore(deps.DB, deps.Redis)
@@ -86,7 +100,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
svc.WechatConfig,
svc.Order,
packageSeriesStore,
shopSeriesAllocationStore,
@@ -108,9 +121,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 +171,25 @@ 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)
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
@@ -173,7 +209,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 +228,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 +276,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),
@@ -227,6 +286,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
@@ -260,6 +321,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
PollingPriority: admin.NewPriorityPollingHandler(
svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate,
),
Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
@@ -276,6 +340,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
h.SetPackageExpiryQuery(packageExpiry)
h.SetPackageExpiryQueue(deps.QueueClient)
h.SetExportTaskService(svc.ExportTask)
h.SetValidator(validate)
return h
}(),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
@@ -284,11 +350,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

@@ -7,9 +7,16 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
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"
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
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"
@@ -18,9 +25,11 @@ import (
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/internal/polling"
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
@@ -65,8 +74,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 +98,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
@@ -105,6 +119,8 @@ type services struct {
Package *packageSvc.Service
PackageDailyRecord *packageSvc.DailyRecordService
PackageCustomerView *packageSvc.CustomerViewService
PackageTrafficAlertRule *packagetrafficalertapp.RuleService
AssetAutoRenewal *assetAutoRenewalApp.Service
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
ShopSeriesGrant *shopSeriesGrantSvc.Service
@@ -119,6 +135,7 @@ type services struct {
PollingAlert *pollingSvc.AlertService
PollingCleanup *pollingSvc.CleanupService
PollingManualTrigger *pollingSvc.ManualTriggerService
PriorityPolling *pollingSvc.PriorityEnqueueService
Asset *assetSvc.Service
AssetLifecycle *assetSvc.LifecycleService
AssetWallet *assetWalletSvc.Service
@@ -127,6 +144,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 +153,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
@@ -170,12 +190,17 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetAccessAudit(auditWriter)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationService.SetStateAuditWriter(iotCard)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
@@ -218,6 +243,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
packageActivation.SetPriorityEventWriter(priorityEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis,
@@ -229,12 +255,16 @@ func initServices(s *stores, deps *Dependencies) *services {
)
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
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 +283,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,9 +295,14 @@ 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)
orderService.SetPriorityEventWriter(priorityEvents)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
@@ -286,6 +322,7 @@ func initServices(s *stores, deps *Dependencies) *services {
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
@@ -296,6 +333,11 @@ func initServices(s *stores, deps *Dependencies) *services {
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
auditWriter,
)
// 付款凭证识别需要对象存储与 Gateway任一缺失时不装配接口统一返回能力未配置。
var agentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
if deps.StorageService != nil && deps.GatewayClient != nil {
agentRechargeVoucherOCR = agentrechargeApp.NewPaymentVoucherOCRService(deps.StorageService.Provider(), deps.GatewayClient)
}
refundService := refundSvc.New(
deps.DB,
s.RefundRequest,
@@ -313,11 +355,23 @@ 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)
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
@@ -348,6 +402,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 +417,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,
@@ -387,6 +457,12 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
// 人工优先入队:与人工触发共用同包权限判定,但使用独立的优先提示通道与合并语义。
priorityPollingService := pollingSvc.NewPriorityEnqueueService(
deps.DB, s.IotCard, s.PollingPriorityItem, pollingQueueMgr, deps.Logger,
)
priorityPollingService.SetAudit(auditWriter)
return &services{
AccessAudit: auditWriter,
Approval: approvalCreationService,
@@ -400,6 +476,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PersonalCustomerOpenID,
s.PersonalCustomer,
s.PersonalCustomerPhone,
s.PhoneAssetAssociation,
s.IotCard,
s.Device,
wechatConfig,
@@ -413,6 +490,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,
@@ -431,6 +511,13 @@ func initServices(s *stores, deps *Dependencies) *services {
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter),
AssetAutoRenewal: assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
Outbox: outbox.NewRepository(), AuditWriter: auditWriter,
PurchaseValidation: purchaseValidation,
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
}),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),
@@ -445,6 +532,7 @@ func initServices(s *stores, deps *Dependencies) *services {
PollingAlert: pollingAlertService,
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingManualTriggerService,
PriorityPolling: priorityPollingService,
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
@@ -453,6 +541,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 +550,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
@@ -51,6 +52,7 @@ type stores struct {
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
PollingManualTriggerLog *postgres.PollingManualTriggerLogStore
PollingPriorityItem *postgres.PollingPriorityItemStore
// 代理钱包系统
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
@@ -69,8 +71,15 @@ type stores struct {
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
// 资产套餐批量订购任务
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
// 业务用户组与成员归属
BusinessUserGroup *postgres.BusinessUserGroupStore
// 店铺负责人 CSV 导入任务
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
// 流量系统
CardDailyUsage *postgres.CardDailyUsageStore
// 套餐真流量预警规则与达量预警事实
PackageTrafficAlert *postgres.PackageTrafficAlertStore
// 资产标识符注册表
AssetIdentifier *postgres.AssetIdentifierStore
}
@@ -89,6 +98,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),
@@ -123,20 +133,25 @@ func initStores(deps *Dependencies) *stores {
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB),
PollingPriorityItem: postgres.NewPollingPriorityItemStore(deps.DB),
// 代理钱包系统
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
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),
PackageTrafficAlert: postgres.NewPackageTrafficAlertStore(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
@@ -64,14 +68,22 @@ type Handlers struct {
PollingAlert *admin.PollingAlertHandler
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
PollingPriority *admin.PriorityPollingHandler
Asset *admin.AssetHandler
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
PackageTrafficAlert *admin.PackageTrafficAlertHandler
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler

View File

@@ -1,13 +1,19 @@
package bootstrap
import (
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
@@ -95,6 +101,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
@@ -106,7 +115,10 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.GatewayClient, deps.Logger,
)
iotCardAuditService.SetAccessAudit(auditWriter)
iotCardAuditService.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
cardObservationService.SetStateAuditWriter(iotCardAuditService)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
@@ -149,6 +161,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
orderService.SetPriorityEventWriter(priorityEvents)
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -160,8 +173,24 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.Logger,
)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))
// 资产钱包自动续费:复机执行同样复用既有停复机单一事实源,通知与复机都走公共 Outbox。
assetAutoRenewalService := assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
Outbox: cardObservationOutbox, AuditWriter: auditWriter,
PurchaseValidation: purchaseValidation,
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
Resume: assetAutoRenewalInfra.NewCardCommander(stopResumeService),
ObservationEvents: observationSeriesEvents,
PriorityEvents: priorityEvents,
})
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
activationService.SetPriorityEventWriter(priorityEvents)
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
orderService.SetResumeCallback(stopResumeService)
@@ -171,6 +200,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,
@@ -178,6 +208,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
ObservationSeriesEvents: observationSeriesEvents,
PriorityEvents: priorityEvents,
CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService,
UsageService: usageService,
@@ -186,6 +217,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
AlertService: alertService,
CleanupService: cleanupService,
StopResumeService: stopResumeService,
CarrierThreshold: carrierThresholdService,
AssetAutoRenewal: assetAutoRenewalService,
OrderExpirer: orderService,
AssetPackageOrderCreator: orderService,
DeviceBatchAllocator: deviceBatchAllocator,

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

@@ -24,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 校验支付确认不变量,并返回是否属于完全一致的重复确认。
@@ -60,7 +63,20 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
if facts.FrozenMerchant {
frozenMethod := strings.TrimSpace(facts.FrozenMerchantPaymentMethod)
providerType := strings.TrimSpace(facts.FrozenMerchantProviderType)
if identity == "" || frozenMethod != method || providerType == "" {
return false, errors.New(errors.CodeConflict, "冻结商户支付事实不一致")
}
expectedChannel := method
if method == constants.RechargeMethodWechat && providerType == model.ProviderTypeFuiou {
expectedChannel = model.ProviderTypeFuiou
}
if channel != expectedChannel {
return false, errors.New(errors.CodeConflict, "支付渠道与冻结商户不一致")
}
} else if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")

View File

@@ -0,0 +1,45 @@
package carrierthreshold
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// SubmissionQueryWindow 是停/复机子任务自提交起允许自动查询确认的窗口。
//
// 语义:子任务进入 submitted 后,恢复扫描只查询运营商状态回填;超过该窗口仍无法确认结果的
// 锁会标记异常并转人工处理,不再每分钟重复查询同一笔无法收敛的结果,也不自动删除锁。
const SubmissionQueryWindow = 30 * time.Minute
// SubmissionExpired 判断子任务自提交起是否已超过自动查询窗口。
// 未提交submittedAt 为空)不算超期。
func SubmissionExpired(submittedAt *time.Time, now time.Time) bool {
if submittedAt == nil {
return false
}
return now.Sub(*submittedAt) >= SubmissionQueryWindow
}
// CommandOutcome 是一次运营商停复机调用可安全记录的结果摘要。
//
// 它只承载回填可靠任务状态所需的事实Integration Log 标识、结果分类与可安全展示的原因,
// 不携带渠道报文原文、凭证或内部错误细节。
type CommandOutcome struct {
// IntegrationID 是本次 Gateway 调用的 Integration Log 标识,失败重试时取最后一次尝试。
IntegrationID string
// Result 取值 constants.AuditResultSuccess / AuditResultFailed / AuditResultUnknown。
Result string
// SafeReason 是可安全对外展示的失败原因,成功时为空。
SafeReason string
}
// Confirmed 判断本次运营商调用是否已明确成功。
func (o CommandOutcome) Confirmed() bool {
return o.Result == constants.AuditResultSuccess
}
// Unresolved 判断本次运营商调用结果是否未知(必须由恢复扫描查询收敛)。
func (o CommandOutcome) Unresolved() bool {
return o.Result == constants.AuditResultUnknown
}

View File

@@ -0,0 +1,18 @@
package carrierthreshold
import "time"
// Evaluation 是一次通道阈值达量判定所需的卡与读数事实。
//
// 读数只来自运营商回传的网关累计读数IoT 卡的 last_gateway_reading_mb
// 不使用本地用量统计、当月用量或套餐真流量ObservedAt 决定该读数所属的计费周期。
type Evaluation struct {
// CardID 是触发判定的物联网卡 ID。
CardID uint
// CarrierID 是卡当前所属运营商 ID决定阈值配置与周期起点。
CarrierID uint
// ReadingMB 是本次已接受的运营商网关累计读数。
ReadingMB float64
// ObservedAt 是本次读数的观测时刻。
ObservedAt time.Time
}

View File

@@ -0,0 +1,50 @@
package carrierthreshold
// 周期锁整行生命周期取值,与 tb_carrier_traffic_threshold_lock.status 的 CHECK 一致。
const (
// LockStatusLocked 表示该卡在该计费周期内持有通道阈值停机锁,周期内拒绝一切复机。
LockStatusLocked = "locked"
// LockStatusUnlocked 表示已跨期解除通道阈值锁,锁行保留为历史事实。
LockStatusUnlocked = "unlocked"
)
// 停复机子任务状态取值,与 stop_status / resume_status 的 CHECK 一致。
const (
// TaskStatusPending 表示子任务待提交,达量判定写锁时停机子任务处于该状态。
TaskStatusPending = "pending"
// TaskStatusSubmitted 表示子任务已提交待确认,认领成功后处于该状态。
TaskStatusSubmitted = "submitted"
// TaskStatusConfirmed 表示运营商调用或状态查询已明确成功。
TaskStatusConfirmed = "confirmed"
// TaskStatusFailed 表示运营商明确失败。
TaskStatusFailed = "failed"
// TaskStatusUnknown 表示结果未知,交由恢复扫描查询收敛。
TaskStatusUnknown = "unknown"
)
// 异常标记取值,与 anomaly_flag 的 CHECK 一致。
const (
// AnomalyFlagNone 表示无需人工核对。
AnomalyFlagNone = 0
// AnomalyFlagManual 表示查询窗口超期或失败无法自动确认,已转人工核对并退出自动扫描。
AnomalyFlagManual = 1
)
// UnresolvedTaskStatuses 返回仍需由恢复扫描查询运营商状态收敛的子任务状态集合。
//
// 语义submitted 表示已提交待确认unknown 表示结果未知failed 表示调用明确失败但运营商侧
// 状态仍可能已生效(例如请求已到达而响应超时/异常)——三者都必须继续用只读状态查询确认,
// 因此恢复扫描的查询谓词与超期判定共用本集合,避免「失败或结果未知」的锁行退出收敛链路。
// pending 表示尚未对运营商发起过调用confirmed 是终态,都不属于本集合。
func UnresolvedTaskStatuses() []string {
return []string{TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed}
}
// IsUnresolvedTaskStatus 判断子任务状态是否仍需恢复扫描收敛。
func IsUnresolvedTaskStatus(status string) bool {
switch status {
case TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed:
return true
}
return false
}

View File

@@ -0,0 +1,29 @@
package carrierthreshold
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// shanghaiLocation 是计费周期边界的固定时区。
// 中国自 1991 年起不实行夏令时,使用固定偏移可避免依赖宿主机 tzdata。
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// PeriodStart 计算 now 所属计费周期的起点(上海时区零点)。
//
// 周期起点 = 本月重置日 0 点,若 now 早于本月重置日 0 点则取上月重置日 0 点。
// resetDay 由该卡所属运营商(锁行自身的 carrier的 data_reset_day 提供,合法范围 1-28
// 因此每个月都有定义。该口径与 isTrafficResetWindow判断读数回落是否为合法清零的观测窗口
// 是两个独立口径,互不修改。
func PeriodStart(now time.Time, resetDay int) (time.Time, error) {
if resetDay < MinResetDay || resetDay > MaxResetDay {
return time.Time{}, errors.New(errors.CodeInvalidParam, "运营商上游流量重置日必须在 1-28 之间")
}
local := now.In(shanghaiLocation)
current := time.Date(local.Year(), local.Month(), resetDay, 0, 0, 0, 0, shanghaiLocation)
if !local.Before(current) {
return current, nil
}
return current.AddDate(0, -1, 0), nil
}

View File

@@ -0,0 +1,69 @@
// Package carrierthreshold 定义运营商通道流量阈值的领域规则。
//
// 本包只表达与传输、持久化无关的纯规则:阈值单位与 GB→MB 换算、按上游流量重置日计算的
// 计费周期起点、以及周期锁与停复机子任务的状态取值。运营商通道即既有 Carrier
// 计费周期与网关计数器清零周期是同一事实,因此周期起点只由 carrier.data_reset_day 决定。
package carrierthreshold
import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 阈值单位枚举:只允许 MB 与 GB换算后统一以 MB 与网关累计读数比较。
const (
UnitMB = "MB"
UnitGB = "GB"
)
// MBPerGB 是 GB→MB 的固定换算系数1 GB = 1024 MB
const MBPerGB = 1024
// 计费周期起点的合法重置日范围1-28 保证每个月都有定义,不存在 2 月 30 日问题。
const (
MinResetDay = 1
MaxResetDay = 28
)
// Threshold 是通道阈值配置的领域值。
type Threshold struct {
// Enabled 为 true 表示该通道参与达量停机判定。
Enabled bool
// Value 是阈值数值,启用时必须为正数。
Value float64
// Unit 是阈值单位,取值 UnitMB 或 UnitGB。
Unit string
}
// Valid 判断阈值配置是否为可用于判定的完整配置。
// 未启用、数值非正、单位未知都视为不可判定;不可判定必须跳过判定而不是按 0 停机。
func (t Threshold) Valid() bool {
if !t.Enabled || t.Value <= 0 {
return false
}
return t.Unit == UnitMB || t.Unit == UnitGB
}
// LimitMB 返回换算为 MB 的阈值上限;单位未知返回稳定参数错误。
func (t Threshold) LimitMB() (float64, error) {
switch t.Unit {
case UnitMB:
return t.Value, nil
case UnitGB:
return t.Value * MBPerGB, nil
default:
return 0, errors.New(errors.CodeInvalidParam, "通道流量阈值单位仅支持 MB 与 GB")
}
}
// Reached 判断运营商回传的当前周期累计读数是否达到或超过阈值。
// readingMB 只来自网关累计读数last_gateway_reading_mb不使用本地用量或套餐真流量。
func (t Threshold) Reached(readingMB float64) (bool, error) {
if !t.Valid() {
return false, nil
}
limitMB, err := t.LimitMB()
if err != nil {
return false, err
}
return readingMB >= limitMB, nil
}

View File

@@ -0,0 +1,199 @@
// 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 == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不能为空")
}
if input.Phone == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号不能为空")
}
if input.Username == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名不能为空")
}
if input.ShopName == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺名称不能为空")
}
if 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
}

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