暂存
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s

This commit is contained in:
2026-08-06 09:35:00 +08:00
parent 8659dfc658
commit 88cc5e96ec
75 changed files with 6520 additions and 513 deletions

View File

@@ -80,9 +80,9 @@
| 代理订单主钱包退款回充 | `refund.approve` 与退款单、订单、原扣款钱包、原扣款流水和唯一退款流水同事务;重复退款流水不重复写成功事件 | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统原路渠道退款尚未实现 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 | | 代理订单主钱包退款回充 | `refund.approve` 与退款单、订单、原扣款钱包、原扣款流水和唯一退款流水同事务;重复退款流水不重复写成功事件 | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统原路渠道退款尚未实现 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 |
| 代理商资金概况信用投影 | N/A普通受权读取不返回其他数据范围的资金事实不执行资金或配置变更 | 只读投影 `tb_shop`、主/佣金钱包、提现汇总和主账号;派生金额不另建事实表 | N/A无外部系统调用 | N/A纯 Query 不产生可靠副作用) | | 代理商资金概况信用投影 | N/A普通受权读取不返回其他数据范围的资金事实不执行资金或配置变更 | 只读投影 `tb_shop`、主/佣金钱包、提现汇总和主账号;派生金额不另建事实表 | N/A无外部系统调用 | N/A纯 Query 不产生可靠副作用) |
| 受控系统配置更新 | N/A用户已明确取消全局 Audit Event仅超级管理员可更新代码注册 Key未知 Key、非法类型和值域均拒绝 | `tb_system_config` 是配置值、类型、模块及更新人的 PostgreSQL 权威事实,更新后失效 Redis 缓存 | N/A配置更新不调用外部系统不得写 Integration Log 冒充配置审计) | N/A配置更新不产生可靠异步副作用 | | 受控系统配置更新 | N/A用户已明确取消全局 Audit Event仅超级管理员可更新代码注册 Key未知 Key、非法类型和值域均拒绝 | `tb_system_config` 是配置值、类型、模块及更新人的 PostgreSQL 权威事实,更新后失效 Redis 缓存 | N/A配置更新不调用外部系统不得写 Integration Log 冒充配置审计) | N/A配置更新不产生可靠异步副作用 |
| 电信实名结果回调 | N/AAudit Event 已移出本 Change运营商来源真实性验证也不在本票边界 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/ignored/not_found/conflict/success/failed` 终态 | 实名事实首次变化时由公共 `ApplyCardObservation` 同事务写入实名状态变化 Outbox重复成功不重复写事件 | | 电信实名结果回调 | 实名状态首次实际变化时写 `iot_card.realname_callback_sync`,使用 `external_system/callback`,关联 IoT 卡和入站 Integration LogAudit Event 与卡状态、首次实名时间及实名变化 Outbox 同事务,审计失败回滚。已解析卡后的业务失败写独立短事务;重复成功不伪造事件 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/ignored/not_found/conflict/success/failed` 终态;未改变实名状态时只保留 Integration Log | 实名事实首次变化时由公共 `ApplyCardObservation` 同事务写入实名状态变化 Outbox重复成功不重复写事件 |
| 移动实名成功回调 | N/AAudit Event 已移出本 Change不接入旧平台登录、MSISDN 补查或来源真实性验证) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed` 终态,并通过 pending 租约恢复中断处理 | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 | | 移动实名成功回调 | 实名状态首次实际变化时写 `iot_card.realname_callback_sync`,使用 `external_system/callback`,关联 IoT 卡和入站 Integration LogAudit Event 与卡状态、首次实名时间及实名变化 Outbox 同事务,审计失败回滚。已解析卡后的业务失败写独立短事务;重复成功不伪造事件 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed` 终态,并通过 pending 租约恢复中断处理;未改变实名状态时只保留 Integration Log | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通实名成功回调 | N/AAudit Event 已移出本 Change不复制旧 `inner_callback`、第三方推送或 Gateway 二次确认) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored` | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 | | 联通实名成功回调 | 实名状态首次实际变化时写 `iot_card.realname_callback_sync`,使用 `external_system/callback`,关联 IoT 卡和入站 Integration LogAudit Event 与卡状态、首次实名时间及实名变化 Outbox 同事务,审计失败回滚。已解析卡后的业务失败写独立短事务;重复成功不伪造事件 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored`;未改变实名状态时只保留 Integration Log | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通解除实名回调 | N/A只识别并留痕外部解除通知不把单次回调作为本地实名逆转事实 | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A不调用公共实名观测不产生状态变化、停机或套餐事件 | | 联通解除实名回调 | N/A只识别并留痕外部解除通知不把单次回调作为本地实名逆转事实 | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A不调用公共实名观测不产生状态变化、停机或套餐事件 |
## 七月确认范围增量登记 ## 七月确认范围增量登记
@@ -182,6 +182,22 @@
- 轮询配置与人工动作已切换统一 Writer轮询配置创建、更新、删除和启停使用 `polling_config.create/update/delete/update_status`,并发配置更新与计数重置使用 `polling_concurrency.update/reset`,告警规则创建、更新和删除使用 `polling_alert.create_rule/update_rule/delete_rule`;成功事件与对应 PostgreSQL 配置事实共用 GORM 事务审计失败回滚配置写入。Redis 并发计数重置无法与 PostgreSQL 原子提交,审计失败时恢复重置前计数;人工单卡去重键在日志/审计事务失败或队列写入失败时移除,避免阻断原有重试。 - 轮询配置与人工动作已切换统一 Writer轮询配置创建、更新、删除和启停使用 `polling_config.create/update/delete/update_status`,并发配置更新与计数重置使用 `polling_concurrency.update/reset`,告警规则创建、更新和删除使用 `polling_alert.create_rule/update_rule/delete_rule`;成功事件与对应 PostgreSQL 配置事实共用 GORM 事务审计失败回滚配置写入。Redis 并发计数重置无法与 PostgreSQL 原子提交,审计失败时恢复重置前计数;人工单卡去重键在日志/审计事务失败或队列写入失败时移除,避免阻断原有重试。
- 单卡、批量、条件筛选人工触发和取消分别使用 `polling_manual_trigger.trigger_single/trigger_batch/trigger_by_condition/cancel_trigger`,记录真实后台账号、手动任务和实际卡资源;配置重名、每日触发上限、重复入队、越权取消和已结束任务取消等已定位资源的拒绝写独立短事务,二次失败保留原业务错误并记录 critical。`tb_polling_manual_trigger_log` 继续承担进度、结果与历史查询,不被 Audit Event 替代或停写;审计不复制 `CardIDs`、条件正文、通知渠道正文或其他安全凭据,只保存任务类型、触发方式、数量、状态及“条件/通知渠道是否配置”等安全事实。 - 单卡、批量、条件筛选人工触发和取消分别使用 `polling_manual_trigger.trigger_single/trigger_batch/trigger_by_condition/cancel_trigger`,记录真实后台账号、手动任务和实际卡资源;配置重名、每日触发上限、重复入队、越权取消和已结束任务取消等已定位资源的拒绝写独立短事务,二次失败保留原业务错误并记录 critical。`tb_polling_manual_trigger_log` 继续承担进度、结果与历史查询,不被 Audit Event 替代或停写;审计不复制 `CardIDs`、条件正文、通知渠道正文或其他安全凭据,只保存任务类型、触发方式、数量、状态及“条件/通知渠道是否配置”等安全事实。
- 实名、流量和卡状态轮询的每次真实 Gateway 尝试继续写 Integration Log套餐/保护期轮询自身不伪造 Gateway 尝试,实际停复机复用共享 `StopResumeService` 的 Audit/Integration 边界。轮询配置、并发状态、告警历史、人工任务状态/历史和监控页面等普通运行查询均为 N/A通知投递、任务 ledger、Audit Event 与 Integration Log 保持独立事实。`polling_cleanup` 数据清理配置未包含在 8.5 明确边界,本轮不借轮询审计扩展其 CRUD 或手动清理动作,继续保留在覆盖清单等待对应显式切片。 - 实名、流量和卡状态轮询的每次真实 Gateway 尝试继续写 Integration Log套餐/保护期轮询自身不伪造 Gateway 尝试,实际停复机复用共享 `StopResumeService` 的 Audit/Integration 边界。轮询配置、并发状态、告警历史、人工任务状态/历史和监控页面等普通运行查询均为 N/A通知投递、任务 ledger、Audit Event 与 Integration Log 保持独立事实。`polling_cleanup` 数据清理配置未包含在 8.5 明确边界,本轮不借轮询审计扩展其 CRUD 或手动清理动作,继续保留在覆盖清单等待对应显式切片。
- 外部 Callback 已按当前生产路由收口:微信/支付宝/富友支付回调继续复用 7.4 的支付、订单和充值事务审计,企微审批回调继续复用 8.1 的权威终态审计;电信、移动、联通实名成功回调在共享 `ApplyCardObservation` 事务中使用 `iot_card.realname_callback_sync`,真实 actor 为各运营商 `external_system/callback`,关联 IoT 卡和入站 Integration Log。重复支付、重复审批、重复实名及无状态变化只保留 Integration Log不伪造成功 Audit Event联通解除实名仍仅留痕且不改变本地实名事实。本切片不修改支付、运营商或企微协议。
- 当前 31 个 Asynq Worker 已按 8.7 逐项复核:`iot_card:import``device:import``order_package:invalidate``asset_package:batch_order``commission:calculate``package:first_activation``package:queue_activation``order:expire``notification:cleanup``auto_purchase:after_recharge``wecom:approval:sync``wecom:approval:recovery``agent_recharge:recovery` 已复用前序纵向切片的统一 Audit Event本切片新增 `card_observation:series``polling:realname/carddata/card_status``system_task/worker` 上下文,仅在实名、流量、网络或设备字段/当前卡槽实际变化时分别写 `iot_card.worker_realname_sync``iot_card.worker_traffic_sync``iot_card.worker_network_sync``device.worker_observation_sync`,并关联对应 Gateway Integration Log。`polling:package/protect` 不另造轮询动作,实际停复机继续复用 `iot_card.auto_stop/auto_start/auto_stop_reason_update`。上述链路传播 request/correlation/parent重试失败写 failed 短事务,无实际变化仅保留 Integration Log 或任务运行事实,不伪造 success。
- Worker N/A 与后续边界:`email:send` 仅模拟邮件投递;`export:dispatch/shard/finalize` 仅做导出技术装配;`commission_stats:update/sync/archive` 仅维护 Redis/PostgreSQL 统计投影;`polling:alert_check` 仅生成告警运行事实;`polling:data_cleanup` 仅执行既有轮询数据清理;`package:expiry_reminder` 仅生成可靠通知 Outbox`daily_traffic:flush` 仅把 Redis 日流量 Domain Ledger 落盘,均不重复创建 Audit Event。`outbox:deliver` 自身是可靠投递技术入口Relay/投递事实 N/A其各业务消费者是否形成新业务事实按任务 8.9 逐项收口,不在 8.7 提前迁移。Scheduler 仅投递或创建任务的边界留给 8.8。
- Scheduler 已按当前注册清单逐项复核:代理在线充值恢复、订单过期关闭和企微审批恢复在真实业务事实变化时使用 `scheduled_job/scheduler`;轮询 Scheduler 直接执行的套餐到期、后续权益接续、流量周期重置及套餐到期停机检查同样保留 `scheduled_job/scheduler` 操作者。套餐到期后的异步停机检查使用不受调度 tick 取消影响的原审计上下文,继续传播 actor、correlation 和 parent不伪造人工操作者。
- Scheduler N/A 与幂等边界Asynq 周期注册、轮询心跳、队列深度检查、分片/手动队列出队、重复调度的 `Unique` 去重与失败回队只是技术调度或任务事实,不写 Audit Event也不把后续 Worker 结果伪装成 Scheduler 成功。告警检查、轮询数据清理、通知保留清理、套餐临期提醒和日流量落盘继续按 8.7 的 Worker/Domain Ledger/Outbox 边界处理;重复调度依赖既有状态条件、领取租约、稳定事件 ID 和 Asynq `Unique`,无实际变化不伪造 success。Worker 消费逻辑未在 8.8 迁移。
- 当前 14 个 Outbox 事件注册、12 个消费者实现已按 8.9 逐项复核:企微审批提交终态、审批标准决策分发、卡实名/流量/网络变化后续处理、代理在线充值入账和三类站内通知生成会形成新的内部业务事实,均通过 `outbox:deliver` 入口取得 `system_task/worker` actor并沿用信封的 request/correlation/parent实际变化继续复用 8.1、7.2、7.6 和 8.4 已接入的同事务 Audit Event。状态条件、处理租约、稳定事件 ID、业务唯一键和通知 `CreateIdempotent` 保证至少一次投递不会伪造重复 success消费者返回成功只代表本次业务处理完成Outbox 的 delivered 状态仍是独立投递事实。
- Outbox 消费者 N/A 边界:卡观测序列请求仅幂等创建后续观测任务,代理主钱包预占/入账/退款消费者仅复核既有 Domain Ledger扣款消费者仅复核流水并按阈值幂等追加新的通知 Outbox均不把“校验通过、任务触发或二次投递成功”写成业务 Audit Event后续观测或通知实际改变业务事实时由对应 Worker/Application 自身写 Audit Event。Relay 的领取、入队、续租、delivered/failed、退避和重投保持技术投递事实不在 8.9 修改或审计化。
- request/correlation 组合时间线只按非空稳定 ID 精确读取 `tb_audit_event``tb_integration_log``tb_outbox_event`,以 `record_source` 保留 Audit、外部交互及可靠投递边界Outbox 只展示当前投递摘要,不把 delivered 解释为业务成功。Asynq 没有通用 PostgreSQL 历史表Query 仅从已落库的导入、批量购包、套餐失效、设备批量和导出任务资源生成 `asynq_task` 摘要,不扫描 Redis、不展示技术重试。订单、支付、退款、充值、钱包/流水、套餐权益、审批和佣金等只生成 `domain_ledger_ref` 稳定引用,金额与状态仍以业务表为准并留给 9.2 专业资金视角。Access Log 只返回 request ID 供开发检索,不读取日志文件;历史缺少 request/correlation/parent、直接 Audit 关联或稳定资源时通过 fidelity 标记原样降级,不按相近时间、相似资源或相同 correlation 猜测技术尝试。节点统一按发生时间、`record_source`、稳定节点 ID 升序排列。
- 资金调查时间线以平台只读 Query 组合 Audit Event、代理/资产钱包流水、代理钱包预占、订单、支付、退款、代理/个人资产充值、佣金、提现和审批当前业务事实;支持从店铺、钱包、订单、支付、退款、充值、审批、第三方交易号、操作者、时间或 correlation 中任一稳定条件进入,并在服务端解析已持久化关联,不要求前端补齐整条链路。节点按发生时间、`record_source`、稳定节点 ID 倒序分页,统一返回调查跳转引用;钱包流水的 `amount/balance_before/balance_after` 及各业务表金额字段明确标记为权威Audit Event 金额只作操作摘要,冲突时不改写历史事件并以对应 Domain Ledger 为准。平台身份复用统一审计 Query 的 SuperAdmin/Platform 后端校验,不增加店铺数据范围,也不提供资金重算、状态修改、导出或恢复能力。
- 风险调查视角只读聚合统一 Audit Event高/严重风险、涉及订单/支付/退款/充值/钱包/佣金等资金资源、安全类别,以及 `failed/denied/partial/unknown` 结果进入固定风险集合普通低风险成功事件明确排除。overview 在最长 31 天的显式时间范围内按风险、结果、action、来源和小时/日趋势聚合events 复用统一事件批量投影、稳定倒序分页及 `investigation_refs`可继续跳转事件、资源、actor 和 correlation。该视角不读取或修改业务状态不产生 Audit Event、Domain Ledger、Integration Log 或 Outbox也不提供处置工单、自动封禁、导出或恢复能力。
- 跨视角查询性能仅补已确认路径的 PostgreSQL B-tree保留事件时间、actor、scope、correlation 和资源时间线既有索引,补 action、result、risk、category、source 的稳定倒序分页索引,将 request/parent 索引补齐时间与 ID并为资源 type+id/key 到事件的反向关联补索引。全局、actor、资源和风险事件先分页事件 ID再批量投影事件与资源Integration 列表同样先分页 ID再批量读取列表字段不加载正文 JSON。第一阶段不增加 JSONB 任意搜索、Redis 结果缓存、月分区或冷热联合查询。
- 跨视角 HTTP 契约复用既有平台 `AuditHandler` 和生产/文档装配,仅新增 request、correlation、finance、risk 的只读 GET Handler/DTO/RouteSpec身份继续只取认证上下文未增加写路由、导出、处置、恢复或前端实现。前端导航文档逐行冻结 design 8.1-8.4 的源页面、前置接口、`response.data` 字段、入口可见条件、目标参数和降级规则,并以资产、订单、退款、钱包、通知、风险节点演示完整调用链。
- Audit Event 与 Event Resource 每日冷归档使用独立 `audit:daily:archive` Asynq 任务和 `tb_log_archive_run` 轻量账本:按 `Asia/Shanghai` 前一完整自然日、事件 `created_at` 半开区间分页读取,每行保存一个完整事件及其全部 `resources[]`,生成 JSONL+gzip、manifest 和 SHA-256对象 Key 按日期与 revision 稳定生成,上传后回读 metadata 核对大小、hash、事件数和资源数成功重复任务直接复用失败或对象不一致使用新 revision 且不覆盖旧对象。该基础设施任务不写业务 Audit Event不读取 Integration/Access/Domain Ledger/Outbox/旧 operation log不清理数据库也不向业务 Writer 注入对象存储;对象存储失败只更新归档账本并由 Asynq 重试,业务审计写入继续正常执行。
- Integration Log 冷归档复用同一对象存储、归档 Service 和 `tb_log_archive_run``integration:daily:archive``created_at` 保存前一自然日的结构化 JSONL+gzip 创建日快照;`integration:monthly:finalize` 在月初逐日按数据库当前内容重新生成并比较记录数与 SHA-256首次终结、内容变化或对象 metadata 不一致时创建新的不可变 revision旧对象不覆盖。月度复核仅把无 `pending` 记录且最终对象、manifest 均复核成功的日期标记 `is_final``pending`、对象损坏或复核失败会使任务失败并明确阻止后续清理。该切片不修改 Integration Writer、恢复语义或业务状态也不删除 PostgreSQL 数据、不提供对象存储查询/恢复接口。
- 月度留存清理使用 `audit:monthly:retention` Asynq 任务,在 `Asia/Shanghai` 每月 1 日 06:00 处理上一完整自然月:先补齐最后一日 Audit/Integration 归档并完成 Integration 最终 revision再逐日核对 ledger、manifest、对象 metadata、压缩对象实际大小/SHA-256 及数据库数量。全月硬门禁通过后仅按 Event Resource → Audit Event → Integration Log 顺序对 `tb_audit_event_resource``tb_audit_event``tb_integration_log` 以 1000 行有界批次执行 GORM 物理删除,并复用 `tb_log_archive_run.cleanup_started_at/cleaned_at` 断点续跑;对象存储归档和 manifest 长期保留。清理结果以 `retention_worker/system_task` 写当月 `audit.retention_cleanup` Audit Event资源为 `log_archive_month`Domain Ledger、Integration Log 新写、Outbox 均为 N/A因该事实是内部留存执行结果不是业务状态、外部交互或可靠投递。Access Log、订单/支付/退款/钱包等 Domain Ledger、Outbox、Asynq 运行事实、手动轮询、旧 operation log 及其他业务表明确不删除。
- 在线审计查询以 `tb_log_archive_run.cleaned_at/range_end` 作为真实清理边界:平台 Audit/Integration、request/correlation/finance/risk 及代理/企业活动响应统一返回 `retention{online_from,archived_before,timezone}`;缺省时间范围只查 PostgreSQL 在线窗口,显式早于或跨越边界返回 `CodeAuditDataArchived` 和当前边界。稳定事件或 Integration ID 只在在线库查找,不存在仍返回资源不存在;历史资源快照搜索和 Integration 尝试序列同样受边界限制。该 Query 切片不访问对象存储,不新增归档下载、恢复、冷热联合查询、导出或写路由,普通读取仍为 Audit Event/Domain Ledger/Integration Log/Outbox N/A。
### 与审计接入分开保留的独立修复 ### 与审计接入分开保留的独立修复

View File

@@ -944,6 +944,7 @@ rdb.Set(ctx, key, status, time.Hour)
## 文档 ## 文档
- [平台基础审计调查接口](docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md) - [平台基础审计调查接口](docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md)
- [跨视角调查与前端导航契约](docs/feature-504-multi-view-audit-center/跨视角调查与前端导航契约.md)
- [外部集成调查接口](docs/feature-504-multi-view-audit-center/外部集成调查接口.md) - [外部集成调查接口](docs/feature-504-multi-view-audit-center/外部集成调查接口.md)
- [代理和企业主体资源活动接口](docs/feature-504-multi-view-audit-center/主体资源活动接口.md) - [代理和企业主体资源活动接口](docs/feature-504-multi-view-audit-center/主体资源活动接口.md)

View File

@@ -16,6 +16,7 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
@@ -146,6 +147,7 @@ func runWorker(cfg *config.Config) {
taskHandler.RegisterHandlers() taskHandler.RegisterHandlers()
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger) registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger) registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, appLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers) outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle) taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger) startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
@@ -685,7 +687,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
} }
}() }()
appLogger.Info("Asynq Scheduler 已启动(企微审批恢复: 每 2 分钟,套餐临期提醒: 上海时区每日 03:00") appLogger.Info("Asynq Scheduler 已启动(企微审批恢复: 每 2 分钟,套餐临期提醒: 上海时区每日 03:00Audit 归档: 每日 04:00Integration 归档: 每日 04:30/月初 05:00月度留存清理: 月初 06:00")
return asynqScheduler return asynqScheduler
} }
@@ -760,9 +762,80 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
); err != nil { ); err != nil {
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err) return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
} }
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 4 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyArchive,
nil,
asynq.MaxRetry(10),
asynq.Timeout(2*time.Hour),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditDailyArchive)),
)); err != nil {
return fmt.Errorf("注册统一审计每日冷归档定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 4 * * *", asynq.NewTask(
constants.TaskTypeIntegrationDailyArchive,
nil,
asynq.MaxRetry(10),
asynq.Timeout(2*time.Hour),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationDailyArchive)),
)); err != nil {
return fmt.Errorf("注册 Integration Log 每日冷归档定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 1 * *", asynq.NewTask(
constants.TaskTypeIntegrationMonthlyFinalize,
nil,
asynq.MaxRetry(10),
asynq.Timeout(6*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationMonthlyFinalize)),
)); err != nil {
return fmt.Errorf("注册 Integration Log 月度最终版本复核任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 1 * *", asynq.NewTask(
constants.TaskTypeAuditMonthlyRetention,
nil,
asynq.MaxRetry(10),
asynq.Timeout(12*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditMonthlyRetention)),
)); err != nil {
return fmt.Errorf("注册月度日志留存清理任务失败: %w", err)
}
return nil return nil
} }
// registerAuditArchiveTask 注册 Audit 与 Integration 冷归档任务处理器。
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime.storageSvc == nil {
appLogger.Warn("对象存储未配置,审计归档任务将在执行时重试")
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(nil, appLogger).Handle)
return
}
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
if !ok || auditWriter == nil {
appLogger.Fatal("初始化月度日志留存清理失败:统一审计 Writer 未配置")
}
service, err := auditArchiveApp.NewService(runtime.db, runtime.storageSvc.Provider(), constants.AuditArchiveInstanceID, auditWriter)
if err != nil {
appLogger.Fatal("初始化统一审计归档服务失败", zap.Error(err))
}
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(service, appLogger).Handle)
appLogger.Info("注册审计归档任务处理器",
zap.String("audit_task_type", constants.TaskTypeAuditDailyArchive),
zap.String("integration_daily_task_type", constants.TaskTypeIntegrationDailyArchive),
zap.String("integration_monthly_task_type", constants.TaskTypeIntegrationMonthlyFinalize),
zap.String("retention_task_type", constants.TaskTypeAuditMonthlyRetention))
}
// createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。 // createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。
func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Handler { func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Handler {
stopResumeSvc, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface) stopResumeSvc, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)

View File

@@ -130,6 +130,82 @@ components:
user_agent: user_agent:
type: string type: string
type: object type: object
AuditFinanceAmountAuthority:
properties:
authoritative:
type: boolean
conflict_rule:
type: string
field:
type: string
table:
type: string
type: object
AuditFinanceTimelineNode:
properties:
amount:
nullable: true
type: integer
amount_authority:
$ref: '#/components/schemas/AuditFinanceAmountAuthority'
balance_after:
nullable: true
type: integer
balance_before:
nullable: true
type: integer
code:
type: string
currency:
type: string
facts:
additionalProperties: {}
nullable: true
type: object
investigation_refs:
$ref: '#/components/schemas/AuditInvestigationRefs'
node_id:
type: string
occurred_at:
format: date-time
type: string
record_source:
type: string
result:
type: string
result_name:
type: string
shop_id:
minimum: 0
nullable: true
type: integer
title:
type: string
wallet:
$ref: '#/components/schemas/AuditFinanceWalletRef'
type: object
AuditFinanceTimelinePage:
properties:
items:
items:
$ref: '#/components/schemas/AuditFinanceTimelineNode'
nullable: true
type: array
page:
type: integer
page_size:
type: integer
total:
type: integer
type: object
AuditFinanceWalletRef:
properties:
resource_type:
type: string
wallet_id:
minimum: 0
type: integer
type: object
AuditIntegrationRef: AuditIntegrationRef:
properties: properties:
integration_id: integration_id:
@@ -171,6 +247,76 @@ components:
resource_type: resource_type:
type: string type: string
type: object type: object
AuditLinkTimeline:
properties:
access_log_lookup_request_id:
nullable: true
type: string
correlation_id:
nullable: true
type: string
nodes:
items:
$ref: '#/components/schemas/AuditLinkTimelineNode'
nullable: true
type: array
request_id:
nullable: true
type: string
type: object
AuditLinkTimelineNode:
properties:
code:
type: string
correlation_id:
nullable: true
type: string
fidelity:
$ref: '#/components/schemas/AuditLinkageFidelity'
investigation_refs:
$ref: '#/components/schemas/AuditInvestigationRefs'
node_id:
type: string
occurred_at:
format: date-time
type: string
parent_event_id:
nullable: true
type: string
record_source:
type: string
reference_only:
type: boolean
request_id:
nullable: true
type: string
resources:
items:
$ref: '#/components/schemas/AuditInvestigationResourceRef'
nullable: true
type: array
result:
type: string
result_name:
type: string
summary:
type: string
title:
type: string
type: object
AuditLinkageFidelity:
properties:
correlation_available:
type: boolean
direct_audit_link_available:
type: boolean
parent_event_available:
type: boolean
request_available:
type: boolean
stable_resource_available:
type: boolean
type: object
AuditResourceCandidate: AuditResourceCandidate:
properties: properties:
display_name: display_name:
@@ -243,6 +389,88 @@ components:
subject_visibility: subject_visibility:
type: string type: string
type: object type: object
AuditRiskEventPage:
properties:
items:
items:
$ref: '#/components/schemas/AuditEventView'
nullable: true
type: array
page:
type: integer
page_size:
type: integer
total:
type: integer
type: object
AuditRiskNamedCount:
properties:
code:
type: string
count:
type: integer
name:
type: string
type: object
AuditRiskOverview:
properties:
actions:
items:
$ref: '#/components/schemas/AuditRiskNamedCount'
nullable: true
type: array
bucket:
type: string
results:
items:
$ref: '#/components/schemas/AuditRiskNamedCount'
nullable: true
type: array
risks:
items:
$ref: '#/components/schemas/AuditRiskNamedCount'
nullable: true
type: array
signals:
items:
$ref: '#/components/schemas/AuditRiskNamedCount'
nullable: true
type: array
sources:
items:
$ref: '#/components/schemas/AuditRiskNamedCount'
nullable: true
type: array
total:
type: integer
trend:
items:
$ref: '#/components/schemas/AuditRiskTrendPoint'
nullable: true
type: array
type: object
AuditRiskTrendPoint:
properties:
bucket_at:
format: date-time
type: string
denied:
type: integer
failed:
type: integer
finance:
type: integer
high_risk:
type: integer
partial:
type: integer
security:
type: integer
total:
type: integer
unknown:
type: integer
type: object
AuditSubjectActivity: AuditSubjectActivity:
properties: properties:
action_code: action_code:
@@ -1594,6 +1822,10 @@ components:
reduction_pct: reduction_pct:
description: 展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1 description: 展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1
type: number type: number
renewal_price:
description: 当前主套餐续费价格(分,按当前销售渠道的生效零售价计算;无主套餐或当前渠道不可续费时为 null
nullable: true
type: integer
sn: sn:
description: 设备序列号 description: 设备序列号
type: string type: string
@@ -15253,6 +15485,73 @@ paths:
summary: 查询操作者行为时间线 summary: 查询操作者行为时间线
tags: tags:
- 审计调查 - 审计调查
/api/admin/audit/correlations/{correlation_id}/timeline:
get:
description: correlation_id 来自审计、外部集成、可靠事件或业务详情的稳定引用。相同 correlation 只说明属于同一业务链路,不用于猜测技术重试。
parameters:
- description: 跨请求、异步任务和外部交互的稳定业务链路ID
in: path
name: correlation_id
required: true
schema:
description: 跨请求、异步任务和外部交互的稳定业务链路ID
type: string
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/AuditLinkTimeline'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询业务关联时间线
tags:
- 审计调查
/api/admin/audit/events: /api/admin/audit/events:
get: get:
description: 筛选值来自调查人员输入或其他调查节点的稳定引用身份范围只读取认证上下文。固定按发生时间和事件ID倒序不提供导出、修改或删除。 description: 筛选值来自调查人员输入或其他调查节点的稳定引用身份范围只读取认证上下文。固定按发生时间和事件ID倒序不提供导出、修改或删除。
@@ -15491,6 +15790,190 @@ paths:
summary: 查询审计事件详情 summary: 查询审计事件详情
tags: tags:
- 审计调查 - 审计调查
/api/admin/audit/finance/timeline:
get:
description: 可使用店铺、钱包、订单、支付、退款、充值、审批、交易号、操作者、时间或 correlation 中任一稳定条件进入;关联事实由服务端解析,金额以钱包流水及对应业务表为权威。
parameters:
- description: 店铺ID
in: query
name: shop_id
schema:
description: 店铺ID
minimum: 0
type: integer
- description: 代理或资产钱包ID
in: query
name: wallet_id
schema:
description: 代理或资产钱包ID
minimum: 0
type: integer
- description: 订单ID
in: query
name: order_id
schema:
description: 订单ID
minimum: 0
type: integer
- description: 订单编号
in: query
name: order_no
schema:
description: 订单编号
type: string
- description: 支付记录ID
in: query
name: payment_id
schema:
description: 支付记录ID
minimum: 0
type: integer
- description: 支付单号
in: query
name: payment_no
schema:
description: 支付单号
type: string
- description: 退款单ID
in: query
name: refund_id
schema:
description: 退款单ID
minimum: 0
type: integer
- description: 退款单号
in: query
name: refund_no
schema:
description: 退款单号
type: string
- description: 代理充值或个人资产充值ID
in: query
name: recharge_id
schema:
description: 代理充值或个人资产充值ID
minimum: 0
type: integer
- description: 充值单号
in: query
name: recharge_no
schema:
description: 充值单号
type: string
- description: 审批实例ID
in: query
name: approval_instance_id
schema:
description: 审批实例ID
minimum: 0
type: integer
- description: 第三方交易号
in: query
name: third_party_trade_no
schema:
description: 第三方交易号
type: string
- description: 操作者类型与actor_id同时提供
in: query
name: actor_kind
schema:
description: 操作者类型与actor_id同时提供
type: string
- description: 操作者稳定ID与actor_kind同时提供
in: query
name: actor_id
schema:
description: 操作者稳定ID与actor_kind同时提供
type: string
- description: 跨步骤业务链路ID
in: query
name: correlation_id
schema:
description: 跨步骤业务链路ID
type: string
- description: 开始时间RFC3339含时区
in: query
name: created_from
schema:
description: 开始时间RFC3339含时区
type: string
- description: 结束时间RFC3339含时区不包含该时刻
in: query
name: created_to
schema:
description: 结束时间RFC3339含时区不包含该时刻
type: string
- description: 页码默认1
in: query
name: page
schema:
description: 页码默认1
minimum: 1
type: integer
- description: 每页数量默认20最大100
in: query
name: page_size
schema:
description: 每页数量默认20最大100
maximum: 100
minimum: 1
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/AuditFinanceTimelinePage'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询资金调查时间线
tags:
- 审计调查
/api/admin/audit/integrations: /api/admin/audit/integrations:
get: get:
description: 组合筛选来自调查输入或关联视角稳定引用固定按创建时间和记录ID倒序分页不提供任意摘要搜索。 description: 组合筛选来自调查输入或关联视角稳定引用固定按创建时间和记录ID倒序分页不提供任意摘要搜索。
@@ -15951,6 +16434,73 @@ paths:
summary: 查询外部集成交互总览 summary: 查询外部集成交互总览
tags: tags:
- 审计调查 - 审计调查
/api/admin/audit/requests/{request_id}/timeline:
get:
description: request_id 来自审计或外部集成节点,也可由开发人员从 Access Log 粘贴。响应组合审计事件、外部交互、可靠事件和业务账本引用,但不扫描 Access Log 文件。
parameters:
- description: HTTP请求关联ID来自审计或外部集成节点也可从Access Log粘贴
in: path
name: request_id
required: true
schema:
description: HTTP请求关联ID来自审计或外部集成节点也可从Access Log粘贴
type: string
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/AuditLinkTimeline'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询请求关联时间线
tags:
- 审计调查
/api/admin/audit/resources/{resource_type}/{resource_id}/timeline: /api/admin/audit/resources/{resource_type}/{resource_id}/timeline:
get: get:
description: resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。 description: resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。
@@ -16153,6 +16703,217 @@ paths:
summary: 精确搜索注册资源 summary: 精确搜索注册资源
tags: tags:
- 审计调查 - 审计调查
/api/admin/audit/risks/events:
get:
description: 筛选条件来自风险总览分桶或调查人员输入,明细返回统一 investigation_refs可继续进入事件、资源、操作者和 correlation 视角;不提供处置或封禁能力。
parameters:
- description: 开始时间RFC3339含时区必填
in: query
name: created_from
required: true
schema:
description: 开始时间RFC3339含时区必填
type: string
- description: 结束时间RFC3339含时区不包含该时刻最长31天必填
in: query
name: created_to
required: true
schema:
description: 结束时间RFC3339含时区不包含该时刻最长31天必填
type: string
- description: 风险等级 (low:低, normal:普通, high:高, critical:严重)
in: query
name: risk
schema:
description: 风险等级 (low:低, normal:普通, high:高, critical:严重)
type: string
- description: 结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)
in: query
name: result
schema:
description: 结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)
type: string
- description: 稳定动作编码
in: query
name: action
schema:
description: 稳定动作编码
type: string
- description: 来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)
in: query
name: source
schema:
description: 来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)
type: string
- description: 页码默认1
in: query
name: page
schema:
description: 页码默认1
minimum: 1
type: integer
- description: 每页数量默认20最大100
in: query
name: page_size
schema:
description: 每页数量默认20最大100
maximum: 100
minimum: 1
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/AuditRiskEventPage'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询风险事件明细
tags:
- 审计调查
/api/admin/audit/risks/overview:
get:
description: 必须提供最长31天的时间范围只聚合高风险、资金、安全、失败、拒绝、部分成功和结果未知事件普通低风险成功事件不计入。
parameters:
- description: 开始时间RFC3339含时区必填
in: query
name: created_from
required: true
schema:
description: 开始时间RFC3339含时区必填
type: string
- description: 结束时间RFC3339含时区不包含该时刻最长31天必填
in: query
name: created_to
required: true
schema:
description: 结束时间RFC3339含时区不包含该时刻最长31天必填
type: string
- description: 风险等级 (low:低, normal:普通, high:高, critical:严重)
in: query
name: risk
schema:
description: 风险等级 (low:低, normal:普通, high:高, critical:严重)
type: string
- description: 结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)
in: query
name: result
schema:
description: 结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)
type: string
- description: 稳定动作编码
in: query
name: action
schema:
description: 稳定动作编码
type: string
- description: 来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)
in: query
name: source
schema:
description: 来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)
type: string
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/AuditRiskOverview'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询风险调查总览
tags:
- 审计调查
/api/admin/authorizations: /api/admin/authorizations:
get: get:
parameters: parameters:

