22 Commits

Author SHA1 Message Date
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
421 changed files with 38238 additions and 2299 deletions

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 索引拆成完整业务能力;拆分后必须同步本表链接。

View File

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

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,6 +28,17 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)

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,6 +37,17 @@ func generateAdminDocs(outputPath string) error {
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)

View File

@@ -16,8 +16,10 @@ 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"
@@ -27,13 +29,16 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
commissionDelivery "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop"
walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
@@ -89,6 +94,10 @@ type workerRuntime struct {
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
refundChannelService *refundchannelApp.Service
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
carrierThresholdService *carrierThresholdApp.Service
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
assetAutoRenewalService *assetAutoRenewalApp.Service
}
func main() {
@@ -161,6 +170,9 @@ func runWorker(cfg *config.Config) {
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger)
registerAssetAutoRenewalTasks(taskHandler.GetMux(), runtime, appLogger)
registerRefundCommissionRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
@@ -287,16 +299,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
pollingIotCardStore := postgres.NewIotCardStore(db, redisClient)
pollingBase := task.NewPollingBase(
redisClient,
pollingQueueMgr,
pollingConfigMgr,
pollingIotCardStore,
appLogger,
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
lifecycleSvc := polling.NewPollingLifecycleService(
@@ -307,9 +309,25 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingDeviceStore,
appLogger,
)
pollingBase := task.NewPollingBase(
redisClient,
pollingQueueMgr,
pollingConfigMgr,
pollingIotCardStore,
lifecycleSvc,
postgres.NewPollingPriorityItemStore(db),
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
db,
appLogger,
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
if workerResult.Services.CarrierThreshold == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
runtime := &workerRuntime{
redisAddr: redisAddr,
@@ -327,14 +345,69 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingIotCardStore: pollingIotCardStore,
pollingBase: pollingBase,
lifecycleSvc: lifecycleSvc,
// 通道阈值停复机复用既有停复机服务作为唯一执行事实源重试、Integration Log、统一审计与既有判定
carrierThresholdService: workerResult.Services.CarrierThreshold,
// 自动续费的复机执行同样复用既有停复机单一事实源。
assetAutoRenewalService: workerResult.Services.AssetAutoRenewal,
}
registerNotificationOutboxConsumer(runtime, appLogger)
registerWalletOutboxConsumer(runtime, appLogger)
registerCardObservationOutboxConsumer(runtime, appLogger)
registerPriorityPollingOutboxConsumer(runtime, appLogger)
registerCarrierThresholdOutboxConsumer(runtime, appLogger)
registerAssetAutoRenewalOutboxConsumer(runtime, appLogger)
registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger)
return runtime
}
// registerPriorityPollingOutboxConsumer 注册卡轮询优先队列请求事件消费者。
// 消费者按卡 × 纳入任务类型建立或合并优先项并下发执行提示;重复投递只合并。
func registerPriorityPollingOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
priorityStore := postgres.NewPollingPriorityItemStore(runtime.db)
consumer := prioritypollingInfra.NewPriorityRequestedConsumer(
runtime.db,
priorityStore,
runtime.pollingQueueMgr,
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
appLogger,
)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypePollingPriorityRequested, consumer); err != nil {
appLogger.Fatal("注册卡轮询优先队列 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypePollingPriorityRequested), zap.Error(err))
}
}
// registerCarrierThresholdOutboxConsumer 注册运营商通道流量阈值停复机事件消费者。
// 两个事件类型共用同一消费者实现:停机与复机各自由锁行认领字段保证至多一次外部调用。
func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.carrierThresholdService == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
consumer := carrierThresholdApp.NewConsumer(runtime.carrierThresholdService)
for _, eventType := range []string{
carrierThresholdApp.EventCarrierThresholdStop,
carrierThresholdApp.EventCarrierThresholdResume,
} {
if err := runtime.outboxConsumers.Register(eventType, consumer); err != nil {
appLogger.Fatal("注册运营商通道流量阈值 Outbox 消费者失败",
zap.String("event_type", eventType), zap.Error(err))
}
}
}
// registerAssetAutoRenewalOutboxConsumer 注册资产钱包自动续费的复机请求事件消费者。
// 消费者按尝试记录认领执行权后执行复机,重复投递不会产生第二次外部调用。
func registerAssetAutoRenewalOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.assetAutoRenewalService == nil {
appLogger.Fatal("资产钱包自动续费用例未配置")
}
consumer := assetAutoRenewalApp.NewResumeConsumer(runtime.assetAutoRenewalService)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAssetAutoRenewalResumeRequested, consumer); err != nil {
appLogger.Fatal("注册资产钱包自动续费 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAssetAutoRenewalResumeRequested), zap.Error(err))
}
}
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
@@ -499,6 +572,17 @@ 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 注册渠道原路退款结果恢复任务。
// 该任务只查询渠道并回填结果,绝不重复发起资金动作。
// 必须复用执行路径的同一用例实例:恢复确认的成功同样需要补写退款完成通知。
@@ -511,6 +595,34 @@ func registerRefundChannelRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
appLogger.Info("注册渠道原路退款结果恢复任务处理器", zap.String("task_type", constants.TaskTypeRefundChannelRecovery))
}
// registerCarrierThresholdTasks 注册运营商通道流量阈值的周期处理与结果恢复任务。
// 两个任务共用同一用例实例:周期处理负责跨期解锁与条件复机,恢复扫描只查询状态回填,绝不重复发起停复机。
func registerCarrierThresholdTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.carrierThresholdService == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
cycleHandler := carrierThresholdInfra.NewCycleTaskHandler(runtime.carrierThresholdService)
mux.HandleFunc(constants.TaskTypeCarrierThresholdCycle, cycleHandler.Handle)
appLogger.Info("注册运营商通道流量阈值周期处理任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdCycle))
recoveryHandler := carrierThresholdInfra.NewRecoveryTaskHandler(runtime.carrierThresholdService)
mux.HandleFunc(constants.TaskTypeCarrierThresholdRecovery, recoveryHandler.Handle)
appLogger.Info("注册运营商通道流量阈值结果恢复任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdRecovery))
}
// registerAssetAutoRenewalTasks 注册资产钱包自动续费的每日扫描与复机结果恢复任务。
// 两个任务共用同一用例实例:每日扫描负责终态收敛与续购执行,恢复扫描只查询状态回填,绝不重复发起复机。
func registerAssetAutoRenewalTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.assetAutoRenewalService == nil {
appLogger.Fatal("资产钱包自动续费用例未配置")
}
scanHandler := assetAutoRenewalInfra.NewDailyScanTaskHandler(runtime.assetAutoRenewalService)
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalScan, scanHandler.Handle)
appLogger.Info("注册资产钱包自动续费每日扫描任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalScan))
recoveryHandler := assetAutoRenewalInfra.NewRecoveryTaskHandler(runtime.assetAutoRenewalService)
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalRecovery, recoveryHandler.Handle)
appLogger.Info("注册资产钱包自动续费复机结果恢复任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalRecovery))
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
@@ -785,6 +897,36 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); 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,
@@ -832,6 +974,34 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 * * *", asynq.NewTask(
constants.TaskTypePackageTrafficAlertScan,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageTrafficAlertScan)),
)); err != nil {
return fmt.Errorf("注册每日套餐真流量达量预警扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 7 * * *", asynq.NewTask(
constants.TaskTypeAssetAutoRenewalScan,
nil,
asynq.MaxRetry(3),
asynq.Timeout(30*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalScan)),
)); err != nil {
return fmt.Errorf("注册每日资产钱包自动续费扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 5m", asynq.NewTask(
constants.TaskTypeAssetAutoRenewalRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(5*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalRecovery)),
)); err != nil {
return fmt.Errorf("注册资产钱包自动续费复机结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register(
"0 2 * * *",
asynq.NewTask(
@@ -844,6 +1014,21 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
); 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
}
@@ -930,6 +1115,44 @@ func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Han
func rescuePendingImportTasks(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
rescuePendingIotCardImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
rescuePendingDeviceImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
rescuePendingShopBusinessOwnerImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
rescuePendingPhoneAssetUnbindImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
}
// rescuePendingShopBusinessOwnerImportTasks 补偿仍停留在待处理状态的店铺负责人导入任务。
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与设备导入补偿互不影响。
func rescuePendingShopBusinessOwnerImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
var importTasks []model.ShopBusinessOwnerImportTask
if err := db.WithContext(ctx).
Where("status = ?", model.ImportTaskStatusPending).
Limit(importRescueLimit).
Find(&importTasks).Error; err != nil {
appLogger.Warn("扫描待补偿店铺负责人导入任务失败", zap.Error(err))
return
}
for _, importTask := range importTasks {
payload := task.ShopBusinessOwnerImportPayload{TaskID: importTask.ID}
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypeShopBusinessOwnerImport, payload, importTask.ID, appLogger)
}
}
// rescuePendingPhoneAssetUnbindImportTasks 补偿仍停留在待处理状态的手机号资产解绑导入任务。
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与其他导入补偿互不影响。
func rescuePendingPhoneAssetUnbindImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
var importTasks []model.PhoneAssetUnbindImportTask
if err := db.WithContext(ctx).
Where("status = ?", model.ImportTaskStatusPending).
Limit(importRescueLimit).
Find(&importTasks).Error; err != nil {
appLogger.Warn("扫描待补偿手机号资产解绑导入任务失败", zap.Error(err))
return
}
for _, importTask := range importTasks {
payload := task.PhoneAssetUnbindImportPayload{TaskID: importTask.ID}
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypePhoneAssetUnbindImport, payload, importTask.ID, appLogger)
}
}
// rescuePendingIotCardImportTasks 补偿仍停留在待处理状态的 IoT 卡导入任务。

View File

