201 Commits

Author SHA1 Message Date
邹进
0138a3fb9c feat: 延期资格返回套餐名称、资产、订购时间、到期时间与沉默期截止时间
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 25m44s
2026-11-07 22:50:37 +09:00
邹进
7b13431f36 fix: 微信 H5 下单被明确拒绝时报真实原因并关单,不再显示请求超时
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
微信 v2 统一下单返回 result_code=FAIL(如 NOAUTH 商户未开通 H5 产品)时订单未创建,
原来一律按结果未知返回"请求超时"、订单挂到支付截止才关。改为验签后按明确失败返回微信原因,
调用方立即关单;用微信配置不可用错误码,不触发商户自动停用。ORDERPAID、OUT_TRADE_NO_USED、SYSTEMERROR 仍按未知处理。
2026-11-07 22:12:42 +09:00
邹进
b2fc74c021 feat: H5 沉默期延期微信支付改为微信内直接拉起(JSAPI)
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- H5 下单带 app_type 且选微信、商户为微信直连时,复用套餐购买的 JSAPI 预下单,返回 pay_config
- 调起参数保存在支付单,延期订单详情可继续支付;查单、回调、超时关单、作废沿用原流程
- 后台生成二维码、支付宝、富友商户保持原方式
2026-11-07 21:57:22 +09:00
邹进
8b0a01753d fix: 后台作废延期订单报"延期订单 ID 必须大于 0"
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 35m45s
ID 来自路径参数,请求体里没有;去掉请求体校验里的 ID 必填,路径 ID 由 handler 校验
2026-11-07 21:03:22 +09:00
邹进
d3face6f66 fix: 延期订单超时不关闭、无法继续支付、不能作废
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 支付宝查单把 ACQ.TRADE_NOT_EXIST 当成查单失败,恢复任务永远判为未知,订单停在待支付;
  同一套餐再下单时旧单也关不掉,新单无法创建。改为按"渠道无此订单"处理,过支付截止后关闭
- 延期订单详情对未过期的待支付订单返回 qr_content,后台可展示二维码、H5 可继续支付
- 新增作废待支付订单:后台 POST /orders/:id/close、H5 POST /orders/:id/cancel;作废前先查单,已支付则按支付成功办理
2026-11-07 20:31:45 +09:00
邹进
226a15573a fix: 观测序列与拒绝停机告警读取 ICCID 时显式指定列名
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1h20m10s
GORM 默认把字段 ICCID 映射成 icc_id:
- 观测序列设备换卡的本地预期判定读不到当前卡 ICCID,永远判不成立,只能靠调网关重试到上限
- 拒绝停机告警列表 ICCID 一直为空
补列名测试防回归。
2026-11-07 17:39:46 +09:00
邹进
eb0d86df4f fix: 记录查询扫描结构显式指定列名,修复 ICCID 一直为空
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
GORM 默认把字段 ICCID 映射成 icc_id,卡日流量的 c.iccid 和展示字段批量查询都读不到值;
扫描用的匿名结构统一写 gorm column tag。
2026-11-07 17:33:28 +09:00
邹进
3f593b912d feat: 记录查询列表补齐 ICCID、设备号、套餐名称、运营商名称与操作人
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 4m7s
分页后按当前页 ID 批量查主键补齐展示字段,不在分页查询中关联,也不在记录表冗余:
- 采集成功记录补 ICCID、运营商名称;采集失败、卡日流量补运营商名称
- 套餐日流量详单、冲正流水补套餐名称、ICCID、设备号与 IMEI
- 冲正流水(含资产详情)补操作人用户名;退款后处理尝试记录补设备号与 IMEI
2026-11-07 17:07:27 +09:00
邹进
7d705cf1e8 chore: 测试环境数据库与 Redis 迁到 kt.whcxd.cn(15434 / 16380)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 29m31s
旧主机 cxd.whcxd.cn 出口带宽约 4.5 Mbit/s,测试环境读写被限速;改用新实例(PostgreSQL 17、Redis 7.2.4)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:03:07 +09:00
邹进
1214181e78 feat: 轮询观测入库异步化,调网关与入库分离,采集记录经消息攒批写入
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 16m2s
- 轮询步骤异步模式下只调网关并发布 card.ingest 入库消息,入库消费者固定并发 30、按采集 ID 去重、可重试错误不限次数退避重投
- 采集记录经 card.collection 攒批写入,失败整批重投,新增 (observation_id, observed_at) 唯一索引
- 网络、实名观测增加乱序保护;轮询读查询走缓存,套餐状态查询各自计时且失败不缓存
- 入库积压告警与反压;系统配置 polling.ingest.mode 默认 sync 可回退;测试环境 nsqd 消息全部落盘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:33:29 +09:00
邹进
7e0e37933e fix: 卡日流量查询改用卡日快照 tb_card_flow_day
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m2s
tb_card_daily_usage 为已停用旧表(测试库为空,且列名与模型不一致),
卡日流量列表与索引改用采集链路写入的 tb_card_flow_day,当日用量取 settled_mb。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:59:30 +09:00
邹进
94cbf2adab feat: 采集记录成功失败分表并保存网关原始数据,到期归档 csv.gz,新增记录分页查询接口
- 网关客户端新增原始数据收集器,记录最后一次尝试的明文请求、HTTP 状态、响应体与 code/msg/trace_id
- tb_card_traffic_collection 只写成功读数,新增 carrier_id 与原始数据列;新增 tb_card_traffic_collection_failure,失败不判断有无套餐并带错误详情
- 采集记录在线保留当月加最近 2 个整月,到期分区摘出、导出 csv.gz、校验行数与 SHA-256 后删除,本地保留 3 个月
- 新增采集成功/失败、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试列表接口:可查单个也可查全部,分页与条件筛选
- 停复机记录支持资产标识、场景、原因、指令号筛选;单卡历史支持动作、结果、来源筛选
- 单卡采集记录支持筛选与合并失败记录;套餐日流量详单支持分页;退款列表支持后处理状态筛选
- 测试环境 api、worker 日志分目录,保留 40 个压缩备份;worker 挂载 archive 目录
- 迁移 000272、000273 需维护者在测试库执行后再部署

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:47:00 +09:00
邹进
8eb7d403c4 feat: 退款资产后处理逐次尝试记录与重试上限,停复机指令调用前结束写跳过记录
- 新增 tb_refund_post_process_attempt,每次尝试一条(步骤、触发方式、结果、错误码与信息、耗时)
- 退款单增加 asset_post_status/attempts/last_error/next_at;失败按 2^(n-1) 分钟退避(最长 1 小时),10 次后转人工
- 中间失败只写应用日志,成功或转人工写一条结论审计(带总尝试次数、首次失败时间、最后错误码)
- 补偿扫描跳过需人工处理与未到重试时间的退款单,Outbox 不再无限重投
- 新增 POST /refunds/:id/asset-post-processing/retry 人工重试
- 停复机指令执行前结束(已是目标状态、条件不再满足、需相反动作)写 skipped 停复机记录
- 迁移 000271 需维护者在测试库执行后再部署

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:33:32 +09:00
邹进
b7a7430cc3 feat: 审计动作按落库策略分级写入,系统过程动作改写应用日志
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m8s
- 审计注册表为可由系统任务、定时任务发起的动作声明落库策略(always/on_success/outcome/log_only/none),未声明按 log_only
- 人工、开放接口、个人客户、外部系统发起的动作始终写审计表
- log_only 写所在服务 app.log(logger=audit),不含快照
- 对接日志不再补写 integration.attempt_started,无上游审计时 audit_event_id 为空
- 新增 audit.persist_overrides 按动作覆盖策略
- 审计覆盖扫描要求机器动作必须声明策略
- 新增 OpenSpec 提案 refactor-audit-log-persistence 与技术方案文档

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:27:24 +09:00
邹进
96ff9c1e35 fix: 换货迁移按套餐是否已开始与新资产占位决定主套餐状态
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m0s
- 未开始的主套餐(无生效、到期时间且无用量)迁移为待生效,新资产不满足实名条件(行业卡/已实名/无需实名)时标记等实名激活
- 已开始的主套餐在新资产无当前主套餐时原样迁移;新资产已有当前主套餐时改为待生效排队、清空周期与用量
- 迁移后重排新资产主套餐优先级,保证同一载体只有一个当前主套餐
- 换货审计记录套餐状态、优先级、用量的前后值

OpenSpec: fix-exchange-migration-package-state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:25:06 +09:00
邹进
b0a7004cf9 docs: 记录换货迁移导致主套餐双生效的生产数据修正
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:24:43 +09:00
邹进
f0b54b8b52 chore: 测试环境 worker 任务并发调到 50、数据库连接池放大
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m59s
默认并发 10 时流量扣减事件投递跟不上,白天积压 1 万+、延迟约 2.5 小时。
网关只读开关不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:16:20 +09:00
邹进
ce2977ce22 fix: C 端设备局域网地址为空时默认 192.168.1.1
网关常不返回设备 lan_ip,H5 点后台管理时提示设备未开机;为空时回落到设备默认网关地址。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:21:38 +09:00
邹进
b3f66a01a8 chore: 补充换货原因长度放宽迁移 000270
生产库与测试库已手动执行,迁移重复执行结果不变;回滚不缩短长度,避免截断已有数据。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:15:39 +09:00
邹进
afc7e5285e fix: 换货原因长度上限放宽到 500 字
从微信复制的多行换货说明常超过 100 字,创建换货单时报参数验证失败。
请求校验与模型字段长度同步改为 500;生产库已手动执行 ALTER,迁移文件随 Iteration/8-11 编号补充。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:15:31 +09:00
邹进
dc816b44d0 fix: 卡或绑定设备关闭轮询时不再自动停复机
关闭轮询的卡/设备已导入其他卡管,去掉套餐到期、退款场景对暂停轮询卡的停机放行,
任何场景都不停机也不复机(单卡判断与设备下逐卡停机同步收口)。
场景判断函数更名为 StopResumeSceneIgnoresNetworkStatus,只保留不看库里开停状态的语义。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:01:55 +09:00
邹进
f274f8bb58 feat: 调整验证码短信文案
改为「您的验证码是XXXXXX,请在5分钟内输入验证。如非本人操作,请忽略本短信。」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:56:26 +09:00
邹进
4f404a11b0 perf: 运营商账户分发按账户并行,互不等待
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m48s
分发器每轮原先逐个处理全部运营商账户,每个账户多次 Redis 往返;
测试环境 Redis 走外网,41 个账户一轮约 19 秒,每个账户每 19 秒只领一次,
慢账户拖住其他账户。改为每轮各账户在独立 goroutine 中抢锁并领取,
一轮耗时取决于最慢账户;账户分发锁不变,多实例下同一账户仍只有一个实例领取。
测试环境 worker 的 Redis 连接池由默认 10 调为 100,避免并行分发与轮询执行争抢连接。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:57:54 +09:00
邹进
c36c9fcd84 perf: 运营商账户轮询初始化按批写入 Redis
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m6s
初始化每批 2000 张卡,原先逐张写入账户队列,每张卡约 4 次 Redis 往返;
测试环境 Redis 走外网,约 750 张/分钟,9.7 万张卡要 2 小时以上,期间未加载的卡不轮询。
新增 Store.UpsertBatch:一次往返读出各卡原账户与优先队列状态,一次往返写入整批;
单卡 Upsert 复用同一逻辑,写入规则不变。批量写入失败时本轮初始化失败、下一轮重试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:28:28 +09:00
邹进
0b6b664a7a docs: 生产全量数据导入测试环境进度(导出、只读表、导入、轮询加载)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m42s
Change: add-gateway-readonly-prod-import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:06:20 +09:00
邹进
5cf67b98de feat: 网关只读资产改为数据表,只读资产只允许查询类调用
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m36s
- 新增 tb_gateway_readonly_card(卡 ICCID / 设备 IMEI)与开关 JUNHONG_GATEWAY_READONLY_ENABLED,
  替换环境变量名单 JUNHONG_GATEWAY_READONLY_ICCIDS
- 只读资产只放行查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态;
  停机、复机、限速、实名认证链接与设备写操作在网关出口本地拦截;查表失败按拦截;不缓存
- 测试环境部署开启只读模式;新增技术方案与清理顺序文档

Change: add-gateway-readonly-prod-import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:55:52 +09:00
邹进
a7428c7924 chore: 测试环境只读卡名单加入 89861590172420420169
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m30s
用于流量采集验证的第二张生产卡(卡住的独立卡),只允许查询,停复机等写操作在网关出口拦截。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:57:06 +09:00
邹进
6266cf9d86 feat: 网关只读卡名单,测试环境导入的生产卡只允许查询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s
- 新增 JUNHONG_GATEWAY_READONLY_ICCIDS:名单内的卡只放行查流量、卡状态、实名查询,
  停机、复机、限速、实名认证等写操作在网关出口本地拦截、不发请求,返回明确失败
- 未配置时不生效;测试环境部署脚本配置卡 89861590172420410612 用于流量采集验证

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:15:00 +09:00
邹进
9c3c258f16 feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点
- 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点
- 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值
- 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警
- 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过
- 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置

Change: add-card-traffic-queue-and-observed-deduction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:18:47 +09:00
邹进
c33cc6f6c3 docs: 沉默期 11.1、3.5 测试环境验证结果与前端修改汇总
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:07:46 +09:00
邹进
af57de0d4d fix: 店铺分配覆盖为实名即生效的套餐纳入沉默期配置口径
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
存量中套餐本身购买即生效、店铺分配覆盖为实名即生效的正式套餐,购买后同样等实名,
但原口径只看套餐本身:无法配置默认沉默期、不进未配置清单与打开前检查,
待实名记录无法回填导致总开关打不开,新购买也绕过沉默期。

- 未配置清单与打开前检查纳入存在实名即生效分配覆盖的套餐
- 购买即生效正式套餐允许选填默认沉默期,加油包仍禁止
- 分配覆盖购买即生效 → 实名即生效在套餐已配置默认沉默期时放行
- 同步 OpenSpec design D11、spec、tasks 3.4/3.5 与前端说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:56:29 +09:00
邹进
69ec98aa5f fix: 后台平台自营与赠送套餐订单不再按 C 端购买跳过实名门槛
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m29s
isCEndPurchase 只看 buyer_type=personal,后台平台自营资产与赠送套餐线下订单
买家也记为 personal,导致未实名资产的实名即生效套餐下单即生效、不进沉默期。
增加订单来源为 client 的条件,与同文件其他 C 端判定保持一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:30:53 +09:00
邹进
94b6cfb47a chore: 重新触发测试环境部署
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:01:39 +09:00
邹进
62ec94adec refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
- 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名
- 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段
- 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:47:02 +09:00
邹进
909f4d0ec2 feat: 实名即生效套餐沉默期、延期办理与资产出库时间
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00
邹进
d2b0d2f534 fix: 排队激活未评估停复机、孤儿套餐重复激活与微信查单订单不存在反复重查
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
- 排队激活与首次实名激活任务的处理器注入停复机服务:原来传 nil,激活后不重新评估停复机,付款或实名后要等下一轮轮询才复机(生产同样存在)
- 孤儿套餐扫描跳过载体未实名的"实名即生效"套餐:激活服务会直接跳过,原来每 10 秒重复提交一次激活任务
- 微信 v2 查单返回 ORDERNOTEXIST 时按"渠道无此订单"处理:代理充值支付单过期后关闭,不再当作结果未知每几分钟重查

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:07:17 +09:00
邹进
c9e5d65590 fix: 退款审批尝试金额约束允许 0 元
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s
000218 的 chk_refund_request_attempt_amount 要求 refund_amount > 0,0 元退款提交审批报 SQLSTATE 23514。
生产库已人工放宽为 >= 0 但无迁移记录,测试库与新建库仍为 > 0。新增 000258 统一为 >= 0,生产执行后不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:52:09 +09:00
邹进
82a2fcc306 fix: 企业账号只能访问授权给本企业的资产
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m25s
/assets/:identifier/* 共用 AssetService.Resolve 解析资产,而店铺数据范围 ApplyShopFilter 对企业账号不加条件,
企业账号可以查询并操作任意卡或设备(资产详情、订单、套餐、停复机、停用、改套餐有效期与已用量、开关轮询、改实名状态)。

- Resolve 对企业账号校验卡或设备当前授权给本企业,未授权与不存在同样返回未找到
- 改套餐有效期、改已用量、停用、开关轮询、改实名状态对企业账号返回无权限;停复机按授权范围放行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:05:05 +09:00
邹进
7963e5dadd docs: 补充后台订单按类别拆分的测试环境验证结果
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:54:29 +09:00
邹进
71fb91f9bf fix: 导出任务场景校验加入按类别拆分的订单导出场景
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
创建导出任务与导出任务列表的 scene 参数用 oneof 写死了允许的场景,order_platform、order_agent、order_online 被参数校验拒绝。
同时修正提案中资产订单接口按授权范围校验企业账号的不准确描述。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:42:29 +09:00
邹进
eb9f12d160 feat: 后台订单按平台、代理商、在线支付拆分列表与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m34s
- 新增 GET /orders/platform、/orders/agent、/orders/online,类别由路径固定,参数、响应、权限同原订单列表
- 新增导出场景 order_platform、order_agent、order_online,筛选与表头同 order
- 类别按钱从哪来划分:客户端下单为在线支付;后台代理钱包扣款(含平台代扣)为代理商;其余后台订单为平台
- 列表与导出共用 internal/query/ordercategory 的类别条件
- 企业账号访问订单列表、详情、购买预检改为无权限,修复其可看到全部订单的问题
- 原订单列表与 order 导出场景保留不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:53:24 +09:00
邹进
3618fc2b20 fix: 企业微信审批同步任务在已停用应用下无限堆积
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m21s
恢复任务每 2 分钟为未终态审批投递详情同步,已停用应用的审批同步必然失败并重试 25 次,
且同一审批单每轮重复投递,测试环境 wecom:approval 队列积压约 15 万任务,在用应用的审批结果迟迟不生效。

- 轮询与结果未知恢复只取启用中企业微信应用的审批
- 轮询投递的详情同步按审批单加 10 分钟唯一性锁,最多重试 3 次
- 应用不存在或已停用时同步任务直接结束不重试
- 恢复定时任务加唯一性,队列拥堵时不再堆积重复恢复任务

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:16:33 +09:00
邹进
06ff3aabbd docs: 补充暂停轮询的卡到期、退款与订单自动恢复的测试环境验证结果
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:08:43 +09:00
邹进
d4fde99cc3 fix: 停复机成功后上游状态滞后导致暂停轮询的卡到期或退款漏停
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m29s
- 停复机成功后保护窗口内(polling.stop_resume.status_grace_seconds,默认 300 秒)查卡状态返回操作前的状态视为上游滞后,不回写网络状态
- 套餐到期、退款场景不看库里的开停状态,资产无可用主套餐即停机,上游已停时按已是目标状态结束
- 迁移 000257 插入保护窗口配置

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:41:45 +09:00
邹进
f6a740b9f3 docs: 告警处理接口说明指向告警页批量关闭轮询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:06:14 +09:00
邹进
0769db8cc8 fix: 设备列表返回是否轮询
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
设备列表已支持按是否轮询筛选,但返回中没有该字段,前端无法展示开关状态与批量开关。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:05:26 +09:00
邹进
9b058f422c docs: 补充轮询开关、停机被拒视图、单卡轮询状态、监控与导出场景的接口说明
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:57:42 +09:00
邹进
f35d2a8196 fix: 改有效期到过去时不自动恢复轮询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m34s
把套餐有效期改到过去时,到期扫描前套餐仍为生效中,原逻辑会误恢复轮询;改为只认未到期的生效中主套餐。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:45:36 +09:00
邹进
5efe0026c0 docs: 停复机指令队列与不轮询清单测试环境验证结果
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 04:09:24 +09:00
邹进
ef5274864f fix: 改有效期自动恢复轮询识别 card 资产类型
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
资产解析返回的卡类型为 card,恢复轮询按 iot_card 匹配,导致改有效期后关闭轮询的卡不恢复。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:33:01 +09:00
邹进
4139732719 fix: 不轮询清单审查问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m31s
- 写清单 ON CONFLICT 的部分索引谓词改为字面量:预编译语句的通用执行计划下参数化谓词匹配不到部分唯一索引,第 6 次起关闭轮询全部失败
- 轮询开关审计允许 Worker、开放接口、C 端与支付回调来源;导入与自动恢复补审计操作者
- 文件导入按 19/20 位 ICCID 匹配
- 钱包重复支付不再恢复轮询

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:18:54 +09:00
邹进
7ddb9312e3 feat: 不轮询清单与批量开关轮询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
- 新增不轮询清单 tb_card_polling_pause(迁移 000256 补写存量),所有改 enable_polling 的入口同事务写清单
- 卡、设备勾选批量开关轮询(单次 500,逐项结果,开启跳过风险停机与已销户)与 CSV 文件导入
- 订单套餐生效、改有效期后自动恢复轮询并按规则评估复机
- 不轮询清单查询与导出;卡、设备列表与导出增加是否轮询
- 失败告警停机被拒视图、导出与批量关闭轮询;开关轮询影响说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:07:20 +09:00
邹进
a4e3fe4d8f fix: 停复机指令队列审查问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
- 开关开启但分发器心跳缺失时回退为当场执行,避免指令无人消费
- 待确认指令先查上游状态再判断;执行期间被覆盖时把待确认带到新指令
- 同方向失败退避中重复写入不提前执行时间
- 领取容量把执行上限与本秒剩余 QPS 分开计算
- 队列复机共用单卡复机锁;执行上下文取消后按结果未知确认
- 消费者并行停止;读取最近调用时间失败时不覆盖;成功但终结日志失败按成功处理

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:43:58 +09:00
邹进
934a3a27a1 feat: 停复机指令队列与设备维度判断
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
自动停复机统一经停复机指令队列执行(全局开关 polling.stop_resume.queue_enabled,默认关闭):
- 统一判断函数 decideCard,新旧路径共用;暂停轮询的卡在套餐到期、退款场景照常停机
- 同卡一条指令、版本号覆盖;领取校验单卡最小重试间隔;先于卡任务领取,不受 scheduler_mode 限制
- 执行器只调用一次网关,按错误分类退避与告警,结果未知在队列内确认
- 设备维度判断合并请求与场景,版本变化再判断,旧路径持设备锁时推后
- 套餐激活、重置、订单、流量扣减、套餐到期、退款、实名管控修改改为请求评估
- 监控增加停复机队列与近 1 小时执行计数,单卡轮询状态返回当前指令
- 迁移 000255 插入开关与告警、退避参数

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:36:26 +09:00
邹进
1369fed642 fix: 账户限流预算小于单卡调用数时卡永远领不到
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m36s
领取脚本在一张卡同组到期调用数大于剩余预算时停止领取,账户 QPS 很小或长期接近上限时,同时到期多项的卡会一直排不上,整个账户停止轮询。改为本轮该组尚未占用且预算大于 0 时至少放行一张,超出部分由网关限流等待模式排队;预算为 0 时仍不领取。
2026-10-25 23:39:25 +09:00
邹进
466cb1f9e8 fix: 修复保存限流组与错误分类规则后缓存失效函数无限递归导致 API 栈溢出崩溃
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
invalidateGroups、invalidateRules 误写为调用自身,任何限流组或错误分类规则保存成功后 API 进程栈溢出退出;改为调用限流组与分类器的 Invalidate
2026-10-25 23:11:58 +09:00
邹进
555ddac725 fix: NSQ 订阅前预建 topic,跳过的检查步骤记最近成功时间
- 订阅前经 nsqd HTTP 接口创建 topic,并把 nsqlookupd 轮询间隔缩短为 15 秒:避免首次发布前查不到 topic 导致消费者最长 60 秒后才发现 nsqd、并持续输出查询失败日志
- 步骤结果为跳过(如所属账户未开实名管控)时记最近成功时间,使读入口请求检查的冷却同样生效,不再反复产生无需调网关的卡任务
2026-10-25 23:03:23 +09:00
邹进
9183863fab docs: 按运营商账户调度任务进度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 17m39s
2026-10-25 18:57:32 +09:00
邹进
c741f1751d feat: 按运营商账户调度的后台接口与重算到期
- /polling-carrier-params 列表(含默认标记与消费能力)、详情、保存(执行上限不得大于订阅进程 max_in_flight 之和)、删除;/polling-check-scenes 列表与修改(检查类型、递增的确认延迟、冷却);/polling-failure-alerts 列表、按账户与错误类别汇总、详情、标记已处理;GET /iot-cards/:id/polling-state 单卡轮询状态;GET /polling-stats/carrier-queues 按账户队列与执行监控
- 读取仅超级管理员与平台账号,修改仅超级管理员(告警处理平台账号可用),均写统一审计;新增审计动作与资源
- carrier 模式下重算到期时间写入卡到期信息与账户队列,只提前不推后
2026-10-25 18:56:32 +09:00
邹进
0d966249b8 feat: 业务事件与读入口在按运营商账户调度时改为请求检查
- 请求检查器按场景配置(tb_polling_check_scene,缓存 30 秒)把卡的检查类型设为立即到期放入账户优先队列,同卡合并、冷却期内不请求、按场景追加延迟确认;设备按有效绑定卡展开
- 业务事件仍在原事务中写 Outbox(提交安全),carrier 模式下 Outbox 消费者把订单支付、套餐激活、自动续费购包、停复机成功事件转为请求检查,不再起三连查观测序列
- 读入口(C 端资产详情、后台资产实时状态、实名链接、设备操作)经模式接缝改为请求检查;设备信息同步保持原观测;OpenAPI 查询仍不触发
- legacy 模式下全部沿用原观测序列
2026-10-25 18:49:36 +09:00
邹进
2fe7c1f8ac feat: 按运营商账户调度的分发器、卡任务执行器与模式切换
- 四个轮询处理器拆为步骤函数 Step(返回结果分类),旧 Asynq Handle 只做并发控制与原有重入队映射,行为不变;carrier 模式下旧 handler 直接结束
- 网关限流增加等待模式(ctx 标记),NSQ 消费者等名额到单步超时,超时按限流处理不计失败;记录限流等待次数
- 卡任务执行器:核对 token 并抢执行权,按配置步骤顺序串行执行到期检查(单步超时、总超时、panic 恢复、流量已触发评估时跳过套餐),执行中续期并 Touch,续期失败立即停止;收尾写回到期、连续失败、退避、延迟确认与告警
- 分发器:每秒按账户抢分发锁领取(执行上限按消费能力收紧、优先占比、按限流组剩余 QPS 估算),发布到 polling.card.{账户},发布失败释放;消费旧优先提示与手动触发;心跳与处理中标记到期计数
- NSQ 生产者与按账户动态订阅的消费者管理器,优雅退出宽限期
- 失败告警异步写 tb_polling_failure_alert(同卡同类型一条未处理,成功转已恢复),账户窗口内告警数超阈值产生账户级告警
- 模式监听:切 carrier 时旧分片调度停止出队,加锁初始化新结构后开始领取并订阅;回 legacy 时停止消费、重建旧分片队列;生命周期(API 与 Worker)在 carrier 模式写账户队列
2026-10-25 18:44:23 +09:00
邹进
e7f8c4bae9 docs: 统一停复机规则转正测试环境验证通过
Some checks are pending
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Waiting to run
2026-10-25 18:29:56 +09:00
邹进
dae44bed30 feat: 按运营商账户调度的迁移、配置与账户队列 Lua
- 迁移 000251~000254:运营商账户调度参数、请求检查场景(预置 8 个)、卡轮询失败告警表,以及调度模式、步骤、退避、告警等受控系统配置(默认 legacy,行为不变);并发配置表注释标记废弃
- 受控系统配置注册与 JSON 结构校验:步骤只能是 4 种检查类型且不重复、退避序列递增
- yaml 增加 NSQ 地址、msg_timeout、租约、续期间隔、分发间隔、默认执行上限与 max_in_flight;引入 go-nsq
- 测试环境 docker-compose 增加 nsqlookupd 与 nsqd,Worker 注入 NSQ 地址
- 账户队列 Redis 结构与 Lua:领取(执行上限、优先占比、按限流组剩余 QPS 估算)、抢执行权、续期、收尾、请求检查(合并、冷却、执行中打优先标记)、移出、发布失败释放
2026-10-25 18:28:42 +09:00
邹进
f49baff7c2 feat: 停复机新规则转正,删除总开关与旧口径
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
- 删除总开关 stop_resume.unified_rule_enabled(配置、加载、部署工作流与 docker-compose.prod.yml 环境变量)
- 复机不看停机原因:删除只对轮询停因复机的旧分支与 ListByDeviceIDAndPollingStopReasons;设备复机取全部停机卡
- 实名门槛只按卡所属通道实名管控判断,删除行业卡与卡/设备实名策略放行;暂停轮询的卡不自动停复机无条件生效
- 通道阈值新周期解锁后的复机条件固定为新规则复机条件
- 资产自动续费复机同步新规则:不看停因、暂停轮询不复机、设备取全部停机卡、复机成功清除停因
- 实名轮询不再按行业卡跳过;所属运营商账户未开启实名管控时跳过查询,但卡未实名且所在资产有等待实名激活的套餐时照常查询,避免囤货套餐无法激活
2026-10-25 18:13:51 +09:00
邹进
457abaabd7 docs: 统一停复机规则第一次发布测试环境验证通过
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m35s
2026-10-25 18:06:14 +09:00
邹进
af8249158d feat: 停复机规则演练与终态自动关闭轮询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
统一停复机规则转正的第一次发布:总开关仍关闭,现有停复机行为不变。

- 新增停复机规则演练:GET /stop-resume-rule/dry-run 按新规则对开机与停机卡只判断不执行,返回运营商账户 × 停机原因 × 判断结果汇总与分页明细,可假设通道实名管控配置;POST /stop-resume-rule/dry-run/export 导出明细(场景 stop_resume_rule_dry_run);仅超级管理员与平台账号
- 风险停机、已销户关闭轮询不再限于独立卡,只关该卡自身轮询,不级联设备
- 错误分类规则增加 auto_disable_polling,仅终态类别可勾选;停复机(含设备停复机)命中该规则时不重试、关闭该卡轮询、回写网关扩展状态并写审计 iot_card.terminal_disable_polling
- 迁移 000250 加列与约束,并为"已销户""仅支持风险复机"置位
2026-10-25 17:52:40 +09:00
邹进
fad7dbfdff docs: 设备保护期下线测试环境验证通过
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m39s
2026-10-25 17:35:54 +09:00
邹进
79b7580554 feat: 下线设备保护期
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s
- 后台设备停复机不再设置 1 小时保护期,也不再因保护期拒绝相反操作;单卡手动停复机去掉保护期检查
- 删除保护期一致性轮询(polling:protect)及其强制停复机方法;调度、初始化、监控、匹配预览不再包含保护期;已入队的旧任务由空处理函数直接确认,下一次发布移除
- 轮询配置接口不再接受与返回 protect_check_interval;并发配置不再返回 protect,查询、更新、重置该类型时拒绝
- 资产详情 device_protect_status 固定返回 none 并标记废弃,兼容老版本客户端
- 删除保护期 Redis key 与时长常量;停机原因 protect_period 保留供历史数据与开放接口映射
- 迁移 000249 只把 protect_check_interval、last_protect_check_at 列注释标记为已废弃,不删列不改数据
2026-10-25 17:19:10 +09:00
邹进
4de67f6dc3 fix: 设备管理接口不再拦截代理与企业账号
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
设备类型选项接口用 router.Group("/devices", 拦截函数) 限制仅平台访问,Fiber 会把该函数注册为整个 /devices 前缀的中间件,导致代理、企业账号的设备列表、设备下的卡、分配回收等接口全部返回 403。改为在 ListDeviceTypes 内部判断用户类型,其他设备接口恢复原有权限。
2026-10-25 17:11:10 +09:00
邹进
a855742752 fix: 关闭轮询的卡不再重新入队
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m32s
关闭轮询时会移出队列并清除卡缓存,但执行收尾重入队、卡状态检查补齐套餐任务、限流重入队
都不判断轮询开关,执行中途被关闭的卡会被放回队列并互相补齐,关闭轮询无法生效。

- 卡缓存写入 enable_polling,旧缓存缺字段时从库补读并回写
- 重入队、补齐缺失任务、限流重入队前判断轮询开关,已关闭则不入队
2026-10-24 18:10:58 +09:00
邹进
2ddaffe373 docs: 轮询配置接口文档与实际行为对齐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m57s
- 匹配预览 GET 只收 query iccid,POST 只收请求体,去掉 POST 文档中多余的 query 参数
- 预览草稿 overrides 改为独立结构,字段全部可选,最多 20 条
- clear_fields 用具名类型生成数组元素枚举;ordered_ids 说明须为正整数且不重复
2026-10-24 17:41:52 +09:00
邹进
8fc3485061 chore: 激活前流量结算记录开始与完成日志
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
2026-10-24 17:31:32 +09:00
邹进
ec5c1996a7 fix: 重算到期时间不抵消失败退避,重置日前统计只计有生效套餐的卡
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 上次检查时间只在查询成功时更新,按"上次检查 + 新间隔"会把失败或限流延后的卡拉到立即执行;
  过期或从未检查时候选改为"现在 + 新间隔",只提前排队超过一个新间隔的成员
- 重置日前加密统计只记录有生效套餐的卡,与"有生效套餐但窗口内未查到"口径一致
2026-10-24 17:28:18 +09:00
邹进
98017ac053 feat: 分层轮询、重置日前加密与激活前流量结算
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
- 轮询配置新增套餐状态、资产状态、重置日前窗口与窗口内流量间隔(迁移 248),间隔三态:正数按间隔、0 不检查、空继承
- 配置按 priority, id 排序合并;在库与已销售无套餐未匹配时告警不入队
- 新增优先级整体重排、匹配预览、立即重算到期时间(异步只提前)、分层统计与重置日前加密统计接口
- 已切换日快照计量的卡流量按观测时间归属,激活前用量只记已结算
- 订单购买与实名激活补齐资产级激活锁,激活前对已切换卡结算一次流量,失败只告警不阻塞,结算不触发通道阈值判定
2026-10-24 17:06:38 +09:00
邹进
02a842f066 fix: 网关限流系统配置 Key 改为三段式,修复注册失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
- 受控系统配置要求 Key 至少三段,原两段式 Key(如 polling.rate_limit_mode)注册被拒,后台不可见、限流器只能使用默认值
- Key 改为 polling.gateway.* 与 polling.stop_resume_record.*;新增迁移 000247 将已落库的旧 Key 改名并保留取值
- 后续提案中规划的开关同步改为三段式(polling.scheduler.mode、polling.stop_resume.queue_enabled)
2026-10-24 16:17:34 +09:00
邹进
9b0930db9f chore: 重新触发测试环境部署(磁盘清理后重跑)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m59s
2026-10-24 15:38:40 +09:00
邹进
78220deb0e docs: 网关限流接口文档补充操作、类别与状态枚举
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 15m23s
- 限流组与错误分类的操作数组元素改为具名类型 GatewayOperation,接口文档按元素给出可选值
- 匹配方式、错误类别、状态字段补充 enum 标签;高优先级操作说明补充取值与必填条件
2026-10-24 15:22:17 +09:00
邹进
42e881576d feat: 卡网关按运营商账户限流并记录停复机调用
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m33s
- 新增网关限流:按本系统运营商账户(卡 carrier_id)→ 限流组 → 操作限制每秒请求数,每次 HTTP 尝试计量,默认限流、仅后台人工点击/通道阈值/自动续费/手动刷新豁免;支持只统计与拦截两种模式,默认只统计
- Asynq 遇限流不计失败、固定短延迟重试;轮询任务与停复机业务重试遇限流短延迟重排
- 停机、复机不再使用网关内部重试,只发一次请求
- 网关返回结构化错误;新增上游错误分类规则表与测试接口
- 新增按月分区的停复机记录表:系统自动停复机只写记录,不再写统一审计与集成日志;普通轮询查询不再写集成日志
- 新增限流组、错误分类、停复机记录、按账户调用统计与枚举后台接口
- 迁移 000243~000246;同步 OpenSpec 提案(含后续 6 个 change)与技术方案、工程约束
2026-10-24 15:06:41 +09:00
邹进
9e45097aa2 fix: 富友订单支持退款,0 元退款不强制客户收款材料映射
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
- decideRefundMethods 将 fuiou 与 wechat、alipay 按线上支付处理,修复富友订单报"该订单的支付方式不支持退款"
- 客户收款信息退款仅在金额大于 0 时强制映射并提交客户收款信息与凭证;0 元退款允许客户收款信息为空

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:00:36 +09:00
邹进
437d1c2c2c feat: 支持 0 元退款(仅作废权益不退钱)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:18:24 +09:00
邹进
1f28ac3151 chore: 测试环境开启停复机新规则总开关
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m37s
部署工作流写入 JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED=true,用于测试环境验证
复机不看停机原因、实名按通道实名管控判断、暂停轮询的卡不自动停复机。
2026-10-20 18:13:18 +09:00
邹进
6954ad6693 chore: 测试环境开启设备 10 的卡流量日快照计量灰度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m0s
部署工作流写入 JUNHONG_TRAFFIC_CALC_MODE=gray 与 JUNHONG_TRAFFIC_CALC_CARDS=device:10;
docker-compose 为 API 与 Worker 透传计量与停复机新规则开关,未配置时保持原行为。
2026-10-20 17:58:18 +09:00
邹进
96771a204f feat: 卡流量改为日快照计量并统一停复机规则
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
卡流量计量(按名单灰度,默认 off):
- 新增卡日快照与切换水位表(000241),按通道上游流量重置日换算自然月、重置日防旧值
- 已切换的卡按月对账产生增量,无生效中套餐只结算不发事件;第一次切换写水位
- 已切换卡读数有效即判定通道阈值,卡表本月用量用快照覆盖,日流量读快照
- 配置 JUNHONG_TRAFFIC_CALC_MODE/CARDS、JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED

停复机规则(总开关 JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED,默认关闭):
- 复机不看停机原因,持通道阈值锁仍拒绝;暂停轮询的卡不自动停复机
- 通道新增实名管控 realname_control_enabled(000242,默认 0),开启后不区分行业卡与实名策略
- 实名管控从 0 改 1 需确认,修改并入通道更新审计

OpenSpec Change: rebuild-card-traffic-metering-and-stop-resume
2026-10-20 17:49:26 +09:00
邹进
44a05cf687 merge: 合并 Iteration/8-11 到 main,使 main 与线上版本一致
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s
冲突处理:
- internal/bootstrap/services.go、internal/service/asset/service.go、
  openspec/specs/asset-device/spec.md、openspec/specs/personal-customer/spec.md
  均保留 Iteration/8-11 的内容;main 上对应的修复在分支上已有(同内容或调整后的版本)。
- 删除 main 上未归档的 fix-depleted-package-display、fix-immediate-package-payment-resume,
  分支已于 2026-09-14 归档。
- main 独有的 lxp 账号 ID 调整所在代码已被两边都包含的“开放套餐过期时间和用量修改权限”删除。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:05:15 +09:00
邹进
b1064ded76 chore: 忽略本地数据库备份目录 back/
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:02:20 +09:00
邹进
975a211efe docs: 新增卡流量计算技术方案(参考老卡管)
流量计算照搬老卡管的日快照与区间取最大值做差,套餐侧不改;
记录已定决策、生产核实结果、灰度与补数据方案。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:59:52 +09:00
邹进
4f16eb1834 fix: Worker 订单服务注入员工代收款建账能力
批量订购线下支付在 Worker 内建单时,订单服务缺少员工代收款建账能力,
每行都会以“员工代收款建账能力未配置”失败;与 API 进程保持一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:59:52 +09:00
邹进
fa6fb2fd78 docs: 新增卡流量事实模型技术方案
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:49:52 +09:00
邹进
08a7a00cd9 fix: 微信授权配置公众号必填、小程序选填
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m25s
商户池变更把授权完整性实现为公众号与小程序四项凭证都必填,
生产只用公众号 H5、未配置小程序,迁移后授权表为空会导致
C 端微信登录与支付全部失败,后台也无法补建。

- SaveAuthorization 启用时只校验公众号 AppID/AppSecret;
  小程序选填,但 AppID 与 AppSecret 须同时填写或同时留空
- 新增迁移 000240:无授权配置且唯一生效综合配置公众号凭证
  完整时补建启用授权配置;down 不删除数据
- 新增 OpenSpec Change allow-wechat-authorization-without-miniapp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:29:55 +09:00
邹进
b434eda5c7 fix: 退款审批通过时仅原路退款校验收款商户凭证
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m3s
线上支付订单选择客户收款信息退款时,审批通过处理仍无条件校验收款商户的原路退款凭证,
商户凭证不完整(如微信 v2 缺 API 客户端证书)即阻断,导致企微已通过的退款无法完成。
将凭证校验移入原路退款分支,旧人工审批入口同步按退款方式判定。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:51:28 +09:00
邹进
1d6c212d04 feat: 第1批 M5 补充 IoT 卡导出追加使用/剩余流量(GB)列
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
- 新任务在「套餐到期时间」后追加「使用流量(GB)」「剩余流量(GB)」,同行 MB ÷ 1024 两位小数
- 冻结表头按末列识别三版尾列,历史任务行宽不变
- OpenSpec 补充提案、设计、spec 与任务 3.2/5.6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:00:44 +09:00
邹进
7e54fe2ba7 fix: 企微审批选填附件控件为空列表时跳过,避免非客户收款退款提交失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:48 +09:00
邹进
bcb1ce0653 fix: 退款审批退款凭证误取客户收款凭证导致非客户收款方式提交失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:57:58 +09:00
邹进
713045a657 fix: 第1批 M9 H5 进入资产的临期个人通知写入即已读
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m9s
H5 进入资产时已同步弹窗展示临期提醒,通知中心再记未读会重复提醒;
通知按剩余天数每天新增一条,保持未读会持续累积红点。
直发通知载荷新增可选 mark_read,仅临期个人通知设置,其他直发通知与店铺临期通知仍为未读。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:45:20 +09:00
邹进
eb98478b09 docs: 第1批 ②④ 测试环境验证进度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m33s
- ② 6.4~6.7 完成:结果未知保持审批中、提交失败决策投递、M13 撤销/拒绝/删除定位、核销提交状态
- ② 6.3 五类业务已验证;佣金提现因测试环境未配置审批场景待补验
- ④ 4.2、4.3 完成:进入资产按资产提醒与通知过滤、每日扫描只写店铺通知

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:43:57 +09:00
邹进
465016a333 fix: 第1批 F1、M12 换货清空客户绑定的审计写入失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m0s
- 换货清空旧资产绑定写 personal_customer.unbind_asset 时,资产资源带主体摘要却缺省为仅内部可见,
  被审计写入拒绝并静默降级(“内部事件不得写入主体投影”);资产资源改为声明结论可见级别。
  后台按虚拟号解除绑定(UnbindByVirtualNo)同样受益
- 设备换货完成审计不再把新设备已有的扫码绑定记为“新设备客户绑定”:换货已不迁移绑定
- 测试环境验证 F1(4.3)与 M12(4.4)完成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:21:46 +09:00
邹进
868d969d8e fix: 补上第1批 M8 提交失败决策写入(6298a906 遗漏)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m35s
- 企微提交被明确拒绝时,在同一事务内写 submission_failed 决策投递记录与终态事件
- Worker 两处 ApprovalContextRepository 注入决策写入器;未注入时提交失败直接报错,不再静默只改实例状态
- 终态决策消费入口单独放行 submission_failed,渠道回调仍不能推进到提交失败
- 6298a906 的编辑脚本在本文件锚点不唯一处中断,三处改动未落盘;本次补齐
- 第1批 ⑤ 5.4、① 5.2/5.4/5.5 测试环境验证完成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:09:58 +09:00
邹进
0f1ea5bc3e fix: 第1批 M9 套餐临期提醒按资产、扫码进入时触发
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m17s
- 每日扫描不再给个人客户写临期通知,店铺通知不变
- H5 资产信息在当前资产剩余 0~15 天且当天首次进入时返回 expiry_reminder,并为该客户、该资产写一条临期通知(Redis 去重,失败不占键)
- 个人通知列表、未读数、全部已读中临期提醒只处理当前资产(按 ref_key)
- OpenSpec Change scope-expiry-reminder-to-current-asset:6/9,临期 fixture 端到端验证与每日扫描验证待测试部署后完成
2026-10-12 00:15:27 +09:00
邹进
9ced14fa1b fix: 第1批 F1、M12 一资产一号与换货按手机号关联校验
- F1 一个资产同时只能有一个有效手机号关联,他号绑定返回 1192(不含号码),同号幂等
- 登录 need_bind_phone 改看资产是否已有有效关联,新增 asset_phone_bound、asset_phone_is_own;H5 绑定按客户/资产两侧状态分别写入
- 客户换号不再迁移资产关联
- M12 换货新资产有有效手机号关联即拒绝,号码只在 data.phone;换货完成清空旧资产客户绑定并失效旧资产关联(exchange_completed),均不迁移
- OpenSpec Change enforce-single-phone-per-asset:11/16,H5 短信与换货端到端验证待测试部署后完成
2026-10-12 00:10:41 +09:00
邹进
6298a9061f fix: 第1批 M8、M13、M14 审批提交失败终态与退款关闭类终态定位
- 审批核心在实例进入提交失败的同一事务内写 submission_failed 决策投递与终态事件,消费入口单独放行
- 核销(4)、退款(7)、扫码注册(3)、资料资格(4)、线下充值(7)、佣金提现(5)新增“审批提交失败”终态并释放预占/冻结
- 三处恢复入口不再重放提交失败;退款关闭类终态按审批尝试定位退款单(M13)
- 核销列表与详情批量返回审批提交状态;筛选、字段说明、状态名称、退款导出加入新状态
- 迁移 000239 放宽五张业务表状态约束与决策投递取值约束(测试库已执行 up/down/up)
- OpenSpec Change fix-approval-submission-failure-terminal:18/26,测试部署后端到端验证与存量补数据待完成
2026-10-12 00:03:46 +09:00
邹进
25c081fe60 fix: 第1批 M2、M3、M16、M23 导入与展示类缺陷
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8h37m36s
- M2 批量换绑表头改为“平台登录账号”,只认手机号,细分格式、不存在、多匹配、非平台、已禁用失败原因
- M3 后台订单列表与详情返回 C 端在线购买的交易流水号、支付渠道、商户名称,详情新增全部支付单 payments
- M16 预警规则不再返回 real_data_mb,预警列表/详情新增 threshold_mb,导出末列追加“阈值流量(MB)”
- M23 设备导入 IMEI 必填,空 IMEI 行记为失败
- OpenSpec Change fix-august-iteration-follow-up-gaps:13/18,导入任务、导出任务的 Worker 端到端验证与前端/运营通知待部署后完成
2026-10-11 23:49:29 +09:00
邹进
260fec7114 fix: 第1批 M1、M4~M6 登录密码统一规则与三类导出补列
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 七个设置登录密码入口统一 8~12 位、四类字符至少三类(1001/1016),修改密码与建店铺返回字段级提示
- 订单导出末列“操作人”、IoT 卡导出末列“套餐到期时间”、设备导出流量标 MB 并新增“设备合计(GB)”
- 历史导出任务按冻结表头识别新旧尾列,行宽不变
- OpenSpec Change fix-august-iteration-export-and-password-hints:10/12,5.3 前端同步与 5.4 测试部署导出验证待完成
2026-10-11 23:41:40 +09:00
邹进
f56113ba55 fix: 测试环境开启手机号强制绑定
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m41s
API 服务的 JUNHONG_CLIENT_REQUIRE_PHONE_BINDING 由 false 改为 true,
使 H5 验证手机号后写入资产手机号关联,便于验证后台查看与解绑。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:21:26 +09:00
邹进
d5826700d5 fix: 恢复退款审批场景可选业务字段「资产标识」
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m54s
提交 622e99b5 本意是移除与 refund_asset_type 重复的 asset_type,
但在 sceneBusinessFields 退款分支中把 asset_identifier 一并删除。

后果:保存映射时 allowedSceneBusinessField 依据该清单校验,
包含资产标识控件的映射被拒绝,报「企业微信控件映射包含当前业务不支持的字段」;
而模板中的资产标识控件为必填,不映射又会因未映射必填控件被拒,退款场景无法保存。
审批快照生成处(creation.go)仍写入 asset_identifier,故仅字段清单缺失。

本次仅在退款分支恢复该字段,asset_type 保持删除。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 12:07:32 +09:00
b09275a0d0 修复待处理换货资产归属
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m53s
2026-09-24 12:30:33 +08:00
390769ab7a 1
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m48s
2026-09-24 11:48:42 +08:00
34f91f129f 修复加油包独立有效期和流量重置
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m22s
2026-09-22 16:36:42 +08:00
89d0a67aad 开放套餐过期时间和用量修改权限 2026-09-22 16:36:42 +08:00
c1c78d5008 修复加油包独立有效期和流量重置
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 4m22s
2026-09-22 16:36:07 +08:00
3610b776ca 开放套餐过期时间和用量修改权限 2026-09-22 16:35:44 +08:00
9959168e22 完成支付失败商户轮换禁用
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 5h7m21s
2026-09-22 15:58:03 +08:00
622e99b543 移除退款审批重复资产类型字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m56s
2026-09-22 14:22:51 +08:00
2ba483db18 修复退款审批服务未注入问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
2026-09-22 12:41:29 +08:00
4330b8a40c 新增设备类型选项接口
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-22 12:35:26 +08:00
22c3e7cc1a 实施业务用户组成员管理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m42s
2026-09-21 21:20:38 +08:00
4a16eb0b1e 新增代理注册申请管理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m41s
2026-09-21 16:51:37 +08:00
b063617153 完善退款审批材料与恢复流程
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
2026-09-20 17:59:52 +08:00
4a6cec2730 修复设备导入卡绑定查询映射
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m30s
2026-09-20 11:39:32 +08:00
28ab910e59 修复设备导入卡绑定查询映射
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 4m37s
2026-09-20 10:53:06 +08:00
863607639f 归档
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-20 10:46:34 +08:00
1964c12ffa 新增字段
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-20 10:38:10 +08:00
98acc51241 修复设备导入卡查询失败后重复重试
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m46s
2026-09-20 10:10:21 +08:00
58a81b245d 修复设备导入卡查询失败后重复重试
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 14m37s
2026-09-20 10:09:46 +08:00
54c4ec7ba4 fix(设备实名): 无当前卡时按绑定卡实名回退并初始化单槽当前卡
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m42s
- 设备资产解析外层 real_name_status:存在当前使用卡时沿用该卡状态,无任何当前卡时任一有效绑定卡已实名即视为已实名
- 手工绑定与设备导入:设备最大槽位为 1 且有效绑定唯一时,在同一事务内将该绑定初始化为当前使用卡;多卡设备不推断
- 同步 asset-device、personal-customer 主 Spec;归档变更并附存量核对清单(75 条单槽唯一待修复绑定,其中 16 条已实名)
2026-09-18 17:49:12 +08:00
1f2fe4a61a fix(设备实名): 无当前卡时按绑定卡实名回退并初始化单槽当前卡
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 6m26s
- 设备资产解析外层 real_name_status:存在当前使用卡时沿用该卡状态,无任何当前卡时任一有效绑定卡已实名即视为已实名
- 手工绑定与设备导入:设备最大槽位为 1 且有效绑定唯一时,在同一事务内将该绑定初始化为当前使用卡;多卡设备不推断
- 同步 asset-device、personal-customer 主 Spec;归档变更并附存量核对清单(75 条单槽唯一待修复绑定,其中 16 条已实名)
2026-09-18 17:48:17 +08:00
d24e777a4c docs(验证证据): 以占位符替换本地 smoke JWT 字面串(ENG-SECRET-001)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m19s
- 验证说明中的本地 smoke JWT 占位值改为 <SMOKE_JWT_SECRET>,不改动证据语义与其它内容
2026-09-18 15:35:20 +08:00
5ed6b39deb feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00
5e78809b93 feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
- 新增成对迁移 000231 与三张快照表:日级头行、设备粒度激活行、到期事件粒度续费行,以快照日期为唯一键
- 新增每日 03:30(Asia/Shanghai)日报快照生成任务与幂等整日替换,失败重试沿用同一目标日
- 新增六条受控入口:两张报表的汇总、日/月趋势与受控导出,配套查询层只读快照事实
- 新增 operations_activation 与 operations_renewal 两个导出场景,创建期冻结筛选与可见店铺范围、派发期冻结表头、执行期只按冻结值复核资格
- 采购数量口径按系统内未删除设备数实施并在 PRD 标注,附实测差额依据
- 同步证据链 requirement-evidence.json 与入口能力矩阵、ARCHITECTURE 与验证记录
- 归档 change add-operations-reports 并新建主 Spec openspec/specs/operations-report/spec.md
2026-09-18 09:42:28 +08:00
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
30c6e5bb78 线上的lxp账号被删除了,重新新建了账号
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m51s
2026-09-11 17:47:43 +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
3b4afd287e 修复换货迁移后 after_order 实名门槛误判
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s
换货迁移只迁移套餐权益不迁移订单,已支付订单仍挂在旧资产上,
导致新资产 HasValidRechargeOrPaidOrder 查不到支付事实,返回 1189。
现增加套餐权益兜底:当前世代存在未退款套餐权益且其关联订单已支付,
即视为满足充值/购买条件。
2026-09-09 17:41:26 +08:00
999acc128e 完成支付购包自动复机变更任务
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m54s
2026-09-09 10:04:23 +08:00
f5f03d1803 修复支付购包后自动复机 2026-09-09 10:01:42 +08:00
ff25586dc9 修复已用完套餐展示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m43s
2026-09-08 17:25:34 +08:00
3c371ab88a 修复已用完套餐展示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m51s
2026-09-08 17:19:30 +08:00
a48ff5d782 切换测试环境部署至八月迭代分支
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m36s
2026-09-07 17:32:19 +08:00
696120ab38 feat: 资产套餐历史增加主子层级查询 2026-09-07 17:17:15 +08:00
c7c2b17d78 归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 54s
2026-09-07 11:34:23 +08:00
3a093ecd6b 清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m36s
2026-09-07 10:09:51 +08:00
5424751993 修复停复机忽略免实名策略并归档变更
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-07 10:06:17 +08:00
370fd3e67f update
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m49s
2026-09-03 09:28:28 +08:00
dbfeeee253 缓解io压力 2026-09-02 16:14:27 +08:00
395e5fb47c 修复套餐接续停机竞态与轮询兜底
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m13s
2026-08-29 16:27:48 +08:00
62f3d25e81 修复批量订购 2026-08-26 14:54:26 +08:00
5797fd0e94 修复企微兜底机制
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m40s
2026-08-20 18:06:45 +08:00
ba677a35e1 Update deploy.yaml
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 28h53m35s
2026-08-20 12:06:58 +08:00
22b95db2f9 每日清理 2026-08-20 12:03:17 +08:00
143df60485 修复 2026-08-18 17:32:15 +08:00
656a921ff0 富友支付支持 2026-08-18 17:13:20 +08:00
46c8e819df 修改相关证据 2026-08-18 16:30:16 +08:00
247d7d9f6e 新增接口 2026-08-18 16:15:46 +08:00
d256f6d176 合并七月迭代分支 2026-08-18 14:53:29 +08:00
7029104e5c 让迁移套餐恢复月流量重置调度
缺少 next_reset_at 时,轮询根据已有激活时间或到期时间与套餐天数推算下一重置点;已有值通过查询条件和条件更新双重保护,不会被覆盖。

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

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

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

Confidence: high

Scope-risk: narrow

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

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

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

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

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

View File

@@ -3,9 +3,7 @@ name: 构建并部署到测试环境(无 SSH)
on:
push:
branches:
- Iteration/7-11
- dev
- test
- Iteration/8-11
env:
REGISTRY: registry.boss160.cn
@@ -30,15 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/Iteration/7-11" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/test" ]; then
echo "tag=test" >> $GITHUB_OUTPUT
else
echo "tag=unknown" >> $GITHUB_OUTPUT
fi
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: 登录 Docker Registry
run: |
@@ -47,32 +37,33 @@ jobs:
- name: 构建 API 镜像
run: |
docker build -f Dockerfile.api -t ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} .
docker tag ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.API_IMAGE }}:${{ github.sha }}
- name: 构建 Worker 镜像
run: |
docker build -f Dockerfile.worker -t ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} .
docker tag ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.WORKER_IMAGE }}:${{ github.sha }}
- name: 推送镜像到 Registry
run: |
docker push ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.API_IMAGE }}:${{ github.sha }}
docker push ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.WORKER_IMAGE }}:${{ github.sha }}
- name: 部署到本地(仅 Iteration/7-11 分支)
if: github.ref == 'refs/heads/Iteration/7-11'
- name: 部署到测试环境(仅八月迭代分支)
if: github.ref == 'refs/heads/Iteration/8-11'
run: |
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
mkdir -p ${{ env.DEPLOY_DIR }}/logs
# 确保部署目录存在:api、worker 各自的日志目录与 worker 的采集记录归档目录(配置已嵌入二进制文件)
mkdir -p ${{ env.DEPLOY_DIR }}/api/logs ${{ env.DEPLOY_DIR }}/worker/logs ${{ env.DEPLOY_DIR }}/worker/archive
# 容器以 uid 1000(appuser)运行,新目录需可写;无权限修改属主时保持原样
chown 1000:1000 ${{ env.DEPLOY_DIR }}/api/logs ${{ env.DEPLOY_DIR }}/worker/logs ${{ env.DEPLOY_DIR }}/worker/archive 2>/dev/null || true
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'
printf 'JUNHONG_WORKER_INSTANCE_NAME=test-worker-all-1\n'
# 测试环境只读模式:tb_gateway_readonly_card 中的生产卡与设备只允许查询类网关调用(add-gateway-readonly-prod-import)
printf 'JUNHONG_GATEWAY_READONLY_ENABLED=true\n'
} > ${{ env.DEPLOY_DIR }}/.env
# 强制更新 docker-compose.prod.yml(确保使用最新配置)

5
.gitignore vendored
View File

@@ -111,6 +111,7 @@ scripts/batch_package_purchase/assets.example_购买结果_20260715_115804.csv
scripts/batch_package_purchase/assets.example_购买结果_20260715_115814.csv
scripts/migration/output
# LongHorizon 本地执行证据
.lh-harness/
.scratch/go-build-cache
# 本地数据库备份(生产 dump,禁止入库)
back/

File diff suppressed because it is too large Load Diff

1757
111.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -32,7 +32,7 @@
| --- | --- | --- | --- |
| `/health`、`/ready` | 公开,只返回进程健康/就绪状态 | `internal/routes/health.go` | `openspec/specs/operations-audit/spec.md` |
| `/api/auth` | 后台账号登录、刷新、登出与当前身份;认证中间件在路由组装配 | `internal/routes/auth.go` | `openspec/specs/identity-access/spec.md` |
| `/api/admin` | 后台认证、角色权限、店铺/企业数据范围;业务层仍需资源级校验 | `internal/routes/admin.go` | `identity-access`、`asset-device`、`package-lifecycle`、`order-payment-wallet`、`operations-audit` |
| `/api/admin` | 后台认证、角色权限、店铺/企业数据范围;业务层仍需资源级校验 | `internal/routes/admin.go` | `identity-access`、`asset-device`、`package-lifecycle`、`order-payment-wallet`、`operations-audit`、`operations-report` |
| `/api/c/v1` | 个人客户 Token 与资产归属边界 | `internal/routes/personal.go` | `openspec/specs/personal-customer/spec.md` |
| `/api/open/v1` | 代理 Open API 独立认证/签名,不复用后台账号权限 | `internal/routes/open.go` | `openspec/specs/agent-open-api/spec.md` |
| `/api/callback` | 无登录认证;每类渠道必须在 Handler/Adapter 内验签、解密、校验金额或事件身份 | `internal/routes/order.go`、`wecom_callback.go` 及运营商回调注册 | `openspec/specs/external-integration/spec.md`、`order-payment-wallet` |
@@ -72,6 +72,7 @@ HTTP 入参、回调 Body/Header、上传文件和代理签名材料均是不可
| 个人客户 | `routes/personal.go` 及 C 端子路由 | `handler/app`;`service/client_*|personal_customer|customer_binding`;通知 Application/Query | Token、资产绑定、钱包、支付、Gateway | [`personal-customer`](openspec/specs/personal-customer/spec.md) |
| 代理 Open API | `routes/open.go` | `handler/openapi` → `service/agent_open_api` | 独立认证、店铺数据范围、卡/套餐/钱包 Store | [`agent-open-api`](openspec/specs/agent-open-api/spec.md) |
| 轮询、通知、导出、配置与审计调查 | `polling_*.go`、`notification.go`、`export_task.go`、`system_config.go`、`audit.go`;Scheduler/Task/Outbox | `application/notification|systemconfig|auditarchive|outbox`;`query/audit|notification|outbox|systemconfig`;旧 polling/export Service | Asynq、Outbox、审计库、Integration Log、对象存储 | [`operations-audit`](openspec/specs/operations-audit/spec.md) |
| 运营报表(设备激活与套餐续费日报快照) | `routes/operations_report.go`(汇总/趋势/受控导出);异步 `TaskTypeOperationsReportSnapshot` 每日 03:30 | `application/operationsreport`(生成用例)→ `domain/operationsreport`(口径域);读取 `query/operationsreport`;导出 `exporter/operations_report_scene.go` | `tb_operations_report_snapshot`、`tb_operations_report_activation_row`、`tb_operations_report_renewal_row`(迁移 `000231`,整日替换);只读适配 `infrastructure/operationsreport` | [`operations-report`](openspec/specs/operations-report/spec.md) |
| 外部集成接点 | 支付/运营商/企微回调,Gateway 调用,对象存储、短信 | `internal/gateway`、`internal/infrastructure/wecom|payment|carriercallback|integrationlog`、`pkg/alipay|wechat|fuiou|sms|storage` | 第三方网络和凭证;协议事实见 integrations | [`external-integration`](openspec/specs/external-integration/spec.md) |
任务 7 会把当前过大的 Spec 索引拆成完整业务能力;拆分后必须同步本表链接。
@@ -114,15 +115,8 @@ rg -o 'constants\.OutboxEventType[A-Za-z0-9_]+' cmd/worker/main.go internal | so
需要业务事实与可靠异步副作用原子提交的用例应同事务写 Outbox;是否已经做到必须逐调用链核对。Relay 和消费者按至少一次投递设计,消费者必须容忍重复。
## 外部调用与回调
- 支付:创建本地支付事实后调用渠道;回调需要验签、金额/业务号/状态校验,重复回调不得重复入账。
- 企业微信:本地审批实例 → Outbox 提交 → 渠道审批;加密回调、轮询和人工同步进入统一状态同步语义。
- Gateway/运营商:查询与控制结果来自外部;超时或未知结果不得理想化为成功。
- 对象存储:上传/预签名 URL 是外部副作用,数据库只保存对象引用。
- SMS:发送结果是外部通知结果,不等同业务事务成功。
外部网络调用不应持有长数据库事务;确需提交后可靠执行时使用 Outbox/Task。实际例外按 As-Is Spec 记录。
- 支付:创建本地支付事实、冻结商户池路由并写入一次不可变路由尝试事实后,事务外调用渠道;回调需要验签、金额/业务号/状态校验,重复回调不得重复入账或重复统计。预下单明确失败条件禁用冻结商户并写入脱敏失败留痕;超时、连接中断、响应无法确认和上下文取消保持结果未知,进入查单/恢复边界,不自动禁用或创建第二个渠道支付。
- 商户池金额/笔数轮询按支付创建提交时的路由尝试事实统计,后续成功、失败、取消、过期或未知不新增、不回冲;历史支付成功事实只作兼容读取。
## 事务、审计与日志边界

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

1056
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
@@ -27,9 +28,30 @@ 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.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
handlers.AgentDistributionRegistration = admin.NewAgentDistributionRegistrationHandler(nil, nil, nil)
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(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)
handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil)

View File

@@ -2,6 +2,7 @@ package main
import (
stdErrors "errors"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"os"
"os/signal"
"reflect"
@@ -22,9 +23,11 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
pkgbootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
"github.com/break/junhong_cmp_fiber/pkg/config"
@@ -70,7 +73,12 @@ func main() {
storageSvc := initStorage(cfg, appLogger)
// 9. 初始化 Gateway 客户端(可选)
gatewayClient := initGateway(cfg, appLogger)
gatewayClient := initGateway(cfg, db, appLogger)
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
if gatewayClient != nil {
gatewayClient.SetCallGuard(gatewayLimit.Guard)
}
// 10. 初始化所有业务组件(通过 Bootstrap)
result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
@@ -83,6 +91,7 @@ func main() {
QueueClient: queueClient,
StorageService: storageSvc,
GatewayClient: gatewayClient,
GatewayLimit: gatewayLimit,
})
if err != nil {
appLogger.Fatal("初始化业务组件失败", zap.Error(err))
@@ -138,6 +147,7 @@ func initLogger(cfg *config.Config) *zap.Logger {
appLogger := logger.GetAppLogger()
appLogger.Info("应用程序启动中...", zap.String("address", cfg.Server.Address))
applyAuditPersistOverrides(cfg, appLogger)
return appLogger
}
@@ -441,7 +451,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
return storage.NewService(provider, &cfg.Storage)
}
func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client {
if cfg.Gateway.BaseURL == "" {
appLogger.Info("Gateway 未配置,跳过初始化")
return nil
@@ -453,6 +463,10 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
cfg.Gateway.AppSecret,
appLogger,
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
if cfg.Gateway.ReadonlyEnabled {
// 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。
client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly)
}
appLogger.Info("Gateway 客户端初始化成功",
zap.String("base_url", cfg.Gateway.BaseURL),
@@ -460,3 +474,10 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
return client
}
// applyAuditPersistOverrides 加载审计落库策略的运行时覆盖;非法项忽略并告警。
func applyAuditPersistOverrides(cfg *config.Config, appLogger *zap.Logger) {
if invalid := auditInfra.SetPersistOverrides(cfg.Audit.PersistOverrides); len(invalid) > 0 {
appLogger.Warn("审计落库策略覆盖存在非法项,已忽略", zap.Strings("invalid", invalid))
}
}

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
@@ -36,8 +37,29 @@ 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.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
handlers.AgentDistributionRegistration = admin.NewAgentDistributionRegistrationHandler(nil, nil, nil)
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(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

@@ -0,0 +1,115 @@
// silence-backfill 是沉默期与首次购买套餐时间历史回填工具(add-package-silence-period,技术方案 §6、§17.4)。
//
// 用法(数据库连接只读取显式传入的 DB_* 环境变量):
//
// DB_HOST=... DB_PORT=... DB_USER=... DB_PASSWORD=... DB_NAME=... DB_SSLMODE=disable \
// go run ./cmd/silence-backfill -target silence # 只输出影响清单(默认 dry-run)
// go run ./cmd/silence-backfill -target silence -execute # 业务确认清单后执行
// go run ./cmd/silence-backfill -target first_purchase # 首次购买套餐时间:只输出可回填数与无订单数
// go run ./cmd/silence-backfill -target first_purchase -execute
//
// 两种回填都只处理为空的记录、分批执行、可重复执行;可在沉默期总开关关闭时运行。
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func main() {
target := flag.String("target", "", "回填对象:silence(套餐沉默期)/ first_purchase(资产首次购买套餐时间)")
execute := flag.Bool("execute", false, "执行写入;不传时只输出影响清单")
flag.Parse()
if *target != "silence" && *target != "first_purchase" {
fail("必须指定 -target silence 或 -target first_purchase")
}
db, err := openDB()
if err != nil {
fail(err.Error())
}
ctx := auditcontext.With(context.Background(), auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: "silence-backfill", ActorName: "沉默期历史回填工具",
Source: constants.AuditSourceWorker,
})
backfill := packagesilenceapp.NewBackfill(db, auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
started := time.Now()
mode := "dry-run(只出清单)"
if *execute {
mode = "execute(执行写入)"
}
fmt.Printf("目标库:%s,对象:%s,模式:%s\n", os.Getenv("DB_NAME"), *target, mode)
var report any
switch *target {
case "silence":
silence, err := backfill.SilenceBackfill(ctx, *execute)
if err != nil {
fail(err.Error())
}
printSilence(silence)
report = silence
case "first_purchase":
firstPurchase, err := backfill.FirstPurchaseBackfill(ctx, *execute)
if err != nil {
fail(err.Error())
}
report = firstPurchase
}
encoded, _ := json.MarshalIndent(report, "", " ")
fmt.Println(string(encoded))
fmt.Printf("耗时:%s\n", time.Since(started).Round(time.Millisecond))
}
func printSilence(report *packagesilenceapp.SilenceBackfillReport) {
fmt.Printf("待回填记录 %d,可回填 %d,未配置默认沉默期 %d(不回填)\n", report.Candidates, report.Backfillable, report.UnconfiguredCount)
fmt.Printf("期内已实名(记为结束)%d,其中实名时间为空 %d;仍在期内 %d;打开总开关后将过期 %d,其中无法付费延期 %d\n",
report.EndedInPeriod, report.RealnamedWithoutTime, report.Waiting, report.WillExpire, report.WillExpireNoPaidExtend)
fmt.Println("按代理分布:")
for _, item := range packagesilenceapp.SortedCounts(report.ByShop) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
fmt.Println("按套餐分布:")
for _, item := range packagesilenceapp.SortedCounts(report.ByPackage) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
if len(report.UnconfiguredPackages) > 0 {
fmt.Println("未配置默认沉默期的套餐:")
for _, item := range packagesilenceapp.SortedCounts(report.UnconfiguredPackages) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
}
}
func openDB() (*gorm.DB, error) {
required := []string{"DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME"}
for _, key := range required {
if strings.TrimSpace(os.Getenv(key)) == "" {
return nil, fmt.Errorf("缺少环境变量 %s(只使用显式传入的数据库连接)", key)
}
}
sslMode := os.Getenv("DB_SSLMODE")
if sslMode == "" {
sslMode = "disable"
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"), sslMode)
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
}
func fail(message string) {
fmt.Fprintln(os.Stderr, "回填失败:"+message)
os.Exit(1)
}

View File

@@ -0,0 +1,356 @@
package main
import (
"context"
"encoding/json"
"os"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
// carrierInitLockKey 新结构初始化锁:多实例时只由一个实例初始化。
const carrierInitLockKey = "polling:carrier:init_lock"
// carrierSchedulerRuntime 是按运营商账户调度在 Worker 内的运行组件:模式监听、初始化、分发器与 NSQ 消费者。
type carrierSchedulerRuntime struct {
cfg config.CarrierSchedulerConfig
runtime *workerRuntime
initializer *polling.CarrierInitializer
dispatcher *carrierqueue.Dispatcher
consumers *nsqbus.ConsumerManager
producer *nsqbus.Producer
alerts *carrierqueue.AlertWriter
executor *task.CarrierCardExecutor
// 停复机指令与设备判断的专用消费者:只看停复机出口开关,不受 scheduler_mode 限制。
srConsumers *nsqbus.ConsumerManager
devConsumers *nsqbus.ConsumerManager
srActive bool
// ingest 是轮询观测入库消费者与积压监控(add-polling-async-ingest);未配置 nsqd 时为 nil。
ingest *pollingIngestRuntime
singleton bool
instanceID string
logger *zap.Logger
mu sync.Mutex
lastMode string
reinitQueued bool
legacyRestarts func()
}
// newCarrierSchedulerParts 在 Worker 运行时初始化阶段创建调度参数、账户队列存储与模式接缝,
// 并注入旧调度相关组件(生命周期、轮询基类),使其在启动前即可按模式写入正确结构。
func newCarrierSchedulerParts(runtime *workerRuntime, appLogger *zap.Logger) {
settings := carrierqueue.NewDefaultSettingsProvider(runtime.db, runtime.redisClient, appLogger)
store := carrierqueue.NewStore(runtime.redisClient)
scheduling := polling.NewCarrierScheduling(store, settings, runtime.pollingConfigMgr, appLogger)
runtime.carrierSettings = settings
runtime.carrierStore = store
runtime.carrierScheduling = scheduling
runtime.lifecycleSvc.SetCarrierScheduling(scheduling)
runtime.pollingBase.SetSchedulerModeReader(func(ctx context.Context) string { return settings.Current(ctx).Mode })
if stopResumeSvc, ok := runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetCommandQueue(carrierqueue.NewCommandQueue(store, settings))
}
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Classifier != nil {
classifier := runtime.gatewayLimit.Classifier
runtime.pollingBase.SetErrorClassifier(func(ctx context.Context, op, message string) string {
return classifier.Classify(ctx, op, message).Category
})
}
}
// startCarrierScheduler 启动模式监听;分发器只在 leader/all 运行(多实例安全),消费者在所有处理任务的实例运行。
func startCarrierScheduler(ctx context.Context, cfg *config.Config, runtime *workerRuntime, steps queue.PollingStepHandlers,
singleton bool, legacyRestart func(), appLogger *zap.Logger) *carrierSchedulerRuntime {
carrierCfg := cfg.Polling.Carrier
instanceID := cfg.Worker.InstanceName
if instanceID == "" {
instanceID, _ = os.Hostname()
}
instanceID += "-" + strconv.Itoa(os.Getpid())
alerts := carrierqueue.NewAlertWriter(runtime.db, runtime.redisClient, runtime.carrierSettings, appLogger)
alerts.Start(ctx)
executor := task.NewCarrierCardExecutor(runtime.pollingBase, runtime.carrierStore, runtime.carrierSettings, alerts,
steps.Realname, steps.Carddata, steps.CardStatus, steps.Package, carrierCfg, instanceID)
c := &carrierSchedulerRuntime{
cfg: carrierCfg, runtime: runtime, singleton: singleton, instanceID: instanceID, logger: appLogger,
alerts: alerts, executor: executor, legacyRestarts: legacyRestart,
initializer: polling.NewCarrierInitializer(runtime.carrierScheduling, runtime.pollingIotCardStore, appLogger),
}
if carrierCfg.NSQDAddress != "" {
producer, err := nsqbus.NewProducer(carrierCfg.NSQDAddress, appLogger)
if err != nil {
appLogger.Error("创建 NSQ 生产者失败,按运营商账户调度不可用", zap.Error(err))
} else {
c.producer = producer
}
c.consumers = nsqbus.NewConsumerManager(nsqbus.ConsumerConfig{
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
}, constants.NSQChannelWorker, executor.Handle, appLogger)
}
c.ingest = startPollingIngest(ctx, cfg, runtime, c.producer, singleton, c.grace(), appLogger)
c.setupStopResume(carrierCfg, alerts)
var budgets carrierqueue.BudgetProvider
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Limiter != nil {
budgets = runtime.gatewayLimit.Limiter
}
if c.producer != nil {
c.dispatcher = carrierqueue.NewDispatcher(runtime.db, runtime.carrierStore, runtime.carrierSettings, c.producer, budgets,
carrierCfg, instanceID, appLogger)
if singleton {
go c.dispatcher.Run(ctx)
}
}
go c.watch(ctx)
return c
}
// RequestReinit 轮询配置变更时请求按新配置重新初始化新结构(仅 carrier 模式生效)。
func (c *carrierSchedulerRuntime) RequestReinit() {
if c == nil {
return
}
c.mu.Lock()
c.reinitQueued = true
c.mu.Unlock()
}
func (c *carrierSchedulerRuntime) watch(ctx context.Context) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
c.step(ctx)
select {
case <-ctx.Done():
c.shutdown()
return
case <-ticker.C:
}
}
}
func (c *carrierSchedulerRuntime) step(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
c.logger.Error("按运营商账户调度模式监听 panic,已恢复", zap.Any("panic", r))
}
}()
c.syncStopResume(ctx)
mode := c.runtime.carrierSettings.Current(ctx).Mode
c.mu.Lock()
previous := c.lastMode
c.lastMode = mode
reinit := c.reinitQueued
c.reinitQueued = false
c.mu.Unlock()
if mode == constants.PollingSchedulerModeCarrier {
if previous != mode {
c.logger.Info("卡轮询调度模式:按运营商账户调度", zap.String("previous", previous))
}
if c.singleton {
c.ensureInitialized(ctx, reinit)
}
c.syncConsumers(ctx)
return
}
if previous == constants.PollingSchedulerModeCarrier {
c.logger.Info("卡轮询调度模式回到旧分片调度:停止领取与消费,重建旧分片队列")
if c.consumers != nil {
c.consumers.StopAll(c.grace())
c.consumers.Reset()
}
if c.singleton {
c.runtime.redisClient.Del(ctx, constants.RedisCarrierSchedulerInitStateKey)
if c.legacyRestarts != nil {
c.legacyRestarts()
}
}
}
}
// ensureInitialized 切到 carrier 后初始化新结构;初始化完成前分发器不领取。多实例时用初始化锁协调。
func (c *carrierSchedulerRuntime) ensureInitialized(ctx context.Context, reinit bool) {
redisClient := c.runtime.redisClient
state, _ := redisClient.Get(ctx, constants.RedisCarrierSchedulerInitStateKey).Result()
if state == carrierqueue.InitStateDone && !reinit {
return
}
ok, err := redisClient.SetNX(ctx, carrierInitLockKey, c.instanceID, 30*time.Minute).Result()
if err != nil || !ok {
return
}
defer redisClient.Del(context.WithoutCancel(ctx), carrierInitLockKey)
if state != carrierqueue.InitStateDone {
redisClient.Set(ctx, constants.RedisCarrierSchedulerInitStateKey, carrierqueue.InitStateRunning, 0)
}
started := time.Now()
written, initErr := c.initializer.Run(ctx)
if initErr != nil {
c.logger.Error("按运营商账户调度初始化失败,下一轮重试", zap.Error(initErr))
return
}
redisClient.Set(ctx, constants.RedisCarrierSchedulerInitStateKey, carrierqueue.InitStateDone, 0)
c.logger.Info("按运营商账户调度初始化完成,开始领取", zap.Int64("cards", written), zap.Duration("duration", time.Since(started)),
zap.String("metric", "polling.carrier.initialized"))
}
// syncConsumers 按当前运营商账户订阅卡任务 topic,max_in_flight 取账户调度参数。
func (c *carrierSchedulerRuntime) syncConsumers(ctx context.Context) {
if c.consumers == nil || c.dispatcher == nil {
return
}
desired := map[string]int{}
for _, carrierID := range c.dispatcher.CarrierIDs(ctx) {
desired[constants.NSQCardTopic(carrierID)] = c.dispatcher.ParamsFor(carrierID).CardMaxInFlight
}
c.consumers.Sync(desired)
}
func (c *carrierSchedulerRuntime) grace() time.Duration {
if c.cfg.ShutdownGrace > 0 {
return c.cfg.ShutdownGrace
}
return 30 * time.Second
}
// setupStopResume 创建停复机指令执行器、设备判断执行器与两个专用消费者。
func (c *carrierSchedulerRuntime) setupStopResume(carrierCfg config.CarrierSchedulerConfig, alerts *carrierqueue.AlertWriter) {
service, ok := c.runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService)
if !ok || carrierCfg.NSQDAddress == "" {
return
}
var classifier *gatewaylimit.Classifier
if c.runtime.gatewayLimit != nil {
classifier = c.runtime.gatewayLimit.Classifier
}
params := func(carrierID uint) carrierqueue.CarrierParams {
if c.dispatcher != nil {
return c.dispatcher.ParamsFor(carrierID)
}
return carrierqueue.MergeParams(model.CarrierPollingParam{}, false, carrierCfg)
}
srExecutor := task.NewStopResumeExecutor(c.runtime.carrierStore, c.runtime.carrierSettings, params, alerts, service, classifier,
c.instanceID, c.logger)
devExecutor := task.NewDeviceEvalExecutor(c.runtime.carrierStore, service, c.instanceID, c.logger)
consumerCfg := nsqbus.ConsumerConfig{
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
}
c.srConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, srExecutor.Handle, c.logger)
c.devConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, devExecutor.Handle, c.logger)
}
// syncStopResume 停复机出口开关开启时订阅各账户停复机 topic 与设备判断 topic;关闭时停止订阅(队列中的指令保留)。
func (c *carrierSchedulerRuntime) syncStopResume(ctx context.Context) {
if c.srConsumers == nil || c.dispatcher == nil {
return
}
enabled := c.runtime.carrierSettings.Current(ctx).StopResumeQueueEnabled
if !enabled {
if c.srActive {
c.logger.Info("停复机出口开关已关闭:停止停复机指令消费,未执行的指令保留")
c.srConsumers.StopAll(c.grace())
c.srConsumers.Reset()
c.devConsumers.StopAll(c.grace())
c.devConsumers.Reset()
c.srActive = false
}
return
}
if !c.srActive {
c.logger.Info("停复机出口开关已开启:自动停复机经停复机指令队列执行")
c.srActive = true
}
desired := map[string]int{}
for _, carrierID := range c.dispatcher.CarrierIDs(ctx) {
desired[constants.NSQStopResumeTopic(carrierID)] = c.dispatcher.ParamsFor(carrierID).SRMaxInFlight
}
c.srConsumers.Sync(desired)
c.devConsumers.Sync(map[string]int{constants.NSQDeviceEvalTopic: deviceEvalMaxInFlight})
}
// deviceEvalMaxInFlight 是每进程设备判断消费者并发。
const deviceEvalMaxInFlight = 10
// shutdown 优雅退出:停止订阅,宽限期内等待执行中的卡任务完成;未完成的由租约到期后重新领取。
func (c *carrierSchedulerRuntime) shutdown() {
// 三类消费者并行停止,共用同一宽限期。
var wg sync.WaitGroup
for _, manager := range []*nsqbus.ConsumerManager{c.consumers, c.srConsumers, c.devConsumers} {
if manager == nil {
continue
}
wg.Add(1)
go func(m *nsqbus.ConsumerManager) {
defer wg.Done()
m.StopAll(c.grace())
}(manager)
}
wg.Wait()
// 卡任务消费者停止后不再产生入库消息,再停入库与采集记录消费者,最后停生产者。
c.ingest.shutdown()
if c.producer != nil {
c.producer.Stop()
}
}
// InitProgress 返回新结构初始化进度。
func (c *carrierSchedulerRuntime) InitProgress() polling.InitProgress {
return c.initializer.Progress()
}
// carrierAwareSeriesConsumer 是业务事件观测序列 Outbox 消费者的模式接缝:按运营商账户调度时把业务事件改为按场景请求检查
// (事件在原业务事务中写入,消费即在事务提交之后),旧分片调度时沿用原观测序列。
type carrierAwareSeriesConsumer struct {
legacy outbox.EventConsumer
requester *polling.CheckRequester
}
// Consume 消费业务观测序列请求事件。
func (c *carrierAwareSeriesConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c.requester == nil || !c.requester.Active(ctx) {
return c.legacy.Consume(ctx, envelope)
}
var event cardObservationApp.SeriesRequestedEvent
if err := json.Unmarshal(envelope.Payload, &event); err != nil {
return nil
}
cardIDs := []uint{event.ResourceID}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
cardIDs = event.ResourceIDs
}
c.requester.RequestCards(ctx, businessEventCheckScene(event), cardIDs, event.ExpectedValue)
return nil
}
// businessEventCheckScene 把业务观测事件映射为请求检查场景:订单支付、套餐激活、自动续费购包按事件来源区分,停复机成功按观测场景。
func businessEventCheckScene(event cardObservationApp.SeriesRequestedEvent) string {
switch {
case strings.HasPrefix(event.EventID, "card-observation:order:"):
return constants.PollingCheckSceneOrderPaid
case strings.HasPrefix(event.EventID, "card-observation:auto-purchase:"), strings.HasPrefix(event.EventID, "asset-auto-renewal:"):
return constants.PollingCheckSceneAutoRenewalPurchase
case event.Scene == constants.CardObservationSceneBusinessStop || event.Scene == constants.CardObservationSceneBusinessResume:
return constants.PollingCheckSceneStopResumeSucceeded
default:
return constants.PollingCheckScenePackageActivated
}
}

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"os"
"os/signal"
"strconv"
@@ -16,26 +17,39 @@ 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/domain/cardflow"
"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/gatewaylimit"
"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"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/task"
@@ -82,6 +96,21 @@ type workerRuntime struct {
pollingIotCardStore *postgres.IotCardStore
pollingBase *task.PollingBase
lifecycleSvc *polling.PollingLifecycleService
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
refundChannelService *refundchannelApp.Service
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
carrierThresholdService *carrierThresholdApp.Service
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
assetAutoRenewalService *assetAutoRenewalApp.Service
// gatewayLimit 是卡网关运营商账户限流组件(限流、统计、停复机记录)。
gatewayLimit *gatewaylimit.Runtime
// 按运营商账户调度:调度参数、账户队列存储与模式接缝(模式为 carrier 时各入口写新结构)。
carrierSettings *carrierqueue.SettingsProvider
carrierStore *carrierqueue.Store
carrierScheduling *polling.CarrierScheduling
// carrierReinit 在 carrier 模式下轮询配置变更时请求重新初始化新结构。
carrierReinit func()
}
func main() {
@@ -122,6 +151,14 @@ func runWorker(cfg *config.Config) {
defer func() {
_ = logger.Sync() // 忽略 sync 错误
}()
if invalid := auditInfra.SetPersistOverrides(cfg.Audit.PersistOverrides); len(invalid) > 0 {
logger.GetAppLogger().Warn("审计落库策略覆盖存在非法项,已忽略", zap.Strings("invalid", invalid))
}
retentionLogger, syncRetentionLogger := logger.NewRetentionLogger(cfg.Logging.Level, logger.LogRotationConfig{
Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize,
MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress,
})
defer func() { _ = syncRetentionLogger() }()
appLogger := logger.GetAppLogger()
ctx, cancel := context.WithCancel(context.Background())
@@ -140,17 +177,39 @@ func runWorker(cfg *config.Config) {
if runsSingletonModules(cfg.Worker.Role) {
pollingInitializer = startPollingInitializer(ctx, runtime, appLogger)
startTrafficCollectionPartitionMaintainer(ctx, runtime, appLogger)
pollingScheduler = startPollingScheduler(ctx, runtime, pollingInitializer, appLogger)
asynqScheduler = startAsynqScheduler(cfg, runtime.redisAddr, appLogger)
}
taskHandler := createTaskHandler(runtime, appLogger)
taskHandler.RegisterHandlers()
carrierRuntime := startCarrierScheduler(ctx, cfg, runtime, taskHandler.PollingSteps(), runsSingletonModules(cfg.Worker.Role), func() {
if pollingInitializer != nil {
pollingInitializer.Restart(ctx)
}
}, appLogger)
runtime.carrierReinit = carrierRuntime.RequestReinit
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger)
registerAssetAutoRenewalTasks(taskHandler.GetMux(), runtime, appLogger)
registerPollingPriorityExpiryTask(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)
partitionMaintainer := gatewaylimit.NewPartitionMaintainer(postgres.NewCardStopResumeRecordStore(runtime.db), runtime.gatewayLimit.Settings, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypeStopResumeRecordPartition, partitionMaintainer.Handle)
if runtime.workerResult.Services.AssetPolling != nil {
importHandler := task.NewPollingStatusImportHandler(runtime.db, runtime.storageSvc, runtime.workerResult.Services.AssetPolling, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypePollingStatusImport, importHandler.Handle)
}
dueRecalculator := polling.NewDueRecalculator(runtime.db, runtime.redisClient, runtime.pollingConfigMgr, runtime.pollingQueueMgr, appLogger)
dueRecalculator.SetCarrierScheduling(runtime.carrierScheduling)
recalcHandler := polling.NewRecalculateTaskHandler(dueRecalculator, runtime.redisClient, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypePollingRecalculateDue, recalcHandler.Handle)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
rescuePendingImportTasks(ctx, runtime, appLogger)
@@ -238,7 +297,12 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
}
storageSvc := initStorage(cfg, appLogger)
gatewayClient := initGateway(cfg, appLogger)
gatewayClient := initGateway(cfg, db, appLogger)
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
if gatewayClient != nil {
gatewayClient.SetCallGuard(gatewayLimit.Guard)
}
asynqClient := asynq.NewClient(asynq.RedisClientOpt{
Addr: redisAddr,
@@ -255,6 +319,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
QueueClient: workerQueueClient,
StorageService: storageSvc,
GatewayClient: gatewayClient,
GatewayLimit: gatewayLimit,
}
workerResult, err := bootstrap.BootstrapWorker(workerDeps)
@@ -262,11 +327,19 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
appLogger.Fatal("Worker Bootstrap 失败", zap.Error(err))
}
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger, queue.WithRateLimitedRetry(queue.RateLimitedRetry{
Match: gateway.IsRateLimited,
Delay: func() time.Duration {
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
},
OnDeferred: gatewayLimit.TrackDeferral,
}))
outboxQueueClient := workerQueueClient
pollingConfigStore := postgres.NewPollingConfigStore(db)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger)
pollingConfigMgr.SetTierResolver(polling.NewTierResolver(postgres.NewPackageUsageStore(db, redisClient),
postgres.NewDeviceSimBindingStore(db, redisClient), postgres.NewCarrierStore(db), appLogger))
if err := pollingConfigMgr.Load(ctx); err != nil {
appLogger.Warn("加载轮询配置失败,使用空配置继续", zap.Error(err))
}
@@ -274,15 +347,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,
)
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
lifecycleSvc := polling.NewPollingLifecycleService(
@@ -293,9 +357,32 @@ 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,
)
pollingBase.SetRateLimitedDelay(func() time.Duration {
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
})
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
if workerResult.Services.IotCardPolling != nil {
// 轮询开关导入与订单自动恢复轮询经该卡服务改开关,开启后按生命周期重新入队。
workerResult.Services.IotCardPolling.SetPollingCallback(lifecycleSvc)
}
if workerResult.Services.CarrierThreshold == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
runtime := &workerRuntime{
redisAddr: redisAddr,
@@ -303,6 +390,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
db: db,
storageSvc: storageSvc,
gatewayClient: gatewayClient,
gatewayLimit: gatewayLimit,
asynqClient: asynqClient,
workerResult: workerResult,
workerServer: workerServer,
@@ -313,14 +401,70 @@ 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,
}
newCarrierSchedulerParts(runtime, appLogger)
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)
@@ -334,7 +478,9 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
)
consumer := wecomInfra.NewApprovalSubmissionConsumer(
wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter),
wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter).SetDecisionWriters(
approvalInfra.NewTerminalEventWriter(outbox.NewRepository()), approvalInfra.NewDecisionDeliveryStore(runtime.db),
),
wecomInfra.NewApprovalSubmissionClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
wecomInfra.NewApprovalAttachmentUploader(tokenProvider, integrationRepository, runtime.storageSvc, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
)
@@ -342,6 +488,12 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
appLogger.Fatal("注册企业微信审批提交 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeApprovalSubmissionRequested), zap.Error(err))
}
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalUnknownRecoveryRequested, wecomInfra.NewUnknownRecoveryConsumer(runtime.db, runtime.outboxQueueClient)); err != nil {
appLogger.Fatal("注册企业微信未知审批恢复消费者失败", zap.Error(err))
}
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalManualSyncRequested, wecomInfra.NewManualSyncConsumer(runtime.db, runtime.outboxQueueClient)); err != nil {
appLogger.Fatal("注册企业微信手动同步消费者失败", zap.Error(err))
}
owner := cfg.Worker.InstanceName
if owner == "" {
owner = fmt.Sprintf("worker-%d", os.Getpid())
@@ -370,7 +522,23 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
)
refundService.SetNotificationOutbox(outbox.NewRepository())
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient))
refundService.SetLifecycleAudit(auditWriter)
// 员工代收款退款冲销与建账共用同一审计 Writer,接入点仅在企微退款成功事务内。
refundService.SetEmployeeCollectionRefundOffset(
employeecollectionApp.NewRefundOffsetService(auditWriter),
)
refundChannelService := refundchannelApp.NewService(
runtime.db,
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(runtime.redisClient), appLogger),
auditWriter,
).SetLogger(appLogger).SetCompletionNotifier(refundService)
refundService.SetChannelRefundService(refundChannelService)
runtime.refundChannelService = refundChannelService
if err := runtime.outboxConsumers.Register(refundchannelApp.EventRefundChannelRefund, refundchannelApp.NewConsumer(refundChannelService)); err != nil {
appLogger.Fatal("注册渠道原路退款 Outbox 消费者失败", zap.Error(err))
}
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
}
@@ -384,8 +552,25 @@ 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,
),
// 沉默期审批(购买时调整、线下付费延期、特殊延期):总开关关闭时照常处理
constants.ApprovalBusinessTypePackageSilence: packagesilenceApp.NewDecisionHandler(runtime.workerResult.Services.SilenceExtensions),
},
owner,
appLogger,
@@ -416,7 +601,9 @@ func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg
approvalInfra.NewDecisionDeliveryStore(runtime.db), nil,
)
decisionSync.SetAuditWriter(auditWriter)
contexts := wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter)
contexts := wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter).SetDecisionWriters(
approvalInfra.NewTerminalEventWriter(outbox.NewRepository()), approvalInfra.NewDecisionDeliveryStore(runtime.db),
)
detailHandler := wecomInfra.NewApprovalDetailTaskHandler(
wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
contexts, decisionSync, integrationRepository,
@@ -442,8 +629,10 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
)
recovery := agentrechargeApp.NewRecoverOnlinePaymentService(
runtime.db,
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
paymentInfra.NewFuiouScanAdapter(integration, appLogger),
confirm,
runtime.workerResult.Services.PaymentAudit,
)
@@ -452,6 +641,74 @@ 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))
}
// registerPollingPriorityExpiryTask 注册优先轮询项有效期到期处理任务。
// 该任务是超期出队的唯一触发器:查询与读侧都不隐式触发过期处理。
func registerPollingPriorityExpiryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.db == nil {
appLogger.Fatal("数据库未配置,无法注册优先轮询项有效期到期处理任务")
}
expiryService := pollingSvc.NewPriorityExpiryService(
runtime.db,
postgres.NewPollingPriorityItemStore(runtime.db),
appLogger,
)
expiryService.SetAudit(auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
handler := task.NewPollingPriorityExpiryHandler(expiryService, appLogger)
mux.HandleFunc(constants.TaskTypePollingPriorityExpiry, handler.Handle)
appLogger.Info("注册优先轮询项有效期到期处理任务处理器", zap.String("task_type", constants.TaskTypePollingPriorityExpiry))
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
@@ -485,7 +742,10 @@ func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *za
queue.NewCardObservationSeriesScheduler(runtime.outboxQueueClient),
cardObservationInfra.NewSeriesAttemptLogger(integrationlog.NewRepository(runtime.db)),
)
seriesConsumer := cardObservationInfra.NewSeriesRequestedConsumer(seriesTrigger)
seriesConsumer := &carrierAwareSeriesConsumer{
legacy: cardObservationInfra.NewSeriesRequestedConsumer(seriesTrigger),
requester: polling.NewCheckRequester(runtime.carrierScheduling, runtime.db, appLogger),
}
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardSeriesRequested, seriesConsumer); err != nil {
appLogger.Fatal("注册业务卡观测序列 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeCardSeriesRequested), zap.Error(err))
@@ -603,6 +863,43 @@ func startOutboxRelay(ctx context.Context, runtime *workerRuntime, instanceName
appLogger.Info("Outbox Relay 已启动", zap.String("lease_owner", owner))
}
// startTrafficCollectionPartitionMaintainer 为 leader/all 维护卡流量采集记录(成功与失败)月分区并扫描采集告警:
// 每 12 小时预建本月起 2 个月的分区,到期分区导出为 csv.gz、校验后删除;每天扫描一次前一天的采集告警。
func startTrafficCollectionPartitionMaintainer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
archiver := cardObservationInfra.NewCollectionArchiver(runtime.db, constants.TrafficCollectionArchiveDir, constants.TrafficCollectionArchiveKeepFiles, appLogger)
alerts := cardObservationInfra.NewTrafficAlertScanner(runtime.db, appLogger)
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
var lastMaintained time.Time
var lastScannedDay string
for {
now := time.Now()
if now.Sub(lastMaintained) >= 12*time.Hour {
if err := archiver.Maintain(ctx, now); err != nil && ctx.Err() == nil {
appLogger.Error("维护卡流量采集记录分区失败", zap.Error(err))
} else {
lastMaintained = now
}
}
// 每天扫描一次前一天(上海日期)的采集告警。
yesterday := cardflow.ShanghaiDate(now).AddDate(0, 0, -1)
if day := yesterday.Format("2006-01-02"); day != lastScannedDay {
if err := alerts.Scan(ctx, yesterday); err != nil && ctx.Err() == nil {
appLogger.Error("扫描卡流量采集告警失败", zap.Error(err))
} else {
lastScannedDay = day
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
// startPollingInitializer 为 leader/all 启动渐进式初始化器,并保留配置变更重启逻辑。
func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) *polling.PollingInitializer {
pollingInitializer := polling.NewPollingInitializer(
@@ -615,6 +912,13 @@ func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLog
pollingInitializer.StartBackground(ctx)
runtime.pollingConfigMgr.WatchChanges(ctx, func(hadConfigs, hasConfigs bool) {
if runtime.carrierScheduling.Active(ctx) {
// 按运营商账户调度时按新配置重新初始化新结构,旧分片队列在回退时再重建。
if runtime.carrierReinit != nil {
runtime.carrierReinit()
}
return
}
if hasConfigs {
appLogger.Info("轮询配置已变更,触发队列重新初始化",
zap.Bool("had_configs", hadConfigs))
@@ -667,6 +971,7 @@ func startPollingScheduler(
dataResetHandler,
)
pollingScheduler.SetInitializer(pollingInitializer)
pollingScheduler.SetCarrierScheduling(runtime.carrierScheduling)
if err := pollingScheduler.Start(ctx); err != nil {
appLogger.Error("启动轮询调度器失败", zap.Error(err))
@@ -687,7 +992,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
&asynq.SchedulerOpts{Location: time.Local},
)
if err := registerAsynqScheduleTasks(asynqScheduler); err != nil {
if err := registerAsynqScheduleTasks(asynqScheduler, cfg.Worker.AuditArchiveTasksEnabled); err != nil {
appLogger.Fatal("注册 Asynq 定时任务失败", zap.Error(err))
}
@@ -698,12 +1003,14 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
}()
appLogger.Info("Asynq Scheduler 已启动",
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled))
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled),
zap.Bool("audit_archive_tasks_enabled", cfg.Worker.AuditArchiveTasksEnabled),
zap.Int("polling_total_max_concurrency", cfg.Worker.PollingTotalMaxConcurrency))
return asynqScheduler
}
// registerAsynqScheduleTasks 注册 Worker 入口需要的全部定时任务。
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEnabled bool) error {
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeAgentRechargeRecovery,
nil,
@@ -714,6 +1021,56 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil {
return fmt.Errorf("注册代理在线充值支付恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeRefundChannelRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundChannelRecovery)),
)); err != nil {
return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypePollingPriorityExpiry,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePollingPriorityExpiry)),
)); 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,
@@ -733,6 +1090,8 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
nil,
asynq.MaxRetry(3),
asynq.Timeout(2*time.Minute),
// 同一时刻只保留一个恢复任务:队列拥堵时不再堆积重复的恢复任务。
asynq.Unique(2*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeWeComApprovalRecovery)),
)); err != nil {
return fmt.Errorf("注册企业微信审批主动恢复定时任务失败: %w", err)
@@ -744,6 +1103,14 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil {
return fmt.Errorf("注册数据清理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 2 * * *", asynq.NewTask(
constants.TaskTypeStopResumeRecordPartition,
nil,
asynq.MaxRetry(3),
asynq.Queue(constants.QueueDataCleanup),
)); err != nil {
return fmt.Errorf("注册停复机记录分区维护定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("15 2 * * *", asynq.NewTask(
constants.TaskTypeNotificationCleanup,
nil,
@@ -761,6 +1128,56 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil {
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
}
// 沉默期延期订单恢复:每 5 分钟查单、超时关闭、异常订单原路退款;不受总开关影响。
if _, err := asynqScheduler.Register(constants.SilenceOrderRecoverCron, asynq.NewTask(
constants.TaskTypePackageSilenceOrderRecover,
nil,
asynq.MaxRetry(3),
asynq.Timeout(4*time.Minute),
asynq.Unique(4*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceOrderRecover)),
)); err != nil {
return fmt.Errorf("注册沉默期延期订单恢复定时任务失败: %w", err)
}
// 沉默期扫描:每 10 分钟;总开关关闭时任务直接返回(技术方案 §5.4)。
if _, err := asynqScheduler.Register(constants.SilenceScanCron, asynq.NewTask(
constants.TaskTypePackageSilenceScan,
nil,
asynq.MaxRetry(constants.SilenceScanMaxRetry),
asynq.Timeout(constants.SilenceScanUniqueTTL),
asynq.Unique(constants.SilenceScanUniqueTTL),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceScan)),
)); 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(
@@ -773,6 +1190,24 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
); err != nil {
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
}
// 运营报表日报快照:上海时区 03:30,晚于每日流量落盘(02:00)、套餐临期提醒(03:00)与 10 秒级到期处理之后。
// 定时调度不带载荷,目标日在处理器内按上海时区取前一自然日(asynq 的 Scheduler 只能注册静态 Task,
// 无法按次生成 payload);失败重试沿用同一目标日期的依据是「cron 时点 + 有界重试窗口」
// (默认重试延迟 n^4+15+rand(0..29)*(n+1) 秒,MaxRetry(3) 合计 ≤236 秒,叠加 Timeout 仍落在同一上海自然日内)。
// 调整本 cron 时点、MaxRetry 或 Timeout 必须重新评估该前提。
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 3 * * *", asynq.NewTask(
constants.TaskTypeOperationsReportSnapshot,
nil,
asynq.MaxRetry(3),
asynq.Timeout(30*time.Minute),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOperationsReportSnapshot)),
)); err != nil {
return fmt.Errorf("注册每日运营报表日报快照生成定时任务失败: %w", err)
}
if !auditArchiveEnabled {
return nil
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 4 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyArchive,
nil,
@@ -793,59 +1228,46 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil {
return fmt.Errorf("注册 Integration Log 每日冷归档定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 1 * *", asynq.NewTask(
constants.TaskTypeIntegrationMonthlyFinalize,
nil,
asynq.MaxRetry(10),
asynq.Timeout(6*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationMonthlyFinalize)),
)); err != nil {
return fmt.Errorf("注册 Integration Log 月度最终版本复核任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 1 * *", asynq.NewTask(
constants.TaskTypeAuditMonthlyRetention,
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyRetention,
nil,
asynq.MaxRetry(10),
asynq.Timeout(12*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditMonthlyRetention)),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditDailyRetention)),
)); err != nil {
return fmt.Errorf("注册月度日志留存演练或清理任务失败: %w", err)
return fmt.Errorf("注册日志日留存演练或清理任务失败: %w", err)
}
return nil
}
// registerAuditArchiveTask 注册 Audit 与 Integration 冷归档任务处理器。
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled bool, appLogger *zap.Logger) {
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled, enabled bool, appLogger, retentionLogger *zap.Logger) {
if runtime.storageSvc == nil {
appLogger.Warn("对象存储未配置,审计归档任务将在执行时重试")
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger)
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger, enabled).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger, enabled)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(nil, appLogger, cleanupEnabled).Handle)
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(nil, retentionLogger, cleanupEnabled, enabled).Handle)
return
}
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
if !ok || auditWriter == nil {
appLogger.Fatal("初始化月度日志留存清理失败:统一审计 Writer 未配置")
appLogger.Fatal("初始化日志日留存清理失败:统一审计 Writer 未配置")
}
service, err := auditArchiveApp.NewService(runtime.db, runtime.storageSvc.Provider(), constants.AuditArchiveInstanceID, auditWriter)
if err != nil {
appLogger.Fatal("初始化统一审计归档服务失败", zap.Error(err))
}
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger)
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger, enabled).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger, enabled)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(service, appLogger, cleanupEnabled).Handle)
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(service, retentionLogger, cleanupEnabled, enabled).Handle)
appLogger.Info("注册审计归档任务处理器",
zap.String("audit_task_type", constants.TaskTypeAuditDailyArchive),
zap.String("integration_daily_task_type", constants.TaskTypeIntegrationDailyArchive),
zap.String("integration_monthly_task_type", constants.TaskTypeIntegrationMonthlyFinalize),
zap.String("retention_task_type", constants.TaskTypeAuditMonthlyRetention),
zap.Bool("retention_cleanup_enabled", cleanupEnabled))
zap.String("retention_task_type", constants.TaskTypeAuditDailyRetention),
zap.Bool("retention_cleanup_enabled", cleanupEnabled), zap.Bool("archive_tasks_enabled", enabled))
}
// createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。
@@ -869,6 +1291,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 卡导入任务。
@@ -988,7 +1448,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
}
// initGateway 初始化 Gateway 客户端
func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client {
if cfg.Gateway.BaseURL == "" {
appLogger.Info("Gateway 未配置,跳过初始化(轮询任务将无法查询真实数据)")
return nil
@@ -1000,6 +1460,10 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
cfg.Gateway.AppSecret,
appLogger,
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
if cfg.Gateway.ReadonlyEnabled {
// 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。
client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly)
}
appLogger.Info("Gateway 客户端初始化成功",
zap.String("base_url", cfg.Gateway.BaseURL),

View File

@@ -0,0 +1,160 @@
package main
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// pollingIngestRuntime 是轮询观测入库异步化在 Worker 内的运行组件(add-polling-async-ingest):
// 入库消费者、采集记录批量消费者、活动优先项索引重建与积压监控。入库消费者常驻订阅,
// 使切回同步模式后队列中已有的入库消息仍会被处理完。
type pollingIngestRuntime struct {
runtime *workerRuntime
nsqdAddress string
executor *task.IngestExecutor
collector *task.CollectionBatchConsumer
ingest *nsqbus.ConsumerManager
collection *nsqbus.ConsumerManager
rebuilder *task.PriorityIndexRebuilder
stopCollect context.CancelFunc
grace time.Duration
logger *zap.Logger
}
// startPollingIngest 注入入库发布器并启动入库与采集记录消费者;未配置 nsqd 时返回 nil,轮询保持同步入库。
func startPollingIngest(ctx context.Context, cfg *config.Config, runtime *workerRuntime, producer *nsqbus.Producer,
singleton bool, grace time.Duration, logger *zap.Logger) *pollingIngestRuntime {
carrierCfg := cfg.Polling.Carrier
if producer == nil || carrierCfg.NSQDAddress == "" {
return nil
}
checkIngestConnBudget(cfg, logger)
publisher := task.NewIngestPublisher(producer)
runtime.pollingBase.SetIngest(publisher, func(ctx context.Context) bool {
return runtime.carrierSettings.Current(ctx).IngestAsync()
})
observation := runtime.workerResult.Services.CardObservation
if observation != nil {
observation.SetCollectionSink(publisher)
}
r := &pollingIngestRuntime{
runtime: runtime, nsqdAddress: carrierCfg.NSQDAddress, grace: grace, logger: logger,
executor: task.NewIngestExecutor(runtime.pollingBase, observation, cfg.Polling.Ingest.EffectiveTimeout()),
collector: task.NewCollectionBatchConsumer(runtime.db, cfg.Polling.Collection.EffectiveBatchSize(), logger),
rebuilder: task.NewPriorityIndexRebuilder(postgres.NewPollingPriorityItemStore(runtime.db), runtime.redisClient, logger),
}
collectCtx, stopCollect := context.WithCancel(context.Background())
r.stopCollect = stopCollect
r.collector.Start(collectCtx)
consumerCfg := nsqbus.ConsumerConfig{
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
MsgTimeout: carrierCfg.MsgTimeout, UnlimitedAttempts: true,
}
r.ingest = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.executor.Handle, logger)
r.collection = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.collector.Handle, logger)
r.ingest.Sync(map[string]int{constants.NSQCardIngestTopic: cfg.Polling.Ingest.EffectiveConcurrency()})
r.collection.Sync(map[string]int{constants.NSQCardCollectionTopic: constants.PollingCollectionMaxInFlight})
logger.Info("轮询观测入库消费者已启动", zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()),
zap.Duration("ingest_timeout", cfg.Polling.Ingest.EffectiveTimeout()),
zap.Int("collection_batch_size", cfg.Polling.Collection.EffectiveBatchSize()))
if singleton {
go r.loop(ctx)
}
return r
}
// checkIngestConnBudget 启动时检查入库并发与采集记录写并发不超过数据库连接数减去预留,超过只记 ERROR、不阻止启动。
func checkIngestConnBudget(cfg *config.Config, logger *zap.Logger) {
maxOpen := cfg.Database.MaxOpenConns
if maxOpen <= 0 {
maxOpen = constants.DefaultMaxOpenConns
}
need := cfg.Polling.Ingest.EffectiveConcurrency() + constants.PollingIngestCollectionWriters
if budget := maxOpen - constants.PollingIngestConnReserve; need > budget {
logger.Error("入库并发超出数据库连接预算,请调小 polling.ingest.concurrency 或调大数据库连接数",
zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()),
zap.Int("collection_writers", constants.PollingIngestCollectionWriters),
zap.Int("max_open_conns", maxOpen), zap.Int("reserved", constants.PollingIngestConnReserve))
}
}
// loop 在 leader 上定期重建活动优先项索引并检查入库积压。
func (r *pollingIngestRuntime) loop(ctx context.Context) {
r.rebuilder.Rebuild(ctx)
rebuild := time.NewTicker(constants.PollingPriorityIndexRebuildEvery)
monitor := time.NewTicker(constants.PollingIngestMonitorInterval)
defer rebuild.Stop()
defer monitor.Stop()
for {
select {
case <-ctx.Done():
return
case <-rebuild.C:
r.rebuilder.Rebuild(ctx)
case <-monitor.C:
r.checkBacklog(ctx)
}
}
}
// checkBacklog 记录入库与采集记录积压,超过告警阈值或等待过久记 ERROR;入库积压超过反压阈值时暂停派发新卡任务,降到一半以下恢复。
func (r *pollingIngestRuntime) checkBacklog(ctx context.Context) {
defer func() {
if recovered := recover(); recovered != nil {
r.logger.Error("入库积压检查 panic,已恢复", zap.Any("panic", recovered))
}
}()
settings := r.runtime.carrierSettings.Current(ctx)
ingestDepth, ingestErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardIngestTopic)
collectionDepth, collectionErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardCollectionTopic)
if ingestErr != nil || collectionErr != nil {
r.logger.Warn("读取入库积压失败", zap.NamedError("ingest_error", ingestErr), zap.NamedError("collection_error", collectionErr))
return
}
lag := r.executor.LastLag()
fields := []zap.Field{zap.Int64("ingest_backlog", ingestDepth), zap.Int64("collection_backlog", collectionDepth),
zap.Duration("ingest_lag", lag), zap.String("ingest_mode", settings.IngestMode)}
r.logger.Info("轮询入库积压", append(fields, zap.String("metric", "polling.ingest.backlog"))...)
alertDepth := int64(settings.IngestAlertDepth)
if ingestDepth > alertDepth || collectionDepth > alertDepth || lag > constants.PollingIngestLagAlert {
r.logger.Error("轮询入库积压超过告警阈值", append(fields, zap.Int64("alert_depth", alertDepth))...)
}
r.applyBackpressure(ctx, ingestDepth, int64(settings.IngestPauseDepth))
}
// applyBackpressure 按积压置位或清除暂停标记;标记带 TTL,leader 失联时自动解除。
func (r *pollingIngestRuntime) applyBackpressure(ctx context.Context, depth, pauseDepth int64) {
redisClient := r.runtime.redisClient
paused, _ := redisClient.Exists(ctx, constants.RedisPollingIngestPausedKey).Result()
ttl := 3 * constants.PollingIngestMonitorInterval
switch {
case depth > pauseDepth:
redisClient.Set(ctx, constants.RedisPollingIngestPausedKey, depth, ttl)
if paused == 0 {
r.logger.Error("入库积压超过反压阈值,暂停派发新卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth))
}
case paused > 0 && depth > pauseDepth/2:
redisClient.Expire(ctx, constants.RedisPollingIngestPausedKey, ttl)
case paused > 0:
redisClient.Del(ctx, constants.RedisPollingIngestPausedKey)
r.logger.Info("入库积压已降到反压阈值一半以下,恢复派发卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth))
}
}
// shutdown 先停入库消费者(处理中的消息在宽限期内完成),再停采集记录消费者,最后停止攒批循环(写完已缓冲的一批)。
func (r *pollingIngestRuntime) shutdown() {
if r == nil {
return
}
r.ingest.StopAll(r.grace)
r.collection.StopAll(r.grace)
r.stopCollect()
}

View File

@@ -29,31 +29,34 @@
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:
- "3000:3000"
environment:
# 数据库配置(必填)
- JUNHONG_DATABASE_HOST=cxd.whcxd.cn
- JUNHONG_DATABASE_PORT=16159
- JUNHONG_DATABASE_HOST=kt.whcxd.cn
- JUNHONG_DATABASE_PORT=15434
- JUNHONG_DATABASE_USER=erp_pgsql
- JUNHONG_DATABASE_PASSWORD=erp_2025
- JUNHONG_DATABASE_DBNAME=junhong_cmp_test
- JUNHONG_DATABASE_SSLMODE=disable
# Redis 配置(必填)
- JUNHONG_REDIS_ADDRESS=cxd.whcxd.cn
- JUNHONG_REDIS_PORT=16299
- JUNHONG_REDIS_ADDRESS=kt.whcxd.cn
- JUNHONG_REDIS_PORT=16380
- JUNHONG_REDIS_PASSWORD=cpNbWtAaqgo1YJmbMp3h
- JUNHONG_REDIS_DB=6
# JWT 配置(必填)
- JUNHONG_JWT_SECRET_KEY=dev-secret-key-for-testing-only-32chars!
# 客户端配置(可选,默认 true;false=不强制绑定手机号)
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=false
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=true
# 日志配置
- JUNHONG_LOGGING_LEVEL=info
- JUNHONG_LOGGING_DEVELOPMENT=false
# 应用日志轮换:单文件 100MB(默认),保留 40 个压缩备份,api 与 worker 合计约 1GB
- JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40
- JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
# 跨域配置
- JUNHONG_MIDDLEWARE_CORS_ENABLED=true
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=*
@@ -72,11 +75,13 @@ services:
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
- JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false}
# 七月迭代:企业微信 Adapter 与旧审批入口切换
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
- JUNHONG_WECOM_TIMEOUT=10s
- JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=${JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED:-true}
- JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=${JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED:-true}
# 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变)
# 短信服务配置
- JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
- JUNHONG_SMS_USERNAME=JH0001
@@ -86,7 +91,7 @@ services:
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=https://cmp-admin.boss160.cn,https://cmp-agent.boss160.cn,https://cmp-c.boss160.cn
- JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true
volumes:
- ./logs:/app/logs
- ./api/logs:/app/logs
networks:
- junhong-network
healthcheck:
@@ -102,29 +107,39 @@ 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:
# 数据库配置(必填)
- JUNHONG_DATABASE_HOST=cxd.whcxd.cn
- JUNHONG_DATABASE_PORT=16159
- JUNHONG_DATABASE_HOST=kt.whcxd.cn
- JUNHONG_DATABASE_PORT=15434
- JUNHONG_DATABASE_USER=erp_pgsql
- JUNHONG_DATABASE_PASSWORD=erp_2025
- JUNHONG_DATABASE_DBNAME=junhong_cmp_test
- JUNHONG_DATABASE_SSLMODE=disable
# Redis 配置(必填)
- JUNHONG_REDIS_ADDRESS=cxd.whcxd.cn
- JUNHONG_REDIS_PORT=16299
- JUNHONG_REDIS_ADDRESS=kt.whcxd.cn
- JUNHONG_REDIS_PORT=16380
- JUNHONG_REDIS_PASSWORD=cpNbWtAaqgo1YJmbMp3h
- JUNHONG_REDIS_DB=6
# worker 并发轮询与按账户并行分发共用连接池,默认 10 不够
- JUNHONG_REDIS_POOL_SIZE=100
# 任务并发:默认 10 时流量扣减事件投递跟不上(白天积压 1 万+、延迟 2.5 小时),调到 50
- JUNHONG_QUEUE_CONCURRENCY=50
# 数据库连接池随任务并发放大(默认 25/10);测试库 max_connections=200,API 另占 25
- JUNHONG_DATABASE_MAX_OPEN_CONNS=60
- JUNHONG_DATABASE_MAX_IDLE_CONNS=20
# JWT 配置(必填)
- JUNHONG_JWT_SECRET_KEY=dev-secret-key-for-testing-only-32chars!
# 客户端配置(可选,默认 true;false=不强制绑定手机号)
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=false
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=true
# 日志配置
- JUNHONG_LOGGING_LEVEL=info
- JUNHONG_LOGGING_DEVELOPMENT=false
# 应用日志轮换:单文件 100MB(默认),保留 40 个压缩备份,api 与 worker 合计约 1GB
- JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40
- JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
# 对象存储配置
- JUNHONG_STORAGE_PROVIDER=s3
- JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
@@ -139,20 +154,60 @@ services:
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
- JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false}
- JUNHONG_POLLING_VERBOSE_LOG=true
# 七月迭代:单 Worker 测试环境同时承担调度和消费
- JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all}
- JUNHONG_WORKER_INSTANCE_NAME=${JUNHONG_WORKER_INSTANCE_NAME:-test-worker-all-1}
# 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变)
# Worker 执行企微审批提交、同步和恢复任务
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
- JUNHONG_WECOM_TIMEOUT=10s
# 按运营商账户调度的 NSQ(卡轮询分发;调度模式在受控系统配置 polling.scheduler.mode 切换)
- JUNHONG_POLLING_CARRIER_NSQD_ADDRESS=${JUNHONG_POLLING_CARRIER_NSQD_ADDRESS:-nsqd:4150}
- JUNHONG_POLLING_CARRIER_NSQLOOKUPD_ADDRESSES=${JUNHONG_POLLING_CARRIER_NSQLOOKUPD_ADDRESSES:-nsqlookupd:4161}
volumes:
- ./logs:/app/logs
- ./worker/logs:/app/logs
# 采集记录到期归档压缩包(archive/traffic-collection),由同步服务器拉走
- ./worker/archive:/app/archive
networks:
- junhong-network
depends_on:
api:
condition: service_healthy
nsqd:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# NSQ:只承担卡轮询的执行分发,Redis 是唯一权威状态(add-carrier-polling-scheduler)
nsqlookupd:
image: nsqio/nsq:v1.3.0
container_name: junhong-cmp-nsqlookupd
restart: unless-stopped
command: /nsqlookupd
networks:
- junhong-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
nsqd:
image: nsqio/nsq:v1.3.0
container_name: junhong-cmp-nsqd
restart: unless-stopped
command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 --broadcast-address=nsqd --data-path=/data --mem-queue-size=0 --max-msg-timeout=15m
volumes:
- ./nsq-data:/data
networks:
- junhong-network
depends_on:
- nsqlookupd
logging:
driver: "json-file"
options:

View File

@@ -0,0 +1,118 @@
# 实名即生效套餐沉默期 — 前端接口变更说明
> 对应 OpenSpec Change `add-package-silence-period`;规则以 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md` 为准。接口字段以 `docs/admin-openapi.yaml` 为准,本文只说明页面要做什么。
## 1. 总开关对页面的影响
沉默期总开关 `package.silence.enabled` **默认关闭**。
| 页面 / 功能 | 关闭时 | 打开时 |
|---|---|---|
| 资产套餐列表、H5 套餐历史里的 `silence` 对象 | **不返回**(字段为空),页面不显示沉默期 | 返回,显示状态、截止日 |
| "延期"入口 | 不显示 | `silence.extendable = true` 时显示 |
| 延期资格、试算、下单、线下登记、特殊延期 | 返回错误码 **1274** | 可用 |
| 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 |
| 购买时调整沉默期 | 可用 | 可用 |
| 卡 / 设备首次购买套餐时间 | 不受开关影响 | 不受开关影响 |
后台"系统配置"通用接口不能修改总开关(只读),请用专用接口:
- `GET /api/admin/package-silence/switch`:当前值 + 打开前 4 项检查(`precheck[]`:code、name、passed、count)。
- `PUT /api/admin/package-silence/switch` `{ "enabled": true|false }`:仅超级管理员。打开前检查不通过返回 **1275**,`data.failed_items` 为未满足项,直接展示即可;关闭不检查。
## 2. 套餐配置
- 新增 / 编辑套餐增加 `silence_period_days`(1~90)、`silence_extension_enabled`(默认 true)。
- **实名即生效的正式套餐必填**默认沉默期,新建时前端预填 30;不填或越界返回 **1260**。
- 购买即生效套餐、加油包不显示、不提交这两个字段。
- 套餐响应增加 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必须配置)。
- 实名即生效套餐改为购买即生效、店铺分配(单个、批量、系列授权)里把实名即生效套餐覆盖为购买即生效,返回 **1262**。购买即生效改为实名即生效时,同一请求必须带 `silence_period_days`。
- 购买即生效的正式套餐也可以选填 `silence_period_days`(1~90,不强制;`silence_config_required=false`),加油包不能填。店铺分配把购买即生效套餐覆盖为实名即生效时,套餐已配置默认沉默期才允许,否则返回 **1262**。
- "未配置沉默期的实名即生效套餐"清单:套餐列表加 `silence_unconfigured=true`(包含本身是购买即生效、但有店铺分配覆盖为实名即生效且未配置默认沉默期的套餐);导出用套餐导出场景,筛选同样传 `silence_unconfigured`。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。
## 3. 后台购买时调整沉默期
`POST /api/admin/orders`(后台给资产购买套餐)新增可选字段:
- `silence_period_days`(≥ 1,不设上限)、`silence_reason`(设置时必填)。
- 需要 `package_silence:override` 权限,无权限传值返回 **1261**。
- 不超过默认值:直接生效;超过默认值:按延期政策直接生效,或**先按默认值购买并提交企微审批**(审批中套餐不会过期)。发起人未绑定企微成员时购买被拒。
- 只对"资产未实名时购买的实名即生效主套餐"生效,其他情况忽略。
## 4. 资产套餐展示
后台资产套餐历史 / 当前套餐、H5 套餐历史(`/api/c/v1/asset/package-history`)每笔套餐增加 `silence` 对象(总开关打开且适用沉默期时返回):
| 字段 | 说明 |
|---|---|
| `silence_status` / `silence_status_name` | `waiting` 待实名(延期后显示"延期待实名")/ `expired` 沉默期已过期 / `ended` 沉默期已结束;待实名但已过截止的直接按 `expired` 返回 |
| `silence_deadline_at`、`silence_start_at`、`silence_period_days` | 截止时间(截止日 23:59:59)、起算时间、天数 |
| `silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used` | 延期累计 |
| `silence_expired_at`、`silence_adjust_pending` | 过期时间、调整审批中 |
| `extendable` | 是否显示"延期"入口 |
套餐沉默期流水:后台 `GET /api/admin/package-silence/records?package_usage_id=`,H5 `GET /api/c/v1/package-silence/records?package_usage_id=`。
## 5. 延期(后台)
均在 `/api/admin/package-silence` 下:
| 接口 | 权限 | 说明 |
|---|---|---|
| `GET /eligibility?package_usage_id=` | — | 资格:`can_extend`、`special_extension_available`、`reason`、`min_days`/`max_days`、`min_months`/`max_months`、`unit_price`、`options[]`(每档计费天数、金额、新截止日)。**金额全部由后端算,前端只传天数** |
| `GET /quote?package_usage_id=&days=` | — | 输入天数试算(按 30 天向上取整:1→30、31→60) |
| `POST /orders/qrcode` | `package_silence:extend` | 运营生成支付二维码:入参 `package_usage_id`、`days`、`payment_method`(wechat/alipay)、`request_id`、`evidence_refs`;返回 `qr_content` 渲染为二维码。已有客户 H5 待支付订单时后端先查单,未付自动关闭旧单 |
| `POST /orders/offline` | `package_silence:offline` | 登记线下付款:`offline_payment_method_id`(线下收款方式字典)、`offline_trade_no`、`paid_amount`(必须等于计算金额,否则 **1273**)、`paid_at`、`payment_voucher_keys`(1~5)、`evidence_refs`、`request_id`。交易单号重复 **1272**。默认进企微审批 |
| `GET /orders`、`GET /orders/:id` | — | 延期订单列表 / 详情,含 `payment_method`、`payment_merchant_name`、`third_party_trade_no`;导出场景 `package_silence_order` |
| `POST /special` | `package_silence:extend`(批量另需 `package_silence:extend_batch`) | 特殊延期:`extension_type`(free/discount/complaint_compensation/system_compensation/special_customer)、`items[]`(package_usage_id + days)、`reason`、`basis`、`evidence_refs`、`amount`(免费为 0)、优惠价有收款时填收款方式 / 交易单号 / 凭证。批量明细逐行校验,不通过返回 `data.rows[]`(行号与原因)。免费次数用完 **1267** |
| `GET /batches`、`GET /batches/:id` | — | 批次与明细(`has_exception=true` 只看含异常的) |
| `POST /extensions/:id/handle` | `package_silence:exception` | 异常明细 `action=retry|close` + `reason`;在线订单关闭时原路退款 |
| `GET /usages?view=expiring|expired|all` | — | 即将过期(默认 7 天内)/ 已过期套餐;导出场景 `package_silence_usage` |
延期政策(`package_silence:policy`):`GET/POST /policies`、`GET/PUT /policies/:id`、`PUT /policies/:id/status`、`GET /policies/match?package_id=`。同级重叠返回 **1269**。
## 6. 延期(H5)
均在 `/api/c/v1/package-silence` 下,资产须属于当前客户:
- `GET /eligibility`、`GET /quote`:同后台。
- `POST /orders`:自助下单(`package_usage_id`、`days`、`payment_method`、`request_id`),返回 `qr_content`(支付链接或二维码内容);支付成功自动延期。
- `GET /orders`、`GET /orders/:id`:我的延期订单。
- `GET /records`:沉默期流水。
## 7. 退款
沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。
## 8. 卡 / 设备首次购买套餐时间
- 卡列表、设备列表返回 `first_purchase_at`,支持 `first_purchase_start`、`first_purchase_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `first_purchase_at`。
- 卡、设备导出末尾新增"首次购买套餐时间"列。
- 首次购买套餐记录:`GET /api/admin/package-silence/assets/:asset_type/:id/first-purchase-records`(asset_type 为 card / device),按世代倒序,含来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因。
- 换货后的新资产不会因换货得到首次购买套餐时间,要等它在当前世代真正支付套餐订单;旧资产换货回库后该时间清空,旧世代记录带结束时间与原因"换货回库"。
- 绑定在设备上的卡不记录首次购买套餐时间;只读,不提供修改。
## 9. 错误码
| 码 | 名称 | 说明 |
|---|---|---|
| 1260 | silence_period_out_of_range | 沉默期天数不在允许范围 |
| 1261 | silence_override_forbidden | 无权限调整沉默期 |
| 1262 | package_expiry_base_change_forbidden | 不允许变更该套餐的生效条件 |
| 1263 | silence_extension_not_expired | 套餐未过期 / 无沉默期 / 已激活 / 已退款(`data.reason` 细分) |
| 1264 | silence_extension_disabled | 该套餐不支持付费延期(可申请特殊延期) |
| 1265 | silence_extension_exceeds_limit | 超过可延期期限(`data.min_months`、`data.max_months`) |
| 1266 | silence_extension_months_invalid | 延期天数不在允许范围(`data.min_days`、`data.max_days`) |
| 1267 | silence_free_extension_used_up | 免费延期次数已用完 |
| 1268 | silence_expired_not_refundable | 沉默期已过期的套餐不能退款 |
| 1269 | silence_policy_overlap | 延期政策重叠 |
| 1270 | silence_extension_no_policy | 未匹配到延期政策 |
| 1271 | silence_extension_order_pending | 已有未结束的延期订单或申请 |
| 1272 | silence_offline_trade_no_duplicate | 线下交易单号已登记 |
| 1273 | silence_offline_amount_mismatch | 实收金额与计算金额不一致(`data.expected_amount`) |
| 1274 | silence_feature_disabled | 沉默期功能未开启 |
| 1275 | silence_switch_precheck_failed | 总开关打开前检查未通过(`data.failed_items`) |
## 10. 企微审批模板
超级管理员在"企微审批场景"配置业务类型 **沉默期审批(package_silence_approval)**,模板需有"申请类型"单选控件,选项映射:`购买时调整沉默期`、`特殊延期`、`线下付费延期`。可映射字段见场景配置页的业务字段列表(申请单号、资产、套餐、原截止日期、延期天数、计费天数、费用、原因、附件、收款方式、交易单号、凭证等)。

View File

@@ -0,0 +1,246 @@
# 订单按类别拆分 → 套餐沉默期:前端修改汇总
日期:2026-10-27
范围:`Iteration/8-11` 提交 `eb9f12d1`(后台订单按类别拆分)至 `af57de0d`(沉默期配置口径调整),均已部署测试环境。
接口字段以 `docs/admin-openapi.yaml` 为准;本文说明页面要改什么。
## 一、总览
| # | 变更 | 前端是否要改 | 章节 |
|---|---|---|---|
| 1 | 后台订单按平台 / 代理商 / 在线支付拆分列表与导出 | **要改**:拆三个页面 | 二 |
| 2 | 企业账号只能访问授权给本企业的资产 | **要改**:企业账号隐藏部分资产操作 | 三 |
| 3 | 实名即生效套餐沉默期、延期办理 | **要改**:套餐配置、购买、资产套餐展示、延期页面、总开关、政策 | 四 |
| 4 | 卡 / 设备首次购买套餐时间 | **要改**:列表列、筛选、详情、记录弹窗 | 五 |
| 5 | 后台线下下单不再绕开实名 | 不改接口,展示需知晓 | 六 |
| 6 | 退款 0 元可提交审批 | 不改接口 | 六 |
| 7 | 排队激活停复机、孤儿套餐、微信查单 | 不涉及前端 | — |
---
## 二、后台订单按类别拆分
### 2.1 新接口
| 页面 | 列表接口 | 导出场景(`POST /api/admin/export-tasks` 的 `scene`) | 包含 |
|---|---|---|---|
| 平台订单 | `GET /api/admin/orders/platform` | `order_platform` | 后台下单且线下支付:平台自营、赠送、平台代购代理名下的卡 |
| 代理商订单 | `GET /api/admin/orders/agent` | `order_agent` | 后台下单且从代理钱包扣款:代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单 |
| 在线支付订单 | `GET /api/admin/orders/online` | `order_online` | 个人客户在客户端下的单:微信、支付宝、卡钱包支付,自动续费,自动购买,待支付 |
- 三个列表接口的查询参数、返回结构与 `GET /api/admin/orders` 完全相同,**不需要也不接受类别参数**。
- 三类互不重叠:相同筛选条件下三个 `total` 之和等于 `GET /orders` 的 `total`。
- 导出 `query.filters` 与 `scene=order` 相同,表头相同。导出任务列表里新任务的 `scene` 是新值,显示中文请按上表映射。
- 订单详情仍用 `GET /api/admin/orders/{id}`;`GET /orders` 与场景 `order` 保留不变。
```json
{ "scene": "order_online", "format": "xlsx",
"query": { "filters": { "payment_status": 2, "start_time": "2026-10-01T00:00:00+08:00", "end_time": "2026-10-26T23:59:59+08:00" } } }
```
### 2.2 账号可见范围
| 账号 | 平台订单 | 代理商订单 | 在线支付订单 |
|---|---|---|---|
| 超管、平台 | 全部 | 全部 | 全部 |
| 代理 | 平台为本店名下卡代购的单 | 买家是本店的钱包订单 | **始终为空**,隐藏此页面 |
| 企业 | 无权限 | 无权限 | 无权限 |
企业账号调用以下接口返回无权限("企业账号无权访问订单"):`GET /orders`、`/orders/platform|agent|online`、`GET /orders/{id}`、`POST /orders/purchase-check`。
### 2.3 前端改动
1. 订单管理下拆三个页面,分别调用对应列表接口,导出按钮传对应场景。
2. 原"订单列表"页可保留或下线。
3. 代理账号隐藏"在线支付订单";企业账号隐藏订单菜单。
---
## 三、企业账号的资产访问范围
`/api/admin/assets/:identifier/*` 对企业账号收紧:
- **只能访问当前授权给本企业的卡或设备**。未授权与不存在一样返回"未找到"。涉及资产解析、实时状态、刷新、套餐、当前套餐、钱包、订单、操作日志、停机 / 复机。
- 以下操作**企业账号一律无权限**(即使资产已授权),返回 `CodeForbidden`"企业账号无权执行该资产操作":
| 接口 | 操作 |
|---|---|
| `PATCH /assets/:identifier/packages/:package_usage_id/expires-at` | 改套餐有效期 |
| `PATCH /assets/:identifier/packages/:package_usage_id/used-data` | 改已用量 |
| `PATCH /assets/:identifier/deactivate` | 停用 |
| `PATCH /assets/:identifier/polling-status` | 开关轮询 |
| `PATCH /assets/:identifier/realname-status` | 改实名状态 |
- 停机 / 复机(`POST /stop`、`/start`)对已授权资产仍可用。
**前端改动**:企业账号登录时隐藏上表 5 个操作按钮;资产搜索结果为"未找到"时按普通未找到提示,不要提示"无权限"。
---
## 四、实名即生效套餐沉默期
> 规则见 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md` 与 OpenSpec `add-package-silence-period`。
### 4.1 总开关对页面的影响
总开关 `package.silence.enabled` **默认关闭**。
| 页面 / 功能 | 关闭时 | 打开时 |
|---|---|---|
| 资产套餐列表、H5 套餐历史里的 `silence` 对象 | **不返回**,页面不显示沉默期 | 返回,显示状态、截止日 |
| "延期"入口 | 不显示 | `silence.extendable = true` 时显示 |
| 延期资格、试算、下单、线下登记、特殊延期 | 返回 **1274** | 可用 |
| 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 |
| 购买时调整沉默期 | 可用 | 可用 |
总开关只能用专用接口切换(通用"系统配置"接口里只读):
- `GET /api/admin/package-silence/switch`:返回 `enabled` 与打开前 4 项检查 `precheck[]`(`code`、`name`、`passed`、`count`)。
- `PUT /api/admin/package-silence/switch` `{ "enabled": true|false }`:仅超级管理员。打开前检查不通过返回 **1275**,`data.failed_items` 为未满足项,直接展示;关闭不检查。
**前端改动**:新增"沉默期总开关"设置页(超管可见),展示 4 项检查结果与开关。
### 4.2 套餐配置
- 新增 / 编辑套餐增加 `silence_period_days`(1~90)、`silence_extension_enabled`(是否支持付费延期,默认 true)。
- **实名即生效的正式套餐必填**默认沉默期,新建时预填 30;不填或越界返回 **1260**。
- **购买即生效的正式套餐可选填**(只在店铺分配覆盖为实名即生效时使用)。
- 加油包不显示、不提交。
- 套餐响应新增 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必填,用来决定表单必填标记)。
- 生效条件变更限制(返回 **1262**):
- 实名即生效套餐改为购买即生效:禁止。
- 店铺分配(单个、批量、系列授权)把实名即生效套餐覆盖为购买即生效:禁止。
- 店铺分配把购买即生效套餐覆盖为实名即生效:**套餐已配置默认沉默期才允许**。
- 套餐由购买即生效改为实名即生效:同一请求必须带 `silence_period_days`。
- "未配置沉默期的实名即生效套餐"清单:套餐列表加查询参数 `silence_unconfigured=true`(含"本身购买即生效、但有店铺分配覆盖为实名即生效"的套餐);套餐导出同样传该筛选。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。
**前端改动**:套餐表单加两个字段(按 `expiry_base`、`package_type` 控制显示与必填);套餐列表加"未配置沉默期"筛选。
### 4.3 后台购买时调整沉默期
`POST /api/admin/orders` 新增可选字段:
- `silence_period_days`(≥ 1,不设上限)、`silence_reason`(填了天数时必填)。
- 需要 `package_silence:override` 权限,无权限传值返回 **1261**。
- 不超过默认值直接生效;超过默认值时按延期政策直接生效,或**先按默认值购买并提交企微审批**。发起人未绑定企微成员时购买被拒。
- 只对"资产未实名时购买的实名即生效主套餐"生效,其他情况忽略。
**前端改动**:后台下单弹窗增加"本次沉默期(天)""调整原因",有权限才显示。
### 4.4 资产套餐展示
后台资产套餐历史 / 当前套餐、H5 套餐历史(`GET /api/c/v1/asset/package-history`)每笔套餐增加 `silence` 对象(总开关打开且适用时返回):
| 字段 | 说明 |
|---|---|
| `silence_status` / `silence_status_name` | `waiting` 待实名(延期后为"延期待实名")/ `expired` 沉默期已过期 / `ended` 沉默期已结束;待实名但已过截止的直接按 `expired` 返回 |
| `silence_deadline_at`、`silence_start_at`、`silence_period_days` | 截止时间(当日 23:59:59)、起算时间、天数 |
| `silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used` | 延期累计 |
| `silence_expired_at`、`silence_adjust_pending` | 过期时间、调整审批中 |
| `extendable` | 是否显示"延期"入口 |
沉默期流水:后台 `GET /api/admin/package-silence/records?package_usage_id=`,H5 `GET /api/c/v1/package-silence/records?package_usage_id=`。
**前端改动**:套餐列表显示沉默期状态与截止日、"延期"入口、"沉默期流水"弹窗(后台与 H5)。
### 4.5 延期(后台)
均在 `/api/admin/package-silence` 下:
| 接口 | 权限 | 说明 |
|---|---|---|
| `GET /eligibility?package_usage_id=` | — | 资格:`can_extend`、`special_extension_available`、`reason`、`min_days`/`max_days`、`min_months`/`max_months`、`unit_price`、`options[]`(每档计费天数、金额、新截止日)。**金额由后端算,前端只传天数** |
| `GET /quote?package_usage_id=&days=` | — | 按天数试算(按 30 天向上取整:1→30、31→60) |
| `POST /orders/qrcode` | `package_silence:extend` | 生成支付二维码:`package_usage_id`、`days`、`payment_method`(wechat/alipay)、`request_id`、`evidence_refs`;返回 `qr_content`,前端渲染为二维码 |
| `POST /orders/offline` | `package_silence:offline` | 登记线下付款:`package_usage_id`、`days`、`offline_payment_method_id`、`offline_trade_no`、`paid_amount`(必须等于计算金额,否则 **1273**)、`paid_at`、`payment_voucher_keys`(1~5)、`evidence_refs`、`request_id`。交易单号重复 **1272**。默认进企微审批 |
| `GET /orders`、`GET /orders/:id` | — | 延期订单列表 / 详情;导出场景 `package_silence_order` |
| `POST /special` | `package_silence:extend`(批量另需 `package_silence:extend_batch`) | 特殊延期:`extension_type`(free/discount/complaint_compensation/system_compensation/special_customer)、`items[]`(`package_usage_id` + `days`)、`reason`、`basis`、`evidence_refs`、`amount`;优惠价有收款时填收款方式、交易单号、凭证。批量逐行校验,失败返回 `data.rows[]`(行号与原因)。免费次数用完 **1267** |
| `GET /batches`、`GET /batches/:id` | — | 批次与明细(`has_exception=true` 只看含异常的) |
| `POST /extensions/:id/handle` | `package_silence:exception` | 异常明细 `action=retry|close` + `reason` |
| `GET /usages?view=expiring|expired|all` | — | 即将过期(默认 7 天内)/ 已过期套餐;导出场景 `package_silence_usage` |
延期政策(`package_silence:policy`):`GET/POST /policies`、`GET/PUT /policies/:id`、`PUT /policies/:id/status`、`GET /policies/match?package_id=`。同级重叠返回 **1269**。
**前端改动**:新增页面——延期弹窗(资格 + 试算 + 二维码 / 线下登记 / 特殊延期三种方式)、延期订单、延期批次与异常处理、即将过期 / 已过期套餐、延期政策管理。
### 4.6 延期(H5)
均在 `/api/c/v1/package-silence` 下,资产须属于当前客户:
- `GET /eligibility`、`GET /quote`:同后台。
- `POST /orders`:自助下单(`package_usage_id`、`days`、`payment_method`、`request_id`),返回 `qr_content`(支付链接或二维码内容);支付成功自动延期。
- `GET /orders`、`GET /orders/:id`:我的延期订单。
- `GET /records`:沉默期流水。
### 4.7 退款
沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。
### 4.8 企微审批场景
在"企微审批场景"配置业务类型 **沉默期审批(`package_silence_approval`)**。模板需有"申请类型"单选控件,选项映射:`购买时调整沉默期`、`特殊延期`、`线下付费延期`。可映射的业务字段见场景配置页。
**前端改动**:审批场景页的业务类型下拉加入"沉默期审批"。
### 4.9 错误码
| 码 | 名称 | 说明 |
|---|---|---|
| 1260 | silence_period_out_of_range | 沉默期天数不在允许范围 |
| 1261 | silence_override_forbidden | 无权限调整沉默期 |
| 1262 | package_expiry_base_change_forbidden | 不允许变更该套餐的生效条件 |
| 1263 | silence_extension_not_expired | 套餐未过期 / 无沉默期 / 已激活 / 已退款(`data.reason` 细分) |
| 1264 | silence_extension_disabled | 该套餐不支持付费延期(可申请特殊延期) |
| 1265 | silence_extension_exceeds_limit | 超过可延期期限(`data.min_months`、`data.max_months`) |
| 1266 | silence_extension_months_invalid | 延期天数不在允许范围(`data.min_days`、`data.max_days`) |
| 1267 | silence_free_extension_used_up | 免费延期次数已用完 |
| 1268 | silence_expired_not_refundable | 沉默期已过期的套餐不能退款 |
| 1269 | silence_policy_overlap | 延期政策重叠 |
| 1270 | silence_extension_no_policy | 未匹配到延期政策 |
| 1271 | silence_extension_order_pending | 已有未结束的延期订单或申请 |
| 1272 | silence_offline_trade_no_duplicate | 线下交易单号已登记 |
| 1273 | silence_offline_amount_mismatch | 实收金额与计算金额不一致(`data.expected_amount`) |
| 1274 | silence_feature_disabled | 沉默期功能未开启 |
| 1275 | silence_switch_precheck_failed | 总开关打开前检查未通过(`data.failed_items`) |
### 4.10 新增权限点
`package_silence:override`、`package_silence:extend`、`package_silence:extend_batch`、`package_silence:offline`、`package_silence:exception`、`package_silence:policy`。前端按权限控制按钮显示,角色配置页需能勾选。
---
## 五、卡 / 设备首次购买套餐时间
- 卡列表、设备列表返回 `first_purchase_at`,支持 `first_purchase_start`、`first_purchase_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `first_purchase_at`。
- 卡、设备导出末尾新增"首次购买套餐时间"列。
- 首次购买套餐记录:`GET /api/admin/package-silence/assets/:asset_type/:id/first-purchase-records`(`asset_type` 为 `card` / `device`),按世代倒序,含来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因。
- 换货后的新资产不会因换货得到该时间,要等它在当前世代真正支付套餐订单;旧资产换货回库后该时间清空,旧记录带结束原因"换货回库"。
- 绑定在设备上的卡不记录;只读,不提供修改。不受沉默期总开关影响。
**前端改动**:卡 / 设备列表加列与时间范围筛选,详情显示该时间,加"首次购买套餐记录"弹窗。
---
## 六、只影响展示、不改接口
- **后台线下下单不再绕开实名**(`69ec98aa`):后台给**平台自营资产**线下下单、或线下下单**赠送套餐**时,若资产未实名、套餐是实名即生效,套餐现在是"待生效(等待实名)",实名后才生效;以前会直接"生效中"。订单 `buyer_type` 仍是 `personal`。前端不用改,测试和运营看到状态变化属正常。
- **退款 0 元可提交审批**(`c9e5d655`):以前 0 元退款提交审批报数据库错误,现在正常。前端若对退款金额做了 `> 0` 校验,可放宽为 `≥ 0`。
---
## 七、前端改动清单
- [ ] 订单管理拆三页(平台 / 代理商 / 在线支付),各自导出场景;代理隐藏在线支付页;企业隐藏订单菜单
- [ ] 企业账号隐藏资产的改有效期、改已用量、停用、开关轮询、改实名状态 5 个按钮
- [ ] 沉默期总开关设置页(超管)
- [ ] 套餐表单:默认沉默期、是否支持付费延期;套餐列表"未配置沉默期"筛选
- [ ] 后台下单弹窗:本次沉默期、调整原因(`package_silence:override`)
- [ ] 资产套餐列表(后台 + H5):沉默期状态、截止日、延期入口、沉默期流水
- [ ] 后台延期:资格 / 试算、二维码、线下登记、特殊延期(单个 + 批量导入)
- [ ] 延期订单、延期批次与异常处理、即将过期 / 已过期套餐页面(含导出)
- [ ] 延期政策管理页
- [ ] H5:延期资格、试算、自助下单、我的延期订单、流水
- [ ] 企微审批场景业务类型加"沉默期审批"
- [ ] 角色权限配置加 6 个 `package_silence:*` 权限点
- [ ] 卡 / 设备:首次购买套餐时间列、筛选、详情、记录弹窗、导出列
- [ ] 错误码 1260~1275 的提示文案

View File

@@ -0,0 +1,440 @@
# 审计日志写入改造技术方案
> 日期:2026-11-05
> 状态:方案已定,未实施(结论见第 10 节)
> 关联:`openspec/changes/add-audit-integration-write-switches`(全局写入开关,本方案是其后的"按动作分级")
> **范围:本方案基于 `Iteration/8-11` 迭代实施,只部署到测试环境**(推送 8-11 由 `.gitea/workflows/deploy.yaml` 自动部署)。本方案不修改生产环境的代码、配置或服务;第 1 节的生产数据只用来说明问题和估算效果。
## 1. 背景
### 1.1 现状数据(生产近 14 天,只读统计)
| 指标 | 数值 |
|---|---|
| `tb_audit_event` 日均新增 | 约 122 万条(含停机重试风暴);扣掉风暴约 20 万条 |
| 机器发起(`system_task` / `scheduled_job`)占比 | 99% 以上 |
| 人、开放接口、个人客户、外部系统发起 | 约 1,210 条/天 |
| 每条事件含索引 | 约 1.9 KB;每条资源快照约 1.2 KB;平均 2–3 条资源 |
| 日均新增存储 | 约 6 GB(风暴期)/ 约 1 GB(平时) |
| 索引数 | `tb_audit_event` 14 个、`tb_audit_event_resource` 7 个、`tb_integration_log` 13 个 |
测试环境日均约 12.6 万条,几乎全是系统任务:流量同步 5.6 万、扣减 2.6 万、网络状态同步 2.5 万、优先轮询约 1.4 万。
### 1.2 问题
1. **审计表被当成过程日志用。** 每次轮询同步、每次扣减、每次对接调用开始、每次失败重试都写一条事件加 2–3 条资源快照。一次业务动作产生多次写入,而且每次写入要维护 21 个索引。
2. **失败重试没有收敛。** 退款后处理停机失败(`refund.process_asset`)和自动停机失败(`iot_card.auto_stop`),每重试一次就写一条审计。11-05 10:16–11:47,86 张卡产生了 26.1 万次停机失败,一个风暴就占了当天审计量的六成以上。
3. **对接日志顺带写审计。** `integrationlog.Repository.Start` 在没有上游审计事件时,会补写一条 `integration.attempt_started`(`internal/infrastructure/integrationlog/repository.go:119`),这一项每天约 45.6 万条。它与 `tb_integration_log` 本身完全重复。
4. **后果。** 11-03 生产 PostgreSQL IO 打满,轮询停摆约 1.7 天,审计和对接日志的写入与留存扫描是主要 IO 来源之一。
### 1.3 老系统做法(`/home/zoujin/works/golang/kg/qc`,供参考)
- **人工后台操作**:写 `tbl_system_log`。
- **业务过程**:按业务各写一张表,如 `tbl_order_log`、`tbl_card_wallet_auto_renew_log`、`tbl_next_month_log`,以及充值、短信日志。
- **下游代理调用我方接口**:只这一类写 `tbl_agent_pool_api_call_log`。
- **网关调用和轮询**:全部写 log4j 按天滚动的文本日志,不进数据库。
## 2. 目标与原则
**目标**:审计表只回答两个问题:
1. **人(或代表人的外部系统)做了什么**;
2. **钱和套餐、卡的业务状态是怎么变的**。
审计日均降到几千条,一年不到 10 GB,不再依赖归档和清理来扛量。
**原则**:
| 原则 | 说明 |
|---|---|
| 人工操作全留 | 后台账号、个人客户、开放接口、外部系统发起的动作,成功和失败都写审计 |
| 机器动作只留"结果" | 系统任务和定时任务只在资金或业务状态**真正改变**时写审计;无变化、同步、读取不写 |
| 每次失败都可查,审计只记结论 | 自动重试的每一次尝试都完整记在业务尝试记录表里(时间、第几次、错误码、上游返回),可分页查询;审计只记这件事的最终结论:成功,或重试用尽转人工 |
| 过程数据去业务表 | 已有专门记录表的(停复机记录、采集记录、扣减效果、日流量),审计不再重复写 |
| 排查用的走文本日志 | 只用于排查、不需要按业务查询的,写入现有应用日志 `app.log`,沿用现有轮换,由日志同步服务器归集 |
| 不改变业务结果 | 写不写审计,都不影响业务事务成功与否(与现状 `Append` 吞错一致) |
## 3. 数据分层
| 层 | 存储 | 放什么 | 保留 |
|---|---|---|---|
| L1 审计 | `tb_audit_event` + `tb_audit_event_resource` | 人工操作;机器发起的资金、套餐生命周期、停复机成功等状态变化 | 按现有留存策略 |
| L2 业务记录 | 各业务专表 | 停复机调用 `tb_card_stop_resume_record`、流量采集 `tb_card_traffic_collection`、扣减效果与日流量、对接调用 `tb_integration_log`、退款单上的后处理状态 | 按各表策略 |
| L3 文本日志 | 现有应用日志 `app.log`(zap JSON + lumberjack 轮换) | 机器过程类动作的结构化日志,例如停机原因更新、网络状态同步、通知投递、优先轮询、流量重置明细 | 沿用 `logging.app_log` 轮换;长期留存由日志同步服务器负责 |
| L4 不写 | — | 与 L1/L2 完全重复的,例如 `integration.attempt_started` | — |
## 4. 动作去向清单
### 4.1 保留在审计(L1)
**人工及外部发起**(约 1,210 条/天,全部保留):
| 发起方 | 日均 | 主要动作 |
|---|---|---|
| 后台账号 | 约 404 | 手动刷新卡、设备切卡、回收、设 WiFi、手动复机、改套餐到期、设备重启、下单、退款、审批、登录等 |
| 开放接口 | 约 612 | 下单、代理钱包扣款、开放接口复机 |
| 个人客户 | 约 182 | 绑资产、建手机号关联、更新微信身份、绑手机号 |
| 外部系统 | 约 9 | 支付确认、外部回调接收 |
**机器发起但改变资金或业务状态**(方案 A 约 3,510 条/天):
| 动作 | 日均 | 方案 B 去向 |
|---|---|---|
| `iot_card.auto_start` 自动复机 | 1,127 | 移到 L2 停复机记录 |
| `package_usage.reset_traffic` 流量重置 | 1,049 | 移到 L2(套餐自身的重置时间加日流量记录) |
| `iot_card.auto_stop` 自动停机,**仅成功** | 380 | 移到 L2 停复机记录 |
| `commission.calculate` 分佣计算 | 238 | 保留 |
| `package_usage.expire` / `activate` / 退款失效 | 176 / 90 / 6 | 保留 |
| 导入建卡、建设备、设备分配 | 93 / 52 / 44 | 保留 |
| 系统下单、订单套餐失效明细、员工代收账单 | 77 / 74 / 51 | 保留 |
| 外部回调接收、审批同步、退款批准、佣金回溯判定 | 27 / 13 / 6 / 6 | 保留 |
| `refund.process_asset`,**仅成功** | 4 | 保留 |
### 4.2 移出审计
| 动作 | 现在日均(估算) | 去向 | 说明 |
|---|---|---|---|
| `integration.attempt_started` | 约 45.6 万 | L4 不写 | `tb_integration_log` 已有同样内容;`audit_event_id` 留空 |
| `refund.process_asset` 失败 | 约 32.6 万 | L2 退款后处理尝试记录 | 每次尝试一条,记在新表 `tb_refund_post_process_attempt`;退款单上保留当前状态、次数和最后错误;审计只在最终成功或转人工时写一条 |
| `iot_card.auto_stop` 失败 | 约 31.5 万 | L2 停复机记录 | `tb_card_stop_resume_record` 每次实际调用一条,已含错误分类 |
| `iot_card.auto_stop_reason_update` | 约 3.8 万 | L3 文本 | 只改停机原因、不改停复机状态 |
| `iot_card.worker_traffic_sync` | 约 3.1 万(正常时 6–8 万) | L2 采集记录 | `tb_card_traffic_collection` 已经记录每次采集 |
| `iot_card.worker_network_sync` | 约 2.6 万(正常时 6–8 万) | L3 文本 | 结果就是卡上的网络状态字段 |
| `package_usage.deduct_traffic` | 约 2.1 万 | L2 | 采集记录、扣减效果、日流量三张表已经能还原 |
| `notification.deliver`、`polling_priority.*` 等 | 约 0.5 万 | L3 文本 | — |
### 4.3 方案选择
采用**方案 B**。停复机和流量重置都已经有更合适的业务表,审计里只留人工操作和资金、套餐生命周期类动作。
| 方案 | 审计事件/天 | 占现在 | 每天新增存储 |
|---|---|---|---|
| 现在 | 约 122 万 | 100% | 约 6 GB |
| 方案 A:4.1 全留 | 约 4,700 | 0.39% | 约 24 MB |
| **方案 B**:停复机、流量重置移到 L2 | 约 **2,200** | 0.18% | 约 11 MB |
## 5. 技术设计
### 5.1 注册表增加"落库策略"
`internal/infrastructure/audit/registry.go` 的 `ActionDefinition` 新增字段:
```go
// PersistPolicy 决定动作是否写入审计表。
type PersistPolicy string
const (
PersistAlways PersistPolicy = "always" // 成功失败都写(人工动作默认)
PersistOnSuccess PersistPolicy = "on_success" // 只写成功;失败写文本日志
PersistOutcome PersistPolicy = "outcome" // 只写最终结论:成功、或重试用尽转人工;中间失败不写审计(每次尝试在业务尝试记录表)
PersistLogOnly PersistPolicy = "log_only" // 不写表,写 L3 文本日志
PersistNone PersistPolicy = "none" // 什么都不写
)
type ActionDefinition struct {
// ……现有字段
Persist PersistPolicy // 为空时按操作者决定,见 5.2
}
```
动作的策略在注册表中**集中定义**,调用方不改代码。这样一份清单就能看清哪些动作进审计,评审和后续调整也只需改这一处。
### 5.2 默认规则
`Persist` 为空时,按本次写入的操作者类型决定:
| 操作者 `actor_kind` | 默认策略 |
|---|---|
| `account`、`personal_customer`、`openapi`、`external_system` | `always` |
| `system_task`、`scheduled_job` | `log_only` |
因此新增机器动作**默认不进审计**,要进审计必须在注册表显式声明 `always`、`on_success` 或 `outcome`。这能防止后面新增的轮询、同步类动作再次把审计表写满。
同一动作可能同时由人和机器触发,例如 `order.create`、`iot_card.manual_start` 与开放接口复机。策略按"动作 + 操作者"判断:注册表里的 `Persist` 只约束机器操作者,人工操作者始终是 `always`。
### 5.3 Writer 改造
只改 `internal/infrastructure/audit/writer.go` 的 `AppendAndGet`,在注册校验通过后、组装事件前分流:
```go
action, ok := w.registry.Action(input.ActionCode)
// ……现有校验
switch w.persistDecision(ctx, tx, action, input) {
case decisionSkip:
return nil, nil
case decisionLog:
w.machineLog.Info("audit", auditLogFields(action, input)...)
return nil, nil
}
// ……现有写表逻辑
```
要点:
1. **调用方兼容**:`Append` 本来就吞错并返回 nil,调用方不受影响。`AppendAndGet` 有 11 处调用(`refund/clawback.go`、`refund/service.go`、`businessusergroup`、`packagetrafficalert`、`distribution`、`refund_channel`、`integrationlog`),实施时逐一确认能处理返回的 `nil` 事件。这 11 处大多是人工或资金动作,仍然是 `always`,实际不会拿到 nil。
2. **`outcome` 的判定**:由调用方在写审计时标明这次是不是最终结论,`AppendInput` 增加 `Final bool`。
- 中间失败(`Final=false`)不写审计表,只写一行应用日志,日志里带尝试记录 ID 方便对照;
- 最终成功,或重试用尽转人工(`Final=true`)写一条审计,`metadata` 带总尝试次数、首次失败时间、最后错误码;
- 用业务键作 `EventID`(例如 `refund-asset:{refund_id}:outcome`),保证同一件事的结论只有一条。
- **每一次尝试的细节都不丢**,都在业务尝试记录表里(见 5.5),可分页查询(见 5.9)。
3. **文本日志写入现有应用日志**:不新建日志文件,也不新增轮换配置。Writer 直接使用 `logger.GetAppLogger().Named("audit")`,写入现有的 `app.log`,轮换沿用 `logging.app_log`(测试环境配置见 5.8),由日志同步服务器自动归集。`NewWriter` 签名不变,调用方不用改。
- 每行固定 `"logger":"audit"`、`"msg":"audit_event"`,便于在同步端按 logger 名过滤和建索引;
- 字段:`action_code`、`actor_kind`、`actor_id`、`source`、`result`、`resource_type`、`resource_key`、`correlation_id`、`request_id`、`error_code`、`summary`;中间失败额外带 `attempt`(第几次)和尝试记录 ID;
- 级别:成功为 Info,失败为 Warn;
- **不写** before/after 快照与主体数据,避免敏感数据进入文本日志;需要变化值的动作(如流量重置前后用量)只写必要的数值字段。
4. **运行时覆盖**:在 `add-audit-integration-write-switches` 的全局开关之外,增加一个按动作覆盖的配置 `audit.persist_overrides`(动作编码 → 策略)。出问题时不发版就能把某个动作临时降为 `log_only`,或恢复为 `always`。
### 5.4 对接日志不再补写审计
`internal/infrastructure/integrationlog/repository.go` 的 `Start`:
- **删除** `input.AuditEventID == nil` 时补写 `integration.attempt_started` 的逻辑(第 118–120 行)。上游有审计事件时照常关联;没有时 `audit_event_id` 留空。
- 注册表里的 `integration.attempt_started` 设为 `none`,作为兜底。
- `auditEventIDForSeries` 按 `trigger_series` 回查审计的逻辑保留,但需要确认它不会走慢查询(它会查审计表)。
### 5.5 失败重试收敛(与停机风暴止血配套)
审计只记结论之后,排查问题靠的是每一次尝试的记录,同时重试本身要收敛。这两件可以单独开 Change,但**必须先于或同时上线**:
1. **退款后处理**(`internal/service/refund/approval_decision.go` 的 `ProcessAssetPostProcessing`,`stopAsset` → `ManualStopCard`):
- 退款单增加或复用后处理字段:`asset_post_status`、`asset_post_attempts`、`asset_post_last_error`、`asset_post_next_at`,表示当前状态;
- **新增尝试记录表** `tb_refund_post_process_attempt`,每次尝试一条,不修改、不删除。字段:`refund_id`、`step`(停机、套餐失效、钱包回收等)、`asset_type`、`asset_id`、`iccid`、`attempt`、`trigger`(outbox 投递、补偿扫描、人工重试)、`result`、`error_code`、`error_message`、`stop_resume_record_id`(关联到那次停机调用)、`duration_ms`、`created_at`。按月分区。每单最多 10 次,量可控;
- 失败按指数退避,超过 N 次(建议 10 次)标记为"需人工处理",不再自动重试;
- outbox 与 `RecoverRefundPostProcessing` 都按这几个字段判断,不再无限重投。
2. **自动停机**:8-11 上的停机已经走停复机指令队列(`stop-resume-command-queue`),按错误分类处理:终态不可操作不重试,限流按退避处理。退款停机只请求停机评估。本次不改这部分。
- 每次实际调用上游,已经在 `tb_card_stop_resume_record` 记一条,包括第几次、错误类别、上游返回码与信息、耗时。
- 指令在调用上游前就结束的情况(已是目标状态、条件不再满足、卡不允许停复机)现在没有记录。改为在停复机记录里写 `result=skipped`,并写明原因,保证"为什么没停"也能查到。限流推迟后还会再执行,不写记录,沿用现有的分钟计数。
### 5.6 方案 B 的业务表补位
| 移出的动作 | 补位方式 | 现状 |
|---|---|---|
| `iot_card.auto_start` / `auto_stop` 成功 | `tb_card_stop_resume_record`(每次实际调用一条,含来源、场景、原因、结果) | Iteration/8-11 已有 |
| `package_usage.reset_traffic` | 套餐使用记录的 `last_reset_at` / `next_reset_at` 加日流量记录 | 已有;重置前后用量写一行 `app.log`(`logger=audit`)备查 |
| `iot_card.auto_start` / `auto_stop` 的停机原因、触发场景 | `tb_card_stop_resume_record` 的 `source`、`scene`、`reason` | 已有;原审计里的 before/after 卡状态不再保留,卡当前状态以卡表为准 |
后台"卡操作记录"之类的页面如果要看到停复机时间线,查询侧改为合并审计和停复机记录两个来源,不要回头再写审计。
### 5.7 审计覆盖治理
`internal/governance/auditcoverage/scanner.go` 扫描每个入口的 `AuditEvent`、`DomainLedger`、`IntegrationLog` 分类。本次需要做三件事:
- 对移到 L2、L3、L4 的入口,把 `AuditEvent` 分类改为不写,并在 `DomainLedger` 里写明对应的业务表;
- 覆盖扫描增加一条校验:机器操作者的动作必须在注册表显式声明 `Persist`,否则扫描失败;
- 同步更新 `openspec/specs/operations-audit` 和 `external-integration` 中"每个入口都写审计"的要求。
### 5.8 应用日志增量与轮换(测试环境)
测试环境由 `docker-compose.prod.yml` 部署,api、worker 两个容器的现状:
- 日志级别是 `LEVEL=info`、`DEVELOPMENT=false`,即 JSON 格式,可以按 `"logger":"audit"` 过滤;
- 两个容器都把宿主机 `./logs` 挂载到 `/app/logs`,且都**没有设置** `JUNHONG_LOGGING_APP_LOG_FILENAME`,所以都使用默认的 `/app/logs/app.log`;
- 轮换参数也是默认值:100 MB 一个文件、3 个备份、7 天、压缩。
**问题**:api 和 worker 写的是宿主机上**同一个** `/opt/junhong_cmp/logs/app.log`。两个进程各自用 lumberjack 按大小轮换,会互相把对方正在写的文件改名、压缩,导致丢日志和备份错乱。这个问题与审计改造无关,但 L3 日志要落在 `app.log` 里,所以要一并修。
**调整原则**:每个服务写在自己运行目录下的 `logs/`。审计日志不新增路径、不改文件名,跟着所在进程的应用日志走。测试环境的问题只在于两个容器共用了同一个宿主机目录,所以只改挂载:
| 服务 | 挂载(宿主机 → 容器) | 宿主机日志位置 |
|---|---|---|
| api | `./api/logs:/app/logs` | `/opt/junhong_cmp/api/logs/app.log`、`access.log` |
| worker | `./worker/logs:/app/logs` | `/opt/junhong_cmp/worker/logs/app.log` |
- 容器内仍然是默认的 `/app/logs/app.log`,**不设置** `APP_LOG_FILENAME`;目录结构与生产 `/opt/junhong_cmp/api/logs`、`/opt/junhong_cmp/worker/logs` 一致;
- 轮换只加两项:`JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40`、`JUNHONG_LOGGING_APP_LOG_MAX_AGE=14`,其余用默认值(100 MB、压缩);
- 容量:两个进程合计约 1 GB,每个进程是 100 MB 当前文件加 40 个压缩备份。每个备份约 10 MB 是估算值,部署后按实际备份大小微调;
- `.gitea/workflows/deploy.yaml` 里 `mkdir -p ${DEPLOY_DIR}/logs` 改为创建 `api/logs`、`worker/logs`;
- 原来的 `/opt/junhong_cmp/logs` 不再写入,旧文件保留不动。
- **新增量**:方案 B 在测试环境移到 L3 的动作,每天约 8 万–10 万行:流量同步改由采集记录承担,不写日志;需要写日志的有网络状态同步约 2.5 万、优先轮询约 1.4 万、停机原因更新与通知若干。按每行约 400 B 计算,每天约 30–40 MB(压缩前),相对单文件 100 MB 没有压力。
文本日志是排查补充。丢几行不影响业务,也不影响审计表。写日志失败时不重试、不报错。
### 5.9 记录查询接口
审计里移出去的数据,要能在后台查得到,否则等于丢了。凡是独立成表的记录,都要提供**分页 + 条件筛选**的查询接口。L3 文本日志不提供接口,由日志同步服务器检索。
**公共约定**(与现有 `ListStopResumeRecords` 一致):
| 项 | 约定 |
|---|---|
| 分页 | `page` 默认 1;`page_size` 默认 20,最大 100(采集记录沿用现有的最大 200);返回 `total`、`page`、`page_size`、`items` |
| 时间范围 | `start_time`、`end_time` 必填,跨度不超过 31 天,按上海时间解析;单卡接口为空时默认取最近 31 天 |
| 卡标识 | `identifier` 支持虚拟号、ICCID、MSISDN,先解析成卡 ID 再查,保证走"卡 ID + 时间"索引;找不到卡时直接返回空页 |
| 排序 | 按记录时间倒序,同一时间按 ID 倒序 |
| 单个与全部 | 每类记录提供一个列表接口,`identifier` 选填:传了就查单卡或单资产,不传就查全部。现有的单卡接口保留给前端兼容,与列表接口共用同一个查询 |
| 筛选 | 按业务排查需要来定,下面逐表列出,不受现有索引限制;为这些筛选新建或重建索引 |
| 时间范围的作用 | 表按月分区,时间范围用于分区裁剪,所以查全部时也只带时间范围、不要求其他条件 |
| 权限 | 平台账号;代理和企业账号是否开放、按归属范围过滤,另行确定 |
**接口清单**:
| 记录 | 表 | 列表接口 | 筛选条件 | 现状 |
|---|---|---|---|---|
| 运营商流量采集记录(成功,失败见 5.10) | `tb_card_traffic_collection` | `GET /traffic-collections` | `identifier`、`carrier_id`(运营商账户)、`decision`(扣减判定)、`source`、`scene`、`has_delta`(是否产生增量)、`stale_guarded` | 只有单卡接口 `GET /assets/:identifier/traffic-collections`,只能按时间分页 |
| 停复机记录 | `tb_card_stop_resume_record` | `GET /card-stop-resume-records` | `identifier`、`carrier_id`、`action`、`source`、`scene`、`reason`、`result`、`error_category`、`command_id` | 已有跨卡接口,但只能用 ICCID 查卡,也不能按场景、原因、指令号筛选;单卡接口没有筛选 |
| 套餐日流量详单 | `tb_package_usage_daily_record` | `GET /package-usage-daily-records` | `identifier`、`package_usage_id`、`package_id`、`carrier_id`、日期范围 | 只有单套餐接口 `GET /package-usages/:id/daily-records`,不分页 |
| 卡日流量 | `tb_card_daily_usage` | `GET /card-daily-usages` | `identifier`、`carrier_id`、日期范围、`min_usage_mb`(用量下限,排查异常大流量) | 无接口 |
| 套餐流量冲正流水 | `tb_package_usage_adjustment` | `GET /package-traffic-adjustments` | `identifier`、`package_usage_id`、操作人、调整方向(补扣或退回)、时间范围 | 只有单套餐接口,没有时间筛选 |
| 对接日志 | `tb_integration_log` | 现有 `GET /audit/integrations` | 现有条件已覆盖 | 不改 |
| 退款后处理尝试记录 | `tb_refund_post_process_attempt`(见 5.5) | `GET /refund-post-process-attempts` | `refund_id`、退款单号、`identifier`、`step`、`result`、`error_code`、`trigger`、时间范围 | 新增。退款列表同时增加 `asset_post_status` 筛选(可查"需人工处理"),列表和详情返回后处理次数、最后错误、下次重试时间 |
**索引**:按上面的筛选设计,需要时新建或重建。分区表的索引建在父表上,会自动应用到各个分区。
| 表 | 索引 |
|---|---|
| `tb_card_traffic_collection` | 保留 `(iot_card_id, observed_at DESC)`;新增冗余列 `carrier_id`,采集时写入;新增 `(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(decision, observed_at DESC)`;失败表索引见 5.10 |
| `tb_card_stop_resume_record` | 现有 `(card_id, created_at)`、`(carrier_id, created_at)`、`(result, created_at)` 重建为倒序;新增 `(created_at DESC)`、`(scene, created_at DESC)`、`(command_id)` |
| `tb_card_daily_usage` | 现有唯一键 `(iot_card_id, date)`;新增 `(date, usage_mb DESC)`,支撑查全部和按用量下限筛选 |
| `tb_package_usage_daily_record` | 新增 `(package_usage_id, date DESC)`、`(date)` |
| `tb_package_usage_adjustment` | 新增 `(iot_card_id, created_at DESC)`、`(created_at DESC)` |
| `tb_refund_post_process_attempt` | `(refund_id, created_at DESC)`、`(created_at DESC)`、`(result, created_at DESC)`、`(error_code, created_at DESC)` |
其余低选择性的条件,如 `source`、`stale_guarded`、调整方向,和上面的索引组合使用即可,不单独建索引。上线后在测试环境看慢查询日志,按实际情况调整。
**说明**:
- **流量扣减结果**直接从采集记录查:每条采集记录都带 `decision`、`delta_mb`、`month_used_mb`、`event_id`,再结合日流量详单,能还原每次扣了多少。
- **扣减效果表** `tb_card_observation_effect` 不对外提供接口。它只保存 outbox 事件的处理进度,没有卡 ID。排查扣减卡住时,在采集记录里按 `event_id` 关联查。
- 新增接口都要在路由 `RouteSpec` 中声明输入、输出 DTO,并同步生成接口文档。
### 5.10 流量采集记录成功与失败分表
**现状**(测试库近 3 天,只读统计):
| 读数结果 | 扣减判定 | 条数 | 平均行大小 |
|---|---|---|---|
| `ok` | `written`(写入增量) | 104.2 万 | 432 B |
| `ok` | `queue_not_full`(队列未满,暂不判断) | 17.3 万 | 285 B |
| `ok` | `slope_negative`、`baseline`、`settled_existing` | 800 | 约 420 B |
| `failed` | — | 14.5 万 | 136 B |
| `no_reading` | — | 400 | 144 B |
- 成功和失败写在同一张 `tb_card_traffic_collection` 里,失败约占 11%,测试环境每天约 4.8 万条。
- 失败记录只存了失败类型。`polling_carddata_handler` 在调用 `recordUnreadable` 时**丢掉了具体错误**,查不到失败原因,只能再去对接日志里按 `observation_id` 找。
- 失败记录也只给"采集时刻有套餐"的卡写,为此每条失败都要额外查一次有效套餐。
**调整**:
| 表 | 写什么 | 保留 |
|---|---|---|
| `tb_card_traffic_collection`(现有) | 只写成功读数(`result=ok`),包括所有扣减判定;新增原始请求与响应字段 | 在线保留 2 个月,到期月分区导出为压缩包后删除(见下文"到期归档") |
| `tb_card_traffic_collection_failure`(新增) | 只写失败:`failed`、`rate_limited`、`invalid_payload`、`no_reading`、`invalid_reading`;带原始请求与响应 | 与成功表相同:在线 2 个月,到期归档后删除 |
失败表字段:`iot_card_id`、`iccid`、`carrier_id`、`observed_at`、`observation_id`(等于对接日志的 `integration_id`,可直接关联)、`source`、`scene`、`result`(失败类型)、`error_category`(按网关错误分类:超时、限流、上游报错、响应格式错等)、`provider_code`、`provider_message`(截断到 500 字)、`duration_ms`、`created_at`,加上下面的原始数据字段。按 `observed_at` 按月分区,只新增不修改。
**原始数据**:成功表和失败表都保存运营商网关的原始请求与原始响应,有据可查。
| 字段 | 内容 |
|---|---|
| `raw_request` | 加密前的业务请求体(明文 JSON,只有卡号等查询参数),jsonb |
| `http_status` | HTTP 状态码;没拿到响应(超时、连接失败)时为空 |
| `gateway_code`、`gateway_msg`、`gateway_trace_id` | 网关响应外层的 `code`、`msg`、`trace_id` |
| `raw_response` | 网关原始响应体,原样保存。能解析成 JSON 的存 jsonb,不能解析的按文本存在 `raw_response_text`。超过 4 KB 时截断,并标记 `raw_truncated=true` |
| `gateway_attempts` | 网关客户端内部重试次数;保存的是最后一次的请求和响应 |
- **获取方式**:网关客户端(`internal/gateway/client.go` 的 `executeWithRetry`)已经读到了完整响应体和外层的 `code`、`msg`、`trace_id`,但只返回 `data`。新增按请求上下文挂载的原始数据收集器 `gateway.WithRawCapture(ctx)`,调用方挂上后,客户端把最后一次的请求体、状态码、响应体和重试次数写进收集器。没挂收集器的调用不受影响。
- **写入**:`polling_carddata_handler` 调 `QueryFlow` 前挂上收集器,成功读数和各类失败都把收集器内容带进 `TrafficObservation`,写入对应的表。
- **安全**:只存明文业务请求体,不存加密后的报文、签名、`app_id`、密钥和请求头。
- **量的估算**:流量查询响应很小(`iccid`、`used`、`unit` 加外层字段,约 150–250 B),每行增加约 300–400 B。测试环境成功读数每天约 40 万条,每天增加约 120–160 MB;在线保留 2 个月,原始数据部分约 7–10 GB。失败每天约 4.8 万条,2 个月约 1 GB。部署后按实际行大小复核。
**到期归档**:
- **在线保留**:当月加上最近 2 个整月。月分区的月末早于"当前时间减 2 个月"时,判定为到期。例如 2027-01-01 起,2026-10 的分区到期。常量 `TrafficCollectionRetentionDays=90` 改为 `TrafficCollectionRetentionMonths=2`,成功表和失败表共用。
- **执行位置**:沿用 leader worker 现有的分区维护循环 `startTrafficCollectionPartitionMaintainer`,每 12 小时运行一次。原来"到期直接 `DROP TABLE`"的逻辑,改为下面的四步:
1. **摘出分区**:`ALTER TABLE ... DETACH PARTITION`,到期分区不再参与在线查询。
2. **导出压缩包**:用 `COPY (SELECT * FROM 分区 ORDER BY observed_at, id) TO STDOUT WITH (FORMAT csv, HEADER)` 流式读出,边读边 gzip 写到 worker 运行目录下的 `archive/traffic-collection/` 目录,不在内存里攒整个月的数据。文件名为 `tb_card_traffic_collection_202610.csv.gz` 或 `tb_card_traffic_collection_failure_202610.csv.gz`。原始请求与响应以 JSON 文本形式保存在 CSV 列里。
3. **校验**:写完后重新读一遍压缩包,统计行数并与分区 `count(*)` 比对;同时生成同名的 `.manifest.json`,记录表名、月份、行数、最早和最晚采集时间、文件大小、SHA-256 和导出时间。
4. **删除分区**:行数一致后才 `DROP TABLE` 已摘出的分区。导出或校验失败时保留已摘出的表,记 Error 日志,下一轮重试;不会出现"数据删了但没有压缩包"的情况。
- **文件同步**:压缩包和 manifest 由你们的同步服务器拉走。本地只保留最近 3 个月的压缩包,更早的自动删除,个数可配置。先写成 `.tmp`,校验通过后再改名为正式文件名,避免同步到写了一半的文件。
- **测试环境挂载**:worker 容器增加 `./worker/archive:/app/archive`,宿主机目录为 `/opt/junhong_cmp/worker/archive/traffic-collection/`。
- **压缩包大小估算**:测试环境成功表每月约 1,200 万行,CSV 约 8–10 GB,gzip 后约 1–1.5 GB;失败表每月约 150 万行,压缩后约 50 MB。
- **回查**:要查归档月份的数据,解压后用 CSV 工具查看,或者临时 `COPY FROM` 导回一张临时表。线上接口只查在线的 2 个月。
**写入规则**:
- 失败记录**不再判断是否有套餐**,所有轮询到的卡都写,省掉每条失败的有效套餐查询。运营商接口出问题和有没有套餐无关,排查时需要看全量。
- `TrafficObservation` 增加失败详情(错误类别、上游返回码与信息、耗时),由 `polling_carddata_handler` 在网关调用失败时填入,不再丢弃错误。
- 成功表仍在观测事务之外写入,规则不变;写入失败只告警,不影响扣减。
- 成功表已有的失败历史数据保留,到期随分区删除;新数据按新规则分表写入。
**查询接口**(与 5.9 公共约定一致:可查单个也可查全部,分页,时间范围必填):
| 接口 | 筛选条件 | 索引 |
|---|---|---|
| `GET /traffic-collections`(成功) | `identifier`、`carrier_id`、`decision`、`source`、`scene`、`has_delta`、`stale_guarded` | 见 5.9 |
| `GET /traffic-collection-failures`(失败) | `identifier`、`carrier_id`、`result`、`error_category`、`provider_code`、`source`、`scene` | `(iot_card_id, observed_at DESC)`、`(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(error_category, observed_at DESC)`、`(observation_id)` |
成功表不再有失败数据,所以 5.9 里成功表的 `result` 筛选和 `(result, observed_at DESC)` 索引都去掉。单卡接口 `GET /assets/:identifier/traffic-collections` 增加 `include_failures` 参数,默认不含失败;传了就把两张表合并后按时间倒序返回,方便在一张卡上连起来看。
## 6. 存量数据
- **不删除、不迁移**已有审计数据,由现有留存和归档任务按策略自然过期。
- 如果要尽快释放空间,可以另行评估"按动作编码清理机器过程类历史数据"的一次性任务。本方案不包含它。
## 7. 分支与范围
- 基于 **Iteration/8-11** 实施。该分支已有 `tb_card_stop_resume_record`、采集记录和扣减效果表,方案 B 的补位表都存在。
- **只部署测试环境**:8-11 推送后自动部署到测试环境;不在生产执行任何代码替换、配置修改或服务重启。生产什么时候、以什么方式合入,不在本方案范围内。
- 全局写入开关 `add-audit-integration-write-switches` 已在 8-11 上,本方案与它互不替代:全局开关用于应急**全停**,本方案是常态下的**按动作分级**。
## 8. 实施步骤
| 步骤 | 内容 | 涉及 |
|---|---|---|
| 1 | 注册表增加 `Persist`,补齐全部机器动作的策略,并加上 5.2 的默认规则 | `internal/infrastructure/audit/registry.go`、`pkg/constants/audit.go` |
| 2 | Writer 按策略分流:`outcome` 只写最终结论;`log_only` 写现有应用日志(`logger=audit`) | `internal/infrastructure/audit/writer.go` |
| 3 | 对接日志去掉 `attempt_started` 补写 | `internal/infrastructure/integrationlog/repository.go` |
| 4 | 退款后处理加重试上限和退避,新增尝试记录表;停机业务性失败不重投,调用前被拦截的写 `skipped` 停复机记录 | `internal/service/refund`、`internal/service/iot_card`、`migrations` |
| 5 | 增加 `audit.persist_overrides` 运行时覆盖配置 | `pkg/config` |
| 6 | 审计覆盖扫描和 OpenSpec 规格同步 | `internal/governance/auditcoverage`、`openspec/specs` |
| 7 | 查询侧:卡、设备时间线合并停复机记录(方案 B) | `internal/query/audit` |
| 8 | 测试环境 api、worker 各自挂载自己的 `logs/` 目录,轮换改为 40 个备份(见 5.8) | `docker-compose.prod.yml`、`.gitea/workflows/deploy.yaml` |
| 9 | 记录查询接口:每类记录一个列表接口,可查单个也可查全部;按 5.9 的筛选条件建立或重建索引;采集记录增加 `carrier_id` 冗余列 | `internal/routes`、`internal/handler/admin`、`internal/model/dto`、`internal/store/postgres`、`migrations` |
| 10 | 采集记录成功与失败分表:新增失败表,补全失败详情,失败不再判断有无套餐;两张表保存网关原始请求与响应(网关客户端新增原始数据收集器);提供失败查询接口(见 5.10) | `internal/gateway/client.go`、`internal/application/cardobservation`、`internal/task/polling_carddata_handler.go`、`internal/model`、`internal/store/postgres`、`migrations` |
| 11 | 采集记录到期归档:分区维护改为摘出、导出 `csv.gz`、校验、删除四步;测试环境 worker 挂载 `archive` 目录 | `internal/store/postgres/card_traffic_collection_store.go`、`cmd/worker/main.go`、`pkg/constants`、`docker-compose.prod.yml` |
建议按"1–3 → 4 → 5–11"分三次提交,每次推送 8-11 后在测试环境验证。
## 9. 测试与验证(测试环境)
**测试环境逐项验证**(项目规定自动化测试为 N/A,按 ENG-TEST-001 在测试环境验证,以下各项是验证要点):
- 每种策略在 `account`、`openapi`、`system_task` 下的写入结果;
- `outcome`:中间失败不写审计,尝试记录表每次都有一条;最终成功或转人工写一条审计,带总次数;同一件事重复写结论只保留一条;
- `log_only` 不写表、文本日志有记录且不含快照;
- 覆盖配置能把 `always` 降为 `log_only`,也能反过来恢复。
- 对接调用没有上游审计时,`tb_integration_log.audit_event_id` 为空,审计表无新增;
- 退款后处理失败超过上限后状态为"需人工处理",outbox 不再重投。
- 流量查询成功写成功表、各类失败写失败表,两张表都带原始请求与响应;失败记录有错误类别和上游返回码;无套餐的卡失败也写;
- 网关响应超过 4 KB 时截断并标记;原始请求里没有密钥、签名或加密报文;
- 到期归档:构造一个过期月分区,执行维护后生成 `csv.gz` 和 manifest,行数与 SHA-256 一致后分区被删除;模拟导出失败时,已摘出的表保留,下一轮重试成功;
- 记录查询接口:每个接口都能查单卡、也能查全部,分页总数正确,各筛选条件生效。
**测试环境观察**(部署后 24 小时):
- 审计日增从约 12.6 万降到约 300 条(只读查询走 `created_at` 索引、按小时统计);
- `app.log` 中出现 `logger=audit` 的行,轮换正常,日志同步服务器能收到并按 logger 名过滤;
- 停复机记录、扣减效果写入量没有变化,业务结果没有变化;成功表加失败表的写入量合计,与改造前采集记录的量相当(失败表多出无套餐卡的失败);
- 新接口在测试库数据量下没有慢查询;
- `/opt/junhong_cmp/worker/archive/traffic-collection/` 目录可写,同步服务器能拉到文件。第一次真实归档要等到在线数据满 2 个月之后,在此之前用单元测试和集成测试覆盖。
## 10. 已定事项(2026-11-05)
| 事项 | 结论 |
|---|---|
| 方案 | 方案 B |
| 范围 | 基于 Iteration/8-11,只部署测试环境 |
| L3 文本日志 | 写入所在服务自己运行目录下的 `logs/app.log`,沿用现有轮换,由日志同步服务器归集 |
| 测试环境日志保留 | api、worker 各自的 `logs/` 目录,各保留 40 个压缩备份,合计约 1 GB;部署后按实际备份大小微调 |
| 退款后处理重试上限 | 10 次,指数退避,最长间隔 1 小时;超过后标记"需人工处理" |
| 失败记录 | 每次尝试都记录在业务尝试记录表(退款后处理尝试、停复机记录),可分页查询;审计只记最终结论,不按首次失败去重 |
| 停机业务性失败 | 按网关错误分类判定为不可重试,不再重投 |
| 运行时覆盖 | 提供 `audit.persist_overrides`,可按动作临时调整策略 |
| 存量审计数据 | 不删除、不迁移,按现有留存策略自然过期 |
| 采集记录分表 | 成功写现有表,失败写新表 `tb_card_traffic_collection_failure`,带完整错误详情,所有卡都写;两张表都保存网关原始请求与响应 |
| 采集记录保留 | 两张表在线保留 2 个月(当月加最近 2 个整月);到期月分区先导出为 `csv.gz` 压缩包并校验,再删除;压缩包由同步服务器拉走,本地保留 3 个月 |
| 记录查询 | 所有独立成表的记录都提供分页加条件筛选接口,可查单个也可查全部;筛选条件按排查需要来定,索引随之新建或重建,清单见 5.9;文本日志不提供接口 |
| 页面依赖 | 实施第 7 步(时间线合并停复机记录)前,与前端或运营确认是否有页面依赖机器动作审计 |

View File

@@ -0,0 +1,230 @@
# 轮询入库异步化技术方案
- 日期:2026-11-06
- 分支:`Iteration/8-11`(只部署测试环境,不涉及生产配置与重启)
- 关联:`add-carrier-polling-scheduler`(按运营商账户调度)、`add-card-traffic-queue-and-observed-deduction`(采集队列与按观测扣减)、`refactor-audit-log-persistence`(采集记录分表)
## 1. 背景
### 1.1 测试环境现状(2026-11-06 上午,只读统计)
| 指标 | 数值 |
|---|---|
| worker 报 `context deadline exceeded` 的 SQL | 每分钟 90–220 次,平均约 130 次,即每小时约 8,000 次,部署审计改造前已存在 |
| 慢查询告警(> 500ms) | 每小时约 13.8 万条,占 worker 日志行数 53% |
| 按主键查单行 `tb_iot_card` | 经常 0.7–2.4 秒 |
| worker 日志量 | 每小时约 26 万行,18 分钟写满一个 100MB 文件,40 个备份只能留约 12 小时 |
| 数据库连接 | worker 连接池 `MAX_OPEN_CONNS=60`,测试库上 61 个连接,基本占满;有一次 30 个会话同时等锁 |
| 采集记录丢失 | 写采集成功表/失败表 3 秒超时,约每 16 分钟丢 60 条 |
### 1.2 原因:消费并发没有总量限制
NSQ 只限制了每个运营商账户的并发(保护网关),没有限制全部账户加起来的并发(保护数据库):
| 来源 | 规则 | 理论上限 | 实测 |
|---|---|---|---|
| 卡轮询 `polling.card.<账户>` | 每账户 20(`default_card_max_in_flight`,`tb_carrier_polling_param` 无记录) | 41 × 20 = 820 | 约 310 |
| 停复机 `polling.stop_resume.<账户>` | 每账户 10 | 41 × 10 = 410 | 约 22 |
| 设备评估 `polling.device_eval` | 10 | 10 | 10 |
| 异步任务队列 | `JUNHONG_QUEUE_CONCURRENCY=50` | 50 | — |
| 合计 | | 约 1,290 | 约 380 |
`worker.polling_total_max_concurrency: 100` 只传给旧分片轮询(`cmd/worker/main.go:371`),按账户调度的 NSQ 消费者不受它约束。
每个轮询步骤在**同一个任务里**先调网关、再同步写库,所有写库都在步骤超时内完成。三百多个任务抢 60 个连接,等连接的时间计入超时,数据库稍有抖动就成批超时。
### 1.3 超时分布(按调用栈,约 16 分钟样本)
| 占比 | 位置 | 说明 |
|---|---|---|
| 约 40% | 配置匹配查套餐状态(`TierResolver.Prefill`,`internal/polling/config_manager.go:191`,共用 3 秒) | 绑定设备的卡连查 3 次;失败后把"未知"结果写进缓存 |
| 约 20% | `writeTrafficFailureAudit` | 沿用已超时的上下文再查卡,属连带报错 |
| 约 20% | 写采集成功表、失败表(`collectionRecordTimeout = 3s`) | 记录直接丢失 |
| 其余 | 流量观测事务、网络状态观测、实名缓存失效等 | |
## 2. 目标与原则
1. **调上游与入库分开控制**:调网关按账户并发(保持现有每账户上限),入库单独发消息、由单独的消费者处理。
2. **数据库并发有总上限**:入库消费者的并发是一个固定、可配置的数,与账户数量无关;加账户不会挤爆数据库。
3. **消费慢一点没关系**:消息在 NSQ 里排队,入库按数据库承受的速度处理;不丢、不重复算。
4. **轮询步骤只做"调网关 + 发消息"**:调网关前必要的读查询走缓存,尽量不碰数据库。
5. **可切回**:保留同步入库路径,用系统配置一键切换,异常时可回退。
6. **范围**:只在 `Iteration/8-11` 上实现,只部署测试环境。
## 3. 现状链路(一次流量步骤)
```
NSQ 卡任务 ──► 步骤(15s 超时)
├─ 读:优先轮询项 FindActive(tb_polling_priority_item)
├─ 读:卡(先清缓存再查库 tb_iot_card)
├─ 读:轮询范围校验(tb_carrier、tb_device、tb_device_sim_binding)
├─ 调网关 QueryFlow
├─ 写:ApplyTrafficObservation
│ ├─ Redis 采集队列 Push
│ ├─ 事务:锁卡行、查有效套餐、写日快照、更新卡、写流量事件 outbox、通道阈值评估、审计
│ └─ 写采集成功表/失败表(3s)
├─ 写:对接日志、优先项完成
└─ 返回 TriggeredEvaluation(决定是否跳过本轮套餐步骤)
```
网络状态步骤(`ApplyNetworkObservation`)、实名步骤(`ApplyCardObservation`)结构相同;套餐步骤不调网关,只读写库。
## 4. 技术设计
### 4.1 两段式
```
采集段(按账户并发,现有 NSQ 卡任务) 入库段(固定小并发)
┌────────────────────────────────┐ ┌──────────────────────────────┐
│ 读缓存 → 调网关 → 发入库消息 │──NSQ──► │ card.ingest 消费者(并发 N) │
│ 网关失败 → 发采集失败消息 │ │ Apply*Observation(事务) │
└────────────────────────────────┘ │ → 发采集记录消息 │
└──────────────┬───────────────┘
│NSQ
┌──────────────▼───────────────┐
│ card.collection 消费者 │
│ 攒批写成功表/失败表 │
└──────────────────────────────┘
```
- **采集段**:现有卡任务消费者,并发规则不变(每账户 `card_max_in_flight`)。步骤拿到网关结果后发一条入库消息就结束,步骤结果只反映"网关是否成功"。
- **入库段**:新 topic `card.ingest`,单独消费者,`max_in_flight` = `polling.ingest.concurrency`(默认 30)。
- **采集记录**:新 topic `card.collection`,单独的批量消费者(见 4.5)。
### 4.2 入库消息
topic `card.ingest`,JSON:
| 字段 | 说明 |
|---|---|
| `kind` | `traffic` / `network` / `realname` |
| `card_id`、`carrier_id`、`iccid` | 卡标识 |
| `observation_id` | 现有 `attempt.IntegrationID`,幂等键 |
| `observed_at` | 网关返回时刻(不是入库时刻) |
| `source`、`scene`、`correlation_id` | 现有观测元数据 |
| `payload` | 流量:`reading_mb`、`reading_result`、`reset_day`;网络:`status`、`extend`、`imei`;实名:`verified` |
| `raw`、`failure` | 网关原始数据(截断规则同采集记录) |
| `priority_item_id` | 本轮认领的优先轮询项,入库成功后由入库段完成 |
| `published_at` | 发出时刻,用于积压与延迟统计 |
### 4.3 入库消费者
处理一条消息:
1. 幂等检查:Redis `ingest:done:<observation_id>` 存在则直接 FIN。
2. 按 `kind` 调用现有 `ApplyTrafficObservation` / `ApplyNetworkObservation` / `ApplyCardObservation`,上下文超时 `polling.ingest.timeout`(默认 30s),从拿到消息开始计时。
3. 成功后:写 `ingest:done:<observation_id>`(TTL 48h);流量有变化时写 Redis 标记 `flow_evaluated:<card_id>`(见 4.6)。
- 本轮认领了优先轮询项的观测不走消息,仍在卡任务内同步入库,优先项完成与对接日志沿用原逻辑。
- 网络观测命中风险停机或已销户时,卡任务按网关结果直接停止该卡轮询,入库段写库时关闭轮询开关。
4. 失败:
- 数据库超时、连接错误等可重试错误:NSQ 延迟重投,退避 5s、15s、30s、1m、2m,之后每 5 分钟一次,**不设次数上限、不丢弃**(入库消费者的 NSQ `max_attempts` 设为 0);第 10 次起每次重投记 ERROR,带 `observation_id`、卡号与错误,便于排查。
- 业务性错误(卡不存在、数据校验失败):不重投,记 WARN 后 FIN。
- 本期不做死信表。长时间重试的消息会体现在积压量与最老消息等待时间上(见 4.7)。
### 4.4 顺序与幂等
NSQ 不保证顺序,也可能重投。逐项说明现有逻辑能否承受:
| 风险 | 处理 |
|---|---|
| 同一张卡两条流量消息乱序 | 采集队列按 `observed_at` 插入,"新点在队尾才写"——旧消息后到时不在队尾,不写快照,只记采集记录。现有规则已覆盖 |
| 同一张卡两条消息并发处理 | 事务内 `FOR UPDATE` 锁卡行,串行执行 |
| 同一条消息重投 | ① `ingest:done` 键挡掉已成功的;② 事务提交后、写键前崩溃:日快照取当日最大值、增量按"本月最大 − 已结算"计算,重放得到 0 增量;流量事件 outbox ID 由 `observation_id` 生成,重复写入被唯一键挡掉;③ 采集队列 Push 同一个点需改为按 `observed_at` 去重(Lua 内判断) |
| 网络/实名状态乱序 | 现有 `ApplyNetworkObservation`、`ApplyCardObservation` 需确认按 `observed_at` 比较"更新的才生效";没有的补上 |
| 采集记录重复 | 成功表/失败表按 `(observation_id, observed_at)` 建唯一索引(含分区键),批量写用 `ON CONFLICT DO NOTHING` |
### 4.5 采集记录批量写
- 入库段写完事务后,把采集成功记录发到 `card.collection`;采集段网关失败、无读数时直接把失败记录发到 `card.collection`,不经过入库段。
- `card.collection` 消费者 `max_in_flight` = 500,攒满 `polling.collection.batch_size`(默认 200)或等满 1 秒就批量写一次(多行 INSERT),成功后统一 FIN,失败统一重投。
- 写库并发固定 1–2 个连接。按测试环境每小时约 2.1 万条计算,每秒约 6 条,一次批量写即可。
- 现有 3 秒超时和"写失败只记日志"的逻辑删除,不再丢记录。
### 4.6 采集段剩余读查询
步骤里调网关前的读查询不能异步,改为走缓存:
| 读查询 | 现状 | 改为 |
|---|---|---|
| 卡信息 | 每步先清缓存再查库 | 读 Redis 卡缓存;入库段写完后失效缓存 |
| 优先轮询项 `FindActive` | 每步每类型查一次库 | Redis 维护"有活动优先项的卡"集合,不在集合里不查库 |
| 运营商实名开关、重置日 | 每步查库 | 进程内缓存 1 分钟 |
| 设备轮询开关、绑定关系 | 每步查库 | 并入卡缓存 |
| 套餐状态(配置匹配) | 3 次查询共用 3 秒,失败缓存"未知" | 每次查询各自计时;失败不写缓存,下次重查 |
套餐步骤跳过逻辑:现在根据同一任务里流量步骤的 `TriggeredEvaluation` 跳过。异步后改为读 Redis 标记 `flow_evaluated:<card_id>`(入库段写,TTL = 套餐检查间隔)。
### 4.7 积压与反压
- 监控:每分钟读 nsqd `/stats`,记录 `card.ingest`、`card.collection` 的积压量和最老消息等待时间,写应用日志。
- 告警:积压超过 `polling.ingest.alert_depth`(默认 200,000)或等待超过 10 分钟,记 ERROR(接现有告警通道)。
- 反压:积压超过 `polling.ingest.pause_depth`(默认 500,000,须大于告警阈值)时,调度器暂停发新的卡任务;降到一半以下恢复。账户限流状态不受影响。
### 4.8 NSQ 持久化
nsqd 当前参数 `--mem-queue-size=10000`:消息先放内存,nsqd 意外退出会丢掉内存里的部分。
- 测试环境改为 `--mem-queue-size=0`:所有消息直接落盘(`./nsq-data` 已挂载),按现有量级(每秒几十条)性能没有影响。
- 卡任务 topic 也会一起落盘,对现有调度无行为变化。
- 磁盘占用:入库消息带网关原始数据,单条约 2KB;积压到反压阈值 50 万条约 1GB,测试服务器剩余 32G,足够。
### 4.9 不改的同步路径
以下入口量小、需要立即返回结果,保持同步入库:后台人工刷新、运营商回调(移动/联通/电信实名)、激活前结算、观测序列(series runner)。
### 4.10 配置项
| 配置 | 默认 | 说明 |
|---|---|---|
| `polling.ingest.mode` | `sync` | `sync` 为现在的同步入库;部署确认无误后切 `async` |
| `polling.ingest.concurrency` | 30 | 入库消费者并发 |
| `polling.ingest.timeout` | 30s | 单条入库超时 |
| `polling.collection.batch_size` | 200 | 采集记录批量大小 |
| `polling.ingest.alert_depth` / `pause_depth` | 200,000 / 500,000 | 告警与反压阈值 |
`mode` 与阈值放系统配置(改了几秒内生效),并发放启动配置。启动时检查:入库并发 + 采集记录写并发(2)不超过 `MAX_OPEN_CONNS` − 20(给采集段读查询、异步任务队列与其他查询留出至少 20 个连接),超过记 ERROR。测试环境 `MAX_OPEN_CONNS=60`:入库 30 + 采集记录 2 = 32 ≤ 40,满足。
### 4.11 发布失败兜底
采集段发消息失败(nsqd 不可用)时,降级为当前的同步入库,并记 WARN;不因 nsqd 故障丢观测。
## 5. 影响与代价
- 入库延迟:正常几秒;积压时更长。卡流量、状态在后台的刷新会比现在晚几秒。
- 停复机判断:超量停机、通道阈值停机依赖入库结果,跟着入库段触发,晚几秒。
- 优先轮询(加急):完成时间从"网关返回"推迟到"入库成功"。
- 同一张卡的数据库写入从"一个任务内"变成"跨两个消费者",排查时按 `observation_id` 串联日志。
## 6. 分支与范围
- 基于 `Iteration/8-11`,只部署测试环境;不写生产配置、不涉及生产重启。
- 依赖 `add-carrier-polling-scheduler`、`add-card-traffic-queue-and-observed-deduction` 的现有实现,不改它们的业务规则。
## 7. 实施步骤
1. 采集队列 Push 按 `observed_at` 去重;网络/实名观测补"更新的才生效"判断(如缺)。
2. 采集记录唯一索引迁移;`card.collection` 批量消费者;采集段失败记录改发消息。
3. `card.ingest` 消息、入库消费者、幂等键、不限次数的退避重试。
4. 三个轮询步骤改为发消息;优先项完成、对接日志、`StopPolling`、`flow_evaluated` 移到入库段;发布失败降级。
5. 采集段读查询走缓存;套餐状态查询各自计时、失败不缓存。
6. 积压监控、告警、反压;启动并发检查。
7. 系统配置与回退开关;测试环境 nsqd 改 `--mem-queue-size=0`。
## 8. 测试与验证(测试环境)
| 验证项 | 通过标准 |
|---|---|
| SQL 超时 | `context deadline exceeded` 从每小时约 8,000 次降到个位数 |
| 慢查询 | 每小时告警数明显下降,worker 日志留存能达到 14 天目标的量级 |
| 采集记录完整 | 每小时:入库消息数 = 成功表 + 失败表新增行数(按 `observation_id` 对账) |
| 计量正确 | 抽样卡日快照、套餐扣减与同步模式结果一致;重投、乱序不重复扣减 |
| 积压 | `card.ingest` 等待时间常态 < 30 秒 |
| 回退 | `polling.ingest.mode=sync` 后恢复现有行为 |
| 降级 | 停掉 nsqd 时采集段改为同步入库,不丢观测 |
## 9. 已定事项(2026-11-06)
1. 入库消费者默认并发 30。
2. 测试环境 nsqd 改为 `--mem-queue-size=0`,所有消息直接落盘。
3. 本期不做死信:可重试错误不限次数退避重试,不丢弃;业务性错误记 WARN 后丢弃。
4. 积压告警阈值 200,000 条;反压(暂停派发新卡任务)阈值 500,000 条。

View File

@@ -0,0 +1,133 @@
# 2026-11-04 换货迁移导致卡上两个主套餐同时生效的数据修正
## 现象
卡 `89860624630055040571`(`tb_iot_card.id = 93937`)在后台的预计套餐到期时间显示"数据异常"(`expiry_estimate_status = invalid_data`)。
## 原因
### 1. 换货迁移把老卡的生效中套餐原样挂到了新卡上
2026-11-03 17:42,换货单 `EXC20261103174239616885`(`tb_exchange_order.id = 1094`)完成直接换货:
- 老卡:`89860624630055027198`(`id = 93846`),停机、未实名,从未产生流量。
- 新卡:`89860624630055040571`(`id = 93937`)。
- 选了迁移数据。换货原因是"未实名卡,给老卡续费"。
`internal/service/exchange/migration.go` 的 `migratePackageUsageWithTx` 只把老卡套餐使用记录的 `iot_card_id` 改成新卡,状态原样保留,也不检查新卡是否已有生效中的主套餐。迁移后新卡上同时有两个生效中(`status = 1`)的主套餐:
| 套餐使用记录 | 套餐 | 订单号 | 修正前状态 | 到期时间 |
|---|---|---|---|---|
| 47051 | Mifi-U省内版(12个月省内流量每月3000G),`package_id = 272` | `MIG-89860624630055040571-1` | 生效中 | 2026-12-14 |
| 46981 | 12个月省内流量每月1000G,`package_id = 230` | `MIG-89860624630055027198-1` | 生效中,`activated_at` 为空 | 2027-12-14 |
46981 的到期时间正好是 47051 到期后再加一年,说明运营的本意是"续费,接在现有套餐后面"。
`internal/query/packageexpiry/query.go` 的 `Calculate` 要求一个资产只能有一个生效中或已用完的主套餐。出现两个时返回 `invalid_data`。
### 2. 流量扣到了续费的套餐上
换货后,新卡的流量全部扣到了 46981 上,47051 从 11-03 17:40 起没有再被扣过:
| 日期 | 47051 日用量 | 46981 日用量 |
|---|---|---|
| 11-01 | 941 MB | |
| 11-02 | 255 MB | |
| 11-03 | 5,393 MB | 52 MB |
| 11-04 | | 2,707 MB |
合计 9,348 MB,和新卡 11 月的 `current_month_usage_mb = 9348.33` 一致。老卡本月和上月的用量都是 0,所以 46981 上的用量全部来自新卡。
### 3. 第一次修正后仍显示数据异常:排序问题
46981 改成待生效后,页面仍然显示数据异常。原因是两条记录的 `priority` 都是 1,`created_at` 也完全相同(同一次迁移写入,都是 `2026-09-02 16:40:32.81965`),排序只能按 `id`,46981 就排在了 47051 前面。`Calculate` 不允许待生效套餐排在当前套餐之前,所以仍然判成 `invalid_data`。把 46981 的 `priority` 改成 2 后,它排到了 47051 后面。
## 已执行的修正(生产库,2026-11-04)
两步都在单个事务中执行:先锁行,改完后自动校验,校验不通过就整体回滚。
### 第一步:用量转回 47051,46981 改为待生效
执行前:46981 的 `data_usage_mb = 2759`,47051 的 `data_usage_mb = 112102`。
```sql
BEGIN;
SELECT id, status, data_usage_mb, iot_card_id FROM tb_package_usage WHERE id IN (46981, 47051) ORDER BY id FOR UPDATE;
-- 46981 的已用量转回 47051
UPDATE tb_package_usage a
SET data_usage_mb = a.data_usage_mb + b.data_usage_mb, updated_at = now()
FROM tb_package_usage b
WHERE a.id = 47051 AND b.id = 46981 AND a.status = 1 AND b.status = 1 AND a.iot_card_id = 93937 AND b.iot_card_id = 93937;
-- 日用量记录并到 47051
INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at)
SELECT 47051, d.date, d.daily_usage_mb, 0, now(), now()
FROM tb_package_usage_daily_record d WHERE d.package_usage_id = 46981
ON CONFLICT (package_usage_id, date) DO UPDATE
SET daily_usage_mb = tb_package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, updated_at = now();
-- 以 11-02 的累计值为起点,重算 47051 从 11-03 起的累计用量
UPDATE tb_package_usage_daily_record r SET cumulative_usage_mb = x.cum, updated_at = now()
FROM (
SELECT id,
(SELECT cumulative_usage_mb FROM tb_package_usage_daily_record WHERE package_usage_id = 47051 AND date = '2026-11-02')
+ sum(daily_usage_mb) OVER (ORDER BY date) AS cum
FROM tb_package_usage_daily_record
WHERE package_usage_id = 47051 AND date >= '2026-11-03'
) x
WHERE r.id = x.id;
DELETE FROM tb_package_usage_daily_record WHERE package_usage_id = 46981;
-- 46981 改为待生效,和现有待生效套餐的数据形态一致;激活时不会把已用量清零,所以这里必须清零
UPDATE tb_package_usage
SET status = 0, data_usage_mb = 0, activated_at = NULL, expires_at = NULL,
last_reset_at = NULL, next_reset_at = NULL, updated_at = now()
WHERE id = 46981 AND status = 1 AND iot_card_id = 93937;
-- 校验项:47051 的已用量等于两者之和;46981 是待生效、已用量为 0;
-- 47051 最后一天的累计用量等于它的已用量;46981 已经没有日用量记录
COMMIT;
```
### 第二步:调整排队顺序
```sql
BEGIN;
UPDATE tb_package_usage SET priority = 2, updated_at = now()
WHERE id = 46981 AND status = 0 AND iot_card_id = 93937 AND priority = 1;
COMMIT;
```
## 修正后的状态
| 套餐使用记录 | 状态 | 优先级 | 已用量 | 生效时间 | 到期时间 | 下次重置 |
|---|---|---|---|---|---|---|
| 47051 | 生效中 | 1 | 114,861 MB | 2025-12-14 | 2026-12-14 | 2026-11-09 |
| 46981 | 待生效 | 2 | 0 | 空 | 空 | 空 |
47051 的日用量记录:11-03 为 5,445 MB,累计 112,154 MB;11-04 为 2,707 MB,累计 114,861 MB。
预期效果:
- 后续流量扣到 47051 上。
- 预计到期时间推算为约 2027-12-14:47051 到期后,46981 由排队激活接上 365 天。
## 回滚
一般不需要回滚。如果必须恢复到修正前的状态:
1. 46981 恢复为 `status = 1`、`priority = 1`、`data_usage_mb = 2759`、`expires_at = '2027-12-14 00:00:00'`、`last_reset_at = '2026-10-18 01:23:14.86183'`、`next_reset_at = '2026-11-17 00:00:00'`。
2. 47051 的 `data_usage_mb` 减去 2759。
3. 日用量记录拆回原样:
- 46981:11-03 为 52/52,11-04 为 2707/2759;
- 47051:11-03 为 5393/112102,并删除 47051 的 11-04 记录。
回滚前,要先确认修正之后又扣进 47051 的新用量。
## 遗留问题
- **代码漏洞未修。** 换货迁移不处理新资产已有生效中主套餐的情况,以后同类换货还会再出现两个主套餐同时生效。可选的修法:
- 迁移过来的主套餐改为待生效,排在新资产现有套餐之后(`priority` 递增);
- 或者在换货前检查,直接拒绝,提示运营先处理。
- **同类问题未排查。** 生产中可能还有其他卡或设备同时挂着多个生效中的主套餐,尚未全量排查。

View File

@@ -8,7 +8,7 @@
- Owner:生产维护者。
- 最后核验:2026-08-13(基于维护者提供的信息,未连接生产环境)。
- 更新触发:Unit、目录、进程数、Worker 角色、发布顺序、迁移方式或回滚方式变化。
- 验证:由维护者在服务器执行本文列出的只读核对命令,并回填结果;Agent 不连接生产环境。
- 验证:Agent 可通过 dbhub 对生产数据库执行只读诊断查询;服务器上的只读核对、生产发布、迁移、回滚及其他写操作由维护者执行并回填结果,Agent 不连接生产主机。
## 与测试环境的边界
@@ -52,7 +52,7 @@ GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/worker ./cmd/worker
- 二进制只从嵌入默认配置和 `JUNHONG_` 环境变量读取;`.env.prod` 必须由 systemd Unit 显式加载,或由 Unit 启动脚本 `source` 后启动。需要以 Unit 内容核实实际方式。
- API 与 Worker 共享数据库、Redis、日志、JWT、对象存储、Gateway、短信、支付等基础配置;只记录键名,不将实际凭据写入仓库文档。
- 七月新增的 API/Worker 共用配置:`JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED`、`JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED`、`JUNHONG_WECOM_BASE_URL`、`JUNHONG_WECOM_TIMEOUT`。
- 七月新增的 Worker 配置:`JUNHONG_WORKER_ROLE`、`JUNHONG_WORKER_INSTANCE_NAME`、`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED`。
- 本次新增的 Worker 配置:`JUNHONG_WORKER_ROLE`、`JUNHONG_WORKER_INSTANCE_NAME`、`JUNHONG_WORKER_POLLING_TOTAL_MAX_CONCURRENCY`、`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED`、`JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED`。轮询总并发必须保持在 1-1000,初始生产值建议 100;归档/留存总开关默认关闭。
- 首发要求:退款人工入口、线下充值人工确认、企微审批、运营商实名回调、微信/支付宝在线充值均按维护者决定启用;审计物理清理保持关闭。
- 企微应用凭据由后台配置写入数据库明文字段;这不是启动环境变量。本文不记录其值。
@@ -74,6 +74,111 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
### 单槽单卡当前卡标识核对与受控修复
本节仅供生产维护者在已备份、已留存核对清单且具备回滚条件的维护窗口执行。Agent 只可通过 dbhub 做只读核对,不得执行下述写操作。
先执行只读核对,候选必须同时满足:设备未删除、最大槽位为 1、只有一条未删除的有效绑定、且没有任何有效绑定被标记为当前使用卡。
```sql
SELECT b.id AS binding_id, b.device_id, b.iot_card_id
FROM tb_device_sim_binding AS b
JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
WHERE b.deleted_at IS NULL
AND b.bind_status = 1
AND d.max_sim_slots = 1
AND NOT EXISTS (
SELECT 1
FROM tb_device_sim_binding AS current_binding
WHERE current_binding.device_id = b.device_id
AND current_binding.deleted_at IS NULL
AND current_binding.bind_status = 1
AND current_binding.is_current
)
AND (
SELECT count(*)
FROM tb_device_sim_binding AS active_binding
WHERE active_binding.device_id = b.device_id
AND active_binding.deleted_at IS NULL
AND active_binding.bind_status = 1
) = 1
ORDER BY b.device_id, b.id;
```
维护者审核并留存候选清单后,在事务中把批准的 `binding_id` 显式填入 `approved`。更新语句会再次核对全部条件;受影响行数与批准清单不一致时必须回滚,不得扩大条件或直接按设备全量更新。
```sql
BEGIN;
WITH approved(binding_id) AS (
VALUES
(CAST(<binding_id> AS bigint))
), eligible AS (
SELECT b.id
FROM tb_device_sim_binding AS b
JOIN approved AS a ON a.binding_id = b.id
JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
WHERE b.deleted_at IS NULL
AND b.bind_status = 1
AND d.max_sim_slots = 1
AND NOT EXISTS (
SELECT 1
FROM tb_device_sim_binding AS current_binding
WHERE current_binding.device_id = b.device_id
AND current_binding.deleted_at IS NULL
AND current_binding.bind_status = 1
AND current_binding.is_current
)
AND (
SELECT count(*)
FROM tb_device_sim_binding AS active_binding
WHERE active_binding.device_id = b.device_id
AND active_binding.deleted_at IS NULL
AND active_binding.bind_status = 1
) = 1
FOR UPDATE OF b
)
UPDATE tb_device_sim_binding AS target
SET is_current = TRUE, updated_at = NOW()
FROM eligible
WHERE target.id = eligible.id
RETURNING target.id AS binding_id, target.device_id, target.iot_card_id;
-- 核对返回行数、设备卡列表与资产信息;不一致时执行 ROLLBACK。
-- 全部一致后才执行 COMMIT。
```
修复后必须确认每个设备至多一条未删除的有效绑定满足 `is_current = TRUE`,并抽样核对设备卡列表和个人客户资产信息均按最新绑定事实返回。回滚时仅对本次留存清单中的绑定恢复 `is_current = FALSE`,不得影响运营商观测或显式切卡产生的其他当前卡事实。
### 商户池支付路由发布与回滚
本节是维护者操作清单,不是已执行证据。迁移、生产发布、Redis 操作和富友真实渠道核验均由维护者执行;本轮未执行,不能以本地构建替代。
**前置条件**
1. 留存维护者指定测试环境或本地验证证据,且不得记录密钥或完整报文中的敏感凭证。富友仅沿用现有实现;未进行外部渠道实测不构成开发、测试部署、任务完成、归档或发布前置。
2. 确认本次商户池 Schema 迁移已完成可恢复备份及校验;停止服务后确认迁移锁影响、无长事务和可接受维护窗口。
3. 上传支持 `merchant_id`/`payment_config_id` 双读的 API 与 Worker 二进制。商户池新支付没有运行时开关。
**发布后检查**
1. 由维护者执行迁移并部署双读二进制。C 端套餐购买、C 端资产钱包充值、代理在线预存款充值的后续新支付立即经启用商户池创建并冻结 `merchant_id`、商户池与 `routing_epoch`。
2. 无可用商户池、成员缺失或池停用必须稳定失败,不得回退旧综合支付配置或自动换商户;后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池。
3. 检查首次成功唯一累计,以及回调/查单/退款 A 对 `merchant_id` 新单和 `payment_config_id` 历史单的双读分流;应用、审计和集成日志不得包含凭证、私钥、Token、证书或完整敏感配置。
**回滚与记录**
1. 不存在关闭商户池新支付创建的运行时开关。故障只能在仍支持双读的二进制上前向修复,不得恢复旧综合支付配置创建。
2. 只要存在 `merchant_id` 非空支付、成功累计事实或新商户池配置,禁止部署不识别新路由的旧二进制,也禁止执行破坏这些事实的 down 迁移。
3. 维护者记录二进制版本、时间、目标 PostgreSQL/Redis 的脱敏标识、备份校验、验证结果与全部阻塞原因。
### 零金额退款发布后核验
发布本次退款审批变更后,维护者应先等待既有重试处理稳定事件 `approval:26:approved`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:
1. 退款单 `RF20260820170954264700` 已通过,关联订单支付状态为已退款。
2. 该退款没有代理主钱包或资产钱包的零金额回款流水。
### 锁的含义与发布影响
`000171` 会对 `tb_agent_wallet`、`000178` 会对 `tb_iot_card` 使用 PostgreSQL `ACCESS EXCLUSIVE` 锁。该锁执行期间会阻塞该表的读写及其他 DDL,直到迁移事务提交或回滚;若有未结束业务查询/事务,它也会等待。因此必须在 API 和全部 Worker 停止后执行,并在迁移前检查没有长事务。锁持续时间取决于表数据量、索引创建和等待中的旧事务;不能从仓库估算具体秒数。
@@ -88,3 +193,12 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
## 已确认数据库备份
每日凌晨 02:00 自动备份 `junhong_cmp_prod`:数据库运行在 Docker 容器 `postgres` 中,备份脚本执行 `pg_dump -Fc -Z 6`,写入 `/data/backups/postgresql/<库名>_<时间>.dump`,同时生成 MD5 文件并以 `pg_restore --list` 校验结构;保留 30 天。发布前仍须人工新建一次备份并确认校验通过,不能只依赖凌晨的最近备份。恢复命令待维护者实际演练或确认后补充。
## 日审计日志留存启用与恢复
归档与日留存由调度 Worker 受控处理 Asia/Shanghai 的已结束自然日;`JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 时不注册调度,已入队任务也安全跳过、不扫描在线日志表。开启后每次最多推进一个日期。`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=false` 时仅验证归档、对象、manifest、数据库数量和日期连续性,不写清理断点、不删除在线数据。Integration 的 `pending` 记录保留在线,但不会阻断同日终态日志、Audit 数据或后续日期的清理;若 pending 后续终结,Worker 会重建该日 revision 后续跑删除。
1. 发布新 Worker 后保持 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 和物理清理开关关闭;轮询总并发先设为 100。维护者先核对 `tb_log_archive_run` 中 Audit 与 Integration 两个来源从历史最早在线日期起没有缺失账本;缺失日期必须先受控补归档,不能跳过失败日。
2. 以关闭开关的 Worker 完成只读演练,观察 `/opt/junhong_cmp/worker/logs/audit-retention.log`:每个日期应有来源计数和耗时;若出现日期、来源、失败分类或安全错误摘要,先修复该来源的归档或校验问题后再演练。仅有 pending 不需要人工终结才可继续。
3. 低峰期先将调度 Worker 的 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=true`,重启该 Worker;确认单日归档稳定后,再将 `JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=true`,并持续观察上述独立日志、`tb_log_archive_run.cleanup_started_at` / `cleaned_at` 断点及表大小。DELETE 释放的 PostgreSQL 页面会供后续写入复用,表文件不会立即缩小;维护者按既有运维窗口评估 VACUUM,Worker 不执行 VACUUM 或表重写。
4. 异常时立即将开关改回 `false` 并重启调度 Worker。已清理日期按已验证的对象和 manifest 执行归档恢复;尚未清理或阻断的日期仍保留在线,无需数据库恢复。恢复后先重新执行只读演练,再决定是否重新开启清理。

View File

@@ -0,0 +1,242 @@
# 后台订单按类别拆分列表技术方案
最后更新:2026-10-26
状态:技术方案(未实现)。已定事项与后续事项见第六部分。
文档结构:
- 第一部分:现状逻辑(As-Is)
- 第二部分:老卡管对照
- 第三部分:方案(To-Be)
- 第四部分:改动范围
- 第五部分:验证
- 第六部分:已定事项与后续事项
---
## 第一部分:现状逻辑(As-Is)
### 1. 接口
| 接口 | 实现 | 说明 |
|---|---|---|
| `GET /api/admin/orders` | `OrderHandler.List`(`internal/handler/admin/order.go:90`) | 订单列表,平台、代理商、在线支付三类订单混在一起返回 |
| `GET /api/admin/orders/:id` | `OrderHandler.Get` | 订单详情 |
| `POST /api/admin/orders` | `OrderHandler.Create` | 后台下单 |
| `POST /api/admin/orders/:id/cancel` | `OrderHandler.Cancel` | 取消订单,仅代理账号 |
| `POST /api/admin/orders/purchase-check` | `OrderHandler.PurchaseCheck` | 套餐购买预检 |
| `POST /api/admin/export-tasks`(`scene=order`) | `OrderDataSource`(`internal/exporter/order_scene.go`) | 订单导出,与列表一样三类混在一起 |
路由在 `internal/routes/order.go`,`GET /orders` 在第 22 行,`GET /orders/:id` 在第 30 行。
### 2. 列表的权限与数据范围
两层条件,全部在服务端决定,前端不能改:
1. Handler 层:代理账号固定买家条件 `buyer_type = agent AND buyer_id = 当前店铺`;平台账号与超管不加买家条件。
2. Store 层:`OrderStore.List`(`internal/store/postgres/order_store.go:85`)叠加 `applyOrderScope`(第 200 行)。代理账号限定为买家、销售店铺或操作者属于本店及下级店铺;平台账号与超管不限。
后台认证 `AdminAuth` 允许超管、平台、代理、企业四类账号访问。
订单导出(`OrderDataSource.applyFilters`,`order_scene.go:130`)复刻了上面两层条件:创建导出任务时冻结账号类型、店铺范围(`ScopeShopIDs`)与发起店铺(`CreatorShopID`),执行时按冻结值过滤;代理账号同样固定"买家是本店"。导出任务只允许超管、平台、代理创建(`internal/service/export_task/service.go`)。
导出场景的接入点:
- 场景常量:`pkg/constants/constants.go:400` `ExportTaskSceneOrder = "order"`。
- 注册:`exporter.NewDefaultRegistry`(`internal/exporter/registry.go`)按数据源的 `Scene()` 建表,创建任务时用 `IsSupported` 校验场景。
- 时间筛选:`timeFilterScenes`(`internal/exporter/time_filters.go`)登记了 `order`,创建任务时按统一格式冻结 `start_time`/`end_time`。
### 3. 订单的来源与支付方式
| 字段 | 取值 | 说明 |
|---|---|---|
| `source` | `admin` 后台 / `client` 客户端 | 非空,默认 `admin`(`pkg/constants/order_source.go`) |
| `payment_method` | `wallet` / `offline` / `wechat` / `alipay` | 后台下单只支持 `wallet` 与 `offline` |
| `buyer_type` | `agent` / `personal` | 买家类型 |
| `operator_account_type`、`operator_type` | `platform` / `agent` / `personal_customer` / 空 | 操作账号类型;历史订单有空值 |
### 4. 各下单场景
后台下单 `CreateAdminOrder`(`internal/service/order/service.go:214`):
| 场景 | 操作账号 | 支付方式 | 买家 | 扣谁的钱 | 退款方式 |
|---|---|---|---|---|---|
| 平台自营 | 平台 | 线下 | 个人(id 0) | 不扣钱包,线下收款 | 退到客户账户 |
| 赠送 | 平台 | 线下 | 个人(id 0) | 不收钱 | 退到客户账户 |
| 平台代购 | 平台 | 线下 | 资源所属代理 | 不扣钱包,线下收款 | 退到客户账户 |
| 平台代扣代理钱包 | 平台 | 钱包 | 资源所属代理 | 该代理主钱包 | 退回代理钱包 |
| 代理自购 | 代理 | 钱包 | 本代理 | 本代理主钱包 | 退回代理钱包 |
| 代理给下级购买 | 代理 | 钱包 | 下级代理 | 本代理(操作方)主钱包 | 退回代理钱包 |
| 代理 OpenAPI 下单 | 代理 | 钱包 | 同代理后台下单 | 代理主钱包 | 退回代理钱包 |
| 资产套餐批量订购 | 同发起账号 | 同上 | 同上 | 同上 | 同上 |
客户端下单(`source = client`):个人客户在 H5 下单,用微信、支付宝或卡钱包支付,含待支付订单;自动续费(`internal/application/assetautorenewal/renew.go`)、自动购买(`internal/task/auto_purchase.go`)也记为客户端订单。
平台代扣代理钱包与代理自购的扣款钱包、买家、成本价、订单角色(都是 `self_purchase`)、退款方式、佣金基准完全相同,只有操作人字段不同。
### 5. 测试环境数据分布(165 笔)
| 来源 | 支付方式 | 场景 | 笔数 |
|---|---|---|---|
| client | 微信、支付宝、卡钱包、空(待支付) | 个人客户在线下单 | 53 |
| admin | wallet | 代理操作 | 21 |
| admin | wallet | 平台代扣代理钱包 | 31 |
| admin | offline | 平台代购 | 41 |
| admin | offline | 平台自营、赠送等(买家个人) | 19 |
生产分布未查到(查询时数据库连接工具断开),实施前需补查,见第五部分。
### 6. 现状问题
- 运营无法按"平台订单 / 代理商订单 / 在线支付订单"分页面查看,只能靠支付方式、订单角色间接筛,而且这两个条件都区分不出"平台代扣代理钱包"和"平台代购"。
---
## 第二部分:老卡管对照
源码 `kyhl-src`(`apps/kyhl-crm`)中三类订单是三张表、三个页面:
| 老页面 | 表 | 内容 | 谁能看 |
|---|---|---|---|
| 微信订单(`agentWeixinOrder/list`) | `tbl_agent_weixin_order` | C 端公众号订单:微信支付、余额支付(卡钱包)、自动续费 | 平台全部;代理看自己与下级卡上的订单 |
| 代理商订单(平台 `agentOrder/syslist`,代理 `agentOrder/list`) | `tbl_agent_order` | 代理账号在后台给卡加套餐,从代理预存款余额扣款(`CardViewComp.agentRecharge`) | 平台全部;代理只看自己的 |
| 平台加套餐 | 不生成订单 | 平台账号给卡加套餐只写套餐记录(`addCardOrBatchImportPackage`) | — |
结论:老系统判断是否为代理商订单,看的是"是否扣代理余额",不看操作人。新系统的平台线下订单(自营、赠送、代购)在老系统里没有订单记录。
附:老系统首页统计的"平台订单 / 代理商订单"按收款公众号划分(`weixin_agent_id` 是否为空),与列表页口径不同,不作为本方案的依据。
---
## 第三部分:方案(To-Be)
### 1. 接口
保留 `GET /api/admin/orders`,参数、响应、行为均不变。新增三个列表接口:
| 接口 | 类别 |
|---|---|
| `GET /api/admin/orders/platform` | 平台订单 |
| `GET /api/admin/orders/agent` | 代理商订单 |
| `GET /api/admin/orders/online` | 在线支付订单 |
- 类别由路径固定,不接收类型参数。
- 查询参数、分页、响应结构与原列表相同:入参 `dto.OrderListRequest`,出参 `dto.OrderListResponse`。
- 认证、用户类型准入、数据范围与原列表相同。
- 订单导出同样分三类,见本部分第 6 节。
- 订单详情、取消、购买预检、后台下单不变。
### 2. 类别划分:按钱从哪来,不按操作人
| 类别 | 条件 | 包含 |
|---|---|---|
| 在线支付订单 | `source = 'client'` | 个人客户微信、支付宝、卡钱包支付,自动续费,自动购买,待支付 |
| 代理商订单 | `source = 'admin' AND payment_method = 'wallet'` | 代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单 |
| 平台订单 | `source = 'admin' AND payment_method <> 'wallet'`(含空) | 平台自营、赠送、平台代购 |
- `source` 非空且只有两个取值,后台订单按是否用代理钱包支付二分,所以三类互斥且覆盖全部订单。相同条件下,三个新接口的总数之和等于原接口的总数。
- 不走代理钱包的后台订单一律不进代理商订单接口。平台代购的买家虽然记为代理,但按线下收款、退款走客户账户,归平台订单;后续如有要求再调整。
- 不依赖操作人字段,历史订单操作人为空也不影响分类。
放弃的方案:按操作账号类型划分(`operator_account_type`,为空回退 `operator_type`)。这样会把平台代扣代理钱包归到平台订单,与资金归属和老卡管口径都不一致,还要兜底历史空值。
### 3. 实现
1. 常量:在 `pkg/constants/order_category.go` 定义订单类别 `platform` / `agent` / `online`。
2. Store:`OrderStore.List` 支持内部过滤键 `order_category`,类别条件集中在一个函数里,三个接口与导出共用(见第 6 节)。
3. Service:`order.Service.List` 增加订单类别入参并透传给 Store;原调用传空类别,行为不变。
4. Handler:把现有 `List` 的参数解析、时间范围解析、代理买家条件提取为内部方法 `list(c, category)`。原 `List` 以空类别调用,新增 `ListPlatform`、`ListAgent`、`ListOnline` 分别以固定类别调用。权限逻辑只有一份。
5. 路由:三个静态路径必须注册在 `GET /orders/:id`(`internal/routes/order.go:30`)之前,否则会被当作订单 ID 解析,返回"无效的订单ID"。
类别不进 DTO,所以请求里即使带 `order_category` 等参数也不会被解析。
### 4. 代理账号看到的结果
权限不变,代理账号在新接口看到的是原接口结果按类别切开的部分:
| 接口 | 代理账号能看到 |
|---|---|
| 平台订单 | 平台为本店名下的卡代购的订单 |
| 代理商订单 | 买家是本店的钱包订单:本店自购、平台代扣本店钱包、上级给本店购买 |
| 在线支付订单 | 始终为空:原接口对代理只返回买家是本店的订单,客户端订单的买家是个人客户 |
代理在"给下级购买"时,订单买家是下级,原接口对该代理不可见,新接口也不可见。
### 4.1 企业账号不能访问订单接口(修复现有问题)
现状:后台认证允许企业账号,原列表对非代理账号不加买家条件,店铺范围对企业账号也不生效,接口权限中间件未接入路由,所以企业账号调用 `GET /orders` 与 `GET /orders/:id` 会看到全部订单。下单已拒绝企业账号,取消只允许代理,导出任务不允许企业账号创建。
做法:`OrderHandler` 增加企业账号校验,原列表、三个新列表、订单详情、购买预检对企业账号返回无权限。校验放在 Handler,不放 Store 的 `applyOrderScope`,因为后者被资产订单查询等其他场景共用。资产订单接口 `GET /assets/{identifier}/orders` 本次不改;测试环境实测它对企业账号未按企业授权范围校验(企业 4 无授权卡仍能查到卡 11486 的订单),归入企业账号仅限 H5 的后续收口。
这是本方案中唯一的破坏性变更,只影响企业账号。
### 5. 性能
类别条件只用 `source` 与 `payment_method`,不加专用索引。实施后在测试环境看执行计划;生产订单量明显增长时,再评估 `(source, payment_method, created_at)` 组合索引。
### 6. 导出分三类
保留 `scene=order`,行为不变。新增三个导出场景,与三个列表接口一一对应:
| 场景 | 对应列表 |
|---|---|
| `order_platform` | `GET /orders/platform` |
| `order_agent` | `GET /orders/agent` |
| `order_online` | `GET /orders/online` |
- 与列表一致:类别由场景决定,`filters` 里不加类别参数;筛选条件、表头、列内容、数据范围与 `scene=order` 完全相同,只多一个固定的类别条件。
- 用新场景而不是给 `order` 加参数,原因是导出本来就靠 `scene` 区分数据,和列表"路径决定类别"是同一个思路,前端每个页面各传自己的场景即可。
实现:
1. 场景常量:在 `pkg/constants` 新增 `ExportTaskSceneOrderPlatform`、`ExportTaskSceneOrderAgent`、`ExportTaskSceneOrderOnline`。
2. 数据源:`OrderDataSource` 增加类别字段,`NewOrderDataSource(db, category)`;`Scene()` 按类别返回场景,类别为空时返回 `order`;`applyFilters` 在现有条件后叠加类别条件。`Count`、`Fetch` 共用 `applyFilters`,条数与内容自动一致。
3. 类别条件只写一份:放在 `internal/query/ordercategory`,按表别名生成条件(导出查询用别名 `o`,Store 无别名),列表与导出共用,保证口径一致。与不轮询清单共用 `internal/query/pollingpause` 的做法一致。
4. 注册:`NewDefaultRegistry` 再注册三个类别的 `OrderDataSource`;`timeFilterScenes` 加入三个新场景,时间格式校验与 `order` 相同;`IsSupportedScene` 同步加入。
5. 导出任务创建接口的场景说明(`internal/routes/export_task.go`)补充三个新场景。
---
## 第四部分:改动范围
| 文件 | 改动 |
|---|---|
| `pkg/constants`(订单相关常量文件) | 新增订单类别常量 |
| `internal/store/postgres/order_store.go` | `List` 支持 `order_category` |
| `internal/service/order/service.go` | `List` 透传订单类别 |
| `internal/handler/admin/order.go` | 提取内部列表方法,新增三个 Handler;企业账号校验 |
| `internal/routes/order.go` | 注册三个路由,写明类别口径和"参数、响应、权限同原订单列表" |
| `pkg/constants/constants.go` | 新增三个订单导出场景常量 |
| `internal/exporter/order_scene.go` | 数据源支持类别,叠加类别条件 |
| `internal/exporter/registry.go`、`internal/exporter/time_filters.go` | 注册三个新场景,纳入时间筛选 |
| `internal/routes/export_task.go` | 场景列表说明补充三个新场景 |
| OpenAPI 文档、前端接口变更说明 | 补充三个列表接口与三个导出场景 |
- 不新增字段,不需要迁移,不改历史数据。
- 原导出场景 `order` 不变;不改订单详情、取消、下单。
- 无自动化测试(项目无 `*_test.go`),在测试环境验证。
---
## 第五部分:验证(测试环境)
1. 平台账号,相同条件调用三个新接口与原接口:三个新接口的总数之和等于原接口总数;抽查平台代扣代理钱包在代理商订单、平台代购在平台订单、自动续费在在线支付订单。
2. 平台账号,叠加支付状态、时间、资产标识、订单号等原有条件,结果正确;原接口结果与上线前一致。
3. 代理账号,三个新接口的结果都是原接口结果的子集,总数之和等于原接口;在线支付订单为空。
4. `GET /orders/:id` 仍正常;`/orders/platform` 等路径不会被当作订单 ID。
4.1 企业账号调用原列表、三个新列表、订单详情、购买预检返回无权限;`GET /assets/{identifier}/orders` 查询本企业资产不受影响。
5. 导出:平台账号与代理账号分别用三个新场景导出,条数与对应列表接口的总数一致,表头与 `scene=order` 相同;`scene=order` 导出结果与上线前一致;新场景传旧时间格式时与 `order` 一样被拒绝。
6. 上线前补查生产分布:按 `source`、`payment_method` 统计,确认没有 `source` 为其他值的订单、后台订单只有钱包和线下两种支付方式。
---
## 第六部分:已定事项与后续事项
已定:
- 原接口保留,新接口不传类型参数;超管、平台、代理的权限与数据范围不变。
- 企业账号不能访问 `/orders` 下的列表、详情、购买预检,修复其可看到全部订单的问题。
- 按钱从哪来划分:代理钱包扣款的后台订单(含平台代扣)归代理商订单;不走代理钱包的后台订单归平台订单。
- 导出也分三类:新增 `order_platform`、`order_agent`、`order_online` 三个场景,原 `order` 场景保留。
后续(有要求再做):
- 平台代购是否改归代理商订单。
- 代理账号是否需要在在线支付订单接口看到自己资产上的个人客户订单(属于权限调整)。
- 在线支付订单是否增加老卡管微信订单页的"订单来源、收款方"筛选。

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
@@ -270,9 +283,9 @@
- **最小正例**:外部失败写脱敏 Integration Log,HTTP 请求写 Access Log。
- **最小反例**:把完整凭证写 app.log。
- **机械检查/人工原因**:人工核对 Logger 字段、Sanitizer 与事实存储;本地按 request_id/correlation_id 查询日志。
- **例外条件**:第三方要求回显的非敏感渠道码可保留。
- **例外条件**:第三方要求回显的非敏感渠道码可保留。卡网关调用(add-carrier-gateway-rate-limit):普通轮询查询不写 Integration Log,失败原因与调用量由 Redis 按运营商账户的分钟计数与卡最近错误承载(同时是优先执行的轮次仍写);系统自动停复机不写 Integration Log 与 Audit Event,每次上游调用写停复机记录表 `tb_card_stop_resume_record`;后台人工点击、通道阈值、自动续费及账户、个人客户、OpenAPI 发起的停复机保持原写法。
- **Owner**:审计与安全负责人
- **最后验证日期**:2026-08-07
- **最后验证日期**:2026-10-24
- **更新触发条件**:日志字段、保留策略或外部集成变化
## ENG-SECRET-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
- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -9,20 +9,37 @@
## 当前实际使用范围
系统仅使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);未发现退款、撤销或查单能力。
系统使用微信预下单 `POST <ApiURL>/wxPreCreate`、主扫统一下单 `POST <ApiURL>/preCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序),主扫下单的订单类型为 `WECHAT` 或 `ALIPAY`;本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。
## 原路退款
退款申请 `POST <ApiURL>/commonRefund`,必填 `version`、`ins_cd`、`mchnt_cd`、`term_id`、`mchnt_order_no`、`random_str`、`sign`、`order_type`、`refund_order_no`、`total_amt`、`refund_amt`;选填 `operator_id`、`reserved_fy_term_id`、`reserved_origi_dt`、`reserved_addn_inf`、`reserved_refund_desc`。响应 `result_code=000000` 表示渠道受理成功,此时取 `refund_id`(富友退款流水号)、`transaction_id`、`reserved_refund_amt`(退款金额,分)、`reserved_fy_settle_dt`(清算日期)。`reserved` 开头字段随报文发出但不参与签名。
退款查询 `POST <ApiURL>/refundQuery`,入参为 `refund_order_no`;响应 `trans_stat` 取值为 `SUCCESS`(退款成功)或 `PAYERROR`(退款失败),未返回该字段表示仍在办理中。
全局约束:`mchnt_order_no` 与 `refund_order_no` 均为全局永久唯一,重复提交会被直接拒绝;商户退款单号格式为「机构码(4 位) + 日期(yyyyMMdd) + 随机段(8–18 位字母数字)」,本系统按该规则生成,三渠道共用同一生成器;接口支持全额退款与多次部分退款。
原交易日期决定可退时限:不传 `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 @@
- Owner:IoT 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

@@ -30,6 +30,17 @@
业务以本地审批实例和企业微信审批单号去重,终态只推进一次;`errcode/errmsg` 保存到集成日志并映射项目错误,不能直接暴露凭证或底层报文。
## 退款审批模板上线前置
退款审批场景上线前,维护者 MUST 在当前企业微信模板中完成并核对以下控件映射:
- 固定必需:退款方式中文名称(单行文本),用于展示“原路退款”“客户收款信息退款”“退回资产钱包”“退回代理主钱包”。
- 条件必需:客户收款信息(多行文本)与客户收款凭证(附件)。仅当本次审批尝试冻结的退款方式为 `customer_account` 时,二者必须同时配置且本次值非空;其他退款方式不要求、不提交空白占位材料。
保存场景时系统会校验固定退款方式映射,并校验已配置条件控件的业务字段与控件类型;提交时按不可变审批尝试快照再次校验。模板缺少客户收款条件控件时,客户收款退款会明确失败并指出缺失字段/控件,不会创建第二张审批单;修复映射后可通过 `POST /api/admin/refunds/{id}/recover-approval` 恢复原审批实例。已有 `sp_no` 只查询同步,结果未知只确认,明确提交失败且无 `sp_no` 才恢复原提交事件。
本次发布不执行真实企业微信或支付调用;上线验收需由维护者在指定测试企业覆盖四种退款方式、条件材料缺失、模板修复后的原实例恢复、结果未知与重复/并发恢复,并核对审批尝试数、实例数和业务资金事实不变。
## 安全与验证
Secret、access_token、回调 AES Key、成员敏感字段、审批正文与附件 URL 必须脱敏。可信 IP、应用可见范围、模板、回调 URL 和素材权限属于部署侧人工配置。

View File

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

View File

@@ -0,0 +1,109 @@
# 后台代客下单误判为"C端已实名购买",套餐跳过实名门槛直接生效(Bug,已修复)
> 2026-10-27 已修复:`activateMainPackage` 的 `isCEndPurchase` 增加 `order.Source == client` 条件,后台平台自营与赠送套餐线下订单不再跳过实名门槛(同时可正常进入沉默期)。
## 起因
排查设备 `60003081756`(换货前旧资产)与其换货后新资产 `60003080119` 的换货记录(换货单 `EXC20260922173715169105`)时发现:换货完成后,操作人蒋恒在后台为新设备(`tb_device.id=19452`)代客户下单购买套餐(订单 `ORD20260922175155311314`,`payment_status=paid`),套餐(`tb_package_usage.id=83144`)在下单瞬间(`2026-09-22 17:51:55`)就变成"生效中"(`status=1`),而此时这张卡(`tb_iot_card.id=132322`,iccid `89860624660030801193`)的 `real_name_status` 仍是 `0`(未实名)。本记录仅做事实归档,**未修改任何代码**,全部结论来自对生产库 `junhong_cmp_prod` 的只读查询(`root@116.162.101.91`,仅执行 `SELECT`)与仓库 Git 历史核对。
## 结论
- **Bug 真实存在,当前代码未修复,仍会触发。**
- 触发条件:后台管理员为**一台当时还没有分配店铺归属(`shop_id` 为空)的设备/卡**,用 `offline` 或 `wallet` 支付方式代客户下单购买套餐时,系统会把订单误判为"客户本人在 App 自助下单",从而跳过套餐本该有的"等实名认证后再激活"逻辑,导致套餐在客户尚未实名的情况下立即生效、开始计时。
- 本次涉及的客户(personal customer_id=1008)事后约 18 分钟自行在运营商侧完成了实名(详见下方"实名完成时间核对"),影响较小;但历史上已有 3 起同类真实案例(非数据迁移脚本产生),不能排除客户未及时实名、白白损失部分套餐有效期的情况。
## 根因
### 1. 直接原因:`isCEndPurchase` 判断条件遗漏 `Source` 检查
- **代码位置**:`internal/service/order/service.go:2574-2596`,函数 `activateMainPackage`。
- 决定"套餐是否要等实名才激活"的开关:
```go
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
if !isCEndPurchase {
// 后台囤货路径:按 expiry_base 决定是否等实名
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase {
if !currentlyRealnamed {
status = constants.PackageUsageStatusPending
pendingRealnameActivation = true
}
}
}
// C 端购买已完成实名前置校验,直接激活。
```
只要 `order.BuyerType == "personal"` 就认定"客户已在 App 里做过实名前置校验,可以直接激活",**完全没有检查订单来源(`order.Source`)**。
- 同一文件里另一处判断"是否真的是客户在 App 自助下单"时,写法是**严谨的**(`internal/service/order/service.go:2868`):
```go
if order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal {
```
两处对"C端购买"的判定标准不一致,`activateMainPackage` 里少了 `Source == client` 这一层。
### 2. 触发路径:`buyer_type=personal` 是怎么产生的
- **代码位置**:`internal/service/order/service.go:284-333`,函数 `CreateAdminOrder`(后台代客下单,线下支付分支)。
- `resourceShopID = resolvedDevice.ShopID`(取设备当前的 `shop_id`):
- 若 `resourceShopID == nil || *resourceShopID == 0`(设备当时没有店铺归属),判定为"平台自营,直接卖给终端客户",**强制**把 `orderBuyerType` 设为 `model.BuyerTypePersonal`。
- 本次案例中,设备 `19452` 在下单那一刻(`17:51:55`)确实还没有分配店铺——分配店铺的动作(`device.allocate`,`shop_id: null → 670`)发生在 `17:52:44`,**在下单之后**。于是订单被自动打上 `buyer_type=personal`,进而在 `activateMainPackage` 里命中上述 `isCEndPurchase=true` 分支。
### 3. 时间线为何这两段代码会"撞车"
| 时间 | 提交 | 内容 |
|---|---|---|
| 2026-03-02 | `8efe7952` | 「修复平台自营资源(未分配代理)无法线下下单的问题」——引入"设备无店铺归属 → `buyer_type=personal`"这条路径 |
| 2026-03-28 | `7dd55630` | 「activateMainPackage 三维决策重构 REALNAME-02」——引入 `isCEndPurchase := order.BuyerType == Personal` 假设 |
`7dd55630` 落地时,`8efe7952` 引入的"后台平台自营代客下单也会产出 `buyer_type=personal`"这条路径已经存在近一个月。`isCEndPurchase` 的假设从写下的那一刻起就没有覆盖到这条路径,二者从未真正对齐过。
## 实名完成时间核对(本案例,非普遍规律)
```
2026-09-22 17:51:55 订单创建,套餐立即激活(status=1,跳过实名等待)
2026-09-22 18:10:38 iot_card.worker_realname_sync(后台定时轮询任务,从运营商侧同步)
before: {"real_name_status": 0}
after: {"real_name_status": 1, "first_realname_at": "2026-09-22T10:10:37Z"(UTC,即北京时间 18:10:37)}
```
客户是在运营商侧真实完成的实名认证,系统通过定时轮询同步过来,不是人工手动改的状态。套餐已提前激活约 18 分钟,这段时间里客户理论上是在为一个"计时中但用不了"(网络仍是停机状态)的套餐付费/计时。
## 数据验证:历史命中范围
用以下条件在生产库上识别同类命中(后台代客下单、`buyer_type=personal`、套餐配置为"需等实名激活"、但落库时直接是生效状态):
```sql
-- 设备类套餐(usage_type='device')
SELECT count(*) FROM tb_package_usage pu
JOIN tb_order o ON o.id = pu.order_id
WHERE o.source = 'admin' AND o.buyer_type = 'personal'
AND pu.expiry_base_snapshot = 'from_activation'
AND pu.pending_realname_activation = false
AND pu.status = 1 AND pu.master_usage_id IS NULL
AND pu.usage_type = 'device'
AND NOT EXISTS (
SELECT 1 FROM tb_device_sim_binding b
JOIN tb_iot_card c2 ON c2.id = b.iot_card_id
WHERE b.device_id = pu.device_id AND b.bind_status = 1 AND c2.real_name_status = 1
);
```
粗查命中 5210 条,但其中 **5207 条来自一次历史数据迁移脚本**(`order_no` 以 `MIG-` 开头,2026-09-03~09-04 一天多内由 2 个系统账号批量写入),属于把老系统里客户本来就已生效的历史套餐批量导入新系统,跳过实名门槛是迁移脚本的预期行为,**不算本 bug 命中**。
刨除迁移脚本后,**真正由人工后台操作触发的同类历史案例只有 3 起**,加上本次共 4 起:
| 订单号 | 操作人 | 设备 | 时间 |
|---|---|---|---|
| ORD20260905144024517558 | 王鹏 | 18914 | 2026-09-05 14:40 |
| ORD20260905144241735458 | 王鹏 | 18913 | 2026-09-05 14:42 |
| ORD20260916112035085745 | 王鹏 | 19108 | 2026-09-16 11:20 |
| ORD20260922175155311314 | 蒋恒 | 19452 | 2026-09-22 17:51(本次) |
## 后续整改(代码侧,尚未执行)
1. **建议修复**:将 `internal/service/order/service.go:2575` 的 `isCEndPurchase` 判断改为与 `service.go:2868` 一致,同时检查 `order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal`,使"后台代客下单"不再被误判为"客户 App 自助下单"。
2. **建议核实**:王鹏那 3 起历史案例(设备 18913、18914、19108)对应的卡后续是否及时完成了实名认证,是否存在客户实际损失套餐有效期天数的情况,需要跟客服/王鹏本人核对。
3. 本记录暂未提交 OpenSpec Change,代码修复建议后续单独立项处理;此文档仅作为问题发现与数据取证的归档,供后续排期与客诉复盘引用。

View File

@@ -0,0 +1,32 @@
# `add-agent-registration-management` 集成验证阻塞证据
日期:2026-09-21
## 部署可达性
- 当前工作分支:`Iteration/8-11`;本地 `HEAD=b0636171530df2cbd468c0f6e25091049ad9aeb1`。
- `git ls-remote origin refs/heads/Iteration/8-11` 返回同一 SHA,说明当前未提交 Change 工作区没有进入远端分支。
- `.gitea/workflows/deploy.yaml` 仅由 `push` 到 `Iteration/8-11` 触发;本次按用户要求不提交、不推送。
- 对 Gitea API `POST https://git.boss160.cn/api/v1/repos/csxj2026/junhong_cmp_fiber/actions/workflows/deploy.yaml/dispatches` 的未认证探测返回 `HTTP/2 401`,`{"message":"token is required","url":"https://git.boss160.cn/api/swagger"}`。环境未提供 Gitea token;不绕过既有工作流。
- `cmp-test` SSH 别名可达(`cmp-test -> qycard001@47.111.166.169:52022`),允许读取状态/日志。容器实际为:
- API:`registry.boss160.cn/junhong/cmp-fiber-api:b0636171530df2cbd468c0f6e25091049ad9aeb1`,health=`healthy`。
- Worker:`registry.boss160.cn/junhong/cmp-fiber-worker:b0636171530df2cbd468c0f6e25091049ad9aeb1`,running=`true`。
- 容器环境确认 PostgreSQL `junhong_cmp_test`(`cxd.whcxd.cn:16159`)和 Redis DB 6(`cxd.whcxd.cn:16299`)。
- 测试 API 在 `cmp-test` 容器内 `curl http://127.0.0.1:3000/health` 返回 `HTTP/1.1 200 OK`;外部未提供测试 API 地址,未冒充本地/外部 API 可达。
- 当前部署 SHA 为 `b063617...`,等于远端基线 SHA;未包含本地未提交的注册管理 Change,因此目标路由验证返回 `HTTP/1.1 404 Not Found`。
## 受控 fixture
- 使用临时 GORM 工具 `cmd/agent_registration_fixture_tmp/main.go`,只连接显式 `DB_*` 的 `junhong_cmp_test`,未使用 psql;dbhub MCP 仅做只读诊断。
- 唯一前缀:`ARM260921`。创建了 5 个店铺、6 个账号、6 条注册记录、6 个审批实例和 6 个企微上下文;包含直属、深层、其他店铺、停用上级、终态与提交异常状态。
- 首次 setup 因测试 fingerprint 超过 `char(64)` 约束回滚,未留下记录;修正为 `arm260921fixture-fingerprint` 后创建成功,IDs 记录在 `/tmp/ARM260921-setup.txt`。
- 已执行受控 cleanup,仅按 `ARM260921` 的注册/店铺/账号及关联审批实例/企微上下文删除;未重置数据库,未操作其他记录。清理输出:`cleanup complete for ARM260921`。
## API 尝试边界
- 在部署基线容器上以 fixture 账号尝试登录:`ARM260921-super` 返回 `code=1040 用户名或密码错误`(该账号由本地 Change fixture 创建,但容器运行的是基线代码;数据库账号密码本身可由部署 API 按登录查询)。
- 直接访问目标路由无 token 返回 `401 code=1002`;带从成功登录获得的现有 fixture token 访问目标路由返回 `404 code=1006 资源未找到`,证明当前部署未装载目标 Change 路由,而非权限断言。
## 结论
工作流可访问但不可触发:Gitea API 需要未提供的 token;按用户要求不能提交/推送,故无法把当前 Change 部署到 `cmp-test`。当前已部署版本健康可达但不包含 Change。5.1–5.4 未勾选、未把基线 404/fixture DB 事实冒充端到端验证。

View File

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

View File

@@ -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=0(HTTP 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 0,total 与缺省一致)**
```
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 含「start−1s / ==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:59(start−1s)、06:00:01(end+1s)、08:00:00(8 小时漂移探针)
导出 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 导出、表头、加油包**
fixture:3 个资产(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
(库内冻结行 69:scope_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 第 1–12 项):
| 表 | 标记方式 | 造行数 | 用途 |
| --- | --- | --- | --- |
| `tb_shop` | `shop_name LIKE 'AUG26014%'` | 1 | 临期资产归属店铺(`parent_id=1`,供代理可见链路) |
| `tb_iot_card` | `iccid LIKE 'AUG26014%'` | 3 | 临期资产 |
| `tb_package_usage` | `package_name LIKE 'AUG26014%'` | 4(3 主套餐 + 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 第 13–14 项):
| 表 | 标记方式 | 造行数 | 用途 |
| --- | --- | --- | --- |
| `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%'` | 3(2 条窗内 + 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:26–17:37 | `d52be16` / 修复前二进制 | 仅作预演记录;本文件引用的有效证据全部为修复后批次 |
| 第一批(§6.2 第 1–12 项) | 17:39–17:47 | `d52be16` / `51c619f8…` | T7 实现尚未提交;当时 HEAD 尚不含维护者的路由门禁修复,故代理 HTTP 项为 403/1005 |
| 第二批(§6.2 第 13–14 项、§6.1 第 15–17 行) | 18:0x–18: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]'` = 0),7.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)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,209 @@
# 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.2–9.7 本机可安全验证部分(A–F + 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`(仅 SELECT);Redis 只读用 `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` | 应用到 228,exit=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 插入活动行 A(id=1 card_id=6012 task_type=carddata status=pending)
[报错] P2 重复活动行(同卡同任务类型) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
[成功] P3 软删 A 后插入活动行 B(id=3)→ 软删行不占用活动项键位
[报错] P4 插入活动行 C(B 仍活动) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
[成功] P5 把 B 转终态(completed/success)后插入活动行 D(id=5)→ 终态行不占用活动项键位
[报错] P6 终态与结果一致性(completed 但 result 为空) → SQLSTATE=23514 constraint=ck_polling_priority_item_terminal_result
索引与约束探针全部符合预期 (exit=0)
```
### 2.3 A–F 批次(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 两次 MarkExecutionStarted 后 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_id:outbox 仍只 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_item,origins 含 account/admin_api 与 system_task/worker`;`F 消费者审计 Actor=system_task / Source=worker` |
| count(残留计数) | 10 PASS | 卡/设备/绑定/套餐使用/优先项/审计/outbox/两类孤儿审计资源 **均 = 0**,且 `本批全部自建行已清理干净` |
口径说明(`close-august-iteration-gaps` 引入的重命名,2026-09-18 核对):本表 A3 的 Store 方法在本次重命名中由 `IncrementAttempt` 改为 `MarkExecutionStarted`(`internal/store/postgres/polling_priority_item_store.go`,签名 `(ctx, id, now)`,断言口径不变);人工入队用例文件由 `internal/service/polling/priority_enqueue_service.go` 改名为 `internal/service/polling/priority_manual_service.go`(类型 `PriorityEnqueueService` → `PriorityManualService`,`Enqueue` 签名不变,新增 `Close`/`Retrigger`)。
### 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_manual_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) 租约内 = 跳过且零上游 | `状态/认领时间/尝试次数/失败原因全部不变 → processing,claimed_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
[清理] 优先项=0;Outbox(prio:)=0;套餐使用=0;绑定=3;卡=6;设备=1
[PASS] 清理后计数 … 应为 0(9 条)+ 本批全部自建行已清理干净
结果:全部断言通过
```
### 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:37Z–02: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>'`,
每条场景运行前后均为 0(15 条独立断言),并由 `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.1–9.7 按用户口径统一勾选,第 4 节未验证清单不因勾选而改变。

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,597 @@
# 卡流量计算技术方案(参考老卡管)
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-20 |
| 状态 | 方案,待评审,**代码尚未修改** |
| 思路 | **流量怎么算,照搬老卡管;套餐那边一行不动**:只替换"增量从哪来",增量照旧通过 V1 事件交给现有 `DeductDataUsage` 扣减 |
| 修订 | 第 2 版(2026-10-20):对照反编译结果与新卡管代码复核后修订——增加切换水位(4.6)、增量改为按月对账(4.5)、已切换卡的通道阈值判定(4.5)、去掉"读数是否有效"标记、更正对老卡管的几处描述;新增第 10 章停复机规则与实名管控;已切换卡的卡表本月用量改由快照覆盖(4.5),切换与退回要求全部停止后再启动(5.2) |
| 改动范围 | 流量部分(第 1~9 章):观测链路(算增量)+ 新增卡日快照表 + 切换水位表 + 日流量读取 + 已切换卡的通道阈值判定条件;**套餐表、套餐服务、V1 消费者、激活/重置/改已用量/换货/退款入口、扣减顺序、停复机全部不改**。停复机部分(第 10 章):停机与复机只看套餐和流量(通道开启实名管控时加上实名)、复机不看停机原因、实名默认不管控、暂停轮询的卡不自动停复机;独立开发、独立上线 |
| 与旧方案关系 | 替代 `docs/流量事实模型技术方案.md`(第 5 版)的计量部分 |
| 老卡管依据 | 反编译 `kyhl-api/WEB-INF/lib` 下 `kyhl-service-1.0.jar`、`kyhl-dal-1.0.jar`、`kyhl-common-1.0.jar`、`kyhl-common-jdbk-1.0.jar` |
---
## 1. 结论
1. **漏洞根源**:新卡管现在的增量 = 本次读数 − 卡上的基线(`tb_iot_card.last_gateway_reading_mb`)。基线是跨套餐、跨用户的卡级状态,一旦出错(被异常高读数抬高、重置日没采到)就永久保留:卡被冻结、读数为 0 时重复计、二次销售时多计。
2. **老卡管不依赖基线**:每张卡每天存一条"本月累计"快照,任何一段时间的用量都用快照现算(区间内最大值 − 区间开始前的最大值)。它没有"读数下降"的概念,已经稳定运行多年。
3. **本方案**:
- 流量计算照搬老卡管:日快照、按各运营商的重置日(`tb_carrier.data_reset_day`)换算成自然月、重置日防旧值、按区间取最大值做差。
- 每次观测的增量 = **本月最大快照 − 本月已结算合计**(按月对账),不再用卡表基线。
- 卡第一次走新路径时记一条**切换水位**:本次仍按老路径的增量发,之前的部分视为老路径已结算。切换衔接、补数据截断、冻结卡判定都以它为准。
- 增量照旧写进 V1 事件 `CardTrafficIncremented`,由现有消费者调用 `DeductDataUsage` 扣减。**套餐分配、加油包顺序、虚流量阈值、停复机、流量详单全部沿用现有代码。**
- 已切换的卡,通道阈值按"读数有效且未被防旧值处理"判定,不再受卡表基线影响,冻结卡切换后通道达量停机随即恢复。
4. **二次销售**:卡没有生效套餐时,增量照样计算,但**只记为已结算、不发事件**。激活之前被采到的用量不会扣到新套餐上;精度到观测粒度:激活后第一次观测里,"上次观测到激活"这一段仍会算给新套餐。
5. **先做卡流量计算,补数据放到后期**:新方案上线后,冻结卡从切换起恢复正常计量;切换前少扣的部分由后期的独立脚本补(5.5)。补数据依据运营商读数(未切换的卡现场查询,已切换的卡取切换水位里切换时的读数),只能补执行时所在运营商周期内的部分,更早的补不回来。
### 1.1 已定决策(2026-10-20)
| # | 事项 | 决定 |
|---|---|---|
| D1 | 流量计算 | 照搬老卡管:日快照、写入时按运营商重置日(`tb_carrier.data_reset_day`,任意 1~28 号)换算成自然月、按区间取最大值做差,没有读数下降判定。重置日不写死,运营商配置改了随之生效 |
| D2 | 套餐侧 | 一行不改:增量照旧走 V1 事件与 `DeductDataUsage` |
| D3 | 重置日防旧值 | **完全照搬老卡管**(含比较基准):只在重置日当天判断,`R = 1` 比上月最大快照、`R > 1` 比本月 1~R−1 号最大快照,超过 0.9 倍按 0 记;第二天起照常累加,不挂起、不告警。老卡管多年运行没有暴露问题 |
| D4 | 卡无生效套餐时的增量 | 只记入 `settled_mb`(已结算),不发事件,用量丢弃 |
| D5 | 快照记录范围 | 全部被采集的卡 |
| D6 | 上线方式 | 按名单灰度,单位是扣减载体,设备整台切;第一批为 103154 所在设备 15276,核对后扩批再全量。非灰度卡行为与现在一致(4.8) |
| D7 | 积压的无套餐回执 | 业务后续手动调整,不在本方案内;处理前不得重放 |
| D8 | 顺序 | 先上卡流量计算,补数据放后期(5.5) |
| D9 | 补数据:本周期中间重置过套餐的冻结卡 | 不自动补,进清单,附按天数比例的估算值,由人工决定 |
| D10 | 增量口径 | 按月对账:`Δ = 本月最大快照 − 本月已结算合计`,不按天对账(4.5) |
| D11 | 切换衔接 | 记切换水位:卡第一次走新路径时,本次照发老路径增量,之前的部分记为已结算;不依赖阶段 1 是否覆盖整月(4.6) |
| D12 | 已切换卡的通道阈值 | 读数有效且本次读数未被防旧值按 0 处理即判定,不再看老路径是否接受读数(4.5) |
| D13 | 套餐在扣减前刚好用完 | 消费者不改,写入已知限制并监控(第 9 节) |
| D14~D17 | 停复机规则、实名管控、暂停轮询 | 见 10.3 |
## 2. 现在的漏洞(已验证,详见旧方案第 2 节)
| # | 漏洞 | 证据 |
|---|---|---|
| 1 | **卡被冻结**:读数下降只在重置日当天或前一天才被认作清零,其余日子一律拒绝。基线被抬高后,之后的读数全被拒,套餐不再增长,也不停机 | `internal/domain/cardobservation/traffic.go:58-67`。卡 103154:基线 72535 MB,运营商读数 48808 MB,9-25 之后已用量一直为 0。2026-10-12 统计 15310 张活跃卡中有 3511 张(23%,上限估计)10 月基线没动过 |
| 2 | **读数为 0 被当成清零**:重置窗口内读到 0,基线被置 0,下一次真实读数整笔重复计 | `traffic.go:60-61`;电信抽查 124 张中 111 张读数为 0 |
| 3 | **无效响应按 0 处理**:`used` 为空、null、无法解析时当成 0,进而触发第 2 条 | `internal/gateway/models.go:55-78` |
| 4 | **二次销售多计**:新用户激活时,卡上的基线如果不是激活那一刻的值,差额就扣到了新套餐上 | 资产回收换代见 `docs/资产回收换代技术方案.md` |
| 5 | **日流量从来没存成功**:`tb_card_daily_usage` 的表结构和代码不一致,表里 0 行 | 旧方案 P14 |
| 6 | **无套餐期间的增量会被扣到下一个套餐上**:老路径不管卡有没有套餐都发 V1 事件;消费者调 `DeductDataUsage` 遇到"没有可用套餐"直接返回错误,任务由 Asynq 重试,重试期间套餐一激活,积压的增量就被扣到新套餐上——这是二次销售多计的另一条路径 | `application/cardobservation/traffic.go:73-76` 不判断有无套餐,有正增量就发事件;`infrastructure/cardobservation/event.go:276-277` 报错即返回;`usage_service.go:110`。**生产核实(2026-10-20)**:回执停在"处理中"(扣减未完成)的有 18,979 条、增量约 11.4 TB;其中 14,838 条对应的卡当前没有生效套餐,**4,141 条(2,404 张卡)对应的卡当前有生效套餐**,这批任务若被重试或人工重放,会扣到现在的套餐上,不得重放。**积压部分由业务后续手动调整,本方案只保证新增的不再产生** |
## 3. 老卡管怎么算(反编译核实)
### 3.1 日快照:每卡每天一条,存"本月累计"
出处:`YidongDayFlowService.saveYidongDayFlow`、`PtLiantongDayFlowService.saveLiantongDayFlow`。
- 表 `tbl_*_day_flow`:`iccid_mark`、`flow_date`、`used_flow`(自然月累计 MB);普联通另存 `vendor_used_flow`(运营商原始读数)。
- 同一天多次查询:用新值覆盖。
- 移动:新值比旧值小就跳过(每月 1 号除外)。
- 普联通:读数 ≤ 0 不覆盖;当天第一条就是 0 时,沿用昨天的值;其余情况后写覆盖,可以变小。
- 电信、广电、广电物联网、5G:与移动、普联通同类(1 号防旧值 + 同一天覆盖),细节略有差异。
- 老卡管按 category 分派到各自的表(`CardFlowUtils.getCardFlow`):电信 1、移动 3、联通 4/8/9、5G 108、广电 112/113 等。设备是主卡、副卡各自按区间取值后相加(`CardFlowUtils.getTraffic`)。
- 电信采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到的是自然月累计,之后同样写日快照、按快照计费。
### 3.2 联通:写入时把运营商周期换算成自然月
联通每月 27 号 0 点清零,运营商读数是"上月 27 号至今"。老卡管在写快照时就换算成自然月累计(老代码把 27 写死在联通的几个类里;本方案改为读 `tb_carrier.data_reset_day`,对任意重置日通用,见 4.3),后面统一按自然月计算:
| 写入日期 | 存入值 |
|---|---|
| 27 号~月底 | 运营商读数 + 本月 1~26 号的最大快照 |
| 1~26 号 | 运营商读数 − 上月 27 号~月底这段的用量(= 上月最大快照 − 上月 27 号前的最大快照) |
> 核实:老代码取"上月 1~26 号最大快照"用的是 `findFlowByBeforeDate`,SQL 为 `flow_date >= 1号 and flow_date <= 26号`,包含 26 号,取值正确;27 号起取"本月 1~26 号最大快照"用的是 `getMaxUsedFlow`,SQL 为 `flow_date < 27号`,同样正确。
> 老代码的小缺陷:`if ((flow -= subFlow) < 0) subFlow = 0` 改的是 `subFlow` 而不是 `flow`,负值会写进快照,靠查询时 `used_flow > 0` 过滤掉。本方案直接按 0 处理。
### 3.3 重置日防旧值:只有一条规则
| 运营商 | 规则 |
|---|---|
| 移动、电信、广电、广电物联网、5G(1 号清零) | 每月 1 号,读数 > 上月最大快照 × 0.9 → 记为 0 |
| 普联通 category 9(27 号清零) | 每月 27 号,读数 > 本月 1~26 号最大快照 × 0.9 → 运营商读数按 0 计(代码写作 `appendFlow > 0 && flow > appendFlow × 1.9`,`flow` 已含 `appendFlow`) |
| 联通 category 4、8 | 没有防旧值,只做自然月换算 |
意思是:重置日读到的值还接近上个周期的总量,就认为运营商还没清零或者读到了旧缓存,当天先不增长。除此之外,老卡管**没有任何读数下降的判定**。
### 3.4 按区间取值
出处:`YidongDayFlowMapper.xml` 的 `getQueryTrafficByDate`。
```
区间用量 = max(区间内的快照) − max(本月 1 号 ~ 区间开始前一天的快照)
```
- 只取 `used_flow > 0` 的快照;不能跨月查询;结果 < 0 时按 0。
- **取的是最大值,不是最新值**,所以旧缓存返回一个偏小的值,不会让用量变小。
### 3.5 套餐用量(本方案不照搬这一块)
老卡管从套餐开始日到今天按月切段累加,再拿总用量和所有套餐的总额度比较,不区分每个套餐用了多少。新卡管有加油包、独立有效期、`by_day` 重置、按套餐退款,这一块**沿用新卡管现有的 `DeductDataUsage`**,不改。
## 4. 新方案
### 4.1 原则
| # | 原则 |
|---|---|
| R1 | **流量照搬老卡管**:日快照 + 自然月换算 + 重置日防旧值 + 区间取最大值做差 |
| R2 | **套餐侧零改动**:增量照旧走 V1 事件和 `DeductDataUsage`;套餐表、套餐入口、分配顺序、停复机都不改(停复机的调整见第 10 章,与流量部分无关) |
| R3 | **不再用卡表基线算增量**:增量只由快照得出;卡表三列照旧由老逻辑维护,只作兼容展示 |
| R4 | **日期一律按 `Asia/Shanghai`** |
### 4.2 数据模型
新增两张表,**不改任何现有表**。迁移从当前最新编号之后开始,成对 up/down。
**`tb_card_flow_day`(卡日快照,永久保留;仓库现无分区表,一年约 3,200 万行,靠唯一索引足够,本期不分区)**
| 列 | 类型 | 含义 |
|---|---|---|
| `id` | bigserial | |
| `iot_card_id` | bigint | |
| `flow_date` | date | 上海日期 |
| `month_used_mb` | numeric(14,3) | 自然月累计(换算后),计算用 |
| `vendor_used_mb` | numeric(14,3) | 当天最后一次有效的运营商原始读数,排障用 |
| `settled_mb` | numeric(14,3) | 当天已结算的增量合计,默认 0。包括:发出 V1 的增量、无生效套餐时丢弃的增量、切换时记为"老路径已结算"的部分(见 4.5、4.6) |
| `stale_guarded` | boolean | 重置日当天是否触发过防旧值(读数按 0 记) |
| `obs_count` | int | 当天有效观测次数 |
| `created_at`、`updated_at` | timestamptz | |
唯一键 `(iot_card_id, flow_date)`。每卡每天一行,8.9 万卡约 3200 万行/年。
**`tb_card_flow_switch`(切换水位,每卡一行)**
卡第一次走新路径时写入(4.6),之后只读。退回老路径时删除(5.2)。
| 列 | 类型 | 含义 |
|---|---|---|
| `id` | bigserial | |
| `iot_card_id` | bigint | 唯一 |
| `switched_at` | timestamptz | 第一次走新路径的观测时刻 |
| `month_used_mb` | numeric(14,3) | 切换时本月最大快照(写入本次读数后) |
| `vendor_used_mb` | numeric(14,3) | 切换时的运营商读数 `r` |
| `old_baseline_mb` | numeric(14,3) | 切换时卡表 `last_gateway_reading_mb` |
| `old_month_usage_mb` | numeric(14,3) | 切换时卡表 `current_month_usage_mb` |
| `old_reading_accepted` | boolean | 切换这次观测老路径是否接受读数(冻结卡为 false) |
| `old_increment_mb` | numeric(14,3) | 切换这次观测老路径算出的增量(照此发 V1) |
| `created_at` | timestamptz | |
作用:切换衔接(4.6)、补数据截断与冻结卡判定(5.5)。
### 4.3 写快照(照搬 3.1~3.3,按运营商重置日 `R` 统一)
`R` 取 `tb_carrier.data_reset_day`。**运营商在每月 `R` 号 0 点(北京时间,`Asia/Shanghai`)清零**:联通为 27 号 0 点(业务确认,见 `docs/engineering/套餐流量计算逻辑核对.md`;与老卡管 `DateUtil.getLtPeriod` 一致),其余为 1 号 0 点。周期边界直接复用现有 `carrierthreshold.PeriodStart`(上海时区重置日 0 点),下文"今天日号""重置日当天"都按北京时间的日期判断。**查询失败、为空或不在 1~28 时,按卡的运营商类型兜底:`tb_iot_card.carrier_type = CUCC`(联通)取 27,其余取 1**,并记日志。不能沿用现有 `CarrierStore.GetDataResetDay` 一律返回 1 的做法(`carrier_store.go:103-104`)——联通卡被错当成 1 号重置会让自然月换算出错、累计值被抬高,按取最大值的规则会多扣且收不回。老路径继续用原函数,不改。
**注意取值位置**:三个入口现在调用 `ApplyTrafficObservation` 前就用 `GetDataResetDay` 算好 `TrafficObservation.ResetDay` 传进来(`polling_carddata_handler.go:103`、`series_runner.go:561`、`service/iot_card/service.go:1638`),查询失败时已经是 1。新逻辑**不能使用这个字段**,必须在观测事务里按已锁定卡的 `carrier_id` 严格查一次 `data_reset_day`,失败再按上面的运营商类型兜底。
每次观测(轮询、人工刷新、系列观测三个入口)读到 `r`:
1. **无效读数和 0 不写**:查询失败、`used` 为空 / null / 无法解析 / `< 0`、或 `r = 0`,一律不写快照、不发增量。(老卡管普联通对 0 是"当天已有行不覆盖、当天第一条沿用昨天的值",移动是写一条 0;本方案直接不写,因为计算只看最大值,效果相同。)Gateway 的 `FlexFloat` 已把空、null、无法解析都转成 0(`internal/gateway/models.go:55-78`),与读数 0 走同一处理,**所以不需要新增"读数是否有效"标记**,Gateway 与三个观测入口都不用改。
2. **换算成自然月累计 `m`**:
- `R = 1`:`m = r`;
- `R > 1`,今天日号 `≥ R`:`m = r + max(本月 1 号~R−1 号的 month_used_mb)`;
- `R > 1`,今天日号 `< R`:`m = r − (上月最大 month_used_mb − 上月 1 号~R−1 号的最大 month_used_mb)`,< 0 按 0。
3. **重置日防旧值(完全照搬老卡管,比较基准也一致)**:只在重置日当天判断,原始读数照存 `vendor_used_mb`:
- `R = 1`:`r > 上月最大 month_used_mb × 0.9` 时按 `r = 0` 计算 `m`(同 `YidongDayFlowService.saveYidongDayFlow`;上月没有快照时比较值为 0,任何正读数当天都按 0 记,次日如实写入,与老卡管一致);
- `R > 1`:本月 1 号~R−1 号的最大 `month_used_mb` 大于 0,且 `r` 超过它的 0.9 倍时,按 `r = 0` 计算 `m`(同 `PtLiantongDayFlowService.saveLiantongDayFlow`:`appendFlow > 0 && flow > appendFlow × 1.9`);本月前 R−1 天没有快照时不判断;
- 触发时置 `stale_guarded = true`;第二天起不再判断,读数照常写入。
4. **同一天合并**:当天已有行时,`month_used_mb = max(旧值, m)`。这一步是改进,不是照搬:老卡管普联通同一天后写覆盖、可以变小,但新方案的增量一旦发出就收不回,所以快照只增不减。
5. **写法**:一律用 `INSERT ... ON CONFLICT (iot_card_id, flow_date) DO UPDATE SET month_used_mb = GREATEST(tb_card_flow_day.month_used_mb, EXCLUDED.month_used_mb), ...`,不先查后写。已切换卡在事务 1 内写,卡行已被锁(`traffic.go:40`),天然串行;未切换卡在事务 1 提交后写,没有卡锁,靠这条原子语句防止轮询与人工刷新并发写丢值。
### 4.4 每天的快照用量(照搬 3.4,用于日流量展示)
```
U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天的 month_used_mb)
< 0 按 0;那天是本月第一条快照时减数为 0
```
- 中间有几天没采到,那几天的用量自然并入采到的那天,不会丢。
- 每天的日流量就是 `U(卡, 那天)`,直接替代 Redis 日流量缓冲、02:00 落盘和 `tb_card_daily_usage`。
- **算增量不用这个公式**,用 4.5 的按月对账。两者按月合计相同,但按天对账遇到乱序观测会重复扣(见 4.5 说明)。
### 4.5 算增量并发出(替代 `r − 基线`)
在观测事务(事务 1,现有 `ApplyTraffic` 所在事务)里,对**已切换且已有切换水位的卡**(没有水位的走 4.6):
1. 卡行已在事务开头加锁(`traffic.go:40`),同卡观测天然串行,不再单独锁快照行;
2. 按 4.3 写今天的快照;
3. 按月对账:`M = 本月最大 month_used_mb`(写入后),`S = 本月各天 settled_mb 合计`,增量 `Δ = M − S`;
4. `Δ > 0` 且**该卡有可扣的生效套餐**(与 `resolveActivePackages` 同口径:卡自身有**生效中(status=1)**的套餐,或卡当前绑定的设备有生效中的套餐;**已用完(2)、待生效(0)不算**;只读查询,不加锁):写一条 V1 事件 `CardTrafficIncremented`,载荷与现在完全相同(只是增量值换了来源),同时今天的 `settled_mb += Δ`;
5. `Δ > 0` 但**没有生效套餐**:不发事件,只把今天的 `settled_mb += Δ`。这部分用量不属于任何套餐,丢弃(日流量照常可见);
6. `Δ ≤ 0`:不发事件。
7. **通道阈值判定**:本次读数有效(`r > 0`)且本次读数未被防旧值按 0 处理时,用 `r` 调用 `EvaluateInTx`,不再看老路径是否接受读数(见下方说明)。
说明:
- **为什么按月对账**:若按天对账(`今天用量 − 今天已发出`),一次迟到的观测改写了昨天的快照,昨天会补发一笔,而今天的增量已按旧的"此前最大值"发过,两笔重叠的部分会扣两次(跨零点时轮询与人工刷新并发即可出现)。按月对账只看"本月最大值"和"本月已结算合计",与观测先后无关。
- **跨月**:新月份 `S = 0`,`M` 只取新月份的快照,第一条快照的增量就是本月累计。重置日为 1 号的卡,"上月最后一次采到 → 清零"之间的用量拿不到(见第 9 节"旧周期尾部")。
- **增量与已结算标记在同一事务里**,和 Outbox 事件一起提交或一起回滚,不会重复发、也不会漏发。
- **消费者完全不改**:现有消费者照旧调用 `DeductDataUsage` 扣减(先加油包、后主套餐、超额记最后一个),写流量详单,调用 `EvaluateAndAct` 判断停复机,回执表照旧保证幂等。
- **没有生效套餐时不发事件**:现有消费者遇到"没有可用套餐"会报错重试(第 2 节第 6 条),所以不能把这部分增量发出去。只记为已结算:**激活之前被采到的用量不会扣到新套餐上**。精度到观测粒度:激活后第一次观测里,"上次观测到激活"这一段仍会算给新套餐(老卡管按天取值,激活当天的全部用量都算给新套餐)。
- 是否有生效套餐只在事务 1 里只读判断一次,不改套餐侧代码;判断出错时按"有套餐"处理并发事件(保持现有行为,不丢量)。事务 1 看到有生效套餐、但消费者扣减前套餐刚好被前一条事件扣完的情况,仍会报错重试,见第 9 节。
- 卡表三列(`last_gateway_reading_mb` 等):老 `ApplyTraffic` 照常计算、照常写,只是它算出的增量对已切换的卡不再使用。`last_gateway_reading_mb` 是观测的 CAS 条件,新代码只读不写。
- **卡表本月用量**:卡表 `current_month_usage_mb` 被资产详情(`service/asset/service.go:482`)、卡列表(`service/iot_card/service.go:501`)、设备导出(`exporter/device_scene.go:423`)直接读取。冻结卡按老逻辑这一列一直是 0,所以**已切换的卡在事务 1 里用本月最大快照 `M` 覆盖这一列**(写在同一条 `Updates` 里,老逻辑算出的值对已切换卡不用),三处读取都不用改。这一列只用于展示;补数据判定冻结卡时,已切换卡看水位、不看这一列(5.5),不受影响。跨月时老逻辑照常把上月值转入 `last_month_total_mb`,已切换卡转入的就是上月快照的最大值。
- **通道阈值**:老路径只在读数被接受时判定(`traffic.go:84`),冻结卡读数一直被拒,所以通道达量停机永远不触发;不改的话,全量切换后依然如此,只能等卡在重置窗口内被采到、基线自行重置。`EvaluateInTx` 只做"达量即写停机锁",没有"读数低就复机",所以放开判定不会误复机;排除被防旧值处理的读数,是为了防止重置日读到上周期旧总量、在新周期误停机;同一天之后读到的真实读数照常判定。此改动只在已切换分支内。
- **已切换的卡不再发老增量的 V1**:老 `ApplyTraffic` 算出的 `decision.IncrementMB` 对已切换的卡丢弃(切换那一次除外,见 4.6),现在第 73-76 行的发事件逻辑只在未切换分支执行;每次观测只会发一条 V1,Outbox 量不增加。
- 只有已用完或待生效套餐的卡,增量同样丢弃。这和现状一致:现状下这类增量会因"没有可用套餐"扣减失败并积压(第 2 节第 6 条),并不会扣到已用完的套餐上。
**未切换的卡**:事务 1 一切照旧(老增量、老 V1、老通道阈值条件);快照在事务 1 提交后按 4.3 第 5 步原子写入,失败只告警(见 4.7),用于切换前积累数据和比对;`settled_mb` 不动。
### 4.6 切换衔接:切换水位
卡已在新路径名单中、但 `tb_card_flow_switch` 里还没有它的行时,这次观测是它的**第一次切换观测**,在事务 1 内:
1. 按 4.3 写今天的快照,得到 `M`(本月最大)与 `S`(本月已结算合计,正常为 0);
2. 本次增量**照用老路径的结果** `decision.IncrementMB`:`> 0` 且有生效套餐时发 V1;无生效套餐时不发(与 4.5 一致,老路径这时会发);
3. 今天的 `settled_mb += max(M − S, 0)`,即把本月到此刻为止全部记为已结算;
4. 写入切换水位行(4.2),记下 `M`、`r`、卡表基线、卡表本月计入、老路径是否接受读数、老增量;
5. 本次的通道阈值仍按老路径条件判定,从下一次观测起按 4.5 第 7 步;卡表本月用量从本次起按 4.5 用 `M` 覆盖。
效果:
- 切换这一次的增量与老路径相同,之后只发切换之后的增长,**不依赖切换前本月是否已有快照**,阶段 1 不需要覆盖整月。
- 冻结卡:老路径本次增量为 0,切换前少扣的部分不在这里处理,由 5.5 按水位补。
- 退回老路径时删除水位行(5.2);再切回会重新走一次本节,退回期间老路径扣过的部分不会重扣。
- 写水位与快照、Outbox 在同一事务,失败随事务 1 回滚。
### 4.7 失败处理
| 失败 | 处理 |
|---|---|
| Gateway 查询失败 / 无效读数 / 读数为 0 | 不写快照、不发增量 |
| 运营商重置日查不到或非法 | 按运营商类型兜底:联通(`CUCC`)27 号 0 点,其余 1 号 0 点,北京时间;记日志。老路径照旧 |
| 已切换卡:快照、已结算标记或切换水位写入失败 | 随事务 1 回滚,观测重入队(与现在观测失败的处理一致) |
| 未切换卡:快照写入失败 | 只告警,不影响观测;总开关可整体停写 |
| 某天没采到 | 用量并入下一个有快照的日子 |
### 4.8 对非灰度卡的影响核对
**硬约束:非灰度的卡和设备,扣费、停复机、观测结果、页面展示都与现在一致。** 新代码碰到非灰度卡的每一处:
| # | 接触点 | 设计 | 结论 |
|---|---|---|---|
| 1 | 写快照(阶段 1 起全部卡都写) | 非灰度卡在观测事务**提交之后**用一条 `INSERT ... ON CONFLICT DO UPDATE` 原子写入(4.3 第 5 步),失败、超时只告警,不让观测失败、不重入队;总开关 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 关掉即停写,不用发版 | 不影响扣费与观测;数据库多一次写入(现约 2,400 次/小时),上线后观察轮询耗时,异常即关开关 |
| 2 | 判断走新路径还是老路径 | 观测事务内用启动时载入内存的卡集合判断,不查库;判断出错一律走老路径 | 不影响 |
| 3 | 增量计算 | 非灰度卡仍用"读数 − 基线",仍发 V1 事件,代码不改 | 不影响 |
| 4 | 无套餐时不发事件 | 只对灰度卡生效 | 不影响 |
| 5 | Gateway 与三个观测入口 | 不改(不新增"读数是否有效"标记,见 4.3 第 1 步) | 不影响 |
| 6 | 日流量查询 | 非灰度卡仍读老来源 | 不影响 |
| 7 | 数据库迁移 | 只新建表,不改现有表 | 不影响 |
| 8 | 设备部分卡切换 | 不允许:设备整台切(5.2) | 不影响 |
| 9 | 通道阈值判定条件 | 只在已切换分支改为"读数有效且本次未被防旧值处理";未切换分支仍是"老路径接受读数" | 不影响 |
| 10 | 切换水位表 | 只有已切换卡读写 | 不影响 |
| 11 | 卡表 `current_month_usage_mb` | 只对已切换卡用快照覆盖;未切换卡仍由老逻辑写 | 不影响 |
**唯一无法靠设计消除的风险**:`ApplyTraffic` 是所有卡共用的代码,改动出错可能波及全部卡。约束:新逻辑只放在"判定为灰度卡"的分支里,老分支一行不改;评审重点核对这一处;测试环境回归"非灰度卡观测一次"的结果与改动前完全一致。
## 5. 上线
### 5.0 上线前提核实(2026-10-20,生产只读抽样)
新算法依赖"运营商读数在 `data_reset_day` 清零"。对生产卡表做 10% 块抽样,取本月有读数被接受的卡,比较当前读数与本月计入量:
| 运营商分组 | 抽样卡数 | 读数 ≈ 本月计入 | 读数略大 | 读数远大于本月计入 | 结论 |
|---|---|---|---|---|---|
| 重置日 1 号(移动、电信、广电共 11 个运营商账号) | 765 | 712(93%) | 28 | 25 | 读数在 1 号清零,与配置一致 |
| 重置日 27 号(联通共 10 个运营商账号) | 406 | 134 | 243 | 29 | 读数含 9-27~9-30,略大于自然月计入,符合 27 号清零 |
- 两组都符合各自配置的重置日。
- "读数远大于本月计入"的少数卡(约 4%~7%)需要在灰度期抽查,可能是单卡读数口径异常或基线问题。
- 抽样未覆盖的运营商账号(本月抽样中没有被接受读数的卡):1、10、11、12、16、17、20~23、25、27、30、34、37,灰度扩批前补查。
- `tb_card_flow_day` 在生产库与测试库均不存在(两边迁移版本均为 240),代码中无引用,新建无冲突。
### 5.1 阶段
| 阶段 | 内容 | 对业务影响 |
|---|---|---|
| 1 写快照 | 建 `tb_card_flow_day`,全部卡在观测事务提交后写快照 | 无,只写不用 |
| 2 灰度切换 | 名单内的卡第一次观测按 4.6 写切换水位,之后按 4.5 算增量;其余照旧 | 只影响名单内的卡 |
| 3 全量 | 全部卡切换;之后下线 Redis 日流量缓冲与 02:00 落盘 | — |
阶段 1 至少跑满一个完整自然日再进入阶段 2。切换衔接靠切换水位(4.6),**不要求阶段 1 覆盖整月**。但防旧值和联通换算要用上个周期的快照:重置日为 1 号的卡,最好上月已有快照;联通卡最好上月 1~26 号已有快照,否则阶段 1 第一个月的联通快照会把上月 27 号以后的用量算进本月(只影响日流量展示的分摊,不影响按月对账的增量)。
### 5.2 切换方式
- 环境变量 `JUNHONG_TRAFFIC_CALC_MODE`:`off`(默认)/ `gray` / `full`;`JUNHONG_TRAFFIC_CALC_CARDS`:灰度名单,写扣减载体:`iot_card:103154`(单卡套餐的卡)或 `device:15276`(设备,展开为当前绑定的全部卡);`JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED`:快照写入总开关,默认开。改完重启 API 和全部 Worker(见下方配置位置)。
- 判定在事务 1 里做,用启动时展开的内存集合,不查库;判定出错一律按老路径。
- **切换、扩批、退回都要先停掉 API 和全部 Worker,再一起启动,不逐个重启**。原因:逐个重启时会有新旧进程并存的窗口,同一张卡如果先被新进程写了水位、再被老进程观测(多为人工刷新),老进程发的那笔增量不计入已结算合计,下次新路径观测会再扣一次。窗口只有几分钟,概率很低,但停后再启即可完全避免。
- **配置位置**:新增 `pkg/config/config.go` 的 `TrafficCalc` 配置段与 `pkg/config/defaults/config.yaml` 的 `traffic_calc:` 默认值(`mode: off`、名单为空、快照开关开),沿用现有"嵌入默认配置 + `JUNHONG_` 环境变量覆盖"机制。生产上由维护者写入各运行目录的 `.env.prod`:**API(`/opt/junhong_cmp/api/.env.prod`,人工刷新走 API 进程)、调度 Worker(`/opt/junhong_cmp/worker/.env.prod`)和三个执行 Worker(`worker-1/2/3/.env.prod`)五处必须一致**,否则同一张卡在不同进程里会走不同路径。改完重启对应服务。
- **灰度单位是扣减载体,设备必须整台切**:名单里的设备,当前绑定的全部卡一起切换;名单里写 `iot_card:X` 但这张卡实际用的是设备套餐(与 `resolveActivePackages` 同口径),启动日志报错并跳过该项,不阻止 worker 启动。否则同一个设备套餐会同时收到新旧两种算法的增量,设备上没切换的卡也会被连带影响。
- **灰度期间灰度设备不换绑、不新绑卡**:名单在启动时展开成卡集合,之后新绑到灰度设备上的卡要重启后才会切换,这期间设备处于"部分切换"状态。确需换绑的,换绑后立即重启 API 和全部 Worker 重新展开;或先把该设备移出名单。
- **退回**:从名单删除或改回 `off`,**删除这些卡在 `tb_card_flow_switch` 里的水位行**,再重启。老 `ApplyTraffic` 一直在维护卡表基线,退回后老路径接着算(冻结卡会回到冻结状态)。水位行必须删:否则以后再切回时不会重新走 4.6,退回期间老路径扣过的增长会被新路径再扣一次。退回与再切回可以在同一天。
- 灰度设备换绑后重启:新绑上的卡第一次走新路径时按 4.6 写水位,不会重复扣;换绑前后这台设备仍短暂处于"部分切换",所以上一条"不换绑"的约束保留。
### 5.3 冻结卡与历史误差
- 切换后冻结卡(如 103154)立即恢复正常计量:切换那一次老路径增量为 0(4.6),之后增量只看快照,不看卡表基线;通道阈值判定同时恢复(4.5 第 7 步)。
- **切换前少扣的部分放到后期补**,用独立脚本执行,见 5.5。
- **上个周期及更早少扣的补不回来**:运营商读数已经清零,没有任何记录;期间套餐已经重置的,那个计费周期也已结束。
- **第一批灰度:103154 所在设备 15276**(联通 103154、移动 99426、电信 106882,三张卡本月计入都为 0)。核对无误后再放其他冻结卡、电信读数常为 0 的卡、二次销售的卡、联通卡、带加油包的载体。
### 5.4 扩批条件
1. 灰度卡逐日的增量合计 = 按快照手工复算的结果;
2. 无误停、无误复;
3. 至少跨过一次运营商清零(每个运营商各自的重置日);
4. 冻结卡恢复计量,二次销售卡没有带入上一个用户的用量。
### 5.5 补数据(后期,独立脚本)
**定位**:卡流量计算上线后再做,不阻塞主方案。只补冻结卡在**执行时所在运营商周期**内少扣的部分;更早周期的补不回来(运营商读数已清零)。
**生产现状(2026-10-20 只读核实)**:
| 项 | 结果 |
|---|---|
| `tb_package_usage_daily_record` 是否漏记扣减 | **没有漏记**:生效中和已用完的套餐中,没有一个"已用量 > 最新日记录累计值"(10,718 个一致;1,972 个已用量为 0、是重置后尚未扣减;10,399 个从未扣减) |
| 疑似冻结卡(最近两天在采集、本月计入为 0、上月 ≥ 100 MB) | **3,425 张**:设备卡 1,705(联通 1,073、移动 450、电信 182),单卡 1,720(广电 931、电信 339、联通 236、移动 214)。含本月确实没用的卡,是上限 |
| 103154 所在设备 15276 | 联通 103154、移动 99426、电信 106882 三张卡**本月计入都是 0**,不只联通卡冻结 |
| 设备下混有不同重置日的卡(联通 27 号 + 其他 1 号) | 7,438 台设备、19,466 张卡,是设备套餐的大头 |
| 套餐计费起点落在本运营商周期内(多为 `by_day` 每 30 天重置) | 10,919 个载体 |
后两项说明:按"载体读数之和 − 载体本周期扣减"的算法,大部分载体会因"重置日不同"或"起点在周期内"被排除,所以**改为按卡补冻结卡**。
**算法(按卡)**:冻结卡在整个本运营商周期里一条读数都没被接受,本周期计入为 0,所以
```
这张卡本周期少扣 = 运营商当前读数 r
```
- 每张卡按自己运营商的重置日确定周期起点,设备下混着不同运营商的卡也能用;不需要按套餐拆分日记录。
- **未切换的卡**:`r` 在执行时现场查询 Gateway,限速执行;查询失败、无效、读数为 0 的跳过,进清单。
- **已切换的卡**:`r` 取切换水位的 `vendor_used_mb`(切换时的运营商读数),不现场查。切换之后的用量已由新路径计算,用水位截断,不会重复。切换时刻不在执行时所在运营商周期内的,进清单。
**判定为冻结卡(全部满足才自动补)**:
| 条件 | 原因 |
|---|---|
| 卡(或其所在设备)有**生效中(status=1)**的套餐(与 4.5 同口径) | 只有已用完或待生效套餐时,`DeductDataUsage` 找不到可扣套餐会报错,补扣事件会积压(第 2 节第 6 条);这类卡进清单 |
| 未切换卡:最近两天在被采集,本月计入(`current_month_usage_mb`)为 0。已切换卡:水位的 `old_reading_accepted = false` 且 `old_month_usage_mb = 0` | 本周期没有任何读数被老路径接受。已切换卡切换后卡表仍由老逻辑维护、本月计入可能一直是 0,所以只能看水位,不能看卡表 |
| `r` < 基线(未切换卡用现场读数与卡表 `last_gateway_reading_mb`;已切换卡用水位的 `vendor_used_mb` 与 `old_baseline_mb`) | 基线属于上个周期或被异常值抬高,读数一直被拒 |
| 卡所在扣减载体的套餐计费起点(激活或最近一次重置)**早于本运营商周期起点** | 否则 `r` 里有一部分属于已经结束的上个计费周期,不该补到新周期 |
| 本周期内没有回收换代、换绑 | 读数和套餐对不上;也排除了二次销售 |
不满足的进人工清单。其中"计费起点在本周期内"的冻结卡,清单里附按时间比例估算的参考值(`r × 起点至今天数 ÷ 本周期已过天数`),由人工决定,不自动扣。
**执行(套餐侧不改)**:
1. **预演(只读)**:输出每张卡的卡号、运营商、扣减载体、`r`、基线、补扣量、补扣后是否超额;汇总补扣总量、预计停机数、清单与原因。
2. 确认后正式执行:每张卡写一条一次性的 V1 事件,事件 ID 由卡、运营商周期、批次组成,重复执行同一批次不重复写;由现有消费者调用 `DeductDataUsage` 扣减,超额照常停机,补扣量照常写入 `daily_record`。
3. 已切换到新方案的卡,切换之后的用量由新方案计算,补扣只补切换之前那段:补扣量取水位的 `vendor_used_mb`,避免与新方案重复。
4. 补扣写审计,注明"冻结卡补扣"与补扣量。
5. 现有消费者会把每条 V1 事件的增量累加进当天的 Redis 日流量缓冲,所以**未切换卡的日流量会在补扣当天出现一次突增**(已切换卡的日流量读快照,不受影响)。执行前告知运营,或在预演清单中标出。
## 6. 改动清单
| 位置 | 改动 |
|---|---|
| `migrations/` | 新建 `tb_card_flow_day`、`tb_card_flow_switch` |
| 新增 `internal/domain/cardflow/` | 纯函数:自然月换算、重置日防旧值、每天用量、按月对账增量;表驱动单测 |
| 新增 `internal/store/postgres/card_flow_day_store.go` | 快照原子写入(`ON CONFLICT DO UPDATE`)、本月最大值与已结算合计查询 |
| 新增 `internal/store/postgres/card_flow_switch_store.go` | 切换水位读写 |
| `internal/application/cardobservation/traffic.go` | 已切换的卡:增量改为 4.5 的按月对账,第一次切换观测按 4.6 写水位,通道阈值按 4.5 第 7 步判定,卡表 `current_month_usage_mb` 用本月最大快照覆盖,V1 事件载荷不变,按卡的 `carrier_id` 严格查重置日;未切换的卡:老逻辑不变,提交后写快照。快照写入都在 `ApplyTrafficObservation` 里完成,三个观测入口与 Gateway 不改 |
| 日流量查询 | 已切换的卡读快照 |
| `pkg/config` | 三个环境变量(模式、灰度名单、快照写入总开关) |
| 后期:补数据脚本(`cmd/`) | 预演(只读)+ 正式执行(写 V1 事件),见 5.5 |
| **套餐表、`internal/service/package/`、V1 消费者、激活/重置/改已用量/换货/退款入口** | **不改** |
## 7. 测试用例
**领域层(表驱动)**
| 场景 | 期望 |
|---|---|
| 移动同月读数上涨 | 今天用量 = 今天最大快照 − 本月此前最大快照 |
| 重置日 `R = 27`:26 号 23:59 读数 5000,27 号 00:05 清零后读数 100(北京时间) | 27 号 `month_used_mb` = 100 + 26 号前最大值;自然月累计连续 |
| 重置日 `R = 15`(任意非 1 号) | 换算规则同上,以 15 号为界,结果连续 |
| 重置日 `R = 1` | 不换算,`month_used_mb` = 读数 |
| 运营商的 `data_reset_day` 被修改 | 从修改后的下一次写入起按新重置日换算 |
| `R > 1` 时 1 号~R−1 号换算 | 减去上月 R 号~月底的用量(= 上月最大 − 上月 1~R−1 号最大,含 R−1 号) |
| 重置日当天读到上周期旧值(> 90%) | 当天按 0 记,`stale_guarded = true`;之后读到真实低值正常累计 |
| 重置日次日起仍读到旧值 | 照常累加(与老卡管一致) |
| 重置日当天真实用量超过上周期 90% | 当天按 0 记;次日如实写入,取最大值把这部分带回来,只晚一天,不丢 |
| 周期中途读到偏小的旧缓存 | 当天快照取最大值,用量不变小,不发负增量 |
| 读数 0 / 无效 | 不写快照,不发增量 |
| 运营商重置日查询失败:联通卡 | 按 27 号换算与防旧值,记日志 |
| 运营商重置日查询失败:非联通卡 | 按 1 号处理,记日志 |
| `R = 1` 重置日当天读数 > 上月最大快照 × 0.9 | 当天按 0 记 |
| `R = 27` 重置日当天读数 > 本月 1~26 号最大快照 × 0.9 | 当天按 0 记 |
| 中间 3 天没采到 | 3 天的用量并入采到的那天 |
| 跨自然月 | 本月第一条快照减数为 0,从本月累计开始算;按月对账时新月份已结算合计为 0 |
| 按月对账:本月最大 500、已结算合计 450 | 增量 50 |
| 乱序:今天已发出后,一次迟到观测抬高了昨天的快照 | 按月对账的增量 = 本月最大 − 已结算合计,不会把重叠部分扣两次 |
**应用层**
| 场景 | 期望 |
|---|---|
| **卡 103154 类:卡表基线 72535,运营商读数 48808** | 切换后增量按快照正常发出,套餐恢复增长 |
| **二次销售:上一个用户本月用到 3000;新用户 15 号 14:00 激活;激活前当天又读到 3020,激活后读到 3050** | 3020 及之前的增量在无套餐时只记入 `settled_mb`、不发事件;新套餐只扣 30 |
| 卡只有已用完或待生效的套餐 | 不发事件,增量丢弃 |
| 灰度卡观测一次 | 只发一条 V1(新算法增量),不再发老增量的 V1 |
| 无套餐期间连续观测,之后激活 | Outbox 里没有该卡积压的 V1 事件;激活后只扣激活之后的增长 |
| 同一次观测重复处理 / 同卡并发观测 | 卡行锁串行,按月对账保证每段增长只结算一次 |
| 事务 1 回滚 | 快照、`settled_mb`、切换水位、Outbox 一起回滚,不重不漏 |
| 第一次切换观测:正常卡,本月已有快照 | 只发老路径这次的增量;写水位;本月已结算合计 = 本月最大 |
| 第一次切换观测:正常卡,本月还没有快照 | 同上,不会把整月累计当增量发出 |
| 第一次切换观测:冻结卡(老路径拒绝读数) | 不发增量;水位记 `old_reading_accepted = false`、`vendor_used_mb = r`;下一次观测起按快照正常发 |
| 退回老路径(删水位)后当天再切回 | 重新按 4.6 写水位,退回期间老路径扣过的部分不重扣 |
| 冻结卡切换后读数超过通道阈值 | 写通道停机锁(老路径下不会触发) |
| 冻结卡切换后 | 资产详情、卡列表、设备导出的本月用量 = 本月最大快照,不再是 0 |
| 重置日当天先触发防旧值、之后读到真实低值 | 真实读数照常做通道阈值判定 |
| 重置日当天读到上周期旧总量并触发防旧值 | 不做通道阈值判定,不误停机 |
| 未切换的卡并发写快照(轮询与人工刷新同时) | 原子写入,`month_used_mb` 取较大值,不丢 |
| 名单写 `device:15276` | 设备当前绑定的 3 张卡全部切换 |
| 联通卡观测时运营商表查询失败(入口传进来的 `ResetDay` 已被兜底成 1) | 新逻辑不用入口的值,自行严格查询,失败按 27 号处理 |
| 名单写 `iot_card:X`,但该卡实际用设备套餐 | 启动日志报错并跳过该项,worker 正常启动,该卡仍走老路径 |
| 非灰度卡观测一次 | 卡表、V1 事件、扣减、停复机结果与改动前完全一致;快照在提交后单独写 |
| 关闭快照写入总开关 | 立即停写快照;非灰度卡不受任何影响 |
| 主套餐 + 加油包 | 与现有 `DeductDataUsage` 行为完全一致(不改) |
| 退回老路径后的下一次观测 | 老路径照常运行 |
| 查询失败 | 不写快照、不发增量、不改套餐状态 |
**补数据脚本(后期)**
| 场景 | 期望 |
|---|---|
| 单卡冻结:读数 48808 < 基线 72535,本月计入 0,套餐起点早于周期起点 | 补扣 48808;超额则停机 |
| 设备 3 卡(联通、移动、电信)都冻结 | 每张卡按自己的周期起点各补其读数,合计扣到设备套餐 |
| 套餐在本周期中间激活(含二次销售) | 不补,进清单 |
| 本周期内回收换代 / 换绑 | 不补,进清单 |
| 读数查询失败 / 为 0 | 不补,进清单 |
| 冻结卡只有已用完的套餐 | 不补,进清单 |
| 少扣为负(多扣) | 不退,进清单 |
| 同一批次重复执行 | 事件 ID 相同,不重复写 |
| 已切换的冻结卡 | 补扣量取水位的 `vendor_used_mb`,不现场查;切换之后的用量不重复补 |
| 已切换的正常卡(水位 `old_reading_accepted = true`) | 不补 |
| 套餐计费起点在本周期内的冻结卡 | 不自动补,进清单并附按时间比例的估算值 |
**测试环境验证**:103154 同类卡、三卡共用设备、二次销售卡、重置日为 27 号与 1 号的运营商各跨一次重置日、另构造一个非常规重置日(如 15 号)各一组,逐日核对快照、增量与套餐已用量。
## 8. 工作量(粗估)
| # | 任务 | 人天 |
|---|---|:-:|
| 1 | 迁移与模型 | 0.25 |
| 2 | 领域层:自然月换算、防旧值、每天用量、按月对账 + 单测 | 1.5 |
| 3 | 快照存储(原子写入)+ 切换水位存储 | 0.75 |
| 4 | `ApplyTraffic` 已切换卡:按月对账、切换水位、通道阈值条件 | 1.25 |
| 5 | 切换机制(模式、名单、启动展开) | 0.5 |
| 6 | 日流量读侧 | 0.5 |
| 7 | 集成测试与测试环境验证 | 1.5 |
| | 合计 | 约 6.25 |
与第 1 版相比:去掉"读数是否有效"标记和三个入口的改动,省下的工作量用于切换水位与通道阈值条件,合计不变。
后期补数据脚本(5.5:预演、条件判断、写 V1 事件、清单)另计约 1.5 人天。
不含灰度观察期:至少跨过一次运营商清零,约 30 天内。
## 9. 已知限制
| 限制 | 说明 |
|---|---|
| 切换前的误差 | 后期按 5.5 补,只能补执行时所在运营商周期内的部分;更早的补不回来;不满足条件的进人工清单 |
| 扣减前套餐刚好用完 | 事务 1 看到有生效套餐并发出事件,但消费者扣减前,同卡的前一条事件已把唯一的套餐扣到"已用完",这条事件扣减时报"没有可用套餐"并重试,之后可能扣到新激活的套餐上(第 2 节第 6 条的同一条路径)。需要同卡在很短时间内有两条事件,概率低。消费者不改(D13),灰度期监控"处理中"回执的新增数,确有发生再评估是否让消费者对已切换卡直接结束 |
| 二次销售精度 | 只精确到观测粒度:激活后第一次观测里"上次观测到激活"的用量会算给新套餐。要更精确需要激活时触发一次立即观测(改激活入口,本期不做) |
| 切换那一次沿用老路径增量 | 第一次切换观测发的是老路径算出的增量(4.6),如果这一次正好撞上老路径的问题(例如重置窗口读到 0 把基线置 0 后,又读到真实值整笔计入),会照样扣一次。每张卡最多一次 |
| 换绑后第一次观测 | 设备换上一张本月没有快照的卡,第一次观测(走 4.6)发出的是老路径的增量,与现状一致:老路径对从未采过的卡基线为 0,会把整张卡的周期读数算给设备套餐 |
| 上一代套餐仍为"生效中" | `DeductDataUsage` 只按状态选套餐、不看世代,回收换代后上一代套餐如果仍为生效中,新用户的增量可能扣到它上面。这是套餐侧问题,按 `docs/资产回收换代技术方案.md` 单独修复,本方案不改套餐侧 |
| 旧周期尾部 | 旧周期最后一次采集到清零之间的用量拿不到,老卡管同样如此 |
| 防旧值 | 完全照搬老卡管:阈值 0.9、只在重置日当天判断。运营商延迟清零超过一天时,次日的旧值会被累加(老卡管同样如此,多年未暴露问题) |
| 积压的无套餐回执 | 18,979 条卡在"处理中"的回执(含 4,141 条对应卡当前有生效套餐)由业务后续手动调整,不在本方案内;处理前不得重放 |
| 修改运营商重置日 | 月中修改 `data_reset_day` 会让当月换算前后不连续,可能产生一次跳变多扣。修改应安排在运营商实际重置日前后,并在修改后核对受影响卡的当日增量 |
| 采集不足 | 轮询吞吐问题(约 2.4k 次/小时,平均每卡约 1.5 天一次)不在本方案范围;采不到只会晚扣,不会丢。但每张卡在自己的重置日(1 号或联通 27 号),最后一次采集到清零之间的用量会丢("旧周期尾部"),采得越稀丢得越多;重置日当天没采到的卡也不会触发防旧值 |
| 扣错收不回 | 套餐侧仍是增量扣减,一旦扣错只能人工改已用量。快照可以完整复算每天应扣多少,作为核对依据 |
| 电信 | 老卡管的电信在采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到自然月累计后同样写日快照、按快照计费;新卡管 Gateway 目前只返回当前周期累计,本期统一按快照处理,读数为 0 的电信卡仍扣不到。**后续安排 Gateway 支持按日期区间查询**:支持后,电信卡改为按区间查询的结果写快照,算增量的逻辑不变;同时可以用来补旧周期尾部、补更早周期的少扣数据 |
## 10. 停复机规则与实名管控
本章与流量计算相互独立,可以分开开发、分开上线;放在同一份方案里一起评审。
### 10.1 停复机原则(已定)
| | 条件 | 说明 |
|---|---|---|
| **停机** | 卡开机,且满足任一:① 没有套餐(套餐到期);② 流量用完;③ 卡所属通道开启了实名管控(`realname_control_enabled = 1`)且卡未实名 | 只有这三条。谁开的机不重要 |
| **复机** | 卡停机,且同时满足:① 有生效套餐;② 流量没用完;③ 卡已实名,**或**卡所属通道关闭了实名管控(`realname_control_enabled = 0`) | **不看停机原因**:外部停的、卡管自动停的、管理员手动停的、设备手动停的,满足条件都复机 |
| 不动作 | 卡或所在设备暂停轮询;卡持有本周期通道阈值锁(属于"流量用完",锁期内不复机) | 见 10.4 |
- **外部停机与卡管无关**:外部环境导致的停机,卡管判断该开机就复机,**除非这张卡(或所在设备)已被加入暂停轮询**。卡管不判断是谁停的,只按上面的条件决定开或停;外部反复停卡,卡管就按规则反复复机,不设次数限制。
- **实名默认不管控**:通道上开启"实名管控"后,未实名的卡会被停机,停机后实名了才复机;没开启的通道,实名不参与停复机。"实名管控"是通道上**新增的独立开关**(`realname_control_enabled`),只管停复机,**不复用通道现有的实名相关字段**(如实名链接类型 `realname_link_type`,它只决定实名链接怎么生成)。开启后,通道下所有卡都按"是否已实名"判断,**不再区分行业卡,也不再看卡或设备上的实名策略**(实名策略只用于购买流程)。
- `stop_reason` 仍照常记录(流量耗尽、无套餐、未实名、手动、运营商侧停机、通道阈值),**只用于展示和审计,不参与复机判断**。
### 10.2 现状与差异(代码核实)
| # | 现在的行为 | 与原则的差异 | 代码 |
|---|---|---|---|
| 1 | 自动复机只处理停机原因为流量耗尽、无套餐、未实名的卡 | 外部停机(记为 `carrier_stopped`)、手动停机(`manual`)、设备保护期停机(`protect_period`)满足条件也不复机,只能人工处理 | `stop_resume_service.go` 的 `isPollingStopReason`、`EvaluateAndAct` 停机分支、`resumeSingleCard`;`iot_card_store.go` 的 `ListByDeviceIDAndPollingStopReasons` |
| 2 | 非行业卡、未实名、卡或设备实名策略不是 `none` 时一律停机且不能复机 | 实名默认管控,且与行业卡、资产实名策略耦合;想放开只能逐张改实名策略 | `isRealnameOK`,被 `checkStopReasons`、`shouldResume`、`resumeDeviceCards`、`ManualStartCard` 共用;保护期一致性检查跳过未实名卡(`polling_protect_handler.go:77`) |
停机这一侧现在已经只看无套餐、流量耗尽、未实名,符合原则;停机和复机里的实名都改成按通道判断。
### 10.3 已定决策(2026-10-20)
| # | 事项 | 决定 |
|---|---|---|
| D14 | 停机条件 | 无套餐、流量用完、通道开启实名管控且卡未实名,三者任一 |
| D15 | 复机条件 | 有生效套餐、流量没用完、卡已实名或通道关闭了实名管控,三条同时满足即复机;**不看停机原因**(含外部停机、手动停机、设备手动停机) |
| D16 | 实名管控 | 通道(`tb_carrier`)新增独立开关 `realname_control_enabled`,不复用现有实名字段,**默认 0 = 不管控**;设为 1 时通道下所有卡按是否已实名判断(未实名停机、实名后才复机),不区分行业卡、不看资产实名策略。**本期只加通道开关,不迁移、不修改任何卡或设备数据**;卡级、设备级的实名管控以后需要时再加(同样用独立字段,默认继承通道) |
| D17 | 暂停轮询的卡 | 卡或所在设备暂停轮询时,卡管不自动停复机,人工停复机照常。风险停机、机卡分离停机、已销户等运营商异常状态**由人工判断并手动暂停轮询**,卡管不自动处理;现有"独立卡风险停机/已销户自动暂停轮询"的逻辑不改 |
### 10.4 设计
**统一判断**:新增一个"目标状态"判断,`EvaluateAndAct` 开机分支、停机分支、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 都用它,不再各自判断。
- 开头先看暂停轮询:卡 `enable_polling = false`,或卡所在设备 `enable_polling = false`(与轮询范围 `ShouldEnqueue` 同口径),本次不动作。
- 卡开机:命中 10.1 的停机条件即停机,停机原因按命中的条件记录。设备维度扩散规则不变(无套餐、流量用完扩散到设备;未实名只停该卡)。
- 卡停机:满足 10.1 的复机条件即复机,**删除"只处理轮询原因停机"的限制**(`isPollingStopReason`)。持有通道阈值锁时不复机(现有 `channelThresholdBlocked`,不变)。
- 设备复机:`ListByDeviceIDAndPollingStopReasons` 改为取设备下全部停机卡,逐张按复机条件判断。
- 套餐判断沿用现有口径(`HasValidByCarrier`:生效中或待生效都算有套餐;流量用完沿用 `isTrafficExhausted`),本章不改套餐口径。
**实名管控(D16)**:
- 迁移:`tb_carrier` 增加 `realname_control_enabled smallint not null default 0`。
- `isRealnameOK`(停机判断、复机判断、设备复机、人工复机共用)改为:卡所属通道(`card.carrier_id`)`realname_control_enabled = 0` → 满足;= 1 → 卡 `real_name_status` 为已实名才满足。**删除行业卡放行和卡/设备实名策略判断**。查询通道失败时本次不动作并记日志(不按任何默认值停机或复机)。
- 保护期一致性检查:只在通道开启实名管控且卡未实名时跳过。
- 人工复机(`ManualStartCard`)同样经过 `isRealnameOK`,自动一致。
- 通道管理接口(`service/carrier` 创建、修改、详情)增加该字段,修改写审计。从 0 改为 1 时,管理端二次确认并提示该通道未实名的开机卡数(下一次评估会被停机);从 1 改为 0 后,该通道因未实名停机的卡会在下一次评估时复机。
- 卡和设备上的实名策略(`none` / `before_order` / `after_order`)、首次实名激活套餐,只用于购买与激活流程,不受影响。
**暂停轮询的卡(D17)**:
- 运营商异常状态(风险停机、机卡分离停机、已销户)不做自动处理,由人工判断后在管理端暂停轮询(现有接口)。现有独立卡风险停机/已销户自动暂停轮询的逻辑(`ShouldStopPollingForRisk`)保持不变。
- 暂停轮询后卡管不自动停复机(10.4 开头的检查),人工处理完再开启轮询,之后按 10.1 正常管理。
- 没有被暂停轮询的异常状态卡,卡管照常按 10.1 判断;复机请求被上游拒绝时按现有失败处理。
### 10.5 上线
新规则上线后会有一批卡集中动作,用一个总开关控制:
| 开关 | 默认 | 作用 |
|---|---|---|
| `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` | `false` | 开启 10.1 的新规则(复机不看停机原因、实名按通道判断);关闭时与现在完全一致 |
| `tb_carrier.realname_control_enabled` | `0` | 按通道配置;只在总开关开启后生效 |
暂停轮询的卡不自动停复机(D17)也受总开关控制。
步骤:
1. 发版,总开关保持关闭。
2. **确认哪些通道需要实名管控**,在管理端设为 1。新规则默认不管控实名,没设置的通道,现在因未实名停机的卡在总开关打开后都会复机。
3. **打开总开关前预演(只读 SQL)**,统计下一次评估会集中动作的卡:
- 会被复机:停机、未暂停轮询、有套餐、流量没用完、(通道开启实名管控时已实名),按停机原因分组(`carrier_stopped`、`manual`、`protect_period`、`not_realname` 等)。
- 会被停机:开机、所属通道开启实名管控、未实名的卡(含行业卡,原来行业卡不停)。
4. 业务确认后打开总开关,先停掉 API 和全部 Worker 再一起启动(与 5.2 相同);观察停复机审计和上游失败数。
### 10.6 改动清单
| 位置 | 改动 |
|---|---|
| `migrations/` | `tb_carrier` 增加 `realname_control_enabled` |
| `internal/model/carrier.go`、`service/carrier`、相关 DTO | 实名管控字段的读写与审计 |
| `internal/service/iot_card/stop_resume_service.go` | 目标状态判断(10.4);复机不看停机原因;`isRealnameOK` 改为按通道判断;暂停轮询不动作;总开关 |
| `internal/store/postgres/iot_card_store.go` | 设备复机改为取设备下全部停机卡 |
| `internal/task/polling_protect_handler.go` | 只在通道开启实名管控且卡未实名时跳过 |
| `pkg/config` | 总开关 |
| **外部停机仍记 `carrier_stopped`、套餐口径、流量侧、购买与激活流程的实名策略** | **不改** |
### 10.7 测试用例
| 场景 | 期望 |
|---|---|
| 开机卡套餐到期 | 停机,原因 `no_package`(设备卡扩散到设备) |
| 开机卡流量用完 | 停机,原因 `traffic_exhausted` |
| 外部停机的卡,有套餐、流量没用完 | 自动复机 |
| 管理员手动停机的卡,有套餐、流量没用完 | 下一次评估自动复机 |
| 设备手动停机,保护期结束后 | 下一次评估按复机条件复机 |
| 停机卡没有套餐 | 保持停机;充值激活后复机 |
| 停机卡持有通道阈值锁 | 锁期内不复机 |
| 通道未开启实名管控(默认),未实名卡有套餐 | 不停机;已因未实名停机的卡复机;人工复机不因未实名被拒 |
| 通道开启实名管控,未实名的卡开着机、有套餐 | 停机,原因 `not_realname`,只停这张卡 |
| 通道开启实名管控,未实名的卡停着机、有套餐 | 不复机;实名后下一次评估复机;人工复机被拒 |
| 通道开启实名管控,未实名的行业卡 | 停机(不再区分行业卡) |
| 设备复机时设备下有一张未实名卡(通道开启实名管控) | 跳过这张卡(停机原因记为 `not_realname`),其他卡照常复机 |
| 通道开启实名管控,卡已实名、实名策略为 `before_order` | 按有套餐、流量判断,实名策略不参与 |
| 查询通道实名管控失败 | 本次不动作,记日志 |
| 设备下混有开启与未开启实名管控的通道的卡 | 各卡按自己通道判断实名;套餐与流量按设备维度 |
| 暂停轮询的卡(卡或所在设备暂停),充值激活触发评估 | 不停不复;人工停复机照常 |
| 总开关关闭 | 与现在完全一致 |

View File

@@ -0,0 +1,443 @@
# 卡流量采集写入与扣减技术方案
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-28 |
| 状态 | 技术方案已确认,2026-10-28 按评审意见修订(T18–T25,T6 照搬老系统、按配置的重置日),**代码尚未修改** |
| 前置方案 | `docs/卡流量计算技术方案-参考老卡管.md`(第 2 版):日快照、按重置日换算、重置日防旧值、按月对账、切换水位。本方案在它的基础上补齐"读数写入前的过滤""采集留痕""扣减口径""换卡""冲正" |
| 对应 Change | `add-card-traffic-queue-and-observed-deduction`(基于已实现的 `rebuild-card-traffic-metering-and-stop-resume`) |
| 范围 | 测试环境 |
---
## 1. 结论
1. **核心问题是多扣和少扣。** 现在的卡表基线算法(`last_gateway_reading_mb` + 重置窗口)一个读数就直接生效:暴涨立即扣、窗口内抖动重复扣、延迟重置整月不扣。灰度路径(日快照 + 本月最大值 − 已结算)解决了重置相关的问题,但没有过滤异常点,而且当天快照取最大值,暴涨会被留下来。
2. **本方案:**
- 读数先进 **Redis 队列**,队列内读数不下降(允许小容差)才写**最后一个点**,异常点在写入前拦下。
- **一次性全量切换**为日快照 + 本月最大值 − 已结算,卡表基线算法、灰度名单、切换水位下线。
- 有套餐的卡**每次采集都留记录**,日快照读数为 0 也写;没套餐的卡只在读数 > 0 时写日快照。
- 扣减保持**增量扣减**,扣费按**采集时刻**确定套餐,日期按采集时间算。
- 扣错了走**人工冲正流水**;初期不做自动对账,由人工排查。
### 1.1 已定决策(2026-10-28)
| # | 事项 | 决定 |
|---|---|---|
| T1 | 无效读数 | 接口失败、限流、缺字段、`used` 为 null / 空 / 解析失败、负数、NaN:不入队、不写快照;`FlexFloat` 要能区分"没值"和 0 |
| T2 | 队列存储 | Redis,每卡一个 key,Lua 原子更新 |
| T3 | 队列长度 | N = 3,可配置 |
| T4 | 写入条件 | 队列满 N 个点,相邻两段都不下降(回退在容差内算持平),写**最后一个点**(即本次采集的点);新点不在队尾时不写 |
| T5 | 跨天清空 | 可配置开关 `traffic.queue.clear_on_new_day`,默认打开;打开时新点与队列里的点不在同一天(上海时间),先清空再入队 |
| T6 | 重置日 | 照搬老系统(移动 `YidongDayFlowService`、联通 `PtLiantongDayFlowService`),只把重置日换成通道配置的重置日:按配置的重置日换算成自然月累计;重置日当天全天防旧值(读数 > 比较基准 × 0.9 按 0 计;比较基准为 0 时当天任何正读数都按 0 计,不论配置的重置日是几号),不按小时区分,不设多日警惕期;重置日当天没取到有效数据,第二天起照常按新周期计;重置日早上的旧周期读数不记回前一天,月末尾巴丢失 |
| T7 | 路径统一 | 测试环境一次性全量切换,不再灰度:所有卡走日快照 + 本月最大值 − 已结算;卡表基线算法、`TrafficCalcPolicy`、切换水位下线;切换时所有卡打"待建起点"标记(见 6.2) |
| T8 | 写入范围 | 有套餐:每次采集写采集记录;日快照读数为 0 也写;套餐日用量每天一行,没扣也写 0。没套餐:不写采集记录,读数 > 0 才写日快照 |
| T9 | 没套餐期间的用量 | 记为已结算,之后买了套餐**不补扣** |
| T10 | 扣减方式 | 增量扣减;按采集时刻生效的套餐扣,套餐到扣费时已用完或到期也照扣 |
| T11 | 停复机判断 | 通道阈值只在**写入的点**上判断,被队列拦下的点不参与 |
| T12 | 其他来源 | 人工刷新、激活前结算与轮询进同一个队列,规则一致(没有运营商流量推送);人工刷新返回本次读数、是否写入、原因 |
| T13 | 当天没有写入 | 不补写日快照,靠告警发现 |
| T14 | 换卡 | 换卡完成时给新卡打"待建起点"标记;新卡第一次**通过队列写入**时建起点(本月最大快照全部记为已结算,不扣费),之后正常扣 |
| T15 | 扣错 | 人工冲正,走冲正流水 |
| T16 | 对账 | 初期不做,人工排查 |
| T17 | "写入值 ≥ 上次写入值" | 不单独判断:日快照取最大值、增量 = 最大值 − 已结算,小的值本来就不产生增量 |
| T18 | "有套餐"口径 | 采集侧(写入范围、是否发事件、采集记录)与扣费侧用同一个"采集时刻有效套餐"判断,**已用完但仍在有效期内的套餐算有套餐**(见 6.1) |
| T19 | 按采集时刻扣费的边界 | 只对当前"生效中"的套餐做状态迁移;停机按当前状态判断;扣费时一个套餐都查不到按成功跳过;采集后套餐流量重置的,扣在重置后的周期(见 8.1) |
| T20 | 读数 0 | 读数校验放宽为"有限且 ≥ 0",有没有套餐决定 0 写不写,不再由读数校验拦(见第 6 节) |
| T21 | 采集间隔上限 | 留 2 次冗余:开关打开时间隔 ≤ 24h ÷ (N+2),关闭时 ≤ `max_point_age_hours` ÷ (N+2)(见 4.4) |
| T22 | 回退容差 | 相邻两点回退 ≤ `traffic.queue.drop_tolerance_mb`(默认 1 MB)算持平;重置等大幅下降仍被拦下 |
| T23 | 冲正 | 锁套餐行、按当前值加减;退回后已用量不能小于 0;提交后走停复机判断,不直接复机(见第 11 节) |
| T24 | 日期 | 整条链路的日期统一用上海日期(`cardflow.ShanghaiDate`),包括消费者的日流量缓冲 key |
| T25 | 套餐日用量 0 行 | 在观测事务里写:当天第一次写入时,为采集时刻的每个有效套餐确保当天一行 |
---
## 2. 整体流程
```
运营商接口
│
├─ ① 解析读数 ──────────── 无效:记采集记录(有套餐的卡),结束
│
├─ ② Redis 队列(Lua 原子):跨天清空(可配)→ 丢弃过期点 → 入队 → 只留最后 N 个
│
├─ ③ 写入判断:新点在队尾、队列满 N 个点且不下降(容差内)→ 写最后一个点 ── 不满足:记采集记录(有套餐的卡),结束
│
├─ ④ 数据库事务(卡级行锁)
│ 有"待建起点"标记:写快照 → 本月最大全部记为已结算 → 清标记,结束(第 10 节)
│ 换算月累计(重置日防旧值)→ 写日快照(取最大)
│ → Δ = 本月最大快照 − 本月已结算 → 记已结算
│ → 采集时刻有套餐:确保当天套餐日用量行(T25);Δ > 0 时写流量事件(outbox,带采集时间、采集 ID)
│ → 通道阈值判断(只在写入的点上)
│ → 写采集记录(有套餐的卡)
│
└─ ⑤ 异步消费流量事件
按采集时刻生效的套餐,按优先级扣减(容量按虚流量阈值)
→ 套餐日用量 upsert(日期 = 采集日期)→ 扣费审计 → 套餐用完触发停机
```
---
## 3. 读数解析
| 运营商返回 | 入队 | 写快照 | 采集记录(有套餐的卡) |
|---|---|---|---|
| 接口失败、超时、非 200 | 否 | 否 | 记,`result = failed` |
| 限流 | 否 | 否 | 记,`result = rate_limited` |
| 响应缺 ICCID 等必要字段 | 否 | 否 | 记,`result = invalid_payload` |
| `used` 为 null、空、无法解析 | 否 | 否 | 记,`result = no_reading`,读数为空 |
| 负数、NaN、Inf | 否 | 否 | 记,`result = invalid_reading` |
| ≥ 0 的数 | 是 | 看第 5 节 | 记 |
**改动**:`internal/gateway/models.go:55` 的 `FlexFloat` 目前把 null、空、无法解析都转成 0。改为带"是否有值"的类型(或 `*float64`),流量查询结果据此区分"没值"和真实的 0;其他使用 `FlexFloat` 的字段保持原行为。
新类型的**零值必须表示"没值"**:响应里完全没有 `used` 字段时,JSON 解析不会调用 `UnmarshalJSON`,字段停在零值。用 `*float64`(nil)或带 `Valid` 标志的结构(`Valid = false`)都满足;不能用零值为 0 的类型。
---
## 4. 采集队列(Redis)
### 4.1 存储
| 项 | 内容 |
|---|---|
| key | `traffic:queue:{card_id}` |
| 值 | 最近 N 个点,每个点 = 采集时间(Unix 毫秒)+ 运营商原始读数(MB,原样,不换算) |
| 过期 | 每次更新后设为第二天 01:00(上海时间);跨天清空关闭时设为 `max_point_age_hours` 之后 |
| 内存 | 每卡约 150 字节,100 万张卡约 150 MB |
队列只是写入前的过滤器,**不是账**:
- key 丢了(Redis 重启、淘汰)→ 队列从空开始,晚写 N−1 次,不多扣也不少扣。
- 入队成功、后面的数据库事务失败 → 队列里多一个点,下次采集重新判断即可。
Redis 淘汰策略:测试环境已确认为 `noeviction`、未设 `maxmemory`,队列不会被淘汰;内存需随卡数观察。
### 4.2 Lua 脚本
入参:`card_id`、新点(采集时间、读数)、`N`、`clear_on_new_day`、`max_point_age_ms`、过期时间。上海日期由 Go 侧算好传入(每个点存日号),Lua 里不做时区换算。
```
points = 读出队列
if clear_on_new_day and points 非空 and 上海日期(points 最后一个点) != 上海日期(新点):
points = []
if max_point_age_ms > 0:
丢掉 points 中 新点时间 − 点时间 > max_point_age_ms 的点
按采集时间把新点插入 points(同一时间的点去重)
只保留最后 N 个
写回,设置过期
return points, 新点是否在队尾
```
按采集时间插入,保证同一张卡同时有两次采集(轮询 + 人工刷新)时顺序正确;观测事务本身仍然加卡级行锁。
**乱序**:新点插入后不在队尾(比队列里已有的点更早到达顺序靠后),或者插进去就被裁掉,本次**不写**,`decision = out_of_order`。队尾的点之前已经判断过,再写一次虽然不影响账(GREATEST + 增量公式),但会让这条采集记录看起来写了自己的读数,排查时误导。
### 4.3 配置项
放在系统配置里,读法与沉默期总开关 `package.silence.enabled` 相同(`internal/application/packagesilence/settings.go`,带短缓存),改了不用重新发布。
| key | 默认 | 说明 |
|---|---|---|
| `traffic.queue.size` | 3 | 队列长度 N,最小 2 |
| `traffic.queue.clear_on_new_day` | true | 跨天是否清空 |
| `traffic.queue.max_point_age_hours` | 72 | 队列里超过这个时长的点,入队时丢掉;0 表示不限 |
| `traffic.queue.drop_tolerance_mb` | 1 | 相邻两点回退不超过这个值算持平;0 表示不允许回退 |
### 4.4 跨天清空开关
| | 打开(默认) | 关闭 |
|---|---|---|
| 队列里的点 | 只有当天的 | 可以跨天,最长 `max_point_age_hours` |
| 每天首次写入 | 当天攒满 N 个点 | 跨天后的新点进来就能判断 |
| 前一天最后一段用量 | 算进第二天第一次写入 | 跨天的点能直接写 |
| 采集间隔上限 | **间隔 ≤ 24h ÷ (N+2)**(N=3 时为 4.8 小时) | 间隔 ≤ `max_point_age_hours` ÷ (N+2)(默认为 14.4 小时) |
关闭时的重置日:队列里存的是原始读数,重置时读数大幅下降、斜率为负,自然被拦下,新周期攒满 N 个不下降的点后才写,不会把新旧周期混着写。
`max_point_age_hours` 的作用:卡停用几天后重新采集时,不用几天前的旧点来确认当前读数是否稳定。
**采集间隔上限**:理论最低要求是窗口内采到 N 次(开关打开时 24h ÷ N),但这样一次失败或限流,当天就写不进去,T13 告警会常态触发。上限按 N+2 次算,留 2 次冗余。
间隔还决定每天第一次写入的时间:开关打开时,当天第一次写入在第一次采集之后 (N−1) × 间隔(N=3、间隔 4.8 小时,约为当天第一次采集后 9.6 小时)。这之前的用量(含前一天最后一段)都在这次写入时一起扣,停机也在这时才判断。需要更及时的扣费和停机时,应缩短间隔,而不是只满足上限。
**采集间隔校验**:只校验**启用中**的轮询配置,停用的不管。保存(启用状态)或启用轮询配置时,流量采集间隔超过当前开关下的上限直接报错;`traffic.queue.size` 或开关修改时,同样校验现有启用中的轮询配置,不满足的拒绝保存。测试库启用中的只有 id 29(间隔 60 秒,满足上限);id 25(86400 秒)是停用的,不拦,以后要启用时再按上限调小。
---
## 5. 写入判断
同时满足以下条件,写**最后一个点**(即本次采集的点):
1. 新点在队尾(4.2 乱序);
2. 队列满 N 个点;
3. 相邻两点都不下降:后一个读数 ≥ 前一个读数 − `drop_tolerance_mb`。
- 只比较先后读数,与采集间隔是否均匀无关。
- 持平也写:有套餐但没用流量的卡能写入 0 用量。
- **容差**:日快照取当天最大值、增量 = 最大值 − 已结算,小幅回退的点写进去也不产生增量,所以容差不会造成多扣;它只是避免读数在几 KB 内来回跳时整窗不写。重置时的大幅下降远超容差,照样被拦下。
N=3、容差 1 MB 的例子:
| 队列 | 结果 |
|---|---|
| 100, 150, 200 | 写 200 |
| 100, 100, 100 | 写 100 |
| 100, 150, 120 | 不写 |
| 100, 99999, 120 | 不写(中间点暴涨被拦下) |
| 100, 110, 99999 | 写 99999(最后一个点暴涨挡不住,见第 14 节) |
| 100.002, 100.001, 100.003 | 回退 0.001 在容差内,写 100.003 |
| 3010, 20, 25(重置) | 不写 |
| 20, 25, 30(新周期) | 写 30 |
不写时,原因记进采集记录(第 7.1 节)。
---
## 6. 写入:日快照与结算
沿用灰度路径(`internal/application/cardobservation/traffic_flow.go`),改动见下。
**读数校验放宽**:`cardflow.ValidReading` 现在要求读数 > 0,`writeSnapshot` 与 `writeUnswitchedSnapshot` 都以它为前提,读数为 0 的点写不进快照,与 T8 冲突。改为"有限且 ≥ 0"(与第 3 节的入队条件一致);没套餐的卡读数为 0 不写快照,由 6.1 的写入范围判断,不再由读数校验拦。读数 0 在重置日不触发防旧值(`isStale` 对 ≤ 0 直接返回否),换算结果不变。
1. **换算**:`cardflow.Convert`,按卡所属通道的重置日把**被写入那个点**的读数换算成自然月累计;重置日当天读数超过比较基准 × 0.9 按 0 计(防旧值)。日期取被写入那个点的采集日期。
2. **写日快照** `tb_card_flow_day`:upsert,`month_used_mb` 取当天最大(`GREATEST`)。
3. **结算**:`Δ = 本月最大快照 − 本月已结算`,Δ > 0 记入当天 `settled_mb`。
4. **事件**:采集时刻卡有套餐(6.1.1)且 Δ > 0,同事务写流量事件(outbox),事件带采集时间和采集 ID。
5. **停复机**:通道阈值只在这里判断(被队列拦下的点不判断)。
### 6.1 写入范围
| | 有套餐 | 没套餐 |
|---|---|---|
| 被写入的点读数为 0 | 写日快照 | 不写 |
| 被写入的点读数 > 0 | 写日快照 | 写日快照 |
| 套餐日用量 | 每个有效套餐(6.1.1)每天一行,没扣也写一行 0 | — |
| Δ | 发事件扣费 | 只记已结算(T9) |
**套餐日用量 0 行在观测事务里写**(T25):扣减消费者只在 Δ > 0 时才运行,没用流量的卡走不到那里。所以在观测事务里,当天第一次写入时,为采集时刻的每个有效套餐 `INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING` 一行 0;之后扣减按 8.2 累加。由 `usage_service` 提供方法,观测事务调用,不在 cardobservation 里直接写套餐表。
#### 6.1.1 "有套餐"口径(T18)
采集侧(写入范围、是否发事件、是否写采集记录)与扣费侧(8.1 查套餐)用**同一个函数**判断"采集时刻有效套餐":
| 条件 | 说明 |
|---|---|
| 状态 | `status IN (生效中, 已用完, 已过期)`;待生效、已失效不算 |
| 已生效 | `activated_at ≤ observed_at`;`activated_at` 为空的历史数据按已生效处理(与现有口径一致) |
| 未到期 | `expires_at` 为空,或 `expires_at > observed_at` |
| 未退款 | `refund_id IS NULL` |
| 载体 | 卡自己的套餐;卡自己一个都没有时,看卡当前绑定设备的套餐 |
- **已用完但仍在有效期内的套餐算有套餐**:套餐用完到真正停机之间的用量照常发事件,扣费时进最后一个套餐的超出部分(T10)。现在 `countActivePackages`(`traffic_calc_policy.go:148`)只认"生效中"、不看 `expires_at`,这段用量被记为已结算、不发事件,`recordOverflowToDepletedPackage` 实际收不到流量,需要改。
- 已过期状态之所以纳入,是因为状态可能在采集之后才变为已过期;是否在采集时刻有效由 `expires_at` 判断。
- 待实名激活、沉默期等待中的套餐处于待生效状态,不算有套餐,期间用量按 T9 记为已结算。
### 6.2 路径统一
测试环境**一次性全量切换**,不再灰度(T7):
- 删掉 `TrafficCalcPolicy`(灰度名单)、卡表基线算法的扣费作用、第一次切换逻辑(`firstSwitch`)和切换水位(`tb_card_flow_switch` 不再读写)。
- **切换时所有卡打"待建起点"标记**(随迁移执行,与换卡同一个标记,第 10 节)。原因:切换前扣费走的是卡表基线算法,日快照上没有本月已结算(测试库 `tb_card_flow_day` 为 0 行);不打标记,切换后第一次写入的 Δ 会是运营商读数对应的整月用量,而这些用量老路径已经扣过。打了标记,每张卡切换后第一次写入只建起点、不扣费。
- 切换过程中老代码还在跑也安全:老代码不认标记,照常按老路径扣;新代码第一次写入只建起点,不会重复扣。
- 代价:老路径最后一次扣费到新路径建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。
- 卡表上的 `current_month_usage_mb` 等展示字段由日快照覆盖(与现灰度路径一致);`last_gateway_reading_mb` 不再使用。
---
## 7. 留痕
### 7.1 采集记录(新表)
只记**有套餐的卡**,每次采集一条,包括接口失败和读数为 0。
| 字段 | 说明 |
|---|---|
| `id`、`iot_card_id`、`observed_at` | |
| `observation_id` | 采集 ID,与流量事件、扣费审计、对接日志串联 |
| `source`、`scene` | 轮询、人工刷新、激活前结算 |
| `result` | 读数是否有效:`ok` / `failed` / `rate_limited` / `invalid_payload` / `no_reading` / `invalid_reading` |
| `vendor_used_mb` | 运营商原始读数,可为空 |
| `queue_points` | 入队后的队列(jsonb:采集时间 + 读数) |
| `decision` | 读数有效之后怎么判断的,`result ≠ ok` 时为空:`written` / `baseline`(建起点)/ `queue_not_full` / `slope_negative` / `out_of_order` / `settled_existing`(激活前结算没写入,用已有本月最大快照结算) |
| `month_used_mb`、`stale_guarded` | 写入时才有:换算后月累计、是否触发防旧值 |
| `delta_mb`、`event_id` | 写入时才有:本次 Δ、流量事件 ID |
| `created_at` | |
- 按 `observed_at` 按月分区,保留 90 天,过期直接删分区(不用 `DELETE ... LIMIT`)。整月都过期才能删,所以同时存在 4 个分区(约 120 天的数据)。
- 数据量:每天行数 ≈ 有套餐的卡数 × 每天采集次数。例:10 万张卡、间隔 4.8 小时(每天 5 次),每天 50 万行,4 个分区约 6000 万行。
- 索引:`(iot_card_id, observed_at DESC)`、`observation_id`。
### 7.2 排查路径
```
扣费审计(package_usage.deduct_traffic:扣了哪个套餐、多少、前后值)
→ parent_event_id → 流量事件(卡、增量、采集时间、采集 ID)
→ observation_id → 采集记录(当时的原始读数、队列、判断结果)
→ 卡 + 日期 → 日快照(当天最大值、已结算)
```
---
## 8. 扣减
扣减逻辑沿用 `internal/service/package/usage_service.go` 的 `DeductDataUsage`:小数部分攒在 Redis;加油包优先,再按 `priority`、`id`;每个套餐容量 = 阈值 − 已用(启用虚流量时阈值为虚总量,否则为真总量);最后一个套餐承接超出部分;全部用完触发停机。
改动两点:
### 8.1 按采集时刻确定套餐
- 流量事件已带 `observed_at`,消费者把它传给扣减。
- 查套餐由"当前 `status = 生效中`"改为"**采集时刻处于生效期内**":已激活、`activated_at ≤ observed_at`、未到期(`expires_at > observed_at`)、未退款、未作废;设备回退口径不变。
- 查询条件与 6.1.1 共用同一个函数,采集侧和扣费侧不会出现"采集说有、扣费说没有"(采集后退款、作废的情况除外,见下)。
- 采集时刻生效、但扣费时已用完或已到期的套餐**照扣**(T10)。
- 排序、容量、超出承接规则不变。
**状态迁移只作用于当前"生效中"的套餐**(T19)。现有循环(`usage_service.go:124-166`)在余量 ≤ 0 或扣到阈值时把套餐改成"已用完"。查询范围扩大后,集合里会有已过期、已用完的套餐,按现有写法会把"已过期"改回"已用完"。改为:
| 套餐当前状态 | 累加 `data_usage_mb`、写日用量 | 改状态 |
|---|---|---|
| 生效中 | 是 | 扣到阈值改为已用完(同现在) |
| 已用完 | 是(只会作为最后一个套餐承接超出) | 否 |
| 已过期 | 是 | 否 |
- 余量 ≤ 0 且不是最后一个套餐的,跳过;只有当前是"生效中"的才顺带改为已用完。
- 停机判断 `checkAndTriggerSuspension` 不变,仍按**当前**状态判断是否还有可用套餐。
**扣费时一个套餐都查不到**:采集时刻有套餐(所以发了事件),扣费前该套餐被退款或作废。现在 `DeductDataUsage` 返回"没有可用套餐"错误,消费者会一直重试。改为:记告警日志和扣费审计(标明跳过原因),事件按成功处理,不重试;该笔用量需要时走人工冲正。
**采集之后套餐流量被重置**:日/月重置任务(`reset_service.go`)把 `data_usage_mb` 清零。采集时刻早于套餐 `last_reset_at`、扣费在重置之后的,**扣在重置后的周期**,不跳过(跳过就是少扣)。审计里记录 `observed_at` 与 `last_reset_at`,排查时能看出这笔是跨重置扣的。列入第 14 节已知限制。
### 8.2 套餐日用量
- `updateDailyRecord` 由"先查后插"改为 upsert(`(package_usage_id, date)` 唯一),`daily_usage_mb` 累加、`cumulative_usage_mb` 覆盖。
- `date` 取采集日期(`cardflow.ShanghaiDate(observed_at)`),不取扣费时间。
- 消费者的日流量缓冲 key(`event.go` 里 `RedisCardDailyTrafficKey(..., event.ObservedAt.Format(...))`)同样改为上海日期;现在直接 `Format`,时间值是 UTC 时上海 0~8 点会落到前一天(T24)。
---
## 9. 其他读数来源
| 来源 | 处理 |
|---|---|
| 轮询 | 第 2~6 节 |
| 人工刷新 | 同一个队列、同一套规则;接口返回本次读数、是否写入、不写原因 |
| 激活前结算 | 读数同样过队列;本次没写入时,用卡上已有的本月最大快照做结算(把它全部记为已结算),不新写快照,`decision = settled_existing` |
激活前结算没写入时,"最近一次写入到激活"之间的用量会算给新套餐,精度到写入粒度(第 14 节)。
---
## 10. 换卡
套餐使用记录随换卡整条迁到新卡(`internal/service/exchange/migration.go:103`),已用量跟着走,所以不需要老系统那套"旧卡流量补算"(`tbl_new_card_add_flow`)。需要处理的是新卡的起点:
| 问题 | 例子 | 处理 |
|---|---|---|
| 新卡换卡前已有读数 | 出库前测试用了 50 MB,库存期间没轮询 | 新卡**建起点**(下文),这 50 MB 不扣 |
| 旧卡最后一段 | 最后一次采集 10:00,换卡完成 10:30 | 不处理:旧卡之后的采集按没套餐处理,记为已结算(与 T9 一致) |
**建起点**:
1. 换卡事务内,在新卡上打"待建起点"标记(`tb_iot_card` 新增标记字段),不在换卡流程里调用运营商接口。
2. 新卡的读数照常过队列。**第一次满足写入条件**时,看到标记:写日快照,把新卡本月最大快照全部记为已结算,不发事件,清掉标记,写采集记录(`decision = baseline`)。
3. 之后按正常规则处理。
建起点也过队列,是为了不让一个异常读数当起点:第一个读数是 99999 时如果直接当起点,本月最大值一直是 99999,之后整月增量为 0、不扣。
N=3、间隔 1 小时的例子:
| 时间 | 事件 | 读数 | 队列 | 处理 |
|---|---|---|---|---|
| 10-20 10:00 | 换卡完成 | — | — | 打标记 |
| 10-20 11:00 | 采集 | 52 | 52 | 不满,不写 |
| 10-20 12:00 | 采集 | 53 | 52, 53 | 不满,不写 |
| 10-20 13:00 | 采集 | 55 | 52, 53, 55 | 写 55,建起点:55 记为已结算,不扣,清标记 |
| 10-20 14:00 | 采集 | 58 | 53, 55, 58 | 写 58,扣 3 |
代价:换卡完成到建起点之间的用量不扣(比直接用第一个读数多 N−1 个采集周期),与"旧卡最后一段不补扣"口径一致。
设备换货时,设备套餐迁到新设备,新设备绑定的每张卡同样打标记。
---
## 11. 人工冲正
新增冲正流水表,冲正只通过它进行,不直接改套餐字段。
| 字段 | 说明 |
|---|---|
| `id`、`package_usage_id`、`iot_card_id` / `device_id` | |
| `delta_mb` | 正数补扣,负数退回 |
| `before_usage_mb`、`after_usage_mb` | 套餐已用量前后值 |
| `reason` | 必填 |
| `ref_observation_id`、`ref_event_id` | 关联的采集记录、流量事件,可选 |
| `operator_id`、`created_at` | |
处理:
1. 事务内先 `SELECT ... FOR UPDATE` 锁套餐行(与扣减同一把锁),按**锁住后的当前值** + `delta_mb` 计算新值,`before_usage_mb` 取锁住后的值。避免与扣减并发时互相覆盖。
2. 退回后已用量小于 0 直接报错,不截成 0。
3. 同一事务内写流水、更新套餐 `data_usage_mb`,按阈值重新计算状态(已用完 ↔ 生效中);已过期、已失效的套餐只改已用量,不改状态(与 8.1 一致)。
4. 写审计(新动作码,例如 `package_usage.adjust_traffic`)。
5. 提交后调用现有停复机判断(`evaluator.EvaluateAndAct`),**不直接复机或停机**:卡可能同时因为实名、沉默期、人工等原因停着,冲正只改变了"套餐用完"这一个条件,由停复机判断综合决定。
6. 卡上的日快照与已结算不改,冲正只作用在套餐上。
---
## 12. 监控与告警
| 指标 | 告警条件 |
|---|---|
| 有套餐的卡一整天没写入 | 开启轮询的卡,当天采集记录里没有 `decision = written`(T13) |
| 队列拦截率 | 某张卡当天 `slope_negative` 占比过高,或全局拦截率突增 |
| 待建起点 | 开启轮询的卡,"待建起点"标记超过 1 天未清除(一直没写入);不轮询的卡不告警 |
| 乱序 | `out_of_order` 数量突增(排查并发采集或时钟问题) |
| 防旧值命中 | 重置日命中数,便于核对重置日配置 |
---
## 13. 改动清单
| 位置 | 改动 |
|---|---|
| `internal/gateway/models.go` | 流量读数区分"没值"和 0,零值表示没值 |
| `internal/task/polling_carddata_handler.go` | 无效读数分类;调用队列;写采集记录 |
| 新增队列组件(Redis + Lua)、配置项注册 | 第 4 节;返回新点是否在队尾;写入判断(不下降 + 容差、写最后一个点) |
| `internal/application/cardobservation/traffic.go`、`traffic_flow.go` | 按 6.1 的写入范围;确保套餐日用量 0 行;通道阈值只在写入点判断;删除未切换分支、`firstSwitch` 与切换水位 |
| `internal/domain/cardobservation/traffic.go` | 基线算法下线 |
| `internal/application/cardobservation/traffic_calc_policy.go` | `hasActivePackageForCard` 改为调用 6.1.1 的共用判断(含已用完、按 `expires_at`、排除退款);删除灰度名单 |
| `internal/domain/cardflow/rules.go` | `ValidReading` 放宽为"有限且 ≥ 0";`isStale` 去掉 `ThisMonthBeforeResetMax > 0` 条件,比较基准为 0 时不论配置的重置日是几号都照常防旧值;调用方注释同步 |
| `internal/infrastructure/cardobservation/event.go` | 把 `observed_at` 传给扣减;日流量缓冲 key 用上海日期 |
| `internal/service/package/usage_service.go` | 按采集时刻查套餐(与采集侧共用判断);状态迁移只作用于当前生效中的套餐;查不到套餐按成功跳过;`updateDailyRecord` upsert,日期按采集日期(上海);提供"确保当天日用量行"方法供观测事务调用 |
| 人工刷新、激活前结算入口 | 过队列;人工刷新返回写入结果 |
| `internal/service/exchange` | 换卡完成时给新卡打"待建起点"标记 |
| 观测写入 | 有"待建起点"标记时,第一次满足写入条件时建起点并清标记 |
| 轮询配置保存/启用、队列配置保存 | 采集间隔上限校验(24h 或 `max_point_age_hours` ÷ (N+2)),只校验启用中的配置 |
| 新表 + 迁移 | 采集记录(按月分区)、冲正流水;`tb_iot_card` 待建起点标记,迁移时**所有卡置为待建起点**(6.2);`(package_usage_id, date)` 唯一索引 |
| 后台接口 | 采集记录查询、冲正(锁套餐行、不允许负数、提交后走停复机判断) |
---
## 14. 已知限制
| 限制 | 说明 |
|---|---|
| 最后一个点暴涨 | 队列挡不住,靠人工冲正 |
| 运营商连续返回错误且递增 | 队列挡不住,靠人工冲正 |
| 运营商整体抬高、事后下调 | 队列挡不住;初期靠人工,后期可接运营商 T+1 数据对账 |
| 月末尾巴 | 最后一次**写入**的旧周期读数到运营商归零之间的用量丢失,与老系统一致 |
| 防旧值比较基准为 0 | 没有快照可比(新卡、之前没写入)时,配置的重置日当天任何正读数都按 0 计,次日如实写入;只会晚一天扣,不会多扣 |
| 运营商跨天才归零 | 防旧值只管重置日当天(T6,不设警惕期);运营商晚一天才清零、重置日当天没取到数后第二天拿到旧数、清零后又返回缓存旧数,这些读数都会被当成新周期用量,造成多扣 |
| 写入粒度 | 激活前结算、换卡、没套餐 → 有套餐的切换点,精度都到"最近一次写入" |
| 晚写 | 队列需要攒满 N 个点,读数回退超过容差时会一直不写,扣费与停机相应延后 |
| 切换和换卡的起点 | 切换、换卡后到建起点之间的用量不扣(约 N−1 个采集周期) |
| 跨套餐流量重置 | 采集在套餐重置前、扣费在重置后的,扣在重置后的周期 |
| 采集后退款、作废 | 扣费时查不到套餐,跳过不扣,需要时人工冲正 |
---
## 15. 后续
- **对账**:接入运营商 T+1 日用量或月账单后,定期从日快照(或运营商数据)重算,与套餐已用量比对;多扣自动退回、少扣告警,调整同样走冲正流水。
- **月末尾巴**:重置日把旧周期读数的增量补上(旧周期读数 ≥ 上次旧周期读数时,差额计入本月),可选。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
# 测试环境导入生产数据验证流量计量技术方案
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-29 |
| 状态 | 方案已确认(2026-10-29),代码未修改 |
| 范围 | 测试环境 |
| 关联 | `docs/卡流量采集写入与扣减技术方案.md`、Change `add-card-traffic-queue-and-observed-deduction`、`docs/测试环境生产卡只读模式清理顺序.md` |
## 1. 背景与目标
生产(迁移版本 240,仍是卡表基线算法)大量卡"卡住":卡表基线 `last_gateway_reading_mb` 高于运营商读数,读数被当成下降拒绝,套餐长期扣 0。2026-10-29 核对:
- 生产有生效套餐、开启轮询、本月用量为 0、基线大于 0 的卡:1632 张(独立卡 1233 张)。
- 其中 916 张在 flowcollector 10-26 起的读数里持续上涨、一直未扣,合计约 1635 GB。
- 单卡导入测试环境已验证新算法可解冻并按增量扣费(测试卡 12244、12245),但单卡无法说明全量差距。
目标:把生产的卡、设备与套餐数据导入测试环境,用新计量逻辑(采集队列、日快照、建起点、按采集时刻扣减)跑真实读数,算出全量差距;**全过程不得对真实卡、设备发起任何写操作(停复机、限速、实名认证、设备指令)**。
不做:订单、钱包、分佣、退款、审批、个人客户、通知等业务流程的验证;生产发布与回滚。
## 2. 数据规模(2026-10-29)
| 项 | 生产 | 测试 |
|---|---|---|
| 卡 | 98,567(开启轮询 90,073,独立卡 59,900) | 33 |
| 设备 | 20,144(有效绑定 38,667) | 6 |
| 生效中套餐 | 22,426(其中设备套餐 10,799) | — |
| 待生效 / 生效中 / 已用完套餐 | 29,175 | 128(全部状态) |
| 使用中的运营商通道 | 34 | 13 |
| 迁移版本 | 240 | 268 |
## 3. 方案总览
```
① 建只读表 + 开关上线(测试环境)
② 生产数据 → 只读表(全部 ICCID 与设备 IMEI)
③ 确认部署生效、只读表已写入
④ 导入卡、设备、绑定、套餐(测试库),全部卡打"待建起点"
⑤ 轮询按新逻辑采集 → 采集记录、日快照、套餐扣减
⑥ 差距报表:新逻辑应扣 vs 生产已扣
⑦ 验证结束后按清理文档撤除
```
必须先 ①②③ 再 ④:导入的卡在测试环境一开始没有套餐或状态不一致,会立即触发停复机判断,此时只读保护必须已生效。
## 4. 只读表与开关
### 4.1 表 `tb_gateway_readonly_card`
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `identifier` | 网关请求里的卡号 `cardNo`:卡为 ICCID,设备指令为设备 IMEI;大写;唯一索引 |
| `identifier_type` | `iccid` / `imei` |
| `source` | 来源,如 `prod_import_20261029` |
| `remark` | 备注 |
| `created_at` | 写入时间 |
- 新的成对迁移建表;不写入数据(数据是测试环境专用,由导入脚本写入)。
- 不使用数据库外键。
### 4.2 开关 `JUNHONG_GATEWAY_READONLY_ENABLED`
只读表里的卡与设备**只允许查询类调用**,其余一律不允许:
| 允许 | 接口 |
|---|---|
| 查卡状态 | `/flow-card/status` |
| 查实名 | `/flow-card/realName` |
| 查流量 | `/flow-card/flow` |
| 查设备信息 | `/device/info` |
| 查设备槽位 | `/device/slot-info` |
| 同步设备状态 | `/device/sync-info` |
- `true`:网关客户端每次调用前,接口不在上表时,按请求卡号(`cardNo`)查表,命中则本地拦截、不发请求,返回明确失败(不是结果未知),记日志 `gateway.readonly_card.blocked`。上表接口不查表、直接放行。
- 被拦截的包括但不限于:停机 `/flow-card/cardStop`、复机 `/flow-card/cardStart`、限速 `/flow-card/speedLimit`、实名认证链接 `/flow-card/RealNameVerification`,以及设备切卡、恢复出厂、重启、WiFi 配置、模式切换。
- 查表失败(含超时、表不存在)按拦截处理:宁可调用失败,不放过真卡。
- 不做内存缓存:非查询类调用本身不频繁,按唯一索引查表约 1 ms;表一写入立即生效,没有刷新空窗。
- 未配置或为 `false`:不查表。生产不配置。
- 替换现有 `JUNHONG_GATEWAY_READONLY_ICCIDS`;名单内现有两张卡(`89861590172420410612`、`89861590172420420169`)迁入表中。
### 4.3 拦截覆盖面
所有网关调用都经过 `internal/gateway/client.go` 的 `doRequest` / `doRequestWithoutPayloadLog`,在这里判断即可覆盖所有入口:自动停复机(轮询、流量扣减、通道阈值、套餐激活与到期、订单、退款)、人工停复机按钮、限速档位、实名认证链接、设备各类接口等。业务代码不做任何改动。
## 5. 数据导入
### 5.1 范围(2026-10-29 已确认:全量)
| 数据 | 导入内容 |
|---|---|
| 运营商通道 | 不导入;按 `carrier_code` 映射到测试库已有通道,测试库缺的通道按生产配置补建(含重置日) |
| 卡 | 生产全部卡(98,567):ICCID、ICCID19/20、MSISDN、虚拟号、通道、卡类别、实名状态、网络状态、实名策略、轮询开关;店铺统一挂测试代理 9「测试采购部」 |
| 资产标识 | 每张卡的 ICCID、虚拟号写入 `tb_asset_identifier` |
| 设备与绑定 | 生产全部设备(20,144):设备号、IMEI、SN、槽位与有效绑定关系;店铺统一挂测试代理 9「测试采购部」 |
| 套餐使用记录 | 卡套餐与设备套餐中待生效、生效中、已用完的记录(约 29,175 条),保留生产的开始(生效)时间、结束(到期)时间、真总量、虚总量、重置周期与下次重置时间、优先级与主套餐 / 加油包关系;**只保存真实已用量**(`data_usage_mb`,导入时刻的生产当前值;待生效套餐本来为 0)。虚流量已用量不存储,由真实已用量 × 展示倍率 `display_gain_ratio_snapshot` 现算(上限为真总量);虚总量、展示倍率、是否启用虚流量是套餐条款快照(停机线 = 启用虚流量时的虚总量,否则真总量),照原样复制;订单号用 `PROD-<原订单号>` 标记,订单 ID 用不存在的占位值;`package_id` 指向一个测试占位套餐 |
| 不导入 | 订单、钱包、分佣、退款、个人客户、审批、历史日用量与审计 |
### 5.2 方式
用导入脚本直接写测试库(后台导卡接口一次只能导卡,不能导设备与套餐,也不适合 10 万级数据):
1. 从生产只读导出(dbhub / 只读连接),生成 CSV。
2. 写只读表:全部 ICCID 与设备 IMEI。
3. 确认开关已部署(以 Gitea Actions 部署成功为准)且只读表行数与导出一致。
4. 按通道映射写卡、资产标识、设备、绑定、套餐;同 ICCID / IMEI 已存在则跳过并记录。
5. 全部导入的卡置"待建起点"(`traffic_baseline_pending = true`)。
6. 触发轮询重新加载(重启 Worker 或调用轮询初始化),使导入的卡进入调度。
导入脚本放在 `cmd/zz_tmp_*`,不提交;只允许连接 `junhong_cmp_test`。
### 5.3 建起点
每张卡在测试环境第一次通过队列写入时只建起点:把当时运营商读数对应的本月累计全部记为已结算,不扣费。之后按增量扣到导入的套餐上。因此测试环境算出的是"导入之后"的应扣,导入之前的差距由第 7 节用 flowcollector 读数补充。
## 6. 轮询
- **完全按测试环境现有的轮询配置与代码逻辑运行**,不为导入的卡、设备单独配置,也不改业务流程:查卡状态、查实名、查流量、查套餐照常执行;流量照常进采集队列、写日快照、建起点、按增量扣套餐;套餐照常激活、到期、重置;停复机判断照常执行并记录。
- 唯一不同:导入资产的网关调用只放行查询类(查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态),停机、复机等其他调用在网关出口拦截(第 4 节)。被拦截的停复机记为失败并按退避重试,本地卡状态不变;这些失败记录是预期的,也正好说明"按新逻辑该停/该复"的卡有哪些。
- 查卡状态、查实名放行后,测试环境的网络状态、实名状态跟随真实情况。
- 轮询频率不作为本方案的约束。
## 7. 差距报表
按套餐(设备套餐按设备合并)输出:
| 列 | 来源 |
|---|---|
| 卡 / 设备、套餐、生效与到期时间、真总量、虚总量 | 导入数据 |
| 导入时已用量 | 导入时刻生产套餐已用量(两边共同起点) |
| 测试环境新增扣减 | 测试环境套餐已用量 − 导入时已用量(建起点之后按新逻辑扣) |
| 导入前差距上限 | 本月运营商读数(重置日按通道配置换算)对生效后的上限,来自 flowcollector |
| 生产同期新增扣减 | 生产套餐已用量(同一时间点只读查询)− 导入时已用量 |
| 差额、是否已到停机线 | 计算 |
汇总:卡住卡数、总差额(GB)、按新逻辑已到停机线但生产未停的套餐数。
## 8. 清理
按 `docs/测试环境生产卡只读模式清理顺序.md` 执行:先处理测试环境里的生产卡 → 关开关并重新部署 → 新迁移删表 → 可选清理代码。导入的卡、设备、套餐与采集数据在第一步删除。
## 9. 已确认事项
已确认(2026-10-29):
1. 导入全量:卡、设备、绑定,以及卡套餐与设备套餐(含开始、结束时间)。
2. 套餐只保存真实已用量(导入时刻生产当前值),虚流量已用量不存储、由真实已用量 × 展示倍率现算。差距报表因此按"导入后测试环境新增扣减 vs 生产同期新增扣减"对比,两边起点一致。
3. 卡和设备全部挂测试代理 9「测试采购部」。之前单独导入的两张卡(测试卡 12244、12245,目前未挂店铺)一并挂过去。
4. 生产卡的 MSISDN、虚拟号等按原值导入,不脱敏。

View File

@@ -0,0 +1,47 @@
# 测试环境生产卡只读模式清理顺序
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-29 |
| 范围 | 测试环境 |
| 对应机制 | 网关只读卡表 `tb_gateway_readonly_card` + 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED` |
## 背景
为验证卡流量采集与扣减,把生产上"卡住"的卡导入测试环境。这些是真实用户的卡,测试环境只能查询,不能停复机。
- 只读卡表 `tb_gateway_readonly_card`:存放导入卡的 ICCID 与导入设备的 IMEI(`identifier` 唯一索引)。
- 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED=true` 时,网关每次写操作(停机、复机、限速、实名认证、设备指令)前按请求卡号查表:在表里就本地拦截、不发请求;查流量、卡状态、实名这三类查询不查表、直接放行。
- 查表失败按拦截处理(宁可停复机失败,不放过真卡)。
- 生产不配置该环境变量,代码不查表。
## 清理顺序(必须按顺序执行)
### 1. 先处理测试环境里的生产卡(保护仍在,安全)
- 把表里这些卡在测试环境的 `enable_polling` 设为 `false`,或删除卡及其测试套餐、采集记录、日快照。
- 确认这些卡已不在轮询队列中,也没有待执行的停复机指令。
> 顺序错误的后果:保护撤掉后这些卡仍在测试环境,一旦触发无套餐、套餐用完、套餐到期等停复机,会**真实停掉用户的卡**。
### 2. 关闭开关并重新部署
- 部署脚本去掉 `JUNHONG_GATEWAY_READONLY_ENABLED`(或设为 `false`),重新部署 API 与 Worker。
- 以 Gitea Actions 部署成功为准确认生效(不要用数据库连接建立时间判断,连接池每 5 分钟会重建连接)。
> 顺序错误的后果:开关开着就删表,查表报"表不存在"按失败处理,测试环境**所有卡**的写操作都会被拦截,直到关闭开关。
### 3. 删表
- 新建一对迁移:up 执行 `DROP TABLE tb_gateway_readonly_card`,down 重建该表。
- 不修改原建表迁移。
### 4. 清理代码(可选)
- 删除写操作前的查表逻辑与开关配置。开关关闭时这段代码不生效,不删也不影响。
## 其他说明
- 生产:不配置开关,不查表。若该版本发布到生产,建表迁移会建出空表,不影响功能,后续删表迁移随之执行即可。
- 数据量:几万行(ICCID + 备注),删表瞬间完成,无锁表风险。
- 留痕:拦截记录在日志、停复机记录与审计中,与该表无关,删表不影响已有记录。

2
go.mod
View File

@@ -48,6 +48,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -61,6 +62,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/nsqio/go-nsq v1.1.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect

4
go.sum
View File

@@ -106,6 +106,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -175,6 +177,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nsqio/go-nsq v1.1.0 h1:PQg+xxiUjA7V+TLdXw7nVrJ5Jbl3sN86EhGCQj4+FYE=
github.com/nsqio/go-nsq v1.1.0/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=

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,21 +18,30 @@ 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 首次入账,其他终态不修改钱包。
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态(含审批提交失败)不修改钱包。
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
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, "员工线下代充值审批终态参数无效")
}
@@ -60,6 +70,12 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已删除")
case constants.ApprovalDecisionRevokedAfterApproved:
return nil
case constants.ApprovalDecisionSubmissionFailed:
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
if err != nil || !failed {
return err
}
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusSubmissionFailed, reason)
default:
return errors.New(errors.CodeInvalidParam, "不支持的线下代充值审批终态")
}
@@ -99,6 +115,10 @@ func (h *ApprovalDecisionHandler) applyApproved(
if err != nil {
return err
}
// 员工代收款建账:锚点为“平台账号发起的线下充值入账成功”,按来源唯一键 recharge:{id} 幂等。
if _, err := h.billCreation.CreateFromRechargeInTx(ctx, tx, record); err != nil {
return err
}
if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied {
return nil
}

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -23,7 +24,12 @@ type CreateOfflineCommand struct {
RechargeNo string
Amount int64
PaymentVoucherKeys []string
Remark string
OtherVoucherKeys []string
// OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。
OfflinePaymentMethodID uint
// ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。
ExternalTransactionNo string
Remark string
}
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
@@ -46,6 +52,106 @@ func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit Rec
return &OfflineCreationService{db: db, approval: approval, audit: audit}
}
// TriggerHistorical 为历史待审批线下代充值补发一次企业微信审批。
func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID uint) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || recordID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
var record model.AgentRechargeRecord
if err := s.db.WithContext(ctx).First(&record, recordID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值申请失败")
}
if record.PaymentMethod != constants.RechargeMethodOffline || record.Status != constants.RechargeStatusPending || record.ApprovalInstanceID != nil {
return nil, errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
}
account, shop, wallet, err := s.loadHistoricalFacts(ctx, &record)
if err != nil {
return nil, err
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: record.UserID,
CorrelationID: record.RechargeNo,
})
if err != nil {
return nil, err
}
command := CreateOfflineCommand{
SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID,
RechargeNo: record.RechargeNo, Amount: record.Amount,
PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark,
OtherVoucherKeys: []string(record.OtherVoucherKeys),
}
if record.ExternalTransactionNo != nil {
command.ExternalTransactionNo = *record.ExternalTransactionNo
}
// 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。
var frozenCode, frozenName string
if record.OfflinePaymentMethodCode != nil {
frozenCode = *record.OfflinePaymentMethodCode
}
if record.OfflinePaymentMethodName != nil {
frozenName = *record.OfflinePaymentMethodName
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName)
if err != nil {
return nil, err
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, recordID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "充值记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史线下代充值申请失败")
}
if current.PaymentMethod != constants.RechargeMethodOffline || current.Status != constants.RechargeStatusPending || current.ApprovalInstanceID != nil {
return errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge,
BusinessID: current.ID, SubmitterAccountID: current.UserID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RechargeNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND payment_method = ? AND status = ? AND approval_instance_id IS NULL", current.ID, constants.RechargeMethodOffline, constants.RechargeStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联线下代充值审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值审批实例关联已变化")
}
current.ApprovalInstanceID = &reference.InstanceID
record = current
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "补发员工线下代充值审批",
Record: &current, Approval: &instance, Wallet: wallet,
AfterData: map[string]any{"status": current.Status, "approval_instance_id": current.ApprovalInstanceID},
})
})
if err != nil {
return nil, err
}
return &CreateOfflineResult{
Record: &record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus,
}, nil
}
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
@@ -65,20 +171,31 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
paymentChannel := constants.RechargeMethodOffline
record := &model.AgentRechargeRecord{
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
externalTransactionNo := strings.TrimSpace(command.ExternalTransactionNo)
var record *model.AgentRechargeRecord
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentMethod, err := loadEnabledOfflinePaymentMethod(ctx, tx, command.OfflinePaymentMethodID)
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, paymentMethod.Code, paymentMethod.Name)
if err != nil {
return err
}
record = &model.AgentRechargeRecord{
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
ExternalTransactionNo: &externalTransactionNo,
OfflinePaymentMethodID: &paymentMethod.ID,
OfflinePaymentMethodCode: &paymentMethod.Code,
OfflinePaymentMethodName: &paymentMethod.Name,
OtherVoucherKeys: model.StringJSONBArray(command.OtherVoucherKeys),
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
}
@@ -129,17 +246,103 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
if command.OfflinePaymentMethodID == 0 {
return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
}
for _, key := range command.PaymentVoucherKeys {
if strings.TrimSpace(key) == "" {
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
}
if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil {
return err
}
if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil {
return err
}
return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个")
}
// validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。
func validateRechargeTransactionNo(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号")
}
if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength {
return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制")
}
return nil
}
// validateVoucherKeys 校验凭证对象键数量与内容,minCount 为 0 时允许为空。
func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error {
if len(keys) < minCount || len(keys) > maxCount {
return errors.New(errors.CodeInvalidParam, message)
}
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空")
}
if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制")
}
if _, exists := seen[trimmed]; exists {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复")
}
seen[trimmed] = struct{}{}
}
return nil
}
// loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。
// 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。
func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
}
var paymentMethod model.EmployeeCollectionPaymentMethod
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &paymentMethod, nil
}
func (s *OfflineCreationService) loadHistoricalFacts(
ctx context.Context, record *model.AgentRechargeRecord,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
if record == nil || record.UserID == 0 || record.ShopID == 0 || record.AgentWalletID == 0 {
return nil, nil, nil, errors.New(errors.CodeInvalidParam)
}
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", record.UserID, constants.StatusEnabled).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeForbidden, "原创建账号不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值创建人失败")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ?", record.ShopID).First(&shop).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值目标店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).
Where("id = ? AND shop_id = ? AND wallet_type = ? AND status = ?", record.AgentWalletID, record.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "原充值主钱包不存在或不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值主钱包失败")
}
return &account, &shop, &wallet, nil
}
func (s *OfflineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOfflineCommand,
@@ -170,7 +373,7 @@ func (s *OfflineCreationService) loadCreationFacts(
return &account, &shop, &wallet, nil
}
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": command.SubmitterAccountID, "account_name": submitterName,
"user_type": command.SubmitterUserType,
@@ -179,15 +382,19 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
constants.ApprovalFieldShopID: command.ShopID,
constants.ApprovalFieldShopName: shopName,
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
constants.ApprovalFieldAmountCent: command.Amount,
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
constants.ApprovalFieldSubmitterName: submitterName,
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
constants.ApprovalFieldShopID: command.ShopID,
constants.ApprovalFieldShopName: shopName,
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
constants.ApprovalFieldAmountCent: command.Amount,
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
constants.ApprovalFieldSubmitterName: submitterName,
constants.ApprovalFieldOfflinePaymentMethod: paymentMethodName,
constants.ApprovalFieldOfflinePaymentMethodCode: paymentMethodCode,
constants.ApprovalFieldExternalTransactionNo: strings.TrimSpace(command.ExternalTransactionNo),
constants.ApprovalFieldOtherVoucherKey: command.OtherVoucherKeys,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")

View File

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

View File

@@ -34,6 +34,8 @@ const (
OnlinePaymentStateClosed = "closed"
// OnlinePaymentStateUnknown 表示渠道状态暂时无法确定。
OnlinePaymentStateUnknown = "unknown"
// OnlinePaymentStateNotFound 表示渠道明确没有这笔订单;恢复任务在支付单过期后按关闭处理。
OnlinePaymentStateNotFound = "not_found"
)
// OnlinePaymentRequest 描述生成支付链接与主动查单所需的最小事实。

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,14 @@ package approval
import (
"context"
stderrors "errors"
"time"
"gorm.io/gorm"
)
// 审批恢复适配器方法见文件末尾。
// PrepareRequest 是业务写入前执行审批渠道可用性检查的请求。
type PrepareRequest struct {
BusinessType string
@@ -79,3 +82,35 @@ type SubmissionRequestedEvent struct {
type SubmissionEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event SubmissionRequestedEvent) error
}
type RecoveryPort interface {
EnqueueSubmittedSync(ctx context.Context, tx *gorm.DB, instanceID uint) error
EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, instanceID uint) error
RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, instanceID uint) (bool, error)
}
// RecoveryAdapter 将恢复动作委托给基础设施实现。
type RecoveryAdapter struct {
SyncFunc func(context.Context, *gorm.DB, uint) error
ConfirmFunc func(context.Context, *gorm.DB, uint) error
RecoverFunc func(context.Context, *gorm.DB, uint) (bool, error)
}
func (a RecoveryAdapter) SyncSubmitted(ctx context.Context, tx *gorm.DB, id uint) error {
if a.SyncFunc == nil {
return stderrors.New("审批原实例同步接缝未配置")
}
return a.SyncFunc(ctx, tx, id)
}
func (a RecoveryAdapter) ConfirmUnknown(ctx context.Context, tx *gorm.DB, id uint) error {
if a.ConfirmFunc == nil {
return stderrors.New("审批结果确认接缝未配置")
}
return a.ConfirmFunc(ctx, tx, id)
}
func (a RecoveryAdapter) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, id uint) (bool, error) {
if a.RecoverFunc == nil {
return false, stderrors.New("审批提交事件恢复接缝未配置")
}
return a.RecoverFunc(ctx, tx, id)
}

View File

@@ -0,0 +1,44 @@
package approval
import (
"context"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ConfirmSubmissionFailed 在业务消费事务内锁定审批实例并确认其仍为“提交失败”,返回可对外展示的失败原因。
// 实例已不是提交失败(例如被恢复重放)时 ok 为 false,业务消费者应不改变业务单。
// 原因只返回固定白名单文案,禁止透传企微原文或内部错误。
func ConfirmSubmissionFailed(ctx context.Context, tx *gorm.DB, instanceID uint) (string, bool, error) {
var row struct {
Status int `gorm:"column:status"`
LastError string `gorm:"column:last_error"`
}
result := tx.WithContext(ctx).Raw(
`SELECT ai.status, COALESCE(wc.last_error, '') AS last_error
FROM tb_approval_instance ai
LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = ai.id
WHERE ai.id = ? FOR UPDATE OF ai`, instanceID).Scan(&row)
if result.Error != nil {
return "", false, errors.Wrap(errors.CodeDatabaseError, result.Error, "锁定提交失败审批实例失败")
}
if result.RowsAffected == 0 {
return "", false, errors.New(errors.CodeNotFound, "审批实例不存在")
}
if row.Status != constants.ApprovalStatusSubmissionFailed {
return "", false, nil
}
return SubmissionFailureReason(row.LastError), true, nil
}
// SubmissionFailureReason 把企微提交失败原文转换为稳定白名单文案。
func SubmissionFailureReason(lastError string) string {
if strings.TrimSpace(lastError) == "" {
return constants.ApprovalSubmissionFailedStatusName
}
return constants.ApprovalSubmissionFailedReason
}

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,817 @@
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 s.silence != nil {
if err := s.silence.ApplyOnUsageCreatedInTx(ctx, tx, usage, plan.pkg, now, constants.SilenceTriggerAutoPurchase); err != nil {
return err
}
if err := s.silence.RecordForOrderInTx(ctx, tx, order); 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,190 @@
// 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
silence SilenceRecorder
}
// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与首次购买套餐时间(无人参与路径只用默认值)。
type SilenceRecorder interface {
ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error
RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error
}
// SetSilenceRecorder 注入沉默期与首次购买套餐时间写入接缝;未注入时不记录。
func (s *Service) SetSilenceRecorder(recorder SilenceRecorder) {
s.silence = recorder
}
// 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

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

View File

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

View File

@@ -0,0 +1,530 @@
// 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)
})
}
// AddMembers 增量增加多个启用且未删除的平台用户到指定启用组。
func (s *Service) AddMembers(ctx context.Context, groupID uint, request *dto.AddBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, 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.BusinessUserGroupMemberMutationResult{GroupID: groupID, AccountIDs: accountIDs, RequestedCount: len(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 := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
}
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
return err
}
before, err := store.MembersByAccountIDs(ctx, accountIDs)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
}
changed := make([]uint, 0, len(accountIDs))
for _, accountID := range accountIDs {
member, exists := before[accountID]
switch {
case !exists:
result.AddedCount++
result.AddedAccountIDs = append(result.AddedAccountIDs, accountID)
changed = append(changed, accountID)
case member.BusinessUserGroupID != groupID:
result.MovedCount++
result.MovedAccountIDs = append(result.MovedAccountIDs, accountID)
changed = append(changed, accountID)
default:
result.UnchangedCount++
result.UnchangedAccountIDs = append(result.UnchangedAccountIDs, accountID)
}
}
if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil {
return mapMemberWriteError(err)
}
return s.appendMemberAudits(ctx, tx, group, changed, before, operatorID, false)
})
if err != nil {
return nil, err
}
return result, nil
}
// RemoveMembers 按目标组作用域增量移除成员;目标组启用或停用均可操作。
func (s *Service) RemoveMembers(ctx context.Context, groupID uint, request *dto.RemoveBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, 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.BusinessUserGroupMemberMutationResult{GroupID: groupID, AccountIDs: accountIDs, RequestedCount: len(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 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, "读取平台用户原分组失败")
}
for _, accountID := range accountIDs {
member, exists := before[accountID]
if !exists || member.BusinessUserGroupID != group.ID {
return errors.New(errors.CodeConflict, "存在账号已不属于目标用户组,整批未修改")
}
}
affected, err := store.RemoveMembersByGroup(ctx, group.ID, accountIDs, operatorID)
if err != nil {
return mapMemberWriteError(err)
}
if affected != int64(len(accountIDs)) {
return errors.New(errors.CodeConflict, "目标用户组成员已被并发修改,整批未修改")
}
result.RemovedCount = len(accountIDs)
result.RemovedAccountIDs = append(result.RemovedAccountIDs, accountIDs...)
return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID, true)
})
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
}
_, 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(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 err
}
// appendMemberAudits 在业务事务内为每个实际变化账号追加一条成员归属事件。
// 账号是实际被替换归属的资源;目标组作为引用资源,移除时 after 为空。
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint, removing bool) 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 !removing {
summary = "增量维护平台用户业务用户组成员"
if group != nil {
afterGroupID = group.ID
}
}
if removing && group == nil {
return errors.New(errors.CodeInvalidStatus, "移除成员审计缺少原用户组")
}
for _, accountID := range accountIDs {
account, exists := accountByID[accountID]
if !exists {
return errors.New(errors.CodeDatabaseError, "成员审计账号不存在")
}
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,
})
}
if _, err := s.auditWriter.AppendAndGet(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,
}); err != nil {
return err
}
}
return nil
}
func optionalID(id uint) *string {
if id == 0 {
return nil
}
value := strconv.FormatUint(uint64(id), 10)
return &value
}

View File

@@ -7,11 +7,13 @@ 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"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -42,6 +44,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 +72,55 @@ type Service struct {
eventWriter EventWriter
cache CacheInvalidator
auditWriter StateAuditWriter
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
channelThreshold ChannelThresholdEvaluator
// trafficQueue 是卡流量采集队列;未注入时流量观测报错。
trafficQueue TrafficQueuePort
// trafficQueueSettings 返回采集队列配置;未注入时使用代码默认值。
trafficQueueSettings TrafficQueueSettingsSource
// logger 记录采集记录写入失败等不影响观测结果的告警;未注入时不输出。
logger *zap.Logger
// commandGrace 返回停复机成功后的状态保护窗口;未注入时不保护。
commandGrace func(ctx context.Context) time.Duration
// collectionSink 把采集记录发布到批量写入消息;未注入或发布失败时同步写库。
collectionSink CollectionSink
}
// CollectionSink 发布一条采集记录(成功表或失败表二选一),由批量消费者写库。
type CollectionSink interface {
PublishCollection(ctx context.Context, success *model.CardTrafficCollection, failure *model.CardTrafficCollectionFailure) error
}
// SetCollectionSink 注入采集记录发布器(Worker 启用 card.collection 批量写入时使用)。
func (s *Service) SetCollectionSink(sink CollectionSink) {
s.collectionSink = sink
}
// SetCommandGrace 注入停复机成功后的状态保护窗口(受控系统配置 polling.stop_resume.status_grace_seconds)。
func (s *Service) SetCommandGrace(grace func(ctx context.Context) time.Duration) {
s.commandGrace = grace
}
func (s *Service) currentCommandGrace(ctx context.Context) time.Duration {
if s == nil || s.commandGrace == nil {
return 0
}
return s.commandGrace(ctx)
}
// SetTrafficQueue 注入卡流量采集队列、队列配置与告警日志。
func (s *Service) SetTrafficQueue(queue TrafficQueuePort, settings TrafficQueueSettingsSource, logger *zap.Logger) {
s.trafficQueue = queue
s.trafficQueueSettings = settings
s.logger = logger
}
// warnLogger 返回可用的告警日志器。
func (s *Service) warnLogger() *zap.Logger {
if s.logger == nil {
return zap.NewNop()
}
return s.logger
}
// NewService 创建卡实名观测应用服务。
@@ -71,6 +128,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
@@ -94,6 +156,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
}
var decision domain.RealnameDecision
var auditedCard *model.IotCard
stale := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -103,6 +166,15 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
}
auditedCard = &card
if staleObservation(card.LastRealNameCheckAt, observation.Metadata.ObservedAt) {
// 异步入库可能乱序:早于已应用观测的实名结果不改卡事实,只记录。
s.warnLogger().Info("实名观测早于卡上最近一次已应用的检查,忽略",
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastRealNameCheckAt),
zap.String("metric", "card_observation.realname_stale"))
stale = true
return nil
}
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
@@ -225,6 +297,9 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
}
return domain.RealnameDecision{}, err
}
if stale {
return domain.RealnameDecision{}, nil
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}

View File

@@ -2,6 +2,8 @@ package cardobservation
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause"
"go.uber.org/zap"
"strconv"
"time"
@@ -35,6 +37,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}
var decision domain.NetworkDecision
var auditedCard *model.IotCard
stale := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -44,13 +47,28 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
}
auditedCard = &card
if staleObservation(card.LastCardStatusCheckAt, observation.Metadata.ObservedAt) {
// 异步入库可能乱序:早于已应用观测的网络状态不改卡事实,只记录。
s.warnLogger().Info("网络状态观测早于卡上最近一次已应用的检查,忽略",
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastCardStatusCheckAt),
zap.String("metric", "card_observation.network_stale"))
stale = true
return nil
}
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
EnablePolling: card.EnablePolling, StoppedAt: card.StoppedAt, ResumedAt: card.ResumedAt,
CommandGrace: s.currentCommandGrace(ctx),
}, observation)
if decisionErr != nil {
return decisionErr
}
if nextDecision.StatusSuppressed {
s.warnLogger().Info("停复机成功后保护窗口内观测到相反状态,判定为上游状态滞后,不回写网络状态",
zap.Uint("card_id", card.ID), zap.Int("network_status", card.NetworkStatus),
zap.String("gateway_status", observation.GatewayStatus), zap.String("metric", "card_observation.network_suppressed"))
}
decision = nextDecision
updates := map[string]any{
"last_card_status_check_at": observation.Metadata.ObservedAt,
@@ -78,6 +96,12 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if decision.StopPolling && card.EnablePolling {
// 风险停机或已销户自动关闭轮询:同事务写不轮询清单(只关该卡自身,不级联设备)。
if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(&card, false), constants.PollingToggleSourceRiskAuto); err != nil {
return err
}
}
if decision.StatusChanged {
eventID := outboxid.Stable("card-network:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":changed")
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
@@ -162,8 +186,16 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}
return domain.NetworkDecision{}, err
}
if stale {
return domain.NetworkDecision{}, nil
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}
// staleObservation 判断观测时间是否早于卡上最近一次已应用的同类检查时间(异步入库乱序保护)。
func staleObservation(lastChecked *time.Time, observedAt time.Time) bool {
return lastChecked != nil && !observedAt.IsZero() && observedAt.Before(*lastChecked)
}

View File

@@ -5,11 +5,15 @@ import (
"strconv"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
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/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/outboxid"
@@ -17,24 +21,56 @@ import (
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
type TrafficIncrementedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
// ObservationID 是采集 ID,扣费审计据此追溯到采集记录。
ObservationID string `json:"observation_id,omitempty"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。
// TrafficQueuePort 是卡流量采集队列:原子入队并返回入队后的队列与新点是否在队尾。
type TrafficQueuePort interface {
Push(ctx context.Context, cardID uint, point cardflow.QueuePoint, settings TrafficQueueSettings) ([]cardflow.QueuePoint, bool, error)
}
// TrafficQueueSettingsSource 返回当前采集队列配置。
type TrafficQueueSettingsSource interface {
Current(ctx context.Context) TrafficQueueSettings
}
// ApplyTrafficObservation 应用一次流量采集:读数先进采集队列,满足写入条件才写日快照、按月对账、
// 在采集时刻有套餐时同事务写流量事件;有套餐的卡每次采集写一条采集记录。
func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
if s == nil || s.db == nil || s.eventWriter == nil || s.trafficQueue == nil {
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
}
var decision domain.TrafficDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := domain.ValidateTrafficObservation(observation); err != nil {
return domain.TrafficDecision{}, err
}
record := newCollectionRecord(observation)
decision := domain.TrafficDecision{Result: record.Result}
if record.Result != constants.TrafficCollectionResultOK {
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
return decision, nil
}
reading := observation.GatewayReadingMB
decision.ReadingMB = &reading
settings := s.queueSettings()
points, tail, err := s.trafficQueue.Push(ctx, observation.CardID,
cardflow.QueuePoint{ObservedAt: observation.Metadata.ObservedAt, ReadingMB: reading}, settings)
if err != nil {
return decision, err
}
record.setQueue(points)
judge := cardflow.JudgeQueue(points, tail, settings.Size, settings.DropToleranceMB)
record.Decision = judge
var outcome *flowOutcome
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -42,102 +78,182 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
LastGatewayReadingMB: card.LastGatewayReadingMB,
}, observation)
if decisionErr != nil {
return decisionErr
decision.CurrentMonthUsageMB, decision.LastGatewayReadingMB = card.CurrentMonthUsageMB, card.LastGatewayReadingMB
packages, err := postgres.FindEffectivePackagesAt(ctx, tx, card.ID, observation.Metadata.ObservedAt, false)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡采集时刻有效套餐失败")
}
decision = nextDecision
updates := map[string]any{
"last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB,
}
if decision.ReadingAccepted {
updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB > 0 {
eventID := outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return err
record.HasPackage = len(packages.Packages) > 0
record.CarrierID = card.CarrierID
if judge != cardflow.QueueWrite {
if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement {
// 激活前结算没写入:用已有本月最大快照结算,不新写快照。
if err := s.settleExisting(ctx, tx, &card, observation.Metadata.ObservedAt); err != nil {
return err
}
record.Decision = constants.TrafficDecisionSettledExisting
}
return touchTrafficCheck(tx, card.ID, observation.Metadata.ObservedAt)
}
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 {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡流量",
Card: &card,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
next, err := s.applyWrittenPoint(ctx, tx, &card, observation, packages)
if err != nil {
return err
}
outcome = next
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
s.writeTrafficFailureAudit(ctx, observation, err)
return domain.TrafficDecision{}, err
}
if outcome != nil {
record.applyOutcome(outcome)
decision.ReadingAccepted = outcome.Written
decision.IncrementMB = outcome.EmitMB
decision.CrossMonth = outcome.CrossMonth
decision.CurrentMonthUsageMB = outcome.MonthMaxMB
}
decision.Decision = record.Decision
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}
// applyWrittenPoint 在观测事务内处理一个通过队列判断的写入点。
func (s *Service) applyWrittenPoint(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, packages postgres.EffectivePackages) (*flowOutcome, error) {
hasPackage := len(packages.Packages) > 0
reading := observation.GatewayReadingMB
observedAt := observation.Metadata.ObservedAt
outcome := &flowOutcome{MonthMaxMB: card.CurrentMonthUsageMB, Decision: constants.TrafficDecisionWritten}
if reading == 0 && !hasPackage {
// 没套餐的卡读数为 0 不写快照。
return outcome, touchTrafficCheck(tx, card.ID, observedAt)
}
flow, err := s.writeFlow(ctx, tx, card, reading, observedAt, hasPackage)
if err != nil {
return nil, err
}
outcome.Written, outcome.MonthMaxMB, outcome.StaleGuarded = true, flow.monthMax, flow.conversion.StaleGuarded
outcome.MonthUsedMB, outcome.DeltaMB = flow.conversion.MonthUsedMB, flow.delta
if card.TrafficBaselinePending {
outcome.Decision = constants.TrafficDecisionBaseline
} else if hasPackage && flow.delta > 0 {
outcome.EmitMB = flow.delta
}
if hasPackage {
if err := ensureDailyUsageRows(ctx, tx, packages.Packages, flow.flowDate); err != nil {
return nil, err
}
}
monthStart := cardflow.MonthStart(flow.flowDate)
outcome.CrossMonth = card.CurrentMonthStartDate == nil || card.CurrentMonthStartDate.Before(monthStart)
updates := map[string]any{
"last_data_check_at": observedAt, "last_sync_time": observedAt,
"current_month_usage_mb": flow.monthMax, "current_month_start_date": monthStart,
"data_usage_mb": card.DataUsageMB + int64(flow.delta), "traffic_baseline_pending": false,
}
if outcome.CrossMonth {
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
}
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量事实失败")
}
if outcome.EmitMB > 0 {
outcome.EventID = outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: outcome.EventID, CardID: card.ID, IncrementMB: outcome.EmitMB,
ObservedAt: observedAt, ObservationID: observation.Metadata.ObservationID,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return nil, err
}
}
// 通道阈值只在写入点判断:读数大于 0 且未被防旧值按 0 处理;激活前结算不触发停复机评估。
if reading > 0 && !outcome.StaleGuarded && s.channelThreshold != nil &&
observation.Metadata.Scene != constants.CardObservationScenePreActivationSettlement {
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
CardID: card.ID, CarrierID: card.CarrierID, ReadingMB: reading, ObservedAt: observedAt,
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
Source: observation.Metadata.Source,
}); err != nil {
return nil, err
}
}
if err := s.writeTrafficStateAudit(ctx, tx, card, observation, outcome); err != nil {
return nil, err
}
return outcome, nil
}
// touchTrafficCheck 只刷新卡的最近流量检查时间(本次没有写入快照)。
func touchTrafficCheck(tx *gorm.DB, cardID uint, observedAt time.Time) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
Updates(map[string]any{"last_data_check_at": observedAt, "last_sync_time": observedAt}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量检查时间失败")
}
return nil
}
// writeTrafficStateAudit 在本次写入改变了卡流量事实时写统一审计(人工刷新或 Worker 观测)。
func (s *Service) writeTrafficStateAudit(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, outcome *flowOutcome) error {
if outcome.DeltaMB <= 0 && !outcome.CrossMonth {
return nil
}
before := map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
}
after := map[string]any{
"data_usage_mb": card.DataUsageMB + int64(outcome.DeltaMB), "current_month_usage_mb": outcome.MonthMaxMB,
"increment_mb": outcome.EmitMB, "delta_mb": outcome.DeltaMB, "decision": outcome.Decision,
"observation_id": observation.Metadata.ObservationID,
}
input := StateAudit{Card: card, BeforeData: before, AfterData: after}
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && audited {
input.ActionCode, input.Summary = actionCode, "人工刷新 IoT 卡流量"
} else if workerObservationAudited(ctx) {
input.ActionCode, input.Summary = constants.AuditActionIotCardWorkerTrafficSynced, "Worker 同步 IoT 卡流量事实"
input.IntegrationID = observation.Metadata.ObservationID
} else {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
return s.auditWriter.WriteCardStateAudit(ctx, tx, input)
}
// writeTrafficFailureAudit 记录 Worker 观测事务失败的审计。
func (s *Service) writeTrafficFailureAudit(ctx context.Context, observation domain.TrafficObservation, err error) {
if !workerObservationAudited(ctx) || s.auditWriter == nil {
return
}
// 观测失败常因上下文已超时,失败审计改用独立短超时上下文,避免连带报错。
auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
ctx = auditCtx
var card model.IotCard
if loadErr := s.db.WithContext(ctx).Where("id = ?", observation.CardID).First(&card).Error; loadErr != nil {
return
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
func (s *Service) queueSettings() TrafficQueueSettings {
if s.trafficQueueSettings == nil {
return DefaultTrafficQueueSettings()
}
return s.trafficQueueSettings.Current(context.Background())
}
// logTrafficWarn 记录不影响观测结果的告警。
func (s *Service) logTrafficWarn(message string, fields ...zap.Field) {
s.warnLogger().Warn(message, fields...)
}

View File

@@ -0,0 +1,167 @@
package cardobservation
import (
"context"
"encoding/json"
"time"
"unicode/utf8"
"go.uber.org/zap"
"gorm.io/datatypes"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"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"
)
// rawResponseMaxBytes 是原始响应体保存上限,超过时截断并标记。
const rawResponseMaxBytes = 4096
// collectionRecord 是一次采集的留痕内容;HasPackage 在观测事务内判断(只对成功读数有意义)。
type collectionRecord struct {
model.CardTrafficCollection
HasPackage bool
failure *domain.CollectionFailure
iccid string
}
func newCollectionRecord(observation domain.TrafficObservation) *collectionRecord {
record := &collectionRecord{CardTrafficCollection: model.CardTrafficCollection{
IotCardID: observation.CardID, ObservedAt: observation.Metadata.ObservedAt,
ObservationID: observation.Metadata.ObservationID, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, Result: domain.ResolveReadingResult(observation),
CollectionRaw: buildCollectionRaw(observation.Raw), CarrierID: observation.CarrierID,
}, failure: observation.Failure, iccid: observation.ICCID}
if record.Result == constants.TrafficCollectionResultOK {
reading := observation.GatewayReadingMB
record.VendorUsedMB = &reading
}
return record
}
func (r *collectionRecord) setQueue(points []cardflow.QueuePoint) {
if encoded, err := json.Marshal(points); err == nil {
r.QueuePoints = encoded
}
}
func (r *collectionRecord) applyOutcome(outcome *flowOutcome) {
r.Decision = outcome.Decision
if !outcome.Written {
return
}
monthUsed, delta, stale := outcome.MonthUsedMB, outcome.DeltaMB, outcome.StaleGuarded
r.MonthUsedMB, r.DeltaMB, r.StaleGuarded, r.EventID = &monthUsed, &delta, &stale, outcome.EventID
}
// writeCollectionRecord 在观测事务之外写采集记录:成功读数只为采集时刻有套餐的卡写成功表;
// 失败读数不判断有无套餐,写失败表。注入发布器时发布到批量写入消息,由消费者攒批写库并失败重投;
// 未注入或发布失败时同步写库。
func (s *Service) writeCollectionRecord(ctx context.Context, record *collectionRecord, _ time.Time) {
if record.Result != constants.TrafficCollectionResultOK {
s.writeCollectionFailure(ctx, record)
return
}
if !record.HasPackage {
return
}
row := &record.CardTrafficCollection
if s.collectionSink != nil {
err := s.collectionSink.PublishCollection(ctx, row, nil)
if err == nil {
return
}
s.logTrafficWarn("发布卡流量采集记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout)
defer cancel()
if err := postgres.NewCardTrafficCollectionStore(s.db).Create(writeCtx, row); err != nil {
s.logTrafficWarn("写卡流量采集记录失败", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
}
func (s *Service) writeCollectionFailure(ctx context.Context, record *collectionRecord) {
row := &model.CardTrafficCollectionFailure{
IotCardID: record.IotCardID, ObservedAt: record.ObservedAt, ObservationID: record.ObservationID,
Source: record.Source, Scene: record.Scene, Result: record.Result, CollectionRaw: record.CollectionRaw,
ICCID: record.iccid, CarrierID: record.CarrierID,
}
if failure := record.failure; failure != nil {
row.ErrorCategory, row.ProviderCode = failure.ErrorCategory, failure.ProviderCode
row.ProviderMessage, row.DurationMS = truncateText(failure.ProviderMessage, 500), failure.DurationMS
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout)
defer cancel()
if row.ICCID == "" || row.CarrierID == 0 {
var card model.IotCard
if err := s.db.WithContext(writeCtx).Unscoped().Select("iccid", "carrier_id").Where("id = ?", record.IotCardID).Take(&card).Error; err == nil {
row.ICCID, row.CarrierID = card.ICCID, card.CarrierID
}
}
if s.collectionSink != nil {
err := s.collectionSink.PublishCollection(ctx, nil, row)
if err == nil {
return
}
s.logTrafficWarn("发布卡流量采集失败记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
if err := postgres.NewCardTrafficCollectionFailureStore(s.db).Create(writeCtx, row); err != nil {
s.logTrafficWarn("写卡流量采集失败记录失败", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
}
// buildCollectionRaw 把网关原始数据转为留存字段:响应可解析为 JSON 且不超过 4KB 时存 JSON,否则存文本并按 4KB 截断。
func buildCollectionRaw(raw *domain.GatewayRaw) model.CollectionRaw {
if raw == nil {
return model.CollectionRaw{}
}
out := model.CollectionRaw{
GatewayCode: raw.GatewayCode, GatewayMsg: truncateText(raw.GatewayMsg, 500),
GatewayTraceID: truncateText(raw.TraceID, 128), GatewayAttempts: raw.Attempts,
}
if raw.HTTPStatus > 0 {
status := raw.HTTPStatus
out.HTTPStatus = &status
}
if len(raw.Request) > 0 && json.Valid(raw.Request) {
out.RawRequest = datatypes.JSON(raw.Request)
}
switch {
case len(raw.Response) == 0:
case len(raw.Response) > rawResponseMaxBytes:
out.RawResponseText, out.RawTruncated = validUTF8Prefix(raw.Response, rawResponseMaxBytes), true
case json.Valid(raw.Response):
out.RawResponse = datatypes.JSON(raw.Response)
default:
out.RawResponseText = validUTF8Prefix(raw.Response, rawResponseMaxBytes)
}
return out
}
// validUTF8Prefix 返回不超过 limit 字节且不截断多字节字符的前缀,并替换非法 UTF-8(PostgreSQL text 不接受)。
func validUTF8Prefix(data []byte, limit int) string {
if len(data) > limit {
data = data[:limit]
for len(data) > 0 && !utf8.Valid(data) {
data = data[:len(data)-1]
}
}
if !utf8.Valid(data) {
return string([]rune(string(data)))
}
return string(data)
}
func truncateText(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}

View File

@@ -0,0 +1,118 @@
package cardobservation
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// flowOutcome 是一个写入点的日快照计量结果。
type flowOutcome struct {
// Written 为 true 表示本次写入了日快照(没套餐且读数为 0 时为 false)。
Written bool
// Decision 是写入判断:已写入或建起点。
Decision string
// EmitMB 是本次写入流量事件的增量,0 表示不写事件。
EmitMB float64
// DeltaMB 是本次按月对账计入已结算的增量(含没套餐、建起点时只记已结算的部分)。
DeltaMB float64
// MonthUsedMB 是本次读数换算后的本月累计;MonthMaxMB 是写入后的本月最大快照。
MonthUsedMB float64
MonthMaxMB float64
// StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。
StaleGuarded bool
// CrossMonth 为 true 表示卡表本月用量跨入新的自然月。
CrossMonth bool
// EventID 是本次写入的流量事件 ID。
EventID string
}
// flowWrite 是一次快照写入与按月对账的结果。
type flowWrite struct {
flowDate time.Time
conversion cardflow.Conversion
monthMax float64
delta float64
}
// writeFlow 按卡所属通道的重置日换算并原子写入当天快照,再按月对账把增量计入当天已结算。
func (s *Service) writeFlow(ctx context.Context, tx *gorm.DB, card *model.IotCard, readingMB float64, observedAt time.Time, hasPackage bool) (flowWrite, error) {
resetDay := s.strictResetDay(ctx, tx, card)
flowDate := cardflow.ShanghaiDate(observedAt)
days := postgres.NewCardFlowDayStore(tx)
monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay)
if err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡日流量快照失败")
}
conversion := cardflow.Convert(readingMB, flowDate.Day(), resetDay, monthCtx)
if err := days.Upsert(ctx, postgres.CardFlowSnapshotWrite{
CardID: card.ID, FlowDate: flowDate, MonthUsedMB: conversion.MonthUsedMB,
VendorUsedMB: readingMB, StaleGuarded: conversion.StaleGuarded,
}); err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败")
}
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
if err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
}
delta := cardflow.MonthlyDelta(monthMax, settled)
if err := days.AddSettled(ctx, card.ID, flowDate, delta); err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量增量失败")
}
return flowWrite{flowDate: flowDate, conversion: conversion, monthMax: monthMax, delta: delta}, nil
}
// settleExisting 用卡上已有的本月最大快照结算(激活前结算本次没写入时):差额全部记为已结算,不新写快照。
func (s *Service) settleExisting(ctx context.Context, tx *gorm.DB, card *model.IotCard, observedAt time.Time) error {
flowDate := cardflow.ShanghaiDate(observedAt)
days := postgres.NewCardFlowDayStore(tx)
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
}
if err := days.AddSettledToLatest(ctx, card.ID, flowDate, cardflow.MonthlyDelta(monthMax, settled)); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活前结算已有快照失败")
}
return nil
}
// ensureDailyUsageRows 为采集时刻每个有效套餐确保当天一行套餐日用量(没扣也写 0)。
func ensureDailyUsageRows(ctx context.Context, tx *gorm.DB, packages []*model.PackageUsage, flowDate time.Time) error {
ids := make([]uint, 0, len(packages))
cumulative := make(map[uint]int64, len(packages))
for _, pkg := range packages {
ids = append(ids, pkg.ID)
cumulative[pkg.ID] = pkg.DataUsageMB
}
if err := postgres.EnsureDailyUsageRows(ctx, tx, ids, flowDate, cumulative); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐当天日用量失败")
}
return nil
}
// strictResetDay 在当前连接内按卡的 carrier_id 读取上游流量重置日,不使用调用方预先计算的值;
// 查询失败、为空或非法时按卡的运营商类型兜底(联通 27,其余 1)并记录日志。
func (s *Service) strictResetDay(ctx context.Context, db *gorm.DB, card *model.IotCard) int {
var days []int
queryErr := db.WithContext(ctx).Model(&model.Carrier{}).
Where("id = ?", card.CarrierID).Limit(1).Pluck("data_reset_day", &days).Error
queried := 0
if len(days) > 0 {
queried = days[0]
}
resetDay, fallback := cardflow.ResolveResetDay(queried, queryErr == nil && len(days) > 0, card.CarrierType)
if fallback {
s.warnLogger().Warn("通道上游流量重置日不可用,按运营商类型兜底",
zap.Uint("card_id", card.ID), zap.Uint("carrier_id", card.CarrierID),
zap.String("carrier_type", card.CarrierType), zap.Int("queried", queried),
zap.Int("reset_day", resetDay), zap.Error(queryErr))
}
return resetDay
}

View File

@@ -0,0 +1,193 @@
package cardobservation
import (
"context"
stderrors "errors"
"math"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// trafficQueueSettingsTTL 是队列配置在进程内的缓存时长;后台修改数秒内生效。
const trafficQueueSettingsTTL = 5 * time.Second
// TrafficQueueSettings 是卡流量采集队列的运行参数。
type TrafficQueueSettings struct {
Size int
ClearOnNewDay bool
MaxPointAgeHours int
DropToleranceMB float64
}
// DefaultTrafficQueueSettings 返回代码默认值。
func DefaultTrafficQueueSettings() TrafficQueueSettings {
return TrafficQueueSettings{
Size: constants.TrafficQueueDefaultSize, ClearOnNewDay: true,
MaxPointAgeHours: constants.TrafficQueueDefaultMaxPointAgeHours, DropToleranceMB: constants.TrafficQueueDefaultDropToleranceMB,
}
}
// TrafficIntervalChecker 校验队列配置变更后启用中轮询配置的流量采集间隔是否仍在上限内。
type TrafficIntervalChecker func(settings TrafficQueueSettings) error
// RegisterTrafficQueueConfigDefinitions 注册采集队列系统配置。checker 非空时,队列长度、跨天清空、最长点龄
// 修改前用它校验启用中的轮询配置(读取其余三项的当前值组合成修改后的配置)。
func RegisterTrafficQueueConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger, current func() TrafficQueueSettings, checker TrafficIntervalChecker) {
if registry == nil {
return
}
minSize, maxSize := int64(constants.TrafficQueueMinSize), int64(constants.TrafficQueueMaxSize)
minAge, maxAge := int64(0), int64(24*30)
withCheck := func(apply func(*TrafficQueueSettings, string) error) func(string) error {
return func(value string) error {
next := DefaultTrafficQueueSettings()
if current != nil {
next = current()
}
if err := apply(&next, value); err != nil {
return err
}
if checker == nil {
return nil
}
return checker(next)
}
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigTrafficQueueSize, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultSize), Min: &minSize, Max: &maxSize,
Description: "卡流量采集队列长度 N:攒满 N 个不下降的读数才写最后一个点",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.Size = n
return err
})},
{Key: constants.SystemConfigTrafficQueueClearOnNewDay, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeBool,
DefaultValue: "true", Description: "卡流量采集队列跨天清空:新点与队列中的点不在同一上海日期时先清空",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
s.ClearOnNewDay = v == "true"
return nil
})},
{Key: constants.SystemConfigTrafficQueueMaxPointAgeHours, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultMaxPointAgeHours), Min: &minAge, Max: &maxAge,
Description: "卡流量采集队列最长点龄(小时):入队时丢弃与新点相差超过该时长的点,0 表示不限",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.MaxPointAgeHours = n
return err
})},
{Key: constants.SystemConfigTrafficQueueDropToleranceMB, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeString,
DefaultValue: "1", Description: "卡流量采集队列回退容差(MB):相邻两点回退不超过该值算持平,0 表示不允许回退",
Validator: func(v string) error {
_, err := parseDropTolerance(v)
return err
}},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
continue
}
if err := registry.Register(definition); err != nil && logger != nil {
logger.Error("注册卡流量采集队列系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
}
}
}
func parseDropTolerance(value string) (float64, error) {
tolerance, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) || tolerance < 0 || tolerance > 1024 {
return 0, stderrors.New("回退容差必须是 0~1024 之间的数字(MB)")
}
return tolerance, nil
}
// trafficConfigReader 读取系统配置的最小接缝。
type trafficConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
// TrafficQueueSettingsProvider 读取采集队列配置,带短缓存;读取失败时使用代码默认值。
type TrafficQueueSettingsProvider struct {
reader trafficConfigReader
logger *zap.Logger
mu sync.Mutex
cached TrafficQueueSettings
expiresAt time.Time
}
// NewTrafficQueueSettingsProvider 创建采集队列配置读取器。
func NewTrafficQueueSettingsProvider(reader trafficConfigReader, logger *zap.Logger) *TrafficQueueSettingsProvider {
if logger == nil {
logger = zap.NewNop()
}
return &TrafficQueueSettingsProvider{reader: reader, logger: logger}
}
// NewDefaultTrafficQueueSettingsProvider 用独立注册表与读取器创建采集队列配置读取器(API 与 Worker 共用同一份数据)。
func NewDefaultTrafficQueueSettingsProvider(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *TrafficQueueSettingsProvider {
registry := systemconfig.NewRegistry()
RegisterTrafficQueueConfigDefinitions(registry, logger, nil, nil)
var cache systemconfig.Cache
if redisClient != nil {
cache = systemconfig.NewRedisCache(redisClient)
}
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
return NewTrafficQueueSettingsProvider(reader, logger)
}
// Current 返回当前采集队列配置。
func (p *TrafficQueueSettingsProvider) Current(ctx context.Context) TrafficQueueSettings {
if p == nil || p.reader == nil {
return DefaultTrafficQueueSettings()
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if now.Before(p.expiresAt) {
return p.cached
}
settings, err := p.load(ctx)
if err != nil {
p.logger.Warn("读取卡流量采集队列配置失败,使用代码默认值", zap.Error(err))
settings = DefaultTrafficQueueSettings()
}
p.cached, p.expiresAt = settings, now.Add(trafficQueueSettingsTTL)
return settings
}
func (p *TrafficQueueSettingsProvider) load(ctx context.Context) (TrafficQueueSettings, error) {
settings := DefaultTrafficQueueSettings()
raw, err := p.reader.Get(ctx, constants.SystemConfigTrafficQueueSize)
if err != nil {
return settings, err
}
if settings.Size, err = strconv.Atoi(raw); err != nil || settings.Size < constants.TrafficQueueMinSize {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列长度配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueClearOnNewDay); err != nil {
return DefaultTrafficQueueSettings(), err
}
settings.ClearOnNewDay = raw == "true"
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueMaxPointAgeHours); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.MaxPointAgeHours, err = strconv.Atoi(raw); err != nil || settings.MaxPointAgeHours < 0 {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列最长点龄配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueDropToleranceMB); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.DropToleranceMB, err = parseDropTolerance(raw); err != nil {
return DefaultTrafficQueueSettings(), err
}
return settings, nil
}

View File

@@ -0,0 +1,312 @@
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 {
// 复机结果与解锁时间在同一条件更新内落库:已发起复机记 resumed,仅解锁记 skipped。
resumeResult := domain.ResumeResultResumed
if !ready {
resumeResult = domain.ResumeResultSkipped
}
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason, resumeResult)
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, domain.ResumeResultManual)
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,129 @@
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
}
// 判定时间取本次观测时刻;观测时刻缺失时退化为系统时刻,绝不写出零值判定时间。
judgedAt := evaluation.ObservedAt
if judgedAt.IsZero() {
judgedAt = s.now()
}
// 命中数值冻结为字面量:读数与阈值共用同一对副本,避免把可变的配置读回留痕。
hitTrafficMB := evaluation.ReadingMB
hitThresholdValue := threshold.Value
lock := &model.CarrierTrafficThresholdLock{
CarrierID: evaluation.CarrierID,
CardID: evaluation.CardID,
PeriodStart: periodStart,
Status: domain.LockStatusLocked,
StopStatus: domain.TaskStatusPending,
ResumeStatus: domain.TaskStatusPending,
// 命中事实与停机锁是同一条 INSERT 的列,不会因业务数据导致留痕失败而阻断停机:
// 命中列的取值范围由判定前置条件(读数已接受、阈值有效、已判定达量)保证,业务数据无法使
// hit_* CHECK 失败。数据库级故障与既有流量观测一致地整体回滚,停机顺延到下一次观测重试,
// 不会出现「已提交停机但无留痕」的半事实。
// 读数取本次判定使用的网关累计读数(与停机判定同一来源,不是本地用量或套餐真流量);
// 阈值与单位取本次判定实际生效的通道配置值,冻结命中时值,之后改阈值不改写历史命中。
HitTrafficMB: &hitTrafficMB,
HitThresholdValue: &hitThresholdValue,
HitThresholdUnit: threshold.Unit,
JudgedAt: judgedAt,
// 来源按列约束归一:未归类来源记为未记录,避免留痕列 CHECK 反过来阻断停机。
TriggerSource: domain.SafeTriggerSource(evaluation.Source),
}
// 唯一冲突(该周期已处理)在 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.Float64("threshold_value", hitThresholdValue), zap.String("threshold_unit", threshold.Unit),
zap.Time("judged_at", judgedAt), zap.String("trigger_source", lock.TriggerSource),
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,369 @@
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 表示已被并发推进。
//
// 同一条件更新同时写入解锁时间与复机结果;未满足复机条件时同事务写入可观察原因。
// 更新集合只覆盖锁生命周期与周期恢复留痕,绝不触碰停机命中事实(hit_* / judged_at /
// trigger_source):命中事实只由达量判定写入,周期恢复 MUST NOT 覆盖。
// 锁行与历史任务结果一律保留,绝不删除。
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason, resumeResult string) (bool, error) {
if tx == nil {
return false, errors.New(errors.CodeInvalidStatus, "通道阈值解锁必须传入事务句柄")
}
updates := map[string]any{
"status": domain.LockStatusUnlocked,
"unlocked_at": s.now(),
"resume_result": resumeResult,
}
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,219 @@
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)
case constants.ApprovalDecisionSubmissionFailed:
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
if err != nil || !failed {
return err
}
return h.closeVersion(ctx, tx, &version, shop, before, event,
constants.WithdrawalQualificationStatusSubmissionFailed, constants.AuditActionWithdrawalQualificationRejected,
"submission_failed", "提现资料资格审批提交失败:"+reason)
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 {
return h.closeVersion(ctx, tx, version, shop, before, event,
constants.WithdrawalQualificationStatusRejected, constants.AuditActionWithdrawalQualificationRejected,
"rejected", "企业微信未通过提现资料资格")
}
// closeVersion 以待审批状态条件更新把资料版本置为未生效终态(已驳回或审批提交失败),不影响其他版本。
func (h *QualificationApprovalHandler) closeVersion(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
shop *model.Shop,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
targetStatus int,
actionCode, eventSuffix, summary string,
) 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": targetStatus,
"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 = targetStatus
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":" + eventSuffix,
ActionCode: actionCode,
Summary: summary,
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}

View File

@@ -0,0 +1,467 @@
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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
type RegistrationResult struct {
RegistrationID uint
Status int
ApprovalInstanceID uint
ApprovalStatus int
}
// RegistrationService 受理公开扫码注册。
// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。
type RegistrationService struct {
db *gorm.DB
verifier VerificationCodeVerifier
approval approvalapp.Port
recovery approvalapp.RecoveryPort
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}
}
// SetRecoveryPort 注入通用审批原实例恢复接缝。
func (s *RegistrationService) SetRecoveryPort(recovery approvalapp.RecoveryPort) {
s.recovery = recovery
}
// RecoverApproval 恢复代理注册原审批提交事实,不创建新审批实例或业务实体。
func (s *RegistrationService) RecoverApproval(ctx context.Context, id uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeEnterprise || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if middleware.GetUserIDFromContext(ctx) == 0 {
return errors.New(errors.CodeUnauthorized)
}
if s == nil || s.db == nil || s.recovery == nil {
return errors.New(errors.CodeServiceUnavailable, "代理注册审批恢复能力尚未配置")
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
registration, err := lockRegistrationForUpdate(ctx, tx, id)
if err != nil {
return err
}
if registration.Status != constants.AgentDistributionRegistrationStatusPending {
return errors.New(errors.CodeConflict, "终态申请不可恢复")
}
var parent model.Shop
if err := tx.Where("id = ?", registration.ParentShopID).First(&parent).Error; err != nil || parent.Status != constants.StatusEnabled {
return errors.New(errors.CodeConflict, "上级店铺已停用,不能恢复审批")
}
if userType == constants.UserTypeAgent && (middleware.GetShopIDFromContext(ctx) == 0 || middleware.GetShopIDFromContext(ctx) != registration.ParentShopID) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID == 0 {
return errors.New(errors.CodeConflict, "注册申请未关联审批实例")
}
instanceID := *registration.ApprovalInstanceID
var instance model.ApprovalInstance
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution, registration.ID).First(&instance).Error; err != nil {
return errors.New(errors.CodeConflict, "审批实例业务关联不一致")
}
var wc model.WeComApprovalContext
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution).First(&wc).Error; err != nil {
return errors.New(errors.CodeConflict, "企业微信审批上下文不存在")
}
branch := "active"
var recoveryErr error
if wc.SPNo != "" {
branch = "sp_no"
recoveryErr = s.recovery.EnqueueSubmittedSync(ctx, tx, instanceID)
} else if wc.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown {
branch = "unknown"
recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instanceID)
} else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed {
// 提交失败已由决策终止注册记录,不再重放。
return errors.New(errors.CodeConflict, constants.ApprovalSubmissionFailedNotRecoverableMessage)
} else if instance.Status == constants.ApprovalStatusSubmitting || instance.Status == constants.ApprovalStatusPending || wc.SubmissionStatus == constants.WeComSubmissionStatusReady || wc.SubmissionStatus == constants.WeComSubmissionStatusSending {
} else {
return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复")
}
if recoveryErr != nil {
return errors.Wrap(errors.CodeConflict, recoveryErr, "恢复审批提交失败")
}
if s.audit != nil {
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{EventID: "agent-distribution:" + uintText(id) + ":recover:" + branch, ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "人工恢复代理注册审批提交", Registration: registration, ParentShop: &parent, BeforeData: map[string]any{"submission_status": wc.SubmissionStatus, "branch": branch}, AfterData: map[string]any{"result": "accepted"}}); err != nil {
return err
}
}
return nil
})
}
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 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, s.parentBusinessOwnerName(ctx, 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/source(Append 会以「审计操作者或入口不符合动作注册规则」失败)。
// 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) {
if appErr.Code == errors.CodeTooManyRequests {
return 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 生成企业微信审批表单业务快照。
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
//
// 业务员取上级店铺当前业务员的名称快照:上级无业务员时以空值提交并显式标记为「无」,
// MUST NOT 以提交人、上级店铺主账号或其它账号填充。该字段是只读快照,审批通过时写入新店铺的
// 初始业务员仍按既有规则取上级业务员当时值,不因本快照与通过时值不同而改写通过结果。
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop, businessOwnerName string) map[string]any {
owner := strings.TrimSpace(businessOwnerName)
absenceMark := ""
if owner == "" {
absenceMark = agentDistributionBusinessOwnerAbsenceMark
}
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),
constants.ApprovalFieldDistributionBusinessOwner: owner,
constants.ApprovalFieldDistributionBusinessOwnerAbsence: absenceMark,
}
}
// agentDistributionBusinessOwnerAbsenceMark 是注册审批材料中「上级店铺无业务员」的显式标记。
// 与「字段未填」区分开,使审批人看到的是确认无业务员而非漏填。
const agentDistributionBusinessOwnerAbsenceMark = "无"
// parentBusinessOwnerName 读取上级店铺当前业务员的名称快照。
// 判定与店铺业务员投影的可用口径一致:账号存在、为平台账号、启用且未删除;
// 任一条件不满足时返回空串,由审批材料显式标记为无,MUST NOT 用其它账号填充。
func (s *RegistrationService) parentBusinessOwnerName(ctx context.Context, parent *model.Shop) string {
if parent == nil || parent.BusinessOwnerAccountID == nil || *parent.BusinessOwnerAccountID == 0 {
return ""
}
var account model.Account
if err := s.db.WithContext(ctx).
Select("id", "username", "user_type", "status").
First(&account, *parent.BusinessOwnerAccountID).Error; err != nil {
return ""
}
if account.UserType != constants.UserTypePlatform || account.Status != constants.StatusEnabled || account.DeletedAt.Valid {
return ""
}
return strings.TrimSpace(account.Username)
}
// 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,308 @@
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)
case constants.ApprovalDecisionSubmissionFailed:
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
if err != nil || !failed {
return err
}
return h.closeRegistration(ctx, tx, registration, event,
constants.AgentDistributionRegistrationStatusSubmissionFailed, reason,
"submission_failed", "扫码注册审批提交失败,未创建任何实体")
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 {
return h.closeRegistration(ctx, tx, registration, event,
constants.AgentDistributionRegistrationStatusRejected, rejectionReason(event.Decision),
"rejected", "企业微信未通过扫码注册,未创建任何实体")
}
// closeRegistration 以待审批状态条件更新把注册记录置为不创建实体的终态(已驳回或审批提交失败)。
func (h *DistributionApprovalHandler) closeRegistration(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
event approvalapp.TerminalDecisionEvent,
targetStatus int,
reason, eventSuffix, summary string,
) 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": targetStatus,
"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 = targetStatus
registration.RejectReason = reason
registration.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "agent-distribution:" + uintText(registration.ID) + ":" + eventSuffix,
ActionCode: constants.AuditActionAgentDistributionRegistrationRejected,
Summary: 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,909 @@
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 !isWithdrawalResubmittable(request.Status) {
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 !qualification.Passed {
return qualificationCheckError(qualification)
}
if err := validateWithdrawalInvoice(qualification.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,
// 当次资料资格校验结果随尝试冻结:资格被替换或失效后,历史尝试仍返回提交时的版本与结果。
QualificationVersionID: qualification.VersionID,
QualificationCheckedAt: &qualification.CheckedAt,
QualificationPassed: qualificationPassedFlag(qualification.Passed),
}
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
}
// qualificationCheck 是本次提现依据的资料资格校验结果。
// 校验结果随当次审批尝试冻结:通过时记录版本标识与校验时间,未通过时给出稳定原因编码,
// 供提现详情查询与审计核对;历史尝试不被改写。
type qualificationCheck struct {
// Qualification 仅在通过时非空。
Qualification *model.WithdrawalQualification
Passed bool
VersionID uint
CheckedAt time.Time
// FailureReason 是未通过时的稳定原因编码(资格不存在/已失效/已停用/资料未通过审批),通过时为空。
FailureReason string
}
// qualificationCheckError 把未通过的稳定原因转成对外的稳定业务错误。
// 提示文案同时携带稳定原因编码与中文名称,使拒绝结果与审计记录都能定位到具体原因(本变更要求
// 拒绝结果与审计记录包含稳定原因)。
func qualificationCheckError(check qualificationCheck) error {
name := constants.GetWithdrawalQualificationFailureReasonName(check.FailureReason)
detail := check.FailureReason + "/" + name
if check.FailureReason == constants.WithdrawalQualificationFailureShopDisabled {
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):店铺已停用,请先恢复店铺后重新提交")
}
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):请先完成资料审批后重新提交")
}
// loadValidQualification 读取当前有效的提现资料资格;缺失、失效、未通过审批或店铺停用时返回未通过结果。
//
// 校验顺序沿用既有实现:先按资料资格判定,再判定店铺启停。稳定原因取值域与迁移
// 000237 的列语义一致(资格不存在、已失效、已停用、资料未通过审批)。
// 仅在数据库访问失败时返回错误,判定不通过一律以结果表达,使调用方能写审计并回传稳定原因。
func loadValidQualification(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (qualificationCheck, error) {
check := qualificationCheck{CheckedAt: time.Now().UTC()}
var latest model.WithdrawalQualification
err := tx.WithContext(ctx).
Where("shop_id = ?", shopID).
Order("id DESC").
First(&latest).Error
if err == gorm.ErrRecordNotFound {
check.FailureReason = constants.WithdrawalQualificationFailureMissing
return check, nil
}
if err != nil {
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
}
check.VersionID = latest.ID
if latest.Status != constants.WithdrawalQualificationStatusApproved {
if latest.Status == constants.WithdrawalQualificationStatusInvalidated {
check.FailureReason = constants.WithdrawalQualificationFailureInvalidated
} else {
check.FailureReason = constants.WithdrawalQualificationFailureNotApproved
}
return check, nil
}
var shop model.Shop
if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
return check, nil
}
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺状态失败")
}
if shop.Status != constants.ShopStatusEnabled {
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
return check, nil
}
check.Qualification = &latest
check.Passed = true
return check, nil
}
// qualificationPassedFlag 把校验结果转为尝试表的 0/1 标记,取值与迁移 000237 的 CHECK 一致。
func qualificationPassedFlag(passed bool) int {
if passed {
return constants.WithdrawalQualificationCheckPassed
}
return constants.WithdrawalQualificationCheckNotPassed
}
// 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
}
// withdrawalResubmittableStatuses 允许重提的提现状态:已驳回与审批提交失败,两者冻结均已释放。
var withdrawalResubmittableStatuses = []int{constants.WithdrawalStatusRejected, constants.WithdrawalStatusSubmissionFailed}
// isWithdrawalResubmittable 判断提现申请当前状态是否允许重提。
func isWithdrawalResubmittable(status int) bool {
for _, allowed := range withdrawalResubmittableStatuses {
if status == allowed {
return true
}
}
return false
}
// 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 IN ?", request.ID, withdrawalResubmittableStatuses).
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)),
// 当次资料资格校验留痕:版本标识与是否通过,未通过时的稳定原因不在成功快照中重复。
"qualification_version_id": attempt.QualificationVersionID,
"qualification_passed": attempt.QualificationPassed,
}
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,400 @@
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)
case constants.ApprovalDecisionSubmissionFailed:
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
if err != nil || !failed {
return err
}
return h.closeAttempt(ctx, tx, request, &attempt, event, withdrawalCloseOutcome{
status: constants.WithdrawalStatusSubmissionFailed, reason: reason, eventSuffix: "submission_failed",
summary: "佣金提现审批提交失败,已释放本次尝试冻结余额",
releaseRemark: "审批提交失败,释放提现冻结,单号:",
})
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 {
return h.closeAttempt(ctx, tx, request, attempt, event, withdrawalCloseOutcome{
status: constants.WithdrawalStatusRejected, reason: rejectionReason(event.Decision), eventSuffix: "closed",
summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额",
releaseRemark: "企业微信未通过,释放提现冻结,单号:",
})
}
// withdrawalCloseOutcome 描述提现尝试未通过时的终态、原因、审计与释放流水文案。
type withdrawalCloseOutcome struct {
status int
reason string
eventSuffix string
summary string
releaseRemark string
}
// closeAttempt 在同一事务内仅一次释放本次尝试冻结、写释放流水,并把申请从待审核置为未通过终态。
// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。
func (h *WithdrawalApprovalHandler) closeAttempt(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
event approvalapp.TerminalDecisionEvent,
outcome withdrawalCloseOutcome,
) 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 := outcome.reason
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending).
Updates(map[string]any{
"status": outcome.status,
"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, outcome.releaseRemark)
if err != nil {
return err
}
attempt.ReleasedAt = &now
before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore}
request.Status = outcome.status
request.ProcessedAt = &now
request.RejectReason = reason
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":" + outcome.eventSuffix,
ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed,
Summary: outcome.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,
remarkPrefix string,
) (*model.AgentWalletTransaction, error) {
remark := remarkPrefix + 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, "创建提现释放钱包流水失败")
}
return transaction, nil
}

View File

@@ -0,0 +1,763 @@
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
recovery approvalapp.RecoveryPort
audit ApplicationAuditWriter
}
// NewApplicationService 创建核销申请用例。
func NewApplicationService(db *gorm.DB, approval approvalapp.Port, audit ApplicationAuditWriter) *ApplicationService {
return &ApplicationService{db: db, approval: approval, audit: audit}
}
// SetRecoveryPort 注入通用审批原实例恢复接缝。
func (s *ApplicationService) SetRecoveryPort(recovery approvalapp.RecoveryPort) {
s.recovery = recovery
}
// RecoveryResult 返回原审批恢复动作的安全结果。
type RecoveryResult struct {
ApplicationID uint
ApprovalInstanceID uint
Action string
Result string
SubmissionStatus int
LastRecoveryAt *time.Time
Recoverable bool
}
// 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)
}
// RecoverApproval 恢复员工核销申请的原审批事实,不创建新尝试或审批实例。
func (s *ApplicationService) RecoverApproval(ctx context.Context, applicationID uint) (*RecoveryResult, error) {
if applicationID == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
actorID := middleware.GetUserIDFromContext(ctx)
if actorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeAgent || userType == constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if s == nil || s.db == nil || s.recovery == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "核销审批恢复能力尚未配置")
}
var result RecoveryResult
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
application, err := lockApplication(ctx, tx, applicationID)
if err != nil {
return err
}
if application.ApplicantAccountID != actorID && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
if application.Status != constants.EmployeeCollectionApplicationStatusPending || application.LatestAttemptID == 0 || application.LatestApprovalInstanceID == 0 {
return errors.New(errors.CodeConflict, "终态申请不可恢复")
}
var attempt model.EmployeeCollectionApplicationAttempt
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND application_id = ?", application.LatestAttemptID, application.ID).First(&attempt).Error; err != nil {
return errors.New(errors.CodeConflict, "核销审批尝试关联不一致")
}
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != application.LatestApprovalInstanceID {
return errors.New(errors.CodeConflict, "核销审批实例关联不一致")
}
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", application.LatestApprovalInstanceID, constants.ApprovalBusinessTypeEmployeeCollection, attempt.ID).First(&instance).Error; err != nil {
return errors.New(errors.CodeConflict, "审批实例业务关联不一致")
}
if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusPending && instance.Status != constants.ApprovalStatusSubmissionFailed && instance.Status != constants.ApprovalStatusSubmissionUnknown {
return errors.New(errors.CodeConflict, "审批终态不允许恢复")
}
var wc model.WeComApprovalContext
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instance.ID, constants.ApprovalBusinessTypeEmployeeCollection).First(&wc).Error; err != nil {
return errors.New(errors.CodeConflict, "企业微信审批上下文不存在")
}
beforeStatus := fmt.Sprintf("%d", wc.SubmissionStatus)
action, recoveryErr := "active", error(nil)
if wc.SPNo != "" {
action = "sp_no"
recoveryErr = s.recovery.EnqueueSubmittedSync(ctx, tx, instance.ID)
} else if wc.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown {
action = "unknown"
recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instance.ID)
} else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed {
// 提交失败已由决策终止业务单,重放会造成“业务单终态、企微又生成审批”的分裂。
return errors.New(errors.CodeConflict, constants.ApprovalSubmissionFailedNotRecoverableMessage)
} else if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusPending && wc.SubmissionStatus != constants.WeComSubmissionStatusReady && wc.SubmissionStatus != constants.WeComSubmissionStatusSending {
return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复")
}
if recoveryErr != nil {
return errors.Wrap(errors.CodeConflict, recoveryErr, "恢复审批提交失败")
}
if err := s.audit.WriteEmployeeCollectionRecovery(ctx, tx, application, &attempt, &instance, actorID, beforeStatus, action, "accepted"); err != nil {
return err
}
result = RecoveryResult{ApplicationID: application.ID, ApprovalInstanceID: instance.ID, Action: action, Result: "accepted", SubmissionStatus: wc.SubmissionStatus, LastRecoveryAt: wc.LastRecoveryAt, Recoverable: true}
return nil
})
if err != nil {
return nil, err
}
return &result, nil
}
// 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,38 @@
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
WriteEmployeeCollectionRecovery(ctx context.Context, tx *gorm.DB, application *model.EmployeeCollectionApplication, attempt *model.EmployeeCollectionApplicationAttempt, instance *model.ApprovalInstance, actorID uint, beforeStatus, action, result string) error
}

View File

@@ -0,0 +1,515 @@
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)
case constants.ApprovalDecisionSubmissionFailed:
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
if err != nil || !failed {
return err
}
return h.applyClosed(ctx, tx, application, &attempt, event,
constants.EmployeeCollectionApplicationStatusSubmissionFailed, reason)
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 ||
targetStatus == constants.EmployeeCollectionApplicationStatusSubmissionFailed {
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
}
summary := "企业微信审批未通过,核销申请预占已释放"
if targetStatus == constants.EmployeeCollectionApplicationStatusSubmissionFailed {
summary = "审批提交失败,核销申请终止,预占已释放"
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: closedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationRejected,
Summary: 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"
case constants.ApprovalDecisionSubmissionFailed:
return "submission_failed"
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_id,seller_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,165 @@
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, "员工代收款退款冲销参数无效")
}
// 0 元退款只作废权益、不退钱:不冲减、不关闭账单、不写冲销记录,也不能让退款事务失败。
if source.RefundAmount == 0 {
return nil
}
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,314 @@
package h5popup
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
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).
// 候选排序:先按类型类别(风险换卡提醒已在调用方先行处理,此处为推广高于公告),
// 再按显式优先级降序,同优先级取最近更新时间最新,最后按 ID 稳定排序。
// 类别顺序 MUST 用显式表达式表达:announcement 的字典序小于 promotion,
// 直接按 popup_type 排序会把公告排在推广之前,与 111.md §13.3 相反。
Order(clause.OrderBy{Expression: clause.Expr{
SQL: "CASE popup_type WHEN ? THEN 0 ELSE 1 END ASC",
Vars: []any{constants.H5PopupTypePromotion},
}}).
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,505 @@
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
PopupType 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
}
// 弹窗类型必填:它既参与类别排序,也决定缺省优先级的取值来源。
popupType := strings.TrimSpace(request.PopupType)
if !constants.IsH5PopupType(popupType) {
return 0, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion(套餐政策推广)或 announcement(通用公告)")
}
priority := constants.H5PopupTypeDefaultPriority(popupType)
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, PopupType: popupType, 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, PopupType: normalized.PopupType,
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, PopupType: record.PopupType, 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.PopupType != nil {
merged.PopupType = strings.TrimSpace(*request.PopupType)
}
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.PopupType = normalized.PopupType
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, "运营弹窗标题长度必须在 1~100 字符之间")
}
// 标题与正文同一口径:两者都会冻结进通知并参与渲染,任一都不接受 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, "运营弹窗正文长度必须在 1~2000 字符之间")
}
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
}
// 类型必填且必须落在受控取值域内:它是候选排序的第一顺位类别键,非法值会让配置落进未定义类别。
normalized.PopupType = strings.TrimSpace(input.PopupType)
if !constants.IsH5PopupType(normalized.PopupType) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion(套餐政策推广)或 announcement(通用公告)")
}
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, "运营弹窗优先级必须在 0~1000000 之间")
}
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, "popup_type": record.PopupType, "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, "popup_type": record.PopupType,
"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,748 @@
// 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 rows []model.PaymentMerchantPool
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, "查询商户池失败")
}
pools := make([]*model.PaymentMerchantPool, 0, len(rows))
for index := range rows {
pools = append(pools, &rows[index])
}
projections, err := loadPoolProjections(ctx, s.db, pools, time.Now())
if err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
for _, pool := range pools {
result = append(result, poolResponse(pool, projections[pool.ID]))
}
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 s.poolView(ctx, &pool, time.Now())
}
// 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++
}
// 时间轮询池修改周期数值、单位、起始时间或成员顺序并保存成功时,起点由服务端按保存成功时间重算,
// 忽略请求携带的旧起点,避免前端时钟或漏传导致时段漂移。
restartTimedSlot := !creating && strings.TrimSpace(req.Strategy) == model.PaymentMerchantStrategyTime && timedSlotChanged(&pool, &req, previousMemberIDs)
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 creating {
// 创建按请求起点;请求缺省起点时取保存时间。
restartTimedSlot = pool.Strategy == model.PaymentMerchantStrategyTime && pool.TimePeriodStartedAt == nil
}
savedAt := time.Now()
if restartTimedSlot {
pool.TimePeriodStartedAt = &savedAt
}
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 s.poolView(ctx, &pool, time.Now())
}
// 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 s.poolView(ctx, &pool, time.Now())
}
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)
}
// timedSlotChanged 判断保存时间轮询池时是否需要按保存成功时间重算时段起点:
// 周期数值、单位、起始时间或成员顺序任一变化即视为需要重算。
func timedSlotChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
if !sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
return true
}
return !sameMemberOrder(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}
}
// poolResponse 组装商户池响应,成员、成员计数与命中成员由只读投影提供。
func poolResponse(p *model.PaymentMerchantPool, projection poolProjection) *dto.PaymentMerchantPoolResponse {
response := &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: projection.MemberIDs, EnabledMemberCount: projection.EnabledMembers, MemberCount: len(projection.MemberIDs), UpdatedAt: p.UpdatedAt, Remark: p.Remark, LastFailureMerchantID: projection.LastFailureMerchantID, LastFailurePaymentID: projection.LastFailurePaymentID, LastFailurePaymentNo: projection.LastFailurePaymentNo, LastFailureReason: projection.LastFailureReason, LastFailureAt: projection.LastFailureAt}
if projection.CurrentMerchant != nil {
merchantID := projection.CurrentMerchant.ID
response.CurrentMerchantID = &merchantID
response.CurrentMerchantName = projection.CurrentMerchant.Name
}
return response
}
// poolView 读取商户池的只读列表投影并组装响应。
func (s *ManagementService) poolView(ctx context.Context, pool *model.PaymentMerchantPool, now time.Time) (*dto.PaymentMerchantPoolResponse, error) {
projections, err := loadPoolProjections(ctx, s.db, []*model.PaymentMerchantPool{pool}, now)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
return poolResponse(pool, projections[pool.ID]), 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
}
// 公众号必填;小程序选填,但 AppID 与 AppSecret 必须同时填写或同时留空。
if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "") {
return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号 AppID 和 AppSecret 必须填写")
}
if (strings.TrimSpace(req.MiniappAppID) == "") != (strings.TrimSpace(req.MiniappAppSecret) == "") {
return nil, errors.New(errors.CodeInvalidParam, "小程序 AppID 和 AppSecret 需同时填写或同时留空")
}
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,717 @@
package merchantpayment
import (
"context"
stderrors "errors"
"fmt"
"net"
"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
}
// routingOutcome 是只读选路结果。
type routingOutcome struct {
// Merchant 是当前命中成员。
Merchant *model.PaymentMerchant
// OpenNewRound 为真表示每轮累计已全部达标,调用方需以条件更新开启新一轮。
OpenNewRound bool
}
// routeStats 是已按统计世代隔离的商户路由尝试累计。
type routeStats struct {
amounts map[uint]int64
counts map[uint]int64
}
// newRouteStats 创建空的路由尝试累计聚合。
func newRouteStats() routeStats {
return routeStats{amounts: map[uint]int64{}, counts: map[uint]int64{}}
}
// add 累加一条路由尝试事实。
func (s routeStats) add(row *model.PaymentMerchantRoutingAttempt) {
s.amounts[row.MerchantID] += row.Amount
s.counts[row.MerchantID]++
}
// statisticCycle 返回金额/笔数池的统计周期。
func statisticCycle(pool *model.PaymentMerchantPool) (string, error) {
if pool.StatisticCycle == nil {
return "", errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
}
return *pool.StatisticCycle, nil
}
// thresholdValue 返回金额/笔数池的达标阈值。
func thresholdValue(pool *model.PaymentMerchantPool) (int64, error) {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if pool.ThresholdAmount == nil {
return 0, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
}
return *pool.ThresholdAmount, nil
}
if pool.ThresholdCount == nil {
return 0, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
}
return *pool.ThresholdCount, nil
}
// selectRouteMember 只读计算当前命中成员,与支付创建共用同一套规则:
// 金额/笔数取成员顺序中第一个未达阈值的成员;「每轮累计」全部达标时取成员顺序第一项,
// 由调用方开启新一轮;自然日与自然月全部达标时视为当期无可用成员;时间方式按当前时段计算。
// 池内只有一个启用成员时该成员固定命中,不因达到阈值被跳过、也不因全部达标被拒绝,
// 该规则优先于自然周期拒绝语义,短路在全部达标判定之前。
// 本函数 MUST NOT 写库:不创建支付单、不推进统计世代、不计入成功累计。
func selectRouteMember(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, stats routeStats, now time.Time) (routingOutcome, error) {
if len(merchants) == 0 {
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
if pool.Strategy == model.PaymentMerchantStrategyTime {
merchant, err := chooseTimedMerchant(pool, merchants, now)
if err != nil {
return routingOutcome{}, err
}
return routingOutcome{Merchant: merchant}, nil
}
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
return routingOutcome{}, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
}
cycle, err := statisticCycle(pool)
if err != nil {
return routingOutcome{}, err
}
threshold, err := thresholdValue(pool)
if err != nil {
return routingOutcome{}, err
}
if len(merchants) == 1 {
return routingOutcome{Merchant: merchants[0]}, nil
}
for _, merchant := range merchants {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if stats.amounts[merchant.ID] < threshold {
return routingOutcome{Merchant: merchant}, nil
}
continue
}
if stats.counts[merchant.ID] < threshold {
return routingOutcome{Merchant: merchant}, nil
}
}
if cycle != "round" {
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "当前周期暂无可用商户")
}
return routingOutcome{Merchant: merchants[0], OpenNewRound: true}, nil
}
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
var stats routeStats
if pool.Strategy == model.PaymentMerchantStrategyAmount || pool.Strategy == model.PaymentMerchantStrategyCount {
loaded, err := loadPoolRouteStats(ctx, tx, pool, now)
if err != nil {
return nil, err
}
stats = loaded
}
outcome, err := selectRouteMember(pool, merchants, stats, now)
if err != nil {
return nil, err
}
if !outcome.OpenNewRound {
return outcome.Merchant, nil
}
// 每轮累计全部达标:当轮结束,以条件更新递增统计世代开启新一轮;
// 世代已被并发改变时按并发冲突拒绝本次创建,绝不在旧世代上重复累计。
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
return nil, err
}
return outcome.Merchant, nil
}
// loadPoolRouteStats 读取单个商户池当前统计世代的路由尝试累计。
// 自然日与自然月周期只统计支付创建时间不早于当期窗口起点的尝试事实。
func loadPoolRouteStats(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, now time.Time) (routeStats, error) {
cycle, err := statisticCycle(pool)
if err != nil {
return routeStats{}, err
}
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
if start, limited := routingWindowStart(cycle, now); limited {
query = query.Where("created_at >= ?", start)
}
var rows []model.PaymentMerchantRoutingAttempt
if err := query.Find(&rows).Error; err != nil {
return routeStats{}, err
}
stats := newRouteStats()
for index := range rows {
stats.add(&rows[index])
}
return stats, 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
}
// poolProjection 是商户池列表的只读计算结果。
type poolProjection struct {
MemberIDs []uint
EnabledMembers int
CurrentMerchant *model.PaymentMerchant
LastFailureMerchantID *uint
LastFailurePaymentID *uint
LastFailurePaymentNo string
LastFailureReason string
LastFailureAt *time.Time
}
// loadPoolProjections 按页批量计算成员计数与当前命中成员,供列表与详情投影复用。
// 只读:不创建支付单、不推进统计世代、不计入成功累计;查询次数为常数,不随池数量线性增长。
// 池停用或没有可用成员时当前命中成员为空,不影响其余字段返回。
func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.PaymentMerchantPool, now time.Time) (map[uint]poolProjection, error) {
projections := make(map[uint]poolProjection, len(pools))
if len(pools) == 0 {
return projections, nil
}
poolIDs := make([]uint, 0, len(pools))
for _, pool := range pools {
poolIDs = append(poolIDs, pool.ID)
}
var members []model.PaymentMerchantPoolMember
if err := db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
return nil, err
}
membersByPool := make(map[uint][]uint, len(pools))
merchantIDs := make([]uint, 0, len(members))
for index := range members {
member := &members[index]
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
merchantIDs = append(merchantIDs, member.MerchantID)
}
merchants := make(map[uint]*model.PaymentMerchant)
if len(merchantIDs) > 0 {
var rows []model.PaymentMerchant
if err := db.WithContext(ctx).Where("id IN ? AND status = ?", merchantIDs, model.PaymentMerchantStatusEnabled).Find(&rows).Error; err != nil {
return nil, err
}
for index := range rows {
merchant := &rows[index]
merchants[merchant.ID] = merchant
}
}
// 金额与笔数启用池当前世代的路由尝试累计一次查出:按池拼出「池 + 世代(+ 当期窗口起点)」条件,避免逐池查询。
terms := make([]string, 0, len(pools))
args := make([]any, 0, len(pools)*3)
for _, pool := range pools {
if pool.Status != model.PaymentMerchantStatusEnabled || len(membersByPool[pool.ID]) == 0 {
continue
}
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
continue
}
cycle, err := statisticCycle(pool)
if err != nil {
continue
}
if start, limited := routingWindowStart(cycle, now); limited {
terms = append(terms, "(pool_id = ? AND routing_epoch = ? AND created_at >= ?)")
args = append(args, pool.ID, pool.RoutingEpoch, start)
continue
}
terms = append(terms, "(pool_id = ? AND routing_epoch = ?)")
args = append(args, pool.ID, pool.RoutingEpoch)
}
statsByPool := make(map[uint]routeStats, len(pools))
if len(terms) > 0 {
var rows []model.PaymentMerchantRoutingAttempt
if err := db.WithContext(ctx).Where(strings.Join(terms, " OR "), args...).Find(&rows).Error; err != nil {
return nil, err
}
for index := range rows {
row := &rows[index]
stats, ok := statsByPool[row.PoolID]
if !ok {
stats = newRouteStats()
statsByPool[row.PoolID] = stats
}
stats.add(row)
}
}
var failures []model.PaymentMerchantFailure
if err := db.WithContext(ctx).Where("pool_id IN ? AND result_class = ?", poolIDs, "explicit_failed").Order("occurred_at DESC, id DESC").Find(&failures).Error; err != nil {
return nil, err
}
failureByPool := make(map[uint]model.PaymentMerchantFailure, len(failures))
for index := range failures {
failure := failures[index]
if _, exists := failureByPool[failure.PoolID]; !exists {
failureByPool[failure.PoolID] = failure
}
}
for _, pool := range pools {
ordered := orderedEnabledMembers(pool, membersByPool[pool.ID], merchants)
projection := poolProjection{MemberIDs: membersByPool[pool.ID], EnabledMembers: len(ordered)}
if failure, ok := failureByPool[pool.ID]; ok {
merchantID, paymentID := failure.MerchantID, failure.PaymentID
projection.LastFailureMerchantID = &merchantID
projection.LastFailurePaymentID = &paymentID
projection.LastFailurePaymentNo = failure.PaymentNo
projection.LastFailureReason = failure.FailureReason
occurredAt := failure.OccurredAt
projection.LastFailureAt = &occurredAt
}
if pool.Status == model.PaymentMerchantStatusEnabled {
if outcome, err := selectRouteMember(pool, ordered, statsByPool[pool.ID], now); err == nil {
projection.CurrentMerchant = outcome.Merchant
}
}
projections[pool.ID] = projection
}
return projections, nil
}
// orderedEnabledMembers 按成员顺序返回启用且支付方式与池一致的商户。
func orderedEnabledMembers(pool *model.PaymentMerchantPool, memberIDs []uint, merchants map[uint]*model.PaymentMerchant) []*model.PaymentMerchant {
ordered := make([]*model.PaymentMerchant, 0, len(memberIDs))
for _, id := range memberIDs {
merchant := merchants[id]
if merchant == nil || merchant.Status != model.PaymentMerchantStatusEnabled || merchant.PaymentMethod != pool.PaymentMethod {
continue
}
ordered = append(ordered, merchant)
}
return ordered
}
// 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
}
// RecordRoutingAttempt 在支付单与冻结路由同一事务中写入一次路由尝试事实。
// 唯一支付约束使并发重放和重复建账不会增加第二次统计。
func RecordRoutingAttempt(ctx context.Context, tx *gorm.DB, payment *model.Payment) error {
if payment == nil || payment.ID == 0 || payment.MerchantID == nil || payment.MerchantPoolID == nil || payment.RoutingEpoch == nil || payment.Amount <= 0 {
return nil
}
attempt := &model.PaymentMerchantRoutingAttempt{
PaymentID: payment.ID, MerchantID: *payment.MerchantID, PoolID: *payment.MerchantPoolID,
RoutingEpoch: *payment.RoutingEpoch, Amount: payment.Amount, ResultClass: "pending",
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "写入商户池路由尝试失败")
}
return nil
}
// DisableMerchantAfterExplicitFailure 条件禁用冻结商户,并写入脱敏失败调查事实。
func DisableMerchantAfterExplicitFailure(ctx context.Context, db *gorm.DB, payment *model.Payment, reason string, now time.Time) error {
if payment == nil || payment.ID == 0 || payment.MerchantID == nil || payment.MerchantPoolID == nil || db == nil {
return nil
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var merchant model.PaymentMerchant
if err := tx.WithContext(ctx).First(&merchant, *payment.MerchantID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取失败支付商户状态失败")
}
result := tx.WithContext(ctx).Model(&model.PaymentMerchant{}).
Where("id = ? AND status = ?", merchant.ID, model.PaymentMerchantStatusEnabled).
Update("status", model.PaymentMerchantStatusDisabled)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "自动禁用失败支付商户失败")
}
after := merchant.Status
if result.RowsAffected == 1 {
after = model.PaymentMerchantStatusDisabled
}
failure := &model.PaymentMerchantFailure{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, MerchantID: merchant.ID,
PoolID: *payment.MerchantPoolID, ResultClass: "explicit_failed", FailureReason: safeFailureReason(reason),
StatusBefore: merchant.Status, StatusAfter: after, OccurredAt: now,
}
if err := tx.WithContext(ctx).Create(failure).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入支付商户失败留痕失败")
}
return nil
})
}
func safeFailureReason(reason string) string {
reason = strings.TrimSpace(reason)
if len(reason) > 128 {
return reason[:128]
}
return reason
}
// PreCreateResultClass 是渠道预下单结果的稳定分类。
type PreCreateResultClass string
const (
PreCreateResultSuccess PreCreateResultClass = "success"
PreCreateResultExplicitFailed PreCreateResultClass = "explicit_failed"
PreCreateResultUnknown PreCreateResultClass = "unknown"
PreCreateResultNotSent PreCreateResultClass = "not_sent"
)
// ClassifyPreCreateResult 统一判断预下单结果及自动禁用资格。
func ClassifyPreCreateResult(err error, sent bool) PreCreateResultClass {
if err == nil {
return PreCreateResultSuccess
}
if !sent {
return PreCreateResultNotSent
}
if !ShouldDisableMerchant(err) {
return PreCreateResultUnknown
}
return PreCreateResultExplicitFailed
}
// ShouldDisableMerchant 仅在没有结果未知信号且错误代表渠道明确拒绝时返回 true。
// 上下文取消、超时、连接中断、网络传输错误和响应无法确认均不得禁用商户。
func ShouldDisableMerchant(err error) bool {
if err == nil {
return false
}
if stderrors.Is(err, context.Canceled) || stderrors.Is(err, context.DeadlineExceeded) {
return false
}
var networkErr net.Error
if stderrors.As(err, &networkErr) {
return false
}
var appErr *errors.AppError
if !stderrors.As(err, &appErr) || appErr == nil {
return false
}
if appErr.Code == errors.CodeTimeout || appErr.Code == errors.CodeServiceUnavailable || appErr.Code == errors.CodeNoPaymentConfig {
return false
}
message := strings.ToLower(appErr.Error())
for _, marker := range []string{"timeout", "deadline", "context canceled", "connection", "transport", "network", "unknown", "结果未知", "无法确认", "连接中断"} {
if strings.Contains(message, marker) {
return false
}
}
return appErr.Code == errors.CodeWechatPayFailed || appErr.Code == errors.CodeFuiouPayFailed
}

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