View File

@@ -11,6 +11,10 @@
| 操作者 | `GET /api/admin/audit/actors/{kind}/{id}/events` | `investigation_refs.actor_ref` 或平台账号选择器 | | 操作者 | `GET /api/admin/audit/actors/{kind}/{id}/events` | `investigation_refs.actor_ref` 或平台账号选择器 |
| 资源搜索 | `GET /api/admin/audit/resources/search` | 调查人员选择类型并输入精确业务标识 | | 资源搜索 | `GET /api/admin/audit/resources/search` | 调查人员选择类型并输入精确业务标识 |
| 资源时间线 | `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline` | 业务响应稳定 ID、资源搜索结果或 `investigation_refs.resource_refs[]` | | 资源时间线 | `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline` | 业务响应稳定 ID、资源搜索结果或 `investigation_refs.resource_refs[]` |
| 请求链路 | `GET /api/admin/audit/requests/{request_id}/timeline` | 节点 `request_id` 或开发人员从 Access Log 粘贴 |
| 业务链路 | `GET /api/admin/audit/correlations/{correlation_id}/timeline` | 节点或业务详情中的稳定 `correlation_id` |
| 资金时间线 | `GET /api/admin/audit/finance/timeline` | 店铺、钱包、订单、支付、退款、充值、审批、交易号、操作者、时间或 correlation |
| 风险总览/明细 | `GET /api/admin/audit/risks/overview``GET /api/admin/audit/risks/events` | 调查时间范围及风险分桶稳定筛选 |
认证身份和数据范围只来自认证上下文,不接受 query/path/body 伪造。接口仅允许超级管理员和平台账号访问,全部为 GET不提供导出、修改、删除、恢复或处置能力。 认证身份和数据范围只来自认证上下文,不接受 query/path/body 伪造。接口仅允许超级管理员和平台账号访问,全部为 GET不提供导出、修改、删除、恢复或处置能力。
@@ -43,7 +47,7 @@
| 店铺主钱包流水 | path `shop_id`、资产类型与 ID | 资金视角使用 `shop_id`;资产 ID 存在时使用对应资源时间线 | | 店铺主钱包流水 | path `shop_id`、资产类型与 ID | 资金视角使用 `shop_id`;资产 ID 存在时使用对应资源时间线 |
| 资产钱包流水 | 上层 `wallet_id` | 资金视角使用 `wallet_id`;不解析业务编号前缀猜测资源 | | 资产钱包流水 | 上层 `wallet_id` | 资金视角使用 `wallet_id`;不解析业务编号前缀猜测资源 |
资金视角将在后续任务交付;当前文档只冻结其第一跳参数,缺失的支付、退款、钱包等关联由服务端 Query 解析。 资金视角已交付;缺失的支付、退款、钱包等关联由服务端 Query 解析。完整逐页面映射和调用链见[跨视角调查与前端导航契约](跨视角调查与前端导航契约.md)。
## 统一调查引用 ## 统一调查引用

View File

@@ -0,0 +1,113 @@
# 跨视角调查与前端导航契约
本文对应 `build-multi-view-audit-center` 任务 9.5,冻结 request、correlation、资金和风险调查接口以及现有业务页面进入审计中心的第一跳。所有字段路径均位于统一响应的 `response.data` 下;列表字段位于 `items[]`。本文只定义前端调用契约,不包含前端页面实现。
## 跨视角只读接口
| 视角 | 接口 | 入参来源 | 响应重点 |
|---|---|---|---|
| 请求链路 | `GET /api/admin/audit/requests/{request_id}/timeline` | 审计或 Integration 节点的 `investigation_refs.request_id`,或开发人员从 Access Log 粘贴 | `request_id``access_log_lookup_request_id``nodes[]` |
| 业务链路 | `GET /api/admin/audit/correlations/{correlation_id}/timeline` | 审计、Integration、Outbox、任务或业务详情中的稳定 correlation | `correlation_id``nodes[]` |
| 资金时间线 | `GET /api/admin/audit/finance/timeline` | 业务页面稳定 ID、调查节点引用或调查人员输入 | 分页 `items[]`、事实来源、金额权威、`investigation_refs` |
| 风险总览 | `GET /api/admin/audit/risks/overview` | 调查人员选择的 RFC3339 时间范围及可选筛选,最长 31 天 | 信号、风险、结果、动作、来源和趋势 |
| 风险明细 | `GET /api/admin/audit/risks/events` | 风险总览分桶携带相同筛选,或调查人员输入 | 分页风险事件及 `investigation_refs` |
以上接口仅允许超级管理员和平台账号访问,全部为 GET。认证身份只来自认证上下文不提供导出、修改、删除、风险处置、自动封禁、重试、补偿或恢复能力。
## 资产和组织页面逐行导航
| 源页面 | 前置接口 | `response.data` 稳定字段 | 入口名称与可见条件 | 目标接口与参数映射 | 降级行为 |
|---|---|---|---|---|---|
| 卡列表 | `GET /api/admin/iot-cards/standalone` | `items[].id/iccid/virtual_no/shop_id/device_virtual_no/authorized_enterprise_id` | 平台显示“审计记录”,要求 `id` 非零;代理显示“活动记录”,要求 `iccid` 非空;企业不从此列表进入 | 平台:`/audit/resources/iot_card/{id}/timeline`;代理:`/agent/resource-activities/iot_card/{iccid}` | 缺少对应 ID/ICCID 时隐藏;企业改从企业卡列表进入 |
| 设备列表 | `GET /api/admin/devices` | `items[].id/virtual_no/imei/sn/shop_id/bound_card_count/authorized_enterprise_id` | 平台要求 `id`;代理要求 `virtual_no`;企业不从此列表进入 | 平台:`/audit/resources/device/{id}/timeline`;代理:`/agent/resource-activities/device/{virtual_no}` | 缺少字段时隐藏;企业改从企业设备列表进入 |
| 设备卡槽 | `GET /api/admin/devices/{virtual_no}/cards` | `bindings[].id/iot_card_id/iccid/slot_position/is_current` | 平台可分别查看卡和绑定审计,要求相应 ID代理可查看卡活动要求 ICCID企业仅在设备和卡均有效授权时显示卡活动 | 平台卡:`iot_card/{iot_card_id}`;平台绑定:`device_sim_binding/{bindings[].id}`;代理/企业卡:`iot_card/{iccid}` 的主体活动接口 | 不用绑定 ID 证明授权;设备时间线继续使用上层设备 ID/VirtualNo |
| 统一资产详情 | `GET /api/admin/assets/resolve/{identifier}` | `asset_type/asset_id/identifier/virtual_no/iccid/bound_device_id/cards[].card_id/exchange_trace[].asset_id/can_view` | 平台在 `asset_id` 存在时显示“审计记录”;代理在卡 ICCID 或设备 VirtualNo 存在时显示“活动记录”;企业不显示 | 平台:`card→iot_card/{asset_id}``device→device/{asset_id}`;代理:卡用 `iot_card/{iccid}`、设备用 `device/{virtual_no}` | 缺稳定字段时隐藏;换货轨迹仅 `can_view=true` 且资产 ID 存在时跳转;企业不得回退调用 resolve |
| 资产分配列表/详情 | `GET /api/admin/asset-allocation-records[/{id}]` | `items[].id/allocation_no/asset_type/asset_id/asset_identifier/from_owner_type/from_owner_id/to_owner_type/to_owner_id/related_device_id`;详情另有 `related_card_ids[]` | 平台要求记录或资产 ID代理要求 `allocation_no` 且当前关联资产或店铺仍在范围内;企业无独立入口 | 平台:`asset_allocation_record/{id}``{asset_type}/{asset_id}`;代理:`/agent/resource-activities/asset_allocation_record/{allocation_no}` | 后端独立复核分配归属;不能因可打开旧详情就视为有权;企业从已授权资产活动查看结论 |
| 换货列表/详情 | `GET /api/admin/exchanges[/{id}]` | `items[].id/exchange_no/old_asset_type/old_asset_id/new_asset_type/new_asset_id/shop_id/submitter_id` | 平台要求换货或资产 ID代理要求 `exchange_no` 且换货店铺仍在范围内;企业无独立入口 | 平台:`exchange_order/{id}`,旧新资产分别使用响应类型和 ID代理`/agent/resource-activities/exchange_order/{exchange_no}` | 旧新资产仅在各自仍可管理时开放;企业从有效授权资产活动查看结论 |
| 店铺列表/详情 | `GET /api/admin/shops[/{id}]` | `items[].id/shop_name/shop_code/parent_id/business_owner_account_id` 或详情同名字段 | 平台要求 `id`;代理要求 `shop_code` 且为自己或下级店铺;企业不显示 | 平台:`/audit/resources/shop/{id}/timeline`;代理:`/agent/resource-activities/shop/{shop_code}` | 缺字段或越权时隐藏/显示不可用,不搜索平台审计 |
| 企业列表 | `GET /api/admin/enterprises` | `items[].id/enterprise_name/enterprise_code/owner_shop_id` | 平台要求 `id`;代理要求 `enterprise_code` 且 owner shop 在范围内;企业自身不显示 | 平台:`enterprise/{id}`;代理:`/agent/resource-activities/enterprise/{enterprise_code}` | 当前没有企业详情接口,前端不得假设存在;企业 ID 不作为主体活动路径参数 |
| 企业卡列表 | `GET /api/admin/enterprises/{id}/cards` | `items[].id/iccid/virtual_no/device_id` | 平台要求 `id`;企业要求 `iccid` 且当前授权有效;代理不从此列表进入 | 平台:`iot_card/{id}`;企业:`/enterprise/resource-activities/iot_card/{iccid}` | 路由中的企业 ID 不作为授权证明;后端始终使用认证上下文复核 |
| 企业设备列表 | `GET /api/admin/enterprises/{id}/devices` | `items[].device_id/virtual_no` | 平台要求 `device_id`;企业要求 `virtual_no` 且当前授权有效;代理不从此列表进入 | 平台:`device/{device_id}`;企业:`/enterprise/resource-activities/device/{virtual_no}` | 字段为空或授权撤销时隐藏/显示活动不可用,不回退平台接口 |
## 账号、交易和资金页面逐行导航
| 源页面 | 前置接口 | `response.data` 稳定字段 | 入口名称与可见条件 | 目标接口与参数映射 | 降级行为 |
|---|---|---|---|---|---|
| 账号列表/详情 | `GET /api/admin/accounts[/{id}]` | 列表 `items[].id`;详情 `id` | 平台在 ID 非零时显示“审计记录” | `/audit/resources/account/{id}/timeline` | 缺 ID 时隐藏,不按用户名搜索 |
| 店铺列表/详情 | `GET /api/admin/shops[/{id}]` | 列表 `items[].id`;详情 `id` | ID 非零时显示“审计记录”和“资金链路” | 审计:`shop/{id}`;资金:`/audit/finance/timeline?shop_id={id}` | 缺 ID 时两个入口均隐藏 |
| 企业列表 | `GET /api/admin/enterprises` | `items[].id` | ID 非零时显示“审计记录” | `/audit/resources/enterprise/{id}/timeline` | 不假设存在企业详情接口 |
| 订单列表/详情 | `GET /api/admin/orders[/{id}]` | `items[].id/order_no` 或详情 `id/order_no` | ID 非零时显示“审计记录”和“资金链路” | 审计:`order/{id}`;资金:`/audit/finance/timeline?order_id={id}` | 不要求前端补 payment/refund ID缺 ID 时隐藏 |
| 退款列表/详情 | `GET /api/admin/refunds[/{id}]` | `items[].id/refund_no/order_id/approval_instance_id` 或详情同名字段 | ID 非零时显示审计和资金入口;审批 ID 非零时显示“审批审计” | 审计:`refund/{id}`;资金:`finance/timeline?refund_id={id}`;审批:`approval_instance/{approval_instance_id}` | 缺审批 ID 只隐藏审批入口,不解析退款编号猜测 |
| 代理充值列表/详情 | `GET /api/admin/agent-recharges[/{id}]` | `items[].id/recharge_no/shop_id/agent_wallet_id/approval_instance_id` 或详情同名字段 | ID 非零时显示审计和资金入口;审批 ID 非零时显示审批审计 | 审计:`agent_recharge/{id}`;资金:`finance/timeline?recharge_id={id}` | 缺 `payment_no` 由服务端关联,不要求前端补猜 |
| 代理在线充值结果 | `POST /api/admin/agent-recharges` | `recharge_id/recharge_no/payment_no` | `recharge_id` 非零时显示“资金链路” | `finance/timeline?recharge_id={recharge_id}`;可附加 `payment_no` 精确筛选 | `payment_no` 不直接构造 Integration 详情;缺 recharge ID 时隐藏 |
| 资产钱包 | `GET /api/admin/assets/{identifier}/wallet` | `wallet_id/resource_type/resource_id` | `wallet_id` 非零时显示“资金链路”;资源类型和 ID 齐全时显示“资产审计” | 资金:`finance/timeline?wallet_id={wallet_id}`;审计:`resources/{resource_type}/{resource_id}/timeline` | 两个入口独立判断;缺某组字段只隐藏对应入口 |
| 店铺资金概况 | `GET /api/admin/shops/fund-summary` | `items[].shop_id` | `shop_id` 非零时显示行内“资金链路” | `finance/timeline?shop_id={shop_id}` | 不要求该接口未返回的 agent wallet ID |
| 店铺主钱包流水 | `GET /api/admin/shops/{shop_id}/main-wallet/transactions` | path `shop_id``items[].id/asset_type/asset_id/asset_identifier` | 始终可按合法 path 显示资金入口;资产类型和 ID 齐全时显示资产审计 | 资金:`finance/timeline?shop_id={shop_id}`;审计:`resources/{asset_type}/{asset_id}/timeline` | 缺资产 ID 仍保留店铺资金入口,不按资产编号猜测 |
| 资产钱包流水 | `GET /api/admin/assets/{identifier}/wallet/transactions` | 上层钱包接口 `wallet_id``items[].id/reference_type/reference_no` | 上层 `wallet_id` 非零时显示“资金链路” | `finance/timeline?wallet_id={wallet_id}` | `reference_type/reference_no` 仅展示;需后端节点明确返回资源引用后才能继续跳转 |
## 调查节点逐行跳转
| `investigation_refs` 字段 | 入口名称 | 可见条件 | 目标接口与参数 | 降级行为 |
|---|---|---|---|---|
| `event_id` | “事件详情” | 非空 | `GET /api/admin/audit/events/{event_id}` | 空值隐藏 |
| `actor_ref.kind/id` | “查看操作者行为” | kind 和 id 均非空 | `GET /api/admin/audit/actors/{kind}/{id}/events` | 任一缺失即隐藏,不用当前账号资料补齐 |
| `resource_refs[]` | “查看资源审计” | `resource_type/resource_id` 均非空 | `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline` | 只有 Key 时先精确搜索;零或多命中不自动选择 |
| `request_id` | “查看请求链路” | 非空 | `GET /api/admin/audit/requests/{request_id}/timeline` | 空值隐藏,不扫描 Access Log 猜测 |
| `correlation_id` | “查看业务链路” | 非空 | `GET /api/admin/audit/correlations/{correlation_id}/timeline` | 空值隐藏,不按相近时间拼链路 |
| `integration_refs[].integration_id` | “查看外部交互” | 非空 | `GET /api/admin/audit/integrations/{integration_id}` | 空值隐藏,不使用数据库主键或相似资源猜测 |
`actor_ref.kind` 第一阶段只使用 `account/openapi/system_task/scheduled_job/external_system`。代理和企业活动响应不得包含 `investigation_refs`
## 六条完整调用链
### 资产详情
1. 调用 `GET /api/admin/assets/resolve/{identifier}`
2. 平台读取 `data.asset_type/asset_id`:卡将 `card` 转为 `iot_card`,调用 `GET /api/admin/audit/resources/iot_card/{asset_id}/timeline?page=1&page_size=20`;设备调用 `.../device/{asset_id}/timeline`
3. 代理读取 `data.iccid``data.virtual_no`,调用对应 `/agent/resource-activities/...`
4. 字段缺失时隐藏入口;企业不调用 resolve也不回退平台接口。
### 订单
1. 调用 `GET /api/admin/orders/{id}`,读取 `data.id`
2. “审计记录”调用 `GET /api/admin/audit/resources/order/{id}/timeline`
3. “资金链路”调用 `GET /api/admin/audit/finance/timeline?order_id={id}&page=1&page_size=20`
4. Payment、Refund、钱包等关联由服务端解析前端不补猜。
### 退款
1. 调用 `GET /api/admin/refunds/{id}`,读取 `data.id` 和可选 `data.approval_instance_id`
2. 审计调用 `resources/refund/{id}/timeline`,资金调用 `finance/timeline?refund_id={id}`
3. 审批实例 ID 非零时再调用 `resources/approval_instance/{approval_instance_id}/timeline`
4. 审批字段缺失只隐藏审批入口,不影响退款审计与资金链路。
### 钱包
1. 调用 `GET /api/admin/assets/{identifier}/wallet`,读取 `data.wallet_id/resource_type/resource_id`
2. “资金链路”调用 `finance/timeline?wallet_id={wallet_id}`
3. “资产审计”调用 `resources/{resource_type}/{resource_id}/timeline`
4. 两组稳定字段分别判断,不使用交易备注或 reference 编号前缀推断资源。
### 通知
1. 点击通知后先调用 `GET /api/admin/notifications/{id}/target`
2. 仅当 `data.available=true` 时展示跳转。
3. `data.target_type=integration_log``data.target_key` 非空时,将 target key 原样作为 `integration_id` 调用 `GET /api/admin/audit/integrations/{target_key}`
4. 其他 target type 先进入对应业务详情,再按本文业务页面矩阵进入审计;不可用时只展示通知正文。
### 风险节点
1. 调用 `GET /api/admin/audit/risks/overview?created_from={from}&created_to={to}`
2. 点击风险、结果、动作或来源分桶时,将相同时间范围和对应稳定编码带入 `GET /api/admin/audit/risks/events`
3. 从明细 `items[].investigation_refs` 直接进入事件、操作者、资源、request、correlation 或 Integration 视角。
4. 缺少的引用入口隐藏;风险中心不提供处置、封禁或恢复按钮。
## 统一降级与事实边界
- 缺少目标接口必需的稳定 ID 或 identifier 时隐藏入口,不按名称、中文描述、时间或编号前缀猜测。
- 平台只有 Registry Key 时先调用精确资源搜索;零命中或多命中停留在搜索结果。
- 已删除资源只要调查节点仍有稳定资源类型和 ID就可查看事件快照时间线。
- 代理或企业遇到越权、授权撤销或资源不存在时统一显示“活动不可用”,不回退平台调查、资源搜索或旧 operation log。
- request/correlation 时间线的 `record_source` 保留 Audit Event、Integration Log、Outbox、Asynq 摘要和 Domain Ledger 引用的事实边界Outbox 投递成功不等于业务成功。
- 资金金额和余额以钱包流水及对应业务表为权威Audit Event 仅用于解释谁做了什么,不用于资金重算。
- 旧 operation log 仅由平台独立历史入口访问,不拼接到新 `/api/admin/audit/*`

View File

@@ -0,0 +1,350 @@
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type integrationArchiveFile struct {
path string
recordCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
type integrationArchiveManifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
RecordCount int64 `json:"record_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
Final bool `json:"final"`
}
// ArchivePreviousIntegrationDay 归档 Asia/Shanghai 前一完整自然日的 Integration Log 创建日快照。
func (s *Service) ArchivePreviousIntegrationDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveIntegrationDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveIntegrationDate 归档指定 Asia/Shanghai 自然日的 Integration Log 创建日快照。
func (s *Service) ArchiveIntegrationDate(ctx context.Context, archiveDate time.Time) error {
return s.archiveIntegrationDate(ctx, archiveDate, false)
}
// FinalizePreviousIntegrationMonth 复核并终结上一个完整自然月的 Integration Log 归档。
func (s *Service) FinalizePreviousIntegrationMonth(ctx context.Context) error {
now := time.Now().In(s.location)
return s.FinalizeIntegrationMonth(ctx, now.AddDate(0, -1, 0))
}
// FinalizeIntegrationMonth 逐日复核指定完整自然月,并为变化内容创建最终 revision。
func (s *Service) FinalizeIntegrationMonth(ctx context.Context, month time.Time) error {
monthStart := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
currentMonth := time.Now().In(s.location)
currentMonthStart := time.Date(currentMonth.Year(), currentMonth.Month(), 1, 0, 0, 0, 0, s.location)
if !monthStart.Before(currentMonthStart) {
return fmt.Errorf("只能终结已经结束的 Integration Log 完整自然月")
}
for date := monthStart; date.Before(monthStart.AddDate(0, 1, 0)); date = date.AddDate(0, 0, 1) {
if err := s.archiveIntegrationDate(ctx, date, true); err != nil {
return fmt.Errorf("终结 %s Integration Log 归档失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.Time, final bool) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("Integration Log 归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
if end.After(time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)) {
return fmt.Errorf("Integration Log 只能归档已经结束的完整自然日")
}
run, err := s.ensureIntegrationRun(ctx, start, end)
if err != nil {
return err
}
file, err := s.buildIntegrationArchiveFile(ctx, start, end)
if err != nil {
return err
}
defer os.Remove(file.path)
if final {
pending, pendingErr := s.integrationPendingCount(ctx, start, end)
if pendingErr != nil {
return pendingErr
}
if pending > 0 {
_ = s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).
Updates(map[string]any{"is_final": false, "error_summary": "存在 pending Integration Log无法形成最终归档", "updated_at": time.Now()}).Error
return fmt.Errorf("仍有 %d 条 pending Integration Log无法形成最终归档", pending)
}
}
if run.Status == constants.ArchiveStatusSuccess && run.RecordCount == file.recordCount && run.SHA256 == file.sha256 {
valid, validateErr := s.validateIntegrationRun(ctx, run)
if validateErr == nil && valid && (!final || run.IsFinal) {
return nil
}
}
acquired, err := s.acquireIntegrationRun(ctx, run)
if err != nil {
return err
}
if !acquired {
return fmt.Errorf("Integration Log 归档任务正在执行")
}
if err := s.uploadIntegrationArchive(ctx, run, file, final); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureIntegrationRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.IntegrationArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建 Integration Log 归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.IntegrationArchiveSource, start, s.instanceID, constants.IntegrationArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status != constants.ArchiveStatusPending {
revision++
}
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, now.Add(-3*time.Hour)).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision, "is_final": false,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定 Integration Log 归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
return true, nil
}
func (s *Service) buildIntegrationArchiveFile(ctx context.Context, start, end time.Time) (*integrationArchiveFile, error) {
temp, err := os.CreateTemp("", "integration-logs-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建 Integration Log 归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &integrationArchiveFile{path: path}
var lastID uint
for {
var logs []model.IntegrationLog
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&logs).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档记录失败: %w", err)
}
if len(logs) == 0 {
break
}
for i := range logs {
line, marshalErr := sonic.Marshal(logs[i])
if marshalErr != nil {
return nil, fmt.Errorf("序列化 Integration Log 归档记录失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入 Integration Log 归档压缩流失败: %w", writeErr)
}
result.recordCount++
result.uncompressedBytes += int64(len(line))
}
lastID = logs[len(logs)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) uploadIntegrationArchive(ctx context.Context, run *model.LogArchiveRun, file *integrationArchiveFile, final bool) error {
count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if count != file.recordCount {
return fmt.Errorf("Integration Log 归档生成期间记录数量发生变化")
}
objectKey, manifestKey := integrationObjectKeys(run.RangeStart, run.Revision)
metadata := integrationArchiveMetadata(file, run, final)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开 Integration Log 归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传 Integration Log 归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := integrationArchiveManifest{
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Source: constants.IntegrationArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
RecordCount: file.recordCount, UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision, GeneratedAt: generatedAt,
Status: constants.ArchiveStatusSuccess, Final: final,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化 Integration Log 归档清单失败: %w", err)
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": file.sha256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(final),
}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传 Integration Log 归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
return s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": constants.ArchiveStatusSuccess, "is_final": final,
"object_key": objectKey, "manifest_key": manifestKey, "record_count": file.recordCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt, "updated_at": completedAt,
}).Error
}
func (s *Service) validateIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
metadata := map[string]string{
"sha256": run.SHA256, "record-count": strconv.FormatInt(run.RecordCount, 10),
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) integrationRecordCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ?", start, end).Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 Integration Log 归档记录失败: %w", err)
}
return count, nil
}
func (s *Service) integrationPendingCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ? AND result = ?", start, end, constants.IntegrationResultPending).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 pending Integration Log 失败: %w", err)
}
return count, nil
}
func integrationObjectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("integration-logs-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func integrationArchiveMetadata(file *integrationArchiveFile, run *model.LogArchiveRun, final bool) map[string]string {
return map[string]string{
"schema-version": constants.IntegrationArchiveSchemaVersion,
"source": constants.IntegrationArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"record-count": strconv.FormatInt(file.recordCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
"final": strconv.FormatBool(final),
}
}

View File

@@ -0,0 +1,542 @@
package auditarchive
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
const maxManifestBytes = 1024 * 1024
// RetentionAudit 描述月度物理清理的统一审计事实。
type RetentionAudit struct {
EventID string
Month string
Summary string
Result string
ErrorSummary string
RangeStart time.Time
RangeEnd time.Time
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
DurationMS int64
}
// RetentionResult 是月度留存清理的结构化执行结果。
type RetentionResult struct {
Month string
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
Duration time.Duration
}
type retentionRuns struct {
audit []*model.LogArchiveRun
integration []*model.LogArchiveRun
}
// CleanupPreviousMonth 校验并物理清理上一个完整自然月的在线审计日志。
func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
}
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
if s.db == nil || s.store == nil || s.audit == nil {
return result, fmt.Errorf("日志留存清理数据库、对象存储或审计 Writer 未配置")
}
start, end, err := s.retentionMonthRange(month)
if err != nil {
return result, err
}
startedAt := time.Now()
result.Month = start.Format("2006-01")
cleanupErr = s.executeRetention(ctx, start, end, &result)
result.Duration = time.Since(startedAt)
if auditErr := s.recordRetentionAudit(ctx, start, end, result, cleanupErr); auditErr != nil {
if cleanupErr != nil {
return result, fmt.Errorf("%w记录留存清理失败审计失败: %v", cleanupErr, auditErr)
}
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
}
return result, cleanupErr
}
func (s *Service) retentionMonthRange(month time.Time) (time.Time, time.Time, error) {
start := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
end := start.AddDate(0, 1, 0)
now := time.Now().In(s.location)
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, s.location)
if !end.Before(currentMonth) && !end.Equal(currentMonth) {
return time.Time{}, time.Time{}, fmt.Errorf("只能清理已经结束的完整自然月")
}
return start, end, nil
}
func (s *Service) executeRetention(ctx context.Context, start, end time.Time, result *RetentionResult) error {
started, err := s.retentionCleanupStarted(ctx, start, end)
if err != nil {
return err
}
if !started {
lastDay := end.AddDate(0, 0, -1)
if err := s.ArchiveDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Audit 归档失败: %w", err)
}
if err := s.ArchiveIntegrationDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Integration Log 归档失败: %w", err)
}
if err := s.FinalizeIntegrationMonth(ctx, start); err != nil {
return err
}
}
runs, err := s.loadRetentionRuns(ctx, start, end)
if err != nil {
return err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return err
}
summarizeRetentionRuns(runs, result)
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
return err
}
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
}
func (s *Service) retentionCleanupStarted(ctx context.Context, start, end time.Time) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("读取月度清理断点失败: %w", err)
}
return count > 0, nil
}
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
var rows []model.LogArchiveRun
err := s.db.WithContext(ctx).Where(
"source IN ? AND archive_date >= ? AND archive_date < ? AND instance_id = ?",
[]string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID,
).Order("archive_date ASC, source ASC").Find(&rows).Error
if err != nil {
return retentionRuns{}, fmt.Errorf("读取月度归档账本失败: %w", err)
}
days := int(end.Sub(start).Hours() / 24)
if len(rows) != days*2 {
return retentionRuns{}, fmt.Errorf("月度归档账本缺日:期望 %d 条,实际 %d 条", days*2, len(rows))
}
runs := retentionRuns{audit: make([]*model.LogArchiveRun, 0, days), integration: make([]*model.LogArchiveRun, 0, days)}
for index := range rows {
run := &rows[index]
switch run.Source {
case constants.AuditArchiveSource:
runs.audit = append(runs.audit, run)
case constants.IntegrationArchiveSource:
runs.integration = append(runs.integration, run)
}
}
if len(runs.audit) != days || len(runs.integration) != days {
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
}
return runs, nil
}
func (s *Service) validateRetentionRuns(ctx context.Context, start, end time.Time, runs retentionRuns) error {
if err := validateCleanupLedgerState(runs.audit); err != nil {
return fmt.Errorf("Audit 清理断点非法: %w", err)
}
if err := validateCleanupLedgerState(runs.integration); err != nil {
return fmt.Errorf("Integration 清理断点非法: %w", err)
}
for index := range runs.audit {
date := start.AddDate(0, 0, index)
if err := s.validateAuditRetentionDay(ctx, date, runs.audit[index]); err != nil {
return fmt.Errorf("%s Audit 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration[index]); err != nil {
return fmt.Errorf("%s Integration 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func validateCleanupLedgerState(runs []*model.LogArchiveRun) error {
started, cleaned := 0, 0
for _, run := range runs {
if run.CleanupStartedAt != nil {
started++
}
if run.CleanedAt != nil {
cleaned++
}
}
if started != 0 && started != len(runs) {
return fmt.Errorf("清理开始断点不是整月原子状态")
}
if cleaned != 0 && cleaned != len(runs) {
return fmt.Errorf("清理完成断点不是整月原子状态")
}
if cleaned > 0 && started == 0 {
return fmt.Errorf("清理完成但缺少开始断点")
}
return nil
}
func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if err := validateRunBase(run, date, constants.AuditArchiveSchemaVersion, false); err != nil {
return err
}
if err := s.validateAuditManifest(ctx, run); err != nil {
return err
}
events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
return validateRemainingCounts(run, events, resources)
}
func (s *Service) validateIntegrationRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if err := validateRunBase(run, date, constants.IntegrationArchiveSchemaVersion, true); err != nil {
return err
}
if err := s.validateIntegrationManifest(ctx, run); err != nil {
return err
}
count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if run.CleanedAt != nil {
if count != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
}
return nil
}
if run.CleanupStartedAt != nil {
if count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
return nil
}
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
}
return nil
}
func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, final bool) error {
if run.Status != constants.ArchiveStatusSuccess || run.SchemaVersion != schema {
return fmt.Errorf("归档状态或 schema version 不符合清理要求")
}
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) ||
!run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
return fmt.Errorf("归档日期或半开时间范围不一致")
}
if final && !run.IsFinal {
return fmt.Errorf("Integration 最终 revision 尚未形成")
}
if run.ObjectKey == "" || run.ManifestKey == "" || run.SHA256 == "" {
return fmt.Errorf("归档对象、manifest 或 SHA-256 缺失")
}
return nil
}
func validateRemainingCounts(run *model.LogArchiveRun, events, resources int64) error {
if run.CleanedAt != nil {
if events != 0 || resources != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
return nil
}
if run.CleanupStartedAt != nil {
if events > run.EventCount || resources > run.ResourceCount {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
return nil
}
if events != run.EventCount || resources != run.ResourceCount {
return fmt.Errorf("数据库事件或资源数量与 manifest 不一致")
}
return nil
}
func (s *Service) validateAuditManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest Manifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount ||
manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey ||
manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Audit manifest 与 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, false)
}
func (s *Service) validateIntegrationManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest integrationArchiveManifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes ||
manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Integration manifest 与最终 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": "true",
}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, true)
}
func (s *Service) readManifest(ctx context.Context, key string, target any) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
return fmt.Errorf("读取 manifest metadata 失败: %w", err)
}
reader, err := s.store.Download(ctx, key)
if err != nil {
return fmt.Errorf("下载 manifest 失败: %w", err)
}
data, readErr := io.ReadAll(io.LimitReader(reader, maxManifestBytes+1))
closeErr := reader.Close()
if readErr != nil {
return fmt.Errorf("读取 manifest 失败: %w", readErr)
}
if closeErr != nil {
return fmt.Errorf("关闭 manifest 对象失败: %w", closeErr)
}
if len(data) > maxManifestBytes || int64(len(data)) != object.Size {
return fmt.Errorf("manifest 大小非法或不完整")
}
if err := sonic.Unmarshal(data, target); err != nil {
return fmt.Errorf("解析 manifest 失败: %w", err)
}
return nil
}
func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchiveRun, final bool) error {
metadata := map[string]string{
"schema-version": run.SchemaVersion, "source": run.Source,
"archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone,
"sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
if run.Source == constants.AuditArchiveSource {
metadata["event-count"] = strconv.FormatInt(run.EventCount, 10)
metadata["resource-count"] = strconv.FormatInt(run.ResourceCount, 10)
} else {
metadata["record-count"] = strconv.FormatInt(run.RecordCount, 10)
metadata["final"] = strconv.FormatBool(final)
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return err
}
reader, err := s.store.Download(ctx, run.ObjectKey)
if err != nil {
return fmt.Errorf("下载归档对象复核 SHA-256 失败: %w", err)
}
hasher := sha256.New()
written, copyErr := io.Copy(hasher, reader)
closeErr := reader.Close()
if copyErr != nil {
return fmt.Errorf("读取归档对象复核 SHA-256 失败: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("关闭归档对象失败: %w", closeErr)
}
if written != run.CompressedBytes || fmt.Sprintf("%x", hasher.Sum(nil)) != run.SHA256 {
return fmt.Errorf("归档对象大小或 SHA-256 复核失败")
}
return nil
}
func summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
result.ManifestKeys = make([]string, 0, len(runs.audit)+len(runs.integration))
for _, run := range runs.audit {
result.EventCount += run.EventCount
result.ResourceCount += run.ResourceCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
for _, run := range runs.integration {
result.IntegrationCount += run.RecordCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
}
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, start, end); err != nil {
return err
}
if err := s.deleteAuditResources(ctx, start, end); err != nil {
return err
}
if err := s.deleteAuditEvents(ctx, start, end); err != nil {
return err
}
return s.markCleaned(ctx, constants.AuditArchiveSource, start, end)
}
func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, start, end); err != nil {
return err
}
for {
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.IntegrationLog{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
break
}
}
return s.markCleaned(ctx, constants.IntegrationArchiveSource, start, end)
}
func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").
Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").
Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).
Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEventResource{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event Resource 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
return nil
}
}
}
func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEvent{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEvent{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
return nil
}
}
}
func (s *Service) markCleanupStarted(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理开始断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleanup_started_at IS NOT NULL", "开始")
}
func (s *Service) markCleaned(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleaned_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理完成断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleaned_at IS NOT NULL", "完成")
}
func (s *Service) validateCleanupMarkerCount(ctx context.Context, source string, start, end time.Time, marker, label string) error {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND "+marker,
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return fmt.Errorf("复核月度清理%s断点失败: %w", label, err)
}
expected := int64(end.Sub(start).Hours() / 24)
if count != expected {
return fmt.Errorf("月度清理%s断点不完整期望 %d 条,实际 %d 条", label, expected, count)
}
return nil
}
func allRunsCleaned(runs []*model.LogArchiveRun) bool {
return len(runs) > 0 && runs[0].CleanedAt != nil
}
func (s *Service) recordRetentionAudit(ctx context.Context, start, end time.Time, result RetentionResult, cleanupErr error) error {
audit := RetentionAudit{
Month: result.Month, RangeStart: start, RangeEnd: end,
EventCount: result.EventCount, ResourceCount: result.ResourceCount,
IntegrationCount: result.IntegrationCount, ManifestKeys: result.ManifestKeys,
DurationMS: result.Duration.Milliseconds(), Result: constants.AuditResultSuccess,
Summary: "完成已归档在线日志月度物理清理",
EventID: "evt_retention_" + strings.ReplaceAll(result.Month, "-", "_"),
}
if cleanupErr != nil {
audit.EventID = ""
audit.Result = constants.AuditResultFailed
audit.Summary = "已归档在线日志月度物理清理失败"
audit.ErrorSummary = truncateRetentionError(cleanupErr)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRetentionCleanup(ctx, tx, audit)
})
}
func truncateRetentionError(err error) string {
value := []rune(err.Error())
if len(value) > 500 {
value = value[:500]
}
return string(value)
}

View File

@@ -0,0 +1,412 @@
// Package auditarchive 实现统一审计每日冷归档用例。
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
const archivePageSize = 1000
// ObjectStore 是每日归档需要的最小对象存储能力。
type ObjectStore interface {
UploadWithMetadata(context.Context, string, io.Reader, string, map[string]string) error
Stat(context.Context, string) (*storage.ObjectMetadata, error)
Download(context.Context, string) (io.ReadCloser, error)
}
// RetentionAuditWriter 记录月度留存清理的系统审计事实。
type RetentionAuditWriter interface {
WriteRetentionCleanup(context.Context, *gorm.DB, RetentionAudit) error
}
// Service 编排审计归档生成、上传、复核和幂等账本更新。
type Service struct {
db *gorm.DB
store ObjectStore
audit RetentionAuditWriter
instanceID string
location *time.Location
}
// Manifest 是归档对象的完整性清单。
type Manifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
EventCount int64 `json:"event_count"`
ResourceCount int64 `json:"resource_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
}
type archiveLine struct {
Event model.AuditEvent `json:"event"`
Resources []model.AuditEventResource `json:"resources"`
}
type archiveFile struct {
path string
eventCount int64
resourceCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
// NewService 创建统一审计每日冷归档服务。
func NewService(db *gorm.DB, store ObjectStore, instanceID string, audit ...RetentionAuditWriter) (*Service, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return nil, fmt.Errorf("加载审计归档时区失败: %w", err)
}
if strings.TrimSpace(instanceID) == "" {
instanceID = "audit-archive"
}
var auditWriter RetentionAuditWriter
if len(audit) > 0 {
auditWriter = audit[0]
}
return &Service{db: db, store: store, audit: auditWriter, instanceID: instanceID, location: location}, nil
}
// ArchivePreviousDay 归档 Asia/Shanghai 前一完整自然日。
func (s *Service) ArchivePreviousDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveDate 归档指定 Asia/Shanghai 自然日。
func (s *Service) ArchiveDate(ctx context.Context, archiveDate time.Time) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("审计归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)
if end.After(todayStart) {
return fmt.Errorf("统一审计只能归档已经结束的完整自然日")
}
run, err := s.ensureRun(ctx, start, end)
if err != nil {
return err
}
if run.Status == constants.ArchiveStatusSuccess {
valid, validateErr := s.validateSuccessfulRun(ctx, run)
if validateErr == nil && valid {
return nil
}
}
acquired, err := s.acquireRun(ctx, run)
if err != nil || !acquired {
return err
}
if err := s.execute(ctx, run); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.AuditArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.AuditArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建审计归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.AuditArchiveSource, start, s.instanceID, constants.AuditArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取审计归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status == constants.ArchiveStatusFailed || run.Status == constants.ArchiveStatusSuccess || run.Status == constants.ArchiveStatusRunning {
revision++
}
now := time.Now()
staleBefore := now.Add(-3 * time.Hour)
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, staleBefore).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "",
"completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定审计归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
run.Status = constants.ArchiveStatusRunning
return true, nil
}
func (s *Service) execute(ctx context.Context, run *model.LogArchiveRun) error {
file, err := s.buildArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
dbEvents, dbResources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if dbEvents != file.eventCount || dbResources != file.resourceCount {
return fmt.Errorf("审计归档生成期间数据数量发生变化")
}
objectKey, manifestKey := objectKeys(run.RangeStart, run.Revision)
metadata := archiveMetadata(file, run)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开审计归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传审计归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭审计归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := Manifest{
SchemaVersion: constants.AuditArchiveSchemaVersion, Source: constants.AuditArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
EventCount: file.eventCount, ResourceCount: file.resourceCount,
UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision,
GeneratedAt: generatedAt, Status: constants.ArchiveStatusSuccess,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化审计归档清单失败: %w", err)
}
manifestMetadata := map[string]string{"source": constants.AuditArchiveSource, "data-sha256": file.sha256, "revision": strconv.Itoa(run.Revision)}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传审计归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
updates := map[string]any{
"status": constants.ArchiveStatusSuccess, "object_key": objectKey, "manifest_key": manifestKey,
"event_count": file.eventCount, "resource_count": file.resourceCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt,
"updated_at": completedAt,
}
if err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("更新审计归档成功账本失败: %w", err)
}
return nil
}
func (s *Service) buildArchiveFile(ctx context.Context, start, end time.Time) (*archiveFile, error) {
temp, err := os.CreateTemp("", "audit-events-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建审计归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &archiveFile{path: path}
var lastID uint
for {
var events []model.AuditEvent
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&events).Error; err != nil {
return nil, fmt.Errorf("读取审计归档事件失败: %w", err)
}
if len(events) == 0 {
break
}
ids := make([]uint, 0, len(events))
for i := range events {
ids = append(ids, events[i].ID)
}
var resources []model.AuditEventResource
if err := s.db.WithContext(ctx).Where("audit_event_id IN ?", ids).
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
return nil, fmt.Errorf("读取审计归档资源失败: %w", err)
}
grouped := make(map[uint][]model.AuditEventResource, len(events))
for i := range resources {
resource := resources[i]
grouped[resource.AuditEventID] = append(grouped[resource.AuditEventID], resource)
}
for i := range events {
eventResources := grouped[events[i].ID]
if eventResources == nil {
eventResources = []model.AuditEventResource{}
}
line, marshalErr := sonic.Marshal(archiveLine{Event: events[i], Resources: eventResources})
if marshalErr != nil {
return nil, fmt.Errorf("序列化审计归档事件失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入审计归档压缩流失败: %w", writeErr)
}
result.eventCount++
result.resourceCount += int64(len(grouped[events[i].ID]))
result.uncompressedBytes += int64(len(line))
}
lastID = events[len(events)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取审计归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) databaseCounts(ctx context.Context, start, end time.Time) (int64, int64, error) {
var eventCount int64
if err := s.db.WithContext(ctx).Model(&model.AuditEvent{}).
Where("created_at >= ? AND created_at < ?", start, end).Count(&eventCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档事件失败: %w", err)
}
var resourceCount int64
subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end)
if err := s.db.WithContext(ctx).Model(&model.AuditEventResource{}).
Where("audit_event_id IN (?)", subquery).Count(&resourceCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档资源失败: %w", err)
}
return eventCount, resourceCount, nil
}
func (s *Service) validateSuccessfulRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil || events != run.EventCount || resources != run.ResourceCount {
return false, err
}
metadata := map[string]string{
"sha256": run.SHA256, "event-count": strconv.FormatInt(run.EventCount, 10),
"resource-count": strconv.FormatInt(run.ResourceCount, 10), "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) verifyObject(ctx context.Context, key string, expectedSize int64, expectedMetadata map[string]string) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
return fmt.Errorf("复核归档对象 metadata 失败: %w", err)
}
if expectedSize >= 0 && object.Size != expectedSize {
return fmt.Errorf("归档对象大小复核失败")
}
for name, value := range expectedMetadata {
if object.Metadata[strings.ToLower(name)] != value {
return fmt.Errorf("归档对象 metadata 字段 %s 复核失败", name)
}
}
return nil
}
func (s *Service) markFailed(ctx context.Context, runID uint, archiveErr error) {
summary := []rune(archiveErr.Error())
if len(summary) > 500 {
summary = summary[:500]
}
failedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = s.db.WithContext(failedCtx).Model(&model.LogArchiveRun{}).Where("id = ?", runID).Updates(map[string]any{
"status": constants.ArchiveStatusFailed, "error_summary": string(summary), "updated_at": time.Now(),
}).Error
}
func objectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("audit-events-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func archiveMetadata(file *archiveFile, run *model.LogArchiveRun) map[string]string {
return map[string]string{
"schema-version": constants.AuditArchiveSchemaVersion,
"source": constants.AuditArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"event-count": strconv.FormatInt(file.eventCount, 10),
"resource-count": strconv.FormatInt(file.resourceCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
}
}

View File

@@ -84,7 +84,7 @@ func (s *Service) RecordCarrierCallbackFailure(ctx context.Context, card *model.
} }
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{ s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced, ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID, Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID,
}, businessErr) }, businessErr)
} }
@@ -94,6 +94,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置") return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
} }
var decision domain.RealnameDecision var decision domain.RealnameDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -102,6 +103,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
} }
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败") return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
} }
auditedCard = &card
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{ nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt, CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt, ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
@@ -185,11 +187,26 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置") return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
} }
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{ if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced, ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态", Summary: "运营商回调同步 IoT 卡实名状态",
Card: &card, Card: &card,
IntegrationID: observation.Metadata.ObservationID, IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt}, BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{ AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified), "real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
}, },
@@ -200,6 +217,13 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return nil return nil
}) })
if err != nil { if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.RealnameDecision{}, err return domain.RealnameDecision{}, err
} }
if s.cache != nil { if s.cache != nil {
@@ -226,6 +250,11 @@ func manualRefreshAuditAction(ctx context.Context) (string, bool) {
} }
} }
func workerObservationAudited(ctx context.Context) bool {
linkage := auditcontext.From(ctx)
return linkage.ActorKind == constants.AuditActorSystemTask && linkage.Source == constants.AuditSourceWorker
}
func realnameChangedEventID(cardID uint, observationID string) string { func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:" prefix := "card-realname:"
digest := sha256.Sum256([]byte(strconv.FormatUint(uint64(cardID), 10) + ":" + observationID + ":changed")) digest := sha256.Sum256([]byte(strconv.FormatUint(uint64(cardID), 10) + ":" + observationID + ":changed"))

View File

@@ -33,6 +33,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置") return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置")
} }
var decision domain.NetworkDecision var decision domain.NetworkDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -41,6 +42,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
} }
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败") return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
} }
auditedCard = &card
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{ nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason, CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling, IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
@@ -117,10 +119,46 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}); err != nil { }); err != nil {
return err return err
} }
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
} }
return nil return nil
}) })
if err != nil { if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.NetworkDecision{}, err return domain.NetworkDecision{}, err
} }
if s.cache != nil { if s.cache != nil {

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
@@ -21,6 +22,7 @@ type SeriesRequest struct {
Source string `json:"source"` Source string `json:"source"`
RequestID string `json:"request_id,omitempty"` RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
} }
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。 // DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
@@ -59,6 +61,7 @@ type SeriesTaskPayload struct {
Source string `json:"source"` Source string `json:"source"`
RequestID string `json:"request_id,omitempty"` RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
} }
// RunResult 描述一次实际 Gateway 请求及公共观测应用结果。 // RunResult 描述一次实际 Gateway 请求及公共观测应用结果。
@@ -135,7 +138,7 @@ func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, cand
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil { if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置") return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
} }
request = normalizeSeriesTrace(request) request = normalizeSeriesTrace(ctx, request)
if err := validateSeriesRequest(request); err != nil { if err := validateSeriesRequest(request); err != nil {
return "", false, err return "", false, err
} }
@@ -171,6 +174,7 @@ func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, cand
Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID, Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID,
SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source, SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source,
RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID, RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID,
ParentEventID: originalRequest.ParentEventID,
} }
if err := s.scheduler.Enqueue(ctx, payload); err != nil { if err := s.scheduler.Enqueue(ctx, payload); err != nil {
s.coordinator.ReleaseSchedule(ctx, seriesID, attempt) s.coordinator.ReleaseSchedule(ctx, seriesID, attempt)
@@ -303,12 +307,24 @@ func validateSeriesPayload(payload SeriesTaskPayload) error {
return validateSeriesRequest(SeriesRequest{ return validateSeriesRequest(SeriesRequest{
Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID, Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID,
SyncType: payload.SyncType, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID, SyncType: payload.SyncType, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID,
ParentEventID: payload.ParentEventID,
}) })
} }
func normalizeSeriesTrace(request SeriesRequest) SeriesRequest { func normalizeSeriesTrace(ctx context.Context, request SeriesRequest) SeriesRequest {
linkage := auditcontext.From(ctx)
request.RequestID = strings.TrimSpace(request.RequestID) request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID) request.CorrelationID = strings.TrimSpace(request.CorrelationID)
request.ParentEventID = strings.TrimSpace(request.ParentEventID)
if request.RequestID == "" {
request.RequestID = strings.TrimSpace(linkage.RequestID)
}
if request.CorrelationID == "" {
request.CorrelationID = strings.TrimSpace(linkage.CorrelationID)
}
if request.ParentEventID == "" {
request.ParentEventID = strings.TrimSpace(linkage.ParentEventID)
}
if request.RequestID == "" && request.CorrelationID == "" { if request.RequestID == "" && request.CorrelationID == "" {
traceID := uuid.NewString() traceID := uuid.NewString()
request.RequestID = traceID request.RequestID = traceID

View File

@@ -32,6 +32,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置") return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
} }
var decision domain.TrafficDecision var decision domain.TrafficDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -40,6 +41,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
} }
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败") return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
} }
auditedCard = &card
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{ nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB, CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB, CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
@@ -99,10 +101,38 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
}); err != nil { }); err != nil {
return err return err
} }
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
} }
return nil return nil
}) })
if err != nil { if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.TrafficDecision{}, err return domain.TrafficDecision{}, err
} }
if s.cache != nil { if s.cache != nil {

View File

@@ -191,7 +191,7 @@ func initServices(s *stores, deps *Dependencies) *services {
) )
cardObservationSeries := cardObservationApp.NewSeriesAttemptService( cardObservationSeries := cardObservationApp.NewSeriesAttemptService(
seriesCoordinator, seriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration), cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration, auditWriter),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration), cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
) )
observationSeries := cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier) observationSeries := cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier)