@@ -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
@@ -313,3 +326,30 @@
- **Owner**:基础设施负责人
- **最后验证日期**2026-09-08
- **更新触发条件**测试库、Redis DB、部署分支、测试主机或验证授权变化
## KNOWN-ISSUE-001
- **状态**:已知缺陷,待修复(当前不阻塞归档;标签功能未启用时无实际影响)
- **适用范围**`internal/service/exchange/migration.go` 的标签复制步骤(换货业务数据迁移的「资产标签」迁移项)
- **问题**:标签复制使用 `clause.OnConflict{Columns: [resource_type, resource_id, tag_id], DoNothing: true}`,未声明 `tb_resource_tag` 上部分唯一索引 `idx_resource_tag_unique``... WHERE deleted_at IS NULL`的谓词PostgreSQL 返回 `42P10`
- **理由**:旧资产存在任意 `tb_resource_tag` 行且换货请求要求迁移时,标签步骤必然失败,导致换货完成整体回滚、迁移状态落 `failed`,「已迁移」在该情形不可达。记录于此以便由独立变更修复,避免在其它任务中顺手改动迁移项。
- **证据**2026-09-14 在 `junhong_cmp_test``tb_audit_event` 实测 4 条 `action_code=exchange.card.complete``result=failed``error_code=1206``error_summary``复制资产标签失败 ... SQLSTATE 42P10``tb_resource_tag` 当前 0 行,故静态库状态下不可观测。该文件自 `add-exchange-data-migration-status` 起未修改md5 与 `git show HEAD` 一致)。
- **例外条件**:标签功能未启用(`tb_resource_tag` 为空)时无实际影响;不影响钱包余额、有效套餐使用记录、累计充值字段、资产归属与个人客户—资产绑定,也不影响无标签资产的换货完成。
- **修复方式**:为该 `OnConflict` 声明部分索引谓词(或调整索引),须另立 OpenSpec Change当前按维护者决策暂不修复仅登记待办。
- **Owner**:数据负责人
- **最后验证日期**2026-09-14
- **更新触发条件**:标签功能启用、换货迁移项变更或该缺陷修复
## KNOWN-ISSUE-002
- **状态**:已登记的边界,非缺陷(当前不阻塞归档)
- **适用范围**:卡轮询优先队列的三处实现边界:①「资产无有效套餐」的触发范围;②认领接缝探测失败的失败方向;③未被接缝使用的批量探测方法
- **问题**
- 边界一(无有效套餐触发范围):`no_valid_package` 只在既有判定点——`internal/service/iot_card/stop_resume_service.go:354` 的「条件B无有效套餐」被加入停机原因列表时——才会追加触发调用点 `internal/service/iot_card/stop_resume_service.go:123`,实现 `:209`)。已经处于停机状态的卡走 `EvaluateAndAct` 的离线分支,不再重新判定「无有效套餐」,因此停机卡不会被该场景加急。
- 边界二(认领接缝探测失败的失败方向):认领接缝的 `FindActive` 探测失败采用 fail-closed——四个轮询 Handler 按既有失败分支记为轮询失败并按既有间隔延后(`internal/task/polling_priority_claim.go:56` 返回错误;`internal/task/polling_realname_handler.go:47``internal/task/polling_carddata_handler.go:55``internal/task/polling_cardstatus_handler.go:56``internal/task/polling_package_handler.go:59` 各对应分支carddata 连同放弃当轮流量同步)。
- 边界三(未被使用的批量探测方法):`internal/store/postgres/polling_priority_item_store.go:148-149``ListActiveByCards` 当前没有任何调用方(`grep -rn "ListActiveByCards" internal/` 只命中定义本身)。
- **理由**:边界一——行为契约的 WHEN 以「普通套餐轮询判定资产无有效套餐」为前提(该 Scenario 见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md:31`停机卡不发生该判定故当前实现不违反契约把它扩展到停机卡属于新的产品口径。边界二——fail-open 会在数据库抖动时让同一卡同一任务类型同时出现两次上游调用,破坏本能力「至多一次上游调用」的 MUST失败方向必须偏保守。边界三——`tasks.md` 1.4 的执行契约要求存储层提供「按卡批量活动项查询」,先交付后使用。
- **证据**:见上述各边界的 `文件:行`;边界三的调用方检索当时结果为空(仅定义处命中)。
- **例外条件**:边界一——若产品要求停机卡也因「无有效套餐」获得加急,须先由产品确认收窄该 Scenario 的措辞,再以独立 Change 扩展触发范围。边界二——数据库连接正常时不会触发该失败方向,普通轮询的并发上限与无限重入队策略不受影响。
- **修复方式**:边界一——产品确认后另立 Change在此之前不改判定点。边界二——不修复刻意设计如未来引入可信的「探测能力不可用」信号可在该信号下单独放行并接受重复调用风险须重新评审。边界三——若认领接缝改为批量探测则启用否则按归档后的清理流程删除不长期保留未使用的方法。
- **Owner**:异步任务负责人
- **最后验证日期**2026-09-16
- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测

View File

@@ -196,6 +196,8 @@
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
> **AUG26-016 实施口径标注2026-09-16**:上文本条中的「普通轮询异常补偿」**本版不实现**。现状该场景没有任何可判定条件(轮询失败按配置间隔无限重入队,无失败上限、无补偿入口),实现等于自造判据;且渠道故障时全量失败卡涌入优先通道、提示通道不受分片背压约束,风险不对称。本版只实现其余四类场景,该场景登记为后续独立 Change需产品确认后另行实施依据见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md` 的「后续候选」)。本条其余文字与其余场景的确认内容不变。
## 2.16 已确认的导出、时间筛选与代理自充收款方式基线
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
@@ -206,7 +208,11 @@
## 2.17 已确认的报表基线
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。功能上线后每日生成稳定日报快照;上线前日期不提供报表或明确显示无快照数据,不回填历史。累计激活设备严格采用任一当前关联卡已实名的口径;每日快照中的累计在网设备为已实名且存在有效主套餐的设备,活跃设备为该套餐周期内任一卡真流量大于零的设备,用量为设备当前套餐周期内全部关联卡真流量之和。报表可选择设备名称、型号、制造商、用户组、代理、店铺、业务员中的一个分组维度;未选择时汇总为一行。套餐续费按资产去重,统计期内有主套餐到期的资产计一次到期,至少成功续购一次主套餐计一次续费,续费率不超过 100%。日报快照冻结当天店铺、业务员及用户组归属。后端提供日/月趋势汇总数据和异步导出,图表渲染由前端负责。
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。
> **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 基线

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -529,7 +529,8 @@
"entry": "GET /api/admin/carriers",
"capability": "external-integration",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"carrier-channel-traffic-threshold::通道阈值配置、权限与审计"
],
"classification": "behavior"
},
@@ -538,7 +539,8 @@
"entry": "GET /api/admin/carriers/{id}",
"capability": "external-integration",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"carrier-channel-traffic-threshold::通道阈值配置、权限与审计"
],
"classification": "behavior"
},
@@ -711,7 +713,8 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::换货业务数据迁移状态与失败恢复"
],
"classification": "behavior"
},
@@ -721,7 +724,8 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::换货业务数据迁移状态与失败恢复"
],
"classification": "behavior"
},
@@ -1164,7 +1168,8 @@
"entry": "GET /api/admin/shops",
"capability": "identity-access",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"business-user-group::店铺负责人和所属组实时推导"
],
"classification": "behavior"
},
@@ -1201,7 +1206,8 @@
"entry": "GET /api/admin/shops/{id}",
"capability": "identity-access",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"business-user-group::店铺负责人和所属组实时推导"
],
"classification": "behavior"
},
@@ -1223,6 +1229,16 @@
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/shops/{shop_id}/commission-records/{id}",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::回溯明细关联查询与导出",
"identity-access::数据范围拒绝"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/shops/{shop_id}/commission-stats",
@@ -1851,7 +1867,8 @@
"entry": "POST /api/admin/carriers",
"capability": "external-integration",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"carrier-channel-traffic-threshold::通道阈值配置、权限与审计"
],
"classification": "behavior"
},
@@ -2065,7 +2082,8 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::换货业务数据迁移状态与失败恢复"
],
"classification": "behavior"
},
@@ -2085,7 +2103,9 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::换货业务数据迁移状态与失败恢复",
"order-refund-exchange::换货业务数据迁移范围"
],
"classification": "behavior"
},
@@ -2105,7 +2125,8 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"order-refund-exchange::换货业务数据迁移状态与失败恢复"
],
"classification": "behavior"
},
@@ -2124,7 +2145,8 @@
"capability": "export-task",
"requirements": [
"export-task::导出任务终态",
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"order-refund-exchange::换货导出业务数据迁移状态"
],
"classification": "behavior"
},
@@ -2584,7 +2606,9 @@
"capability": "personal-customer",
"requirements": [
"identity-access::令牌生命周期",
"personal-customer::个人客户身份"
"personal-customer::个人客户身份",
"phone-asset-association::H5 验证建立关联与数量上限",
"phone-asset-association::H5 绑定与换绑的关联写入"
],
"classification": "behavior"
},
@@ -2594,7 +2618,8 @@
"capability": "personal-customer",
"requirements": [
"identity-access::令牌生命周期",
"personal-customer::个人客户身份"
"personal-customer::个人客户身份",
"phone-asset-association::H5 绑定与换绑的关联写入"
],
"classification": "behavior"
},
@@ -2624,7 +2649,8 @@
"capability": "personal-customer",
"requirements": [
"identity-access::令牌生命周期",
"personal-customer::个人客户身份"
"personal-customer::个人客户身份",
"phone-asset-association::登录提示判定"
],
"classification": "behavior"
},
@@ -2644,7 +2670,8 @@
"capability": "personal-customer",
"requirements": [
"identity-access::令牌生命周期",
"personal-customer::个人客户身份"
"personal-customer::个人客户身份",
"phone-asset-association::登录提示判定"
],
"classification": "behavior"
},
@@ -2654,7 +2681,8 @@
"capability": "personal-customer",
"requirements": [
"identity-access::令牌生命周期",
"personal-customer::个人客户身份"
"personal-customer::个人客户身份",
"phone-asset-association::登录提示判定"
],
"classification": "behavior"
},
@@ -2908,7 +2936,8 @@
"entry": "PUT /api/admin/carriers/{id}",
"capability": "external-integration",
"requirements": [
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"carrier-channel-traffic-threshold::通道阈值配置、权限与审计"
],
"classification": "behavior"
},
@@ -3308,6 +3337,17 @@
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.OutboxEventTypePollingPriorityRequested",
"capability": "infrastructure",
"requirements": [
"priority-polling-queue::优先轮询触发场景与入队事实",
"priority-polling-queue::入队执行对象为资产当前在用卡",
"priority-polling-queue::同卡同任务类型的唯一活动项与合并"
],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAgentRechargeRecovery",
@@ -3408,7 +3448,9 @@
"entry_type": "async",
"entry": "constants.TaskTypeDeviceImport",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"phone-asset-association::关联不影响换货与其他写入路径"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3455,7 +3497,9 @@
"entry_type": "async",
"entry": "constants.TaskTypeIotCardImport",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"phone-asset-association::关联不影响换货与其他写入路径"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3525,7 +3569,12 @@
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
"polling-load-control::业务变化与轮询异常仍可追溯",
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
"priority-polling-queue::优先调度与执行提示通道",
"priority-polling-queue::优先项认领、租约与执行前校验",
"priority-polling-queue::优先项尝试次数与出队",
"priority-polling-queue::与运营商通道阈值停机的边界"
],
"classification": "behavior"
},
@@ -3539,7 +3588,12 @@
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
"polling-load-control::业务变化与轮询异常仍可追溯",
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
"priority-polling-queue::优先调度与执行提示通道",
"priority-polling-queue::优先项认领、租约与执行前校验",
"priority-polling-queue::优先项尝试次数与出队",
"priority-polling-queue::与运营商通道阈值停机的边界"
],
"classification": "behavior"
},
@@ -3550,7 +3604,12 @@
"requirements": [
"polling-operations::手工轮询任务状态",
"polling-load-control::轮询全局并发与背压",
"polling-operations::停复机遵循实际生效实名策略"
"polling-operations::停复机遵循实际生效实名策略",
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
"priority-polling-queue::优先调度与执行提示通道",
"priority-polling-queue::优先项认领、租约与执行前校验",
"priority-polling-queue::优先项尝试次数与出队",
"priority-polling-queue::与运营商通道阈值停机的边界"
],
"classification": "behavior"
},
@@ -3574,10 +3633,27 @@
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
"polling-load-control::业务变化与轮询异常仍可追溯",
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
"priority-polling-queue::优先调度与执行提示通道",
"priority-polling-queue::优先项认领、租约与执行前校验",
"priority-polling-queue::优先项尝试次数与出队",
"priority-polling-queue::与运营商通道阈值停机的边界"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeRefundCommissionRecovery",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::退款佣金回扣可靠完成",
"agent-funds-commission::退款后处理可补偿",
"agent-funds-commission::套餐退款佣金回溯",
"order-refund-exchange::退款终态事实与失败分类"
],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeWeComApprovalRecovery",
@@ -3999,5 +4075,490 @@
"agent-distribution-withdrawal::提现冻结与企业微信终审"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/business-user-groups",
"capability": "business-user-group",
"requirements": [
"business-user-group::业务用户组生命周期与成员归属"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/business-user-groups",
"capability": "business-user-group",
"requirements": [
"business-user-group::业务用户组生命周期与成员归属"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/business-user-groups/{id}",
"capability": "business-user-group",
"requirements": [
"business-user-group::业务用户组生命周期与成员归属"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/business-user-groups/{id}",
"capability": "business-user-group",
"requirements": [
"business-user-group::业务用户组生命周期与成员归属"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "DELETE /api/admin/business-user-groups/{id}",
"capability": "business-user-group",
"requirements": [
"business-user-group::业务用户组生命周期与成员归属"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/business-user-groups/{id}/members",
"capability": "business-user-group",
"requirements": [
"business-user-group::业务用户组生命周期与成员归属"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "DELETE /api/admin/business-user-groups/members",
"capability": "business-user-group",
"requirements": [
"business-user-group::业务用户组生命周期与成员归属"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/shops/business-owner/batch",
"capability": "business-user-group",
"requirements": [
"business-user-group::店铺负责人批量交接",
"identity-access::数据范围拒绝"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/shops/business-owner-imports",
"capability": "business-user-group",
"requirements": [
"business-user-group::店铺负责人 CSV 导入"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/shops/business-owner-imports",
"capability": "business-user-group",
"requirements": [
"business-user-group::店铺负责人 CSV 导入"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/shops/business-owner-imports/{id}",
"capability": "business-user-group",
"requirements": [
"business-user-group::店铺负责人 CSV 导入"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeShopBusinessOwnerImport",
"capability": "infrastructure",
"requirements": [
"business-user-group::店铺负责人 CSV 导入"
],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "http",
"entry": "GET /api/admin/phone-asset-associations",
"capability": "phone-asset-association",
"requirements": [
"phone-asset-association::后台查看关联"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/phone-asset-associations/batch-unbind",
"capability": "phone-asset-association",
"requirements": [
"phone-asset-association::后台解除关联"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/phone-asset-associations/unbind-imports",
"capability": "phone-asset-association",
"requirements": [
"phone-asset-association::后台解除关联"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/phone-asset-associations/unbind-imports",
"capability": "phone-asset-association",
"requirements": [
"phone-asset-association::后台解除关联"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/phone-asset-associations/unbind-imports/{id}",
"capability": "phone-asset-association",
"requirements": [
"phone-asset-association::后台解除关联",
"phone-asset-association::后台查看关联"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "DELETE /api/admin/phone-asset-associations/{id}",
"capability": "phone-asset-association",
"requirements": [
"phone-asset-association::后台解除关联"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypePhoneAssetUnbindImport",
"capability": "phone-asset-association",
"requirements": [
"phone-asset-association::后台解除关联"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/h5-popup-configurations",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::运营弹窗实时匹配"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/h5-popup-configurations",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::运营弹窗实时匹配"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/h5-popup-configurations/{id}",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::运营弹窗实时匹配"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/h5-popup-configurations/{id}",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::运营弹窗实时匹配"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/h5-popup-configurations/{id}/enable",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::运营弹窗实时匹配"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/h5-popup-configurations/{id}/disable",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::运营弹窗实时匹配"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/c/v1/popup-candidates",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::风险换卡候选与地址提交",
"h5-popup-notification::运营弹窗实时匹配",
"h5-popup-notification::通知留存与已读"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/c/v1/risk-exchanges/{asset_id}/address",
"capability": "h5-popup-notification",
"requirements": [
"h5-popup-notification::风险换卡候选与地址提交"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/package-traffic-alert-rules",
"capability": "package-traffic-alert",
"requirements": [
"package-traffic-alert::真流量预警规则"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/package-traffic-alert-rules",
"capability": "package-traffic-alert",
"requirements": [
"package-traffic-alert::真流量预警规则"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/package-traffic-alert-rules/{id}",
"capability": "package-traffic-alert",
"requirements": [
"package-traffic-alert::真流量预警规则"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/package-traffic-alerts",
"capability": "package-traffic-alert",
"requirements": [
"package-traffic-alert::预警查询与导出"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/package-traffic-alerts/{id}",
"capability": "package-traffic-alert",
"requirements": [
"package-traffic-alert::预警查询与导出"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/package-traffic-alerts/export",
"capability": "package-traffic-alert",
"requirements": [
"package-traffic-alert::预警查询与导出"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypePackageTrafficAlertScan",
"capability": "package-traffic-alert",
"requirements": [
"package-traffic-alert::有效套餐与汇总口径",
"package-traffic-alert::去重与补建",
"package-traffic-alert::预警通知",
"package-traffic-alert::与运营商通道阈值分离"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeCarrierThresholdCycle",
"capability": "carrier-channel-traffic-threshold",
"requirements": [
"carrier-channel-traffic-threshold::通道阈值停机与周期恢复"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeCarrierThresholdRecovery",
"capability": "carrier-channel-traffic-threshold",
"requirements": [
"carrier-channel-traffic-threshold::通道阈值停机与周期恢复"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/polling-priority-items",
"capability": "polling-operations",
"requirements": [
"identity-access::数据范围拒绝",
"priority-polling-queue::人工优先入队不受人工触发防滥用配额约束",
"priority-polling-queue::优先轮询事实与查询的可追溯"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/polling-priority-items",
"capability": "polling-operations",
"requirements": [
"identity-access::数据范围拒绝",
"priority-polling-queue::优先轮询事实与查询的可追溯"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/polling-priority-items/{id}",
"capability": "polling-operations",
"requirements": [
"identity-access::数据范围拒绝",
"priority-polling-queue::优先轮询事实与查询的可追溯"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.OutboxEventTypeAssetAutoRenewalResumeRequested",
"capability": "asset-auto-renewal",
"requirements": [
"asset-auto-renewal::成功后的可靠复机"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAssetAutoRenewalScan",
"capability": "asset-auto-renewal",
"requirements": [
"asset-auto-renewal::自动续费配置与权限",
"asset-auto-renewal::每日扫描与续购资格",
"asset-auto-renewal::每日一次尝试与尝试记录终态收敛",
"asset-auto-renewal::资金、价格与单事务闭合",
"asset-auto-renewal::失败通知与接收人",
"asset-auto-renewal::尝试记录与可追溯字段",
"asset-auto-renewal::手动续购优先",
"asset-auto-renewal::不得因钱包余额跳过停机判定"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAssetAutoRenewalRecovery",
"capability": "asset-auto-renewal",
"requirements": [
"asset-auto-renewal::成功后的可靠复机"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/expiring-assets/export",
"capability": "export-time-filter",
"requirements": [
"export-time-filter::统一时间筛选参数与解析契约",
"export-time-filter::受影响端点与旧格式替换",
"export-time-filter::临期导出",
"export-time-filter::三类异步导出的创建期冻结与不扩大范围"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/activation-summary",
"capability": "operations-report",
"requirements": [
"operations-report::激活情况指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/activation-trend",
"capability": "operations-report",
"requirements": [
"operations-report::激活情况指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/operations-reports/activation-summary/export",
"capability": "operations-report",
"requirements": [
"operations-report::激活情况指标口径",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/package-renewal-summary",
"capability": "operations-report",
"requirements": [
"operations-report::套餐续费指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/package-renewal-trend",
"capability": "operations-report",
"requirements": [
"operations-report::套餐续费指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/operations-reports/package-renewal-summary/export",
"capability": "operations-report",
"requirements": [
"operations-report::套餐续费指标口径",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeOperationsReportSnapshot",
"capability": "operations-report",
"requirements": [
"operations-report::每日报表快照与不回填历史"
],
"classification": "behavior"
}
]

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,10 +17,12 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。
// 校验成功即消费验证码,同一验证码不可二次使用。
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码接缝。
// CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费,
// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。
type VerificationCodeVerifier interface {
VerifyCode(ctx context.Context, phone string, code string) error
CheckCode(ctx context.Context, phone string, code string) error
ConsumeCode(ctx context.Context, phone string, code string) error
}
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。

View File

@@ -2,6 +2,8 @@ package distributionwithdrawal
import (
"context"
stderrors "errors"
"slices"
"strconv"
"strings"
@@ -45,8 +47,11 @@ func NewRegistrationService(
}
// Register 创建待审批注册记录。
// 无效分销码停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误
// 分销码无效、上级店铺停用上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取
// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码;
// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。
func (s *RegistrationService) Register(
ctx context.Context,
input distributiondomain.RegistrationInput,
@@ -61,24 +66,19 @@ func (s *RegistrationService) Register(
}
code = strings.TrimSpace(code)
if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
return nil, errors.New(errors.CodeInvalidParam, "短信验证码不能为空")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
}
var parent *model.Shop
if err := s.db.WithContext(ctx).
Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled).
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
parent, err := s.findDistributionParent(ctx, normalized.DistributionCode)
if err != nil {
return nil, err
}
// 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果
if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
// 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码
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 {
@@ -107,6 +107,17 @@ func (s *RegistrationService) Register(
}
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, "创建待审批注册记录失败")
}
@@ -132,9 +143,123 @@ func (s *RegistrationService) Register(
if err != nil {
return nil, err
}
// 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。
// 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。
_ = s.verifier.ConsumeCode(ctx, normalized.Phone, code)
return result, nil
}
// registrationKeyScopePrefix 是注册关键字段串行化点的键前缀,与其它用例的 advisory lock 键空间隔离。
const registrationKeyScopePrefix = "agent-distribution-registration:"
// lockRegistrationKeyScopes 在事务内为注册关键字段(手机号、用户名、店铺编号)取稳定串行化点。
// 目标关键字段的待审批记录可能尚不存在,行锁无法覆盖「首次并发提交」,
// 因此按 key 字符串升序取事务级 advisory lock升序保证并发提交不会形成 A→B / B→A 死锁环。
// 锁随本次事务提交或回滚自动释放。
func lockRegistrationKeyScopes(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
keys := []string{
registrationKeyScopePrefix + "phone:" + phone,
registrationKeyScopePrefix + "username:" + username,
registrationKeyScopePrefix + "shop_code:" + shopCode,
}
slices.Sort(keys)
for _, key := range keys {
if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定注册关键字段串行化点失败")
}
}
return nil
}
// ensureRegistrationKeysAvailable 校验注册关键字段未被既有账号或店铺占用。
// 手机号与用户名对应 tb_account 的条件唯一索引,店铺编号对应 tb_shop 的条件唯一索引;
// 查询沿用 GORM 默认软删除范围,软删除账号或店铺占用的关键字段可被重新注册。
func ensureRegistrationKeysAvailable(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "phone", phone); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册手机号失败")
} else if exists {
return errors.New(errors.CodePhoneExists, "手机号已被使用")
}
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "username", username); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册用户名失败")
} else if exists {
return errors.New(errors.CodeUsernameExists, "用户名已存在")
}
if exists, err := registrationKeyTaken(ctx, tx, &model.Shop{}, "shop_code", shopCode); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册店铺编号失败")
} else if exists {
return errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
}
return nil
}
// registrationKeyTaken 判断目标表(默认软删除范围)是否已存在占用该关键字段的记录。
func registrationKeyTaken(ctx context.Context, tx *gorm.DB, target any, column, value string) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(target).Where(column+" = ?", value).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// ensureNoPendingRegistration 校验关键字段没有正在等待审批的注册申请。
// 已通过或已驳回的终态记录不阻塞重新注册:资料填错后重新扫码必须能形成新的申请与新审批实例。
func ensureNoPendingRegistration(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
var pending model.AgentDistributionRegistration
err := tx.WithContext(ctx).
Where("status = ? AND (phone = ? OR username = ? OR shop_code = ?)",
constants.AgentDistributionRegistrationStatusPending, phone, username, shopCode).
Order("id ASC").First(&pending).Error
switch {
case err == nil:
return pendingKeyConflict(&pending, phone, username)
case stderrors.Is(err, gorm.ErrRecordNotFound):
return nil
default:
return errors.Wrap(errors.CodeDatabaseError, err, "校验待审批注册申请失败")
}
}
// pendingKeyConflict 把命中的待审批记录映射为指明冲突字段的冲突错误。
// 查询条件保证三个关键字段至少一个命中,店铺编号作为兜底分支。
func pendingKeyConflict(pending *model.AgentDistributionRegistration, phone, username string) error {
switch {
case pending.Phone == phone:
return errors.New(errors.CodeConflict, "该手机号已有待审批的注册申请,请等待审批结果")
case pending.Username == username:
return errors.New(errors.CodeConflict, "该用户名已有待审批的注册申请,请等待审批结果")
default:
return errors.New(errors.CodeConflict, "该店铺编号已有待审批的注册申请,请等待审批结果")
}
}
// findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。
// 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。
func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) {
var parent model.Shop
if err := s.db.WithContext(ctx).
Where("distribution_code = ?", distributionCode).
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码无效")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
}
if parent.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "分销码所属店铺已停用")
}
return &parent, nil
}
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
func verificationFailure(err error) error {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
}
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
@@ -144,7 +269,7 @@ func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
}

View File

@@ -89,7 +89,10 @@ func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approval
}
// applyApproved 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
// 上级店铺必须仍然存在且启用;手机号用户名已被并发注册占用时整体回滚,不留半套实体。
// 上级店铺必须仍然存在且启用;手机号用户名或店铺编号已被既有账号/店铺占用时整体回滚,不留半套实体。
// 审批路径不取提交侧的关键字段 advisory lock提交侧的校验与插入同处一个串行化区间
// 且账号/店铺写入与注册记录状态推进同事务提交,因此提交侧只会看到「已提交的账号/店铺」或「仍待审批的冲突记录」,
// 两种情况都会拒绝。
func (h *DistributionApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
@@ -104,6 +107,11 @@ func (h *DistributionApprovalHandler) applyApproved(
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,246 @@
// Package operationsreport 实现运营报表日报快照的生成用例。
//
// 生成 = 读取生成时刻的只读事实(设备、当前有效关联卡、套餐使用记录、店铺与业务员、用户组、
// 套餐与套餐系列)→ 按报表口径域组装三张快照表的行与头行 → 在单事务内整日替换该日全部行并校验不变量。
// 用例本身不依赖 GORM、Fiber、Redis 或 Asynq读写分别由 FactsReader 与 SnapshotWriter 端口提供。
package operationsreport
import (
"context"
"time"
"go.uber.org/zap"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DeviceActivationFact 是一台未删除设备在生成时刻的冻结事实。
//
// 归属(店铺、业务员、用户组与代理的两个取值)是生成时刻取值,之后不再重新解析:
// 历史快照行不因后续归属变化被改写。
type DeviceActivationFact struct {
DeviceID uint
VirtualNo string
DeviceName string
DeviceModel string
Manufacturer string
ShopID *uint
ShopName string
RootShopID *uint
RootShopName string
AgentAccountID *uint
AgentAccountName string
BusinessOwnerAccountID *uint
BusinessOwnerName string
BusinessUserGroupID *uint
BusinessUserGroupName string
Purchased bool
Realnamed bool
Online bool
Active bool
RealTrafficMB float64
}
// RenewalFact 是一条到期事实及其续费判定结果在生成时刻的冻结事实。
type RenewalFact struct {
AssetType string
AssetID uint
AssetIdentifier string
ExpiredUsageID uint
PackageID uint
PackageName string
SeriesID *uint
SeriesName string
ShopID *uint
ShopName string
RootShopID *uint
RootShopName string
AgentAccountID *uint
AgentAccountName string
BusinessOwnerAccountID *uint
BusinessOwnerName string
BusinessUserGroupID *uint
BusinessUserGroupName string
Renewed bool
}
// DayFacts 是一个快照日的全部源事实。
type DayFacts struct {
Devices []DeviceActivationFact
Renewals []RenewalFact
}
// FactsReader 读取生成一份日报快照所需的只读事实。
type FactsReader interface {
// CountUndeletedDevicesAsOf 是采购数量口径的取值端口,只被 domain.PurchaseCountAsOf 调用。
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
// LoadDayFacts 读取该快照日的设备事实与到期事实。
LoadDayFacts(ctx context.Context, snapshotDate time.Time) (*DayFacts, error)
}
// SnapshotWriter 整日替换日报快照。
type SnapshotWriter interface {
// ReplaceDay 在单事务内先删除该日三张快照表的全部行,再整日写入头行与两类明细行,并校验不变量。
// 任一步失败必须回滚,使该日不残留部分口径。
ReplaceDay(ctx context.Context, snapshotDate time.Time, snapshot model.OperationsReportSnapshot,
activations []model.OperationsReportActivationRow, renewals []model.OperationsReportRenewalRow) error
}
// Generator 生成某一天的运营报表日报快照。
type Generator struct {
reader FactsReader
writer SnapshotWriter
logger *zap.Logger
}
// NewGenerator 创建日报快照生成用例。
func NewGenerator(reader FactsReader, writer SnapshotWriter, logger *zap.Logger) *Generator {
return &Generator{reader: reader, writer: writer, logger: logger}
}
// Generate 生成指定上海自然日的日报快照。
// 同一日期重复生成的结果等于最后一次执行的结果:整日替换保证不产生重复行或第二套口径。
func (g *Generator) Generate(ctx context.Context, snapshotDate time.Time) error {
if g == nil || g.reader == nil || g.writer == nil {
return errors.New(errors.CodeInternalError, "运营报表快照生成用例未配置")
}
day := domainreport.SnapshotDay(snapshotDate)
// 采购数量经唯一的采购数量口径函数取值(设计 D6
purchasedCount, err := domainreport.PurchaseCountAsOf(ctx, g.reader, day)
if err != nil {
return err
}
facts, err := g.reader.LoadDayFacts(ctx, day)
if err != nil {
return err
}
if facts == nil {
facts = &DayFacts{}
}
generatedAt := time.Now().UTC()
snapshot, activations, renewals, err := BuildSnapshotRows(day, generatedAt, purchasedCount, facts)
if err != nil {
return err
}
if err := g.writer.ReplaceDay(ctx, day, snapshot, activations, renewals); err != nil {
return err
}
if g.logger != nil {
g.logger.Info("运营报表日报快照生成完成",
zap.String("snapshot_date", domainreport.FormatSnapshotDay(day)),
zap.Int64("purchased_device_count", snapshot.PurchasedDeviceCount),
zap.Int64("activated_device_count", snapshot.ActivatedDeviceCount),
zap.Int64("online_device_count", snapshot.OnlineDeviceCount),
zap.Int64("active_device_count", snapshot.ActiveDeviceCount),
zap.Int64("renewal_due_asset_count", snapshot.RenewalDueAssetCount),
zap.Int64("renewal_renewed_asset_count", snapshot.RenewalRenewedAssetCount),
)
}
return nil
}
// BuildSnapshotRows 把生成时刻的事实组装为头行与两类明细行。
//
// 头行值是明细行的汇总值(不是另一套独立读数),因此
// 「同一快照日期、任一受支持维度下分组行各指标之和等于头行值」由构造保证;
// 到期资产数与续费资产数按资产去重,续费资产恒为到期资产的子集。
func BuildSnapshotRows(day time.Time, generatedAt time.Time, purchasedCount int64, facts *DayFacts) (
model.OperationsReportSnapshot, []model.OperationsReportActivationRow, []model.OperationsReportRenewalRow, error) {
activations := make([]model.OperationsReportActivationRow, 0, len(facts.Devices))
snapshot := model.OperationsReportSnapshot{
SnapshotDate: day,
GeneratedAt: generatedAt,
}
for _, fact := range facts.Devices {
if fact.Purchased {
snapshot.PurchasedDeviceCount++
}
if fact.Realnamed {
snapshot.ActivatedDeviceCount++
}
if fact.Online {
snapshot.OnlineDeviceCount++
}
if fact.Active {
snapshot.ActiveDeviceCount++
}
snapshot.TotalRealTrafficMB = domainreport.Round2(snapshot.TotalRealTrafficMB + fact.RealTrafficMB)
activations = append(activations, model.OperationsReportActivationRow{
SnapshotDate: day,
DeviceID: fact.DeviceID,
VirtualNo: fact.VirtualNo,
DeviceName: fact.DeviceName,
DeviceModel: fact.DeviceModel,
Manufacturer: fact.Manufacturer,
ShopID: fact.ShopID,
ShopName: fact.ShopName,
RootShopID: fact.RootShopID,
RootShopName: fact.RootShopName,
AgentAccountID: fact.AgentAccountID,
AgentAccountName: fact.AgentAccountName,
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
BusinessOwnerName: fact.BusinessOwnerName,
BusinessUserGroupID: fact.BusinessUserGroupID,
BusinessUserGroupName: fact.BusinessUserGroupName,
Purchased: fact.Purchased,
Realnamed: fact.Realnamed,
Online: fact.Online,
Active: fact.Active,
RealTrafficMB: domainreport.Round2(fact.RealTrafficMB),
})
}
if snapshot.PurchasedDeviceCount != purchasedCount {
// 采购数量口径函数与明细行必须描述同一总体;不一致说明本次读取跨越了设备增删,
// 与其写入一套自相矛盾的快照,不如让该日失败(重试沿用同一目标日期,不产生部分口径)。
return model.OperationsReportSnapshot{}, nil, nil, errors.New(errors.CodeDatabaseError,
"运营报表快照的采购数量与设备事实不一致,本次生成已终止")
}
renewals := make([]model.OperationsReportRenewalRow, 0, len(facts.Renewals))
dueAssets := make(map[assetKey]struct{}, len(facts.Renewals))
renewedAssets := make(map[assetKey]struct{}, len(facts.Renewals))
for _, fact := range facts.Renewals {
key := assetKey{AssetType: fact.AssetType, AssetID: fact.AssetID}
dueAssets[key] = struct{}{}
if fact.Renewed {
renewedAssets[key] = struct{}{}
}
renewals = append(renewals, model.OperationsReportRenewalRow{
SnapshotDate: day,
AssetType: fact.AssetType,
AssetID: fact.AssetID,
AssetIdentifier: fact.AssetIdentifier,
ExpiredUsageID: fact.ExpiredUsageID,
PackageID: fact.PackageID,
PackageName: fact.PackageName,
SeriesID: fact.SeriesID,
SeriesName: fact.SeriesName,
ShopID: fact.ShopID,
ShopName: fact.ShopName,
RootShopID: fact.RootShopID,
RootShopName: fact.RootShopName,
AgentAccountID: fact.AgentAccountID,
AgentAccountName: fact.AgentAccountName,
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
BusinessOwnerName: fact.BusinessOwnerName,
BusinessUserGroupID: fact.BusinessUserGroupID,
BusinessUserGroupName: fact.BusinessUserGroupName,
Renewed: fact.Renewed,
})
}
snapshot.RenewalDueAssetCount = int64(len(dueAssets))
snapshot.RenewalRenewedAssetCount = int64(len(renewedAssets))
return snapshot, activations, renewals, nil
}
// assetKey 是续费指标的资产去重键(沿用既有载体类型取值)。
type assetKey struct {
AssetType string
AssetID uint
}

View File

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

View File

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

View File

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

View File

@@ -20,12 +20,15 @@ import (
)
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
//
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号。
type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
// Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识
Attempt *model.RefundRequestAttempt
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空
ChannelRefundRequestNo string
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
@@ -126,11 +129,14 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder)
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder, "")
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
@@ -148,11 +154,13 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := attachRefundFirstInstance(ctx, tx, &current, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
current.ApprovalInstanceID = &reference.InstanceID
refund = current
order = currentOrder
var instance model.ApprovalInstance
@@ -178,7 +186,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Order == nil || command.Attempt == nil ||
if command.Refund == nil || command.Order == nil ||
command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
@@ -199,6 +207,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
return nil, err
}
var approvalStatus int
var attempt *model.RefundRequestAttempt
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
@@ -215,47 +224,53 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
}
command.Attempt.RefundID = command.Refund.ID
if err := tx.WithContext(ctx).Create(command.Attempt).Error; err != nil {
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: command.Attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
BusinessID: attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, command.Attempt, reference.InstanceID); err != nil {
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, command.Refund, command.Attempt, reference.InstanceID); err != nil {
if err := attachRefundFirstInstance(ctx, tx, command.Refund, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, command.Refund, attempt, reference.InstanceID); err != nil {
return err
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: command.Attempt,
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: attempt,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, Attempt: command.Attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return &CreateResult{Refund: command.Refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
// ResubmitCommand 描述重提时的材料变更。
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收)
// Attempt 是本次新增的不可变审批尝试记录。
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收)
// 本次新增的不可变审批尝试记录由本用例在同一事务内构造
type ResubmitCommand struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
Refund *model.RefundRequest
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
ChannelRefundRequestNo string
}
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
@@ -267,7 +282,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if refundID == 0 || command.Refund == nil || command.Attempt == nil || command.Refund.Creator == 0 {
if refundID == 0 || command.Refund == nil || command.Refund.Creator == 0 {
return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整")
}
account, err := s.loadSubmitter(ctx, command.Refund.Creator)
@@ -303,7 +318,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
current.RefundVoucherKey = command.Refund.RefundVoucherKey
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
attempt, err := buildAttempt(ctx, tx, &current, &order)
attempt, err := buildAttempt(ctx, tx, &current, &order, command.ChannelRefundRequestNo)
if err != nil {
return err
}
@@ -415,7 +430,8 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (*model.RefundRequestAttempt, error) {
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, channelRefundRequestNo string) (*model.RefundRequestAttempt, error) {
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
if err != nil {
return nil, err
@@ -434,6 +450,7 @@ func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest,
CustomerAccountInfo: refund.CustomerAccountInfo,
CustomerVoucherKeys: refund.RefundVoucherKey,
PackageUsageSnapshot: snapshot,
ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo),
SubmittedByAccountID: refund.Creator,
}, nil
}
@@ -495,8 +512,25 @@ func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.Refu
return nil
}
// attachRefundFirstInstance 把审批实例回写到退款申请的首次接入引用。
// 既有 approval_instance_id 保持「首次接入企业微信审批的实例」语义:条件更新在引用为空时才写入,
// 因此重提只新增尝试引用,不会改写首次接入事实,也不会破坏其部分唯一索引。
func attachRefundFirstInstance(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", refund.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回写退款申请首次审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请首次审批实例已变化")
}
refund.ApprovalInstanceID = &instanceID
return nil
}
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
// 既有 approval_instance_id 在该函数外单独回写,保持「首次接入企业微信审批的实例」语义不变。
// 既有 approval_instance_id 由 attachRefundFirstInstance 单独回写,保持「首次接入企业微信审批的实例」语义不变。
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID,

View File

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

View File

@@ -2,7 +2,9 @@ package bootstrap
import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
@@ -18,18 +20,24 @@ import (
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
businessUserGroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup"
distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
@@ -61,7 +69,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB)
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB)
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
businessUserGroupStore := postgres.NewBusinessUserGroupStore(deps.DB)
carrierStore := postgres.NewCarrierStore(deps.DB)
pollingPriorityItemStore := postgres.NewPollingPriorityItemStore(deps.DB)
rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis)
paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis)
commissionRecordStore := postgres.NewCommissionRecordStore(deps.DB, deps.Redis)
@@ -162,6 +172,26 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
))
svc.Account.SetWeComMemberFinder(wecomMembers)
// H5 弹窗候选必须当次返回可用通知标识,因此 API 进程直接复用 Outbox 消费的同一套渲染、展示期与幂等写入规则。
notificationAudit := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
notificationDirectWriter := notificationApp.NewDeliveryService(
notificationInfra.NewRepository(deps.DB), notificationInfra.NewRegistry(), nil, deps.Logger, notificationAudit,
)
// 资产标识解析复用既有 Store 方法,保证与资产详情、换货入口同一口径。
candidateService := h5PopupApp.NewCandidateService(
deps.DB,
postgres.NewAssetIdentifierStore(deps.DB),
postgres.NewIotCardStore(deps.DB, deps.Redis),
postgres.NewDeviceStore(deps.DB, deps.Redis),
svc.CustomerBinding,
notificationDirectWriter,
)
riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit)
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
operationsReportQuery := operationsreportquery.NewQuery(deps.DB)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
@@ -200,7 +230,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
ClientPopup: app.NewClientPopupHandler(candidateService, riskExchangeService, validate),
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
@@ -212,8 +243,21 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
return handler
}(),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
BusinessUserGroup: func() *admin.BusinessUserGroupHandler {
handler := admin.NewBusinessUserGroupHandler(
businessUserGroupApp.New(deps.DB, businessUserGroupStore, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)),
validate,
)
handler.SetQuery(businessUserGroupQuery.NewQuery(deps.DB, businessUserGroupStore))
batchService := shopApp.NewBatchBusinessOwnerService(deps.DB)
batchService.SetBatchBusinessOwnerAudit(auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
handler.SetBatchService(batchService)
return handler
}(),
ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(svc.ShopBusinessOwnerImport),
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(svc.PhoneAssetAssociation, validate),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: func() *admin.ShopCommissionHandler {
handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate)
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
@@ -234,7 +278,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(popupConfigurationService, popupConfigurationQuery, validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
@@ -243,6 +288,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
OperationsReport: admin.NewOperationsReportHandler(operationsReportQuery, svc.ExportTask, validate),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
@@ -276,6 +324,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
PollingPriority: admin.NewPriorityPollingHandler(
svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate,
),
Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
@@ -292,6 +343,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
h.SetPackageExpiryQuery(packageExpiry)
h.SetPackageExpiryQueue(deps.QueueClient)
h.SetExportTaskService(svc.ExportTask)
h.SetValidator(validate)
return h
}(),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),

View File

@@ -7,11 +7,14 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
@@ -22,9 +25,11 @@ import (
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/internal/polling"
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
@@ -69,8 +74,10 @@ import (
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
phoneAssetAssociationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
shopBusinessOwnerImportSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
@@ -112,6 +119,8 @@ type services struct {
Package *packageSvc.Service
PackageDailyRecord *packageSvc.DailyRecordService
PackageCustomerView *packageSvc.CustomerViewService
PackageTrafficAlertRule *packagetrafficalertapp.RuleService
AssetAutoRenewal *assetAutoRenewalApp.Service
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
ShopSeriesGrant *shopSeriesGrantSvc.Service
@@ -126,6 +135,7 @@ type services struct {
PollingAlert *pollingSvc.AlertService
PollingCleanup *pollingSvc.CleanupService
PollingManualTrigger *pollingSvc.ManualTriggerService
PriorityPolling *pollingSvc.PriorityEnqueueService
Asset *assetSvc.Service
AssetLifecycle *assetSvc.LifecycleService
AssetWallet *assetWalletSvc.Service
@@ -143,6 +153,8 @@ type services struct {
CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service
ShopBusinessOwnerImport *shopBusinessOwnerImportSvc.Service
PhoneAssetAssociation *phoneAssetAssociationSvc.Service
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService
@@ -178,12 +190,17 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetAccessAudit(auditWriter)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationService.SetStateAuditWriter(iotCard)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
@@ -226,6 +243,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
packageActivation.SetPriorityEventWriter(priorityEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis,
@@ -237,12 +255,16 @@ func initServices(s *stores, deps *Dependencies) *services {
)
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
iotCard.SetEnterpriseCardAuthStore(s.EnterpriseCardAuthorization)
iotCard.SetEnterpriseStore(s.Enterprise)
iotCard.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
iotCard.SetRedisClient(deps.Redis)
device := deviceSvc.New(
deps.DB,
@@ -261,6 +283,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.Enterprise,
)
device.SetAccessAudit(auditWriter)
device.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
@@ -279,6 +302,7 @@ func initServices(s *stores, deps *Dependencies) *services {
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
orderService.SetPriorityEventWriter(priorityEvents)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
@@ -347,6 +371,7 @@ func initServices(s *stores, deps *Dependencies) *services {
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
exchangeService.SetAccessAudit(auditWriter)
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder)
assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
assetService.SetAccessAudit(auditWriter)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
@@ -432,6 +457,12 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
// 人工优先入队:与人工触发共用同包权限判定,但使用独立的优先提示通道与合并语义。
priorityPollingService := pollingSvc.NewPriorityEnqueueService(
deps.DB, s.IotCard, s.PollingPriorityItem, pollingQueueMgr, deps.Logger,
)
priorityPollingService.SetAudit(auditWriter)
return &services{
AccessAudit: auditWriter,
Approval: approvalCreationService,
@@ -445,6 +476,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PersonalCustomerOpenID,
s.PersonalCustomer,
s.PersonalCustomerPhone,
s.PhoneAssetAssociation,
s.IotCard,
s.Device,
wechatConfig,
@@ -479,6 +511,13 @@ func initServices(s *stores, deps *Dependencies) *services {
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter),
AssetAutoRenewal: assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
Outbox: outbox.NewRepository(), AuditWriter: auditWriter,
PurchaseValidation: purchaseValidation,
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
}),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),
@@ -493,6 +532,7 @@ func initServices(s *stores, deps *Dependencies) *services {
PollingAlert: pollingAlertService,
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingManualTriggerService,
PriorityPolling: priorityPollingService,
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
@@ -510,8 +550,13 @@ func initServices(s *stores, deps *Dependencies) *services {
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
ShopBusinessOwnerImport: shopBusinessOwnerImportSvc.New(s.ShopBusinessOwnerImportTask, deps.QueueClient, auditWriter),
PhoneAssetAssociation: phoneAssetAssociationSvc.New(
deps.DB, s.PhoneAssetAssociation, s.PhoneAssetUnbindImportTask,
s.AssetIdentifier, s.IotCard, s.Device, deps.QueueClient, auditWriter,
),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
}
}

View File

@@ -17,6 +17,7 @@ type stores struct {
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
CommissionRecord *postgres.CommissionRecordStore
CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore
@@ -51,6 +52,7 @@ type stores struct {
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
PollingManualTriggerLog *postgres.PollingManualTriggerLogStore
PollingPriorityItem *postgres.PollingPriorityItemStore
// 代理钱包系统
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
@@ -69,8 +71,15 @@ type stores struct {
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
// 资产套餐批量订购任务
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
// 业务用户组与成员归属
BusinessUserGroup *postgres.BusinessUserGroupStore
// 店铺负责人 CSV 导入任务
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
// 流量系统
CardDailyUsage *postgres.CardDailyUsageStore
// 套餐真流量预警规则与达量预警事实
PackageTrafficAlert *postgres.PackageTrafficAlertStore
// 资产标识符注册表
AssetIdentifier *postgres.AssetIdentifierStore
}
@@ -89,6 +98,7 @@ func initStores(deps *Dependencies) *stores {
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB),
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis),
@@ -123,20 +133,25 @@ func initStores(deps *Dependencies) *stores {
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB),
PollingPriorityItem: postgres.NewPollingPriorityItemStore(deps.DB),
// 代理钱包系统
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
// 资产钱包系统
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB),
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
PackageTrafficAlert: postgres.NewPackageTrafficAlertStore(deps.DB),
}
}

View File

@@ -25,6 +25,7 @@ type Handlers struct {
ClientDevice *app.ClientDeviceHandler
ClientRechargeOrder *app.ClientRechargeOrderHandler
ClientNotification *app.ClientNotificationHandler
ClientPopup *app.ClientPopupHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
@@ -41,6 +42,7 @@ type Handlers struct {
IotCardImport *admin.IotCardImportHandler
ExportTask *admin.ExportTaskHandler
Notification *admin.NotificationHandler
H5PopupConfiguration *admin.H5PopupConfigurationHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler
@@ -66,6 +68,7 @@ type Handlers struct {
PollingAlert *admin.PollingAlertHandler
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
PollingPriority *admin.PriorityPollingHandler
Asset *admin.AssetHandler
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
@@ -76,6 +79,12 @@ type Handlers struct {
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
BusinessUserGroup *admin.BusinessUserGroupHandler
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
PackageTrafficAlert *admin.PackageTrafficAlertHandler
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
OperationsReport *admin.OperationsReportHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,9 +43,20 @@ func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, erro
input.City = strings.TrimSpace(input.City)
input.District = strings.TrimSpace(input.District)
input.Address = strings.TrimSpace(input.Address)
if input.DistributionCode == "" || input.Phone == "" || input.Username == "" ||
input.ShopName == "" || input.ShopCode == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用")
if input.DistributionCode == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不能为空")
}
if input.Phone == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号不能为空")
}
if input.Username == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名不能为空")
}
if input.ShopName == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺名称不能为空")
}
if input.ShopCode == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺编号不能为空")
}
if len(input.Phone) != 11 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")

View File

@@ -0,0 +1,137 @@
package operationsreport
// 报表分组维度:激活情况表七项、套餐续费表六项。
// 只支持单一分组维度,不支持同时按多个维度分组;未选择维度时汇总为一行,分组列值为「全部」。
const (
// DimensionDeviceName 表示设备名称维度。
DimensionDeviceName = "device_name"
// DimensionDeviceModel 表示设备型号维度。
DimensionDeviceModel = "device_model"
// DimensionManufacturer 表示制造商维度。
DimensionManufacturer = "manufacturer"
// DimensionBusinessUserGroup 表示用户组维度。
DimensionBusinessUserGroup = "business_user_group"
// DimensionAgent 表示代理维度。
DimensionAgent = "agent"
// DimensionShop 表示店铺维度。
DimensionShop = "shop"
// DimensionBusinessOwner 表示业务员维度。
DimensionBusinessOwner = "business_owner"
// DimensionPackageSeries 表示套餐系列维度。
DimensionPackageSeries = "package_series"
// DimensionPackageName 表示套餐名称维度。
DimensionPackageName = "package_name"
)
// DimensionAll 是未选择分组维度时唯一汇总行的分组列值。
const DimensionAll = "全部"
// 趋势粒度取值:只表达粒度,不引入月份参数。
const (
// GranularityDay 表示按日趋势。
GranularityDay = "day"
// GranularityMonth 表示按月趋势。
GranularityMonth = "month"
)
// NormalizeGranularity 归一趋势粒度,缺省为按日;返回 false 表示取值不受支持。
func NormalizeGranularity(value string) (string, bool) {
switch value {
case "", GranularityDay:
return GranularityDay, true
case GranularityMonth:
return GranularityMonth, true
default:
return "", false
}
}
// PlaceholderUnset 是分组取值为空时的固定占位展示。
const PlaceholderUnset = "未设置"
// activationDimensions 是有序的激活情况分组维度与其中文名。
var activationDimensions = []struct {
Code string
Name string
}{
{DimensionDeviceName, "设备名称"},
{DimensionDeviceModel, "设备型号"},
{DimensionManufacturer, "制造商"},
{DimensionBusinessUserGroup, "用户组"},
{DimensionAgent, "代理"},
{DimensionShop, "店铺"},
{DimensionBusinessOwner, "业务员"},
}
// renewalDimensions 是有序的套餐续费分组维度与其中文名。
var renewalDimensions = []struct {
Code string
Name string
}{
{DimensionPackageSeries, "套餐系列"},
{DimensionPackageName, "套餐名称"},
{DimensionBusinessUserGroup, "用户组"},
{DimensionAgent, "代理"},
{DimensionShop, "店铺"},
{DimensionBusinessOwner, "业务员"},
}
// ActivationDimensionCodes 返回激活情况支持的维度编码(按展示顺序)。
func ActivationDimensionCodes() []string {
codes := make([]string, 0, len(activationDimensions))
for _, dimension := range activationDimensions {
codes = append(codes, dimension.Code)
}
return codes
}
// RenewalDimensionCodes 返回套餐续费支持的维度编码(按展示顺序)。
func RenewalDimensionCodes() []string {
codes := make([]string, 0, len(renewalDimensions))
for _, dimension := range renewalDimensions {
codes = append(codes, dimension.Code)
}
return codes
}
// ActivationDimensionName 返回激活情况维度的中文名;不支持时返回 false。
func ActivationDimensionName(code string) (string, bool) {
return dimensionName(activationDimensions, code)
}
// RenewalDimensionName 返回套餐续费维度的中文名;不支持时返回 false。
func RenewalDimensionName(code string) (string, bool) {
return dimensionName(renewalDimensions, code)
}
// IsActivationDimension 判断是否为受支持的激活情况维度。
func IsActivationDimension(code string) bool {
_, ok := ActivationDimensionName(code)
return ok
}
// IsRenewalDimension 判断是否为受支持的套餐续费维度。
func IsRenewalDimension(code string) bool {
_, ok := RenewalDimensionName(code)
return ok
}
func dimensionName(dimensions []struct {
Code string
Name string
}, code string) (string, bool) {
for _, dimension := range dimensions {
if dimension.Code == code {
return dimension.Name, true
}
}
return "", false
}
// TextOrPlaceholder 返回非空文本,为空时返回固定占位。
func TextOrPlaceholder(value string) string {
if value == "" {
return PlaceholderUnset
}
return value
}

View File

@@ -0,0 +1,150 @@
// Package operationsreport 是运营报表(设备激活与套餐续费)的口径域。
//
// 本包只表达可复现的口径与纯计算:真流量换算、比率与卡均、分母为零语义、
// 预测卡均的当月口径、采购数量口径入口与续费判定规则。
// 不依赖 Fiber、GORM、Redis、Asynq 或任何外部 SDK也不做任何读写。
package operationsreport
import (
"math"
"time"
)
// MBPerGB 是报表域自持的流量换算常量1 GB = 1024 MB。
// 与 internal/domain/carrierthreshold 的换算同值同源;不为一个换算常数建立跨域依赖。
const MBPerGB = 1024
// shanghaiLocation 是报表口径使用的上海时区(固定 +08:00
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ShanghaiLocation 返回报表口径使用的上海时区。
func ShanghaiLocation() *time.Location {
return shanghaiLocation
}
// SnapshotDay 把任意时刻归一为它所在的上海自然日零点。
// 报表的一切跨日比较都使用上海自然日,不使用服务器本地时区。
func SnapshotDay(value time.Time) time.Time {
local := value.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}
// PreviousDay 返回给定上海自然日的前一自然日零点。
func PreviousDay(day time.Time) time.Time {
return SnapshotDay(day).AddDate(0, 0, -1)
}
// ParseSnapshotDay 解析 yyyy-MM-dd 形式的上海自然日。
func ParseSnapshotDay(value string) (time.Time, error) {
parsed, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation)
if err != nil {
return time.Time{}, err
}
return parsed, nil
}
// FormatSnapshotDay 输出上海自然日的 yyyy-MM-dd 文本。
func FormatSnapshotDay(day time.Time) string {
return SnapshotDay(day).Format("2006-01-02")
}
// FormatMonthPeriod 输出上海自然月的 yyyy-MM 文本。
func FormatMonthPeriod(day time.Time) string {
return SnapshotDay(day).Format("2006-01")
}
// LowerBoundDay 按落界规则返回区间起点入选的最早快照日期。
//
// 落界规则(设计 D10快照日期 D 入选,当且仅当 D 的零点(+08:00落在请求区间内。
// 因此起点恰好落在零点时当日入选,否则从次日起入选。
func LowerBoundDay(start time.Time) time.Time {
day := SnapshotDay(start)
if start.After(day) {
return day.AddDate(0, 0, 1)
}
return day
}
// UpperBoundDay 按落界规则返回区间终点入选的最晚快照日期。
func UpperBoundDay(end time.Time) time.Time {
return SnapshotDay(end)
}
// PeriodOf 返回给定快照日期所属的趋势期标识:按日为上海自然日,按月为该月首日。
func PeriodOf(granularity string, day time.Time) time.Time {
normalized := SnapshotDay(day)
if granularity == GranularityMonth {
return time.Date(normalized.Year(), normalized.Month(), 1, 0, 0, 0, 0, shanghaiLocation)
}
return normalized
}
// FormatPeriod 输出趋势期标识:按日为 yyyy-MM-dd按月为 yyyy-MM。
func FormatPeriod(granularity string, day time.Time) string {
if granularity == GranularityMonth {
return FormatMonthPeriod(day)
}
return FormatSnapshotDay(day)
}
// PreviousPeriodStart 返回给定期起始日所属期的前一期起始日(按日减一天,按月减一个月)。
func PreviousPeriodStart(granularity string, periodStart time.Time) time.Time {
if granularity == GranularityMonth {
return periodStart.AddDate(0, -1, 0)
}
return periodStart.AddDate(0, 0, -1)
}
// Ratio 计算比率并按两位小数取整;分母不大于零时不可计算,返回 false空值语义
// 比率不设上限:设备删除或迁移可使激活率超过 100%,如实呈现。
func Ratio(numerator, denominator int64) (float64, bool) {
if denominator <= 0 {
return 0, false
}
return Round2(float64(numerator) / float64(denominator)), true
}
// CardAverageGB 计算卡均用量GB累计真流量折算 GB 后除以分母设备数。
// 分母不大于零时不可计算,返回 false空值语义
func CardAverageGB(totalRealTrafficMB float64, denominator int64) (float64, bool) {
if denominator <= 0 {
return 0, false
}
average := totalRealTrafficMB / MBPerGB / float64(denominator)
return Round2(average), true
}
// ForecastCardAverageGB 计算预测卡均GB先按卡均口径得出日均再按结束日所在上海自然月年化。
// 「当月」= 所选结束日所在上海自然月;已过天数 = 结束日日期号;当月总天数 = 该月自然日数。
// 分母不大于零时不可计算,返回 false空值语义
func ForecastCardAverageGB(totalRealTrafficMB float64, denominator int64, endDate time.Time) (float64, bool) {
average, ok := CardAverageGB(totalRealTrafficMB, denominator)
if !ok {
return 0, false
}
elapsedDays, totalDays := MonthElapsedAndTotalDays(endDate)
if elapsedDays <= 0 || totalDays <= 0 {
return 0, false
}
return Round2(average * float64(totalDays) / float64(elapsedDays)), true
}
// MonthElapsedAndTotalDays 返回结束日所在上海自然月的已过天数与当月总天数。
// 已过天数按结束日的日期号取值(不区分当月剩余天数),当月总天数取该自然月的实际天数。
func MonthElapsedAndTotalDays(endDate time.Time) (int, int) {
day := SnapshotDay(endDate)
totalDays := time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, shanghaiLocation).Day()
return day.Day(), totalDays
}
// Round2 按两位小数四舍五入。
func Round2(value float64) float64 {
return math.Round(value*100) / 100
}
// RenewalRate 计算续费率:续费资产数除以到期资产数。
// 分母为零时不可计算,返回 false空值语义导出写「-」)。
// 分子为分母子集,因此续费率不超过 100% 由构造保证,不做任何截断或钳制。
func RenewalRate(renewed, due int64) (float64, bool) {
return Ratio(renewed, due)
}

View File

@@ -0,0 +1,37 @@
package operationsreport
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PurchaseCountSource 是采购数量口径的取值端口。
// 由基础设施层实现为只读查询(截至快照日系统内未删除的设备数)。
type PurchaseCountSource interface {
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
}
// PurchaseCountAsOf 返回截至快照日的采购数量,是采购数量口径的**唯一实现点**(设计 D6
//
// 采用口径:采购数量 = 截至快照日(上海自然日)系统内未删除的设备数,
// 与 `111.md` §22.4.1「系统录入的设备数量」同读法,不新建采购或入库台账。
//
// 被拒绝的字面口径:`tb_device_import_task` 中 `operation_type='import'` 且已完成任务的
// `success_count` 之和。生产库实测该值为 474而系统内未删除设备为 18,970
// 差额来自老系统迁移脚本直接写入设备表、绕过导入任务,按字面口径激活率约 1,399%,指标不可用。
//
// 切换口径只需替换本函数体内的取值方式(一行),调用方与快照表结构都不需要改动。
func PurchaseCountAsOf(ctx context.Context, source PurchaseCountSource, snapshotDate time.Time) (int64, error) {
return source.CountUndeletedDevicesAsOf(ctx, snapshotDate)
}
// ValidMainPackageStatuses 是「有效主套餐」的状态集合:生效中与已用完。
// 「有效」的完整口径为主套餐master_usage_id IS NULL、状态属于本集合、未退款refund_id IS NULL
// 既有先例见 internal/query/packageexpiry/list.go、internal/query/assetautorenewal/query.go
// 与 internal/infrastructure/packagetrafficalert/scanner.go。
var ValidMainPackageStatuses = []int{
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
}

View File

@@ -0,0 +1,43 @@
package operationsreport
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// ExpiredMainUsage 是一条到期事实:快照日(上海自然日)等于其到期日的主套餐使用记录。
type ExpiredMainUsage struct {
UsageID uint
AssetType string
AssetID uint
ExpiresAt time.Time
}
// MainUsageCandidate 是同资产上参与续费判定的主套餐记录投影。
// 调用方必须只传入未退款refund_id IS NULL的主套餐master_usage_id IS NULL记录。
type MainUsageCandidate struct {
UsageID uint
Status int
ActivatedAt *time.Time
}
// IsRenewed 判定该到期事实是否已续费(设计 D9
// 存在**另一条**未退款主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
//
// 候选中属于本条记录自身的项不参与判定;判定的结果挂在到期行上,
// 使续费资产集合恒为到期资产集合的子集,续费率不超过 100% 由构造保证,不做任何截断或钳制。
func IsRenewed(expired ExpiredMainUsage, candidates []MainUsageCandidate) bool {
for _, candidate := range candidates {
if candidate.UsageID == expired.UsageID {
continue
}
if candidate.Status == constants.PackageUsageStatusPending {
return true
}
if candidate.ActivatedAt != nil && candidate.ActivatedAt.After(expired.ExpiresAt) {
return true
}
}
return false
}

View File

@@ -0,0 +1,52 @@
// Package packagetrafficalert 提供套餐真流量达量预警的领域判定规则。
//
// 判定口径固定为「真流量」:分子取套餐使用记录的真已用量,分母取套餐使用记录的真总量快照,
// 二者按资产汇总后再与主套餐规则阈值比较;全部使用整数万分比比较,不使用浮点判定,
// 避免边界(例如恰好等于阈值)因二进制浮点误差产生错误结论。
package packagetrafficalert
import "math"
// ratioScale 是万分比刻度1% = 1000.01% = 1。
const ratioScale = 10000
// MinThresholdPercent 与 MaxThresholdPercent 是可配置阈值百分比的闭区间端点。
const (
MinThresholdPercent = 1.0
MaxThresholdPercent = 100.0
)
// ThresholdBasisPoints 把百分比阈值换算为整数万分比0.01% = 1
// 数据库以 NUMERIC(5,2) 保存两位小数,读取后先四舍五入到两位再换算,保证 1.25% 恒等于 125。
func ThresholdBasisPoints(percent float64) int64 {
return int64(math.Round(NormalizeThresholdPercent(percent) * 100))
}
// NormalizeThresholdPercent 把百分比四舍五入到两位小数,与 NUMERIC(5,2) 的存储精度一致。
func NormalizeThresholdPercent(percent float64) float64 {
return math.Round(percent*100) / 100
}
// IsValidThresholdPercent 判断百分比是否落在 1%100% 闭区间内。
func IsValidThresholdPercent(percent float64) bool {
normalized := NormalizeThresholdPercent(percent)
return normalized >= MinThresholdPercent && normalized <= MaxThresholdPercent
}
// Decide 按资产的汇总真流量判定是否达到阈值,并返回向下取整的汇总比例万分比。
//
// usedMB 为该资产全部当前有效套餐的真已用量之和limitMB 为同集合的真总量快照之和。
// 分母不大于零属于不可判定资产,调用方必须先跳过;此处返回未命中以避免除零。
func Decide(usedMB, limitMB, thresholdBasisPoints int64) (bool, int64) {
if limitMB <= 0 || thresholdBasisPoints <= 0 {
return false, 0
}
ratioBasisPoints := usedMB * ratioScale / limitMB
hit := usedMB*ratioScale >= thresholdBasisPoints*limitMB
return hit, ratioBasisPoints
}
// PercentFromBasisPoints 把万分比换算为保留两位小数的百分比展示值。
func PercentFromBasisPoints(basisPoints int64) float64 {
return float64(basisPoints) / 100
}

View File

@@ -27,7 +27,10 @@ func (s *AgentRechargeDataSource) Scene() string {
// Count 统计代理充值导出行数。
func (s *AgentRechargeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
query, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return 0, err
}
if err := query.Count(&total).Error; err != nil {
return 0, err
}
@@ -49,7 +52,11 @@ func (s *AgentRechargeDataSource) Fetch(ctx context.Context, params ExportParams
}
var items []agentRechargeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
filtered, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return nil, err
}
query := filtered.
Select(`
r.recharge_no,
r.amount,
@@ -121,7 +128,7 @@ func (s *AgentRechargeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_agent_recharge_record AS r").Where("r.deleted_at IS NULL")
}
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
query = applyExportShopScope(query, params, "r.shop_id")
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("r.shop_id = ?", shopID)
@@ -129,13 +136,17 @@ func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportPara
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("r.status = ?", status)
}
if start, ok := filterTime(params.Filters, "start_date"); ok {
query = query.Where("r.created_at >= ?", start)
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
query = query.Where("r.created_at <= ?", end)
if start != nil {
query = query.Where("r.created_at >= ?", *start)
}
return query
if end != nil {
query = query.Where("r.created_at <= ?", *end)
}
return query, nil
}
type agentRechargeExportRow struct {

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,10 @@ func (s *ExchangeDataSource) Scene() string {
// Count 统计换货记录导出行数。
func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
query, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return 0, err
}
if err := query.Count(&total).Error; err != nil {
return 0, err
}
@@ -38,7 +41,7 @@ func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (in
func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符",
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间",
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "迁移状态", "创建人", "创建时间",
}, nil
}
@@ -49,7 +52,11 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
}
var items []exchangeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
filtered, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return nil, err
}
query := filtered.
Select(`
e.exchange_no,
e.flow_type,
@@ -64,6 +71,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
e.express_company,
e.express_no,
e.status,
e.migration_status,
e.created_at,
COALESCE(ac.username, '') AS creator_name
`).
@@ -91,6 +99,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
item.ExpressCompany,
item.ExpressNo,
constants.GetExchangeStatusName(item.Status),
constants.GetExchangeMigrationStatusName(item.MigrationStatus),
item.CreatorName,
item.CreatedAt.Format(exportTimeLayout),
})
@@ -102,7 +111,7 @@ func (s *ExchangeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_exchange_order AS e").Where("e.deleted_at IS NULL")
}
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
query = applyExportShopScope(query, params, "e.shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("e.status = ?", status)
@@ -112,13 +121,17 @@ func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *
}
query = applyExchangeAssetKeyword(query, "old", filterValue(params.Filters, "old_asset_keyword"))
query = applyExchangeAssetKeyword(query, "new", filterValue(params.Filters, "new_asset_keyword"))
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
query = query.Where("e.created_at >= ?", start)
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
query = query.Where("e.created_at <= ?", end)
if start != nil {
query = query.Where("e.created_at >= ?", *start)
}
return query
if end != nil {
query = query.Where("e.created_at <= ?", *end)
}
return query, nil
}
func applyExchangeAssetKeyword(query *gorm.DB, side, keyword string) *gorm.DB {
@@ -171,6 +184,7 @@ type exchangeExportRow struct {
ExpressCompany string `gorm:"column:express_company"`
ExpressNo string `gorm:"column:express_no"`
Status int `gorm:"column:status"`
MigrationStatus string `gorm:"column:migration_status"`
CreatorName string `gorm:"column:creator_name"`
CreatedAt time.Time `gorm:"column:created_at"`
}

View File

@@ -0,0 +1,229 @@
package exporter
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ExpiringAssetDataSource 临期资产导出数据源。
//
// 粒度为一行对应一项资产,取该资产当前生效主套餐的最终到期时间与剩余天数,加油包不单独成行。
// 候选预筛、最终到期推算与行序全部复用 internal/query/packageexpiry 的列表实现,不另写第二套到期口径。
// 店铺、业务员与用户组按导出执行时的当前归属补充,结果仍受任务创建时冻结的可见店铺范围约束。
type ExpiringAssetDataSource struct {
db *gorm.DB
expiryQ *packageexpiryquery.Query
}
// NewExpiringAssetDataSource 创建临期资产导出数据源。
func NewExpiringAssetDataSource(db *gorm.DB) *ExpiringAssetDataSource {
return &ExpiringAssetDataSource{db: db, expiryQ: packageexpiryquery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *ExpiringAssetDataSource) Scene() string {
return constants.ExportTaskSceneExpiringAsset
}
// Count 统计临期资产导出行数,与列表同一筛选下的行集合一致。
func (s *ExpiringAssetDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
items, err := s.items(ctx, params)
if err != nil {
return 0, err
}
return len(items), nil
}
// Headers 返回临期资产导出表头,列序与 111.md §18.1 逐列一致。
func (s *ExpiringAssetDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"店铺", "业务员", "用户组", "资产类型", "设备类型", "设备型号", "资产标识", "当前套餐", "到期时间", "剩余天数",
}, nil
}
// Fetch 按 offset/limit 查询临期资产导出数据。
func (s *ExpiringAssetDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
items, err := s.items(ctx, params)
if err != nil {
return nil, err
}
if offset >= len(items) {
return [][]string{}, nil
}
end := offset + limit
if end > len(items) {
end = len(items)
}
page := items[offset:end]
shopIDs := make([]uint, 0, len(page))
deviceIDs := make([]uint, 0, len(page))
for _, item := range page {
if item.ShopID != nil {
shopIDs = append(shopIDs, *item.ShopID)
}
if item.AssetType == constants.AssetTypeDevice {
deviceIDs = append(deviceIDs, item.AssetID)
}
}
shops, err := s.loadShopOwners(ctx, normalizeUintSlice(shopIDs))
if err != nil {
return nil, err
}
ownerIDs := make([]uint, 0, len(shops))
for _, shop := range shops {
if shop.BusinessOwnerAccountID != nil {
ownerIDs = append(ownerIDs, *shop.BusinessOwnerAccountID)
}
}
ownerIDs = normalizeUintSlice(ownerIDs)
ownerNames, err := loadAccountNames(ctx, s.db, ownerIDs)
if err != nil {
return nil, err
}
groupNames, err := businessUserGroupNames(ctx, s.db, ownerIDs)
if err != nil {
return nil, err
}
devices, err := s.loadDeviceAttributes(ctx, normalizeUintSlice(deviceIDs))
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(page))
for _, item := range page {
var shopName, ownerName, groupName string
if item.ShopID != nil {
shop := shops[*item.ShopID]
shopName = shop.ShopName
if shop.BusinessOwnerAccountID != nil {
ownerName = ownerNames[*shop.BusinessOwnerAccountID]
groupName = currentOwnerGroupName(groupNames, shop.BusinessOwnerAccountID)
}
}
var deviceType, deviceModel string
if item.AssetType == constants.AssetTypeDevice {
device := devices[item.AssetID]
deviceType, deviceModel = device.DeviceType, device.DeviceModel
}
rows = append(rows, []string{
shopName,
ownerName,
groupName,
assetTypeName(item.AssetType),
deviceType,
deviceModel,
item.Identifier,
item.PackageName,
formatOptionalTime(item.EstimatedFinalExpiresAt),
formatRemainingDaysValue(item.DaysUntilFinalExpiry),
})
}
return rows, nil
}
// items 复用列表同一候选预筛与最终到期推算,数据范围来自任务创建时冻结的可见店铺范围。
func (s *ExpiringAssetDataSource) items(ctx context.Context, params ExportParams) ([]dto.ExpiringAssetItem, error) {
filter, err := s.listFilter(params)
if err != nil {
return nil, err
}
scope := func(query *gorm.DB) *gorm.DB {
return applyExportShopScope(query, params, "shop_id")
}
return s.expiryQ.ListAllWithScope(ctx, scope, filter)
}
// listFilter 把任务冻结的筛选快照转换为列表同一口径的筛选条件。
func (s *ExpiringAssetDataSource) listFilter(params ExportParams) (packageexpiryquery.ListFilter, error) {
filter := packageexpiryquery.ListFilter{
AssetType: filterValue(params.Filters, "asset_type"),
Keyword: filterValue(params.Filters, "keyword"),
}
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
filter.ShopID = &shopID
}
if packageID, ok := filterUint(params.Filters, "package_id"); ok {
filter.PackageID = &packageID
}
if daysMin, ok := filterInt(params.Filters, "days_min"); ok {
filter.DaysMin = &daysMin
}
if daysMax, ok := filterInt(params.Filters, "days_max"); ok {
filter.DaysMax = &daysMax
}
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return packageexpiryquery.ListFilter{}, err
}
filter.StartTime, filter.EndTime = start, end
return filter, nil
}
func (s *ExpiringAssetDataSource) loadShopOwners(ctx context.Context, shopIDs []uint) (map[uint]expiringAssetShop, error) {
result := make(map[uint]expiringAssetShop, len(shopIDs))
if len(shopIDs) == 0 {
return result, nil
}
var rows []expiringAssetShop
if err := s.db.WithContext(ctx).Table("tb_shop").
Select("id, shop_name, business_owner_account_id").
Where("id IN ? AND deleted_at IS NULL", shopIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出店铺当前归属失败")
}
for _, row := range rows {
result[row.ID] = row
}
return result, nil
}
func (s *ExpiringAssetDataSource) loadDeviceAttributes(ctx context.Context, deviceIDs []uint) (map[uint]expiringAssetDevice, error) {
result := make(map[uint]expiringAssetDevice, len(deviceIDs))
if len(deviceIDs) == 0 {
return result, nil
}
var rows []expiringAssetDevice
if err := s.db.WithContext(ctx).Table("tb_device").
Select("id, device_type, device_model").
Where("id IN ?", deviceIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出设备类型与型号失败")
}
for _, row := range rows {
result[row.ID] = row
}
return result, nil
}
// expiringAssetShop 是执行时当前店铺归属投影。
type expiringAssetShop struct {
ID uint `gorm:"column:id"`
ShopName string `gorm:"column:shop_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
}
// expiringAssetDevice 是执行时当前设备类型与型号投影。
type expiringAssetDevice struct {
ID uint `gorm:"column:id"`
DeviceType string `gorm:"column:device_type"`
DeviceModel string `gorm:"column:device_model"`
}
// formatRemainingDaysValue 输出列表同一最终到期推算给出的剩余上海自然日天数;无法精确推算时为空。
func formatRemainingDaysValue(days *int) string {
if days == nil {
return ""
}
return strconv.Itoa(*days)
}

View File

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

View File

@@ -0,0 +1,334 @@
package exporter
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// 导出空值占位:分母为零的比率与卡均、以及无快照时的空指标一律写「-」。
const operationsReportEmptyValue = "-"
// 合计行的分组列值。
const operationsReportTotalGroup = "合计"
// OperationsActivationDataSource 设备激活情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次,
// 阻止通过通用导出入口以代理身份创建本场景任务后读到运营报表数据。
type OperationsActivationDataSource struct {
query *operationsreportquery.Query
}
// NewOperationsActivationDataSource 创建设备激活情况报表导出数据源。
func NewOperationsActivationDataSource(db *gorm.DB) *OperationsActivationDataSource {
return &OperationsActivationDataSource{query: operationsreportquery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *OperationsActivationDataSource) Scene() string {
return constants.ExportTaskSceneOperationsActivation
}
// Count 统计导出行数(分组行 + 合计行)。
func (s *OperationsActivationDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
result, err := s.build(ctx, params)
if err != nil {
return 0, err
}
return len(result.rows), nil
}
// Headers 返回设备激活情况导出表头。
func (s *OperationsActivationDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return result.headers, nil
}
// Fetch 按 offset/limit 返回设备激活情况导出行。
func (s *OperationsActivationDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return sliceOperationsReportRows(result.rows, offset, limit), nil
}
// build 构造导出表头与全部行(分组行 + 合计行)。
func (s *OperationsActivationDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) {
if err := ensureOperationsReportExportAllowed(params); err != nil {
return nil, err
}
request, err := activationExportRequest(params)
if err != nil {
return nil, err
}
response, err := s.query.ActivationSummary(frozenQueryContext(ctx, params), request)
if err != nil {
return nil, err
}
headers := []string{
response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数",
"累计在网数", "活跃用户数", "累计用量(GB)", "单用户卡均(GB)", "含零预测卡均(GB)", "不含零预测卡均(GB)",
}
rows := make([][]string, 0, len(response.Items)+1)
for _, item := range response.Items {
rows = append(rows, []string{
item.GroupValue,
formatOptionalInt64(item.PurchasedDeviceCount),
formatOptionalInt64(item.ActivatedDeviceCount),
formatOptionalFloat(item.ActivationRate),
formatOptionalInt64(item.NewActivatedDeviceCount),
formatOptionalInt64(item.OnlineDeviceCount),
formatOptionalInt64(item.ActiveDeviceCount),
formatOptionalFloat(item.TotalRealTrafficGB),
formatOptionalFloat(item.PerUserAverageGB),
formatOptionalFloat(item.ForecastAverageIncludingZeroGB),
formatOptionalFloat(item.ForecastAverageExcludingZeroGB),
})
}
if response.Totals != nil {
total := response.Totals
rows = append(rows, []string{
operationsReportTotalGroup,
formatOptionalInt64(total.PurchasedDeviceCount),
formatOptionalInt64(total.ActivatedDeviceCount),
formatOptionalFloat(total.ActivationRate),
formatOptionalInt64(total.NewActivatedDeviceCount),
formatOptionalInt64(total.OnlineDeviceCount),
formatOptionalInt64(total.ActiveDeviceCount),
formatOptionalFloat(total.TotalRealTrafficGB),
formatOptionalFloat(total.PerUserAverageGB),
formatOptionalFloat(total.ForecastAverageIncludingZeroGB),
formatOptionalFloat(total.ForecastAverageExcludingZeroGB),
})
}
return &operationsReportResult{headers: headers, rows: rows}, nil
}
// OperationsRenewalDataSource 套餐续费情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景同样按任务内冻结的账号类型复核导出资格。
type OperationsRenewalDataSource struct {
query *operationsreportquery.Query
}
// NewOperationsRenewalDataSource 创建套餐续费情况报表导出数据源。
func NewOperationsRenewalDataSource(db *gorm.DB) *OperationsRenewalDataSource {
return &OperationsRenewalDataSource{query: operationsreportquery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *OperationsRenewalDataSource) Scene() string {
return constants.ExportTaskSceneOperationsRenewal
}
// Count 统计导出行数(分组行 + 合计行)。
func (s *OperationsRenewalDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
result, err := s.build(ctx, params)
if err != nil {
return 0, err
}
return len(result.rows), nil
}
// Headers 返回套餐续费情况导出表头。
func (s *OperationsRenewalDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return result.headers, nil
}
// Fetch 按 offset/limit 返回套餐续费情况导出行。
func (s *OperationsRenewalDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return sliceOperationsReportRows(result.rows, offset, limit), nil
}
// build 构造导出表头与全部行(分组行 + 合计行)。
func (s *OperationsRenewalDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) {
if err := ensureOperationsReportExportAllowed(params); err != nil {
return nil, err
}
request, err := renewalExportRequest(params)
if err != nil {
return nil, err
}
response, err := s.query.RenewalSummary(frozenQueryContext(ctx, params), request)
if err != nil {
return nil, err
}
headers := []string{response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"}
rows := make([][]string, 0, len(response.Items)+1)
for _, item := range response.Items {
rows = append(rows, []string{
item.GroupValue,
formatOptionalInt64(item.DueAssetCount),
formatOptionalInt64(item.RenewedAssetCount),
formatOptionalFloat(item.RenewalRate),
formatOptionalInt64(item.NewUnrenewedAssetCount),
})
}
if response.Totals != nil {
total := response.Totals
rows = append(rows, []string{
operationsReportTotalGroup,
formatOptionalInt64(total.DueAssetCount),
formatOptionalInt64(total.RenewedAssetCount),
formatOptionalFloat(total.RenewalRate),
formatOptionalInt64(total.NewUnrenewedAssetCount),
})
}
return &operationsReportResult{headers: headers, rows: rows}, nil
}
// operationsReportResult 是一次导出构造的表头与全部行。
type operationsReportResult struct {
headers []string
rows [][]string
}
// activationExportRequest 把任务冻结的筛选快照还原为汇总查询请求。
// 时间边界只按统一严格解析器解析冻结值,非法值返回错误由调用方落任务失败。
func activationExportRequest(params ExportParams) (dto.OperationsActivationSummaryRequest, error) {
start, end, err := frozenOperationsReportRange(params.Filters)
if err != nil {
return dto.OperationsActivationSummaryRequest{}, err
}
groupBy, err := frozenOperationsReportGroupBy(params.Filters)
if err != nil {
return dto.OperationsActivationSummaryRequest{}, err
}
return dto.OperationsActivationSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil
}
// renewalExportRequest 把任务冻结的筛选快照还原为汇总查询请求。
func renewalExportRequest(params ExportParams) (dto.OperationsRenewalSummaryRequest, error) {
start, end, err := frozenOperationsReportRange(params.Filters)
if err != nil {
return dto.OperationsRenewalSummaryRequest{}, err
}
groupBy, err := frozenOperationsReportGroupBy(params.Filters)
if err != nil {
return dto.OperationsRenewalSummaryRequest{}, err
}
return dto.OperationsRenewalSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil
}
// operationsReportGroupByKey 是冻结筛选中的分组维度键。
// 未选择分组维度时冻结为空串,导出仍然只有唯一一行「全部」,因此空值也按已冻结处理。
const operationsReportGroupByKey = "group_by"
// frozenOperationsReportGroupBy 读取冻结的分组维度;键缺失或空串都表示未选择分组维度。
func frozenOperationsReportGroupBy(filters map[string]any) (string, error) {
value, exists := filters[operationsReportGroupByKey]
if !exists || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", errors.New(errors.CodeInvalidParam, "导出筛选的分组维度格式不正确")
}
return text, nil
}
// frozenOperationsReportRange 读取冻结的时间边界并复用统一严格解析器校验格式与顺序。
func frozenOperationsReportRange(filters map[string]any) (string, string, error) {
start, err := frozenOperationsReportTime(filters, exportTimeFilterStartKey)
if err != nil {
return "", "", err
}
end, err := frozenOperationsReportTime(filters, exportTimeFilterEndKey)
if err != nil {
return "", "", err
}
if _, _, err := utils.ParseTimeRange(start, end); err != nil {
return "", "", err
}
return start, end, nil
}
// frozenOperationsReportTime 读取单个冻结的时间边界值。
func frozenOperationsReportTime(filters map[string]any, key string) (string, error) {
value, exists := filters[key]
if !exists || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", utils.TimeFilterFormatError(key)
}
if text == "" {
return "", nil
}
return text, nil
}
// frozenQueryContext 以任务内冻结的账号类型与可见店铺范围构造查询上下文。
// 执行期不读取当前请求上下文:创建后的角色、店铺归属或筛选变化都不会扩大或收紧已建任务的数据集。
func frozenQueryContext(ctx context.Context, params ExportParams) context.Context {
return middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserType: params.UserType,
SubordinateShopIDs: params.ScopeShopIDs,
})
}
// ensureOperationsReportExportAllowed 只允许超级管理员与平台账号使用运营报表导出场景。
// 判定依据是任务内冻结的账号类型,不读取当前请求上下文,因此创建后角色变化不会放宽或收紧已建任务。
func ensureOperationsReportExportAllowed(params ExportParams) error {
if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform {
return nil
}
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
// sliceOperationsReportRows 按 offset/limit 切分导出行。
func sliceOperationsReportRows(rows [][]string, offset, limit int) [][]string {
if offset < 0 {
offset = 0
}
if limit <= 0 || offset >= len(rows) {
return [][]string{}
}
end := offset + limit
if end > len(rows) {
end = len(rows)
}
return rows[offset:end]
}
// formatOptionalInt64 输出可选整数,为空写「-」。
func formatOptionalInt64(value *int64) string {
if value == nil {
return operationsReportEmptyValue
}
return strconv.FormatInt(*value, 10)
}
// formatOptionalFloat 输出可选小数(保留两位),为空写「-」。
func formatOptionalFloat(value *float64) string {
if value == nil {
return operationsReportEmptyValue
}
return strconv.FormatFloat(*value, 'f', 2, 64)
}

View File

@@ -148,11 +148,15 @@ func (s *OrderDataSource) applyFilters(ctx context.Context, query *gorm.DB, para
if sellerShopID, ok := filterUint(params.Filters, "seller_shop_id"); ok {
query = query.Where("o.seller_shop_id = ?", sellerShopID)
}
if start, ok := filterTime(params.Filters, "start_time"); ok {
query = query.Where("o.created_at >= ?", start)
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if end, ok := filterTime(params.Filters, "end_time"); ok {
query = query.Where("o.created_at <= ?", end)
if start != nil {
query = query.Where("o.created_at >= ?", *start)
}
if end != nil {
query = query.Where("o.created_at <= ?", *end)
}
if buyerPhone, ok := filterString(params.Filters, "buyer_phone"); ok {
query = query.Where("o.buyer_phone = ?", buyerPhone)

View File

@@ -0,0 +1,60 @@
package exporter
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// businessUserGroupNames 按执行时当前业务员账号批量推导业务用户组名称。
// 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接;达量预警与临期导出共用同一口径。
func businessUserGroupNames(ctx context.Context, db *gorm.DB, ownerIDs []uint) (map[uint]string, error) {
result := make(map[uint]string, len(ownerIDs))
if len(ownerIDs) == 0 {
return result, nil
}
var rows []struct {
AccountID uint `gorm:"column:account_id"`
GroupName string `gorm:"column:group_name"`
}
if err := db.WithContext(ctx).Table("tb_business_user_group_member AS m").
Select("m.account_id, g.name AS group_name").
Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL").
Where("m.account_id IN ? AND m.deleted_at IS NULL", ownerIDs).
Order("m.account_id ASC, g.sort_order ASC, g.id ASC").
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败")
}
for _, row := range rows {
if existing := result[row.AccountID]; existing != "" {
result[row.AccountID] = existing + "、" + row.GroupName
continue
}
result[row.AccountID] = row.GroupName
}
return result, nil
}
// loadAccountNames 按账号 ID 批量读取账号名称,供导出侧执行时当前归属补充。
func loadAccountNames(ctx context.Context, db *gorm.DB, accountIDs []uint) (map[uint]string, error) {
result := make(map[uint]string, len(accountIDs))
if len(accountIDs) == 0 {
return result, nil
}
var rows []struct {
ID uint `gorm:"column:id"`
Username string `gorm:"column:username"`
}
if err := db.WithContext(ctx).Table("tb_account").
Select("id, username").
Where("id IN ? AND deleted_at IS NULL", accountIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账号名称失败")
}
for _, row := range rows {
result[row.ID] = row.Username
}
return result, nil
}

View File

@@ -0,0 +1,340 @@
package exporter
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PackageTrafficAlertDataSource 套餐真流量达量预警导出数据源。
//
// 粒度为一条预警记录。套餐、用量、总量、阈值、到期时间与资产标识类列一律读预警行冻结的触发快照;
// 店铺、业务员与用户组按导出执行时当前归属补充,用户组按既有实时推导,不写入店铺表。
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再校验一次,
// 阻止通过通用导出入口以代理身份创建本场景任务后读到预警数据。
type PackageTrafficAlertDataSource struct {
db *gorm.DB
}
// NewPackageTrafficAlertDataSource 创建套餐真流量达量预警导出数据源。
func NewPackageTrafficAlertDataSource(db *gorm.DB) *PackageTrafficAlertDataSource {
return &PackageTrafficAlertDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *PackageTrafficAlertDataSource) Scene() string {
return constants.ExportTaskScenePackageTrafficAlert
}
// Count 统计导出预警行数。
func (s *PackageTrafficAlertDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
if err := ensurePackageTrafficAlertExportAllowed(params); err != nil {
return 0, err
}
var total int64
query, err := s.applyFilters(s.baseQuery(ctx, params), params)
if err != nil {
return 0, err
}
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回套餐真流量达量预警导出表头。
// 表头在 dispatch 阶段冻结,历史任务重导出沿用同一列序;不含任何运营商通道列。
func (s *PackageTrafficAlertDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"资产类型", "资产标识", "对应标识符", "卡标识", "设备类型", "设备型号",
"套餐名称", "真流量已用量(MB)", "真流量额度(MB)", "比例(%)", "阈值快照(%)",
"到期时间", "剩余天数", "触发时间", "店铺", "业务员", "用户组", "通知投递结果",
}, nil
}
// Fetch 按 offset/limit 查询预警导出数据。
func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
if err := ensurePackageTrafficAlertExportAllowed(params); err != nil {
return nil, err
}
var items []packageTrafficAlertExportRow
filtered, err := s.applyFilters(s.baseQuery(ctx, params), params)
if err != nil {
return nil, err
}
query := filtered.
Select(`
a.asset_type,
a.asset_identifier_snapshot,
a.counterpart_identifier_snapshot,
a.card_identifier_snapshot,
a.device_type_snapshot,
a.device_model_snapshot,
a.package_name_snapshot,
a.used_mb_snapshot,
a.limit_mb_snapshot,
a.usage_percent_snapshot,
a.threshold_percent_snapshot,
a.expires_at_snapshot,
a.triggered_at,
a.shop_id_snapshot,
a.shop_name_snapshot,
a.business_owner_account_id_snapshot,
a.business_owner_name_snapshot,
a.notification_event_id,
sh.id AS current_shop_id,
COALESCE(sh.shop_name, '') AS current_shop_name,
owner.id AS current_owner_id,
COALESCE(owner.username, '') AS current_owner_name,
oe.status AS outbox_status,
n.id AS notification_id
`).
Order("a.triggered_at DESC").Order("a.id DESC").
Limit(limit).Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警导出数据失败")
}
groupNames, err := s.loadBusinessUserGroupNames(ctx, items)
if err != nil {
return nil, err
}
now := time.Now().UTC()
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
assetTypeName(item.AssetType),
item.AssetIdentifier,
item.CounterpartIdentifier,
item.CardIdentifier,
item.DeviceType,
item.DeviceModel,
item.PackageName,
strconv.FormatInt(item.UsedMB, 10),
strconv.FormatInt(item.LimitMB, 10),
formatPercentValue(item.UsagePercent),
formatPercentValue(item.ThresholdPercent),
formatOptionalTime(item.ExpiresAt),
formatRemainingDays(item.ExpiresAt, now),
item.TriggeredAt.Format(exportTimeLayout),
item.CurrentShopName,
item.CurrentOwnerName,
currentOwnerGroupName(groupNames, item.CurrentOwnerID),
constants.GetPackageTrafficAlertNotifyStatusName(resolveAlertNotifyStatus(item)),
})
}
return rows, nil
}
// baseQuery 构造预警导出基础查询。
// 归属展示列按执行时当前归属补充:资产 → 当前店铺 → 店铺当前业务员;用户组随后按业务员账号实时推导。
func (s *PackageTrafficAlertDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_package_traffic_alert AS a").
Joins("LEFT JOIN tb_iot_card AS c ON a.asset_type = ? AND c.id = a.asset_id AND c.deleted_at IS NULL",
constants.AssetTypeIotCard).
Joins("LEFT JOIN tb_device AS d ON a.asset_type = ? AND d.id = a.asset_id AND d.deleted_at IS NULL",
constants.AssetTypeDevice).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = COALESCE(c.shop_id, d.shop_id) AND sh.deleted_at IS NULL").
Joins("LEFT JOIN tb_account AS owner ON owner.id = sh.business_owner_account_id AND owner.deleted_at IS NULL").
Joins("LEFT JOIN tb_outbox_event AS oe ON oe.event_id = a.notification_event_id").
Joins("LEFT JOIN tb_notification AS n ON n.event_id = a.notification_event_id")
// 数据范围使用导出侧范围过滤(空范围拒绝),不得使用请求上下文版过滤(空范围语义相反)。
return applyExportShopScope(query, params, "a.shop_id_snapshot")
}
// applyFilters 应用导出筛选快照。
// 筛选口径与列表一致,都作用在触发快照列上;时间范围按触发时间闭区间解析,
// 冻结值一律按统一严格解析器解析,非法值返回错误由调用方落任务失败。
func (s *PackageTrafficAlertDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
if packageID, ok := filterUint(params.Filters, "package_id"); ok {
query = query.Where("a.package_id = ?", packageID)
}
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("a.shop_id_snapshot = ?", shopID)
}
if ownerID, ok := filterUint(params.Filters, "business_owner_account_id"); ok {
query = query.Where("a.business_owner_account_id_snapshot = ?", ownerID)
}
if assetType, ok := filterString(params.Filters, "asset_type"); ok {
query = query.Where("a.asset_type = ?", assetType)
}
if identifier, ok := filterString(params.Filters, "asset_identifier"); ok {
pattern := "%" + identifier + "%"
query = query.Where("(a.asset_identifier_snapshot ILIKE ? OR a.card_identifier_snapshot ILIKE ? "+
"OR a.counterpart_identifier_snapshot ILIKE ?)", pattern, pattern, pattern)
}
if threshold, ok := alertFilterFloat(params.Filters, "threshold_percent"); ok {
query = query.Where("a.threshold_percent_snapshot = ?",
packagetrafficalert.NormalizeThresholdPercent(threshold))
}
startTime, endTime, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if startTime != nil {
query = query.Where("a.triggered_at >= ?", *startTime)
}
if endTime != nil {
query = query.Where("a.triggered_at <= ?", *endTime)
}
if status, ok := filterInt(params.Filters, "notification_status"); ok {
query = applyAlertNotificationStatusFilter(query, status)
}
return query, nil
}
// applyAlertNotificationStatusFilter 按通知投递结果筛选,口径与读侧列表一致。
func applyAlertNotificationStatusFilter(query *gorm.DB, status int) *gorm.DB {
const hasEvent = "a.notification_event_id <> ''"
const hasNotification = "n.id IS NOT NULL"
switch status {
case constants.PackageTrafficAlertNotifyNoBusinessOwner:
return query.Where("a.notification_event_id = ''")
case constants.PackageTrafficAlertNotifyNotified:
return query.Where(hasEvent).Where(hasNotification)
case constants.PackageTrafficAlertNotifyPending:
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
Where("oe.status IN ?", []int{constants.OutboxStatusPending, constants.OutboxStatusDelivering})
case constants.PackageTrafficAlertNotifyFailed:
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
Where("oe.status = ?", constants.OutboxStatusFailed)
case constants.PackageTrafficAlertNotifyRecipientGone:
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
Where("oe.status = ?", constants.OutboxStatusDelivered)
default:
return query
}
}
// loadBusinessUserGroupNames 按执行时当前业务员账号批量推导业务用户组名称。
// 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接。
func (s *PackageTrafficAlertDataSource) loadBusinessUserGroupNames(ctx context.Context,
items []packageTrafficAlertExportRow) (map[uint]string, error) {
ownerIDs := make([]uint, 0, len(items))
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.CurrentOwnerID == nil || *item.CurrentOwnerID == 0 {
continue
}
if _, exists := seen[*item.CurrentOwnerID]; exists {
continue
}
seen[*item.CurrentOwnerID] = struct{}{}
ownerIDs = append(ownerIDs, *item.CurrentOwnerID)
}
return businessUserGroupNames(ctx, s.db, ownerIDs)
}
// packageTrafficAlertExportRow 是预警导出的一行原始投影。
type packageTrafficAlertExportRow struct {
AssetType string `gorm:"column:asset_type"`
AssetIdentifier string `gorm:"column:asset_identifier_snapshot"`
CounterpartIdentifier string `gorm:"column:counterpart_identifier_snapshot"`
CardIdentifier string `gorm:"column:card_identifier_snapshot"`
DeviceType string `gorm:"column:device_type_snapshot"`
DeviceModel string `gorm:"column:device_model_snapshot"`
PackageName string `gorm:"column:package_name_snapshot"`
UsedMB int64 `gorm:"column:used_mb_snapshot"`
LimitMB int64 `gorm:"column:limit_mb_snapshot"`
UsagePercent float64 `gorm:"column:usage_percent_snapshot"`
ThresholdPercent float64 `gorm:"column:threshold_percent_snapshot"`
ExpiresAt *time.Time `gorm:"column:expires_at_snapshot"`
TriggeredAt time.Time `gorm:"column:triggered_at"`
ShopIDSnapshot uint `gorm:"column:shop_id_snapshot"`
ShopNameSnapshot string `gorm:"column:shop_name_snapshot"`
BusinessOwnerID *uint `gorm:"column:business_owner_account_id_snapshot"`
BusinessOwnerName string `gorm:"column:business_owner_name_snapshot"`
NotificationEventID string `gorm:"column:notification_event_id"`
CurrentShopID *uint `gorm:"column:current_shop_id"`
CurrentShopName string `gorm:"column:current_shop_name"`
CurrentOwnerID *uint `gorm:"column:current_owner_id"`
CurrentOwnerName string `gorm:"column:current_owner_name"`
OutboxStatus *int `gorm:"column:outbox_status"`
NotificationID *uint `gorm:"column:notification_id"`
}
// resolveAlertNotifyStatus 推导导出行的通知投递结果,与列表、详情同口径。
func resolveAlertNotifyStatus(item packageTrafficAlertExportRow) int {
return constants.ResolvePackageTrafficAlertNotifyStatus(
item.NotificationEventID != "", item.OutboxStatus, item.NotificationID != nil)
}
// ensurePackageTrafficAlertExportAllowed 只允许超级管理员与平台账号使用本场景。
// 通用导出入口不做场景级角色校验,因此这一层门禁是防止代理越权读取预警数据的必要防线。
func ensurePackageTrafficAlertExportAllowed(params ExportParams) error {
if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform {
return nil
}
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
// alertFilterFloat 解析导出筛选中的小数百分比。
// 阈值筛选只在预警导出使用,为避免改动既有共享筛选助手文件,这里就地解析。
func alertFilterFloat(filters map[string]any, key string) (float64, bool) {
value, ok := filters[key]
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
if err != nil {
return 0, false
}
return parsed, true
default:
return 0, false
}
}
// currentOwnerGroupName 返回执行时当前业务员的用户组名称,无有效业务员时为空。
func currentOwnerGroupName(groupNames map[uint]string, ownerID *uint) string {
if ownerID == nil {
return ""
}
return groupNames[*ownerID]
}
// assetTypeName 返回资产类型的中文名称。
func assetTypeName(assetType string) string {
if assetType == constants.AssetTypeDevice {
return "设备"
}
return "物联网卡"
}
// formatPercentValue 输出保留两位小数的百分比。
func formatPercentValue(value float64) string {
return strconv.FormatFloat(value, 'f', 2, 64)
}
// formatRemainingDays 按上海自然日推算剩余天数;无到期时间时输出空字符串。
func formatRemainingDays(expiresAt *time.Time, now time.Time) string {
if expiresAt == nil {
return ""
}
location := time.FixedZone("Asia/Shanghai", 8*60*60)
localExpires := expiresAt.In(location)
localNow := now.In(location)
expiresDate := time.Date(localExpires.Year(), localExpires.Month(), localExpires.Day(), 0, 0, 0, 0, location)
nowDate := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location)
days := int(expiresDate.Sub(nowDate).Hours() / 24)
return strconv.Itoa(days)
}

View File

@@ -36,6 +36,11 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewAgentRechargeDataSource(db),
NewRefundDataSource(db),
NewExchangeDataSource(db),
NewCommissionRecordDataSource(db),
NewPackageTrafficAlertDataSource(db),
NewExpiringAssetDataSource(db),
NewOperationsActivationDataSource(db),
NewOperationsRenewalDataSource(db),
)
}
@@ -71,7 +76,12 @@ func IsSupportedScene(scene string) bool {
constants.ExportTaskSceneAgentWalletTransaction,
constants.ExportTaskSceneAgentRecharge,
constants.ExportTaskSceneRefund,
constants.ExportTaskSceneExchange:
constants.ExportTaskSceneExchange,
constants.ExportTaskSceneCommissionRecord,
constants.ExportTaskScenePackageTrafficAlert,
constants.ExportTaskSceneExpiringAsset,
constants.ExportTaskSceneOperationsActivation,
constants.ExportTaskSceneOperationsRenewal:
return true
default:
return false

View File

@@ -0,0 +1,125 @@
package exporter
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// 受影响导出场景统一使用的冻结时间筛选键。
const (
exportTimeFilterStartKey = "start_time"
exportTimeFilterEndKey = "end_time"
)
// timeFilterScenes 是纳入统一时间筛选契约的导出场景。
// 未列入的场景保持既有宽松解析,见设计文档「已知差异登记」。
// 达量预警的列表与创建入口仍接受全局宽松时间格式并由创建期归一为 UTC RFC3339 秒级串,
// 但执行期同样只按冻结值严格解析,冻结值非法时任务落失败。
var timeFilterScenes = map[string]struct{}{
constants.ExportTaskSceneExchange: {},
constants.ExportTaskSceneAgentRecharge: {},
constants.ExportTaskSceneOrder: {},
constants.ExportTaskSceneCommissionRecord: {},
constants.ExportTaskSceneExpiringAsset: {},
constants.ExportTaskScenePackageTrafficAlert: {},
// 运营报表的两个导出场景在创建期冻结 start_time/end_time 与分组维度,执行期只按冻结值严格解析。
constants.ExportTaskSceneOperationsActivation: {},
constants.ExportTaskSceneOperationsRenewal: {},
}
// legacyTimeFilterKeys 是受影响场景必须拒绝的旧时间筛选键。
// 键存在且非空时一律拒绝,避免旧前端静默丢条件后导出全量数据。
var legacyTimeFilterKeys = map[string][]string{
constants.ExportTaskSceneExchange: {"created_at_start", "created_at_end"},
constants.ExportTaskSceneAgentRecharge: {"start_date", "end_date"},
}
// NormalizeTaskTimeFilters 在创建导出任务时按场景校验并规范化时间边界。
// 规范化结果为 UTC RFC3339 秒级字符串,随筛选快照一并冻结;
// 非法格式、旧参数键与开始晚于结束一律在创建期拒绝。
func NormalizeTaskTimeFilters(scene string, query map[string]interface{}) error {
if !isTimeFilterScene(scene) {
return nil
}
filters, ok := query["filters"].(map[string]interface{})
if !ok {
return nil
}
if err := rejectLegacyTimeFilterKeys(scene, filters); err != nil {
return err
}
start, end, err := parseFrozenTimeRange(filters)
if err != nil {
return err
}
if start != nil {
filters[exportTimeFilterStartKey] = utils.FormatTimeFilterValue(*start)
}
if end != nil {
filters[exportTimeFilterEndKey] = utils.FormatTimeFilterValue(*end)
}
return nil
}
// ValidateTaskTimeFilters 校验导出任务筛选快照中的时间边界,创建期与执行期共用。
// 非法值(含变更前遗留任务的旧格式冻结值)返回错误,由调用方把任务落为失败并写安全失败摘要,
// 不得忽略该条件后放行全量数据。
func ValidateTaskTimeFilters(scene string, filters map[string]any) error {
if !isTimeFilterScene(scene) {
return nil
}
if err := rejectLegacyTimeFilterKeys(scene, filters); err != nil {
return err
}
_, _, err := parseFrozenTimeRange(filters)
return err
}
// strictTimeRange 解析任务冻结的 start_time/end_time 闭区间,供受影响场景构造执行期筛选。
// 冻结值必须是创建期规范化后的带时区 RFC3339 秒级字符串,任一端缺省表示该端不限。
func strictTimeRange(filters map[string]any) (*time.Time, *time.Time, error) {
return parseFrozenTimeRange(filters)
}
func isTimeFilterScene(scene string) bool {
_, ok := timeFilterScenes[scene]
return ok
}
func parseFrozenTimeRange(filters map[string]any) (*time.Time, *time.Time, error) {
start, err := frozenTimeFilterValue(filters, exportTimeFilterStartKey)
if err != nil {
return nil, nil, err
}
end, err := frozenTimeFilterValue(filters, exportTimeFilterEndKey)
if err != nil {
return nil, nil, err
}
return utils.ParseTimeRange(start, end)
}
func frozenTimeFilterValue(filters map[string]any, key string) (string, error) {
value, ok := filters[key]
if !ok || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", utils.TimeFilterFormatError(key)
}
return text, nil
}
func rejectLegacyTimeFilterKeys(scene string, filters map[string]any) error {
for _, key := range legacyTimeFilterKeys[scene] {
text, exists := filters[key].(string)
if !exists || text == "" {
continue
}
return errors.New(errors.CodeInvalidParam, key+" 已废弃,请改用 start_time 与 end_time")
}
return nil
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// AgentRechargeHandler 代理预充值 Handler
@@ -190,14 +191,21 @@ func (h *AgentRechargeHandler) PaymentVoucherOCR(c *fiber.Ctx) error {
// GET /api/admin/agent-recharges
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
var req dto.AgentRechargeListRequest
if err := rejectLegacyTimeParams(c, "start_date", "end_date"); err != nil {
return err
}
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
list, total, err := h.service.List(c.UserContext(), &req)
list, total, err := h.service.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -7,9 +7,11 @@ import (
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/hibiken/asynq"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
@@ -23,6 +25,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"go.uber.org/zap"
)
@@ -40,6 +43,13 @@ type AssetHandler struct {
observationSeries cardObservationApp.BestEffortSeriesDispatcher
packageExpiryQuery *packageExpiryQuery.Query
packageExpiryTrigger func(context.Context) error
assetExportService AssetExportTaskCreator
validator *validator.Validate
}
// AssetExportTaskCreator 定义创建导出任务的用例,供临期导出受控入口调用。
type AssetExportTaskCreator interface {
CreateTask(ctx context.Context, req *dto.CreateExportTaskRequest) (*dto.CreateExportTaskResponse, error)
}
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
@@ -52,6 +62,16 @@ func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) {
h.packageExpiryQuery = query
}
// SetExportTaskService 注入导出任务创建用例,供临期资产导出受控入口使用。
func (h *AssetHandler) SetExportTaskService(creator AssetExportTaskCreator) {
h.assetExportService = creator
}
// SetValidator 注入请求参数校验器,供新增的受控入口按 DTO 规则校验并返回字段级中文提示。
func (h *AssetHandler) SetValidator(v *validator.Validate) {
h.validator = v
}
// SetPackageExpiryQueue 注入套餐临期提醒任务队列。
func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) {
if client == nil {
@@ -138,6 +158,9 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
// GET /api/admin/expiring-assets
func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
var request dto.ExpiringAssetListRequest
if err := rejectLegacyTimeParams(c, "expires_from", "expires_to"); err != nil {
return err
}
if err := c.QueryParser(&request); err != nil {
logger.GetAppLogger().Warn("临期资产列表参数解析失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
@@ -146,7 +169,11 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
if h.packageExpiryQuery == nil {
return errors.New(errors.CodeInternalError, "套餐临期查询未配置")
}
result, err := h.packageExpiryQuery.List(c.UserContext(), request)
startTime, endTime, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return err
}
result, err := h.packageExpiryQuery.List(c.UserContext(), request, startTime, endTime)
if err != nil {
return err
}
@@ -155,6 +182,67 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
})
}
// ExportExpiring 创建临期资产异步导出任务。
// POST /api/admin/expiring-assets/export
// 只暴露受控入口;导出任务创建时冻结当页全部筛选、时间边界、操作者与可见店铺范围。
func (h *AssetHandler) ExportExpiring(c *fiber.Ctx) error {
var req dto.ExportExpiringAssetRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("导出临期资产参数不合法", &req, err))
}
if h.assetExportService == nil {
return errors.New(errors.CodeInternalError, "导出任务服务未配置")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
createRequest := dto.CreateExportTaskRequest{
Scene: constants.ExportTaskSceneExpiringAsset,
Format: req.Format,
Query: map[string]interface{}{"filters": expiringAssetExportFilters(req, startTime, endTime)},
}
result, err := h.assetExportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// expiringAssetExportFilters 把临期导出请求转换为导出任务的筛选快照。
// 筛选集合与临期列表一致;时间边界在创建时规范化为 UTC RFC3339 秒级字符串后冻结。
func expiringAssetExportFilters(req dto.ExportExpiringAssetRequest, startTime, endTime *time.Time) map[string]interface{} {
filters := make(map[string]interface{})
if req.AssetType != "" {
filters["asset_type"] = req.AssetType
}
if req.Keyword != "" {
filters["keyword"] = req.Keyword
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if req.PackageID != nil {
filters["package_id"] = *req.PackageID
}
if req.DaysMin != nil {
filters["days_min"] = *req.DaysMin
}
if req.DaysMax != nil {
filters["days_max"] = *req.DaysMax
}
if startTime != nil {
filters["start_time"] = utils.FormatTimeFilterValue(*startTime)
}
if endTime != nil {
filters["end_time"] = utils.FormatTimeFilterValue(*endTime)
}
return filters
}
// TriggerPackageExpiryReminder 手动提交每日临期提醒扫描任务。
// POST /api/admin/expiring-assets/reminder-scan
func (h *AssetHandler) TriggerPackageExpiryReminder(c *fiber.Ctx) error {

View File

@@ -9,6 +9,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type AssetAllocationRecordHandler struct {
@@ -21,9 +22,16 @@ func NewAssetAllocationRecordHandler(service *assetAllocationRecordService.Servi
func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error {
var req dto.ListAssetAllocationRecordRequest
if err := rejectLegacyTimeParams(c, "created_at_start", "created_at_end"); err != nil {
return err
}
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
@@ -35,7 +43,7 @@ func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error {
}
}
result, err := h.service.List(ctx, &req, userShopID)
result, err := h.service.List(ctx, &req, startTime, endTime, userShopID)
if err != nil {
return err
}

View File

@@ -0,0 +1,82 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AssetAutoRenewalConfigHandler 资产钱包自动续费配置 Handler。
//
// 路由组已有「仅超级管理员与平台账号」门禁应用层仍会复核账号类型ENG-AUTHZ-001
// 因此代理、企业与个人客户即使绕过路由也无任何入口。
type AssetAutoRenewalConfigHandler struct {
service *assetAutoRenewalApp.Service
validator *validator.Validate
}
// NewAssetAutoRenewalConfigHandler 创建资产钱包自动续费配置 Handler。
func NewAssetAutoRenewalConfigHandler(service *assetAutoRenewalApp.Service, validator *validator.Validate) *AssetAutoRenewalConfigHandler {
return &AssetAutoRenewalConfigHandler{service: service, validator: validator}
}
// GetConfig 读取资产钱包自动续费配置。
// GET /api/admin/asset-auto-renewal-config
func (h *AssetAutoRenewalConfigHandler) GetConfig(c *fiber.Ctx) error {
config, err := h.service.GetConfig(c.UserContext())
if err != nil {
return err
}
return response.Success(c, toAssetAutoRenewalConfigResponse(config))
}
// UpdateConfig 保存资产钱包自动续费配置。
// PUT /api/admin/asset-auto-renewal-config
func (h *AssetAutoRenewalConfigHandler) UpdateConfig(c *fiber.Ctx) error {
var req dto.UpdateAssetAutoRenewalConfigRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("保存自动续费配置参数不合法", &req, err))
}
config, err := h.service.SaveConfig(c.UserContext(), assetAutoRenewalApp.ConfigRequest{
Enabled: req.Enabled,
Scope: req.Scope,
PackageIDs: req.PackageIDs,
DaysBeforeExpiry: req.DaysBeforeExpiry,
})
if err != nil {
return err
}
return response.Success(c, toAssetAutoRenewalConfigResponse(config))
}
// toAssetAutoRenewalConfigResponse 组装配置响应枚举附加中文名称字段ENG-DTO-001
func toAssetAutoRenewalConfigResponse(config *assetAutoRenewalApp.ConfigView) dto.AssetAutoRenewalConfigResponse {
packageIDs := config.PackageIDs
if packageIDs == nil {
packageIDs = []uint{}
}
enabledName := "关闭"
if config.Enabled == constants.AssetAutoRenewalConfigEnabledOn {
enabledName = "开启"
}
return dto.AssetAutoRenewalConfigResponse{
Enabled: config.Enabled,
Scope: config.Scope,
PackageIDs: packageIDs,
DaysBeforeExpiry: config.DaysBeforeExpiry,
ConfigVersion: config.ConfigVersion,
ScopeName: constants.GetAssetAutoRenewalScopeName(config.Scope),
EnabledName: enabledName,
Updater: config.Updater,
UpdatedAt: config.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}

View File

@@ -10,6 +10,7 @@ import (
enterpriseCardService "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type AuthorizationHandler struct {
@@ -25,14 +26,18 @@ func (h *AuthorizationHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListRecords(c.UserContext(), enterpriseCardService.ListRecordsRequest{
EnterpriseID: req.EnterpriseID,
ICCID: req.ICCID,
AuthorizerType: req.AuthorizerType,
Status: req.Status,
StartTime: req.StartTime,
EndTime: req.EndTime,
StartTime: startTime,
EndTime: endTime,
Page: req.Page,
PageSize: req.PageSize,
})

View File

@@ -0,0 +1,231 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
businessusergroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
businessusergroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// BusinessUserGroupHandler 业务用户组与店铺负责人批量交接处理器。
type BusinessUserGroupHandler struct {
service *businessusergroupApp.Service
query *businessusergroupQuery.Query
batchService *shopApp.BatchBusinessOwnerService
validator *validator.Validate
}
// NewBusinessUserGroupHandler 创建业务用户组处理器。
func NewBusinessUserGroupHandler(service *businessusergroupApp.Service, validator *validator.Validate) *BusinessUserGroupHandler {
return &BusinessUserGroupHandler{service: service, validator: validator}
}
// SetQuery 注入业务用户组读取投影。
func (h *BusinessUserGroupHandler) SetQuery(query *businessusergroupQuery.Query) {
h.query = query
}
// SetBatchService 注入店铺负责人批量交接事务脚本。
func (h *BusinessUserGroupHandler) SetBatchService(service *shopApp.BatchBusinessOwnerService) {
h.batchService = service
}
// Create 创建业务用户组。
// POST /api/admin/business-user-groups
func (h *BusinessUserGroupHandler) Create(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.CreateBusinessUserGroupRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.Create(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询业务用户组列表。
// GET /api/admin/business-user-groups
func (h *BusinessUserGroupHandler) List(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.BusinessUserGroupListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.query == nil {
return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置")
}
result, err := h.query.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// Detail 查询业务用户组详情。
// GET /api/admin/business-user-groups/:id
func (h *BusinessUserGroupHandler) Detail(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
if h.query == nil {
return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置")
}
result, err := h.query.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// Update 更新业务用户组的名称、业务线、排序、启停与备注;编码不可修改。
// PUT /api/admin/business-user-groups/:id
func (h *BusinessUserGroupHandler) Update(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
var request dto.UpdateBusinessUserGroupRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.Update(c.UserContext(), id, &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// Delete 二次确认删除无成员的业务用户组。
// DELETE /api/admin/business-user-groups/:id
func (h *BusinessUserGroupHandler) Delete(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
var request dto.BusinessUserGroupDeleteRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if !request.Confirm {
return errors.New(errors.CodeInvalidParam, "删除业务用户组必须二次确认")
}
if err := h.service.Delete(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// SetMembers 批量设置平台用户的业务用户组归属,直接替换原归属。
// PUT /api/admin/business-user-groups/:id/members
func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
var request dto.SetBusinessUserGroupMembersRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.SetMembers(c.UserContext(), id, &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// ClearMembers 批量清空平台用户的业务用户组归属。
// DELETE /api/admin/business-user-groups/members
func (h *BusinessUserGroupHandler) ClearMembers(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.ClearBusinessUserGroupMembersRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.ClearMembers(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// BatchUpdateShopBusinessOwner 勾选店铺批量设置或清空平台业务员负责人。
// PUT /api/admin/shops/business-owner/batch
func (h *BusinessUserGroupHandler) BatchUpdateShopBusinessOwner(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.BatchUpdateShopBusinessOwnerRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.batchService == nil {
return errors.New(errors.CodeInternalError, "店铺负责人批量交接服务尚未配置")
}
result, err := h.batchService.Execute(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *BusinessUserGroupHandler) validate(request any) error {
if h.validator == nil {
return errors.New(errors.CodeInternalError)
}
return h.validator.Struct(request)
}
// requirePlatformManagement 校验调用者仅限超级管理员与平台账号,代理与企业统一返回 403。
func requirePlatformManagement(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return nil
}

View File

@@ -10,6 +10,7 @@ import (
commissionWithdrawalService "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// CommissionWithdrawalHandler 提现申请管理处理器
@@ -28,8 +29,12 @@ func (h *CommissionWithdrawalHandler) ListWithdrawalRequests(c *fiber.Ctx) error
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListWithdrawalRequests(c.UserContext(), &req)
result, err := h.service.ListWithdrawalRequests(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}
@@ -66,8 +71,10 @@ func (h *CommissionWithdrawalHandler) RejectWithdrawal(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// id 只来自路径,必须在校验前回填,否则 ID 的 required 恒失败。
req.ID = uint(id)
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
return errors.New(errors.CodeInvalidParam, validationMessage("提现驳回参数不合法", &req, err))
}
result, err := h.service.Reject(c.UserContext(), uint(id), &req)

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type DeviceImportHandler struct {
@@ -70,8 +71,12 @@ func (h *DeviceImportHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.List(c.UserContext(), &req)
result, err := h.service.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -3,12 +3,14 @@ package admin
import (
"context"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
@@ -16,7 +18,7 @@ import (
// ExchangeLister 定义换货列表读取用例。
type ExchangeLister interface {
List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error)
List(ctx context.Context, req *dto.ExchangeListRequest, startTime, endTime *time.Time) (*dto.ExchangeListResponse, error)
}
// ExchangeHandler 处理后台换货管理接口。
@@ -53,6 +55,9 @@ func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
// GET /api/admin/exchanges
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
var req dto.ExchangeListRequest
if err := rejectLegacyTimeParams(c, "created_at_start", "created_at_end"); err != nil {
return err
}
if err := c.QueryParser(&req); err != nil {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
@@ -61,8 +66,12 @@ func (h *ExchangeHandler) List(c *fiber.Ctx) error {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
data, err := h.listQuery.List(c.UserContext(), &req)
data, err := h.listQuery.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -9,6 +9,7 @@ import (
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// ExportTaskHandler 导出任务 Handler。
@@ -49,8 +50,12 @@ func (h *ExportTaskHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListTasks(c.UserContext(), &req)
result, err := h.service.ListTasks(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -0,0 +1,178 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
h5popupapp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
h5popupquery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// H5PopupConfigurationHandler H5 运营弹窗配置后台 Handler。
// 全部接口仅超级管理员与平台账号可用,代理与企业统一返回 403。
type H5PopupConfigurationHandler struct {
service *h5popupapp.ConfigurationService
query *h5popupquery.Query
validator *validator.Validate
}
// NewH5PopupConfigurationHandler 创建 H5 运营弹窗配置后台 Handler。
func NewH5PopupConfigurationHandler(service *h5popupapp.ConfigurationService, query *h5popupquery.Query, validate *validator.Validate) *H5PopupConfigurationHandler {
return &H5PopupConfigurationHandler{service: service, query: query, validator: validate}
}
// ListH5PopupConfigurations 查询运营弹窗配置列表。
// GET /api/admin/h5-popup-configurations
func (h *H5PopupConfigurationHandler) ListH5PopupConfigurations(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.H5PopupConfigurationListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.validator != nil {
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("运营弹窗配置列表参数不合法", &request, err))
}
}
if h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
result, err := h.query.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// GetH5PopupConfiguration 查询运营弹窗配置详情。
// GET /api/admin/h5-popup-configurations/:id
func (h *H5PopupConfigurationHandler) GetH5PopupConfiguration(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := h.parseID(c)
if err != nil {
return err
}
if h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateH5PopupConfiguration 创建运营弹窗配置,初始版本为 1。
// POST /api/admin/h5-popup-configurations
func (h *H5PopupConfigurationHandler) CreateH5PopupConfiguration(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.CreateH5PopupConfigurationRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.validator != nil {
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("创建运营弹窗配置参数不合法", &request, err))
}
}
if h.service == nil || h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
id, err := h.service.Create(c.UserContext(), request)
if err != nil {
return err
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateH5PopupConfiguration 更新运营弹窗配置并递增版本。
// PUT /api/admin/h5-popup-configurations/:id
func (h *H5PopupConfigurationHandler) UpdateH5PopupConfiguration(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := h.parseID(c)
if err != nil {
return err
}
var request dto.UpdateH5PopupConfigurationParams
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
// 路径来源字段必须由 Handler 回填后再校验,避免被请求体覆盖,也避免 required 恒失败。
request.ID = id
if h.validator != nil {
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("更新运营弹窗配置参数不合法", &request, err))
}
}
if h.service == nil || h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
if err := h.service.Update(c.UserContext(), id, request.UpdateH5PopupConfigurationRequest); err != nil {
return err
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// EnableH5PopupConfiguration 启用运营弹窗配置,仅影响后续候选并刷新最近更新时间。
// POST /api/admin/h5-popup-configurations/:id/enable
func (h *H5PopupConfigurationHandler) EnableH5PopupConfiguration(c *fiber.Ctx) error {
return h.setEnabled(c, true)
}
// DisableH5PopupConfiguration 停用运营弹窗配置,仅影响后续候选并刷新最近更新时间。
// POST /api/admin/h5-popup-configurations/:id/disable
func (h *H5PopupConfigurationHandler) DisableH5PopupConfiguration(c *fiber.Ctx) error {
return h.setEnabled(c, false)
}
func (h *H5PopupConfigurationHandler) setEnabled(c *fiber.Ctx, enabled bool) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := h.parseID(c)
if err != nil {
return err
}
if h.service == nil || h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
if err := h.service.SetEnabled(c.UserContext(), id, enabled); err != nil {
return err
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// parseID 从路径解析运营弹窗配置 ID。
func (h *H5PopupConfigurationHandler) parseID(c *fiber.Ctx) (uint, error) {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return 0, errors.New(errors.CodeInvalidParam, "运营弹窗配置ID不合法")
}
return uint(id), nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type IotCardImportHandler struct {
@@ -56,8 +57,12 @@ func (h *IotCardImportHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.List(c.UserContext(), &req)
result, err := h.service.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -0,0 +1,172 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// OperationsReportHandler 运营报表 Handler。
// 查询与导出只对超级管理员与平台账号开放路由组已有角色门禁Handler 不做任何跳过业务校验的分支。
type OperationsReportHandler struct {
query *operationsreportquery.Query
exportService *exportTaskService.Service
validator *validator.Validate
}
// NewOperationsReportHandler 创建运营报表 Handler。
func NewOperationsReportHandler(query *operationsreportquery.Query,
exportService *exportTaskService.Service, validator *validator.Validate) *OperationsReportHandler {
return &OperationsReportHandler{query: query, exportService: exportService, validator: validator}
}
// ActivationSummary 查询设备激活情况汇总。
// GET /api/admin/operations-reports/activation-summary
func (h *OperationsReportHandler) ActivationSummary(c *fiber.Ctx) error {
var req dto.OperationsActivationSummaryRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况汇总参数不合法", &req, err))
}
result, err := h.query.ActivationSummary(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ActivationTrend 查询设备激活情况日/月趋势。
// GET /api/admin/operations-reports/activation-trend
func (h *OperationsReportHandler) ActivationTrend(c *fiber.Ctx) error {
var req dto.OperationsActivationTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况趋势参数不合法", &req, err))
}
result, err := h.query.ActivationTrend(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// RenewalSummary 查询套餐续费情况汇总。
// GET /api/admin/operations-reports/package-renewal-summary
func (h *OperationsReportHandler) RenewalSummary(c *fiber.Ctx) error {
var req dto.OperationsRenewalSummaryRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况汇总参数不合法", &req, err))
}
result, err := h.query.RenewalSummary(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// RenewalTrend 查询套餐续费情况日/月趋势。
// GET /api/admin/operations-reports/package-renewal-trend
func (h *OperationsReportHandler) RenewalTrend(c *fiber.Ctx) error {
var req dto.OperationsRenewalTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况趋势参数不合法", &req, err))
}
result, err := h.query.RenewalTrend(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ExportActivationSummary 创建设备激活情况报表异步导出任务。
// POST /api/admin/operations-reports/activation-summary/export
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
func (h *OperationsReportHandler) ExportActivationSummary(c *fiber.Ctx) error {
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsActivation, domainreport.IsActivationDimension,
"导出设备激活情况参数不合法")
if err != nil {
return err
}
return h.createExportTask(c, constants.ExportTaskSceneOperationsActivation, request)
}
// ExportRenewalSummary 创建套餐续费情况报表异步导出任务。
// POST /api/admin/operations-reports/package-renewal-summary/export
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
func (h *OperationsReportHandler) ExportRenewalSummary(c *fiber.Ctx) error {
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsRenewal, domainreport.IsRenewalDimension,
"导出套餐续费情况参数不合法")
if err != nil {
return err
}
return h.createExportTask(c, constants.ExportTaskSceneOperationsRenewal, request)
}
// createExportTask 复用既有导出任务创建路径:创建期冻结操作者、可见店铺范围与筛选快照。
func (h *OperationsReportHandler) createExportTask(c *fiber.Ctx, scene string, request dto.ExportOperationsReportRequest) error {
createRequest := dto.CreateExportTaskRequest{
Scene: scene,
Format: request.Format,
Query: map[string]interface{}{"filters": exportOperationsReportFilters(request)},
}
result, err := h.exportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// parseExportRequest 解析并校验受控导出请求。
// 时间边界在创建期用统一严格解析器校验(格式非法或开始晚于结束一律拒绝),
// 分组维度必须属于该报表支持的维度集合。
func (h *OperationsReportHandler) parseExportRequest(c *fiber.Ctx, scene string,
isDimension func(string) bool, message string) (dto.ExportOperationsReportRequest, error) {
var request dto.ExportOperationsReportRequest
if err := c.BodyParser(&request); err != nil {
return request, errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&request); err != nil {
return request, errors.New(errors.CodeInvalidParam, validation.Message(message, &request, err))
}
if _, _, err := utils.ParseTimeRange(request.StartTime, request.EndTime); err != nil {
return request, err
}
if request.GroupBy != "" && !isDimension(request.GroupBy) {
return request, errors.New(errors.CodeInvalidParam, "不支持的分组维度 "+request.GroupBy)
}
return request, nil
}
// exportOperationsReportFilters 把导出请求转换为导出任务的筛选快照。
// 时间边界按统一契约冻结为纯字符串值,创建期由导出任务服务规范化为 UTC RFC3339 秒级;
// 分组维度恒定冻结(未选择时为空串),执行期不再重新解释请求。
func exportOperationsReportFilters(request dto.ExportOperationsReportRequest) map[string]interface{} {
filters := make(map[string]interface{}, 3)
if request.StartTime != "" {
filters["start_time"] = request.StartTime
}
if request.EndTime != "" {
filters["end_time"] = request.EndTime
}
filters["group_by"] = request.GroupBy
return filters
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// OrderHandler 后台订单处理器
@@ -91,6 +92,10 @@ func (h *OrderHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
@@ -107,7 +112,7 @@ func (h *OrderHandler) List(c *fiber.Ctx) error {
buyerID = 0
}
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
orders, err := h.service.List(ctx, &req, startTime, endTime, buyerType, buyerID)
if err != nil {
return err
}

View File

@@ -0,0 +1,183 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PackageTrafficAlertHandler 套餐真流量预警 Handler。
// 规则维护与预警读取/导出只对超级管理员与平台账号开放,路由组已有角色门禁,
// Handler 仍不做任何跳过业务校验的分支。
type PackageTrafficAlertHandler struct {
ruleService *packagetrafficalertapp.RuleService
query *packagetrafficalertquery.Query
exportService *exportTaskService.Service
validator *validator.Validate
}
// NewPackageTrafficAlertHandler 创建套餐真流量预警 Handler。
func NewPackageTrafficAlertHandler(ruleService *packagetrafficalertapp.RuleService,
query *packagetrafficalertquery.Query, exportService *exportTaskService.Service,
validator *validator.Validate) *PackageTrafficAlertHandler {
return &PackageTrafficAlertHandler{ruleService: ruleService, query: query, exportService: exportService, validator: validator}
}
// CreateRule 创建套餐真流量预警规则。
// POST /api/admin/package-traffic-alert-rules
func (h *PackageTrafficAlertHandler) CreateRule(c *fiber.Ctx) error {
var req dto.CreatePackageTrafficAlertRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("创建套餐真流量预警规则参数不合法", &req, err))
}
result, err := h.ruleService.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateRule 修改套餐真流量预警规则的阈值、启停与备注。
// PUT /api/admin/package-traffic-alert-rules/:id
func (h *PackageTrafficAlertHandler) UpdateRule(c *fiber.Ctx) error {
ruleID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || ruleID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的预警规则 ID")
}
var req dto.UpdatePackageTrafficAlertRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("修改套餐真流量预警规则参数不合法", &req, err))
}
result, err := h.ruleService.Update(c.UserContext(), uint(ruleID), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListRules 查询套餐真流量预警规则列表。
// GET /api/admin/package-traffic-alert-rules
func (h *PackageTrafficAlertHandler) ListRules(c *fiber.Ctx) error {
var req dto.ListPackageTrafficAlertRuleRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐真流量预警规则参数不合法", &req, err))
}
result, err := h.query.ListRules(c.UserContext(), req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// ListAlerts 查询套餐真流量达量预警列表。
// GET /api/admin/package-traffic-alerts
func (h *PackageTrafficAlertHandler) ListAlerts(c *fiber.Ctx) error {
var req dto.ListPackageTrafficAlertRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐真流量达量预警参数不合法", &req, err))
}
if req.NotificationStatus != nil && !constants.IsValidPackageTrafficAlertNotifyStatus(*req.NotificationStatus) {
return errors.New(errors.CodeInvalidParam, "通知投递结果必须为 1/2/3/4/5 之一")
}
result, err := h.query.ListAlerts(c.UserContext(), req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// GetAlert 查询套餐真流量达量预警详情。
// GET /api/admin/package-traffic-alerts/:id
func (h *PackageTrafficAlertHandler) GetAlert(c *fiber.Ctx) error {
alertID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || alertID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的预警 ID")
}
result, err := h.query.GetAlert(c.UserContext(), uint(alertID))
if err != nil {
return err
}
return response.Success(c, result)
}
// ExportAlerts 创建套餐真流量达量预警异步导出任务。
// POST /api/admin/package-traffic-alerts/export
// 只暴露受控入口;导出任务创建时冻结操作者、筛选、时间范围与可见资产范围。
func (h *PackageTrafficAlertHandler) ExportAlerts(c *fiber.Ctx) error {
var req dto.ExportPackageTrafficAlertRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("导出套餐真流量达量预警参数不合法", &req, err))
}
if req.NotificationStatus != nil && !constants.IsValidPackageTrafficAlertNotifyStatus(*req.NotificationStatus) {
return errors.New(errors.CodeInvalidParam, "通知投递结果必须为 1/2/3/4/5 之一")
}
createRequest := dto.CreateExportTaskRequest{
Scene: constants.ExportTaskScenePackageTrafficAlert,
Format: req.Format,
Query: map[string]interface{}{"filters": exportFilters(req)},
}
result, err := h.exportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// exportFilters 把导出请求转换为导出任务的筛选快照。
// 时间范围在创建时冻结为 RFC3339 字符串,执行期按触发时间解析为闭区间。
func exportFilters(req dto.ExportPackageTrafficAlertRequest) map[string]interface{} {
filters := make(map[string]interface{})
if req.PackageID != nil {
filters["package_id"] = *req.PackageID
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if req.BusinessOwnerAccountID != nil {
filters["business_owner_account_id"] = *req.BusinessOwnerAccountID
}
if req.AssetType != "" {
filters["asset_type"] = req.AssetType
}
if req.AssetIdentifier != "" {
filters["asset_identifier"] = req.AssetIdentifier
}
if req.ThresholdPercent != nil {
filters["threshold_percent"] = *req.ThresholdPercent
}
if req.StartTime != nil {
filters["start_time"] = req.StartTime.UTC().Format("2006-01-02T15:04:05Z07:00")
}
if req.EndTime != nil {
filters["end_time"] = req.EndTime.UTC().Format("2006-01-02T15:04:05Z07:00")
}
if req.NotificationStatus != nil {
filters["notification_status"] = *req.NotificationStatus
}
return filters
}

View File

@@ -0,0 +1,157 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
associationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PhoneAssetAssociationHandler 手机号—资产关联后台处理器。
// 仅提供查看投影与解除入口;关联只能由 H5 短信验证建立,后台不提供创建或补录入口。
type PhoneAssetAssociationHandler struct {
service *associationSvc.Service
validator *validator.Validate
}
// NewPhoneAssetAssociationHandler 创建手机号—资产关联处理器。
func NewPhoneAssetAssociationHandler(service *associationSvc.Service, validator *validator.Validate) *PhoneAssetAssociationHandler {
return &PhoneAssetAssociationHandler{service: service, validator: validator}
}
// List 查询手机号—资产关联列表。
// GET /api/admin/phone-asset-associations
func (h *PhoneAssetAssociationHandler) List(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.ListPhoneAssetAssociationRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("手机号资产关联查询参数不合法", &request); err != nil {
return err
}
result, err := h.service.List(c.UserContext(), &request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// Unbind 解除指定的一条手机号—资产关联。
// DELETE /api/admin/phone-asset-associations/:id
func (h *PhoneAssetAssociationHandler) Unbind(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
var request dto.UnbindPhoneAssetAssociationRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 路径字段由 Handler 解析后回填再执行结构校验ENG-ERR-002
request.ID = id
if err := h.validate("手机号资产解绑参数不合法", &request); err != nil {
return err
}
result, err := h.service.Unbind(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// BatchUnbind 按资产集合解除全部当前有效关联。
// POST /api/admin/phone-asset-associations/batch-unbind
func (h *PhoneAssetAssociationHandler) BatchUnbind(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.BatchUnbindPhoneAssetAssociationRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("手机号资产批量解绑参数不合法", &request); err != nil {
return err
}
result, err := h.service.BatchUnbind(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateImportTask 创建手机号资产解绑 CSV 导入任务。
// POST /api/admin/phone-asset-associations/unbind-imports
func (h *PhoneAssetAssociationHandler) CreateImportTask(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.CreatePhoneAssetUnbindImportRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("手机号资产解绑导入参数不合法", &request); err != nil {
return err
}
result, err := h.service.CreateImportTask(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListImportTasks 查询手机号资产解绑导入任务列表。
// GET /api/admin/phone-asset-associations/unbind-imports
func (h *PhoneAssetAssociationHandler) ListImportTasks(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.ListPhoneAssetUnbindImportRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("手机号资产解绑导入查询参数不合法", &request); err != nil {
return err
}
result, err := h.service.ListImportTasks(c.UserContext(), &request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// ImportTaskDetail 查询手机号资产解绑导入任务详情与逐行结果。
// GET /api/admin/phone-asset-associations/unbind-imports/:id
func (h *PhoneAssetAssociationHandler) ImportTaskDetail(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.GetImportTask(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// validate 执行结构校验失败时返回字段级提示ENG-ERR-002
func (h *PhoneAssetAssociationHandler) validate(prefix string, request any) error {
if h.validator == nil {
return errors.New(errors.CodeInternalError)
}
if err := h.validator.Struct(request); err != nil {
return errors.New(errors.CodeInvalidParam, validationMessage(prefix, request, err))
}
return nil
}

View File

@@ -0,0 +1,112 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
prioritypollingquery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PriorityPollingHandler 处理卡轮询优先队列的人工入队与只读查询请求。
type PriorityPollingHandler struct {
enqueue *pollingSvc.PriorityEnqueueService
query *prioritypollingquery.Query
validator *validator.Validate
}
// NewPriorityPollingHandler 创建卡轮询优先队列处理器。
func NewPriorityPollingHandler(
enqueue *pollingSvc.PriorityEnqueueService,
query *prioritypollingquery.Query,
validator *validator.Validate,
) *PriorityPollingHandler {
return &PriorityPollingHandler{enqueue: enqueue, query: query, validator: validator}
}
// Enqueue 人工优先入队。
// POST /api/admin/polling-priority-items
func (h *PriorityPollingHandler) Enqueue(c *fiber.Ctx) error {
if h.enqueue == nil {
return errors.New(errors.CodeInternalError, "人工优先入队能力未配置")
}
var req dto.EnqueuePriorityPollingRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("人工优先入队参数不合法", &req, err))
}
result, err := h.enqueue.Enqueue(c.UserContext(), req.CardID, req.Reason, middleware.GetUserIDFromContext(c.UserContext()))
if err != nil {
return err
}
taskTypeNames := make([]string, 0, len(result.TaskTypes))
items := make([]dto.EnqueuePriorityPollingItemView, 0, len(result.Items))
for _, taskType := range result.TaskTypes {
taskTypeNames = append(taskTypeNames, constants.PollingPriorityTaskTypeName(taskType))
}
for _, item := range result.Items {
items = append(items, dto.EnqueuePriorityPollingItemView{
ItemID: item.ItemID,
TaskType: item.TaskType,
TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType),
Status: item.Status,
StatusName: constants.PollingPriorityStatusName(item.Status),
TriggerCount: item.TriggerCount,
LastTriggeredAt: item.LastTriggeredAt,
Created: item.Created,
})
}
return response.Success(c, dto.EnqueuePriorityPollingResponse{
CardID: result.CardID,
TaskTypes: result.TaskTypes,
TaskTypeNames: taskTypeNames,
CreatedCount: result.CreatedCount,
MergedCount: result.MergedCount,
Items: items,
})
}
// ListPriorityItems 分页查询优先轮询项。
// GET /api/admin/polling-priority-items
func (h *PriorityPollingHandler) ListPriorityItems(c *fiber.Ctx) error {
if h.query == nil {
return errors.New(errors.CodeInternalError, "优先轮询项查询能力未配置")
}
var req dto.PriorityPollingItemListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("优先轮询项查询参数不合法", &req, err))
}
result, err := h.query.List(c.UserContext(), req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// GetPriorityItem 查询优先轮询项详情。
// GET /api/admin/polling-priority-items/:id
func (h *PriorityPollingHandler) GetPriorityItem(c *fiber.Ctx) error {
if h.query == nil {
return errors.New(errors.CodeInternalError, "优先轮询项查询能力未配置")
}
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.query.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -0,0 +1,71 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
importService "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ShopBusinessOwnerImportHandler 店铺负责人 CSV 导入任务处理器。
type ShopBusinessOwnerImportHandler struct {
service *importService.Service
}
// NewShopBusinessOwnerImportHandler 创建店铺负责人导入任务处理器。
func NewShopBusinessOwnerImportHandler(service *importService.Service) *ShopBusinessOwnerImportHandler {
return &ShopBusinessOwnerImportHandler{service: service}
}
// Create 创建店铺负责人 CSV 导入任务。
// POST /api/admin/shops/business-owner-imports
func (h *ShopBusinessOwnerImportHandler) Create(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.CreateShopBusinessOwnerImportRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.Create(c.UserContext(), &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询店铺负责人导入任务列表。
// GET /api/admin/shops/business-owner-imports
func (h *ShopBusinessOwnerImportHandler) List(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.ListShopBusinessOwnerImportRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.List(c.UserContext(), &request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// Detail 查询店铺负责人导入任务详情与逐行结果。
// GET /api/admin/shops/business-owner-imports/:id
func (h *ShopBusinessOwnerImportHandler) Detail(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.GetByID(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// ShopCommissionHandler 代理商资金管理 Handler
@@ -75,8 +76,12 @@ func (h *ShopCommissionHandler) ListWithdrawalRequests(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListShopWithdrawalRequests(c.UserContext(), uint(shopID), &req)
result, err := h.service.ListShopWithdrawalRequests(c.UserContext(), uint(shopID), &req, startTime, endTime)
if err != nil {
return err
}
@@ -96,8 +101,12 @@ func (h *ShopCommissionHandler) ListCommissionRecords(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListShopCommissionRecords(c.UserContext(), uint(shopID), &req)
result, err := h.service.ListShopCommissionRecords(c.UserContext(), uint(shopID), &req, startTime, endTime)
if err != nil {
return err
}
@@ -105,6 +114,34 @@ func (h *ShopCommissionHandler) ListCommissionRecords(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// GetCommissionRecord 佣金明细详情
// GET /api/admin/shops/:shop_id/commission-records/:id
// source 区分原佣金与回溯明细;越权与不存在返回同一结果。
func (h *ShopCommissionHandler) GetCommissionRecord(c *fiber.Ctx) error {
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
recordID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || recordID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的佣金明细ID")
}
var req dto.ShopCommissionRecordDetailReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
req.ShopID = uint(shopID)
req.ID = uint(recordID)
result, err := h.service.GetShopCommissionRecord(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ResolveCommissionRecord 修正待审佣金记录
// POST /api/admin/commission-records/:id/resolve
func (h *ShopCommissionHandler) ResolveCommissionRecord(c *fiber.Ctx) error {
@@ -203,11 +240,14 @@ func (h *ShopCommissionHandler) ResubmitWithdrawal(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// shop_id 与 id 只来自路径,必须在校验前回填,否则两者的 required 恒失败。
req.ShopID = uint(shopID)
req.ID = uint(requestID)
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现重提校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "提现重提参数不合法")
return errors.New(errors.CodeInvalidParam, validationMessage("提现重提参数不合法", &req, err))
}
result, err := h.service.ResubmitWithdrawalRequest(c.UserContext(), uint(shopID), uint(requestID), &req)
if err != nil {

View File

@@ -0,0 +1,20 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// rejectLegacyTimeParams 拒绝受影响端点上已废弃的旧时间参数名。
// 契约要求被替换的旧参数在受影响端点被拒绝,不能因未知参数被忽略而静默返回全量;
// 只有空值等同未传,出现且非空即返回参数非法错误码。
func rejectLegacyTimeParams(c *fiber.Ctx, keys ...string) error {
for _, key := range keys {
if c.Query(key) == "" {
continue
}
return errors.New(errors.CodeInvalidParam, key+" 已废弃,请改用 start_time 与 end_time")
}
return nil
}

View File

@@ -8,6 +8,7 @@ import (
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -43,15 +44,17 @@ func (h *WithdrawalQualificationHandler) SubmitWithdrawalQualification(c *fiber.
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
// shop_id 只来自路径,必须在校验前回填,否则 ShopID 的 required 恒失败。
req.ShopID = uint(shopID)
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "提现资料资格参数不合法")
}
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
return errors.New(errors.CodeInvalidParam, validationMessage("提现资料资格参数不合法", &req, err))
}
result, err := h.service.Submit(c.UserContext(), uint(shopID), distributiondomain.QualificationInput{
SubjectType: req.SubjectType,
@@ -94,7 +97,7 @@ func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ct
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
return errors.New(errors.CodeInvalidParam, validationMessage("作废提现资料资格参数不合法", &req, err))
}
if err := h.service.Void(c.UserContext(), uint(id), req.Reason); err != nil {
return err
@@ -102,6 +105,12 @@ func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ct
return response.Success(c, nil)
}
// validationMessage 把请求校验失败转换为可定位字段的中文提示。
// 规则实现收口在 internal/handler/validation管理端与 C 端共用同一套提示口径。
func validationMessage(prefix string, req any, err error) string {
return validation.Message(prefix, req, err)
}
// ListWithdrawalQualifications 查询提现资料资格版本
// GET /api/admin/shops/:shop_id/withdrawal-qualifications
// 仅返回当前账号数据范围内的资料版本;证件号脱敏,附件只返回对象存储 Key。

View File

@@ -6,6 +6,7 @@ import (
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"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"
@@ -29,7 +30,9 @@ func NewAgentDistributionHandler(
// RegisterAgentDistribution 提交代理扫码注册
// POST /api/c/v1/agent-distribution-registrations
// 无需认证、JWT、角色或权限只创建待审批注册记录不返回任何账号凭证。
// 无效分销码停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库
// 分销码无效、上级店铺停用上级店铺缺少启用的主账号、短信验证码无效分别返回各自提示且不落库
// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误,与其它待审批申请冲突时返回资源冲突;
// 短信验证码在注册记录落库成功后消费,落库前的失败(含关键字段冲突)不消耗验证码。
func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error {
if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
@@ -42,7 +45,7 @@ func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error
return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "注册参数不合法")
return errors.New(errors.CodeInvalidParam, validation.Message("注册参数不合法", &req, err))
}
result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{
DistributionCode: req.DistributionCode,

View File

@@ -118,7 +118,10 @@ func (h *ClientAuthHandler) BindPhone(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.service.BindPhone(c.UserContext(), customerID, &req)
// 建联只依据当前访问资产身份JWT 缺失资产身份时只完成账号手机号绑定。
assetType, assetID, _ := middleware.GetCurrentAsset(c)
resp, err := h.service.BindPhone(c.UserContext(), customerID, assetType, assetID, &req)
if err != nil {
return err
}

View File

@@ -0,0 +1,104 @@
package app
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
h5popupapp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ClientPopupHandler 提供 H5 弹窗候选查询与风险换卡地址提交。
type ClientPopupHandler struct {
candidates *h5popupapp.CandidateService
riskExchanges *h5popupapp.RiskExchangeService
validate *validator.Validate
}
// NewClientPopupHandler 创建 H5 弹窗 Handler。
func NewClientPopupHandler(candidates *h5popupapp.CandidateService, riskExchanges *h5popupapp.RiskExchangeService, validate *validator.Validate) *ClientPopupHandler {
return &ClientPopupHandler{candidates: candidates, riskExchanges: riskExchanges, validate: validate}
}
// GetCandidates 查询当前页面与当前资产的弹窗候选。
// GET /api/c/v1/popup-candidates
// 该查询有副作用:命中时会创建或复用个人站内通知并保持未读,这是产品契约(不预生成通知),
// 客户端关闭或稍后处理时必须用返回的 notification_id 调用既有已读接口。
func (h *ClientPopupHandler) GetCandidates(c *fiber.Ctx) error {
var request dto.PopupCandidateRequest
if err := c.QueryParser(&request); err != nil {
logPopupValidationFailure(c, "弹窗候选参数不合法", err)
return errors.New(errors.CodeInvalidParam)
}
if h.validate != nil {
if err := h.validate.Struct(&request); err != nil {
logPopupValidationFailure(c, "弹窗候选参数不合法", err)
return errors.New(errors.CodeInvalidParam, validation.Message("弹窗候选参数不合法", &request, err))
}
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if h.candidates == nil {
return errors.New(errors.CodeServiceUnavailable, "弹窗投放能力尚未配置")
}
result, err := h.candidates.GetCandidate(c.UserContext(), customerID, request)
if err != nil {
return err
}
return response.Success(c, result)
}
// SubmitRiskAddress 提交风险换卡收货地址。
// POST /api/c/v1/risk-exchanges/:asset_id/address
// 重复提交返回首次创建的物流换货单与首次地址;首地址锁定,客户不能修改。
func (h *ClientPopupHandler) SubmitRiskAddress(c *fiber.Ctx) error {
assetID, err := strconv.ParseUint(c.Params("asset_id"), 10, 64)
if err != nil || assetID == 0 {
return errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法")
}
var request dto.ClientRiskExchangeAddressParams
if err := c.BodyParser(&request); err != nil {
logPopupValidationFailure(c, "风险换卡地址参数不合法", err)
return errors.New(errors.CodeInvalidParam)
}
// 路径来源字段必须由 Handler 回填后再校验,避免被请求体覆盖,也避免 required 恒失败。
request.AssetID = uint(assetID)
if h.validate != nil {
if err := h.validate.Struct(&request); err != nil {
logPopupValidationFailure(c, "风险换卡地址参数不合法", err)
return errors.New(errors.CodeInvalidParam, validation.Message("风险换卡地址参数不合法", &request, err))
}
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if h.riskExchanges == nil {
return errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置")
}
result, err := h.riskExchanges.Submit(c.UserContext(), customerID, uint(assetID), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// logPopupValidationFailure 记录参数校验失败,仅记录字段错误,不回显请求体内容。
func logPopupValidationFailure(c *fiber.Ctx, message string, err error) {
logger.GetAppLogger().Warn("H5 弹窗接口参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.String("message", message),
zap.Error(err),
)
}

View File

@@ -0,0 +1,114 @@
// Package validation 提供请求参数校验失败的可定位中文提示。
// 提示只使用字段的 description 与校验规则,不拼接底层错误文本,也不回显字段值。
package validation
import (
"reflect"
"strings"
"github.com/go-playground/validator/v10"
)
// Message 把请求校验失败转换为可定位字段的中文提示。
func Message(prefix string, req any, err error) string {
fieldErrs, ok := err.(validator.ValidationErrors)
if !ok || len(fieldErrs) == 0 {
return prefix
}
return prefix + "" + describeFieldError(req, fieldErrs[0])
}
// describeFieldError 用字段中文名与失败规则描述单个字段错误。
func describeFieldError(req any, fieldErr validator.FieldError) string {
label := fieldDescription(req, fieldErr.StructField())
switch fieldErr.Tag() {
case "required":
// 数字字段的 required 只在零值失败;说“不能为空”会误导为缺字段。
if isNumericField(req, fieldErr.StructField()) {
return label + "必须大于 0"
}
return label + "不能为空"
case "min":
if isNumericField(req, fieldErr.StructField()) {
return label + "不能小于 " + fieldErr.Param()
}
return label + "长度不能小于 " + fieldErr.Param()
case "max":
if isNumericField(req, fieldErr.StructField()) {
return label + "不能超过 " + fieldErr.Param()
}
return label + "长度不能超过 " + fieldErr.Param()
case "oneof":
return label + "必须为 " + strings.ReplaceAll(fieldErr.Param(), " ", "/") + " 之一"
default:
return label + "不合法(" + fieldErr.Tag() + ""
}
}
// isNumericField 判断字段是否为整数或浮点类型。
func isNumericField(req any, fieldName string) bool {
field, ok := lookupField(req, fieldName)
if !ok {
return false
}
switch field.Type.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
default:
return false
}
}
// lookupField 在去指针的结构体类型上按名取字段。
func lookupField(req any, fieldName string) (reflect.StructField, bool) {
typ := reflect.TypeOf(req)
for typ != nil && typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
if typ == nil || typ.Kind() != reflect.Struct {
return reflect.StructField{}, false
}
if field, ok := typ.FieldByName(fieldName); ok {
return field, true
}
// 嵌套(含匿名嵌入)结构体字段:校验错误报告的是内层字段名,提示也要能取到它的 description。
for index := range typ.NumField() {
field := typ.Field(index)
nested := field.Type
for nested.Kind() == reflect.Ptr {
nested = nested.Elem()
}
if nested.Kind() != reflect.Struct {
continue
}
if inner, ok := nested.FieldByName(fieldName); ok {
return inner, true
}
}
return reflect.StructField{}, false
}
// fieldDescription 取字段 description 的首个中文短语作为提示名,缺失时退回字段名。
func fieldDescription(req any, fieldName string) string {
field, ok := lookupField(req, fieldName)
if !ok {
return fieldName
}
description := strings.TrimSpace(field.Tag.Get("description"))
if description == "" {
return fieldName
}
if cut := strings.IndexAny(description, "(:,;"); cut > 0 {
description = strings.TrimSpace(description[:cut])
}
if description == "" {
return fieldName
}
// 提示名以拉丁字母/数字结尾时补一个空格,避免与后续中文粘连。
if last := description[len(description)-1]; last < 0x80 {
description += " "
}
return description
}

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