View File

@@ -102,11 +102,18 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
cardObservationInfra.NewEventWriter(cardObservationOutbox), cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger), cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
) )
iotCardAuditService := iotCardSvc.New(
deps.DB, stores.IotCard, stores.Shop, stores.AssetAllocationRecord,
stores.ShopPackageAllocation, stores.ShopSeriesAllocation, stores.PackageSeries,
deps.GatewayClient, deps.Logger, assetAudit,
)
iotCardAuditService.SetAccessAudit(auditWriter)
cardObservationService.SetStateAuditWriter(iotCardAuditService)
cardObservationIntegration := integrationlog.NewRepository(deps.DB) cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis) cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService( cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
cardObservationSeriesCoordinator, cardObservationSeriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, cardObservationIntegration), cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, cardObservationIntegration, auditWriter),
cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration), cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration),
) )

View File

@@ -121,15 +121,115 @@ func (h *AuditHandler) ResourceTimeline(c *fiber.Ctx) error {
return response.Success(c, result) return response.Success(c, result)
} }
// RequestTimeline 查询指定 HTTP 请求关联的跨事实时间线。
// GET /api/admin/audit/requests/:request_id/timeline
func (h *AuditHandler) RequestTimeline(c *fiber.Ctx) error {
requestID := c.Params("request_id")
if requestID == "" {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.RequestTimeline(c.UserContext(), requestID)
if err != nil {
return err
}
return response.Success(c, result)
}
// CorrelationTimeline 查询跨请求业务关联时间线。
// GET /api/admin/audit/correlations/:correlation_id/timeline
func (h *AuditHandler) CorrelationTimeline(c *fiber.Ctx) error {
correlationID := c.Params("correlation_id")
if correlationID == "" {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.CorrelationTimeline(c.UserContext(), correlationID)
if err != nil {
return err
}
return response.Success(c, result)
}
// FinanceTimeline 查询资金审计与业务账本的组合时间线。
// GET /api/admin/audit/finance/timeline
func (h *AuditHandler) FinanceTimeline(c *fiber.Ctx) error {
var request dto.AuditFinanceTimelineRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.FinanceTimeline(c.UserContext(), auditquery.FinanceFilter{
ShopID: request.ShopID, WalletID: request.WalletID, OrderID: request.OrderID, OrderNo: request.OrderNo,
PaymentID: request.PaymentID, PaymentNo: request.PaymentNo, RefundID: request.RefundID, RefundNo: request.RefundNo,
RechargeID: request.RechargeID, RechargeNo: request.RechargeNo, ApprovalInstanceID: request.ApprovalInstanceID,
ThirdPartyTradeNo: request.ThirdPartyTradeNo, ActorKind: request.ActorKind, ActorID: request.ActorID,
CorrelationID: request.CorrelationID, CreatedFrom: from, CreatedTo: to, Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// RiskOverview 查询固定风险信号总览。
// GET /api/admin/audit/risks/overview
func (h *AuditHandler) RiskOverview(c *fiber.Ctx) error {
var request dto.AuditRiskOverviewRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
filter, err := riskFilter(request.AuditRiskFilterRequest, 0, 0)
if err != nil {
return err
}
result, err := h.auditQuery.RiskOverview(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
// RiskEvents 查询固定风险集合的事件明细。
// GET /api/admin/audit/risks/events
func (h *AuditHandler) RiskEvents(c *fiber.Ctx) error {
var request dto.AuditRiskEventsRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
filter, err := riskFilter(request.AuditRiskFilterRequest, request.Page, request.PageSize)
if err != nil {
return err
}
result, err := h.auditQuery.RiskEvents(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
func riskFilter(request dto.AuditRiskFilterRequest, page, pageSize int) (auditquery.RiskFilter, error) {
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return auditquery.RiskFilter{}, errors.New(errors.CodeInvalidParam)
}
return auditquery.RiskFilter{
CreatedFrom: from, CreatedTo: to, Risk: request.Risk, Result: request.Result,
Action: request.Action, Source: request.Source, Page: page, PageSize: pageSize,
}, nil
}
// AgentResourceActivities 查询代理范围内的安全资源活动。 // AgentResourceActivities 查询代理范围内的安全资源活动。
// GET /api/admin/agent/resource-activities/:resource_type/:identifier // GET /api/admin/agent/resource-activities/:resource_type/:identifier
func (h *AuditHandler) AgentResourceActivities(c *fiber.Ctx) error { func (h *AuditHandler) AgentResourceActivities(c *fiber.Ctx) error {
request, err := subjectActivityRequest(c) request, from, to, err := subjectActivityRequest(c)
if err != nil { if err != nil {
return err return err
} }
result, err := h.auditQuery.AgentResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{ result, err := h.auditQuery.AgentResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier, ResourceType: request.ResourceType, Identifier: request.Identifier,
CreatedFrom: from, CreatedTo: to,
Page: request.Page, PageSize: request.PageSize, Page: request.Page, PageSize: request.PageSize,
}) })
if err != nil { if err != nil {
@@ -141,12 +241,13 @@ func (h *AuditHandler) AgentResourceActivities(c *fiber.Ctx) error {
// EnterpriseResourceActivities 查询企业当前有效授权资产的安全资源活动。 // EnterpriseResourceActivities 查询企业当前有效授权资产的安全资源活动。
// GET /api/admin/enterprise/resource-activities/:resource_type/:identifier // GET /api/admin/enterprise/resource-activities/:resource_type/:identifier
func (h *AuditHandler) EnterpriseResourceActivities(c *fiber.Ctx) error { func (h *AuditHandler) EnterpriseResourceActivities(c *fiber.Ctx) error {
request, err := subjectActivityRequest(c) request, from, to, err := subjectActivityRequest(c)
if err != nil { if err != nil {
return err return err
} }
result, err := h.auditQuery.EnterpriseResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{ result, err := h.auditQuery.EnterpriseResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier, ResourceType: request.ResourceType, Identifier: request.Identifier,
CreatedFrom: from, CreatedTo: to,
Page: request.Page, PageSize: request.PageSize, Page: request.Page, PageSize: request.PageSize,
}) })
if err != nil { if err != nil {
@@ -155,17 +256,21 @@ func (h *AuditHandler) EnterpriseResourceActivities(c *fiber.Ctx) error {
return response.Success(c, result) return response.Success(c, result)
} }
func subjectActivityRequest(c *fiber.Ctx) (dto.SubjectResourceActivityRequest, error) { func subjectActivityRequest(c *fiber.Ctx) (dto.SubjectResourceActivityRequest, *time.Time, *time.Time, error) {
var request dto.SubjectResourceActivityRequest var request dto.SubjectResourceActivityRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) { if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return request, errors.New(errors.CodeInvalidParam) return request, nil, nil, errors.New(errors.CodeInvalidParam)
} }
request.ResourceType = c.Params("resource_type") request.ResourceType = c.Params("resource_type")
request.Identifier = c.Params("identifier") request.Identifier = c.Params("identifier")
if request.ResourceType == "" || request.Identifier == "" { if request.ResourceType == "" || request.Identifier == "" {
return request, errors.New(errors.CodeInvalidParam) return request, nil, nil, errors.New(errors.CodeInvalidParam)
} }
return request, nil from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return request, nil, nil, errors.New(errors.CodeInvalidParam)
}
return request, from, to, nil
} }
// IntegrationOverview 查询外部集成交互总览。 // IntegrationOverview 查询外部集成交互总览。

View File

@@ -109,7 +109,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
}, },
}) })
if err != nil { if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, &card, log.IntegrationID, err) h.observation.RecordCarrierCallbackFailure(ctx, card, log.IntegrationID, err)
return h.fail(ctx, log.IntegrationID, err) return h.fail(ctx, log.IntegrationID, err)
} }
if h.series != nil { if h.series != nil {

View File

@@ -135,7 +135,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
}, },
}) })
if err != nil { if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, &card, log.IntegrationID, err) h.observation.RecordCarrierCallbackFailure(ctx, card, log.IntegrationID, err)
return h.failPending(ctx, log.IntegrationID, err) return h.failPending(ctx, log.IntegrationID, err)
} }
if h.series != nil { if h.series != nil {

View File

@@ -108,7 +108,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
}, },
}) })
if err != nil { if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, &card, log.IntegrationID, err) h.observation.RecordCarrierCallbackFailure(ctx, card, log.IntegrationID, err)
return h.fail(ctx, log.IntegrationID, err) return h.fail(ctx, log.IntegrationID, err)
} }
if h.series != nil { if h.series != nil {

View File

@@ -303,7 +303,7 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
} }
if log.Result == constants.IntegrationResultPending { if log.Result == constants.IntegrationResultPending {
resolvedResourceID := strconv.FormatUint(uint64(result.PaymentID), 10) resolvedResourceID := strconv.FormatUint(uint64(result.PaymentID), 10)
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{ _, err := h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS", Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS",
ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed}, ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed},
StateChanged: !result.AlreadyConfirmed, ResourceID: &resolvedResourceID, StateChanged: !result.AlreadyConfirmed, ResourceID: &resolvedResourceID,

View File

@@ -105,6 +105,9 @@ func NewRegistry() *Registry {
iotCardRealnameCallbackSynced := iotCardAction(constants.AuditActionIotCardRealnameCallbackSynced, "运营商回调同步 IoT 卡实名状态", constants.AuditActorExternalSystem, constants.AuditSourceCallback) iotCardRealnameCallbackSynced := iotCardAction(constants.AuditActionIotCardRealnameCallbackSynced, "运营商回调同步 IoT 卡实名状态", constants.AuditActorExternalSystem, constants.AuditSourceCallback)
iotCardManualRefreshed := iotCardAction(constants.AuditActionIotCardManualRefreshed, "人工刷新 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI) iotCardManualRefreshed := iotCardAction(constants.AuditActionIotCardManualRefreshed, "人工刷新 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardPersonalRefreshed := iotCardAction(constants.AuditActionIotCardPersonalRefreshed, "个人客户刷新 IoT 卡", constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) iotCardPersonalRefreshed := iotCardAction(constants.AuditActionIotCardPersonalRefreshed, "个人客户刷新 IoT 卡", constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
iotCardWorkerRealnameSynced := iotCardAction(constants.AuditActionIotCardWorkerRealnameSynced, "Worker 同步 IoT 卡实名事实", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardWorkerTrafficSynced := iotCardAction(constants.AuditActionIotCardWorkerTrafficSynced, "Worker 同步 IoT 卡流量事实", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardWorkerNetworkSynced := iotCardAction(constants.AuditActionIotCardWorkerNetworkSynced, "Worker 同步 IoT 卡网络事实", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardManualStopped := iotCardAction(constants.AuditActionIotCardManualStopped, "人工停用 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI) iotCardManualStopped := iotCardAction(constants.AuditActionIotCardManualStopped, "人工停用 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardManualStarted := iotCardAction(constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI) iotCardManualStarted := iotCardAction(constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardAutoStopped := iotCardAction(constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络", constants.AuditActorSystemTask, constants.AuditSourceWorker) iotCardAutoStopped := iotCardAction(constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络", constants.AuditActorSystemTask, constants.AuditSourceWorker)
@@ -131,6 +134,7 @@ func NewRegistry() *Registry {
deviceCardBound := deviceAction(constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI) deviceCardBound := deviceAction(constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceCardUnbound := deviceAction(constants.AuditActionDeviceCardUnbound, "设备解绑 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI) deviceCardUnbound := deviceAction(constants.AuditActionDeviceCardUnbound, "设备解绑 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceCurrentCardSwitched := deviceExternalAction(constants.AuditActionDeviceCurrentCardSwitched, "切换设备当前卡", true) deviceCurrentCardSwitched := deviceExternalAction(constants.AuditActionDeviceCurrentCardSwitched, "切换设备当前卡", true)
deviceWorkerObservationSynced := deviceAction(constants.AuditActionDeviceWorkerObservationSynced, "Worker 同步设备观测事实", constants.AuditActorSystemTask, constants.AuditSourceWorker)
cardExchangeCreated := cardExchangeAction(constants.AuditActionCardExchangeCreated, "创建卡换货单", constants.AuditRiskNormal, false) cardExchangeCreated := cardExchangeAction(constants.AuditActionCardExchangeCreated, "创建卡换货单", constants.AuditRiskNormal, false)
cardExchangeShippingInfoSubmitted := cardExchangeAction(constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息", constants.AuditRiskHigh, true) cardExchangeShippingInfoSubmitted := cardExchangeAction(constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息", constants.AuditRiskHigh, true)
cardExchangeShipped := cardExchangeAction(constants.AuditActionCardExchangeShipped, "卡换货发货", constants.AuditRiskNormal, false) cardExchangeShipped := cardExchangeAction(constants.AuditActionCardExchangeShipped, "卡换货发货", constants.AuditRiskNormal, false)
@@ -205,6 +209,14 @@ func NewRegistry() *Registry {
notificationReadAll.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}} notificationReadAll.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
notificationCleanup := notificationAction(constants.AuditActionNotificationCleanup, "清理过期通知", constants.AuditResourceNotificationCleanupBatch, constants.AuditActorSystemTask, constants.AuditSourceWorker) notificationCleanup := notificationAction(constants.AuditActionNotificationCleanup, "清理过期通知", constants.AuditResourceNotificationCleanupBatch, constants.AuditActorSystemTask, constants.AuditSourceWorker)
notificationCleanupItem := notificationAction(constants.AuditActionNotificationCleanupItem, "清理单条过期通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker) notificationCleanupItem := notificationAction(constants.AuditActionNotificationCleanupItem, "清理单条过期通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker)
retentionCleanup := ActionDefinition{
Code: constants.AuditActionLogRetentionCleanup, Name: "清理已归档在线日志",
Category: constants.AuditCategoryReliability, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceLogArchiveMonth, AllowedActor: constants.AuditActorSystemTask,
Source: constants.AuditSourceWorker, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
pollingConfigCreated := pollingAction(constants.AuditActionPollingConfigCreated, "创建轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh) pollingConfigCreated := pollingAction(constants.AuditActionPollingConfigCreated, "创建轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigUpdated := pollingAction(constants.AuditActionPollingConfigUpdated, "更新轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh) pollingConfigUpdated := pollingAction(constants.AuditActionPollingConfigUpdated, "更新轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigDeleted := pollingAction(constants.AuditActionPollingConfigDeleted, "删除轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh) pollingConfigDeleted := pollingAction(constants.AuditActionPollingConfigDeleted, "删除轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
@@ -407,6 +419,9 @@ func NewRegistry() *Registry {
constants.AuditActionIotCardRealnameCallbackSynced: iotCardRealnameCallbackSynced, constants.AuditActionIotCardRealnameCallbackSynced: iotCardRealnameCallbackSynced,
constants.AuditActionIotCardManualRefreshed: iotCardManualRefreshed, constants.AuditActionIotCardManualRefreshed: iotCardManualRefreshed,
constants.AuditActionIotCardPersonalRefreshed: iotCardPersonalRefreshed, constants.AuditActionIotCardPersonalRefreshed: iotCardPersonalRefreshed,
constants.AuditActionIotCardWorkerRealnameSynced: iotCardWorkerRealnameSynced,
constants.AuditActionIotCardWorkerTrafficSynced: iotCardWorkerTrafficSynced,
constants.AuditActionIotCardWorkerNetworkSynced: iotCardWorkerNetworkSynced,
constants.AuditActionIotCardManualStopped: iotCardManualStopped, constants.AuditActionIotCardManualStopped: iotCardManualStopped,
constants.AuditActionIotCardManualStarted: iotCardManualStarted, constants.AuditActionIotCardManualStarted: iotCardManualStarted,
constants.AuditActionIotCardAutoStopped: iotCardAutoStopped, constants.AuditActionIotCardAutoStopped: iotCardAutoStopped,
@@ -432,6 +447,7 @@ func NewRegistry() *Registry {
constants.AuditActionDeviceCardBound: deviceCardBound, constants.AuditActionDeviceCardBound: deviceCardBound,
constants.AuditActionDeviceCardUnbound: deviceCardUnbound, constants.AuditActionDeviceCardUnbound: deviceCardUnbound,
constants.AuditActionDeviceCurrentCardSwitched: deviceCurrentCardSwitched, constants.AuditActionDeviceCurrentCardSwitched: deviceCurrentCardSwitched,
constants.AuditActionDeviceWorkerObservationSynced: deviceWorkerObservationSynced,
constants.AuditActionCardExchangeCreated: cardExchangeCreated, constants.AuditActionCardExchangeCreated: cardExchangeCreated,
constants.AuditActionCardExchangeShippingInfoSubmitted: cardExchangeShippingInfoSubmitted, constants.AuditActionCardExchangeShippingInfoSubmitted: cardExchangeShippingInfoSubmitted,
constants.AuditActionCardExchangeShipped: cardExchangeShipped, constants.AuditActionCardExchangeShipped: cardExchangeShipped,
@@ -478,6 +494,7 @@ func NewRegistry() *Registry {
constants.AuditActionNotificationReadAll: notificationReadAll, constants.AuditActionNotificationReadAll: notificationReadAll,
constants.AuditActionNotificationCleanup: notificationCleanup, constants.AuditActionNotificationCleanup: notificationCleanup,
constants.AuditActionNotificationCleanupItem: notificationCleanupItem, constants.AuditActionNotificationCleanupItem: notificationCleanupItem,
constants.AuditActionLogRetentionCleanup: retentionCleanup,
constants.AuditActionPollingConfigCreated: pollingConfigCreated, constants.AuditActionPollingConfigCreated: pollingConfigCreated,
constants.AuditActionPollingConfigUpdated: pollingConfigUpdated, constants.AuditActionPollingConfigUpdated: pollingConfigUpdated,
constants.AuditActionPollingConfigDeleted: pollingConfigDeleted, constants.AuditActionPollingConfigDeleted: pollingConfigDeleted,
@@ -608,6 +625,10 @@ func NewRegistry() *Registry {
"resource_type", "resource_id", "resource_key", "correlation_id", "resource_type", "resource_id", "resource_key", "correlation_id",
}, },
}, },
constants.AuditResourceLogArchiveMonth: {
Type: constants.AuditResourceLogArchiveMonth, Name: "日志归档自然月",
IdentityFields: []string{"month", "timezone", "range_start", "range_end"},
},
constants.AuditResourceDeviceBatchTask: { constants.AuditResourceDeviceBatchTask: {
Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务", Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务",
IdentityFields: []string{"task_no", "operation_type"}, IdentityFields: []string{"task_no", "operation_type"},

View File

@@ -0,0 +1,38 @@
package audit
import (
"context"
"gorm.io/gorm"
auditarchive "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// WriteRetentionCleanup 将月度物理清理结果写入当前在线月份的统一审计事件。
func (w *Writer) WriteRetentionCleanup(ctx context.Context, tx *gorm.DB, input auditarchive.RetentionAudit) error {
return w.Append(ctx, tx, AppendInput{
EventID: input.EventID, ActionCode: constants.AuditActionLogRetentionCleanup,
Summary: input.Summary,
Actor: ActorInput{
Kind: constants.AuditActorSystemTask, ID: constants.AuditActorIDRetentionWorker, Name: "日志留存清理任务",
},
Source: constants.AuditSourceWorker, ScopeType: constants.AuditScopePlatform,
Result: input.Result, ErrorSummary: input.ErrorSummary,
CorrelationID: "retention:" + input.Month,
Metadata: map[string]any{
"event_count": input.EventCount, "resource_count": input.ResourceCount,
"integration_count": input.IntegrationCount, "manifest_keys": input.ManifestKeys,
"duration_ms": input.DurationMS,
},
Resources: []ResourceInput{{
Type: constants.AuditResourceLogArchiveMonth, Key: input.Month, DisplayName: input.Month,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRetentionMonth,
IdentitySnapshot: map[string]any{
"month": input.Month, "timezone": constants.AuditArchiveTimezone,
"range_start": input.RangeStart, "range_end": input.RangeEnd,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}

View File

@@ -12,9 +12,11 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
@@ -77,11 +79,15 @@ type SeriesRunner struct {
observation *cardapp.Service observation *cardapp.Service
integration *integrationlog.Repository integration *integrationlog.Repository
carrier *postgres.CarrierStore carrier *postgres.CarrierStore
auditWriter *audit.Writer
} }
// NewSeriesRunner 创建卡观测序列 Gateway 执行器。 // NewSeriesRunner 创建卡观测序列 Gateway 执行器。
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *SeriesRunner { func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository, auditWriter *audit.Writer) *SeriesRunner {
return &SeriesRunner{db: db, gateway: gatewayClient, observation: observation, integration: integration, carrier: postgres.NewCarrierStore(db)} return &SeriesRunner{
db: db, gateway: gatewayClient, observation: observation, integration: integration,
carrier: postgres.NewCarrierStore(db), auditWriter: auditWriter,
}
} }
// Provider 返回用于请求互斥的运营商接入标识。 // Provider 返回用于请求互斥的运营商接入标识。
@@ -188,7 +194,7 @@ func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.Series
response, runErr := r.gateway.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: deviceGatewayIdentifier(device)}) response, runErr := r.gateway.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: deviceGatewayIdentifier(device)})
result := cardapp.RunResult{} result := cardapp.RunResult{}
if runErr == nil { if runErr == nil {
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response) result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response, attempt.IntegrationID)
} }
completionResult := constants.IntegrationResultSuccess completionResult := constants.IntegrationResultSuccess
if runErr != nil { if runErr != nil {
@@ -222,7 +228,7 @@ func (r *SeriesRunner) startDeviceAttempt(ctx context.Context, payload cardapp.S
}) })
} }
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp) (bool, error) { func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp, integrationID string) (bool, error) {
if response == nil { if response == nil {
return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空") return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空")
} }
@@ -236,21 +242,19 @@ func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device
if lastOnlineTime := parseGatewayTime(response.LastOnlineTime); lastOnlineTime != nil { if lastOnlineTime := parseGatewayTime(response.LastOnlineTime); lastOnlineTime != nil {
updates["last_online_time"] = lastOnlineTime updates["last_online_time"] = lastOnlineTime
} }
var currentBinding struct { deviceChanged := device.OnlineStatus != int(response.OnlineStatus) ||
SlotPosition int
}
currentSlotErr := r.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("slot_position").
Where("device_id = ? AND bind_status = ? AND is_current = ?", device.ID, constants.BindStatusBound, true).
Take(&currentBinding).Error
if currentSlotErr != nil && currentSlotErr != gorm.ErrRecordNotFound {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, currentSlotErr, "读取设备当前槽位失败")
}
changed := device.OnlineStatus != int(response.OnlineStatus) ||
device.SoftwareVersion != string(response.SoftwareVersion) || device.SoftwareVersion != string(response.SoftwareVersion) ||
device.SwitchMode != string(response.SwitchMode) || device.SwitchMode != string(response.SwitchMode)
currentBinding.SlotPosition != int(response.CurrentSlotNo) changed := deviceChanged
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
state, err := loadDeviceObservationState(ctx, tx, device.ID, int(response.CurrentSlotNo))
if err != nil {
return err
}
beforeSlot := bindingSlot(state.current)
afterSlot := bindingSlot(state.target)
changed = deviceChanged || beforeSlot != int(response.CurrentSlotNo)
auditChanged := deviceChanged || beforeSlot != afterSlot
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil { if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil {
return err return err
} }
@@ -259,19 +263,192 @@ func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device
Update("is_current", false).Error; err != nil { Update("is_current", false).Error; err != nil {
return err return err
} }
if int(response.CurrentSlotNo) <= 0 { if int(response.CurrentSlotNo) > 0 {
if err := tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound).
Update("is_current", true).Error; err != nil {
return err
}
}
if !auditChanged {
return nil return nil
} }
return tx.Model(&model.DeviceSimBinding{}). return r.appendDeviceObservationAudit(ctx, tx, device, state, beforeSlot, afterSlot, integrationID, map[string]any{
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound). "online_status": device.OnlineStatus, "software_version": device.SoftwareVersion,
Update("is_current", true).Error "switch_mode": device.SwitchMode, "current_slot": beforeSlot,
}, map[string]any{
"online_status": int(response.OnlineStatus), "software_version": string(response.SoftwareVersion),
"switch_mode": string(response.SwitchMode), "current_slot": afterSlot,
})
}) })
if err != nil { if err != nil {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败") wrapped := apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
r.recordDeviceObservationFailure(ctx, device, integrationID, wrapped)
return false, wrapped
} }
return changed, nil return changed, nil
} }
type deviceObservationState struct {
bindings []model.DeviceSimBinding
cards map[uint]*model.IotCard
current *model.DeviceSimBinding
target *model.DeviceSimBinding
}
func loadDeviceObservationState(ctx context.Context, tx *gorm.DB, deviceID uint, targetSlot int) (*deviceObservationState, error) {
state := &deviceObservationState{cards: make(map[uint]*model.IotCard)}
if err := tx.WithContext(ctx).Where("device_id = ? AND bind_status = ?", deviceID, constants.BindStatusBound).
Order("slot_position ASC").Find(&state.bindings).Error; err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取设备有效卡槽失败")
}
cardIDs := make([]uint, 0, len(state.bindings))
for index := range state.bindings {
binding := &state.bindings[index]
cardIDs = append(cardIDs, binding.IotCardID)
if binding.IsCurrent && state.current == nil {
state.current = binding
}
if targetSlot > 0 && binding.SlotPosition == targetSlot {
state.target = binding
}
}
if len(cardIDs) == 0 {
return state, nil
}
var cards []*model.IotCard
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取设备卡槽关联 IoT 卡失败")
}
for _, card := range cards {
state.cards[card.ID] = card
}
return state, nil
}
func bindingSlot(binding *model.DeviceSimBinding) int {
if binding == nil {
return 0
}
return binding.SlotPosition
}
func (r *SeriesRunner) appendDeviceObservationAudit(
ctx context.Context,
tx *gorm.DB,
device *model.Device,
state *deviceObservationState,
beforeSlot, afterSlot int,
integrationID string,
beforeData, afterData map[string]any,
) error {
if r.auditWriter == nil {
return apperrors.New(apperrors.CodeInternalError, "设备观测统一审计能力未配置")
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "Worker 同步设备观测事实",
}}
if beforeSlot != afterSlot {
resources = appendDeviceObservationBindingResources(resources, device, state.current, state.cards[stateCardID(state.current)], false,
constants.AuditResourceRoleDeviceOldCurrentCard, constants.AuditResourceRoleDeviceOldCurrentBinding)
resources = appendDeviceObservationBindingResources(resources, device, state.target, state.cards[stateCardID(state.target)], true,
constants.AuditResourceRoleDeviceNewCurrentCard, constants.AuditResourceRoleDeviceNewCurrentBinding)
}
resources = append(resources, deviceObservationIntegrationResource(ctx, device, integrationID))
return r.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionDeviceWorkerObservationSynced,
Summary: "Worker 同步设备观测事实", ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, Resources: resources,
})
}
func appendDeviceObservationBindingResources(
resources []audit.ResourceInput,
device *model.Device,
binding *model.DeviceSimBinding,
card *model.IotCard,
afterCurrent bool,
cardRole, bindingRole string,
) []audit.ResourceInput {
if binding == nil {
return resources
}
if card != nil {
cardID := strconv.FormatUint(uint64(card.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID,
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: cardRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
BeforeData: map[string]any{"is_current": binding.IsCurrent}, AfterData: map[string]any{"is_current": afterCurrent},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备当前卡槽已同步",
})
}
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
identity := map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": device.VirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID, "is_current": afterCurrent,
}
if card != nil {
identity["iccid"] = card.ICCID
identity["virtual_no"] = card.VirtualNo
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: bindingRole,
IdentitySnapshot: identity,
BeforeData: map[string]any{"is_current": binding.IsCurrent}, AfterData: map[string]any{"is_current": afterCurrent},
SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func stateCardID(binding *model.DeviceSimBinding) uint {
if binding == nil {
return 0
}
return binding.IotCardID
}
func deviceObservationIntegrationResource(ctx context.Context, device *model.Device, integrationID string) audit.ResourceInput {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
return audit.ResourceInput{
Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleWorkerIntegration,
IdentitySnapshot: map[string]any{
"integration_id": integrationID, "provider": constants.IntegrationProviderGateway,
"direction": constants.IntegrationDirectionOutbound, "operation": constants.IntegrationOperationGatewayDeviceInfo,
"resource_type": constants.CardObservationResourceTypeDevice, "resource_id": deviceID,
"resource_key": "device:" + deviceID, "correlation_id": auditcontext.From(ctx).CorrelationID,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
func (r *SeriesRunner) recordDeviceObservationFailure(ctx context.Context, device *model.Device, integrationID string, businessErr error) {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
r.auditWriter.RecordFailure(ctx, r.db, audit.AppendInput{
ActionCode: constants.AuditActionDeviceWorkerObservationSynced,
Summary: "Worker 同步设备观测事实失败", ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
BeforeData: map[string]any{
"online_status": device.OnlineStatus, "software_version": device.SoftwareVersion,
"switch_mode": device.SwitchMode,
},
SubjectVisibility: constants.AuditSubjectResult,
}, deviceObservationIntegrationResource(ctx, device, integrationID)},
}, businessErr)
}
func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) { func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) {
if payload.ResourceType != constants.CardObservationResourceTypeDevice { if payload.ResourceType != constants.CardObservationResourceTypeDevice {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效") return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效")

View File

@@ -62,18 +62,75 @@ type AuditResourceTimelineRequest struct {
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量默认20最大100"` PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
} }
// AuditRequestTimelineParams 是请求链路时间线的路径参数。
type AuditRequestTimelineParams struct {
RequestID string `json:"request_id" path:"request_id" required:"true" description:"HTTP请求关联ID来自审计或外部集成节点也可从Access Log粘贴"`
}
// AuditCorrelationTimelineParams 是业务关联时间线的路径参数。
type AuditCorrelationTimelineParams struct {
CorrelationID string `json:"correlation_id" path:"correlation_id" required:"true" description:"跨请求、异步任务和外部交互的稳定业务链路ID"`
}
// AuditFinanceTimelineRequest 是资金调查时间线的稳定业务筛选参数。
type AuditFinanceTimelineRequest struct {
ShopID uint `json:"shop_id" query:"shop_id" description:"店铺ID"`
WalletID uint `json:"wallet_id" query:"wallet_id" description:"代理或资产钱包ID"`
OrderID uint `json:"order_id" query:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" query:"order_no" description:"订单编号"`
PaymentID uint `json:"payment_id" query:"payment_id" description:"支付记录ID"`
PaymentNo string `json:"payment_no" query:"payment_no" description:"支付单号"`
RefundID uint `json:"refund_id" query:"refund_id" description:"退款单ID"`
RefundNo string `json:"refund_no" query:"refund_no" description:"退款单号"`
RechargeID uint `json:"recharge_id" query:"recharge_id" description:"代理充值或个人资产充值ID"`
RechargeNo string `json:"recharge_no" query:"recharge_no" description:"充值单号"`
ApprovalInstanceID uint `json:"approval_instance_id" query:"approval_instance_id" description:"审批实例ID"`
ThirdPartyTradeNo string `json:"third_party_trade_no" query:"third_party_trade_no" description:"第三方交易号"`
ActorKind string `json:"actor_kind" query:"actor_kind" description:"操作者类型与actor_id同时提供"`
ActorID string `json:"actor_id" query:"actor_id" description:"操作者稳定ID与actor_kind同时提供"`
CorrelationID string `json:"correlation_id" query:"correlation_id" description:"跨步骤业务链路ID"`
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间RFC3339含时区"`
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间RFC3339含时区不包含该时刻"`
Page int `json:"page" query:"page" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
}
// AuditRiskFilterRequest 是风险总览和明细共用的受控筛选参数。
type AuditRiskFilterRequest struct {
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间RFC3339含时区默认从在线窗口开始"`
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间RFC3339含时区不包含该时刻最长31天默认当前时间"`
Risk string `json:"risk" query:"risk" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
Result string `json:"result" query:"result" description:"结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
Action string `json:"action" query:"action" description:"稳定动作编码"`
Source string `json:"source" query:"source" description:"来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)"`
}
// AuditRiskOverviewRequest 是风险总览请求参数。
type AuditRiskOverviewRequest struct {
AuditRiskFilterRequest
}
// AuditRiskEventsRequest 是风险事件明细请求参数。
type AuditRiskEventsRequest struct {
AuditRiskFilterRequest
Page int `json:"page" query:"page" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
}
// SubjectResourceActivityRequest 是代理和企业安全资源活动的路径及分页参数。 // SubjectResourceActivityRequest 是代理和企业安全资源活动的路径及分页参数。
type SubjectResourceActivityRequest struct { type SubjectResourceActivityRequest struct {
ResourceType string `json:"resource_type" path:"resource_type" required:"true" description:"资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业)"` ResourceType string `json:"resource_type" path:"resource_type" required:"true" description:"资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业)"`
Identifier string `json:"identifier" path:"identifier" required:"true" description:"业务稳定标识卡使用ICCID设备使用VirtualNo其他资源使用对应业务编号"` Identifier string `json:"identifier" path:"identifier" required:"true" description:"业务稳定标识卡使用ICCID设备使用VirtualNo其他资源使用对应业务编号"`
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间RFC3339含时区默认从在线窗口开始"`
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间RFC3339含时区不包含该时刻默认当前时间"`
Page int `json:"page" query:"page" minimum:"1" description:"页码默认1"` Page int `json:"page" query:"page" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量默认20最大100"` PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
} }
// IntegrationFilterRequest 是外部集成调查的公共受控筛选参数。 // IntegrationFilterRequest 是外部集成调查的公共受控筛选参数。
type IntegrationFilterRequest struct { type IntegrationFilterRequest struct {
CreatedFrom string `json:"created_from" query:"created_from" required:"true" description:"开始时间RFC3339含时区,必填"` CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间RFC3339含时区;默认从在线窗口开始"`
CreatedTo string `json:"created_to" query:"created_to" required:"true" description:"结束时间RFC3339含时区不包含该时刻,必填"` CreatedTo string `json:"created_to" query:"created_to" description:"结束时间RFC3339含时区不包含该时刻;默认当前时间"`
IntegrationID string `json:"integration_id" query:"integration_id" description:"稳定外部集成记录ID"` IntegrationID string `json:"integration_id" query:"integration_id" description:"稳定外部集成记录ID"`
Provider string `json:"provider" query:"provider" description:"外部服务提供方稳定编码"` Provider string `json:"provider" query:"provider" description:"外部服务提供方稳定编码"`
Direction string `json:"direction" query:"direction" description:"交互方向 (inbound:入站, outbound:出站)"` Direction string `json:"direction" query:"direction" description:"交互方向 (inbound:入站, outbound:出站)"`

View File

@@ -0,0 +1,38 @@
package model
import "time"
// LogArchiveRun 是日志冷归档运行账本,不保存日志正文。
type LogArchiveRun struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Source string `gorm:"column:source;type:varchar(32);not null" json:"source"`
ArchiveDate time.Time `gorm:"column:archive_date;type:date;not null" json:"archive_date"`
InstanceID string `gorm:"column:instance_id;type:varchar(100);not null" json:"instance_id"`
SchemaVersion string `gorm:"column:schema_version;type:varchar(32);not null" json:"schema_version"`
Revision int `gorm:"column:revision;not null;default:1" json:"revision"`
Status string `gorm:"column:status;type:varchar(16);not null" json:"status"`
IsFinal bool `gorm:"column:is_final;not null;default:false" json:"is_final"`
RangeStart time.Time `gorm:"column:range_start;type:timestamptz;not null" json:"range_start"`
RangeEnd time.Time `gorm:"column:range_end;type:timestamptz;not null" json:"range_end"`
ObjectKey string `gorm:"column:object_key;type:varchar(500);not null;default:''" json:"object_key"`
ManifestKey string `gorm:"column:manifest_key;type:varchar(500);not null;default:''" json:"manifest_key"`
EventCount int64 `gorm:"column:event_count;not null;default:0" json:"event_count"`
ResourceCount int64 `gorm:"column:resource_count;not null;default:0" json:"resource_count"`
RecordCount int64 `gorm:"column:record_count;not null;default:0" json:"record_count"`
UncompressedBytes int64 `gorm:"column:uncompressed_bytes;not null;default:0" json:"uncompressed_bytes"`
CompressedBytes int64 `gorm:"column:compressed_bytes;not null;default:0" json:"compressed_bytes"`
SHA256 string `gorm:"column:sha256;type:varchar(64);not null;default:''" json:"sha256"`
AttemptCount int `gorm:"column:attempt_count;not null;default:0" json:"attempt_count"`
ErrorSummary string `gorm:"column:error_summary;type:varchar(500);not null;default:''" json:"error_summary"`
GeneratedAt *time.Time `gorm:"column:generated_at" json:"generated_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at,omitempty"`
CleanupStartedAt *time.Time `gorm:"column:cleanup_started_at" json:"cleanup_started_at,omitempty"`
CleanedAt *time.Time `gorm:"column:cleaned_at" json:"cleaned_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
}
// TableName 返回日志冷归档运行账本表名。
func (LogArchiveRun) TableName() string {
return "tb_log_archive_run"
}

View File

@@ -41,6 +41,13 @@ type PackageActivationHandler struct {
logger *zap.Logger logger *zap.Logger
} }
func workerCorrelation(correlationID string, task *asynq.Task) string {
if correlationID != "" {
return correlationID
}
return task.ResultWriter().TaskID()
}
// PackageActivationPayload 套餐激活任务载荷 // PackageActivationPayload 套餐激活任务载荷
type PackageActivationPayload struct { type PackageActivationPayload struct {
PackageUsageID uint `json:"package_usage_id"` PackageUsageID uint `json:"package_usage_id"`
@@ -48,6 +55,9 @@ type PackageActivationPayload struct {
CarrierID uint `json:"carrier_id"` CarrierID uint `json:"carrier_id"`
ActivationType string `json:"activation_type"` // "queue" 或 "realname" ActivationType string `json:"activation_type"` // "queue" 或 "realname"
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
} }
// NewPackageActivationHandler 创建套餐激活检查处理器 // NewPackageActivationHandler 创建套餐激活检查处理器
@@ -397,10 +407,11 @@ func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, c
if h.stopResumeCallback == nil { if h.stopResumeCallback == nil {
return return
} }
detachedCtx := context.WithoutCancel(ctx)
if carrierType == "iot_card" { if carrierType == "iot_card" {
go func() { go func() {
if err := h.stopResumeCallback.CheckAndStopCard(context.Background(), carrierID); err != nil { if err := h.stopResumeCallback.CheckAndStopCard(detachedCtx, carrierID); err != nil {
h.logger.Error("套餐过期后停机失败", h.logger.Error("套餐过期后停机失败",
zap.Uint("card_id", carrierID), zap.Uint("card_id", carrierID),
zap.Error(err)) zap.Error(err))
@@ -420,7 +431,7 @@ func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, c
for _, b := range bindings { for _, b := range bindings {
cardID := b.IotCardID cardID := b.IotCardID
go func(cID uint) { go func(cID uint) {
if err := h.stopResumeCallback.CheckAndStopCard(context.Background(), cID); err != nil { if err := h.stopResumeCallback.CheckAndStopCard(detachedCtx, cID); err != nil {
h.logger.Error("套餐过期后停机失败", h.logger.Error("套餐过期后停机失败",
zap.Uint("card_id", cID), zap.Uint("card_id", cID),
zap.Error(err)) zap.Error(err))
@@ -432,12 +443,16 @@ func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, c
// enqueueActivationTask 提交套餐激活任务到 Asynq // enqueueActivationTask 提交套餐激活任务到 Asynq
func (h *PackageActivationHandler) enqueueActivationTask(ctx context.Context, packageUsageID uint, carrierType string, carrierID uint, activationType string) error { func (h *PackageActivationHandler) enqueueActivationTask(ctx context.Context, packageUsageID uint, carrierType string, carrierID uint, activationType string) error {
linkage := auditcontext.From(ctx)
payload := PackageActivationPayload{ payload := PackageActivationPayload{
PackageUsageID: packageUsageID, PackageUsageID: packageUsageID,
CarrierType: carrierType, CarrierType: carrierType,
CarrierID: carrierID, CarrierID: carrierID,
ActivationType: activationType, ActivationType: activationType,
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),
RequestID: linkage.RequestID,
CorrelationID: linkage.CorrelationID,
ParentEventID: linkage.ParentEventID,
} }
payloadBytes, err := sonic.Marshal(payload) payloadBytes, err := sonic.Marshal(payload)
@@ -477,6 +492,7 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
ctx = auditcontext.With(ctx, auditcontext.Context{ ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageQueueActivation, ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageQueueActivation,
ActorName: "套餐排队激活任务", Source: constants.AuditSourceWorker, ActorName: "套餐排队激活任务", Source: constants.AuditSourceWorker,
RequestID: payload.RequestID, CorrelationID: workerCorrelation(payload.CorrelationID, t), ParentEventID: payload.ParentEventID,
}) })
h.logger.Info("开始执行套餐激活", h.logger.Info("开始执行套餐激活",
@@ -534,6 +550,7 @@ func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Cont
ctx = auditcontext.With(ctx, auditcontext.Context{ ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageFirstActivation, ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageFirstActivation,
ActorName: "套餐首次实名激活任务", Source: constants.AuditSourceWorker, ActorName: "套餐首次实名激活任务", Source: constants.AuditSourceWorker,
RequestID: payload.RequestID, CorrelationID: workerCorrelation(payload.CorrelationID, t), ParentEventID: payload.ParentEventID,
}) })
if payload.CarrierType == "" || payload.CarrierID == 0 { if payload.CarrierType == "" || payload.CarrierID == 0 {

View File

@@ -10,6 +10,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -39,10 +40,17 @@ type EventFilter struct {
// EventPage 是平台全局事件稳定分页结果。 // EventPage 是平台全局事件稳定分页结果。
type EventPage struct { type EventPage struct {
Total int64 `json:"total"` Total int64 `json:"total"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
Items []EventView `json:"items"` Items []EventView `json:"items"`
Retention retentionquery.Info `json:"retention"`
}
// EventDetail 是单个审计事件及在线留存边界。
type EventDetail struct {
EventView
Retention retentionquery.Info `json:"retention"`
} }
// EventView 是不暴露 GORM Model 的审计事件投影。 // EventView 是不暴露 GORM Model 的审计事件投影。
@@ -147,6 +155,14 @@ func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, error
if err := q.authorize(ctx); err != nil { if err := q.authorize(ctx); err != nil {
return nil, err return nil, err
} }
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo)
if err != nil {
return nil, err
}
if !validEventFilter(filter) { if !validEventFilter(filter) {
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
@@ -156,16 +172,33 @@ func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, error
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计审计事件失败") return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计审计事件失败")
} }
rows := make([]model.AuditEvent, 0, filter.PageSize) rows, err := q.loadEventPage(ctx, query, filter.Page, filter.PageSize)
if err := query.Order("occurred_at DESC, id DESC"). if err != nil {
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil { return nil, err
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件失败")
} }
items, err := q.project(ctx, rows) items, err := q.project(ctx, rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil return &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
}
// loadEventPage 先分页主键,再批量读取事件宽行,避免排序阶段加载 JSON 字段。
func (q *Query) loadEventPage(ctx context.Context, query *gorm.DB, page, pageSize int) ([]model.AuditEvent, error) {
ids := make([]uint, 0, pageSize)
if err := query.Select("id").Order("occurred_at DESC, id DESC").
Offset((page-1)*pageSize).Limit(pageSize).Pluck("id", &ids).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件分页ID失败")
}
rows := make([]model.AuditEvent, 0, len(ids))
if len(ids) == 0 {
return rows, nil
}
if err := q.db.WithContext(ctx).Where("id IN ?", ids).
Order("occurred_at DESC, id DESC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量投影审计事件失败")
}
return rows, nil
} }
func validEventFilter(filter EventFilter) bool { func validEventFilter(filter EventFilter) bool {
@@ -192,15 +225,19 @@ func validOptionalValue(value string, allowed ...string) bool {
} }
// Get 查询平台范围的单个稳定审计事件详情。 // Get 查询平台范围的单个稳定审计事件详情。
func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) { func (q *Query) Get(ctx context.Context, eventID string) (*EventDetail, error) {
if err := q.authorize(ctx); err != nil { if err := q.authorize(ctx); err != nil {
return nil, err return nil, err
} }
if eventID == "" { if eventID == "" {
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
var row model.AuditEvent var row model.AuditEvent
if err := q.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error; err != nil { if err := q.db.WithContext(ctx).Where("event_id = ? AND occurred_at >= ?", eventID, retention.OnlineFrom.UTC()).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "审计事件不存在") return nil, errors.New(errors.CodeNotFound, "审计事件不存在")
} }
@@ -210,7 +247,7 @@ func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &items[0], nil return &EventDetail{EventView: items[0], Retention: retention}, nil
} }
func (q *Query) authorize(ctx context.Context) error { func (q *Query) authorize(ctx context.Context) error {

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
@@ -17,16 +18,18 @@ import (
type ResourceSearchFilter struct { type ResourceSearchFilter struct {
ResourceType string ResourceType string
Keyword string Keyword string
OnlineFrom time.Time
Page int Page int
PageSize int PageSize int
} }
// ResourceSearchPage 是资源候选稳定分页结果。 // ResourceSearchPage 是资源候选稳定分页结果。
type ResourceSearchPage struct { type ResourceSearchPage struct {
Total int64 `json:"total"` Total int64 `json:"total"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
Items []ResourceCandidate `json:"items"` Items []ResourceCandidate `json:"items"`
Retention retentionquery.Info `json:"retention"`
} }
// ResourceCandidate 是当前业务表或历史事件快照解析出的稳定资源候选。 // ResourceCandidate 是当前业务表或历史事件快照解析出的稳定资源候选。
@@ -59,6 +62,11 @@ func (q *Query) SearchResources(ctx context.Context, filter ResourceSearchFilter
if filter.Keyword == "" || !searchableResourceType(filter.ResourceType) { if filter.Keyword == "" || !searchableResourceType(filter.ResourceType) {
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.OnlineFrom = retention.OnlineFrom
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize) filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
items, total, err := q.searchCurrent(ctx, filter) items, total, err := q.searchCurrent(ctx, filter)
if err != nil { if err != nil {
@@ -70,7 +78,7 @@ func (q *Query) SearchResources(ctx context.Context, filter ResourceSearchFilter
return nil, err return nil, err
} }
} }
return &ResourceSearchPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil return &ResourceSearchPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
} }
// ResourceTimeline 查询注册资源作为任意关系参与的统一事件时间线。 // ResourceTimeline 查询注册资源作为任意关系参与的统一事件时间线。
@@ -196,7 +204,7 @@ func (q *Query) searchHistorical(ctx context.Context, filter ResourceSearchFilte
func (q *Query) historicalIdentifierQuery(ctx context.Context, filter ResourceSearchFilter) *gorm.DB { func (q *Query) historicalIdentifierQuery(ctx context.Context, filter ResourceSearchFilter) *gorm.DB {
query := q.db.WithContext(ctx).Model(&model.AuditEventResource{}). query := q.db.WithContext(ctx).Model(&model.AuditEventResource{}).
Where("resource_type = ? AND resource_id IS NOT NULL", filter.ResourceType) Where("resource_type = ? AND resource_id IS NOT NULL AND created_at >= ?", filter.ResourceType, filter.OnlineFrom.UTC())
switch filter.ResourceType { switch filter.ResourceType {
case constants.AuditResourceIotCard: case constants.AuditResourceIotCard:
return query.Where("resource_key = ? OR identity_snapshot ->> 'iccid' = ? OR identity_snapshot ->> 'iccid_19' = ? OR identity_snapshot ->> 'iccid_20' = ? OR identity_snapshot ->> 'virtual_no' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword) return query.Where("resource_key = ? OR identity_snapshot ->> 'iccid' = ? OR identity_snapshot ->> 'iccid_19' = ? OR identity_snapshot ->> 'iccid_20' = ? OR identity_snapshot ->> 'virtual_no' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)

View File

@@ -0,0 +1,299 @@
package audit
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RiskFilter 定义固定风险调查视角的时间范围与筛选条件。
type RiskFilter struct {
CreatedFrom *time.Time
CreatedTo *time.Time
Risk string
Result string
Action string
Source string
Page int
PageSize int
}
// RiskOverview 是风险信号、固定维度与时间趋势的只读聚合。
type RiskOverview struct {
Total int64 `json:"total"`
Bucket string `json:"bucket"`
Signals []RiskNamedCount `json:"signals"`
Risks []RiskNamedCount `json:"risks"`
Results []RiskNamedCount `json:"results"`
Actions []RiskNamedCount `json:"actions"`
Sources []RiskNamedCount `json:"sources"`
Trend []RiskTrendPoint `json:"trend"`
Retention retentionquery.Info `json:"retention"`
}
// RiskNamedCount 是风险聚合维度的稳定编码、中文名称和数量。
type RiskNamedCount struct {
Code string `json:"code"`
Name string `json:"name"`
Count int64 `json:"count"`
}
// RiskTrendPoint 是固定时间桶内的风险信号趋势。
type RiskTrendPoint struct {
BucketAt time.Time `json:"bucket_at"`
Total int64 `json:"total"`
HighRisk int64 `json:"high_risk"`
Finance int64 `json:"finance"`
Security int64 `json:"security"`
Failed int64 `json:"failed"`
Denied int64 `json:"denied"`
Partial int64 `json:"partial"`
Unknown int64 `json:"unknown"`
}
// RiskEventPage 是风险事件的稳定分页结果。
type RiskEventPage struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []EventView `json:"items"`
Retention retentionquery.Info `json:"retention"`
}
// RiskOverview 查询指定时间范围内的固定风险调查总览。
func (q *Query) RiskOverview(ctx context.Context, filter RiskFilter) (*RiskOverview, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo, constants.AuditRiskQueryMaxRange)
if err != nil {
return nil, err
}
if !validRiskFilter(filter) {
return nil, errors.New(errors.CodeInvalidParam)
}
base := q.applyRiskFilters(q.db.WithContext(ctx).Model(&model.AuditEvent{}), filter)
result := &RiskOverview{
Bucket: riskTrendBucket(*filter.CreatedFrom, *filter.CreatedTo),
Signals: []RiskNamedCount{}, Risks: []RiskNamedCount{}, Results: []RiskNamedCount{},
Actions: []RiskNamedCount{}, Sources: []RiskNamedCount{}, Trend: []RiskTrendPoint{},
Retention: retention,
}
if err := loadRiskSignals(base, result); err != nil {
return nil, err
}
if err := loadRiskDimension(base, "risk_level", result); err != nil {
return nil, err
}
if err := loadRiskDimension(base, "result", result); err != nil {
return nil, err
}
if err := loadRiskDimension(base, "action_code", result); err != nil {
return nil, err
}
if err := loadRiskDimension(base, "source", result); err != nil {
return nil, err
}
if err := loadRiskTrend(base, result); err != nil {
return nil, err
}
return result, nil
}
// RiskEvents 查询指定时间范围内的风险事件明细。
func (q *Query) RiskEvents(ctx context.Context, filter RiskFilter) (*RiskEventPage, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo, constants.AuditRiskQueryMaxRange)
if err != nil {
return nil, err
}
if !validRiskFilter(filter) {
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
query := q.applyRiskFilters(q.db.WithContext(ctx).Model(&model.AuditEvent{}), filter)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计风险事件失败")
}
rows, err := q.loadEventPage(ctx, query, filter.Page, filter.PageSize)
if err != nil {
return nil, err
}
items, err := q.project(ctx, rows)
if err != nil {
return nil, err
}
return &RiskEventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
}
func validRiskFilter(filter RiskFilter) bool {
if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) ||
filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.AuditRiskQueryMaxRange {
return false
}
return validEventFilter(EventFilter{
Risk: filter.Risk, Result: filter.Result, Action: filter.Action, Source: filter.Source,
Page: filter.Page, PageSize: filter.PageSize,
})
}
func (q *Query) applyRiskFilters(query *gorm.DB, filter RiskFilter) *gorm.DB {
query = q.applyFilters(query, EventFilter{
CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo,
Risk: filter.Risk, Result: filter.Result, Action: filter.Action, Source: filter.Source,
})
return query.Where(riskScopeSQL(), riskLevels(), constants.AuditCategorySecurity, abnormalResults(), financeResourceTypes())
}
func loadRiskSignals(query *gorm.DB, result *RiskOverview) error {
var row struct {
Total, HighRisk, Finance, Security, Failed, Denied, Partial, Unknown int64
}
err := query.Select(`COUNT(*) AS total,
COUNT(*) FILTER (WHERE risk_level IN ?) AS high_risk,
COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM tb_audit_event_resource aer WHERE aer.audit_event_id = tb_audit_event.id AND aer.resource_type IN ?)) AS finance,
COUNT(*) FILTER (WHERE category = ?) AS security,
COUNT(*) FILTER (WHERE result = ?) AS failed,
COUNT(*) FILTER (WHERE result = ?) AS denied,
COUNT(*) FILTER (WHERE result = ?) AS partial,
COUNT(*) FILTER (WHERE result = ?) AS unknown`, riskLevels(), financeResourceTypes(), constants.AuditCategorySecurity,
constants.AuditResultFailed, constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown).Scan(&row).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "聚合风险信号失败")
}
result.Total = row.Total
result.Signals = []RiskNamedCount{
{Code: constants.AuditRiskSignalHighRisk, Name: "高风险", Count: row.HighRisk},
{Code: constants.AuditRiskSignalFinance, Name: "资金", Count: row.Finance},
{Code: constants.AuditRiskSignalSecurity, Name: "安全", Count: row.Security},
{Code: constants.AuditRiskSignalFailed, Name: "失败", Count: row.Failed},
{Code: constants.AuditRiskSignalDenied, Name: "拒绝", Count: row.Denied},
{Code: constants.AuditRiskSignalPartial, Name: "部分成功", Count: row.Partial},
{Code: constants.AuditRiskSignalUnknown, Name: "结果未知", Count: row.Unknown},
}
return nil
}
func loadRiskDimension(query *gorm.DB, column string, result *RiskOverview) error {
var rows []struct {
Code string
Name string
Count int64
}
selectClause := column + " AS code, '' AS name, COUNT(*) AS count"
groupClause := column
if column == "action_code" {
selectClause = "action_code AS code, action_name AS name, COUNT(*) AS count"
groupClause = "action_code, action_name"
}
if err := query.Select(selectClause).Group(groupClause).Order(column + " ASC").Scan(&rows).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "聚合风险维度失败")
}
items := make([]RiskNamedCount, 0, len(rows))
for _, row := range rows {
name := row.Name
if name == "" {
name = riskDimensionName(column, row.Code)
}
items = append(items, RiskNamedCount{Code: row.Code, Name: name, Count: row.Count})
}
switch column {
case "risk_level":
result.Risks = items
case "result":
result.Results = items
case "action_code":
result.Actions = items
case "source":
result.Sources = items
}
return nil
}
func loadRiskTrend(query *gorm.DB, result *RiskOverview) error {
err := query.Select(`date_trunc(?, occurred_at) AS bucket_at, COUNT(*) AS total,
COUNT(*) FILTER (WHERE risk_level IN ?) AS high_risk,
COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM tb_audit_event_resource aer WHERE aer.audit_event_id = tb_audit_event.id AND aer.resource_type IN ?)) AS finance,
COUNT(*) FILTER (WHERE category = ?) AS security,
COUNT(*) FILTER (WHERE result = ?) AS failed,
COUNT(*) FILTER (WHERE result = ?) AS denied,
COUNT(*) FILTER (WHERE result = ?) AS partial,
COUNT(*) FILTER (WHERE result = ?) AS unknown`, result.Bucket, riskLevels(), financeResourceTypes(),
constants.AuditCategorySecurity, constants.AuditResultFailed, constants.AuditResultDenied,
constants.AuditResultPartial, constants.AuditResultUnknown).
Group("bucket_at").Order("bucket_at ASC").Scan(&result.Trend).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "聚合风险趋势失败")
}
return nil
}
func riskScopeSQL() string {
return `(risk_level IN ? OR category = ? OR result IN ? OR EXISTS (
SELECT 1 FROM tb_audit_event_resource aer
WHERE aer.audit_event_id = tb_audit_event.id AND aer.resource_type IN ?
))`
}
func riskLevels() []string {
return []string{constants.AuditRiskHigh, constants.AuditRiskCritical}
}
func abnormalResults() []string {
return []string{constants.AuditResultFailed, constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown}
}
func financeResourceTypes() []string {
return []string{
constants.AuditResourceOrder, constants.AuditResourceRefund, constants.AuditResourceAgentRecharge,
constants.AuditResourceRechargeOrder, constants.AuditResourceAssetWallet, constants.AuditResourceAssetWalletTransaction,
constants.AuditResourceAgentWallet, constants.AuditResourceAgentWalletTransaction,
constants.AuditResourceAgentWalletReservation, constants.AuditResourcePayment,
constants.AuditResourceCommissionRecord, constants.AuditResourceCommissionWithdrawal,
}
}
func riskTrendBucket(from, to time.Time) string {
if to.Sub(from) <= constants.AuditRiskHourlyTrendMaxRange {
return "hour"
}
return "day"
}
func riskDimensionName(column, code string) string {
names := map[string]map[string]string{
"risk_level": {
constants.AuditRiskLow: "低", constants.AuditRiskNormal: "普通",
constants.AuditRiskHigh: "高", constants.AuditRiskCritical: "严重",
},
"result": {
constants.AuditResultSuccess: "成功", constants.AuditResultFailed: "失败",
constants.AuditResultDenied: "拒绝", constants.AuditResultPartial: "部分成功",
constants.AuditResultUnknown: "结果未知",
},
"source": {
constants.AuditSourceAdminAPI: "后台管理 API", constants.AuditSourcePersonalAPI: "个人客户 API",
constants.AuditSourceOpenAPI: "代理 OpenAPI", constants.AuditSourceWorker: "异步 Worker",
constants.AuditSourceScheduler: "计划任务", constants.AuditSourceCallback: "外部系统回调",
},
}
return names[column][code]
}

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -18,17 +19,20 @@ import (
type SubjectActivityFilter struct { type SubjectActivityFilter struct {
ResourceType string ResourceType string
Identifier string Identifier string
CreatedFrom *time.Time
CreatedTo *time.Time
Page int Page int
PageSize int PageSize int
} }
// SubjectActivityPage 是不包含平台调查字段的代理资源活动分页结果。 // SubjectActivityPage 是不包含平台调查字段的代理资源活动分页结果。
type SubjectActivityPage struct { type SubjectActivityPage struct {
Resource SubjectResourceSummary `json:"resource"` Resource SubjectResourceSummary `json:"resource"`
Total int64 `json:"total"` Total int64 `json:"total"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
Items []SubjectActivity `json:"items"` Items []SubjectActivity `json:"items"`
Retention retentionquery.Info `json:"retention"`
} }
// SubjectActivity 是写入时已生成的主体安全活动投影。 // SubjectActivity 是写入时已生成的主体安全活动投影。
@@ -78,11 +82,19 @@ func (q *Query) AgentResourceActivities(ctx context.Context, filter SubjectActiv
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize) filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo)
if err != nil {
return nil, err
}
target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs) target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) { return q.subjectActivitiesForTarget(ctx, filter, target, retention, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.agentAllowedResourceIDs(ctx, resources, shopIDs) return q.agentAllowedResourceIDs(ctx, resources, shopIDs)
}) })
} }
@@ -100,21 +112,30 @@ func (q *Query) EnterpriseResourceActivities(ctx context.Context, filter Subject
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize) filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo)
if err != nil {
return nil, err
}
target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID) target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) { return q.subjectActivitiesForTarget(ctx, filter, target, retention, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.enterpriseAllowedResourceIDs(ctx, resources, enterpriseID) return q.enterpriseAllowedResourceIDs(ctx, resources, enterpriseID)
}) })
} }
func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectActivityFilter, target subjectTarget, authorize subjectResourceAuthorizer) (*SubjectActivityPage, error) { func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectActivityFilter, target subjectTarget, retention retentionquery.Info, authorize subjectResourceAuthorizer) (*SubjectActivityPage, error) {
resourceMatch := q.db.Table("tb_audit_event_resource AS target").Select("1"). resourceMatch := q.db.Table("tb_audit_event_resource AS target").Select("1").
Where("target.audit_event_id = tb_audit_event.id AND target.resource_type = ? AND target.resource_id = ?", filter.ResourceType, target.id). Where("target.audit_event_id = tb_audit_event.id AND target.resource_type = ? AND target.resource_id = ?", filter.ResourceType, target.id).
Where("target.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail}) Where("target.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail})
base := q.db.WithContext(ctx).Model(&model.AuditEvent{}).Where("EXISTS (?)", resourceMatch) base := q.db.WithContext(ctx).Model(&model.AuditEvent{}).
Where("occurred_at >= ? AND occurred_at < ?", filter.CreatedFrom.UTC(), filter.CreatedTo.UTC()).Where("EXISTS (?)", resourceMatch)
var total int64 var total int64
if err := base.Count(&total).Error; err != nil { if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理资源活动失败") return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理资源活动失败")
@@ -131,7 +152,7 @@ func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectAc
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
} }
func (q *Query) resolveEnterpriseTarget(ctx context.Context, resourceType, identifier string, enterpriseID uint) (subjectTarget, error) { func (q *Query) resolveEnterpriseTarget(ctx context.Context, resourceType, identifier string, enterpriseID uint) (subjectTarget, error) {

View File

@@ -0,0 +1,317 @@
package audit
import (
"context"
"fmt"
"sort"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// LinkTimeline 是 request 或 correlation 的跨事实只读时间线。
type LinkTimeline struct {
RequestID *string `json:"request_id"`
CorrelationID *string `json:"correlation_id"`
AccessLogLookupRequestID *string `json:"access_log_lookup_request_id"`
Nodes []LinkTimelineNode `json:"nodes"`
Retention retentionquery.Info `json:"retention"`
}
// LinkTimelineNode 是保留各事实源权威边界的时间线节点。
type LinkTimelineNode struct {
RecordSource string `json:"record_source"`
NodeID string `json:"node_id"`
OccurredAt time.Time `json:"occurred_at"`
Code string `json:"code"`
Title string `json:"title"`
Result string `json:"result"`
ResultName string `json:"result_name"`
Summary string `json:"summary"`
ReferenceOnly bool `json:"reference_only"`
RequestID *string `json:"request_id"`
CorrelationID *string `json:"correlation_id"`
ParentEventID *string `json:"parent_event_id"`
Resources []InvestigationResourceRef `json:"resources"`
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
Fidelity LinkageFidelity `json:"fidelity"`
}
// LinkageFidelity 明确节点已有的稳定关联能力,不补猜历史缺失字段。
type LinkageFidelity struct {
RequestAvailable bool `json:"request_available"`
CorrelationAvailable bool `json:"correlation_available"`
ParentEventAvailable bool `json:"parent_event_available"`
DirectAuditLinkAvailable bool `json:"direct_audit_link_available"`
StableResourceAvailable bool `json:"stable_resource_available"`
}
// RequestTimeline 按精确 request ID 组合已持久化事实,不扫描 Access Log。
func (q *Query) RequestTimeline(ctx context.Context, requestID string) (*LinkTimeline, error) {
return q.linkTimeline(ctx, "request_id", requestID)
}
// CorrelationTimeline 按精确 correlation ID 组合跨请求业务链路。
func (q *Query) CorrelationTimeline(ctx context.Context, correlationID string) (*LinkTimeline, error) {
return q.linkTimeline(ctx, "correlation_id", correlationID)
}
func (q *Query) linkTimeline(ctx context.Context, column, value string) (*LinkTimeline, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if value == "" || (column != "request_id" && column != "correlation_id") {
return nil, errors.New(errors.CodeInvalidParam)
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit, retentionquery.SourceIntegration)
if err != nil {
return nil, err
}
auditRows, integrationRows, outboxRows, err := q.loadLinkRows(ctx, column, value, retention.OnlineFrom)
if err != nil {
return nil, err
}
events, err := q.project(ctx, auditRows)
if err != nil {
return nil, err
}
nodes := make([]LinkTimelineNode, 0, len(events)+len(integrationRows)+len(outboxRows))
integrationByAudit := integrationRefsByAuditID(integrationRows)
for index, event := range events {
refs := event.InvestigationRefs
refs.IntegrationRefs = append(refs.IntegrationRefs, integrationByAudit[auditRows[index].ID]...)
refs.IntegrationRefs = append(refs.IntegrationRefs, integrationResourceRefs(event.Resources)...)
refs.IntegrationRefs = uniqueIntegrationRefs(refs.IntegrationRefs)
nodes = append(nodes, auditTimelineNode(event, refs))
nodes = append(nodes, resourceReferenceNodes(event, refs)...)
}
for _, row := range integrationRows {
nodes = append(nodes, integrationTimelineNode(row))
}
for _, row := range outboxRows {
nodes = append(nodes, outboxTimelineNode(row))
}
sort.Slice(nodes, func(i, j int) bool {
if nodes[i].OccurredAt.Equal(nodes[j].OccurredAt) {
if nodes[i].RecordSource == nodes[j].RecordSource {
return nodes[i].NodeID < nodes[j].NodeID
}
return nodes[i].RecordSource < nodes[j].RecordSource
}
return nodes[i].OccurredAt.Before(nodes[j].OccurredAt)
})
timeline := &LinkTimeline{Nodes: nodes, Retention: retention}
if timeline.Nodes == nil {
timeline.Nodes = []LinkTimelineNode{}
}
if column == "request_id" {
timeline.RequestID = stringPointer(value)
timeline.AccessLogLookupRequestID = stringPointer(value)
} else {
timeline.CorrelationID = stringPointer(value)
}
return timeline, nil
}
func (q *Query) loadLinkRows(ctx context.Context, column, value string, onlineFrom time.Time) ([]model.AuditEvent, []model.IntegrationLog, []model.OutboxEvent, error) {
auditRows := []model.AuditEvent{}
if err := q.db.WithContext(ctx).Where(column+" = ? AND occurred_at >= ?", value, onlineFrom.UTC()).Find(&auditRows).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询链路审计事件失败")
}
integrationRows := []model.IntegrationLog{}
if err := q.db.WithContext(ctx).Where(column+" = ? AND created_at >= ?", value, onlineFrom.UTC()).Find(&integrationRows).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询链路外部交互失败")
}
outboxRows := []model.OutboxEvent{}
if err := q.db.WithContext(ctx).Where(column+" = ? AND created_at >= ?", value, onlineFrom.UTC()).Find(&outboxRows).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询链路可靠事件失败")
}
return auditRows, integrationRows, outboxRows, nil
}
func auditTimelineNode(event EventView, refs InvestigationRefs) LinkTimelineNode {
return LinkTimelineNode{
RecordSource: constants.AuditRecordSourceAuditEvent, NodeID: event.EventID,
OccurredAt: event.OccurredAt, Code: event.ActionCode, Title: event.ActionName,
Result: event.Result, Summary: event.Summary,
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
ParentEventID: stringPointer(event.ParentEventID), Resources: refs.ResourceRefs, InvestigationRefs: refs,
Fidelity: linkageFidelity(event.RequestID, event.CorrelationID, event.ParentEventID, true, len(refs.ResourceRefs) > 0),
}
}
func integrationTimelineNode(row model.IntegrationLog) LinkTimelineNode {
resource := integrationResourceRef(row)
resources := make([]InvestigationResourceRef, 0, 1)
if resource != nil {
resources = append(resources, *resource)
}
refs := InvestigationRefs{
ResourceRefs: resources, RequestID: row.RequestID, CorrelationID: row.CorrelationID,
IntegrationRefs: []IntegrationRef{{IntegrationID: row.IntegrationID}},
}
return LinkTimelineNode{
RecordSource: constants.AuditRecordSourceIntegrationLog, NodeID: row.IntegrationID,
OccurredAt: row.CreatedAt, Code: row.Operation,
Title: constants.IntegrationProviderName(row.Provider) + " · " + constants.IntegrationOperationName(row.Operation),
Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), Summary: "外部交互事实",
RequestID: row.RequestID, CorrelationID: row.CorrelationID, Resources: resources, InvestigationRefs: refs,
Fidelity: linkageFidelity(pointerValue(row.RequestID), pointerValue(row.CorrelationID), "", row.AuditEventID != nil, resource != nil),
}
}
func outboxTimelineNode(row model.OutboxEvent) LinkTimelineNode {
resourceType := row.ResourceType
if resourceType == "" {
resourceType = row.AggregateType
}
resourceID := row.ResourceID
if resourceID == "" {
resourceID = row.AggregateID
}
resourceKey := row.BusinessKey
if resourceKey == "" {
resourceKey = row.AggregateID
}
resource := InvestigationResourceRef{ResourceType: resourceType, ResourceID: stringPointer(resourceID), ResourceKey: resourceKey, DisplayName: resourceKey}
refs := InvestigationRefs{
ResourceRefs: []InvestigationResourceRef{resource}, RequestID: stringPointer(row.RequestID),
CorrelationID: stringPointer(row.CorrelationID), IntegrationRefs: []IntegrationRef{},
}
return LinkTimelineNode{
RecordSource: constants.AuditRecordSourceOutboxEvent, NodeID: row.EventID,
OccurredAt: row.CreatedAt, Code: row.EventType, Title: "可靠事件:" + row.EventType,
Result: strconv.Itoa(row.Status), ResultName: constants.GetOutboxStatusName(row.Status),
Summary: fmt.Sprintf("%s/%s重试 %d 次", row.AggregateType, row.AggregateID, row.RetryCount),
RequestID: stringPointer(row.RequestID), CorrelationID: stringPointer(row.CorrelationID),
ParentEventID: stringPointer(row.ParentEventID), Resources: refs.ResourceRefs, InvestigationRefs: refs,
Fidelity: linkageFidelity(row.RequestID, row.CorrelationID, row.ParentEventID, row.ParentEventID != "", resourceType != "" && resourceID != ""),
}
}
func resourceReferenceNodes(event EventView, refs InvestigationRefs) []LinkTimelineNode {
nodes := make([]LinkTimelineNode, 0, len(event.Resources))
for _, resource := range event.Resources {
recordSource := ""
titlePrefix := ""
summary := ""
switch {
case isAsynqTaskResource(resource.ResourceType):
recordSource = constants.AuditRecordSourceAsynqTask
titlePrefix = "异步任务:"
summary = "持久化任务资源摘要;不读取或推断 Redis 队列历史"
case isDomainLedgerResource(resource.ResourceType):
recordSource = constants.AuditRecordSourceDomainLedgerRef
titlePrefix = "业务账本引用:"
summary = "状态、金额及业务结论以对应业务表为准"
default:
continue
}
resourceRef := InvestigationResourceRef{ResourceType: resource.ResourceType, ResourceID: resource.ResourceID, ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName}
nodeRefs := refs
nodeRefs.ResourceRefs = []InvestigationResourceRef{resourceRef}
nodes = append(nodes, LinkTimelineNode{
RecordSource: recordSource,
NodeID: fmt.Sprintf("%s:%s:%s:%s:%s", event.EventID, resource.ResourceType, pointerValue(resource.ResourceID), resource.ResourceKey, resource.Role),
OccurredAt: event.OccurredAt, Code: resource.ResourceType, Title: titlePrefix + resource.DisplayName,
Result: event.Result, Summary: summary, ReferenceOnly: true,
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
ParentEventID: stringPointer(event.ParentEventID), Resources: nodeRefs.ResourceRefs, InvestigationRefs: nodeRefs,
Fidelity: linkageFidelity(event.RequestID, event.CorrelationID, event.ParentEventID, true, resource.ResourceID != nil || resource.ResourceKey != ""),
})
}
return nodes
}
func integrationRefsByAuditID(rows []model.IntegrationLog) map[uint][]IntegrationRef {
refs := make(map[uint][]IntegrationRef)
for _, row := range rows {
if row.AuditEventID != nil {
refs[*row.AuditEventID] = append(refs[*row.AuditEventID], IntegrationRef{IntegrationID: row.IntegrationID})
}
}
return refs
}
func integrationResourceRefs(resources []ResourceView) []IntegrationRef {
refs := make([]IntegrationRef, 0)
for _, resource := range resources {
if resource.ResourceType == constants.AuditResourceIntegrationLog && resource.ResourceKey != "" {
refs = append(refs, IntegrationRef{IntegrationID: resource.ResourceKey})
}
}
return refs
}
func uniqueIntegrationRefs(refs []IntegrationRef) []IntegrationRef {
unique := make([]IntegrationRef, 0, len(refs))
seen := make(map[string]bool, len(refs))
for _, ref := range refs {
if ref.IntegrationID == "" || seen[ref.IntegrationID] {
continue
}
seen[ref.IntegrationID] = true
unique = append(unique, ref)
}
return unique
}
func integrationResourceRef(row model.IntegrationLog) *InvestigationResourceRef {
if row.ResourceType == nil || *row.ResourceType == "" {
return nil
}
ref := InvestigationResourceRef{ResourceType: *row.ResourceType, ResourceID: row.ResourceID}
if row.ResourceKey != nil {
ref.ResourceKey = *row.ResourceKey
ref.DisplayName = *row.ResourceKey
}
return &ref
}
func linkageFidelity(requestID, correlationID, parentEventID string, directAuditLink, stableResource bool) LinkageFidelity {
return LinkageFidelity{
RequestAvailable: requestID != "", CorrelationAvailable: correlationID != "",
ParentEventAvailable: parentEventID != "", DirectAuditLinkAvailable: directAuditLink,
StableResourceAvailable: stableResource,
}
}
func pointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func isAsynqTaskResource(resourceType string) bool {
switch resourceType {
case constants.AuditResourceDeviceBatchTask, constants.AuditResourceIotCardImportTask,
constants.AuditResourceDeviceImportTask, constants.AuditResourceAssetPackageBatchOrderTask,
constants.AuditResourceOrderPackageInvalidateTask, constants.AuditResourceExportTask:
return true
default:
return false
}
}
func isDomainLedgerResource(resourceType string) bool {
switch resourceType {
case constants.AuditResourceOrder, constants.AuditResourcePayment, constants.AuditResourceRefund,
constants.AuditResourceAgentRecharge, constants.AuditResourceRechargeOrder,
constants.AuditResourceAssetWallet, constants.AuditResourceAssetWalletTransaction,
constants.AuditResourceAgentWallet, constants.AuditResourceAgentWalletTransaction,
constants.AuditResourceAgentWalletReservation, constants.AuditResourcePackageUsage,
constants.AuditResourceApprovalInstance, constants.AuditResourceCommissionRecord,
constants.AuditResourceCommissionWithdrawal:
return true
default:
return false
}
}

View File

@@ -12,6 +12,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -35,10 +36,11 @@ type ListFilter struct {
// ListPage 是按创建时间和主键稳定倒序的分页结果。 // ListPage 是按创建时间和主键稳定倒序的分页结果。
type ListPage struct { type ListPage struct {
Total int64 `json:"total"` Total int64 `json:"total"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
Items []ListItem `json:"items"` Items []ListItem `json:"items"`
Retention retentionquery.Info `json:"retention"`
} }
// ListItem 是 Integration Log 列表投影。 // ListItem 是 Integration Log 列表投影。
@@ -81,6 +83,12 @@ type Detail struct {
Fidelity FidelityView `json:"fidelity"` Fidelity FidelityView `json:"fidelity"`
} }
// DetailResponse 是外部交互详情及在线留存边界。
type DetailResponse struct {
Detail
Retention retentionquery.Info `json:"retention"`
}
// AttemptView 是显式 trigger_series 下的单次技术尝试。 // AttemptView 是显式 trigger_series 下的单次技术尝试。
type AttemptView struct { type AttemptView struct {
IntegrationID string `json:"integration_id"` IntegrationID string `json:"integration_id"`
@@ -169,6 +177,10 @@ func (q *Query) List(ctx context.Context, filter ListFilter) (*ListPage, error)
if err := q.authorize(ctx); err != nil { if err := q.authorize(ctx); err != nil {
return nil, err return nil, err
} }
filter, retention, err := q.normalizeOnlineFilter(ctx, filter)
if err != nil {
return nil, err
}
if !validFilter(filter) { if !validFilter(filter) {
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
@@ -178,27 +190,52 @@ func (q *Query) List(ctx context.Context, filter ListFilter) (*ListPage, error)
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计外部交互日志失败") return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计外部交互日志失败")
} }
rows := make([]model.IntegrationLog, 0, filter.PageSize) rows, err := q.loadListPage(ctx, query, filter.Page, filter.PageSize)
if err := query.Order("created_at DESC, id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil { if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志失败") return nil, err
} }
items := make([]ListItem, len(rows)) items := make([]ListItem, len(rows))
for i, row := range rows { for i, row := range rows {
items[i] = projectListItem(row) items[i] = projectListItem(row)
} }
return &ListPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil return &ListPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
}
// loadListPage 先分页主键,再批量读取列表字段,避免加载正文摘要 JSON。
func (q *Query) loadListPage(ctx context.Context, query *gorm.DB, page, pageSize int) ([]model.IntegrationLog, error) {
ids := make([]uint, 0, pageSize)
if err := query.Select("id").Order("created_at DESC, id DESC").
Offset((page-1)*pageSize).Limit(pageSize).Pluck("id", &ids).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志分页ID失败")
}
rows := make([]model.IntegrationLog, 0, len(ids))
if len(ids) == 0 {
return rows, nil
}
if err := q.db.WithContext(ctx).Select(
"id", "integration_id", "provider", "direction", "operation",
"resource_type", "resource_id", "resource_key", "result", "duration_ms",
"state_changed", "request_id", "correlation_id", "created_at",
).Where("id IN ?", ids).Order("created_at DESC, id DESC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量投影外部交互日志失败")
}
return rows, nil
} }
// Get 使用稳定 integration_id 查询结构化详情。 // Get 使用稳定 integration_id 查询结构化详情。
func (q *Query) Get(ctx context.Context, integrationID string) (*Detail, error) { func (q *Query) Get(ctx context.Context, integrationID string) (*DetailResponse, error) {
if err := q.authorize(ctx); err != nil { if err := q.authorize(ctx); err != nil {
return nil, err return nil, err
} }
if integrationID == "" { if integrationID == "" {
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceIntegration)
if err != nil {
return nil, err
}
var row model.IntegrationLog var row model.IntegrationLog
if err := q.db.WithContext(ctx).Where("integration_id = ?", integrationID).First(&row).Error; err != nil { if err := q.db.WithContext(ctx).Where("integration_id = ? AND created_at >= ?", integrationID, retention.OnlineFrom.UTC()).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "外部交互日志不存在") return nil, errors.New(errors.CodeNotFound, "外部交互日志不存在")
} }
@@ -216,12 +253,12 @@ func (q *Query) Get(ctx context.Context, integrationID string) (*Detail, error)
if err != nil { if err != nil {
return nil, err return nil, err
} }
attempts, err := q.loadAttempts(ctx, row) attempts, err := q.loadAttempts(ctx, row, retention.OnlineFrom)
if err != nil { if err != nil {
return nil, err return nil, err
} }
providerMessage, providerMessageFidelity := safeProviderMessage(row.ProviderMessage) providerMessage, providerMessageFidelity := safeProviderMessage(row.ProviderMessage)
return &Detail{ return &DetailResponse{Detail: Detail{
Identity: IdentityView{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), ExternalID: row.ExternalID}, Identity: IdentityView{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), ExternalID: row.ExternalID},
Resource: resourceView(row), Trigger: TriggerView{Source: row.TriggerSource, Scene: row.TriggerScene, Series: row.TriggerSeries, Attempt: row.Attempt}, Resource: resourceView(row), Trigger: TriggerView{Source: row.TriggerSource, Scene: row.TriggerScene, Series: row.TriggerSeries, Attempt: row.Attempt},
Result: ResultView{Code: row.Result, Name: constants.IntegrationResultName(row.Result), Category: constants.IntegrationResultCategory(row.Result), HTTPStatus: row.HTTPStatus, ProviderCode: row.ProviderCode, ProviderMessage: providerMessage, DurationMS: row.DurationMS, StateChanged: row.StateChanged, RecoveryStrategy: row.RecoveryStrategy}, Result: ResultView{Code: row.Result, Name: constants.IntegrationResultName(row.Result), Category: constants.IntegrationResultCategory(row.Result), HTTPStatus: row.HTTPStatus, ProviderCode: row.ProviderCode, ProviderMessage: providerMessage, DurationMS: row.DurationMS, StateChanged: row.StateChanged, RecoveryStrategy: row.RecoveryStrategy},
@@ -235,13 +272,13 @@ func (q *Query) Get(ctx context.Context, integrationID string) (*Detail, error)
ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "", ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "",
ProviderMessageFidelity: providerMessageFidelity, ProviderMessageFidelity: providerMessageFidelity,
}, },
}, nil }, Retention: retention}, nil
} }
func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog) ([]AttemptView, error) { func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog, onlineFrom time.Time) ([]AttemptView, error) {
rows := []model.IntegrationLog{current} rows := []model.IntegrationLog{current}
if current.TriggerSeries != nil && *current.TriggerSeries != "" { if current.TriggerSeries != nil && *current.TriggerSeries != "" {
if err := q.db.WithContext(ctx).Where("trigger_series = ?", *current.TriggerSeries). if err := q.db.WithContext(ctx).Where("trigger_series = ? AND created_at >= ?", *current.TriggerSeries, onlineFrom.UTC()).
Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil { Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败") return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败")
} }
@@ -270,6 +307,15 @@ func (q *Query) authorize(ctx context.Context) error {
return nil return nil
} }
func (q *Query) normalizeOnlineFilter(ctx context.Context, filter ListFilter) (ListFilter, retentionquery.Info, error) {
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceIntegration)
if err != nil {
return filter, retention, err
}
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo, constants.IntegrationQueryMaxRange)
return filter, retention, err
}
func validFilter(filter ListFilter) bool { func validFilter(filter ListFilter) bool {
if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) || filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.IntegrationQueryMaxRange { if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) || filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.IntegrationQueryMaxRange {
return false return false

View File

@@ -7,6 +7,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
@@ -19,17 +20,18 @@ type OverviewFilter struct {
// Overview 是外部交互固定维度聚合结果。 // Overview 是外部交互固定维度聚合结果。
type Overview struct { type Overview struct {
Total int64 `json:"total"` Total int64 `json:"total"`
AnomalyCount int64 `json:"anomaly_count"` AnomalyCount int64 `json:"anomaly_count"`
UnknownCount int64 `json:"unknown_count"` UnknownCount int64 `json:"unknown_count"`
StalePendingCount int64 `json:"stale_pending_count"` StalePendingCount int64 `json:"stale_pending_count"`
StateChangedCount int64 `json:"state_changed_count"` StateChangedCount int64 `json:"state_changed_count"`
AverageDurationMS float64 `json:"average_duration_ms"` AverageDurationMS float64 `json:"average_duration_ms"`
P95DurationMS float64 `json:"p95_duration_ms"` P95DurationMS float64 `json:"p95_duration_ms"`
Results []ResultCount `json:"results"` Results []ResultCount `json:"results"`
Providers []NamedCount `json:"providers"` Providers []NamedCount `json:"providers"`
Directions []NamedCount `json:"directions"` Directions []NamedCount `json:"directions"`
Trend []TrendPoint `json:"trend"` Trend []TrendPoint `json:"trend"`
Retention retentionquery.Info `json:"retention"`
} }
// ResultCount 是原始结果及其派生类别计数。 // ResultCount 是原始结果及其派生类别计数。
@@ -66,11 +68,17 @@ func (q *Query) Overview(ctx context.Context, filter OverviewFilter) (*Overview,
if filter.Bucket == "" { if filter.Bucket == "" {
filter.Bucket = "hour" filter.Bucket = "hour"
} }
var retention retentionquery.Info
var err error
filter.ListFilter, retention, err = q.normalizeOnlineFilter(ctx, filter.ListFilter)
if err != nil {
return nil, err
}
if !validFilter(filter.ListFilter) || (filter.Bucket != "hour" && filter.Bucket != "day") { if !validFilter(filter.ListFilter) || (filter.Bucket != "hour" && filter.Bucket != "day") {
return nil, errors.New(errors.CodeInvalidParam) return nil, errors.New(errors.CodeInvalidParam)
} }
base := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter.ListFilter) base := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter.ListFilter)
result := &Overview{Results: []ResultCount{}, Providers: []NamedCount{}, Directions: []NamedCount{}, Trend: []TrendPoint{}} result := &Overview{Results: []ResultCount{}, Providers: []NamedCount{}, Directions: []NamedCount{}, Trend: []TrendPoint{}, Retention: retention}
if err := loadOverviewMetrics(base, result); err != nil { if err := loadOverviewMetrics(base, result); err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,115 @@
// Package retention 提供在线审计查询的统一留存边界。
package retention
import (
"context"
"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"
)
// Source 表示受在线留存边界约束的数据源。
type Source string
const (
// SourceAudit 表示统一审计事件。
SourceAudit Source = constants.AuditArchiveSource
// SourceIntegration 表示外部交互日志。
SourceIntegration Source = constants.IntegrationArchiveSource
)
// Info 是查询响应公开的在线留存边界。
type Info struct {
OnlineFrom time.Time `json:"online_from" description:"当前可在线查询的最早时间"`
ArchivedBefore *time.Time `json:"archived_before" description:"早于该时间的数据已归档;尚未清理时为空"`
Timezone string `json:"timezone" description:"留存自然日时区"`
}
// Load 从归档账本读取已完成物理清理的数据边界。
func Load(ctx context.Context, db *gorm.DB, sources ...Source) (Info, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return Info{}, errors.Wrap(errors.CodeInternalError, err, "加载审计留存时区失败")
}
now := time.Now().In(location)
info := Info{OnlineFrom: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location), Timezone: constants.AuditArchiveTimezone}
for _, source := range sources {
boundary, cleaned, err := sourceBoundary(ctx, db, source, location)
if err != nil {
return Info{}, err
}
if boundary.Before(info.OnlineFrom) && info.ArchivedBefore == nil {
info.OnlineFrom = boundary
}
if cleaned && (info.ArchivedBefore == nil || boundary.After(*info.ArchivedBefore)) {
value := boundary
info.ArchivedBefore = &value
info.OnlineFrom = boundary
}
}
return info, nil
}
func sourceBoundary(ctx context.Context, db *gorm.DB, source Source, location *time.Location) (time.Time, bool, error) {
var cleanedEnd *time.Time
if err := db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND cleaned_at IS NOT NULL", source).
Select("MAX(range_end)").Scan(&cleanedEnd).Error; err != nil {
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计留存清理边界失败")
}
if cleanedEnd != nil {
return cleanedEnd.In(location), true, nil
}
var earliest *time.Time
table, column := "tb_audit_event", "occurred_at"
if source == SourceIntegration {
table, column = "tb_integration_log", "created_at"
}
if err := db.WithContext(ctx).Table(table).Select("MIN(" + column + ")").Scan(&earliest).Error; err != nil {
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计在线数据边界失败")
}
if earliest != nil {
return earliest.In(location), false, nil
}
now := time.Now().In(location)
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location), false, nil
}
// NormalizeRange 将缺省范围收敛到在线窗口,并拒绝归档或跨边界查询。
func NormalizeRange(info Info, from, to *time.Time, maxRange ...time.Duration) (*time.Time, *time.Time, error) {
explicitFrom := from != nil
if from != nil && info.ArchivedBefore != nil && from.Before(info.OnlineFrom) {
return nil, nil, archivedError(info)
}
if to != nil && info.ArchivedBefore != nil && !to.After(info.OnlineFrom) {
return nil, nil, archivedError(info)
}
if from == nil {
value := info.OnlineFrom
from = &value
}
if to == nil {
value := time.Now()
to = &value
}
if len(maxRange) > 0 && maxRange[0] > 0 && to.Sub(*from) > maxRange[0] {
if explicitFrom {
return nil, nil, errors.New(errors.CodeInvalidParam)
}
value := to.Add(-maxRange[0])
from = &value
}
if !from.Before(*to) {
return nil, nil, errors.New(errors.CodeInvalidParam)
}
return from, to, nil
}
func archivedError(info Info) error {
return errors.NewWithData(errors.CodeAuditDataArchived, map[string]any{"retention": info})
}

View File

@@ -15,13 +15,13 @@ func registerAuditRoutes(router fiber.Router, handler *admin.AuditHandler, doc *
agent := router.Group("/agent/resource-activities") agent := router.Group("/agent/resource-activities")
Register(agent, doc, basePath+"/agent/resource-activities", "GET", "/:resource_type/:identifier", handler.AgentResourceActivities, RouteSpec{ Register(agent, doc, basePath+"/agent/resource-activities", "GET", "/:resource_type/:identifier", handler.AgentResourceActivities, RouteSpec{
Summary: "查询代理资源活动", Summary: "查询代理资源活动",
Description: "resource_type/identifier 来自代理当前业务页面稳定字段;店铺范围只读取认证上下文。仅返回写入时生成的安全业务结论和白名单详情,越权与不存在同错。", Description: "resource_type/identifier 来自代理当前业务页面稳定字段;店铺范围只读取认证上下文。仅查询 retention 标明的在线窗口,归档范围不从对象存储读取。",
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true, Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
}) })
enterprise := router.Group("/enterprise/resource-activities") enterprise := router.Group("/enterprise/resource-activities")
Register(enterprise, doc, basePath+"/enterprise/resource-activities", "GET", "/:resource_type/:identifier", handler.EnterpriseResourceActivities, RouteSpec{ Register(enterprise, doc, basePath+"/enterprise/resource-activities", "GET", "/:resource_type/:identifier", handler.EnterpriseResourceActivities, RouteSpec{
Summary: "查询企业资源活动", Summary: "查询企业资源活动",
Description: "仅支持企业当前有效授权的卡和设备;企业身份只读取认证上下文,授权撤销后立即不可读取。响应不包含平台操作者、风险、内部前后值或外部交互内容。", Description: "仅支持企业当前有效授权的卡和设备;企业身份只读取认证上下文。仅查询 retention 标明的在线窗口,响应不包含平台内部调查字段。",
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true, Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
}) })
@@ -30,13 +30,13 @@ func registerAuditRoutes(router fiber.Router, handler *admin.AuditHandler, doc *
Register(audit, doc, groupPath, "GET", "/events", handler.ListEvents, RouteSpec{ Register(audit, doc, groupPath, "GET", "/events", handler.ListEvents, RouteSpec{
Summary: "查询全局审计事件", Summary: "查询全局审计事件",
Description: "筛选值来自调查人员输入或其他调查节点的稳定引用;身份范围只读取认证上下文。固定按发生时间和事件ID倒序,不提供导出、修改或删除。", Description: "筛选值来自调查人员输入或其他调查节点的稳定引用;缺省只查 retention 标明的在线窗口,归档范围返回稳定错误。固定倒序分页,不提供导出、修改或删除。",
Tags: []string{"审计调查"}, Input: new(dto.AuditEventListRequest), Output: new(auditquery.EventPage), Auth: true, Tags: []string{"审计调查"}, Input: new(dto.AuditEventListRequest), Output: new(auditquery.EventPage), Auth: true,
}) })
Register(audit, doc, groupPath, "GET", "/events/:event_id", handler.GetEvent, RouteSpec{ Register(audit, doc, groupPath, "GET", "/events/:event_id", handler.GetEvent, RouteSpec{
Summary: "查询审计事件详情", Summary: "查询审计事件详情",
Description: "event_id 来自事件、资源、操作者或链路节点的 investigation_refs返回全部资源快照和各资源 before/after。", Description: "event_id 来自 investigation_refs只查询在线 PostgreSQL未命中仍返回资源不存在不扫描对象存储。返回全部资源快照和各资源 before/after。",
Tags: []string{"审计调查"}, Input: new(dto.AuditEventIDParams), Output: new(auditquery.EventView), Auth: true, Tags: []string{"审计调查"}, Input: new(dto.AuditEventIDParams), Output: new(auditquery.EventDetail), Auth: true,
}) })
Register(audit, doc, groupPath, "GET", "/actors/:kind/:id/events", handler.ListActorEvents, RouteSpec{ Register(audit, doc, groupPath, "GET", "/actors/:kind/:id/events", handler.ListActorEvents, RouteSpec{
Summary: "查询操作者行为时间线", Summary: "查询操作者行为时间线",
@@ -54,20 +54,45 @@ func registerAuditRoutes(router fiber.Router, handler *admin.AuditHandler, doc *
Description: "resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。", Description: "resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。",
Tags: []string{"审计调查"}, Input: new(dto.AuditResourceTimelineRequest), Output: new(auditquery.EventPage), Auth: true, Tags: []string{"审计调查"}, Input: new(dto.AuditResourceTimelineRequest), Output: new(auditquery.EventPage), Auth: true,
}) })
Register(audit, doc, groupPath, "GET", "/requests/:request_id/timeline", handler.RequestTimeline, RouteSpec{
Summary: "查询请求关联时间线",
Description: "request_id 来自审计或外部集成节点,也可从 Access Log 粘贴。只组合 retention 在线窗口内的持久化事实,不扫描 Access Log 或对象存储。",
Tags: []string{"审计调查"}, Input: new(dto.AuditRequestTimelineParams), Output: new(auditquery.LinkTimeline), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/correlations/:correlation_id/timeline", handler.CorrelationTimeline, RouteSpec{
Summary: "查询业务关联时间线",
Description: "correlation_id 来自稳定调查引用。只组合 retention 在线窗口内的持久化事实;相同 correlation 不用于猜测技术重试。",
Tags: []string{"审计调查"}, Input: new(dto.AuditCorrelationTimelineParams), Output: new(auditquery.LinkTimeline), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/finance/timeline", handler.FinanceTimeline, RouteSpec{
Summary: "查询资金调查时间线",
Description: "可使用任一稳定资金条件进入;缺省只查 retention 在线窗口,归档范围不返回部分结果。关联事实由服务端解析,金额以业务账本为权威。",
Tags: []string{"审计调查"}, Input: new(dto.AuditFinanceTimelineRequest), Output: new(auditquery.FinanceTimelinePage), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/risks/overview", handler.RiskOverview, RouteSpec{
Summary: "查询风险调查总览",
Description: "时间范围最长31天缺省时使用当前在线窗口只聚合高风险、资金、安全、失败、拒绝、部分成功和结果未知事件。",
Tags: []string{"审计调查"}, Input: new(dto.AuditRiskOverviewRequest), Output: new(auditquery.RiskOverview), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/risks/events", handler.RiskEvents, RouteSpec{
Summary: "查询风险事件明细",
Description: "筛选条件来自风险总览分桶或调查人员输入,缺省只查 retention 在线窗口;明细返回 investigation_refs不提供处置或封禁能力。",
Tags: []string{"审计调查"}, Input: new(dto.AuditRiskEventsRequest), Output: new(auditquery.RiskEventPage), Auth: true,
})
// Integration 总览静态路径必须先于动态详情路径,避免 overview 被当作 integration_id。 // Integration 总览静态路径必须先于动态详情路径,避免 overview 被当作 integration_id。
Register(audit, doc, groupPath, "GET", "/integrations/overview", handler.IntegrationOverview, RouteSpec{ Register(audit, doc, groupPath, "GET", "/integrations/overview", handler.IntegrationOverview, RouteSpec{
Summary: "查询外部集成交互总览", Summary: "查询外部集成交互总览",
Description: "筛选和时间范围来自调查输入或关联视角跳转,身份只来自认证上下文。总览区分成功、处理中、结果不确定、失败和未发送终态。", Description: "筛选和时间范围来自调查输入或关联视角跳转;缺省只查 retention 在线窗口,归档范围返回稳定错误。总览区分五类结果。",
Tags: []string{"审计调查"}, Input: new(dto.IntegrationOverviewRequest), Output: new(integrationquery.Overview), Auth: true, Tags: []string{"审计调查"}, Input: new(dto.IntegrationOverviewRequest), Output: new(integrationquery.Overview), Auth: true,
}) })
Register(audit, doc, groupPath, "GET", "/integrations", handler.ListIntegrations, RouteSpec{ Register(audit, doc, groupPath, "GET", "/integrations", handler.ListIntegrations, RouteSpec{
Summary: "查询外部集成交互列表", Summary: "查询外部集成交互列表",
Description: "组合筛选来自调查输入或关联视角稳定引用固定按创建时间和记录ID倒序分页,不提供任意摘要搜索。", Description: "组合筛选来自调查输入或稳定引用;缺省只查 retention 在线窗口,归档范围不返回空页或部分结果。固定倒序分页,不提供任意摘要搜索。",
Tags: []string{"审计调查"}, Input: new(dto.IntegrationListRequest), Output: new(integrationquery.ListPage), Auth: true, Tags: []string{"审计调查"}, Input: new(dto.IntegrationListRequest), Output: new(integrationquery.ListPage), Auth: true,
}) })
Register(audit, doc, groupPath, "GET", "/integrations/:integration_id", handler.GetIntegration, RouteSpec{ Register(audit, doc, groupPath, "GET", "/integrations/:integration_id", handler.GetIntegration, RouteSpec{
Summary: "查询外部集成交互详情", Summary: "查询外部集成交互详情",
Description: "integration_id 来自列表、通知目标 target_key 或调查节点稳定引用;只展示结构化详情和显式尝试序列,不提供重试、补偿、确认、绑定、恢复、修改、删除或导出。", Description: "integration_id 来自稳定引用;只查询在线 PostgreSQL未命中仍返回资源不存在。展示结构化详情和在线尝试序列,不提供归档读取、恢复、修改、删除或导出。",
Tags: []string{"审计调查"}, Input: new(dto.IntegrationIDParams), Output: new(integrationquery.Detail), Auth: true, Tags: []string{"审计调查"}, Input: new(dto.IntegrationIDParams), Output: new(integrationquery.DetailResponse), Auth: true,
}) })
} }

View File

@@ -83,10 +83,6 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error
} }
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败") return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
} }
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
before := *carrier
return s.toResponse(carrier), nil return s.toResponse(carrier), nil
} }
@@ -103,6 +99,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
} }
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败") return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
} }
before := *carrier
if req.CarrierName != nil { if req.CarrierName != nil {
carrier.CarrierName = *req.CarrierName carrier.CarrierName = *req.CarrierName

View File

@@ -65,12 +65,12 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置") return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
} }
if err := validateEnterpriseDeviceActor(ctx); err != nil { if err := validateEnterpriseDeviceActor(ctx); err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err) s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{Model: gorm.Model{ID: enterpriseID}}, nil, nil, err)
return nil, err return nil, err
} }
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil { if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr) s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{Model: gorm.Model{ID: enterpriseID}}, nil, nil, permissionErr)
return nil, permissionErr return nil, permissionErr
} }
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID) enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
@@ -417,12 +417,12 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置") return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
} }
if err := validateEnterpriseDeviceActor(ctx); err != nil { if err := validateEnterpriseDeviceActor(ctx); err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err) s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{Model: gorm.Model{ID: enterpriseID}}, nil, nil, err)
return nil, err return nil, err
} }
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil { if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr) s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{Model: gorm.Model{ID: enterpriseID}}, nil, nil, permissionErr)
return nil, permissionErr return nil, permissionErr
} }
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID) enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)

View File

@@ -23,38 +23,46 @@ func (s *Service) SetAccessAudit(writer *audit.Writer) {
s.auditWriter = writer s.auditWriter = writer
} }
// WriteCardStateAudit 将卡观测事务中的人工状态操作写入统一 Audit Event。 // WriteCardStateAudit 将卡观测事务中的人工、回调或 Worker 状态变化写入统一 Audit Event。
func (s *Service) WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input cardapp.StateAudit) error { func (s *Service) WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input cardapp.StateAudit) error {
extraResources := make([]audit.ResourceInput, 0, 1) extraResources := make([]audit.ResourceInput, 0, 1)
if input.IntegrationID != "" { if input.IntegrationID != "" {
extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID)) extraResources = append(extraResources, cardStateIntegrationAuditResource(ctx, input.IntegrationID))
} }
return s.appendCardLifecycleAudit(ctx, tx, input.ActionCode, input.Summary, constants.AuditResultSuccess, return s.appendCardLifecycleAudit(ctx, tx, input.ActionCode, input.Summary, constants.AuditResultSuccess,
input.Card, input.BeforeData, input.AfterData, nil, extraResources...) input.Card, input.BeforeData, input.AfterData, nil, extraResources...)
} }
// WriteCardStateFailure 使用独立短事务记录已解析卡资源后的回调失败。 // WriteCardStateFailure 使用独立短事务记录已解析卡资源后的回调或 Worker 失败。
func (s *Service) WriteCardStateFailure(ctx context.Context, input cardapp.StateAudit, businessErr error) { func (s *Service) WriteCardStateFailure(ctx context.Context, input cardapp.StateAudit, businessErr error) {
extraResources := make([]audit.ResourceInput, 0, 1) extraResources := make([]audit.ResourceInput, 0, 1)
if input.IntegrationID != "" { if input.IntegrationID != "" {
extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID)) extraResources = append(extraResources, cardStateIntegrationAuditResource(ctx, input.IntegrationID))
} }
s.recordCardLifecycleFailure(ctx, input.ActionCode, input.Summary, constants.AuditResultFailed, s.recordCardLifecycleFailure(ctx, input.ActionCode, input.Summary, constants.AuditResultFailed,
input.Card, input.Card.ID, businessErr, extraResources...) input.Card, input.Card.ID, businessErr, extraResources...)
} }
func callbackIntegrationAuditResource(ctx context.Context, integrationID string) audit.ResourceInput { func cardStateIntegrationAuditResource(ctx context.Context, integrationID string) audit.ResourceInput {
linkage := auditcontext.From(ctx) linkage := auditcontext.From(ctx)
correlationID := linkage.CorrelationID correlationID := linkage.CorrelationID
if correlationID == "" { if correlationID == "" {
correlationID = linkage.RequestID correlationID = linkage.RequestID
} }
direction := constants.IntegrationDirectionInbound
role := constants.AuditResourceRoleCallbackIntegration
provider := linkage.ActorID
if linkage.Source == constants.AuditSourceWorker {
direction = constants.IntegrationDirectionOutbound
role = constants.AuditResourceRoleWorkerIntegration
provider = constants.IntegrationProviderGateway
}
return audit.ResourceInput{ return audit.ResourceInput{
Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID, Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCallbackIntegration, Relation: constants.AuditResourceRelationReference, Role: role,
IdentitySnapshot: map[string]any{ IdentitySnapshot: map[string]any{
"integration_id": integrationID, "provider": linkage.ActorID, "integration_id": integrationID, "provider": provider,
"direction": constants.IntegrationDirectionInbound, "correlation_id": correlationID, "direction": direction, "correlation_id": correlationID,
}, },
SubjectVisibility: constants.AuditSubjectInternalOnly, SubjectVisibility: constants.AuditSubjectInternalOnly,
} }

View File

@@ -2709,8 +2709,12 @@ func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint
return return
} }
linkage := auditcontext.From(ctx)
// 直接传 map由 EnqueueTask 内部统一序列化一次(传 []byte 会导致 sonic.Marshal 二次 base64 编码) // 直接传 map由 EnqueueTask 内部统一序列化一次(传 []byte 会导致 sonic.Marshal 二次 base64 编码)
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeCommission, map[string]any{"order_id": orderID}); err != nil { if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeCommission, map[string]any{
"order_id": orderID, "request_id": linkage.RequestID, "correlation_id": linkage.CorrelationID,
"parent_event_id": linkage.ParentEventID,
}); err != nil {
s.logger.Error("佣金计算任务入队失败", s.logger.Error("佣金计算任务入队失败",
zap.Uint("order_id", orderID), zap.Uint("order_id", orderID),
zap.Error(err), zap.Error(err),

View File

@@ -14,6 +14,7 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// ResumeCallback 复机回调接口 // ResumeCallback 复机回调接口

View File

@@ -9,6 +9,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/task" "github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/queue" "github.com/break/junhong_cmp_fiber/pkg/queue"
@@ -227,7 +228,11 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
linkedIDs := rechargeOrder.LinkedPackageIDs linkedIDs := rechargeOrder.LinkedPackageIDs
if len(linkedIDs) > 0 { if len(linkedIDs) > 0 {
taskPayload := task.AutoPurchasePayload{RechargeOrderID: rechargeOrder.ID} linkage := auditcontext.From(ctx)
taskPayload := task.AutoPurchasePayload{
RechargeOrderID: rechargeOrder.ID, RequestID: linkage.RequestID,
CorrelationID: linkage.CorrelationID, ParentEventID: linkage.ParentEventID,
}
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeAutoPurchaseAfterRecharge, taskPayload, if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeAutoPurchaseAfterRecharge, taskPayload,
asynq.MaxRetry(3), asynq.MaxRetry(3),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAutoPurchaseAfterRecharge)), asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAutoPurchaseAfterRecharge)),

View File

@@ -132,12 +132,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
if req.RoleName != nil && *req.RoleName != role.RoleName { if req.RoleName != nil && *req.RoleName != role.RoleName {
exists, err := s.roleStore.ExistsByName(ctx, *req.RoleName, id) exists, err := s.roleStore.ExistsByName(ctx, *req.RoleName, id)
if err != nil { if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err) s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, nil, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败") return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
} }
if exists { if exists {
appErr := errors.New(errors.CodeRoleNameExists) appErr := errors.New(errors.CodeRoleNameExists)
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "拒绝更新重复角色名", constants.AuditResultDenied, role, beforeData, nil, appErr) s.recordFailure(ctx, constants.AuditActionRoleUpdated, "拒绝更新重复角色名", constants.AuditResultDenied, role, nil, beforeData, appErr)
return nil, appErr return nil, appErr
} }
role.RoleName = *req.RoleName role.RoleName = *req.RoleName
@@ -162,7 +162,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role), OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
}) })
}); err != nil { }); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err) s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, nil, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新角色失败") return nil, errors.Wrap(errors.CodeInternalError, err, "更新角色失败")
} }
@@ -181,19 +181,19 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id) accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
if err != nil { if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err) s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, nil, roleAuditData(role), err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败") return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
} }
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id) shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
if err != nil { if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err) s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, nil, roleAuditData(role), err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败") return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
} }
if accountCount > 0 || shopCount > 0 { if accountCount > 0 || shopCount > 0 {
appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再删除", accountCount, shopCount)) appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再删除", accountCount, shopCount))
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "拒绝删除使用中的角色", constants.AuditResultDenied, role, roleAuditData(role), nil, appErr) s.recordFailure(ctx, constants.AuditActionRoleDeleted, "拒绝删除使用中的角色", constants.AuditResultDenied, role, nil, roleAuditData(role), appErr)
return appErr return appErr
} }
@@ -208,7 +208,7 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
OperatorID: operatorID, Role: role, BeforeData: beforeData, AfterData: map[string]any{"deleted": true}, OperatorID: operatorID, Role: role, BeforeData: beforeData, AfterData: map[string]any{"deleted": true},
}) })
}); err != nil { }); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, beforeData, nil, err) s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, nil, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "删除角色失败") return errors.Wrap(errors.CodeInternalError, err, "删除角色失败")
} }
@@ -510,19 +510,19 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
if status == constants.StatusDisabled { if status == constants.StatusDisabled {
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id) accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
if err != nil { if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err) s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, nil, roleAuditData(role), err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败") return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
} }
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id) shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
if err != nil { if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err) s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, nil, roleAuditData(role), err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败") return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
} }
if accountCount > 0 || shopCount > 0 { if accountCount > 0 || shopCount > 0 {
appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再禁用", accountCount, shopCount)) appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再禁用", accountCount, shopCount))
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "拒绝禁用使用中的角色", constants.AuditResultDenied, role, roleAuditData(role), nil, appErr) s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "拒绝禁用使用中的角色", constants.AuditResultDenied, role, nil, roleAuditData(role), appErr)
return appErr return appErr
} }
} }
@@ -540,7 +540,7 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role), OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
}) })
}); err != nil { }); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, beforeData, nil, err) s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, nil, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "更新角色状态失败") return errors.Wrap(errors.CodeInternalError, err, "更新角色状态失败")
} }

View File

@@ -0,0 +1,61 @@
package task
import (
"context"
"fmt"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// AuditDailyArchivePayload 是人工补档时可选的任务载荷。
type AuditDailyArchivePayload struct {
ArchiveDate string `json:"archive_date"`
}
// AuditDailyArchiveHandler 处理统一审计每日冷归档任务。
type AuditDailyArchiveHandler struct {
service *auditarchive.Service
logger *zap.Logger
}
// NewAuditDailyArchiveHandler 创建统一审计每日冷归档任务处理器。
func NewAuditDailyArchiveHandler(service *auditarchive.Service, logger *zap.Logger) *AuditDailyArchiveHandler {
return &AuditDailyArchiveHandler{service: service, logger: logger}
}
// Handle 执行前一完整自然日归档,或按任务载荷补档指定自然日。
func (h *AuditDailyArchiveHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h.service == nil {
return fmt.Errorf("统一审计归档服务未配置")
}
var err error
if len(task.Payload()) == 0 {
err = h.service.ArchivePreviousDay(ctx)
} else {
var payload AuditDailyArchivePayload
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
return fmt.Errorf("解析统一审计归档任务载荷失败: %w", unmarshalErr)
}
location, locationErr := time.LoadLocation(constants.AuditArchiveTimezone)
if locationErr != nil {
return fmt.Errorf("加载统一审计归档时区失败: %w", locationErr)
}
archiveDate, parseErr := time.ParseInLocation(time.DateOnly, payload.ArchiveDate, location)
if parseErr != nil {
return fmt.Errorf("解析统一审计归档日期失败: %w", parseErr)
}
err = h.service.ArchiveDate(ctx, archiveDate)
}
if err != nil {
h.logger.Error("统一审计每日冷归档失败", zap.Error(err))
return err
}
h.logger.Info("统一审计每日冷归档完成")
return nil
}

View File

@@ -0,0 +1,64 @@
package task
import (
"context"
"fmt"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
)
// AuditMonthlyRetentionPayload 是人工补跑月度清理时可选的任务载荷。
type AuditMonthlyRetentionPayload struct {
ArchiveMonth string `json:"archive_month"`
}
// AuditMonthlyRetentionHandler 处理归档完整性门禁与上月在线日志物理清理。
type AuditMonthlyRetentionHandler struct {
service *auditarchive.Service
logger *zap.Logger
}
// NewAuditMonthlyRetentionHandler 创建月度日志留存清理处理器。
func NewAuditMonthlyRetentionHandler(service *auditarchive.Service, logger *zap.Logger) *AuditMonthlyRetentionHandler {
return &AuditMonthlyRetentionHandler{service: service, logger: logger}
}
// Handle 校验整月归档后按固定顺序分批物理删除 PostgreSQL 在线日志。
func (h *AuditMonthlyRetentionHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h.service == nil {
return fmt.Errorf("月度日志留存清理服务未配置")
}
startedAt := time.Now()
var result auditarchive.RetentionResult
var err error
if len(task.Payload()) == 0 {
result, err = h.service.CleanupPreviousMonth(ctx)
} else {
var payload AuditMonthlyRetentionPayload
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
return fmt.Errorf("解析月度日志留存清理任务载荷失败: %w", unmarshalErr)
}
month, parseErr := parseArchiveMonth(payload.ArchiveMonth)
if parseErr != nil {
return parseErr
}
result, err = h.service.CleanupMonth(ctx, month)
}
fields := []zap.Field{
zap.String("archive_month", result.Month), zap.Int64("audit_event_count", result.EventCount),
zap.Int64("event_resource_count", result.ResourceCount), zap.Int64("integration_log_count", result.IntegrationCount),
zap.Duration("duration", time.Since(startedAt)), zap.Int("manifest_count", len(result.ManifestKeys)),
}
if err != nil {
fields = append(fields, zap.String("severity", "critical"), zap.Error(err))
h.logger.Error("月度日志留存清理失败PostgreSQL 整月清理已阻断或等待断点续跑", fields...)
return err
}
h.logger.Info("月度日志留存清理完成", fields...)
return nil
}

View File

@@ -27,7 +27,10 @@ import (
// AutoPurchasePayload 充值后自动购包任务载荷 // AutoPurchasePayload 充值后自动购包任务载荷
type AutoPurchasePayload struct { type AutoPurchasePayload struct {
RechargeOrderID uint `json:"recharge_order_id"` RechargeOrderID uint `json:"recharge_order_id"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
} }
// AutoPurchaseHandler 充值后自动购包任务处理器 // AutoPurchaseHandler 充值后自动购包任务处理器
@@ -123,10 +126,14 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
h.logger.Error("查询充值订单失败", zap.Uint("recharge_order_id", payload.RechargeOrderID), zap.Error(err)) h.logger.Error("查询充值订单失败", zap.Uint("recharge_order_id", payload.RechargeOrderID), zap.Error(err))
return err return err
} }
correlationID := payload.CorrelationID
if correlationID == "" {
correlationID = rechargeOrder.RechargeOrderNo
}
ctx = auditcontext.With(ctx, auditcontext.Context{ ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeAutoPurchaseAfterRecharge, ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeAutoPurchaseAfterRecharge,
ActorName: "充值后自动购包任务", Source: constants.AuditSourceWorker, ActorName: "充值后自动购包任务", Source: constants.AuditSourceWorker,
CorrelationID: rechargeOrder.RechargeOrderNo, RequestID: payload.RequestID, CorrelationID: correlationID, ParentEventID: payload.ParentEventID,
}) })
if rechargeOrder.AutoPurchaseStatus == constants.AutoPurchaseStatusSuccess { if rechargeOrder.AutoPurchaseStatus == constants.AutoPurchaseStatusSuccess {
@@ -301,7 +308,11 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
// 事务提交成功后触发佣金计算(不在事务内,防止任务提交后事务回滚的数据一致性问题) // 事务提交成功后触发佣金计算(不在事务内,防止任务提交后事务回滚的数据一致性问题)
if h.asynqClient != nil && createdOrderID > 0 { if h.asynqClient != nil && createdOrderID > 0 {
payloadBytes, marshalErr := sonic.Marshal(map[string]any{"order_id": createdOrderID}) linkage := auditcontext.From(ctx)
payloadBytes, marshalErr := sonic.Marshal(CommissionCalculationPayload{
OrderID: createdOrderID, RequestID: linkage.RequestID,
CorrelationID: linkage.CorrelationID, ParentEventID: linkage.ParentEventID,
})
if marshalErr != nil { if marshalErr != nil {
h.logger.Warn("佣金任务载荷序列化失败", h.logger.Warn("佣金任务载荷序列化失败",
zap.Uint("order_id", createdOrderID), zap.Uint("order_id", createdOrderID),

View File

@@ -8,6 +8,8 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"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/errors"
) )
@@ -32,6 +34,11 @@ func (h *CardObservationSeriesHandler) Handle(ctx context.Context, task *asynq.T
h.logger.Error("解析卡观测序列任务载荷失败", zap.Error(err)) h.logger.Error("解析卡观测序列任务载荷失败", zap.Error(err))
return errors.Wrap(errors.CodeInvalidParam, err, "卡观测序列任务载荷无法解析") return errors.Wrap(errors.CodeInvalidParam, err, "卡观测序列任务载荷无法解析")
} }
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeCardObservationSeries,
ActorName: "卡观测序列任务", Source: constants.AuditSourceWorker,
RequestID: payload.RequestID, CorrelationID: payload.CorrelationID, ParentEventID: payload.ParentEventID,
})
if err := h.service.Execute(ctx, payload); err != nil { if err := h.service.Execute(ctx, payload); err != nil {
h.logger.Warn("卡观测序列当前尝试失败", h.logger.Warn("卡观测序列当前尝试失败",
zap.String("series_id", payload.SeriesID), zap.Int("attempt", payload.Attempt), zap.Error(err)) zap.String("series_id", payload.SeriesID), zap.Int("attempt", payload.Attempt), zap.Error(err))

View File

@@ -18,7 +18,10 @@ const (
) )
type CommissionCalculationPayload struct { type CommissionCalculationPayload struct {
OrderID uint `json:"order_id"` OrderID uint `json:"order_id"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
} }
type CommissionCalculationHandler struct { type CommissionCalculationHandler struct {
@@ -48,9 +51,15 @@ func (h *CommissionCalculationHandler) HandleCommissionCalculation(ctx context.C
) )
return asynq.SkipRetry return asynq.SkipRetry
} }
correlationID := payload.CorrelationID
if correlationID == "" {
correlationID = task.ResultWriter().TaskID()
}
ctx = auditcontext.With(ctx, auditcontext.Context{ ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDCommissionCalculationWorker, ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDCommissionCalculationWorker,
ActorName: "订单佣金计算任务", Source: constants.AuditSourceWorker, ActorName: "订单佣金计算任务", Source: constants.AuditSourceWorker,
RequestID: payload.RequestID, CorrelationID: correlationID,
ParentEventID: payload.ParentEventID,
}) })
if err := h.service.CalculateCommission(ctx, payload.OrderID); err != nil { if err := h.service.CalculateCommission(ctx, payload.OrderID); err != nil {

View File

@@ -0,0 +1,113 @@
package task
import (
"context"
"fmt"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// IntegrationDailyArchivePayload 是人工补档时可选的任务载荷。
type IntegrationDailyArchivePayload struct {
ArchiveDate string `json:"archive_date"`
}
// IntegrationMonthlyFinalizePayload 是人工月度复核时可选的任务载荷。
type IntegrationMonthlyFinalizePayload struct {
ArchiveMonth string `json:"archive_month"`
}
// IntegrationArchiveHandler 处理 Integration Log 每日归档与月度最终复核。
type IntegrationArchiveHandler struct {
service *auditarchive.Service
logger *zap.Logger
}
// NewIntegrationArchiveHandler 创建 Integration Log 归档任务处理器。
func NewIntegrationArchiveHandler(service *auditarchive.Service, logger *zap.Logger) *IntegrationArchiveHandler {
return &IntegrationArchiveHandler{service: service, logger: logger}
}
// HandleDaily 执行前一完整自然日归档,或按任务载荷补档指定自然日。
func (h *IntegrationArchiveHandler) HandleDaily(ctx context.Context, task *asynq.Task) error {
if h.service == nil {
return fmt.Errorf("Integration Log 归档服务未配置")
}
var err error
if len(task.Payload()) == 0 {
err = h.service.ArchivePreviousIntegrationDay(ctx)
} else {
var payload IntegrationDailyArchivePayload
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
return fmt.Errorf("解析 Integration Log 每日归档任务载荷失败: %w", unmarshalErr)
}
date, parseErr := parseArchiveDate(payload.ArchiveDate)
if parseErr != nil {
return parseErr
}
err = h.service.ArchiveIntegrationDate(ctx, date)
}
if err != nil {
h.logger.Error("Integration Log 每日冷归档失败", zap.Error(err))
return err
}
h.logger.Info("Integration Log 每日冷归档完成")
return nil
}
// HandleMonthlyFinalize 执行上一个完整自然月复核,或按任务载荷复核指定月份。
func (h *IntegrationArchiveHandler) HandleMonthlyFinalize(ctx context.Context, task *asynq.Task) error {
if h.service == nil {
return fmt.Errorf("Integration Log 归档服务未配置")
}
var err error
if len(task.Payload()) == 0 {
err = h.service.FinalizePreviousIntegrationMonth(ctx)
} else {
var payload IntegrationMonthlyFinalizePayload
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
return fmt.Errorf("解析 Integration Log 月度复核任务载荷失败: %w", unmarshalErr)
}
month, parseErr := parseArchiveMonth(payload.ArchiveMonth)
if parseErr != nil {
return parseErr
}
err = h.service.FinalizeIntegrationMonth(ctx, month)
}
if err != nil {
h.logger.Error("Integration Log 月度最终版本复核失败,后续清理必须阻止", zap.Error(err))
return err
}
h.logger.Info("Integration Log 月度最终版本复核完成")
return nil
}
func parseArchiveDate(value string) (time.Time, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return time.Time{}, fmt.Errorf("加载 Integration Log 归档时区失败: %w", err)
}
date, err := time.ParseInLocation(time.DateOnly, value, location)
if err != nil {
return time.Time{}, fmt.Errorf("解析 Integration Log 归档日期失败: %w", err)
}
return date, nil
}
func parseArchiveMonth(value string) (time.Time, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return time.Time{}, fmt.Errorf("加载 Integration Log 归档时区失败: %w", err)
}
month, err := time.ParseInLocation("2006-01", value, location)
if err != nil {
return time.Time{}, fmt.Errorf("解析 Integration Log 归档月份失败: %w", err)
}
return month, nil
}

View File

@@ -23,10 +23,11 @@ func NewNotificationCleanupHandler(service *notificationinfra.CleanupService, lo
} }
// Handle 执行有界、可重入的通知分批清理。 // Handle 执行有界、可重入的通知分批清理。
func (h *NotificationCleanupHandler) Handle(ctx context.Context, _ *asynq.Task) error { func (h *NotificationCleanupHandler) Handle(ctx context.Context, task *asynq.Task) error {
ctx = auditcontext.With(ctx, auditcontext.Context{ ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeNotificationCleanup, ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeNotificationCleanup,
ActorName: "站内通知清理任务", Source: constants.AuditSourceWorker, ActorName: "站内通知清理任务", Source: constants.AuditSourceWorker,
CorrelationID: task.ResultWriter().TaskID(),
}) })
h.logger.Info("开始执行站内通知保留清理") h.logger.Info("开始执行站内通知保留清理")
if err := h.service.Run(ctx); err != nil { if err := h.service.Run(ctx); err != nil {

View File

@@ -77,6 +77,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil { if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 流量 Integration Log 失败", logErr) return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 流量 Integration Log 失败", logErr)
} }
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCarddata, "卡流量轮询任务", attempt.IntegrationID)
if h.observation == nil || h.carrier == nil { if h.observation == nil || h.carrier == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡流量观测能力未配置", nil) return h.failAndRequeue(ctx, cardID, startedAt, "卡流量观测能力未配置", nil)
} }

View File

@@ -76,6 +76,7 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil { if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 网络 Integration Log 失败", logErr) return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 网络 Integration Log 失败", logErr)
} }
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCardStatus, "卡网络状态轮询任务", attempt.IntegrationID)
if h.observation == nil { if h.observation == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡网络观测能力未配置", nil) return h.failAndRequeue(ctx, cardID, startedAt, "卡网络观测能力未配置", nil)
} }

View File

@@ -44,6 +44,7 @@ func (h *PollingPackageHandler) Handle(ctx context.Context, t *asynq.Task) error
if !ok { if !ok {
return nil return nil
} }
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingPackage, "套餐状态轮询任务", t.ResultWriter().TaskID())
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingPackage) { if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingPackage) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID)) h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))

View File

@@ -52,6 +52,7 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
if !ok { if !ok {
return nil return nil
} }
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingProtect, "保护期一致性轮询任务", t.ResultWriter().TaskID())
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingProtect) { if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingProtect) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID)) h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))

View File

@@ -68,6 +68,7 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil { if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 实名 Integration Log 失败", logErr) return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 实名 Integration Log 失败", logErr)
} }
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingRealname, "实名状态轮询任务", attempt.IntegrationID)
if h.observation == nil { if h.observation == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡实名观测能力未配置", nil) return h.failAndRequeue(ctx, cardID, startedAt, "卡实名观测能力未配置", nil)
} }

View File

@@ -1,14 +1,29 @@
package task package task
import ( import (
"context"
"strconv" "strconv"
"time" "time"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
) )
// withPollingWorkerAuditContext 为实际改变业务事实的轮询任务补充真实系统操作者与链路。
func withPollingWorkerAuditContext(ctx context.Context, taskType, taskName, correlationID string) context.Context {
if correlationID == "" {
correlationID = taskType
}
return auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: taskType,
ActorName: taskName, Source: constants.AuditSourceWorker, CorrelationID: correlationID,
})
}
// shortTaskType 从完整任务类型中提取简短名称(如 polling:carddata → carddata // shortTaskType 从完整任务类型中提取简短名称(如 polling:carddata → carddata
func shortTaskType(fullTaskType string) string { func shortTaskType(fullTaskType string) string {
for i := len(fullTaskType) - 1; i >= 0; i-- { for i := len(fullTaskType) - 1; i >= 0; i-- {

1
m.csv Normal file
View File

@@ -0,0 +1 @@
89861590172420377385
1 89861590172420377385

View File

@@ -0,0 +1,19 @@
-- 回滚统一审计跨视角查询索引,并恢复 request 与 parent 的原始精确查询索引。
DROP INDEX idx_audit_resource_key_event;
DROP INDEX idx_audit_resource_id_event;
DROP INDEX idx_audit_event_parent;
CREATE INDEX idx_audit_event_parent
ON tb_audit_event (parent_event_id)
WHERE parent_event_id <> '';
DROP INDEX idx_audit_event_request;
CREATE INDEX idx_audit_event_request
ON tb_audit_event (request_id)
WHERE request_id <> '';
DROP INDEX idx_audit_event_source_occurred;
DROP INDEX idx_audit_event_category_occurred;
DROP INDEX idx_audit_event_risk_occurred;
DROP INDEX idx_audit_event_result_occurred;
DROP INDEX idx_audit_event_action_occurred;

View File

@@ -0,0 +1,32 @@
-- 为统一审计跨视角查询补充受控 B-tree 索引,不增加 JSONB 搜索、缓存或分区。
CREATE INDEX idx_audit_event_action_occurred
ON tb_audit_event (action_code, occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_result_occurred
ON tb_audit_event (result, occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_risk_occurred
ON tb_audit_event (risk_level, occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_category_occurred
ON tb_audit_event (category, occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_source_occurred
ON tb_audit_event (source, occurred_at DESC, id DESC);
DROP INDEX idx_audit_event_request;
CREATE INDEX idx_audit_event_request
ON tb_audit_event (request_id, occurred_at DESC, id DESC)
WHERE request_id <> '';
DROP INDEX idx_audit_event_parent;
CREATE INDEX idx_audit_event_parent
ON tb_audit_event (parent_event_id, occurred_at DESC, id DESC)
WHERE parent_event_id <> '';
CREATE INDEX idx_audit_resource_id_event
ON tb_audit_event_resource (resource_type, resource_id, audit_event_id)
WHERE resource_id IS NOT NULL;
CREATE INDEX idx_audit_resource_key_event
ON tb_audit_event_resource (resource_type, resource_key, audit_event_id);

View File

@@ -0,0 +1,11 @@
LOCK TABLE tb_log_archive_run IN ACCESS EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM tb_log_archive_run LIMIT 1) THEN
RAISE EXCEPTION 'tb_log_archive_run 已存在归档运行事实,禁止回滚迁移';
END IF;
END;
$$;
DROP TABLE tb_log_archive_run;

View File

@@ -0,0 +1,53 @@
CREATE TABLE tb_log_archive_run (
id bigserial PRIMARY KEY,
source varchar(32) NOT NULL,
archive_date date NOT NULL,
instance_id varchar(100) NOT NULL,
schema_version varchar(32) NOT NULL,
revision integer NOT NULL DEFAULT 1,
status varchar(16) NOT NULL,
is_final boolean NOT NULL DEFAULT false,
range_start timestamptz NOT NULL,
range_end timestamptz NOT NULL,
object_key varchar(500) NOT NULL DEFAULT '',
manifest_key varchar(500) NOT NULL DEFAULT '',
event_count bigint NOT NULL DEFAULT 0,
resource_count bigint NOT NULL DEFAULT 0,
record_count bigint NOT NULL DEFAULT 0,
uncompressed_bytes bigint NOT NULL DEFAULT 0,
compressed_bytes bigint NOT NULL DEFAULT 0,
sha256 varchar(64) NOT NULL DEFAULT '',
attempt_count integer NOT NULL DEFAULT 0,
error_summary varchar(500) NOT NULL DEFAULT '',
generated_at timestamptz,
completed_at timestamptz,
cleanup_started_at timestamptz,
cleaned_at timestamptz,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT uq_log_archive_run UNIQUE (source, archive_date, instance_id, schema_version),
CONSTRAINT ck_log_archive_run_status CHECK (status IN ('pending', 'running', 'success', 'failed')),
CONSTRAINT ck_log_archive_run_revision CHECK (revision > 0),
CONSTRAINT ck_log_archive_run_range CHECK (range_end > range_start),
CONSTRAINT ck_log_archive_run_counts CHECK (
event_count >= 0 AND resource_count >= 0 AND record_count >= 0
AND uncompressed_bytes >= 0 AND compressed_bytes >= 0 AND attempt_count >= 0
)
);
CREATE INDEX idx_log_archive_run_date_status
ON tb_log_archive_run (archive_date, status, source);
COMMENT ON TABLE tb_log_archive_run IS '日志冷归档运行账本,不保存日志正文';
COMMENT ON COLUMN tb_log_archive_run.source IS '归档数据源稳定编码';
COMMENT ON COLUMN tb_log_archive_run.archive_date IS 'Asia/Shanghai 归档自然日';
COMMENT ON COLUMN tb_log_archive_run.instance_id IS '归档任务实例标识';
COMMENT ON COLUMN tb_log_archive_run.schema_version IS '归档 JSONL 结构版本';
COMMENT ON COLUMN tb_log_archive_run.revision IS '同一归档日不可变对象版本';
COMMENT ON COLUMN tb_log_archive_run.status IS '归档状态pending、running、success 或 failed';
COMMENT ON COLUMN tb_log_archive_run.is_final IS '是否已按数据库当前内容形成月度最终版本';
COMMENT ON COLUMN tb_log_archive_run.object_key IS 'JSONL gzip 对象 Key';
COMMENT ON COLUMN tb_log_archive_run.manifest_key IS '归档 manifest 对象 Key';
COMMENT ON COLUMN tb_log_archive_run.sha256 IS 'gzip 对象内容 SHA-256';
COMMENT ON COLUMN tb_log_archive_run.cleanup_started_at IS '月度物理清理已通过门禁并开始执行的时间';
COMMENT ON COLUMN tb_log_archive_run.cleaned_at IS '后续月度在线数据清理完成时间';

View File

@@ -82,24 +82,24 @@
- [x] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计审计中心自身仍不提供导出。【主Application + AsynqObject Storage边界现有导入批量和业务导出任务动作不迁移导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线核对通过LSP 无诊断】 - [x] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计审计中心自身仍不提供导出。【主Application + AsynqObject Storage边界现有导入批量和业务导出任务动作不迁移导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线核对通过LSP 无诊断】
- [x] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action系统清理使用系统 actorAudit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker边界通知状态变化不迁移通知查询验证人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联核对通过LSP 无诊断】 - [x] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action系统清理使用系统 actorAudit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker边界通知状态变化不迁移通知查询验证人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联核对通过LSP 无诊断】
- [x] 8.5 迁移轮询配置、并发配置、告警规则、人工触发/取消和状态变化纵向用例;手动轮询表继续承担进度/结果,实际 Gateway 尝试写 Integration Log人工触发动作另写 Audit Event。【主Application + AdapterScheduler/Query边界轮询配置与人工动作不迁移删除手动任务表和普通运行查询验证配置/触发/拒绝、任务 ledger 保留、Integration/Audit 分界和 actor 核对通过LSP 无诊断】 - [x] 8.5 迁移轮询配置、并发配置、告警规则、人工触发/取消和状态变化纵向用例;手动轮询表继续承担进度/结果,实际 Gateway 尝试写 Integration Log人工触发动作另写 Audit Event。【主Application + AdapterScheduler/Query边界轮询配置与人工动作不迁移删除手动任务表和普通运行查询验证配置/触发/拒绝、任务 ledger 保留、Integration/Audit 分界和 actor 核对通过LSP 无诊断】
- [ ] 8.6 迁移支付、运营商实名和企微等外部 Callback 中实际改变内部事实的入口,使用 external actor无状态变化只保留 Integration Log。【主Application/Infrastructure Adapter边界当前外部回调不迁移渠道协议验证逐回调 action、幂等、资源解析、Integration/Audit 分界和链路核对通过LSP 无诊断】 - [x] 8.6 迁移支付、运营商实名和企微等外部 Callback 中实际改变内部事实的入口,使用 external actor无状态变化只保留 Integration Log。【主Application/Infrastructure Adapter边界当前外部回调不迁移渠道协议验证逐回调 action、幂等、资源解析、Integration/Audit 分界和链路核对通过LSP 无诊断】
- [ ] 8.7 迁移当前 Worker 中实际改变内部业务事实的入口,使用 system_task actor 并传播 correlation/parent纯投递或技术装配按清单 N/A。【主Application/Asynq边界当前 Worker 写入口不迁移Scheduler 与 Callback验证逐 Worker 覆盖、幂等、失败重试和未登记动作门禁 PASSLSP 无诊断】 - [x] 8.7 迁移当前 Worker 中实际改变内部业务事实的入口,使用 system_task actor 并传播 correlation/parent纯投递或技术装配按清单 N/A。【主Application/Asynq边界当前 Worker 写入口不迁移Scheduler 与 Callback验证逐 Worker 覆盖、幂等、失败重试和未登记动作门禁 PASSLSP 无诊断】
- [ ] 8.8 迁移当前 Scheduler 中实际创建任务、改变配置/状态或产生业务事实的入口,使用 scheduled_job actor实施时以当前清单为准不硬编码历史数量。【主Application/Scheduler边界当前计划任务不迁移Worker 消费逻辑|验证:逐 Scheduler 覆盖、重复调度幂等、父子链路和 N/A 理由 PASSLSP 无诊断】 - [x] 8.8 迁移当前 Scheduler 中实际创建任务、改变配置/状态或产生业务事实的入口,使用 scheduled_job actor实施时以当前清单为准不硬编码历史数量。【主Application/Scheduler边界当前计划任务不迁移Worker 消费逻辑|验证:逐 Scheduler 覆盖、重复调度幂等、父子链路和 N/A 理由 PASSLSP 无诊断】
- [ ] 8.9 迁移当前 Outbox 消费者中实际形成新业务事实的入口,保留 Outbox 投递事实并为内部变化写 system_task Audit Event不把投递成功伪装成业务成功。【主Application/Outbox Consumer边界当前可靠事件消费者不迁移Relay 实现|验证:逐消费者覆盖、至少一次幂等、业务/投递结果分离和 correlation 核对通过LSP 无诊断】 - [x] 8.9 迁移当前 Outbox 消费者中实际形成新业务事实的入口,保留 Outbox 投递事实并为内部变化写 system_task Audit Event不把投递成功伪装成业务成功。【主Application/Outbox Consumer边界当前可靠事件消费者不迁移Relay 实现|验证:逐消费者覆盖、至少一次幂等、业务/投递结果分离和 correlation 核对通过LSP 无诊断】
## 9. 跨视角调查与性能收口 ## 9. 跨视角调查与性能收口
- [ ] 9.1 交付 request 和 correlation 组合时间线,按 `record_source` 组合 Audit Event、Integration Log、Outbox/Asynq 摘要及 Domain Ledger 引用,不扫描 Access Log、不猜测历史链路或技术重试。【主Query边界跨事实只读投影不迁移Access Log 存储与关系图验证支付、退款、审批、异步链路、历史缺字段和稳定排序核对通过LSP 无诊断】 - [x] 9.1 交付 request 和 correlation 组合时间线,按 `record_source` 组合 Audit Event、Integration Log、Outbox/Asynq 摘要及 Domain Ledger 引用,不扫描 Access Log、不猜测历史链路或技术重试。【主Query边界跨事实只读投影不迁移Access Log 存储与关系图验证支付、退款、审批、异步链路、历史缺字段和稳定排序核对通过LSP 无诊断】
- [ ] 9.2 交付资金调查时间线,按店铺、钱包、订单、支付、退款、充值、审批、交易号、操作者、时间和 correlation 查询并明确金额权威来自钱包流水及业务表。【主Query边界资金多源投影不迁移资金重算或状态修改验证余额变化、退款、充值、佣金、来源冲突和权限身份核对通过LSP 无诊断】 - [x] 9.2 交付资金调查时间线,按店铺、钱包、订单、支付、退款、充值、审批、交易号、操作者、时间和 correlation 查询并明确金额权威来自钱包流水及业务表。【主Query边界资金多源投影不迁移资金重算或状态修改验证余额变化、退款、充值、佣金、来源冲突和权限身份核对通过LSP 无诊断】
- [ ] 9.3 交付风险 overview/events聚合高风险、资金、安全、失败、拒绝、partial 和 unknown支持跳转事件、资源、actor 和 correlation不建设处置工单或自动封禁。【主Query边界固定风险调查视角不迁移风控决策系统验证计数/趋势、低风险排除、跳转和时间范围性能核对通过LSP 无诊断】 - [x] 9.3 交付风险 overview/events聚合高风险、资金、安全、失败、拒绝、partial 和 unknown支持跳转事件、资源、actor 和 correlation不建设处置工单或自动封禁。【主Query边界固定风险调查视角不迁移风控决策系统验证计数/趋势、低风险排除、跳转和时间范围性能核对通过LSP 无诊断】
- [ ] 9.4 为事件、资源、actor、action/result/risk、scope、request、correlation、parent 及跨视角常用过滤补最小索引,使用先分页 ID 后批量投影避免 N+1不为第一阶段增加 JSONB 任意模糊搜索或 Redis 结果缓存。【主Query/Infrastructure + Migration边界已确认查询路径不迁移数据库月分区与冷热联合查询验证迁移 up/down、EXPLAIN/基准、数据库 <50ms、API P95/P99 和并发分页核对通过LSP 无诊断】 - [x] 9.4 为事件、资源、actor、action/result/risk、scope、request、correlation、parent 及跨视角常用过滤补最小索引,使用先分页 ID 后批量投影避免 N+1不为第一阶段增加 JSONB 任意模糊搜索或 Redis 结果缓存。【主Query/Infrastructure + Migration边界已确认查询路径不迁移数据库月分区与冷热联合查询验证迁移 up/down、EXPLAIN/基准、数据库 <50ms、API P95/P99 和并发分页核对通过LSP 无诊断】
- [ ] 9.5 完成跨视角 Handler/DTO/RouteSpec、生产装配、共享文档 Handler、`cmd/api/docs.go``cmd/gendocs/main.go` 和中文功能/API 文档,前端契约必须逐行呈现 design 8.1-8.4 的源页面、前置接口、`response.data` 字段、入口名称/可见条件、目标接口、参数映射和降级行为,不得只罗列审计 API。【主API/Documentation边界9.1-9.3 查询接口|不迁移:前端页面实现|验证:以资产详情、订单、退款、钱包、通知、风险节点逐条演示完整调用链,两条 OpenAPI、README 索引和构建检查 PASSLSP 无诊断】 - [x] 9.5 完成跨视角 Handler/DTO/RouteSpec、生产装配、共享文档 Handler、`cmd/api/docs.go``cmd/gendocs/main.go` 和中文功能/API 文档,前端契约必须逐行呈现 design 8.1-8.4 的源页面、前置接口、`response.data` 字段、入口名称/可见条件、目标接口、参数映射和降级行为,不得只罗列审计 API。【主API/Documentation边界9.1-9.3 查询接口|不迁移:前端页面实现|验证:以资产详情、订单、退款、钱包、通知、风险节点逐条演示完整调用链,两条 OpenAPI、README 索引和构建检查 PASSLSP 无诊断】
## 10. 每日冷归档与月初受控清理 ## 10. 每日冷归档与月初受控清理
- [ ] 10.1 交付 Audit Event + Event Resource 每日归档纵向切片:新增无外键 `tb_log_archive_run`、按 `Asia/Shanghai` 前一完整自然日读取、事件携带完整 resources 的 JSONL+gzip、manifest/SHA-256、对象 metadata 复核、稳定对象 Key 与重复任务幂等;复用现有对象存储和 Asynq不阻塞业务 Writer。【主Infrastructure + Application/Asynq边界Audit 每日冷归档完整闭环不迁移Integration、Access、清理与归档查询验证迁移 up/down、空日/大日、事件资源计数、对象损坏、重复投递、存储故障和业务写入隔离核对通过LSP 无诊断】 - [x] 10.1 交付 Audit Event + Event Resource 每日归档纵向切片:新增无外键 `tb_log_archive_run`、按 `Asia/Shanghai` 前一完整自然日读取、事件携带完整 resources 的 JSONL+gzip、manifest/SHA-256、对象 metadata 复核、稳定对象 Key 与重复任务幂等;复用现有对象存储和 Asynq不阻塞业务 Writer。【主Infrastructure + Application/Asynq边界Audit 每日冷归档完整闭环不迁移Integration、Access、清理与归档查询验证迁移 up/down、空日/大日、事件资源计数、对象损坏、重复投递、存储故障和业务写入隔离核对通过LSP 无诊断】
- [ ] 10.2 交付 Integration Log 每日归档及月度最终 revision 纵向切片:每日保存创建日快照,月初按数据库当前内容复核可变记录,内容变化时创建不可变新 revisionpending 或不一致阻止清理不覆盖旧对象。【主Infrastructure + Application/Asynq边界Integration 冷归档最终性不迁移Integration Writer 语义、恢复和对象存储查询验证pending→success/unknown、跨月更新、hash 变化、重复复核、无法终结和对象版本核对通过LSP 无诊断】 - [x] 10.2 交付 Integration Log 每日归档及月度最终 revision 纵向切片:每日保存创建日快照,月初按数据库当前内容复核可变记录,内容变化时创建不可变新 revisionpending 或不一致阻止清理不覆盖旧对象。【主Infrastructure + Application/Asynq边界Integration 冷归档最终性不迁移Integration Writer 语义、恢复和对象存储查询验证pending→success/unknown、跨月更新、hash 变化、重复复核、无法终结和对象版本核对通过LSP 无诊断】
- [ ] 10.3 交付月初整月数据库物理删除纵向切片:先完成上月最后一天归档,再校验全部日期的 Audit/Integration manifest、Audit 事件与资源数、Integration 最终 revision、对象大小和 SHA-256通过后使用 GORM、索引和有界批次按先 Event Resource 后 Event、再 Integration 的受控流程物理 `DELETE` PostgreSQL 上月数据,任务可从 ledger 断点继续,并写当前月 `retention_worker` Audit Event审计 Model 不含 `gorm.DeletedAt`不写归档状态、不迁移数据库历史表、不删除对象存储备份。【主Application + Infrastructure/Asynq边界归档门禁与 PostgreSQL 上月数据物理删除不迁移Access Log、Domain Ledger、Outbox、旧 operation log、对象存储生命周期、分区改造和人工清表验证缺日/hash 不一致/部分对象/pending 全部阻断物理行数归零、批次中断续跑、窗口隔离、对象长期保留、清理事件和数据库膨胀指标核对通过LSP 无诊断】 - [x] 10.3 交付月初整月数据库物理删除纵向切片:先完成上月最后一天归档,再校验全部日期的 Audit/Integration manifest、Audit 事件与资源数、Integration 最终 revision、对象大小和 SHA-256通过后使用 GORM、索引和有界批次按先 Event Resource 后 Event、再 Integration 的受控流程物理 `DELETE` PostgreSQL 上月数据,任务可从 ledger 断点继续,并写当前月 `retention_worker` Audit Event审计 Model 不含 `gorm.DeletedAt`不写归档状态、不迁移数据库历史表、不删除对象存储备份。【主Application + Infrastructure/Asynq边界归档门禁与 PostgreSQL 上月数据物理删除不迁移Access Log、Domain Ledger、Outbox、旧 operation log、对象存储生命周期、分区改造和人工清表验证缺日/hash 不一致/部分对象/pending 全部阻断物理行数归零、批次中断续跑、窗口隔离、对象长期保留、清理事件和数据库膨胀指标核对通过LSP 无诊断】
- [ ] 10.4 为平台 Audit/Integration 及代理/企业活动 DTO 增加 `retention{online_from,archived_before,timezone}`显式时间范围早于或跨越在线边界时返回稳定已归档错误默认只查在线窗口ID-only 不存在仍按资源不存在处理不访问对象存储也不新增下载或恢复路由。【主Query/API边界在线查询留存语义不迁移冷热联合查询和前端页面实现验证在线、已归档、跨边界、默认范围、空在线月、路由/OpenAPI 无归档读写能力核对通过LSP 无诊断】 - [ ] 10.4 为平台 Audit/Integration 及代理/企业活动 DTO 增加 `retention{online_from,archived_before,timezone}`显式时间范围早于或跨越在线边界时返回稳定已归档错误默认只查在线窗口ID-only 不存在仍按资源不存在处理不访问对象存储也不新增下载或恢复路由。【主Query/API边界在线查询留存语义不迁移冷热联合查询和前端页面实现验证在线、已归档、跨边界、默认范围、空在线月、路由/OpenAPI 无归档读写能力核对通过LSP 无诊断】
- [ ] 10.5 先以“只归档、不清理”开关运行并验收一个完整自然月记录每日成功率、积压、对象大小、压缩率、hash/计数差异、Integration revision、清理预估耗时和告警只有月度演练全部通过后才启用清理开关。【主Release/Observability边界留存能力灰度启用不迁移Access Log、自动恢复和分区改造验证完整月 manifest、故障重试、月度 dry-run、监控阈值、操作手册和启停回滚证据齐全】 - [ ] 10.5 先以“只归档、不清理”开关运行并验收一个完整自然月记录每日成功率、积压、对象大小、压缩率、hash/计数差异、Integration revision、清理预估耗时和告警只有月度演练全部通过后才启用清理开关。【主Release/Observability边界留存能力灰度启用不迁移Access Log、自动恢复和分区改造验证完整月 manifest、故障重试、月度 dry-run、监控阈值、操作手册和启停回滚证据齐全】

View File

@@ -1,5 +1,63 @@
package constants package constants
import "time"
const (
// AuditRiskQueryMaxRange 是风险调查允许的最大连续时间范围。
AuditRiskQueryMaxRange = 31 * 24 * time.Hour
// AuditRiskHourlyTrendMaxRange 是风险趋势使用小时粒度的最大时间范围。
AuditRiskHourlyTrendMaxRange = 72 * time.Hour
// AuditRiskSignalHighRisk 表示高风险或严重风险信号。
AuditRiskSignalHighRisk = "high_risk"
// AuditRiskSignalFinance 表示涉及资金资源的风险信号。
AuditRiskSignalFinance = "finance"
// AuditRiskSignalSecurity 表示安全类别风险信号。
AuditRiskSignalSecurity = "security"
// AuditRiskSignalFailed 表示执行失败信号。
AuditRiskSignalFailed = "failed"
// AuditRiskSignalDenied 表示业务或权限拒绝信号。
AuditRiskSignalDenied = "denied"
// AuditRiskSignalPartial 表示部分成功信号。
AuditRiskSignalPartial = "partial"
// AuditRiskSignalUnknown 表示结果未知信号。
AuditRiskSignalUnknown = "unknown"
)
const (
// AuditRecordSourceAuditEvent 表示统一内部审计事件节点。
AuditRecordSourceAuditEvent = "audit_event"
// AuditRecordSourceIntegrationLog 表示外部交互日志节点。
AuditRecordSourceIntegrationLog = "integration_log"
// AuditRecordSourceOutboxEvent 表示可靠事件投递摘要节点。
AuditRecordSourceOutboxEvent = "outbox_event"
// AuditRecordSourceAsynqTask 表示具有持久化任务资源的异步任务摘要节点。
AuditRecordSourceAsynqTask = "asynq_task"
// AuditRecordSourceDomainLedgerRef 表示业务权威表的稳定引用节点。
AuditRecordSourceDomainLedgerRef = "domain_ledger_ref"
// AuditRecordSourceAgentWalletTransaction 表示代理钱包权威流水节点。
AuditRecordSourceAgentWalletTransaction = "agent_wallet_transaction"
// AuditRecordSourceAssetWalletTransaction 表示资产钱包权威流水节点。
AuditRecordSourceAssetWalletTransaction = "asset_wallet_transaction"
// AuditRecordSourceAgentWalletReservation 表示代理钱包预占事实节点。
AuditRecordSourceAgentWalletReservation = "agent_wallet_reservation"
// AuditRecordSourceOrder 表示订单业务事实节点。
AuditRecordSourceOrder = "order"
// AuditRecordSourcePayment 表示支付业务事实节点。
AuditRecordSourcePayment = "payment"
// AuditRecordSourceRefund 表示退款业务事实节点。
AuditRecordSourceRefund = "refund"
// AuditRecordSourceAgentRecharge 表示代理充值业务事实节点。
AuditRecordSourceAgentRecharge = "agent_recharge"
// AuditRecordSourceRechargeOrder 表示个人资产充值业务事实节点。
AuditRecordSourceRechargeOrder = "recharge_order"
// AuditRecordSourceCommissionRecord 表示佣金业务事实节点。
AuditRecordSourceCommissionRecord = "commission_record"
// AuditRecordSourceCommissionWithdrawal 表示佣金提现业务事实节点。
AuditRecordSourceCommissionWithdrawal = "commission_withdrawal"
// AuditRecordSourceApprovalInstance 表示审批业务事实节点。
AuditRecordSourceApprovalInstance = "approval_instance"
)
const ( const (
// AuditActionAccountCreated 表示创建账号。 // AuditActionAccountCreated 表示创建账号。
AuditActionAccountCreated = "account.created" AuditActionAccountCreated = "account.created"
@@ -105,6 +163,12 @@ const (
AuditActionIotCardManualRefreshed = "iot_card.manual_refresh" AuditActionIotCardManualRefreshed = "iot_card.manual_refresh"
// AuditActionIotCardPersonalRefreshed 表示个人客户刷新名下 IoT 卡并同步实际变化的内部事实。 // AuditActionIotCardPersonalRefreshed 表示个人客户刷新名下 IoT 卡并同步实际变化的内部事实。
AuditActionIotCardPersonalRefreshed = "iot_card.personal_refresh" AuditActionIotCardPersonalRefreshed = "iot_card.personal_refresh"
// AuditActionIotCardWorkerRealnameSynced 表示 Worker 观测并同步 IoT 卡实名事实。
AuditActionIotCardWorkerRealnameSynced = "iot_card.worker_realname_sync"
// AuditActionIotCardWorkerTrafficSynced 表示 Worker 观测并同步 IoT 卡流量事实。
AuditActionIotCardWorkerTrafficSynced = "iot_card.worker_traffic_sync"
// AuditActionIotCardWorkerNetworkSynced 表示 Worker 观测并同步 IoT 卡网络事实。
AuditActionIotCardWorkerNetworkSynced = "iot_card.worker_network_sync"
// AuditActionIotCardManualStopped 表示人工停用 IoT 卡网络。 // AuditActionIotCardManualStopped 表示人工停用 IoT 卡网络。
AuditActionIotCardManualStopped = "iot_card.manual_stop" AuditActionIotCardManualStopped = "iot_card.manual_stop"
// AuditActionIotCardManualStarted 表示人工恢复 IoT 卡网络。 // AuditActionIotCardManualStarted 表示人工恢复 IoT 卡网络。
@@ -155,6 +219,8 @@ const (
AuditActionDeviceCardUnbound = "device.unbind_card" AuditActionDeviceCardUnbound = "device.unbind_card"
// AuditActionDeviceCurrentCardSwitched 表示切换设备当前使用的 IoT 卡。 // AuditActionDeviceCurrentCardSwitched 表示切换设备当前使用的 IoT 卡。
AuditActionDeviceCurrentCardSwitched = "device.switch_current_card" AuditActionDeviceCurrentCardSwitched = "device.switch_current_card"
// AuditActionDeviceWorkerObservationSynced 表示 Worker 根据 Gateway 观测同步设备及当前卡槽事实。
AuditActionDeviceWorkerObservationSynced = "device.worker_observation_sync"
// AuditActionCardExchangeCreated 表示创建卡换货单。 // AuditActionCardExchangeCreated 表示创建卡换货单。
AuditActionCardExchangeCreated = "exchange.card.create" AuditActionCardExchangeCreated = "exchange.card.create"
// AuditActionCardExchangeShippingInfoSubmitted 表示个人客户提交卡换货收货信息。 // AuditActionCardExchangeShippingInfoSubmitted 表示个人客户提交卡换货收货信息。
@@ -369,6 +435,8 @@ const (
AuditActionNotificationCleanup = "notification.cleanup" AuditActionNotificationCleanup = "notification.cleanup"
// AuditActionNotificationCleanupItem 表示系统清理单条过期通知。 // AuditActionNotificationCleanupItem 表示系统清理单条过期通知。
AuditActionNotificationCleanupItem = "notification.cleanup_item" AuditActionNotificationCleanupItem = "notification.cleanup_item"
// AuditActionLogRetentionCleanup 表示留存任务物理清理已归档在线日志。
AuditActionLogRetentionCleanup = "audit.retention_cleanup"
// AuditActionPollingConfigCreated 表示创建轮询配置。 // AuditActionPollingConfigCreated 表示创建轮询配置。
AuditActionPollingConfigCreated = "polling_config.create" AuditActionPollingConfigCreated = "polling_config.create"
// AuditActionPollingConfigUpdated 表示更新轮询配置。 // AuditActionPollingConfigUpdated 表示更新轮询配置。
@@ -466,6 +534,8 @@ const (
AuditResourceOutboxEvent = "outbox_event" AuditResourceOutboxEvent = "outbox_event"
// AuditResourceIntegrationLog 表示外部集成日志资源。 // AuditResourceIntegrationLog 表示外部集成日志资源。
AuditResourceIntegrationLog = "integration_log" AuditResourceIntegrationLog = "integration_log"
// AuditResourceLogArchiveMonth 表示已归档日志自然月。
AuditResourceLogArchiveMonth = "log_archive_month"
// AuditResourceDeviceBatchTask 表示设备批量分配任务资源。 // AuditResourceDeviceBatchTask 表示设备批量分配任务资源。
AuditResourceDeviceBatchTask = "device_batch_task" AuditResourceDeviceBatchTask = "device_batch_task"
// AuditResourceIotCardImportTask 表示 IoT 卡导入任务资源。 // AuditResourceIotCardImportTask 表示 IoT 卡导入任务资源。
@@ -598,10 +668,14 @@ const (
AuditResourceRoleApprovalIntegration = "approval_integration" AuditResourceRoleApprovalIntegration = "approval_integration"
// AuditResourceRoleCallbackIntegration 表示外部回调对应的集成交互事实。 // AuditResourceRoleCallbackIntegration 表示外部回调对应的集成交互事实。
AuditResourceRoleCallbackIntegration = "callback_integration" AuditResourceRoleCallbackIntegration = "callback_integration"
// AuditResourceRoleWorkerIntegration 表示 Worker 外部观测对应的集成交互事实。
AuditResourceRoleWorkerIntegration = "worker_integration"
// AuditResourceRoleApprovalOutbox 表示审批链路对应的可靠 Outbox 事实。 // AuditResourceRoleApprovalOutbox 表示审批链路对应的可靠 Outbox 事实。
AuditResourceRoleApprovalOutbox = "approval_outbox" AuditResourceRoleApprovalOutbox = "approval_outbox"
// AuditResourceRoleBatchTask 表示批量根事件的任务资源。 // AuditResourceRoleBatchTask 表示批量根事件的任务资源。
AuditResourceRoleBatchTask = "batch_task" AuditResourceRoleBatchTask = "batch_task"
// AuditResourceRoleRetentionMonth 表示留存清理目标自然月。
AuditResourceRoleRetentionMonth = "retention_month"
// AuditResourceRoleBatchItem 表示批量子事件的单项资源。 // AuditResourceRoleBatchItem 表示批量子事件的单项资源。
AuditResourceRoleBatchItem = "batch_item" AuditResourceRoleBatchItem = "batch_item"
// AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。 // AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。
@@ -897,6 +971,8 @@ const (
AuditActorIDRefundCommissionPostProcessing = "refund_commission_post_processing" AuditActorIDRefundCommissionPostProcessing = "refund_commission_post_processing"
// AuditActorIDCommissionCalculationWorker 表示订单佣金计算任务。 // AuditActorIDCommissionCalculationWorker 表示订单佣金计算任务。
AuditActorIDCommissionCalculationWorker = "commission_calculation_worker" AuditActorIDCommissionCalculationWorker = "commission_calculation_worker"
// AuditActorIDRetentionWorker 表示日志留存清理任务。
AuditActorIDRetentionWorker = "retention_worker"
// AuditSourceAdminAPI 表示后台管理 API 入口。 // AuditSourceAdminAPI 表示后台管理 API 入口。
AuditSourceAdminAPI = "admin_api" AuditSourceAdminAPI = "admin_api"
// AuditSourcePersonalAPI 表示个人客户 API 入口。 // AuditSourcePersonalAPI 表示个人客户 API 入口。

View File

@@ -0,0 +1,37 @@
package constants
const (
// TaskTypeAuditDailyArchive 表示统一审计每日冷归档任务。
TaskTypeAuditDailyArchive = "audit:daily:archive"
// TaskTypeIntegrationDailyArchive 表示 Integration Log 每日冷归档任务。
TaskTypeIntegrationDailyArchive = "integration:daily:archive"
// TaskTypeIntegrationMonthlyFinalize 表示 Integration Log 月度最终版本复核任务。
TaskTypeIntegrationMonthlyFinalize = "integration:monthly:finalize"
// TaskTypeAuditMonthlyRetention 表示审计日志月度物理清理任务。
TaskTypeAuditMonthlyRetention = "audit:monthly:retention"
// AuditArchiveSource 表示 Audit Event 与 Event Resource 归档数据源。
AuditArchiveSource = "audit"
// AuditArchiveSchemaVersion 表示审计归档 JSONL 结构版本。
AuditArchiveSchemaVersion = "v1"
// AuditArchiveTimezone 表示审计归档自然日时区。
AuditArchiveTimezone = "Asia/Shanghai"
// AuditArchiveInstanceID 表示当前单库审计归档实例。
AuditArchiveInstanceID = "primary"
// IntegrationArchiveSource 表示 Integration Log 归档数据源。
IntegrationArchiveSource = "integration"
// IntegrationArchiveSchemaVersion 表示 Integration Log 归档 JSONL 结构版本。
IntegrationArchiveSchemaVersion = "v1"
// ArchiveStatusPending 表示归档任务等待执行。
ArchiveStatusPending = "pending"
// ArchiveStatusRunning 表示归档任务正在执行。
ArchiveStatusRunning = "running"
// ArchiveStatusSuccess 表示归档对象与 metadata 已复核成功。
ArchiveStatusSuccess = "success"
// ArchiveStatusFailed 表示归档任务执行失败并等待重试。
ArchiveStatusFailed = "failed"
// AuditRetentionDeleteBatchSize 表示月度物理清理单批删除上限。
AuditRetentionDeleteBatchSize = 1000
)

View File

@@ -298,6 +298,8 @@ func QueueForTaskType(taskType string) string {
return QueueDataCleanup return QueueDataCleanup
case TaskTypeDailyTrafficFlush: case TaskTypeDailyTrafficFlush:
return QueueDailyTrafficFlush return QueueDailyTrafficFlush
case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeIntegrationMonthlyFinalize, TaskTypeAuditMonthlyRetention:
return QueueDataCleanup
case TaskTypeOutboxDeliver: case TaskTypeOutboxDeliver:
return QueueOutboxDeliver return QueueOutboxDeliver
case TaskTypeCardObservationSeries: case TaskTypeCardObservationSeries:

View File

@@ -152,6 +152,7 @@ const (
ReferenceTypeCommission = "commission" // 分佣 ReferenceTypeCommission = "commission" // 分佣
ReferenceTypeWithdrawal = "withdrawal" // 提现 ReferenceTypeWithdrawal = "withdrawal" // 提现
ReferenceTypeTopup = "topup" // 充值 ReferenceTypeTopup = "topup" // 充值
ReferenceTypeRecharge = "recharge" // 个人资产充值支付
ReferenceTypeRefund = "refund" // 退款 ReferenceTypeRefund = "refund" // 退款
ReferenceTypeExchange = "exchange" // 换货 ReferenceTypeExchange = "exchange" // 换货
ReferenceTypeManualAdjustment = "manual_adjustment" // 人工余额调整 ReferenceTypeManualAdjustment = "manual_adjustment" // 人工余额调整

View File

@@ -173,6 +173,9 @@ const (
CodeWeComApplicationNotFound = 1210 // 企业微信应用配置不存在 CodeWeComApplicationNotFound = 1210 // 企业微信应用配置不存在
CodeWeComCredentialInvalid = 1211 // 企业微信加密凭据不可用 CodeWeComCredentialInvalid = 1211 // 企业微信加密凭据不可用
// 审计留存相关错误 (1220-1229)
CodeAuditDataArchived = 1220 // 查询范围已归档,当前不支持在线查询
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码 // 服务端错误 (2000-2999) -> 5xx HTTP 状态码
CodeInternalError = 2001 // 内部服务器错误 CodeInternalError = 2001 // 内部服务器错误
CodeDatabaseError = 2002 // 数据库错误 CodeDatabaseError = 2002 // 数据库错误
@@ -316,6 +319,7 @@ var allErrorCodes = []int{
CodePaymentMethodUnavailable, CodePaymentMethodUnavailable,
CodeWeComApplicationNotFound, CodeWeComApplicationNotFound,
CodeWeComCredentialInvalid, CodeWeComCredentialInvalid,
CodeAuditDataArchived,
CodeInternalError, CodeInternalError,
CodeDatabaseError, CodeDatabaseError,
CodeRedisError, CodeRedisError,
@@ -453,6 +457,7 @@ var errorMessages = map[int]string{
CodePaymentMethodUnavailable: "当前资产不支持所选支付方式", CodePaymentMethodUnavailable: "当前资产不支持所选支付方式",
CodeWeComApplicationNotFound: "企业微信应用配置不存在或已禁用", CodeWeComApplicationNotFound: "企业微信应用配置不存在或已禁用",
CodeWeComCredentialInvalid: "企业微信凭据配置无效", CodeWeComCredentialInvalid: "企业微信凭据配置无效",
CodeAuditDataArchived: "数据已归档,第一阶段不支持在线查询",
CodeInvalidCredentials: "用户名或密码错误", CodeInvalidCredentials: "用户名或密码错误",
CodeAccountLocked: "账号已锁定", CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期", CodePasswordExpired: "密码已过期",
@@ -499,6 +504,8 @@ func GetHTTPStatus(code int) int {
return 403 // Forbidden return 403 // Forbidden
case CodeNotFound: case CodeNotFound:
return 404 // Not Found return 404 // Not Found
case CodeAuditDataArchived:
return 410 // Gone
case CodeConflict, case CodeConflict,
CodeUsernameExists, CodeUsernameExists,
CodePhoneExists, CodePhoneExists,

View File

@@ -28,6 +28,7 @@ type AppError struct {
Code int // 应用错误码 Code int // 应用错误码
Message string // 错误消息 Message string // 错误消息
Err error // 底层错误(可选) Err error // 底层错误(可选)
Data any // 可安全返回的结构化错误上下文(可选)
} }
func (e *AppError) Error() string { func (e *AppError) Error() string {
@@ -59,6 +60,13 @@ func New(code int, customMsg ...string) *AppError {
} }
} }
// NewWithData 创建携带安全结构化上下文的 AppError。
func NewWithData(code int, data any, customMsg ...string) *AppError {
err := New(code, customMsg...)
err.Data = data
return err
}
// Wrap 用错误码和消息包装现有错误 // Wrap 用错误码和消息包装现有错误
// 优先使用 errorMessages 映射表中的消息,允许通过可选参数覆盖 // 优先使用 errorMessages 映射表中的消息,允许通过可选参数覆盖
// 用法: // 用法:

View File

@@ -51,6 +51,7 @@ func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
var code int var code int
var message string var message string
var httpStatus int var httpStatus int
var data any
var appErr *AppError var appErr *AppError
var fiberErr *fiber.Error var fiberErr *fiber.Error
@@ -60,6 +61,7 @@ func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
code = appErr.Code code = appErr.Code
message = appErr.Message message = appErr.Message
httpStatus = GetHTTPStatus(appErr.Code) httpStatus = GetHTTPStatus(appErr.Code)
data = appErr.Data
// 记录错误日志(包含完整上下文) // 记录错误日志(包含完整上下文)
logFields := append(errCtx.ToLogFields(), logFields := append(errCtx.ToLogFields(),
@@ -116,7 +118,7 @@ func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
// 6. 返回统一 JSON 响应 // 6. 返回统一 JSON 响应
errResp := c.Status(httpStatus).JSON(fiber.Map{ errResp := c.Status(httpStatus).JSON(fiber.Map{
"code": code, "code": code,
"data": nil, "data": data,
"msg": message, "msg": message,
"timestamp": time.Now().Format(time.RFC3339), "timestamp": time.Now().Format(time.RFC3339),
}) })

View File

@@ -59,11 +59,17 @@ func NewS3Provider(cfg *config.StorageConfig) (*S3Provider, error) {
} }
func (p *S3Provider) Upload(ctx context.Context, key string, reader io.Reader, contentType string) error { func (p *S3Provider) Upload(ctx context.Context, key string, reader io.Reader, contentType string) error {
return p.UploadWithMetadata(ctx, key, reader, contentType, nil)
}
// UploadWithMetadata 上传对象并保存用于完整性复核的 metadata。
func (p *S3Provider) UploadWithMetadata(ctx context.Context, key string, reader io.Reader, contentType string, metadata map[string]string) error {
input := &s3manager.UploadInput{ input := &s3manager.UploadInput{
Bucket: aws.String(p.bucket), Bucket: aws.String(p.bucket),
Key: aws.String(key), Key: aws.String(key),
Body: reader, Body: reader,
ContentType: aws.String(contentType), ContentType: aws.String(contentType),
Metadata: aws.StringMap(metadata),
} }
_, err := p.uploader.UploadWithContext(ctx, input) _, err := p.uploader.UploadWithContext(ctx, input)
@@ -73,6 +79,26 @@ func (p *S3Provider) Upload(ctx context.Context, key string, reader io.Reader, c
return nil return nil
} }
// Stat 读取对象大小、内容类型和 metadata。
func (p *S3Provider) Stat(ctx context.Context, key string) (*ObjectMetadata, error) {
result, err := p.client.HeadObjectWithContext(ctx, &s3.HeadObjectInput{
Bucket: aws.String(p.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("读取对象 metadata 失败: %w", err)
}
metadata := make(map[string]string, len(result.Metadata))
for name, value := range result.Metadata {
metadata[strings.ToLower(name)] = aws.StringValue(value)
}
return &ObjectMetadata{
Size: aws.Int64Value(result.ContentLength),
ContentType: aws.StringValue(result.ContentType),
Metadata: metadata,
}, nil
}
func (p *S3Provider) Download(ctx context.Context, key string) (io.ReadCloser, error) { func (p *S3Provider) Download(ctx context.Context, key string) (io.ReadCloser, error) {
input := &s3.GetObjectInput{ input := &s3.GetObjectInput{
Bucket: aws.String(p.bucket), Bucket: aws.String(p.bucket),

View File

@@ -8,6 +8,8 @@ import (
type Provider interface { type Provider interface {
Upload(ctx context.Context, key string, reader io.Reader, contentType string) error Upload(ctx context.Context, key string, reader io.Reader, contentType string) error
UploadWithMetadata(ctx context.Context, key string, reader io.Reader, contentType string, metadata map[string]string) error
Stat(ctx context.Context, key string) (*ObjectMetadata, error)
Download(ctx context.Context, key string) (io.ReadCloser, error) Download(ctx context.Context, key string) (io.ReadCloser, error)
DownloadToTemp(ctx context.Context, key string) (localPath string, cleanup func(), err error) DownloadToTemp(ctx context.Context, key string) (localPath string, cleanup func(), err error)
Delete(ctx context.Context, key string) error Delete(ctx context.Context, key string) error
@@ -15,3 +17,10 @@ type Provider interface {
GetUploadURL(ctx context.Context, key string, contentType string, expires time.Duration) (string, error) GetUploadURL(ctx context.Context, key string, contentType string, expires time.Duration) (string, error)
GetDownloadURL(ctx context.Context, key string, expires time.Duration) (string, error) GetDownloadURL(ctx context.Context, key string, expires time.Duration) (string, error)
} }
// ObjectMetadata 是对象存储返回的受控对象属性。
type ObjectMetadata struct {
Size int64
ContentType string
Metadata map[string]string
}

View File

@@ -0,0 +1,91 @@
order_no
ORD20260803110607615595
ORD20260803110608277358
ORD20260803110609298852
ORD20260803110609993771
ORD20260803110610862222
ORD20260803110611648789
ORD20260803110612391555
ORD20260803110613360237
ORD20260803110614033440
ORD20260803110615619499
ORD20260803110616321048
ORD20260803110616305693
ORD20260803110617261387
ORD20260803110618333268
ORD20260803110619282396
ORD20260803110620338587
ORD20260803110620648002
ORD20260803110621100796
ORD20260803110621377665
ORD20260803110622985501
ORD20260803110623744573
ORD20260803110624555820
ORD20260803110625257789
ORD20260803110626061375
ORD20260803110626383282
ORD20260803110628343084
ORD20260803110630203516
ORD20260803110630817942
ORD20260803110631500070
ORD20260803110632923546
ORD20260803110633703072
ORD20260803110634423001
ORD20260803110635743929
ORD20260803110635838650
ORD20260803110636164609
ORD20260803110637042987
ORD20260803110638462120
ORD20260803110638820678
ORD20260803110639694789
ORD20260803110639666282
ORD20260803110640131286
ORD20260803110640764346
ORD20260803110641546312
ORD20260803110641801253
ORD20260803110642118405
ORD20260803110643965799
ORD20260803110644712849
ORD20260803110645045631
ORD20260803110645785900
ORD20260803110646330720
ORD20260803110857032891
ORD20260803110717724848
ORD20260803110717967630
ORD20260803110718644411
ORD20260803110719642078
ORD20260803110720131946
ORD20260803110720239975
ORD20260803110721523329
ORD20260803110721864337
ORD20260803110722867379
ORD20260803110722886903
ORD20260803110723862079
ORD20260803110724441746
ORD20260803110724799240
ORD20260803110725852929
ORD20260803110725604711
ORD20260803110726473340
ORD20260803110726008729
ORD20260803110727813949
ORD20260803110727095324
ORD20260803110728413520
ORD20260803110728903686
ORD20260803110729965364
ORD20260803110729067178
ORD20260803110730751445
ORD20260803110731653883
ORD20260803110731038843
ORD20260803110732876903
ORD20260803110732344625
ORD20260803110733592658
ORD20260803110734238582
ORD20260803110734200187
ORD20260803110736908880
ORD20260803110737672069
ORD20260803110738546467
ORD20260803110738284338
ORD20260803110739256634
ORD20260803110740462025
ORD20260803110740132007
ORD20260803110741839255
1 order_no
2 ORD20260803110607615595
3 ORD20260803110608277358
4 ORD20260803110609298852
5 ORD20260803110609993771
6 ORD20260803110610862222
7 ORD20260803110611648789
8 ORD20260803110612391555
9 ORD20260803110613360237
10 ORD20260803110614033440
11 ORD20260803110615619499
12 ORD20260803110616321048
13 ORD20260803110616305693
14 ORD20260803110617261387
15 ORD20260803110618333268
16 ORD20260803110619282396
17 ORD20260803110620338587
18 ORD20260803110620648002
19 ORD20260803110621100796
20 ORD20260803110621377665
21 ORD20260803110622985501
22 ORD20260803110623744573
23 ORD20260803110624555820
24 ORD20260803110625257789
25 ORD20260803110626061375
26 ORD20260803110626383282
27 ORD20260803110628343084
28 ORD20260803110630203516
29 ORD20260803110630817942
30 ORD20260803110631500070
31 ORD20260803110632923546
32 ORD20260803110633703072
33 ORD20260803110634423001
34 ORD20260803110635743929
35 ORD20260803110635838650
36 ORD20260803110636164609
37 ORD20260803110637042987
38 ORD20260803110638462120
39 ORD20260803110638820678
40 ORD20260803110639694789
41 ORD20260803110639666282
42 ORD20260803110640131286
43 ORD20260803110640764346
44 ORD20260803110641546312
45 ORD20260803110641801253
46 ORD20260803110642118405
47 ORD20260803110643965799
48 ORD20260803110644712849
49 ORD20260803110645045631
50 ORD20260803110645785900
51 ORD20260803110646330720
52 ORD20260803110857032891
53 ORD20260803110717724848
54 ORD20260803110717967630
55 ORD20260803110718644411
56 ORD20260803110719642078
57 ORD20260803110720131946
58 ORD20260803110720239975
59 ORD20260803110721523329
60 ORD20260803110721864337
61 ORD20260803110722867379
62 ORD20260803110722886903
63 ORD20260803110723862079
64 ORD20260803110724441746
65 ORD20260803110724799240
66 ORD20260803110725852929
67 ORD20260803110725604711
68 ORD20260803110726473340
69 ORD20260803110726008729
70 ORD20260803110727813949
71 ORD20260803110727095324
72 ORD20260803110728413520
73 ORD20260803110728903686
74 ORD20260803110729965364
75 ORD20260803110729067178
76 ORD20260803110730751445
77 ORD20260803110731653883
78 ORD20260803110731038843
79 ORD20260803110732876903
80 ORD20260803110732344625
81 ORD20260803110733592658
82 ORD20260803110734238582
83 ORD20260803110734200187
84 ORD20260803110736908880
85 ORD20260803110737672069
86 ORD20260803110738546467
87 ORD20260803110738284338
88 ORD20260803110739256634
89 ORD20260803110740462025
90 ORD20260803110740132007
91 ORD20260803110741839255

View File

@@ -0,0 +1,91 @@
line_no,identifier,package_usage_id,package_name,old_expires_at,new_expires_at,status,http_status,code,msg
2,8986032445201075309,34878,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
3,8986032445201075310,34879,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
4,8986032445201075311,34880,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
5,8986032445201075312,34881,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
6,8986032445201075313,34882,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
7,8986032445201075314,34883,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
8,8986032445201075315,34884,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
9,8986032445201075316,34885,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
10,8986032445201075317,34886,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
11,8986032445201075318,34887,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
12,8986032445201075319,34888,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
13,8986032445201075320,34889,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
14,8986032445201075321,34890,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
15,8986032445201075322,34891,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
16,8986032445201075323,34892,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
17,8986032445201075324,34893,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
18,8986032445201075325,34894,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
19,8986032445201075326,34895,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
20,8986032445201075327,34896,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
21,8986032445201075328,34897,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
22,8986032445201075329,34898,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
23,8986032445201075330,34899,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
24,8986032445201075331,34900,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
25,8986032445201075332,34901,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
26,8986032445201075333,34902,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
27,8986032445201075334,34903,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
28,8986032445201075335,34904,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
29,8986032445201075336,34905,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
30,8986032445201075337,34906,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
31,8986032445201075338,34907,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
32,8986032445201075339,34908,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
33,8986032445201075340,34909,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
34,8986032445201075341,34910,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
35,8986032445201075342,34911,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
36,8986032445201075343,34912,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
37,8986032445201075344,34913,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
38,8986032445201075345,34914,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
39,8986032445201075346,34915,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
40,8986032445201075347,34916,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
41,8986032445201075348,34917,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
42,8986032445201075349,34918,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
43,8986032445201075350,34919,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
44,8986032445201075351,34920,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
45,8986032445201075352,34921,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
46,8986032445201075353,34922,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
47,8986032445201075354,34923,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
48,8986032445201075355,34924,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
49,8986032445201075356,34925,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
50,8986032445201075357,34926,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
51,8986032445201075358,34927,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
52,8986032445201075359,34967,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
53,8986032445201075360,34928,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
54,8986032445201075361,34929,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
55,8986032445201075362,34930,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
56,8986032445201075363,34931,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,失败,,,请求失败:<urlopen error timed out>
57,8986032445201075364,34932,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
58,8986032445201075365,34933,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
59,8986032445201075366,34934,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
60,8986032445201075367,34935,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
61,8986032445201075368,34936,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
62,8986032445201075369,34937,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
63,8986032445201075370,34938,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
64,8986032445201075371,34939,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
65,8986032445201075372,34940,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
66,8986032445201075373,34941,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
67,8986032445201075374,34942,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
68,8986032445201075375,34943,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
69,8986032445201075376,34944,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
70,8986032445201075377,34945,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
71,8986032445201075378,34946,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
72,8986032445201075379,34947,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
73,8986032445201075380,34948,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
74,8986032445201075381,34949,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
75,8986032445201075382,34950,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
76,8986032445201075383,34951,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
77,8986032445201075384,34952,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
78,8986032445201075385,34953,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
79,8986032445201075386,34954,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
80,8986032445201075387,34955,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
81,8986032445201075388,34956,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
82,8986032445201075389,34957,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
83,8986032445201075390,34958,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
84,8986032445201075391,34959,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
85,8986032445201075392,34960,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
86,8986032445201075393,34961,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
87,8986032445201075394,34962,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
88,8986032445201075395,34963,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
89,8986032445201075396,34964,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
90,8986032445201075397,34965,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
91,8986032445201075398,34966,星网专享年卡套餐每月1G12个月,2027-08-03T23:59:59+08:00,2027-04-08 23:59:59,成功,200,0,success
1 line_no identifier package_usage_id package_name old_expires_at new_expires_at status http_status code msg
2 2 8986032445201075309 34878 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
3 3 8986032445201075310 34879 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
4 4 8986032445201075311 34880 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
5 5 8986032445201075312 34881 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
6 6 8986032445201075313 34882 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
7 7 8986032445201075314 34883 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
8 8 8986032445201075315 34884 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
9 9 8986032445201075316 34885 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
10 10 8986032445201075317 34886 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
11 11 8986032445201075318 34887 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
12 12 8986032445201075319 34888 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
13 13 8986032445201075320 34889 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
14 14 8986032445201075321 34890 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
15 15 8986032445201075322 34891 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
16 16 8986032445201075323 34892 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
17 17 8986032445201075324 34893 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
18 18 8986032445201075325 34894 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
19 19 8986032445201075326 34895 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
20 20 8986032445201075327 34896 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
21 21 8986032445201075328 34897 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
22 22 8986032445201075329 34898 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
23 23 8986032445201075330 34899 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
24 24 8986032445201075331 34900 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
25 25 8986032445201075332 34901 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
26 26 8986032445201075333 34902 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
27 27 8986032445201075334 34903 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
28 28 8986032445201075335 34904 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
29 29 8986032445201075336 34905 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
30 30 8986032445201075337 34906 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
31 31 8986032445201075338 34907 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
32 32 8986032445201075339 34908 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
33 33 8986032445201075340 34909 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
34 34 8986032445201075341 34910 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
35 35 8986032445201075342 34911 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
36 36 8986032445201075343 34912 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
37 37 8986032445201075344 34913 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
38 38 8986032445201075345 34914 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
39 39 8986032445201075346 34915 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
40 40 8986032445201075347 34916 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
41 41 8986032445201075348 34917 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
42 42 8986032445201075349 34918 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
43 43 8986032445201075350 34919 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
44 44 8986032445201075351 34920 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
45 45 8986032445201075352 34921 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
46 46 8986032445201075353 34922 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
47 47 8986032445201075354 34923 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
48 48 8986032445201075355 34924 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
49 49 8986032445201075356 34925 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
50 50 8986032445201075357 34926 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
51 51 8986032445201075358 34927 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
52 52 8986032445201075359 34967 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
53 53 8986032445201075360 34928 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
54 54 8986032445201075361 34929 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
55 55 8986032445201075362 34930 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
56 56 8986032445201075363 34931 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 失败 请求失败:<urlopen error timed out>
57 57 8986032445201075364 34932 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
58 58 8986032445201075365 34933 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
59 59 8986032445201075366 34934 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
60 60 8986032445201075367 34935 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
61 61 8986032445201075368 34936 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
62 62 8986032445201075369 34937 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
63 63 8986032445201075370 34938 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
64 64 8986032445201075371 34939 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
65 65 8986032445201075372 34940 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
66 66 8986032445201075373 34941 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
67 67 8986032445201075374 34942 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
68 68 8986032445201075375 34943 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
69 69 8986032445201075376 34944 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
70 70 8986032445201075377 34945 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
71 71 8986032445201075378 34946 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
72 72 8986032445201075379 34947 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
73 73 8986032445201075380 34948 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
74 74 8986032445201075381 34949 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
75 75 8986032445201075382 34950 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
76 76 8986032445201075383 34951 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
77 77 8986032445201075384 34952 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
78 78 8986032445201075385 34953 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
79 79 8986032445201075386 34954 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
80 80 8986032445201075387 34955 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
81 81 8986032445201075388 34956 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
82 82 8986032445201075389 34957 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
83 83 8986032445201075390 34958 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
84 84 8986032445201075391 34959 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
85 85 8986032445201075392 34960 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
86 86 8986032445201075393 34961 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
87 87 8986032445201075394 34962 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
88 88 8986032445201075395 34963 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
89 89 8986032445201075396 34964 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
90 90 8986032445201075397 34965 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success
91 91 8986032445201075398 34966 星网专享年卡套餐每月1G(12个月) 2027-08-03T23:59:59+08:00 2027-04-08 23:59:59 成功 200 0 success

View File

@@ -1,352 +1,352 @@
imei imei
862639075986135 862639075986135
862639075972853 862639075972853
862639076004532 862639076004532
862639076024787 862639076024787
862639076035379 862639076035379
862639076007840 862639076007840
862639075987521 862639075987521
862639075981524 862639075981524
862639076008442 862639076008442
862639076022815 862639076022815
862639076016668 862639076016668
862639075990475 862639075990475
862639076036872 862639076036872
862639076037805 862639076037805
862639076030503 862639076030503
862639076004870 862639076004870
862639076020678 862639076020678
862639076023888 862639076023888
862639075998429 862639075998429
862639075989949 862639075989949
862639076016726 862639076016726
862639076022922 862639076022922
862639076004284 862639076004284
862639075982696 862639075982696
862639076026915 862639076026915
862639076031998 862639076031998
862639075979866 862639075979866
862639076010695 862639076010695
862639075977365 862639075977365
862639076028531 862639076028531
862639075979502 862639075979502
862639076015843 862639076015843
862639075974404 862639075974404
862639076016171 862639076016171
862639076037201 862639076037201
862639075984924 862639075984924
862639075981144 862639075981144
862639076009861 862639076009861
862639075991663 862639075991663
862639076017195 862639076017195
862639075973158 862639075973158
862639076002122 862639076002122
862639076005844 862639076005844
862639076015421 862639076015421
862639076015462 862639076015462
862639075980880 862639075980880
862639076008434 862639076008434
862639076016932 862639076016932
862639075971343 862639075971343
862639076032764 862639076032764
862639075972119 862639075972119
862639076017609 862639076017609
862639076025933 862639076025933
862639075972887 862639075972887
862639075978769 862639075978769
862639076030594 862639076030594
862639076000373 862639076000373
862639076018862 862639076018862
862639075979213 862639075979213
862639076039637 862639076039637
862639076026485 862639076026485
862639076000480 862639076000480
862639076006040 862639076006040
862639076034802 862639076034802
862639076001504 862639076001504
862639076021890 862639076021890
862639075987182 862639075987182
862639076032012 862639076032012
862639076024902 862639076024902
862639075994386 862639075994386
862639075972770 862639075972770
862639076026840 862639076026840
862639075985533 862639075985533
862639075991515 862639075991515
862639075974115 862639075974115
862639075979155 862639075979155
862639075991853 862639075991853
862639076030073 862639076030073
862639076015876 862639076015876
862639076037821 862639076037821
862639076002403 862639076002403
862639075974560 862639075974560
862639075980245 862639075980245
862639075973539 862639075973539
862639076030537 862639076030537
862639075993883 862639075993883
862639076020249 862639076020249
862639075987778 862639075987778
862639075994683 862639075994683
862639076032251 862639076032251
862639075984882 862639075984882
862639075993693 862639075993693
862639076017427 862639076017427
862639076033853 862639076033853
862639075994998 862639075994998
862639075987331 862639075987331
862639075970535 862639075970535
862639076039165 862639076039165
862639076001363 862639076001363
862639076022450 862639076022450
862639076019886 862639076019886
862639076039686 862639076039686
862639076015082 862639076015082
862639075980781 862639075980781
862639075996506 862639075996506
862639075993313 862639075993313
862639076030479 862639076030479
862639076029760 862639076029760
862639076022591 862639076022591
862639076011024 862639076011024
862639075971079 862639075971079
862639076023672 862639076023672
862639076022716 862639076022716
862639075989683 862639075989683
862639075980211 862639075980211
862639076002395 862639076002395
862639076002221 862639076002221
862639076009085 862639076009085
862639076010448 862639076010448
862639076003500 862639076003500
862639076009036 862639076009036
862639076015579 862639076015579
862639075984262 862639075984262
862639075977233 862639075977233
862639075999187 862639075999187
862639075978546 862639075978546
862639075976300 862639075976300
862639075999732 862639075999732
862639076028481 862639076028481
862639076029810 862639076029810
862639076028697 862639076028697
862639075980492 862639075980492
862639076002064 862639076002064
862639076000209 862639076000209
862639075987422 862639075987422
862639076013905 862639076013905
862639076030099 862639076030099
862639075995953 862639075995953
862639076036781 862639076036781
862639076037177 862639076037177
862639076030784 862639076030784
862639076017500 862639076017500
862639076014754 862639076014754
862639076004219 862639076004219
862639075979247 862639075979247
862639075993354 862639075993354
862639075998460 862639075998460
862639076024969 862639076024969
862639075994923 862639075994923
862639076021833 862639076021833
862639076007337 862639076007337
862639075971327 862639075971327
862639076003203 862639076003203
862639076008897 862639076008897
862639075975641 862639075975641
862639075993701 862639075993701
862639075975625 862639075975625
862639076016353 862639076016353
862639076018425 862639076018425
862639076012089 862639076012089
862639075983595 862639075983595
862639075998254 862639075998254
862639075999369 862639075999369
862639075970543 862639075970543
862639076029968 862639076029968
862639076015769 862639076015769
862639075987448 862639075987448
862639075987505 862639075987505
862639075993164 862639075993164
862639075993289 862639075993289
862639076035692 862639076035692
862639075982175 862639075982175
862639075992166 862639075992166
862639075996118 862639075996118
862639076023557 862639076023557
862639076033168 862639076033168
862639075976730 862639075976730
862639075996324 862639075996324
862639075974677 862639075974677
862639076019241 862639076019241
862639076032731 862639076032731
862639076023607 862639076023607
862639075997702 862639075997702
862639076007014 862639076007014
862639076001231 862639076001231
862639075972507 862639075972507
862639075993230 862639075993230
862639075976490 862639075976490
862639075995862 862639075995862
862639076002353 862639076002353
862639076026741 862639076026741
862639075975500 862639075975500
862639076029281 862639076029281
862639076030180 862639076030180
862639075979510 862639075979510
862639075975245 862639075975245
862639075996928 862639075996928
862639075979270 862639075979270
862639076022658 862639076022658
862639076023474 862639076023474
862639076001629 862639076001629
862639076000282 862639076000282
862639075996159 862639075996159
862639075980310 862639075980310
862639075980070 862639075980070
862639076003633 862639076003633
862639075980047 862639075980047
862639075975765 862639075975765
862639076028655 862639076028655
862639076017971 862639076017971
862639076032327 862639076032327
862639076002841 862639076002841
862639076021478 862639076021478
862639075973430 862639075973430
862639075996589 862639075996589
862639075976888 862639075976888
862639076003815 862639076003815
862639076016429 862639076016429
862639076010315 862639076010315
862639076010901 862639076010901
862639076004391 862639076004391
862639075988305 862639075988305
862639076031949 862639076031949
862639075970949 862639075970949
862639076003898 862639076003898
862639075978587 862639075978587
862639075984007 862639075984007
862639075970220 862639075970220
862639075970816 862639075970816
862639076014580 862639076014580
862639076026725 862639076026725
862639076015074 862639076015074
862639076018961 862639076018961
862639075991499 862639075991499
862639075974834 862639075974834
862639076022302 862639076022302
862639075996423 862639075996423
862639075983371 862639075983371
862639075995441 862639075995441
862639075977829 862639075977829
862639075972267 862639075972267
862639076020447 862639076020447
862639075987398 862639075987398
862639076015231 862639076015231
862639075978314 862639075978314
862639076027897 862639076027897
862639076023045 862639076023045
862639076033846 862639076033846
862639076003104 862639076003104
862639075986663 862639075986663
862639076035890 862639076035890
862639076010067 862639076010067
862639076031071 862639076031071
862639075990061 862639075990061
862639075983462 862639075983462
862639075982480 862639075982480
862639075987497 862639075987497
862639076022955 862639076022955
862639076017997 862639076017997
862639075975922 862639075975922
862639076022484 862639076022484
862639076019340 862639076019340
862639075997066 862639075997066
862639076030214 862639076030214
862639075980344 862639075980344
862639076037359 862639076037359
862639075985558 862639075985558
862639076028986 862639076028986
862639076038134 862639076038134
862639076023896 862639076023896
862639076002635 862639076002635
862639075976458 862639075976458
862639076006529 862639076006529
862639076022252 862639076022252
862639075985954 862639075985954
862639076019894 862639076019894
862639075980864 862639075980864
862639076017179 862639076017179
862639076026279 862639076026279
862639076018607 862639076018607
862639075988354 862639075988354
862639076030966 862639076030966
862639076001736 862639076001736
862639075972259 862639075972259
862639076001611 862639076001611
862639075973927 862639075973927
862639075974909 862639075974909
862639076003872 862639076003872
862639076028465 862639076028465
862639076012642 862639076012642
862639076023078 862639076023078
862639076019290 862639076019290
862639076029596 862639076029596
862639075973950 862639075973950
862639075970808 862639075970808
862639076028275 862639076028275
862639076011669 862639076011669
862639076027061 862639076027061
862639075993321 862639075993321
862639075975732 862639075975732
862639076030982 862639076030982
862639076031881 862639076031881
862639076026675 862639076026675
862639076002551 862639076002551
862639076017716 862639076017716
862639076039264 862639076039264
862639075970832 862639075970832
862639076011321 862639076011321
862639075983132 862639075983132
862639075998270 862639075998270
862639075999302 862639075999302
862639076006727 862639076006727
862639075973307 862639075973307
862639076014721 862639076014721
862639076020991 862639076020991
862639075985897 862639075985897
862639075978462 862639075978462
862639075993222 862639075993222
862639076007832 862639076007832
862639076011339 862639076011339
862639075994378 862639075994378
862639075977597 862639075977597
862639075988271 862639075988271
862639076026709 862639076026709
862639075998072 862639075998072
862639076035940 862639076035940
862639076033895 862639076033895
862639076012659 862639076012659
862639075980534 862639075980534
862639075986614 862639075986614
862639076036435 862639076036435
862639075970725 862639075970725
862639075998296 862639075998296
862639076018953 862639076018953
862639075978843 862639075978843
862639075999609 862639075999609
862639076032640 862639076032640
862639076003674 862639076003674
862639076038704 862639076038704
862639075990657 862639075990657
862639076003930 862639076003930
862639075992315 862639075992315
862639075981029 862639075981029
862639076020603 862639076020603
862639075999385 862639075999385
862639076024183 862639076024183
862639076035932 862639076035932
862639076017823 862639076017823
862639075971855 862639075971855
862639076021668 862639076021668
862639076026600 862639076026600
1 imei
2 862639075986135
3 862639075972853
4 862639076004532
5 862639076024787
6 862639076035379
7 862639076007840
8 862639075987521
9 862639075981524
10 862639076008442
11 862639076022815
12 862639076016668
13 862639075990475
14 862639076036872
15 862639076037805
16 862639076030503
17 862639076004870
18 862639076020678
19 862639076023888
20 862639075998429
21 862639075989949
22 862639076016726
23 862639076022922
24 862639076004284
25 862639075982696
26 862639076026915
27 862639076031998
28 862639075979866
29 862639076010695
30 862639075977365
31 862639076028531
32 862639075979502
33 862639076015843
34 862639075974404
35 862639076016171
36 862639076037201
37 862639075984924
38 862639075981144
39 862639076009861
40 862639075991663
41 862639076017195
42 862639075973158
43 862639076002122
44 862639076005844
45 862639076015421
46 862639076015462
47 862639075980880
48 862639076008434
49 862639076016932
50 862639075971343
51 862639076032764
52 862639075972119
53 862639076017609
54 862639076025933
55 862639075972887
56 862639075978769
57 862639076030594
58 862639076000373
59 862639076018862
60 862639075979213
61 862639076039637
62 862639076026485
63 862639076000480
64 862639076006040
65 862639076034802
66 862639076001504
67 862639076021890
68 862639075987182
69 862639076032012
70 862639076024902
71 862639075994386
72 862639075972770
73 862639076026840
74 862639075985533
75 862639075991515
76 862639075974115
77 862639075979155
78 862639075991853
79 862639076030073
80 862639076015876
81 862639076037821
82 862639076002403
83 862639075974560
84 862639075980245
85 862639075973539
86 862639076030537
87 862639075993883
88 862639076020249
89 862639075987778
90 862639075994683
91 862639076032251
92 862639075984882
93 862639075993693
94 862639076017427
95 862639076033853
96 862639075994998
97 862639075987331
98 862639075970535
99 862639076039165
100 862639076001363
101 862639076022450
102 862639076019886
103 862639076039686
104 862639076015082
105 862639075980781
106 862639075996506
107 862639075993313
108 862639076030479
109 862639076029760
110 862639076022591
111 862639076011024
112 862639075971079
113 862639076023672
114 862639076022716
115 862639075989683
116 862639075980211
117 862639076002395
118 862639076002221
119 862639076009085
120 862639076010448
121 862639076003500
122 862639076009036
123 862639076015579
124 862639075984262
125 862639075977233
126 862639075999187
127 862639075978546
128 862639075976300
129 862639075999732
130 862639076028481
131 862639076029810
132 862639076028697
133 862639075980492
134 862639076002064
135 862639076000209
136 862639075987422
137 862639076013905
138 862639076030099
139 862639075995953
140 862639076036781
141 862639076037177
142 862639076030784
143 862639076017500
144 862639076014754
145 862639076004219
146 862639075979247
147 862639075993354
148 862639075998460
149 862639076024969
150 862639075994923
151 862639076021833
152 862639076007337
153 862639075971327
154 862639076003203
155 862639076008897
156 862639075975641
157 862639075993701
158 862639075975625
159 862639076016353
160 862639076018425
161 862639076012089
162 862639075983595
163 862639075998254
164 862639075999369
165 862639075970543
166 862639076029968
167 862639076015769
168 862639075987448
169 862639075987505
170 862639075993164
171 862639075993289
172 862639076035692
173 862639075982175
174 862639075992166
175 862639075996118
176 862639076023557
177 862639076033168
178 862639075976730
179 862639075996324
180 862639075974677
181 862639076019241
182 862639076032731
183 862639076023607
184 862639075997702
185 862639076007014
186 862639076001231
187 862639075972507
188 862639075993230
189 862639075976490
190 862639075995862
191 862639076002353
192 862639076026741
193 862639075975500
194 862639076029281
195 862639076030180
196 862639075979510
197 862639075975245
198 862639075996928
199 862639075979270
200 862639076022658
201 862639076023474
202 862639076001629
203 862639076000282
204 862639075996159
205 862639075980310
206 862639075980070
207 862639076003633
208 862639075980047
209 862639075975765
210 862639076028655
211 862639076017971
212 862639076032327
213 862639076002841
214 862639076021478
215 862639075973430
216 862639075996589
217 862639075976888
218 862639076003815
219 862639076016429
220 862639076010315
221 862639076010901
222 862639076004391
223 862639075988305
224 862639076031949
225 862639075970949
226 862639076003898
227 862639075978587
228 862639075984007
229 862639075970220
230 862639075970816
231 862639076014580
232 862639076026725
233 862639076015074
234 862639076018961
235 862639075991499
236 862639075974834
237 862639076022302
238 862639075996423
239 862639075983371
240 862639075995441
241 862639075977829
242 862639075972267
243 862639076020447
244 862639075987398
245 862639076015231
246 862639075978314
247 862639076027897
248 862639076023045
249 862639076033846
250 862639076003104
251 862639075986663
252 862639076035890
253 862639076010067
254 862639076031071
255 862639075990061
256 862639075983462
257 862639075982480
258 862639075987497
259 862639076022955
260 862639076017997
261 862639075975922
262 862639076022484
263 862639076019340
264 862639075997066
265 862639076030214
266 862639075980344
267 862639076037359
268 862639075985558
269 862639076028986
270 862639076038134
271 862639076023896
272 862639076002635
273 862639075976458
274 862639076006529
275 862639076022252
276 862639075985954
277 862639076019894
278 862639075980864
279 862639076017179
280 862639076026279
281 862639076018607
282 862639075988354
283 862639076030966
284 862639076001736
285 862639075972259
286 862639076001611
287 862639075973927
288 862639075974909
289 862639076003872
290 862639076028465
291 862639076012642
292 862639076023078
293 862639076019290
294 862639076029596
295 862639075973950
296 862639075970808
297 862639076028275
298 862639076011669
299 862639076027061
300 862639075993321
301 862639075975732
302 862639076030982
303 862639076031881
304 862639076026675
305 862639076002551
306 862639076017716
307 862639076039264
308 862639075970832
309 862639076011321
310 862639075983132
311 862639075998270
312 862639075999302
313 862639076006727
314 862639075973307
315 862639076014721
316 862639076020991
317 862639075985897
318 862639075978462
319 862639075993222
320 862639076007832
321 862639076011339
322 862639075994378
323 862639075977597
324 862639075988271
325 862639076026709
326 862639075998072
327 862639076035940
328 862639076033895
329 862639076012659
330 862639075980534
331 862639075986614
332 862639076036435
333 862639075970725
334 862639075998296
335 862639076018953
336 862639075978843
337 862639075999609
338 862639076032640
339 862639076003674
340 862639076038704
341 862639075990657
342 862639076003930
343 862639075992315
344 862639075981029
345 862639076020603
346 862639075999385
347 862639076024183
348 862639076035932
349 862639076017823
350 862639075971855
351 862639076021668
352 862639076026600