固化七月迭代审计治理进展以隔离线上热修

Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。

Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。

Confidence: medium

Scope-risk: broad

Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。

Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。

Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
This commit is contained in:
2026-08-03 09:47:22 +08:00
parent cf2ff0ac1c
commit b3499adfca
114 changed files with 16961 additions and 2782 deletions

View File

@@ -1,17 +1,56 @@
# 全系统审计覆盖基线
状态:源码扫描与自动分类已完成,待业务、研发和安全三方评审确认
状态:2026-07-29 当前源码重扫及业务语义、研发边界、安全字段三视角复核已完成;后续入口变更由静态门禁持续校验
## 可复核制品
- 显式逐入口清单:[`审计覆盖清单.json`](审计覆盖清单.json),当前共 490 项
- 扫描范围:270 个 HTTP RouteSpec、24 个 Asynq Worker、4 个定时任务、3 个 Application 公共写入口、189 个旧 Service 公共写入口
- 显式逐入口清单:[`审计覆盖清单.json`](审计覆盖清单.json),当前共 647 项;七月旧 490 项只用于遗漏比对,不再作为现行契约
- 扫描范围:326 个 HTTP RouteSpec、31 个 Asynq Worker、8 Asynq 定时任务、27 个 Application、5 个 Domain、178 个旧 Service 公共业务入口、57 个 Integration Log 调用点和 15 个旧 Writer 调用点
- 生成入口:`go run ./cmd/audit-coverage`
- 发布门禁:`go test ./internal/governance/auditcoverage`。源码新增、删除或修改入口后,若未同步更新显式清单,测试必定失败
- 每项均固定代码入口、业务所有者、中文摘要以及待评审的 Audit Event / Domain Ledger / Integration Log / Outbox 分类候选,并预留动作、风险、资源、操作者来源、事务边界、失败策略、前后数据、敏感策略和确认测试接缝。
- 发布门禁:重新执行 `go run ./cmd/audit-coverage`并通过清单差异、gopls、构建和数据库数据核对确认入口与决策同步
- 每项均固定代码入口、业务所有者、中文摘要以及待评审的 Audit Event / Domain Ledger / Integration Log / Outbox 分类候选,并预留动作、风险、资源、操作者来源、事务边界、失败策略、前后数据、敏感策略和确认核对接缝。
- 当前 `Audit Event=N/A` 候选均填写逐入口理由;评审前不能据此宣称全系统分类已经确认。
生成器只负责产生待评审候选,不能自行代表评审通过。更新清单时必须核对业务语义,不能仅运行生成命令后直接提交。
生成器只负责产生待评审候选,不能自行代表评审通过。更新清单时必须核对业务语义,不能仅运行生成命令后直接提交。本轮复核已按下述证据完成,后续新增入口仍需重新执行同样流程。
## 2026-07-29 当前入口重扫结论
### 现行矩阵与旧清单差异
- 当前逐入口矩阵固定 `code_entry/kind/owner/action/actor/resource/transaction/visibility`,并分别登记 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 理由647 项不存在空白决策字段。
- RouteSpec 从旧 270 项增至当前 316 项Worker 从 24 项增至 31 项Asynq 定时任务从 4 项增至 8 项。当前另显式登记 60 个 Integration Log 调用点和 21 个旧 Writer 调用点,避免用模块级泛化描述替代真实入口。
- 扫描器排除无 `context.Context` 的依赖注入 `SetXxx`、构造器、注册/装配函数和纯分发壳;普通 GET、购买检查和资产验证均按只读 N/A 登记。`SetSpeedTier` 因包含真实业务上下文和 Gateway 副作用保留为业务入口。
- Callback 使用 `external_system/callback` actorWorker 与消费者使用 `system_task/asynq`Scheduler 只投递任务时为 N/A真实业务变化归对应 Worker/Application。
### 敏感读取单列
| 入口 | 资源与安全决定 | 四类事实 |
|---|---|---|
| 企微应用配置 `GET /applications` | 返回 Secret、CallbackToken、EncodingAESKey返回前必须 fail-closed Audit凭据不得写入 Audit/Integration | Audit=必须Domain Ledger=只读配置Integration/Outbox=N/A |
| 资产实时状态 `GET /:identifier/realtime-status` | 设备响应含 WiFi 明文密码且实时访问 Gateway | Audit=必须Domain Ledger=只读Integration=每次尝试必须Outbox=N/A |
| IoT 卡实名链接 `GET /:iccid/realname-link` 与 C 端 `/realname/link` | 返回短期实名业务凭证 | Audit=必须Domain Ledger=只读卡事实;实际外部尝试写 IntegrationOutbox=N/A |
| 导出任务详情 `GET /export-tasks/:id` | 完成态返回预签名 `download_url` | Audit=必须导出任务是运行事实Integration=N/AOutbox=N/A |
| 批量下载/上传预签名 URL | 对象 Key 和 URL 受资源权限约束,签名 URL 不入审计正文 | Audit=必须Domain Ledger 按目标业务资源对象存储只写基础设施日志Outbox=N/A |
操作密码是否设置、实名状态、普通账号/资产/订单列表与统计均为普通受权读取Audit Event=N/AJSSDK 配置只返回客户端初始化签名,真实 token 回源由 Integration Log 记录,不把短期客户端配置误判为系统凭据读取。
### Integration Log 与旧 Writer 复核
- Integration Log 唯一 Repository 写入口为 `Start/Complete/RecordInbound/ClaimExpiredInboundPending`,当前已覆盖运营商回调、支付 H5 下单/查单/回调、卡观测、卡限速及企微 token/通讯录/模板/附件/提交/详情/回调;支付宝 WAP URL 本地签名不伪造外部尝试。
- 当前设备 Gateway、IoT 卡 Gateway、统一资产实时状态等直连调用尚未全部接入 Integration Log矩阵已逐用例登记为“必须”由后续 3.3、6.x、8.68.8 纵向切片落实,不能用旧 Asset Operation Log 代替。
- 旧 Writer 当前 15 个显式调用点仍覆盖线下充值、套餐配置、支付配置及部分资产、卡、设备、导入和轮询。Writer 自身使用裸 goroutine充值和支付配置还存在外层 goroutine形成双层异步并丢失事务/request/correlation后续切片必须逐调用归零。
### 已落地代表切片
- 受控系统配置更新使用 `system_config.updated`:成功与 `tb_system_config` 同事务,已注册只读/非法值拒绝及事务失败在业务未落地后同步写独立短事务;未注册 Key、空 Key 或无 actor 不生成无资源 Audit Event。
- Outbox 人工重放与过期租约释放分别使用 `outbox.replayed``outbox.expired_lease_released`:成功事件关联批次内全部 Outbox 资源并保存状态/租约前后值;重复恢复、有效租约等完整定位资源后的拒绝写 `denied`,业务事务或成功审计失败写 `failed`,均保持 Outbox 原状态。
- 失败/拒绝审计二次写入失败保留原业务错误,使用进程内原子计数器累计,并输出含 `severity=critical`、稳定 action/resource/request/correlation/error code 的安全日志;全链路无裸 goroutine。
### 三视角复核记录
- 业务语义:逐 RouteSpec、Application/Service、Worker/Callback/Consumer 追踪真实副作用,确认通知已读属于低风险写,购买检查/资产验证属于 N/A多资源、资金、批量和系统动作未按名称一刀切。
- 研发边界:核对 GORM 事务、Domain Ledger、Integration Repository、Outbox consumer/relay、旧 Writer 调用链Setter、注册器、纯查询和分发壳已剔除。
- 安全字段单列企微明文凭据、WiFi 密码、实名链接、预签名 URL/导出下载;平台内部、代理/企业安全投影和 `internal_only` 可见性已逐项填写,越权与不存在保持同错。
## 分类边界
@@ -36,6 +75,7 @@
| 代理主钱包订单统一扣款 | 延期(测试环境冻结;生产前由 6.5 为资金变更补齐同事务 Audit Event | `tb_order``tb_agent_wallet``tb_agent_wallet_transaction``tb_payment` 与套餐使用记录在同一事务形成权威事实 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.debited`,为余额预警等后续消费者提供稳定事实 |
| 代理主钱包订单资金预占、释放与完成扣除 | 延期(测试环境冻结;生产前由 6.5 为冻结及终态变更补齐同事务 Audit Event | `tb_agent_wallet_reservation` 是预占金额、付款钱包与唯一终态的权威事实;钱包与完成扣除流水同事务更新 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.reservation.changed`;完成扣除同时写入 `wallet.agent_main.debited`,消费者按权威事实幂等确认 |
| 代理主钱包充值与人工调整正向入账 | 延期(测试环境冻结;生产前由 6.5 为入账补齐同事务 Audit Event | 充值/人工调整业务事实、`tb_agent_wallet` 与唯一成功流水在同一事务形成权威事实 | N/A本接缝不调用支付或审批外部系统 | 同事务写入 `wallet.agent_main.credited`,消费者按成功流水复核;支付/审批 Integration Log 由 UR#34 外部流程负责 |
| 代理在线充值支付链接创建 | 延期(测试环境冻结;创建人、店铺、金额和支付方式由充值单与支付单留痕,生产前按 6.5 复核 Audit Event | `tb_agent_recharge_record``tb_payment` 同事务保存待支付事实和收款身份快照 | 微信 v3 H5/v2 MWEB 下单每次真实外呼写 Integration Log支付宝 WAP URL 仅本地签名N/A后续查单与回调仍逐次记录 | N/A创建阶段不产生可靠异步副作用支付确认后才同事务写入钱包入账 Outbox |
| 代理订单主钱包退款回充 | 延期(测试环境冻结;生产前由 6.5 为退款资金变更补齐同事务 Audit Event | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 |
| 代理商资金概况信用投影 | 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配置更新不产生可靠异步副作用 |
@@ -59,7 +99,7 @@
| 主钱包首次跌破 100 元通知店铺业务员 | N/A由已审计资金事实派生的内部提醒不新增人工操作或敏感读取 | `tb_agent_wallet_transaction``wallet.agent_main.debited` 是余额前后值的权威事实,`tb_notification` 保存最终通知与已读状态 | N/A不调用外部系统 | 扣款事实消费者仅在 `balance_before >= 10000 && balance_after < 10000` 时同事务幂等写入明确后台账号通知 Outbox无有效业务员时正常结束 |
| 创建物流换货单并提醒关联个人客户 | N/A通知记录本身是投递事实当前 Change 不新增全局 Audit Event Writer后台创建操作继续进入 Access Log | `tb_exchange_order` 是物流换货申请及状态的权威事实,`tb_notification` 是接收人通知与已读状态的权威事实 | N/A不调用外部系统 | 换货单与每个启用关联客户的 `notification.personal_customer.direct.requested` 在同一 GORM 事务写入;事件 ID 使用换货单和客户 ID 稳定防重,消费端按事件与接收人唯一键幂等 |
| 套餐临期列表、数量与每日/手动 15/7/3 天节点提醒 | N/A列表和数量是普通受权读取手动入口仅允许超级管理员提交同一幂等扫描任务操作者进入 Access Log 和任务日志,不直接修改业务事实) | `tb_package_usage` 的计时条款快照和到期队列是预计最终到期的权威事实,`tb_notification` 保存店铺账号、平台业务员和个人账号通知及已读状态 | N/A不调用企业微信、短信、邮件或其他外部系统 | 每日或手动任务按资产、到期日和节点生成稳定事件 ID同一 GORM 事务内向店铺动态接收人写入 `notification.admin.dynamic.requested`,并向绑定个人账号写入 `notification.personal_customer.direct.requested`;列表和数量纯 Query 不产生 Outbox |
| 企业微信应用连接配置保存与明文读取 | 配置保存复用 `systemconfig.AuditWriter`,只记录应用标识、状态和 `credentials_configured=true`,不记录连接凭据;明文读取仅允许超级管理员并进入 Access Log统一敏感读取 Audit Writer 在本 Change 的治理收口任务中继续核验 | `tb_wecom_application` 是 corp_id、agent_id、应用状态及明文 Secret、回调 Token、EncodingAESKey 的权威事实;管理响应按用户确认向超级管理员返回明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) |
| 企业微信应用连接配置保存与明文读取 | 配置保存复用 `systemconfig.AuditWriter`,只记录应用标识、状态和 `credentials_configured=true`;明文列表读取仅允许超级管理员,返回前以独立短事务同步写 `wecom.application.credentials_read`审计缺失或失败均不返回结果Application 先投影无凭据结构Audit Writer 从类型上不接收 Secret、回调 Token 或 EncodingAESKey | `tb_wecom_application` 是 corp_id、agent_id、应用状态及明文 Secret、回调 Token、EncodingAESKey 的权威事实;管理响应按用户确认向超级管理员返回明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) |
| 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(corp_id, userid, name)`,不记录手机号或邮箱 | `tb_wecom_member` 是最近同步的应用可见成员选择快照,`tb_account.wecom_*` 是管理员确认后的账号绑定事实;不建立部门组织模型 | 每次真实调用应用可见成员接口均写 `tb_integration_log`,仅记录应用、根部门、成员数量、状态码和耗时,不保存 access_token 或成员列表正文 | N/A同步和绑定均为同步事务不产生必须可靠投递的提交后副作用 |
| 企业微信审批业务场景与模板控件映射 | 配置保存复用事务内 `systemconfig.AuditWriter`,记录业务类型、应用 ID、模板 ID、状态和最近校验时间不保存凭据、审批节点或审批人规则到审计数据 | `tb_wecom_approval_scene` 是两个稳定业务类型的当前模板、控件映射、模板最小快照和启用状态权威事实 | 保存前每次真实调用模板详情接口均写 `tb_integration_log`,记录应用、模板 ID、状态码、控件数量和耗时不保存 access_token 或完整外部响应 | N/A配置保存为同步事务不产生必须可靠投递的提交后副作用 |
| 企业微信默认发起人与审批提交 | 默认发起人配置复用事务内 `systemconfig.AuditWriter`记录应用、userid 和姓名快照;真实业务提交人继续保存在业务申请及通用审批实例中,不以默认成员伪造操作者 | `tb_wecom_application.default_creator_*` 是应用默认发起人当前配置,`tb_wecom_approval_context` 冻结模板、实际 creator 来源和提交状态,`tb_approval_instance.external_ref` 保存 sp_no | 每次附件上传和 applyevent 均写 `tb_integration_log`;摘要不含 Secret、access_token、media_id、附件正文或完整企微响应提交超时记 unknown 并登记时间窗批量单号/详情查询恢复策略 | 业务事务写入 `approval.submission.requested`Worker 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 |
@@ -93,6 +133,21 @@
- Writer 与 Store`internal/service/account_audit/service.go``internal/store/postgres/account_operation_log_store.go`
- 生产装配:`internal/bootstrap/services.go``internal/bootstrap/stores.go`
- 调用模块:`internal/service/account/service.go``internal/service/agent_recharge/service.go``internal/service/shop_package_batch_allocation/service.go``internal/service/wechat_config/service.go`
- 已切换入口:账号创建、基础资料更新、独立启停、软删除、管理员改密、本人改密、企微绑定及后台登录/登出已改用统一 WriterPostgreSQL 账号安全事实与审计同事务,登录/登出审计为提交后 best-effort失败不撤销 Token 也不改变原认证结果;刷新保持原单次刷新协议,不因审计重写。企微绑定旧账号日志调用已移除,其他借用旧账号日志的调用模块仍按后续任务保留。
- 角色创建/更新/启停/删除/默认信用、权限创建/更新/删除及角色权限分配/移除已切换统一 Writer角色、权限和关联变化与 Audit Event 同一 GORM 事务,批量配置不再逐项自动提交,权限快照保存 code/name 和资源级 before/after。
- 账号角色分配/移除及店铺默认角色分配/移除已切换统一 Writer账号或店铺为主要资源实际变化角色保存 ID/name/type/status 与分配前后值,关系变化与审计共用同一 GORM 事务;权限缓存在提交后 best-effort 失效Redis 失败不回滚业务。账号两处旧 operation log 写入已移除;店铺侧原本不存在旧账号日志写入。
- 店铺创建与基础资料变化已切换统一 Writer创建事件保存店铺编码、名称、上级和层级并与店铺、初始账号、默认角色及钱包初始化共用原有事务更新事件只记录实际变化的基础资料状态、业务员和 C 端登录限制留给 5.6。父子层级仍仅在创建时按既有七级规则确定,相关入口原本不存在旧账号日志写入。
- 店铺状态、业务员归属、C 端登录限制及删除已切换统一 Writer混合 PUT 按实际字段差异分别记录 `shop.enable/disable``shop.update_business_owner``shop.update_client_login_limit`,主体投影只保存安全结论;删除与账号禁用、账号资源变化及审计使用同一 GORM 事务,相关缓存在提交后 best-effort 失效。`Service.Enable/Disable` 当前无生产调用方,生产启停仍由 Update 状态字段承载;这些入口原本不存在旧账号日志写入。
- 企业创建、基础资料、状态和账号改密已切换统一 Writer分别使用 `enterprise.create``enterprise.update``enterprise.update_status``enterprise.update_password`,企业为主要资源,归属店铺为引用资源,实际企业账号为受影响资源;成功审计与企业/账号事实共用 GORM 事务,改密仅保存 `credentials_configured/state` 安全事实,不借审计改变原令牌行为。普通企业列表保持 N/A资产授权明确留给 5.8/5.9;这些入口原本不存在旧 operation log 写入。
- 企业卡授权、回收和授权备注已切换统一 Writer分别使用 `enterprise_card.allocate_cards``enterprise_card.recall_cards``enterprise_card.update_record_remark`企业为主要资源owner shop 为引用资源,实际变化的 IoT 卡和授权记录为受影响资源;授权事实与 Audit Event 共用 GORM 事务,重复有效授权不伪造变化。卡资源仅保存 `subject_result`安全结论,授权记录及备注保持 `internal_only``BatchAuthorize``RevokeAuthorizations` 无生产调用方、`AllocateCardsPreview` 为普通读取,均登记 Audit Event N/A。设备授权留给 5.9,本切片不改变卡授权有效性规则,也不引入第二套 Writer。
- 企业设备授权与回收已切换统一 Writer使用 `enterprise_device.allocate_devices``enterprise_device.recall_devices`企业为主要资源owner shop 为引用资源,实际变化的设备、设备授权、随设备处理的绑定卡及卡授权为受影响资源,实际卡槽绑定仅作为引用快照。授权创建与 Audit Event 共用 GORM 事务并锁定设备与当前卡槽绑定;回收改为事务内行锁和条件更新,按事务内真实命中项返回计数,不再调用持有独立 `db` 的 Store 方法形成伪事务。Service 边界显式拒绝空筛选和非法选取模式,参数错误不写 Audit Event企业/设备越权统一同错,零成功及并发全项冲突使用独立短事务写 `denied`。企业账号被明确拒绝,平台/代理继续复用 `CanManageEnterprise`,代理设备范围保持既有“仅本店设备”规则;设备与卡仅保存 `subject_result`,授权记录与卡槽绑定保持 `internal_only`。本切片不改变 1-4 卡槽绑定规则,不停止设备或卡的旧资产 Writer对应后续 6.x 用例)。
- 个人客户资料、手机号与微信主体已切换统一 Writer使用 `personal_customer.update_profile``personal_customer.bind_phone``personal_customer.change_phone``personal_customer.update_wechat_identity`,个人客户为主要资源,实际手机号或 OpenID 关系为受影响资源;资料更新、手机号绑定/换绑、客户或 OpenID 实际创建/同步与 Audit Event 共用 GORM 事务已识别客户后的业务拒绝或失败使用独立短事务。actor/source 固定为真实 `personal_customer/personal_api`,主体投影为 Registry 白名单约束的 `subject_detail`验证码、JWT、Cookie 和 Redis Token 不进入审计。重复微信登录且资料/OpenID 无变化不写资料事件,普通 `GetProfile`、资产令牌签发、登录 Token 签发与读取保持 N/A客户资产绑定仍留给 5.11,登录链路不会伪造该任务已迁移。
### 与审计接入分开保留的独立修复
- 企业、企业卡和企业设备的权限校验、空筛选防全量、批量边界、越权同错及企业设备 TOCTOU/真实命中计数作为独立安全与并发修复保留,不视为审计所需的业务重构。
- 手机号绑定/换绑的行锁和事务内二次复检、账号状态与代理越权校验、管理员账号改密后撤销 Token 作为独立修复保留;企业账号改密不扩展同样的 Token 行为。
- 角色/权限变化和店铺删除后的权限缓存清理能力作为独立修复保留,但只在数据库提交后 best-effort 执行,不让 Redis 失败反向回滚业务事实。
- 已确认裸 goroutine 调用:`internal/service/agent_recharge/service.go` 1 处,`internal/service/wechat_config/service.go` 5 处Writer 自身另启 goroutine。
- 迁移责任04、07、0819 号票验证生产装配和直接旧表写入归零。
@@ -112,10 +167,10 @@
## 评审门禁
以下确认尚未由本地代码执行替代00 号票在三方评审前不得标记完成
本轮已按当前代码完成以下三视角复核;后续源码入口、事务、敏感字段或可见性变化会使覆盖门禁失败并要求重新复核
- 业务评审逐入口业务所有者、资源、Domain Ledger 与 N/A 理由准确,没有改变已评审业务范围。
- 研发评审:事务边界、失败策略、旧 Writer 清单、动作编码和测试接缝能由对应迁移票落地。
- 安全评审:风险等级、敏感字段策略、拒绝/失败覆盖和外部正文摘要策略完整。
评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。
后续评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。

File diff suppressed because it is too large Load Diff

View File

@@ -940,8 +940,13 @@ 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/前端建设/README.md)**:面向从零建设管理 Web、管理 H5 和独立 C 端 H5说明业务全景、前端选型、页面接口矩阵、权限模型和联调契约
- **[API 文档生成规范](docs/api-documentation-guide.md)**路由注册规范、DTO 规范、OpenAPI 文档生成流程
- **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性
- **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读)

View File

@@ -25,6 +25,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

View File

@@ -34,6 +34,7 @@ func generateAdminDocs(outputPath string) error {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
# 主体资源活动接口
代理和企业使用独立安全投影,不得调用 `/api/admin/audit/*`,也不复用平台审计 DTO。
## 接口
- 代理:`GET /api/admin/agent/resource-activities/{resource_type}/{identifier}`
- 企业:`GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}`
- 查询参数:`page` 默认 1`page_size` 默认 20、最大 100。
响应资源摘要仅包含 `resource_type/resource_id/resource_key/display_name`;活动项仅包含动作编码及中文名、`subject_summary`、Registry 白名单约束的 `subject_data`、结果、发生时间和当前主体仍有权查看的关联资源摘要。
平台操作者、内部原因和备注、风险、内部 `before/after`、Audit Event ID、Integration Log 内容及系统安全凭据均不返回。`internal_only` 事件不会产生占位记录或数量提示。
## 页面字段映射
| 主体与前置接口 | `response.data` 字段 | 目标调用 | 降级行为 |
|---|---|---|---|
| 代理卡资产详情 `GET /api/admin/assets/resolve/{identifier}` | `asset_type=card``iccid` | `/agent/resource-activities/iot_card/{iccid}` | ICCID 为空时隐藏入口 |
| 代理设备资产详情 | `asset_type=device``virtual_no` | `/agent/resource-activities/device/{virtual_no}` | VirtualNo 为空时隐藏入口 |
| 代理分配记录 | `allocation_no` | `/agent/resource-activities/asset_allocation_record/{allocation_no}` | 后端独立复核记录关联店铺和当前资产归属 |
| 代理换货单 | `exchange_no` | `/agent/resource-activities/exchange_order/{exchange_no}` | 后端按换货单当前所属店铺复核 |
| 代理店铺 | `shop_code` | `/agent/resource-activities/shop/{shop_code}` | 仅自己及下级店铺 |
| 代理归属企业 | `enterprise_code` | `/agent/resource-activities/enterprise/{enterprise_code}` | 仅 owner shop 在代理范围内 |
| 企业卡列表 `GET /api/admin/enterprises/{id}/cards` | `items[].iccid` | `/enterprise/resource-activities/iot_card/{iccid}` | 路由企业 ID 不作为授权证明 |
| 企业设备列表 `GET /api/admin/enterprises/{id}/devices` | `items[].virtual_no` | `/enterprise/resource-activities/device/{virtual_no}` | 授权撤销或字段为空时隐藏入口 |
企业不能调用统一资产 resolve 接口,因此不从该页面构造活动入口。所有 shop ID、enterprise ID 和授权范围都来自认证上下文;调用方不能通过 query、path 或 body 伪造。资源不存在、不支持、越权或授权已撤销统一返回“无权限操作该资源或资源不存在”。
`GET /api/admin/assets/{identifier}/operation-logs` 仅保留为平台切换前历史入口,不向代理或企业开放,也不把旧记录拼接到新活动接口。

View File

@@ -0,0 +1,26 @@
# 外部集成调查接口
本文冻结 `build-multi-view-audit-center` 任务 3.4 的只读接口与跳转契约。
## 接口
| 视角 | 接口 | 参数来源 |
|---|---|---|
| 总览 | `GET /api/admin/audit/integrations/overview` | 调查筛选区或关联视角携带的稳定筛选值;必须提供 RFC3339 时间范围 |
| 列表 | `GET /api/admin/audit/integrations` | 调查筛选区、通知目标或关联调查节点;默认每页 20最大 100 |
| 详情 | `GET /api/admin/audit/integrations/{integration_id}` | 列表返回的 `integration_id`、通知目标 `target_key` 或调查节点稳定引用 |
认证身份只来自认证上下文。接口仅允许超级管理员和平台账号访问,全部为 GET不提供重试、补偿、结果确认、外部单号绑定、人工恢复、修改、删除或导出能力。
## 通知跳转
点击通知时先调用 `GET /api/admin/notifications/{id}/target`。仅当响应同时满足 `available=true``target_type=integration_log``target_key` 非空时,前端才将 `target_key` 原样作为 `integration_id` 打开详情;目标不可用时只展示通知正文,不解析通知列表的 `ref_type/ref_id/ref_key` 猜测目标。
## 调查与降级规则
- provider、direction、operation、result 等筛选使用后端稳定编码,时间范围最长 31 天。
- 详情使用稳定 `integration_id`,不接受数据库自增 ID。
- attempts 只按相同非空 `trigger_series` 组织;`correlation_id` 只表示业务链路,不代表技术重试。
- 缺少稳定 ID 时隐藏跳转,不按相似资源、相近时间、中文描述或编号前缀猜测。
- 请求摘要、响应摘要和 metadata 返回前再次删除安全凭据;第三方原始错误正文不直接展示。
- 第一阶段不增加 JSONB GIN 或任意全文搜索。

View File

@@ -0,0 +1,59 @@
# 平台基础审计调查接口
本文冻结 `build-multi-view-audit-center` 任务 2.4 的接口、业务页面参数来源和降级规则。字段路径均位于统一响应的 `response.data` 下;列表字段位于 `items[]`
## 基础接口
| 视角 | 接口 | 参数来源 |
|---|---|---|
| 全局事件 | `GET /api/admin/audit/events` | 调查筛选区或其他节点携带的稳定筛选值 |
| 事件详情 | `GET /api/admin/audit/events/{event_id}` | `investigation_refs.event_id` 或人工粘贴的稳定 ID |
| 操作者 | `GET /api/admin/audit/actors/{kind}/{id}/events` | `investigation_refs.actor_ref` 或平台账号选择器 |
| 资源搜索 | `GET /api/admin/audit/resources/search` | 调查人员选择类型并输入精确业务标识 |
| 资源时间线 | `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline` | 业务响应稳定 ID、资源搜索结果或 `investigation_refs.resource_refs[]` |
认证身份和数据范围只来自认证上下文,不接受 query/path/body 伪造。接口仅允许超级管理员和平台账号访问,全部为 GET不提供导出、修改、删除、恢复或处置能力。
## 资产与组织页面映射
| 前置接口 | 稳定字段 | 平台目标 |
|---|---|---|
| 卡列表 `/iot-cards/standalone` | `id` | `iot_card/{id}` |
| 设备列表 `/devices` | `id` | `device/{id}` |
| 设备卡槽 `/devices/{virtual_no}/cards` | `bindings[].iot_card_id``bindings[].id` | `iot_card/{iot_card_id}``device_sim_binding/{id}` |
| 统一资产 `/assets/resolve/{identifier}` | `asset_type``asset_id`、绑定资产 ID | `card` 转换为 `iot_card/{asset_id}``device` 使用 `device/{asset_id}` |
| 分配列表/详情 `/asset-allocation-records[/{id}]` | `id``asset_type``asset_id``related_device_id``related_card_ids[]` | `asset_allocation_record/{id}` 及相应资产资源 ID接口已返回的 `iot_card/device` 不再转换 |
| 换货列表/详情 `/exchanges[/{id}]` | `id`、旧/新 `asset_type``asset_id` | `exchange_order/{id}` 及旧、新资产资源 ID |
| 店铺列表/详情 `/shops[/{id}]` | `id` | `shop/{id}` |
| 企业列表 `/enterprises` | `id` | `enterprise/{id}`;不得假设存在企业详情接口 |
| 企业卡列表 `/enterprises/{id}/cards` | `items[].id` | `iot_card/{id}` |
| 企业设备列表 `/enterprises/{id}/devices` | `items[].device_id` | `device/{device_id}` |
## 交易与资金页面映射
| 前置接口 | 稳定字段 | 平台目标 |
|---|---|---|
| 账号列表/详情 `/accounts[/{id}]` | `id` | `account/{id}` |
| 订单列表/详情 `/orders[/{id}]` | `id` | `order/{id}`;资金视角使用 `order_id={id}` |
| 退款列表/详情 `/refunds[/{id}]` | `id``approval_instance_id` | `refund/{id}`;资金视角使用 `refund_id={id}`;审批非空时使用 `approval_instance/{id}` |
| 代理充值列表/详情 `/agent-recharges[/{id}]` | `id` | `agent_recharge/{id}`;资金视角使用 `recharge_id={id}` |
| 代理在线充值创建结果 | `recharge_id``payment_no` | 资金视角以 `recharge_id` 为第一跳,`payment_no` 仅作额外精确筛选 |
| 资产钱包 `/assets/{identifier}/wallet` | `wallet_id``resource_type``resource_id` | 资金视角使用 `wallet_id`;资产审计使用 `{resource_type}/{resource_id}` |
| 店铺资金概况 `/shops/fund-summary` | `items[].shop_id` | 资金视角使用 `shop_id` |
| 店铺主钱包流水 | path `shop_id`、资产类型与 ID | 资金视角使用 `shop_id`;资产 ID 存在时使用对应资源时间线 |
| 资产钱包流水 | 上层 `wallet_id` | 资金视角使用 `wallet_id`;不解析业务编号前缀猜测资源 |
资金视角将在后续任务交付;当前文档只冻结其第一跳参数,缺失的支付、退款、钱包等关联由服务端 Query 解析。
## 统一调查引用
平台调查节点统一返回 `investigation_refs`:可空 `event_id`、可空 `actor_ref{kind,id}``resource_refs[]{resource_type,resource_id,resource_key,display_name}`、可空 `request_id`、可空 `correlation_id``integration_refs[]{integration_id}`。前端只使用存在的稳定引用,不解析中文名称、备注、编号前缀或相近时间推断关系。
## 降级规则
- 缺少目标接口必需的稳定 ID 或 identifier 时隐藏入口。
- 只有 Registry Key、没有内部 ID 时先精确资源搜索;零命中或多命中均不自动选择。
- 已删除资源只要节点保留 `resource_type/resource_id`,仍可查看事件快照时间线。
- 代理或企业不得回退调用平台审计、资源搜索或旧 operation log。
- 旧 operation log 保持独立历史入口,不拼接到新审计接口。
- 通知先调用 `/notifications/{id}/target`;仅 `available=true` 时按白名单目标继续跳转。

View File

@@ -39,7 +39,7 @@ debt_amount = max(-balance, 0)
## 既有店铺实际额度调整
`PUT /api/admin/shops/{id}/credit-limit` 用于调整既有店铺主钱包的实际信用额度。按当前产品决定,后端不校验 `shop:credit-limit:manage` 或账号类型;该权限编码只供前端决定是否展示按钮,能够看到按钮的账号即可调用。请求携带钱包 `version`,更新同时约束主钱包类型、版本和调整后的总可用金额,成功后版本加一;降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
`PUT /api/admin/shops/{id}/credit-limit` 用于调整既有店铺主钱包的实际信用额度。按当前产品决定,后端不校验 `shop:credit-limit:manage` 或账号类型;该权限编码只供前端决定是否展示按钮,能够看到按钮的账号即可调用。请求携带 `credit_enabled``credit_limit`;服务端读取钱包当前版本并执行乐观锁条件更新,前端不管理乐观锁。降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
## 统一订单扣款

View File

@@ -0,0 +1,223 @@
// Package accessaudit 定义账号权限与组织简单写用例的统一审计接缝。
package accessaudit
import (
"context"
stderrors "errors"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ChangeAudit 是账号权限与组织变更交给统一审计 Port 的事实。
type ChangeAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
OperatorID uint
ActorKind string
ActorName string
Source string
ScopeType string
Account *model.Account
Accounts []AccountChange
Shop *model.Shop
ParentShop *model.Shop
Enterprise *model.Enterprise
Cards []IotCardChange
CardAuthorizations []EnterpriseCardAuthorizationChange
Devices []DeviceChange
DeviceBindings []DeviceSimBindingChange
DeviceAuthorizations []EnterpriseDeviceAuthorizationChange
PersonalCustomer *model.PersonalCustomer
PersonalPhones []PersonalCustomerPhoneChange
PersonalOpenIDs []PersonalCustomerOpenIDChange
Role *model.Role
Roles []RoleChange
Permissions []PermissionChange
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// PersonalCustomerPhoneChange 保存个人客户手机号资源变化。
type PersonalCustomerPhoneChange struct {
Phone *model.PersonalCustomerPhone
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
type PersonalCustomerOpenIDChange struct {
OpenID *model.PersonalCustomerOpenID
BeforeData map[string]any
AfterData map[string]any
}
// DeviceChange 保存组织操作关联设备的资源变化与主体安全投影。
type DeviceChange struct {
Device *model.Device
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// DeviceSimBindingChange 保存企业设备授权涉及的卡槽绑定快照。
type DeviceSimBindingChange struct {
Binding *model.DeviceSimBinding
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// EnterpriseDeviceAuthorizationChange 保存企业设备授权记录的直接变化。
type EnterpriseDeviceAuthorizationChange struct {
Authorization *model.EnterpriseDeviceAuthorization
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// IotCardChange 保存组织操作关联卡的资源变化与主体安全投影。
type IotCardChange struct {
Card *model.IotCard
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// EnterpriseCardAuthorizationChange 保存企业卡授权记录的直接变化。
type EnterpriseCardAuthorizationChange struct {
Authorization *model.EnterpriseCardAuthorization
BeforeData map[string]any
AfterData map[string]any
}
// AccountChange 保存店铺操作关联账号的资源角色与直接变化。
type AccountChange struct {
Account *model.Account
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// RoleChange 保存主体授权中单个角色资源的前后变化。
type RoleChange struct {
Role *model.Role
BeforeData map[string]any
AfterData map[string]any
}
// PermissionChange 保存单个权限资源的前后变化。
type PermissionChange struct {
Permission *model.Permission
BeforeData map[string]any
AfterData map[string]any
}
// Writer 接收账号权限与组织事务内审计事实。
type Writer interface {
WriteAccessChange(context.Context, *gorm.DB, ChangeAudit) error
}
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
func RecordFailure(ctx context.Context, db *gorm.DB, writer Writer, change ChangeAudit, originalErr error) {
fillFailure(changeError(originalErr), &change)
if db == nil || writer == nil {
recordSecondaryFailure(ctx, change, apperrors.New(apperrors.CodeInvalidStatus, "统一组织审计接缝未配置"))
return
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return writer.WriteAccessChange(ctx, tx, change)
}); err != nil {
recordSecondaryFailure(ctx, change, err)
}
}
func changeError(err error) *apperrors.AppError {
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return appErr
}
return apperrors.New(apperrors.CodeInternalError, "账号权限或组织操作失败")
}
func fillFailure(appErr *apperrors.AppError, change *ChangeAudit) {
if change.Result == "" {
change.Result = constants.AuditResultFailed
}
if change.ErrorCode == "" {
change.ErrorCode = strconv.Itoa(appErr.Code)
}
if change.ErrorSummary == "" {
change.ErrorSummary = appErr.Message
}
}
func recordSecondaryFailure(ctx context.Context, change ChangeAudit, err error) {
value := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
change.ActionCode, resourceKey(change), value.RequestID, value.CorrelationID, change.ErrorCode, err,
)
}
func resourceKey(change ChangeAudit) string {
if change.Account != nil {
if change.Account.ID != 0 {
return strconv.FormatUint(uint64(change.Account.ID), 10)
}
return change.Account.Username
}
if change.Enterprise != nil {
if change.Enterprise.ID != 0 {
return strconv.FormatUint(uint64(change.Enterprise.ID), 10)
}
return change.Enterprise.EnterpriseCode
}
if change.PersonalCustomer != nil {
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
}
if change.Shop != nil {
if change.Shop.ID != 0 {
return strconv.FormatUint(uint64(change.Shop.ID), 10)
}
return change.Shop.ShopCode
}
if change.Role != nil {
if change.Role.ID != 0 {
return strconv.FormatUint(uint64(change.Role.ID), 10)
}
return change.Role.RoleName
}
for _, permission := range change.Permissions {
if permission.Permission == nil {
continue
}
if permission.Permission.ID != 0 {
return strconv.FormatUint(uint64(permission.Permission.ID), 10)
}
return permission.Permission.PermCode
}
return "unknown"
}

View File

@@ -0,0 +1,46 @@
// Package accountaudit 定义账号生命周期写入统一审计的应用边界。
package accountaudit
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
// LifecycleAudit 是账号生命周期用例提交给统一 Writer 的业务事实。
type LifecycleAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
Account *model.Account
Shop *model.Shop
Enterprise *model.Enterprise
Roles []*model.Role
BeforeData map[string]any
AfterData map[string]any
}
// SecurityAudit 是账号安全用例提交给统一 Writer 的无凭据业务事实。
type SecurityAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
ActorID uint
ActorName string
Account *model.Account
AuthenticationKey string
Authentication map[string]any
BeforeData map[string]any
AfterData map[string]any
}
// Writer 在调用方提供的事务内追加账号生命周期事件。
type Writer interface {
WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit LifecycleAudit) error
WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit SecurityAudit) error
}

View File

@@ -26,6 +26,7 @@ type ConfirmOnlinePaymentCommand struct {
PaidAt time.Time
RequestID string
CorrelationID string
ParentEventID string
}
// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。
@@ -44,6 +45,7 @@ type PaymentConfirmedEvent struct {
PaidAt time.Time `json:"paid_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。
@@ -54,6 +56,7 @@ type PaymentConfirmedEventWriter interface {
// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。
type ConfirmOnlinePaymentResult struct {
RechargeID uint
PaymentID uint
AlreadyConfirmed bool
}
@@ -92,6 +95,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
return err
}
result.RechargeID = recharge.ID
result.PaymentID = payment.ID
if alreadyConfirmed {
result.AlreadyConfirmed = true
return nil
@@ -124,6 +128,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
ParentEventID: command.ParentEventID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")

View File

@@ -4,6 +4,7 @@ package outbox
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
@@ -11,6 +12,7 @@ import (
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -33,6 +35,30 @@ type RecoveryAudit struct {
BatchID string
RequestID string
CorrelationID string
Events []RecoveryEventAudit
Result string
ErrorCode string
ErrorSummary string
}
// RecoveryEventAudit 是一次人工恢复中单个 Outbox 事件的身份和状态变化。
type RecoveryEventAudit struct {
ID uint
EventID string
EventType string
AggregateType string
AggregateID string
ResourceType string
ResourceID string
BusinessKey string
BeforeStatus int
AfterStatus int
BeforeNextAttempt time.Time
AfterNextAttempt time.Time
BeforeLeaseOwner *string
BeforeLeaseExpires *time.Time
AfterLeaseOwner *string
AfterLeaseExpires *time.Time
}
// AuditWriter 是 tech-global-audit 提供实现的统一审计接缝。
@@ -65,23 +91,38 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
}
batchID := uuid.NewString()
now := s.now().UTC()
failureAudit := RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay,
Description: "人工重放 Outbox 事件失败", Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
events, err := loadSelectedForUpdate(tx, ids)
if err != nil {
return err
}
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
if len(events) != len(ids) {
failureAudit.Events = nil
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
}
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
allowed := event.Status == constants.OutboxStatusFailed ||
(event.Status == constants.OutboxStatusDelivering && event.LeaseExpiresAt != nil && !event.LeaseExpiresAt.After(now))
if !allowed {
failureAudit.Result = constants.AuditResultDenied
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
failureAudit.ErrorSummary = "选择的事件不存在或状态不允许重放"
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
}
eventIDs = append(eventIDs, event.EventID)
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
}
failureAudit.Result = constants.AuditResultFailed
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
failureAudit.ErrorSummary = "Outbox 人工重放事务已回滚"
result := tx.Model(&model.OutboxEvent{}).Where("id IN ?", ids).Updates(map[string]any{
"status": constants.OutboxStatusPending, "next_attempt_at": now,
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
@@ -90,11 +131,14 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
return result.Error
}
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
OperatorID: operator.ID, OperationType: "outbox_replay", Description: "人工重放 Outbox 事件",
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay, Description: "人工重放 Outbox 事件",
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
})
})
if err != nil {
s.recordFailure(ctx, failureAudit)
}
return batchID, err
}
@@ -105,21 +149,36 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
}
batchID := uuid.NewString()
now := s.now().UTC()
failureAudit := RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease,
Description: "人工释放 Outbox 过期租约失败", Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
events, err := loadSelectedForUpdate(tx, ids)
if err != nil {
return err
}
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
if len(events) != len(ids) {
failureAudit.Events = nil
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
}
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
if event.Status != constants.OutboxStatusDelivering || event.LeaseExpiresAt == nil || event.LeaseExpiresAt.After(now) {
failureAudit.Result = constants.AuditResultDenied
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
failureAudit.ErrorSummary = "选择的租约不存在或仍然有效"
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
}
eventIDs = append(eventIDs, event.EventID)
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
}
failureAudit.Result = constants.AuditResultFailed
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
failureAudit.ErrorSummary = "Outbox 过期租约释放事务已回滚"
result := tx.Model(&model.OutboxEvent{}).Where("id IN ? AND status = ? AND lease_expires_at <= ?", ids, constants.OutboxStatusDelivering, now).
Updates(map[string]any{
"status": constants.OutboxStatusPending, "next_attempt_at": now,
@@ -129,14 +188,31 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
return result.Error
}
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
OperatorID: operator.ID, OperationType: "outbox_release_expired_lease", Description: "人工释放 Outbox 过期租约",
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease, Description: "人工释放 Outbox 过期租约",
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
})
})
if err != nil {
s.recordFailure(ctx, failureAudit)
}
return batchID, err
}
func (s *RecoveryService) recordFailure(ctx context.Context, audit RecoveryAudit) {
if len(audit.Events) == 0 || audit.Result == "" {
return
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRecovery(ctx, tx, audit)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(
audit.OperationType, audit.Events[0].EventID, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
)
}
}
func validateCommand(operator Operator, ids []uint, reason string) error {
if !operator.SuperAdmin {
return pkgerrors.New(pkgerrors.CodeForbidden)
@@ -152,3 +228,28 @@ func loadSelectedForUpdate(tx *gorm.DB, ids []uint) ([]model.OutboxEvent, error)
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&events).Error
return events, err
}
func recoveryEventAudit(event model.OutboxEvent, nextAttempt time.Time) RecoveryEventAudit {
return RecoveryEventAudit{
ID: event.ID, EventID: event.EventID, EventType: event.EventType,
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
BeforeStatus: event.Status, AfterStatus: constants.OutboxStatusPending,
BeforeNextAttempt: event.NextAttemptAt, AfterNextAttempt: nextAttempt,
BeforeLeaseOwner: event.LeaseOwner, BeforeLeaseExpires: event.LeaseExpiresAt,
}
}
func unchangedRecoveryAudit(events []model.OutboxEvent) ([]string, []RecoveryEventAudit) {
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
eventIDs = append(eventIDs, event.EventID)
auditEvent := recoveryEventAudit(event, event.NextAttemptAt)
auditEvent.AfterStatus = event.Status
auditEvent.AfterLeaseOwner = event.LeaseOwner
auditEvent.AfterLeaseExpires = event.LeaseExpiresAt
auditEvents = append(auditEvents, auditEvent)
}
return eventIDs, auditEvents
}

View File

@@ -3,7 +3,9 @@ package role
import (
"context"
stdErrors "errors"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -20,11 +22,12 @@ type PermissionChecker interface {
type DefaultCreditService struct {
db *gorm.DB
permissionChecker PermissionChecker
accessAudit accessauditapp.Writer
}
// NewDefaultCreditService 创建角色默认信用模板服务。
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker) *DefaultCreditService {
return &DefaultCreditService{db: db, permissionChecker: permissionChecker}
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker, accessAudit accessauditapp.Writer) *DefaultCreditService {
return &DefaultCreditService{db: db, permissionChecker: permissionChecker, accessAudit: accessAudit}
}
// Update 更新模板;该操作不扫描或修改任何既有钱包。
@@ -39,8 +42,8 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
if err := validateDefaultCredit(enabled, limit); err != nil {
return nil, err
}
var role model.Role
var beforeData map[string]any
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses().First(&role, roleID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -48,6 +51,7 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
}
return errors.Wrap(errors.CodeInternalError, err, "读取角色失败")
}
beforeData = defaultCreditAuditData(&role)
if role.RoleType != constants.RoleTypeCustomer {
return errors.New(errors.CodeInvalidParam, "只有客户角色可以配置新建代理默认信用")
}
@@ -68,14 +72,46 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
role.DefaultCreditEnabled = enabled
role.DefaultCreditLimit = limit
role.Updater = operatorID
return nil
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "角色默认信用审计接缝未配置")
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: &role, BeforeData: beforeData, AfterData: defaultCreditAuditData(&role),
})
})
if err != nil {
if role.ID != 0 {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板失败",
Result: defaultCreditAuditResult(err), OperatorID: operatorID, Role: &role, BeforeData: beforeData,
}, err)
}
return nil, err
}
return &role, nil
}
func defaultCreditAuditData(role *model.Role) map[string]any {
return map[string]any{
"role_name": role.RoleName, "role_type": role.RoleType,
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
}
}
func defaultCreditAuditResult(err error) string {
var appErr *errors.AppError
if !stdErrors.As(err, &appErr) {
return constants.AuditResultFailed
}
switch appErr.Code {
case errors.CodeInternalError, errors.CodeDatabaseError, errors.CodeRedisError:
return constants.AuditResultFailed
default:
return constants.AuditResultDenied
}
}
func (s *DefaultCreditService) authorize(ctx context.Context, operatorID uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeSuperAdmin {

View File

@@ -3,11 +3,13 @@ package shop
import (
"context"
stderrors "errors"
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -17,12 +19,13 @@ import (
// CreateService 收口平台与代理创建店铺的完整事务。
type CreateService struct {
db *gorm.DB
db *gorm.DB
audit accessauditapp.Writer
}
// NewCreateService 创建店铺创建事务脚本。
func NewCreateService(db *gorm.DB) *CreateService {
return &CreateService{db: db}
func NewCreateService(db *gorm.DB, audit accessauditapp.Writer) *CreateService {
return &CreateService{db: db, audit: audit}
}
// Create 按操作者类型执行平台显式归属或代理安全继承。
@@ -37,26 +40,26 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
case constants.UserTypeAgent:
if request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限设置店铺业务员"))
}
if request.ParentID == nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
resolver = resolveInheritedBusinessOwner
default:
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
return s.fail(ctx, request, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败"))
}
var response *dto.ShopResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
created, createErr := createShop(tx, request, operatorID, string(hashedPassword), resolver)
created, createErr := createShop(ctx, tx, request, operatorID, string(hashedPassword), resolver, s.audit)
if createErr != nil {
return createErr
}
@@ -64,14 +67,14 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
return nil
})
if err != nil {
return nil, err
return s.fail(ctx, request, err)
}
return response, nil
}
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver) (*dto.ShopResponse, error) {
func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver, audit accessauditapp.Writer) (*dto.ShopResponse, error) {
if exists, err := recordExists(tx, &model.Shop{}, "shop_code = ?", request.ShopCode); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺编号失败")
} else if exists {
@@ -157,9 +160,77 @@ func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, ha
if err := fillBusinessOwnerResponse(tx, shop, response); err != nil {
return nil, err
}
if audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺创建审计接缝未配置")
}
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺",
OperatorID: operatorID, Shop: shop, ParentShop: parent,
AfterData: shopCreationData(shop),
}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺创建审计失败")
}
if ownerID != nil {
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "设置店铺业务员归属",
OperatorID: operatorID, Shop: shop, ParentShop: parent,
Accounts: businessOwnerAuditAccounts(tx, nil, ownerID),
AfterData: map[string]any{"business_owner_account_id": ownerID},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已设置",
}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
}
}
return response, nil
}
func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest, originalErr error) (*dto.ShopResponse, error) {
shop := &model.Shop{ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID}
var parent *model.Shop
if request.ParentID != nil {
parent = &model.Shop{}
parent.ID = *request.ParentID
}
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺失败", Result: shopAuditFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, ParentShop: parent,
}, originalErr)
return nil, originalErr
}
func shopCreationData(shop *model.Shop) map[string]any {
data := shopProfileData(shop)
data["shop_code"] = shop.ShopCode
data["parent_id"] = shop.ParentID
data["level"] = shop.Level
return data
}
func shopProfileData(shop *model.Shop) map[string]any {
return map[string]any{
"shop_name": shop.ShopName, "contact_name": shop.ContactName, "contact_phone": shop.ContactPhone,
"province": shop.Province, "city": shop.City, "district": shop.District, "address": shop.Address,
}
}
func shopProfileChanged(before, after *model.Shop) bool {
return before.ShopName != after.ShopName || before.ContactName != after.ContactName ||
before.ContactPhone != after.ContactPhone || before.Province != after.Province || before.City != after.City ||
before.District != after.District || before.Address != after.Address
}
func shopAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeInvalidParentID,
errors.CodeShopLevelExceeded, errors.CodeShopCodeExists, errors.CodeUsernameExists, errors.CodePhoneExists:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
func resolveParent(tx *gorm.DB, parentID *uint) (*model.Shop, int, error) {
if parentID == nil {
return nil, 1, nil

View File

@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -15,12 +16,13 @@ import (
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
type UpdateService struct {
db *gorm.DB
db *gorm.DB
audit accessauditapp.Writer
}
// NewUpdateService 创建店铺更新事务脚本。
func NewUpdateService(db *gorm.DB) *UpdateService {
return &UpdateService{db: db}
func NewUpdateService(db *gorm.DB, audit accessauditapp.Writer) *UpdateService {
return &UpdateService{db: db, audit: audit}
}
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
@@ -50,11 +52,16 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
return nil, errors.New(errors.CodeUnauthorized)
}
var response *dto.ShopResponse
var beforeShop *model.Shop
var parentShop *model.Shop
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var shop model.Shop
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
before := shop
beforeShop = &before
parentShop = loadAuditParentShop(tx, shop.ParentID)
if request.BusinessOwnerAccountIDSet {
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
if err != nil {
@@ -88,14 +95,160 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
return err
}
if shopProfileChanged(&before, &shop) {
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "店铺更新审计接缝未配置")
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料",
OperatorID: operatorID, Shop: &shop, ParentShop: parentShop,
BeforeData: shopProfileData(&before), AfterData: shopProfileData(&shop),
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺更新审计失败")
}
}
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
return err
}
return nil
})
if err != nil {
if beforeShop != nil && requestedShopProfileChanged(beforeShop, request) {
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料失败", Result: shopAuditFailureResult(err),
OperatorID: operatorID, Shop: beforeShop, ParentShop: parentShop,
}, err)
}
s.recordStateFailures(ctx, beforeShop, parentShop, request, operatorID, err)
return nil, err
}
return response, nil
}
func (s *UpdateService) writeStateAudits(ctx context.Context, tx *gorm.DB, before, after, parent *model.Shop, operatorID uint) error {
if s.audit == nil && (before.Status != after.Status || !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) || before.ClientLoginDisabled != after.ClientLoginDisabled) {
return errors.New(errors.CodeInvalidStatus, "店铺状态审计接缝未配置")
}
if before.Status != after.Status {
action, summary, subject := constants.AuditActionShopDisabled, "禁用店铺", "店铺已禁用"
if after.Status == constants.StatusEnabled {
action, summary, subject = constants.AuditActionShopEnabled, "启用店铺", "店铺已启用"
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: action, Summary: summary, OperatorID: operatorID, Shop: after, ParentShop: parent,
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": after.Status},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺状态审计失败")
}
}
if !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) {
accounts := businessOwnerAuditAccounts(tx, before.BusinessOwnerAccountID, after.BusinessOwnerAccountID)
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "更新店铺业务员归属",
OperatorID: operatorID, Shop: after, ParentShop: parent, Accounts: accounts,
BeforeData: map[string]any{"business_owner_account_id": before.BusinessOwnerAccountID},
AfterData: map[string]any{"business_owner_account_id": after.BusinessOwnerAccountID},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已更新",
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
}
}
if before.ClientLoginDisabled != after.ClientLoginDisabled {
subject := "店铺 C 端登录限制已解除"
if after.ClientLoginDisabled {
subject = "店铺 C 端登录已限制"
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopClientLoginLimitUpdated, Summary: "更新店铺 C 端登录限制",
OperatorID: operatorID, Shop: after, ParentShop: parent,
BeforeData: map[string]any{"client_login_disabled": before.ClientLoginDisabled},
AfterData: map[string]any{"client_login_disabled": after.ClientLoginDisabled},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺登录限制审计失败")
}
}
return nil
}
func (s *UpdateService) recordStateFailures(ctx context.Context, shop, parent *model.Shop, request *dto.UpdateShopRequest, operatorID uint, originalErr error) {
if shop == nil {
return
}
record := func(action, summary string) {
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: action, Summary: summary, Result: shopAuditFailureResult(originalErr),
OperatorID: operatorID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
if shop.Status != request.Status {
action := constants.AuditActionShopDisabled
if request.Status == constants.StatusEnabled {
action = constants.AuditActionShopEnabled
}
record(action, "更新店铺状态失败")
}
if request.BusinessOwnerAccountIDSet && !sameOptionalUint(shop.BusinessOwnerAccountID, request.BusinessOwnerAccountID) {
record(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属失败")
}
if request.ClientLoginDisabled != nil && shop.ClientLoginDisabled != *request.ClientLoginDisabled {
record(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制失败")
}
}
func businessOwnerAuditAccounts(tx *gorm.DB, beforeID, afterID *uint) []accessauditapp.AccountChange {
changes := make([]accessauditapp.AccountChange, 0, 2)
if account := loadAuditAccount(tx, beforeID); account != nil {
changes = append(changes, accessauditapp.AccountChange{
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopPreviousBusinessOwner,
BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false},
})
}
if account := loadAuditAccount(tx, afterID); account != nil {
changes = append(changes, accessauditapp.AccountChange{
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopBusinessOwner,
BeforeData: map[string]any{"assigned": false}, AfterData: map[string]any{"assigned": true},
})
}
return changes
}
func loadAuditAccount(tx *gorm.DB, accountID *uint) *model.Account {
if accountID == nil {
return nil
}
var account model.Account
if err := tx.Unscoped().First(&account, *accountID).Error; err != nil {
return nil
}
return &account
}
func sameOptionalUint(left, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func requestedShopProfileChanged(shop *model.Shop, request *dto.UpdateShopRequest) bool {
return shop.ShopName != request.ShopName || shop.ContactName != request.ContactName ||
shop.ContactPhone != request.ContactPhone || shop.Province != request.Province || shop.City != request.City ||
shop.District != request.District || shop.Address != request.Address
}
func loadAuditParentShop(tx *gorm.DB, parentID *uint) *model.Shop {
if parentID == nil {
return nil
}
var parent model.Shop
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
return nil
}
return &parent
}
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
if requestedID == nil {
return nil, nil

View File

@@ -3,8 +3,7 @@ package systemconfig
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
"gorm.io/gorm"
@@ -13,6 +12,7 @@ import (
configinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -24,13 +24,17 @@ type ChangeAudit struct {
OperationType string
Description string
ConfigKey string
Module string
BeforeData map[string]any
AfterData map[string]any
RequestID string
CorrelationID string
Result string
ErrorCode string
ErrorSummary string
}
// AuditWriter 可选接收系统配置事务内审计事实。
// AuditWriter 接收系统配置事务内审计事实。
type AuditWriter interface {
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
}
@@ -69,14 +73,29 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
if operatorID == 0 || key == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "系统配置审计接缝未配置")
}
definition, registered := s.registry.Get(key)
if !registered {
return nil, errors.New(errors.CodeInvalidParam, "系统配置 Key 未注册")
}
if definition.Readonly {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "拒绝更新只读系统配置", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidStatus),
ErrorSummary: "系统配置为只读,更新请求已拒绝",
})
return nil, errors.New(errors.CodeInvalidStatus, "系统配置为只读,不能更新")
}
if err := configinfra.ValidateValue(definition, request.Value); err != nil {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "拒绝非法系统配置值", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam),
ErrorSummary: "系统配置值不符合注册规则",
})
return nil, errors.New(errors.CodeInvalidParam, "系统配置值不符合注册规则")
}
now := s.now().UTC()
@@ -121,21 +140,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if s.audit != nil {
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
ConfigKey: key,
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate, Description: "更新受控系统配置",
ConfigKey: key, Module: definition.Module,
BeforeData: auditData(definition, beforeValue), AfterData: auditData(definition, request.Value),
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
saved = existing
return nil
})
if err != nil {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "系统配置更新失败", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultFailed, ErrorCode: strconv.Itoa(errors.CodeDatabaseError),
ErrorSummary: "系统配置更新事务已回滚",
})
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新系统配置失败")
}
s.registry.Remember(key, request.Value)
@@ -159,10 +181,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
}, nil
}
func auditValue(definition configinfra.Definition, value string) string {
if !definition.Sensitive {
return value
func (s *UpdateService) recordFailure(ctx context.Context, audit ChangeAudit) {
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
audit.RequestID = *value
audit.CorrelationID = *value
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteConfigChange(ctx, tx, audit)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(
audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
)
}
sum := sha256.Sum256([]byte(value))
return "[敏感值 sha256:" + hex.EncodeToString(sum[:8]) + "]"
}
func auditData(definition configinfra.Definition, value string) map[string]any {
if definition.Sensitive {
return map[string]any{"credentials_configured": value != ""}
}
return map[string]any{"value": value}
}

View File

@@ -22,8 +22,8 @@ func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
return &ChangeCreditService{db: db}
}
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
// Execute 使用服务端读取的版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stored model.AgentWallet
@@ -35,7 +35,7 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
return err
}
update := tx.Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, stored.Version, aggregate.EffectiveCredit()).
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
if update.Error != nil {
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
@@ -45,13 +45,13 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(&current).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
if current.Version != version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
if current.Version != stored.Version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: stored.Version + 1}
return nil
})
return result, err

View File

@@ -38,14 +38,44 @@ type AccessTokenProvider interface {
Invalidate(ctx context.Context, applicationID uint)
}
// SensitiveReadAuditWriter 定义明文连接凭据读取的失败关闭审计边界。
type SensitiveReadAuditWriter interface {
WriteSensitiveRead(ctx context.Context, tx *gorm.DB, audit SensitiveReadAudit) error
}
// SensitiveReadAudit 是一次企业微信应用明文凭据读取事实。
type SensitiveReadAudit struct {
OperatorID uint
Applications []SensitiveReadResource
FieldClasses []string
RequestID string
CorrelationID string
}
// SensitiveReadResource 是不含任何明文凭据的读取目标快照。
type SensitiveReadResource struct {
ID uint
CorpID string
AgentID int64
Name string
Status int
CredentialsConfigured bool
}
// ConnectionService 保存应用配置并测试企业微信连接。
type ConnectionService struct {
db *gorm.DB
repo ApplicationRepository
tokens AccessTokenProvider
audit systemconfigapp.AuditWriter
members DefaultCreatorMemberFinder
now func() time.Time
db *gorm.DB
repo ApplicationRepository
tokens AccessTokenProvider
audit systemconfigapp.AuditWriter
readAudit SensitiveReadAuditWriter
members DefaultCreatorMemberFinder
now func() time.Time
}
// SetSensitiveReadAuditWriter 注入明文凭据读取的失败关闭审计 Writer。
func (s *ConnectionService) SetSensitiveReadAuditWriter(writer SensitiveReadAuditWriter) {
s.readAudit = writer
}
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
@@ -156,6 +186,33 @@ func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicati
if err != nil {
return nil, err
}
if len(applications) > 0 {
if s.db == nil || s.readAudit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "敏感读取审计能力未配置")
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resources := make([]SensitiveReadResource, 0, len(applications))
for _, application := range applications {
resources = append(resources, SensitiveReadResource{
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID,
Name: application.Name, Status: application.Status,
CredentialsConfigured: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
})
}
readAudit := SensitiveReadAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), Applications: resources,
FieldClasses: []string{"secret", "callback_token", "encoding_aes_key"},
RequestID: requestID, CorrelationID: requestID,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.readAudit.WriteSensitiveRead(ctx, tx, readAudit)
}); err != nil {
return nil, err
}
}
result := make([]dto.WeComApplicationResponse, 0, len(applications))
for _, application := range applications {
result = append(result, toApplicationResponse(application))

View File

@@ -28,5 +28,5 @@ type Dependencies struct {
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port(可选,装配后与配置同事务写入)
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port;生产为空时装配统一 Writer
}

View File

@@ -12,6 +12,7 @@ import (
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
@@ -19,7 +20,9 @@ import (
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
@@ -107,8 +110,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigAudit := deps.SystemConfigAudit
if systemConfigAudit == nil {
systemConfigAudit = auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
}
systemConfigUpdate := systemConfigApp.NewUpdateService(
deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil,
deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAudit, systemConfigAlerts, nil,
)
wecomRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomBaseURL := ""
@@ -122,8 +129,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
wecomBaseURL, wecomTimeout, deps.Logger,
)
wecomConnections := wecomApp.NewConnectionService(
deps.DB, wecomRepository, wecomTokens, deps.SystemConfigAudit,
deps.DB, wecomRepository, wecomTokens, systemConfigAudit,
)
if sensitiveReadAudit, ok := systemConfigAudit.(wecomApp.SensitiveReadAuditWriter); ok {
wecomConnections.SetSensitiveReadAuditWriter(sensitiveReadAudit)
}
wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
wecomDirectory := wecomApp.NewDirectoryService(
@@ -146,7 +156,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Account: admin.NewAccountHandler(svc.Account),
Role: func() *admin.RoleHandler {
handler := admin.NewRoleHandler(svc.Role, validate)
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission))
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission, svc.AccessAudit))
return handler
}(),
Permission: admin.NewPermissionHandler(svc.Permission),
@@ -180,8 +190,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB))
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB))
return handler
@@ -280,6 +290,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate),
Audit: admin.NewAuditHandler(auditQuery.New(deps.DB), integrationQuery.New(deps.DB)),
WeCom: func() *admin.WeComHandler {
handler := admin.NewWeComHandler(wecomConnections, validate)
handler.SetDirectoryService(wecomDirectory)

View File

@@ -5,6 +5,7 @@ import (
"go.uber.org/zap"
accessauditApp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
@@ -12,6 +13,7 @@ import (
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
@@ -76,6 +78,7 @@ import (
)
type services struct {
AccessAudit accessauditApp.Writer
Approval *approvalApp.CreationService
Account *accountSvc.Service
AccountAudit *accountAuditSvc.Service
@@ -147,7 +150,13 @@ func initServices(s *stores, deps *Dependencies) *services {
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation)
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
account.SetLifecycleAudit(deps.DB, auditWriter)
account.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
account.SetTokenManager(deps.TokenManager)
authService := authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger)
authService.SetSecurityAudit(deps.DB, auditWriter)
// 创建 IotCard service 并设置回调
iotCard := iotCardSvc.New(
@@ -330,15 +339,22 @@ func initServices(s *stores, deps *Dependencies) *services {
refundService.SetRefundApprovalCreationService(
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService),
)
roleService := roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole)
roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis)
permissionService.SetAccessAudit(deps.DB, auditWriter)
shopService := shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role)
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
return &services{
AccessAudit: auditWriter,
Approval: approvalCreationService,
Account: account,
AccountAudit: accountAudit,
AssetAudit: assetAudit,
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
Role: roleService,
Permission: permissionService,
PersonalCustomer: personalCustomerSvc.NewService(deps.DB, s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger, auditWriter),
ClientAuth: clientAuthSvc.New(
deps.DB,
s.PersonalCustomerOpenID,
@@ -352,9 +368,10 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Redis,
deps.Logger,
customerBinding,
auditWriter,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
Shop: shopService,
Auth: authService,
ShopCommission: shopCommission,
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
@@ -376,10 +393,10 @@ func initServices(s *stores, deps *Dependencies) *services {
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
),
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account, auditWriter),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard, auditWriter),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
Authorization: enterpriseCardSvc.NewAuthorizationService(deps.DB, s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService),

View File

@@ -75,6 +75,7 @@ type Handlers struct {
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler
Audit *admin.AuditHandler
WeCom *admin.WeComHandler
AgentOpenAPI *openapiHandler.Handler
}

View File

@@ -35,6 +35,7 @@ type Entry struct {
PrimaryResource string `json:"primary_resource,omitempty"`
AffectedResource string `json:"affected_resource,omitempty"`
ActorSource string `json:"actor_source"`
Visibility string `json:"visibility"`
Transaction string `json:"transaction"`
FailureStrategy string `json:"failure_strategy"`
SensitivePolicy string `json:"sensitive_policy"`
@@ -46,7 +47,10 @@ type Entry struct {
// Scan 扫描当前仓库中对外 HTTP、Asynq Worker 和定时任务注册入口。
func Scan(root string) ([]Entry, error) {
var entries []Entry
files := []string{"internal/routes", "internal/application", "internal/domain", "internal/service", "pkg/queue", "cmd/worker"}
files := []string{
"internal/routes", "internal/application", "internal/domain", "internal/service",
"internal/handler", "internal/infrastructure", "internal/polling", "pkg/queue", "cmd/worker",
}
for _, directory := range files {
err := filepath.Walk(filepath.Join(root, directory), func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
@@ -108,7 +112,10 @@ func scanFile(root, path string) ([]Entry, error) {
if identifier, ok := call.Fun.(*ast.Ident); ok && identifier.Name == "Register" && len(call.Args) >= 7 {
method, methodOK := stringLiteral(call.Args[3])
pathSuffix, pathOK := stringLiteral(call.Args[4])
if methodOK && pathOK {
if !pathOK {
pathSuffix = expression(call.Args[4])
}
if methodOK {
entry := classifyHTTP(relative, position.Line, method, pathSuffix, expression(call.Args[5]), routeSummary(call.Args[6]))
entries = append(entries, entry)
}
@@ -129,6 +136,12 @@ func scanFile(root, path string) ([]Entry, error) {
entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule))
}
}
case "LogOperation":
entries = append(entries, classifyLegacyWriter(relative, position.Line, expression(call.Fun)))
case "Start", "Complete", "RecordInbound":
if isIntegrationLogCall(relative, expression(selector.X)) {
entries = append(entries, classifyIntegrationLog(relative, position.Line, expression(call.Fun)))
}
}
return true
})
@@ -136,7 +149,7 @@ func scanFile(root, path string) ([]Entry, error) {
strings.HasPrefix(relative, "internal/service/") {
for _, declaration := range file.Decls {
function, ok := declaration.(*ast.FuncDecl)
if !ok || function.Recv == nil || !isBusinessMethod(function.Name.Name) {
if !ok || function.Recv == nil || !isBusinessMethod(function) {
continue
}
position := set.Position(function.Pos())
@@ -154,14 +167,15 @@ func classifyHTTP(file string, line int, method, path, handler, summary string)
entry := Entry{
Key: fmt.Sprintf("http:%s:%d:%s:%s", file, line, method, path), Kind: "http",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file),
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file, path, handler),
DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path),
Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A",
Visibility: httpVisibility(file, path, handler),
SensitivePolicy: "禁止字段删除手机号、IP、ICCID、金额和第三方单号按权限脱敏单字段 16KB 上限",
BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用",
TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口",
}
if method == "GET" && !isSensitiveRead(file, path, summary) {
if isReadOnlyHTTP(method, path) && !isSensitiveRead(file, path, summary) {
entry.AuditEvent = "N/A"
entry.Transaction = "N/A"
entry.FailureStrategy = "Access Log 记录统一错误;普通读取不创建业务审计"
@@ -192,6 +206,7 @@ func classifyWorker(file string, line int, taskType, handler string) Entry {
Category: categoryFor(owner), Risk: riskFor(owner, taskType, handler),
PrimaryResource: owner, AffectedResource: "任务载荷定位的直接业务资源",
ActorSource: "system_task/asynq", Transaction: "业务状态变化、领域流水和 Audit Event 按用例原子提交",
Visibility: "内部系统入口;外部主体只读取对应业务安全投影",
FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计",
SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL",
BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log",
@@ -206,6 +221,7 @@ func classifySchedule(file string, line int, taskType, schedule string) Entry {
CodeEntry: fmt.Sprintf("%s:%d", file, line), Owner: workerOwner(taskType), Summary: "按 " + schedule + " 调度 " + taskType,
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "N/A",
ActorSource: "system_task/scheduled_job", Transaction: "N/A",
Visibility: "内部系统入口,不直接对用户展示",
FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责",
SensitivePolicy: "调度日志仅记录任务类型与安全时间信息",
BeforeAfterPolicy: "N/A调度入口不修改业务事实",
@@ -230,6 +246,7 @@ func classifyBusinessMethod(file string, line int, method string) Entry {
ActionCode: actionCode(owner, method), Risk: riskFor(owner, file, method),
PrimaryResource: owner, AffectedResource: "完整用例直接修改或引用的资源",
ActorSource: "由调用入口传入操作者与来源快照",
Visibility: "由完整用例决定平台完整视图、主体安全投影或 internal_only",
SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品",
BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化Domain 方法由 Application 投影",
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实Domain 使用纯领域测试;覆盖门禁静态比对本入口",
@@ -254,6 +271,45 @@ func classifyBusinessMethod(file string, line int, method string) Entry {
return entry
}
func classifyLegacyWriter(file string, line int, call string) Entry {
return Entry{
Key: fmt.Sprintf("legacy_writer:%s:%d:%s", file, line, call), Kind: "legacy_writer",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
Summary: "调用旧 Operation Log Writer", AuditEvent: "必须迁移到统一 Audit Event 后停写旧表",
DomainLedger: "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger",
IntegrationLog: "N/A旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录",
Outbox: "由原完整用例决定,旧 Writer 不得替代 Outbox",
ActionCode: actionCode(filepath.Base(filepath.Dir(file)), call), ActionName: "迁移旧审计写入",
Category: categoryFor(file), Risk: riskFor(file, call, ""), PrimaryResource: filepath.Base(filepath.Dir(file)),
AffectedResource: "按原完整业务用例登记实际资源", ActorSource: "沿用原调用入口真实操作者",
Visibility: "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影",
Transaction: "迁移后关键成功与业务事实同事务,旧异步 Writer 停写",
FailureStrategy: "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine",
SensitivePolicy: "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据",
BeforeAfterPolicy: "按资源保存本次直接变化,不复制旧单体 JSON",
TestSeam: "静态调用归零扫描 + 对应业务入口与数据库抽样核对",
}
}
func classifyIntegrationLog(file string, line int, call string) Entry {
return Entry{
Key: fmt.Sprintf("integration_log:%s:%d:%s", file, line, call), Kind: "integration_log",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
Summary: "记录外部交互尝试或终态", AuditEvent: "N/A",
DomainLedger: "N/AIntegration Log 只记录外部交互事实,不替代内部业务表",
IntegrationLog: "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要",
Outbox: "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox",
ActorSource: "external_system 或发起外呼的真实 Application/Worker/Callback",
Visibility: "仅平台内部调查完整可见;代理/企业不得读取外部交互细节",
Transaction: "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event",
FailureStrategy: "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功",
SensitivePolicy: "请求、响应和 metadata 写入前删除凭据,历史读取再次清理",
BeforeAfterPolicy: "N/A保存外部尝试结构化摘要和本地状态是否变化",
TestSeam: "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对",
NAReason: "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event",
}
}
func routeSummary(expr ast.Expr) string {
composite, ok := expr.(*ast.CompositeLit)
if !ok {
@@ -305,13 +361,18 @@ func expression(expr ast.Expr) string {
return value.Value
case *ast.CallExpr:
return expression(value.Fun)
case *ast.BinaryExpr:
return expression(value.X) + value.Op.String() + expression(value.Y)
default:
return fmt.Sprintf("%T", expr)
}
}
func httpActorSource(file string) string {
func httpActorSource(file, path, handler string) string {
text := strings.ToLower(file + " " + path + " " + handler)
switch {
case strings.Contains(text, "callback") || strings.Contains(path, "/carriers/"):
return "external_system/callback"
case strings.HasSuffix(file, "personal.go"):
return "personal_customer/personal_api"
case strings.HasSuffix(file, "order.go"):
@@ -321,14 +382,46 @@ func httpActorSource(file string) string {
}
}
func httpVisibility(file, path, handler string) string {
text := strings.ToLower(file + " " + path + " " + handler)
switch {
case strings.Contains(text, "callback"):
return "外部回调入口;只记录内部完整事实,不直接向外部主体展示"
case strings.Contains(text, "/audit"):
return "仅超级管理员和平台账号可见"
case strings.Contains(text, "enterprise"):
return "企业认证上下文范围内可见;内部审计字段不可见"
case strings.Contains(text, "personal"):
return "当前个人客户本人范围内可见"
default:
return "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离"
}
}
func isIntegrationLogCall(file, receiver string) bool {
if strings.Contains(file, "/integrationlog/") {
return false
}
receiver = strings.ToLower(receiver)
return strings.Contains(receiver, "integration")
}
func isSensitiveRead(file, path, summary string) bool {
text := strings.ToLower(file + " " + path + " " + summary)
for _, marker := range []string{"download", "export", "realname-link", "实名", "敏感", "完整", "operation-password"} {
for _, marker := range []string{"download", "realname-link", "realname/link", "实名链接", "敏感", "realtime-status"} {
if strings.Contains(text, marker) {
return true
}
}
return false
return strings.HasSuffix(file, "wecom.go") && path == "/applications" ||
strings.HasSuffix(file, "export_task.go") && path == "/:id"
}
func isReadOnlyHTTP(method, path string) bool {
if method == "GET" {
return true
}
return strings.Contains(path, "purchase-check") || strings.Contains(path, "verify-asset")
}
func integrationDecision(file, path string) string {
@@ -412,7 +505,11 @@ func workerOwner(taskType string) string {
return normalize(strings.TrimPrefix(taskType, "constants.TaskType"))
}
func isBusinessMethod(name string) bool {
func isBusinessMethod(function *ast.FuncDecl) bool {
name := function.Name.Name
if strings.HasPrefix(name, "Set") && !hasContextParameter(function) {
return false
}
for _, prefix := range []string{
"Create", "Update", "Delete", "Set", "Assign", "Remove", "Cancel", "Reject", "Approve",
"Import", "Allocate", "Recall", "Stop", "Resume", "Bind", "Unbind", "Reset", "Activate",
@@ -429,6 +526,19 @@ func isBusinessMethod(name string) bool {
return false
}
func hasContextParameter(function *ast.FuncDecl) bool {
if function.Type.Params == nil {
return false
}
for _, field := range function.Type.Params.List {
selector, ok := field.Type.(*ast.SelectorExpr)
if ok && expression(selector) == "context.Context" {
return true
}
}
return false
}
func normalize(value string) string {
value = strings.Trim(value, "\"")
var output []rune

View File

@@ -452,6 +452,10 @@ func (h *AssetHandler) Orders(c *fiber.Ctx) error {
// OperationLogs 查询资产操作审计日志
// GET /api/admin/assets/:identifier/operation-logs
func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if h.assetAuditService == nil {
return errors.New(errors.CodeInternalError, "资产审计服务未配置")
}

View File

@@ -0,0 +1,265 @@
package admin
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
auditquery "github.com/break/junhong_cmp_fiber/internal/query/audit"
integrationquery "github.com/break/junhong_cmp_fiber/internal/query/integration"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AuditHandler 提供平台基础审计调查只读接口。
type AuditHandler struct {
auditQuery *auditquery.Query
integrationQuery *integrationquery.Query
}
// NewAuditHandler 创建平台基础审计调查 Handler。
func NewAuditHandler(auditQuery *auditquery.Query, integrationQuery *integrationquery.Query) *AuditHandler {
return &AuditHandler{auditQuery: auditQuery, integrationQuery: integrationQuery}
}
// ListEvents 查询平台全局审计事件。
// GET /api/admin/audit/events
func (h *AuditHandler) ListEvents(c *fiber.Ctx) error {
var request dto.AuditEventListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.List(c.UserContext(), auditquery.EventFilter{
CreatedFrom: from, CreatedTo: to, Action: request.Action, Category: request.Category,
ActorKind: request.ActorKind, ActorID: request.ActorID, Source: request.Source,
Result: request.Result, Risk: request.Risk, ScopeType: request.ScopeType, ScopeID: request.ScopeID,
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// GetEvent 查询单个稳定审计事件详情。
// GET /api/admin/audit/events/:event_id
func (h *AuditHandler) GetEvent(c *fiber.Ctx) error {
result, err := h.auditQuery.Get(c.UserContext(), c.Params("event_id"))
if err != nil {
return err
}
return response.Success(c, result)
}
// ListActorEvents 查询操作者行为时间线。
// GET /api/admin/audit/actors/:kind/:id/events
func (h *AuditHandler) ListActorEvents(c *fiber.Ctx) error {
var request dto.AuditActorEventsRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
request.Kind, request.ID = c.Params("kind"), c.Params("id")
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.ListActorEvents(c.UserContext(), auditquery.ActorEventFilter{
Kind: request.Kind, ID: request.ID, Action: request.Action, Result: request.Result, Risk: request.Risk,
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
CreatedFrom: from, CreatedTo: to, Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// SearchResources 按注册业务标识精确搜索资源。
// GET /api/admin/audit/resources/search
func (h *AuditHandler) SearchResources(c *fiber.Ctx) error {
var request dto.AuditResourceSearchRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.SearchResources(c.UserContext(), auditquery.ResourceSearchFilter{
ResourceType: request.ResourceType, Keyword: request.Keyword,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// ResourceTimeline 查询资源作为任意关系参与的通用事件时间线。
// GET /api/admin/audit/resources/:resource_type/:resource_id/timeline
func (h *AuditHandler) ResourceTimeline(c *fiber.Ctx) error {
var request dto.AuditResourceTimelineRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
request.ResourceType, request.ResourceID = c.Params("resource_type"), c.Params("resource_id")
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.ResourceTimeline(c.UserContext(), auditquery.ResourceTimelineFilter{
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
CreatedFrom: from, CreatedTo: to, Action: request.Action, Result: request.Result,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// AgentResourceActivities 查询代理范围内的安全资源活动。
// GET /api/admin/agent/resource-activities/:resource_type/:identifier
func (h *AuditHandler) AgentResourceActivities(c *fiber.Ctx) error {
request, err := subjectActivityRequest(c)
if err != nil {
return err
}
result, err := h.auditQuery.AgentResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// EnterpriseResourceActivities 查询企业当前有效授权资产的安全资源活动。
// GET /api/admin/enterprise/resource-activities/:resource_type/:identifier
func (h *AuditHandler) EnterpriseResourceActivities(c *fiber.Ctx) error {
request, err := subjectActivityRequest(c)
if err != nil {
return err
}
result, err := h.auditQuery.EnterpriseResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
func subjectActivityRequest(c *fiber.Ctx) (dto.SubjectResourceActivityRequest, error) {
var request dto.SubjectResourceActivityRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return request, errors.New(errors.CodeInvalidParam)
}
request.ResourceType = c.Params("resource_type")
request.Identifier = c.Params("identifier")
if request.ResourceType == "" || request.Identifier == "" {
return request, errors.New(errors.CodeInvalidParam)
}
return request, nil
}
// IntegrationOverview 查询外部集成交互总览。
// GET /api/admin/audit/integrations/overview
func (h *AuditHandler) IntegrationOverview(c *fiber.Ctx) error {
var request dto.IntegrationOverviewRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
filter, err := integrationFilter(request.IntegrationFilterRequest)
if err != nil {
return err
}
result, err := h.integrationQuery.Overview(c.UserContext(), integrationquery.OverviewFilter{
ListFilter: filter,
Bucket: request.Bucket,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// ListIntegrations 查询外部集成交互列表。
// GET /api/admin/audit/integrations
func (h *AuditHandler) ListIntegrations(c *fiber.Ctx) error {
var request dto.IntegrationListRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
filter, err := integrationFilter(request.IntegrationFilterRequest)
if err != nil {
return err
}
filter.Page, filter.PageSize = request.Page, request.PageSize
result, err := h.integrationQuery.List(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetIntegration 查询稳定外部集成记录详情。
// GET /api/admin/audit/integrations/:integration_id
func (h *AuditHandler) GetIntegration(c *fiber.Ctx) error {
result, err := h.integrationQuery.Get(c.UserContext(), c.Params("integration_id"))
if err != nil {
return err
}
return response.Success(c, result)
}
func integrationFilter(request dto.IntegrationFilterRequest) (integrationquery.ListFilter, error) {
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return integrationquery.ListFilter{}, errors.New(errors.CodeInvalidParam)
}
return integrationquery.ListFilter{
CreatedFrom: from, CreatedTo: to, IntegrationID: request.IntegrationID,
Provider: request.Provider, Direction: request.Direction, Operation: request.Operation,
Result: request.Result, ResultCategory: request.ResultCategory, ExternalID: request.ExternalID,
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
TriggerSource: request.TriggerSource, TriggerScene: request.TriggerScene, TriggerSeries: request.TriggerSeries,
StateChanged: request.StateChanged, HTTPStatus: request.HTTPStatus, ProviderCode: request.ProviderCode,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
}, nil
}
func auditTimeRange(fromValue, toValue string) (*time.Time, *time.Time, error) {
from, err := optionalAuditTime(fromValue)
if err != nil {
return nil, nil, err
}
to, err := optionalAuditTime(toValue)
if err != nil {
return nil, nil, err
}
if from != nil && to != nil && !from.Before(*to) {
return nil, nil, errors.New(errors.CodeInvalidParam)
}
return from, to, nil
}
func optionalAuditTime(value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, err
}
return &parsed, nil
}
func invalidAuditPage(page, pageSize int) bool {
return page < 0 || pageSize < 0 || pageSize > 100
}

View File

@@ -137,7 +137,7 @@ func (h *ShopHandler) UpdateCreditLimit(c *fiber.Ctx) error {
if h.changeCreditService == nil {
return errors.New(errors.CodeInternalError, "店铺信用额度服务未配置")
}
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit, *request.Version)
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit)
if err != nil {
return err
}

View File

@@ -77,3 +77,13 @@ func recordDisabledCarrierCallback(
})
return err
}
// completeResolvedCarrierCallback 在回调已精确解析卡后补充真实本地资源ID。
func completeResolvedCarrierCallback(ctx context.Context, repository *integrationlog.Repository, integrationID, result string, stateChanged bool, reason string, cardID uint) error {
resourceID := strconv.FormatUint(uint64(cardID), 10)
_, err := repository.Complete(ctx, integrationID, integrationlog.Completion{
Result: result, HTTPStatus: fiber.StatusOK, StateChanged: stateChanged, ResourceID: &resourceID,
ResponseSummary: map[string]any{"reason": reason},
})
return err
}

View File

@@ -115,7 +115,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
h.logger.Warn("移动实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
}
func (h *CMCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CMCCRealnameTranslation, requestID *string) error {

View File

@@ -141,7 +141,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
h.logger.Warn("电信实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
}
func (h *CTCCRealnameHandler) failPending(ctx context.Context, integrationID string, original error) error {

View File

@@ -114,7 +114,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
h.logger.Warn("联通实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
}
func (h *CUCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameTranslation, requestID *string) error {

View File

@@ -93,7 +93,7 @@ func (h *CUCCRealnameRemovalHandler) process(ctx context.Context, body []byte, c
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, "精确列匹配多张卡")
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultIgnored, "解除实名仅留痕,不修改本地实名事实")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultIgnored, false, "解除实名仅留痕,不修改本地实名事实", cards[0].ID)
}
func (h *CUCCRealnameRemovalHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameRemovalTranslation, requestID *string) error {

View File

@@ -20,6 +20,7 @@ import (
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
"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/fuiou"
@@ -112,6 +113,7 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
)
return errors.New(errors.CodeWechatCallbackInvalid, "微信支付服务未配置")
}
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderWechatPay)
switch cfg.ProviderType {
case model.ProviderTypeWechatV2:
@@ -218,7 +220,9 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
if h.agentPaymentConfirm == nil || h.integration == nil {
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
}
resourceID, correlationID := callback.PaymentNo, callback.PaymentNo
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
linkage := auditcontext.From(ctx)
idempotencyKey := callback.TransactionID
if idempotencyKey == "" {
idempotencyKey = callback.PaymentNo
@@ -226,7 +230,7 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceID: &resourceID,
ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceKey: &resourceKey,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
@@ -236,20 +240,27 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID,
MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID,
Amount: callback.Amount, PaidAt: callback.PaidAt, CorrelationID: correlationID,
Amount: callback.Amount, PaidAt: callback.PaidAt, RequestID: linkage.RequestID,
CorrelationID: correlationID, ParentEventID: linkage.ParentEventID,
})
if confirmErr != nil {
h.logger.Error("代理充值支付确认失败",
zap.String("integration_id", log.IntegrationID),
zap.String("payment_no", callback.PaymentNo),
zap.Error(confirmErr),
)
h.completePaymentCallbackLog(ctx, log, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderMessage: confirmErr.Error(),
Result: constants.IntegrationResultFailed, SafeProviderMessage: "代理充值支付确认失败",
ResponseSummary: map[string]any{"confirmed": false},
})
return confirmErr
}
if log.Result == constants.IntegrationResultPending {
resolvedResourceID := strconv.FormatUint(uint64(result.PaymentID), 10)
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS",
ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed},
StateChanged: !result.AlreadyConfirmed,
StateChanged: !result.AlreadyConfirmed, ResourceID: &resolvedResourceID,
})
if err != nil {
return err
@@ -305,6 +316,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
)
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置不可用")
}
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderAlipay)
// 使用支付宝公钥验签DecodeNotification 内部完成签名校验)
notification, err := alipay.DecodeNotification(ctx, cfg, values)
@@ -513,6 +525,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
)
return c.Send(fuiou.BuildNotifyFailResponse("payment config unavailable"))
}
ctx = paymentCallbackContext(ctx, model.ProviderTypeFuiou)
if cfg.ProviderType != model.ProviderTypeFuiou ||
strings.TrimSpace(preNotify.InsCd) != strings.TrimSpace(cfg.FyInsCd) ||
strings.TrimSpace(preNotify.MchntCd) != strings.TrimSpace(cfg.FyMchntCd) {
@@ -604,6 +617,13 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
return c.Send(fuiou.BuildNotifySuccessResponse())
}
func paymentCallbackContext(ctx context.Context, provider string) context.Context {
return auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorExternalSystem, ActorID: provider,
ActorName: provider, Source: constants.AuditSourceCallback,
})
}
// fuiouCallbackPayload 提取富友回调载荷,兼容 body、form req 和 query req 三种来源。
func fuiouCallbackPayload(c *fiber.Ctx) ([]byte, string) {
if req := strings.TrimSpace(c.FormValue("req")); req != "" {

View File

@@ -0,0 +1,41 @@
package audit
import (
"context"
"gorm.io/gorm"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BatchInput 描述一条批次根事件及每个已识别资源的子事件。
type BatchInput struct {
Root AppendInput
Children []AppendInput
}
// AppendBatch 在同一事务内追加批次根事件和资源子事件。
func (w *Writer) AppendBatch(ctx context.Context, tx *gorm.DB, input BatchInput) error {
if input.Root.EventID == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次根事件缺少稳定事件ID")
}
if err := w.Append(ctx, tx, input.Root); err != nil {
return err
}
for index := range input.Children {
child := input.Children[index]
if child.EventID == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次子事件缺少稳定事件ID")
}
if child.ParentEventID == "" {
child.ParentEventID = input.Root.EventID
}
if child.CorrelationID == "" {
child.CorrelationID = input.Root.CorrelationID
}
if err := w.Append(ctx, tx, child); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,410 @@
// Package audit 实现统一 Audit Event 的注册表与持久化 Adapter。
package audit
import "github.com/break/junhong_cmp_fiber/pkg/constants"
// ActionDefinition 是受控审计动作的写入契约。
type ActionDefinition struct {
Code string
Name string
Category string
Risk string
PrimaryResource string
AllowedActor string
Source string
RequireTransaction bool
DefaultVisibility string
AllowedVisibility []string
SubjectFields []string
SensitiveRead bool
}
// ResourceDefinition 是受控审计资源的快照契约。
type ResourceDefinition struct {
Type string
Name string
IdentityFields []string
}
// Registry 保存首批已评审的动作与资源定义。
type Registry struct {
actionsByOperation map[string]ActionDefinition
actionsByCode map[string]ActionDefinition
resources map[string]ResourceDefinition
}
// NewRegistry 创建首批统一审计注册表。
func NewRegistry() *Registry {
accountCreated := accountLifecycleAction(constants.AuditActionAccountCreated, "创建账号", constants.AuditRiskNormal)
accountUpdated := accountLifecycleAction(constants.AuditActionAccountUpdated, "更新账号", constants.AuditRiskNormal)
accountDeleted := accountLifecycleAction(constants.AuditActionAccountDeleted, "删除账号", constants.AuditRiskHigh)
accountPasswordReset := accountSecurityAction(constants.AuditActionAccountPasswordReset, "重置账号密码", constants.AuditRiskHigh)
accountPasswordChanged := accountSecurityAction(constants.AuditActionAccountPasswordChanged, "修改账号密码", constants.AuditRiskHigh)
accountWeComBound := accountSecurityAction(constants.AuditActionAccountWeComBound, "绑定账号企业微信身份", constants.AuditRiskNormal)
authLogin := accountSecurityAction(constants.AuditActionAuthLogin, "后台账号登录", constants.AuditRiskNormal)
authLogout := accountSecurityAction(constants.AuditActionAuthLogout, "后台账号退出登录", constants.AuditRiskNormal)
authTokenRefreshed := accountSecurityAction(constants.AuditActionAuthTokenRefreshed, "刷新后台访问令牌", constants.AuditRiskNormal)
accountRolesAssigned := accessAction(constants.AuditActionAccountRolesAssigned, "分配账号角色", constants.AuditResourceAccount)
accountRoleRemoved := accessAction(constants.AuditActionAccountRoleRemoved, "移除账号角色", constants.AuditResourceAccount)
shopRolesAssigned := accessAction(constants.AuditActionShopRolesAssigned, "分配店铺角色", constants.AuditResourceShop)
shopRoleDeleted := accessAction(constants.AuditActionShopRoleDeleted, "移除店铺角色", constants.AuditResourceShop)
shopRolesAssigned.Category = constants.AuditCategoryBusiness
shopRoleDeleted.Category = constants.AuditCategoryBusiness
shopCreated := shopIdentityAction(constants.AuditActionShopCreated, "创建店铺")
shopUpdated := shopIdentityAction(constants.AuditActionShopUpdated, "更新店铺基础资料")
shopEnabled := shopStateAction(constants.AuditActionShopEnabled, "启用店铺", constants.AuditRiskNormal)
shopDisabled := shopStateAction(constants.AuditActionShopDisabled, "禁用店铺", constants.AuditRiskNormal)
shopDeleted := shopStateAction(constants.AuditActionShopDeleted, "删除店铺", constants.AuditRiskHigh)
shopBusinessOwnerUpdated := shopStateAction(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属", constants.AuditRiskNormal)
shopClientLoginLimitUpdated := shopStateAction(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制", constants.AuditRiskHigh)
enterpriseCreated := enterpriseAction(constants.AuditActionEnterpriseCreated, "创建企业", constants.AuditCategoryBusiness, constants.AuditRiskNormal)
enterpriseUpdated := enterpriseAction(constants.AuditActionEnterpriseUpdated, "更新企业基础资料", constants.AuditCategoryBusiness, constants.AuditRiskNormal)
enterpriseStatusUpdated := enterpriseAction(constants.AuditActionEnterpriseStatusUpdated, "更新企业状态", constants.AuditCategoryBusiness, constants.AuditRiskNormal)
enterprisePasswordUpdated := enterpriseAction(constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码", constants.AuditCategoryBusiness, constants.AuditRiskHigh)
enterpriseCardsAllocated := enterpriseCardAction(constants.AuditActionEnterpriseCardsAllocated, "向企业授权卡")
enterpriseCardsRecalled := enterpriseCardAction(constants.AuditActionEnterpriseCardsRecalled, "回收企业卡授权")
enterpriseCardRemarkUpdated := enterpriseCardAction(constants.AuditActionEnterpriseCardRemarkUpdated, "更新企业卡授权备注")
enterpriseDevicesAllocated := enterpriseCardAction(constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备")
enterpriseDevicesRecalled := enterpriseCardAction(constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权")
personalProfileUpdated := personalAction(constants.AuditActionPersonalCustomerProfileUpdated, "更新个人资料", []string{"nickname", "avatar_url"})
personalPhoneBound := personalAction(constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", []string{"phone"})
personalPhoneChanged := personalAction(constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", []string{"phone"})
personalWechatIdentityUpdated := personalAction(constants.AuditActionPersonalCustomerWechatIdentityUpdated, "同步个人微信主体", []string{"app_id", "app_type"})
systemConfigUpdated := ActionDefinition{
Code: constants.AuditActionSystemConfigUpdated, Name: "更新受控系统配置",
Category: constants.AuditCategoryConfiguration, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceSystemConfig, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
outboxReplayed := outboxRecoveryAction(
constants.AuditActionOutboxReplayed,
"人工重放 Outbox 事件",
)
outboxExpiredLeaseReleased := outboxRecoveryAction(
constants.AuditActionOutboxExpiredLeaseReleased,
"人工释放 Outbox 过期租约",
)
deviceBatchCompleted := deviceBatchAction(
constants.AuditActionDeviceBatchAllocationCompleted,
"完成设备批量分配",
constants.AuditResourceDeviceBatchTask,
)
deviceBatchItem := deviceBatchAction(
constants.AuditActionDeviceBatchAllocationItem,
"处理设备批量分配项",
constants.AuditResourceDevice,
)
deviceBatchItem.AllowedVisibility = []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}
wecomCredentialsRead := ActionDefinition{
Code: constants.AuditActionWeComCredentialsRead, Name: "读取企业微信应用明文凭据",
Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceWeComApplication, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, SensitiveRead: true,
}
roleCreated := accessAction(constants.AuditActionRoleCreated, "创建角色", constants.AuditResourceRole)
roleUpdated := accessAction(constants.AuditActionRoleUpdated, "更新角色", constants.AuditResourceRole)
roleStatusUpdated := accessAction(constants.AuditActionRoleStatusUpdated, "更新角色状态", constants.AuditResourceRole)
roleDefaultCreditUpdated := accessAction(constants.AuditActionRoleDefaultCreditUpdated, "更新角色默认信用额度", constants.AuditResourceRole)
roleDeleted := accessAction(constants.AuditActionRoleDeleted, "删除角色", constants.AuditResourceRole)
rolePermissionsAssigned := accessAction(constants.AuditActionRolePermissionsAssigned, "配置角色权限", constants.AuditResourceRole)
rolePermissionRemoved := accessAction(constants.AuditActionRolePermissionRemoved, "移除角色权限", constants.AuditResourceRole)
rolePermissionsBatchRemoved := accessAction(constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限", constants.AuditResourceRole)
permissionCreated := accessAction(constants.AuditActionPermissionCreated, "创建权限", constants.AuditResourcePermission)
permissionUpdated := accessAction(constants.AuditActionPermissionUpdated, "更新权限", constants.AuditResourcePermission)
permissionDeleted := accessAction(constants.AuditActionPermissionDeleted, "删除权限", constants.AuditResourcePermission)
return &Registry{
actionsByOperation: map[string]ActionDefinition{
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
constants.AuditOperationOutboxReplay: outboxReplayed,
constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased,
},
actionsByCode: map[string]ActionDefinition{
constants.AuditActionAccountCreated: accountCreated,
constants.AuditActionAccountUpdated: accountUpdated,
constants.AuditActionAccountDeleted: accountDeleted,
constants.AuditActionAccountPasswordReset: accountPasswordReset,
constants.AuditActionAccountPasswordChanged: accountPasswordChanged,
constants.AuditActionAccountWeComBound: accountWeComBound,
constants.AuditActionAuthLogin: authLogin,
constants.AuditActionAuthLogout: authLogout,
constants.AuditActionAuthTokenRefreshed: authTokenRefreshed,
constants.AuditActionAccountRolesAssigned: accountRolesAssigned,
constants.AuditActionAccountRoleRemoved: accountRoleRemoved,
constants.AuditActionShopRolesAssigned: shopRolesAssigned,
constants.AuditActionShopRoleDeleted: shopRoleDeleted,
constants.AuditActionShopCreated: shopCreated,
constants.AuditActionShopUpdated: shopUpdated,
constants.AuditActionShopEnabled: shopEnabled,
constants.AuditActionShopDisabled: shopDisabled,
constants.AuditActionShopDeleted: shopDeleted,
constants.AuditActionShopBusinessOwnerUpdated: shopBusinessOwnerUpdated,
constants.AuditActionShopClientLoginLimitUpdated: shopClientLoginLimitUpdated,
constants.AuditActionEnterpriseCreated: enterpriseCreated,
constants.AuditActionEnterpriseUpdated: enterpriseUpdated,
constants.AuditActionEnterpriseStatusUpdated: enterpriseStatusUpdated,
constants.AuditActionEnterprisePasswordUpdated: enterprisePasswordUpdated,
constants.AuditActionEnterpriseCardsAllocated: enterpriseCardsAllocated,
constants.AuditActionEnterpriseCardsRecalled: enterpriseCardsRecalled,
constants.AuditActionEnterpriseCardRemarkUpdated: enterpriseCardRemarkUpdated,
constants.AuditActionEnterpriseDevicesAllocated: enterpriseDevicesAllocated,
constants.AuditActionEnterpriseDevicesRecalled: enterpriseDevicesRecalled,
constants.AuditActionPersonalCustomerProfileUpdated: personalProfileUpdated,
constants.AuditActionPersonalCustomerPhoneBound: personalPhoneBound,
constants.AuditActionPersonalCustomerPhoneChanged: personalPhoneChanged,
constants.AuditActionPersonalCustomerWechatIdentityUpdated: personalWechatIdentityUpdated,
constants.AuditActionSystemConfigUpdated: systemConfigUpdated,
constants.AuditActionOutboxReplayed: outboxReplayed,
constants.AuditActionOutboxExpiredLeaseReleased: outboxExpiredLeaseReleased,
constants.AuditActionDeviceBatchAllocationCompleted: deviceBatchCompleted,
constants.AuditActionDeviceBatchAllocationItem: deviceBatchItem,
constants.AuditActionWeComCredentialsRead: wecomCredentialsRead,
constants.AuditActionRoleCreated: roleCreated,
constants.AuditActionRoleUpdated: roleUpdated,
constants.AuditActionRoleStatusUpdated: roleStatusUpdated,
constants.AuditActionRoleDefaultCreditUpdated: roleDefaultCreditUpdated,
constants.AuditActionRoleDeleted: roleDeleted,
constants.AuditActionRolePermissionsAssigned: rolePermissionsAssigned,
constants.AuditActionRolePermissionRemoved: rolePermissionRemoved,
constants.AuditActionRolePermissionsBatchRemoved: rolePermissionsBatchRemoved,
constants.AuditActionPermissionCreated: permissionCreated,
constants.AuditActionPermissionUpdated: permissionUpdated,
constants.AuditActionPermissionDeleted: permissionDeleted,
},
resources: map[string]ResourceDefinition{
constants.AuditResourceAccount: {
Type: constants.AuditResourceAccount, Name: "账号",
IdentityFields: []string{"id", "username", "phone", "user_type", "shop_id", "enterprise_id", "wecom_userid", "wecom_name"},
},
constants.AuditResourceRole: {
Type: constants.AuditResourceRole, Name: "角色",
IdentityFields: []string{"id", "role_name", "role_type", "status", "default_credit_enabled", "default_credit_limit"},
},
constants.AuditResourcePermission: {
Type: constants.AuditResourcePermission, Name: "权限",
IdentityFields: []string{"id", "perm_name", "perm_code", "perm_type", "platform", "available_for_role_types", "parent_id", "status"},
},
constants.AuditResourceSystemConfig: {
Type: constants.AuditResourceSystemConfig, Name: "受控系统配置",
IdentityFields: []string{"config_key", "module"},
},
constants.AuditResourceOutboxEvent: {
Type: constants.AuditResourceOutboxEvent, Name: "Outbox 事件",
IdentityFields: []string{
"event_id", "event_type", "aggregate_type", "aggregate_id",
"resource_type", "resource_id", "business_key",
},
},
constants.AuditResourceDeviceBatchTask: {
Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务",
IdentityFields: []string{"task_no", "operation_type"},
},
constants.AuditResourceDevice: {
Type: constants.AuditResourceDevice, Name: "设备",
IdentityFields: []string{"id", "virtual_no", "imei", "sn", "generation"},
},
constants.AuditResourceIotCard: {
Type: constants.AuditResourceIotCard, Name: "IoT卡",
IdentityFields: []string{"id", "iccid", "iccid_19", "iccid_20", "virtual_no", "msisdn", "carrier_type", "shop_id", "series_id", "generation"},
},
constants.AuditResourceShop: {
Type: constants.AuditResourceShop, Name: "店铺",
IdentityFields: []string{"id", "shop_code", "shop_name", "parent_id", "level"},
},
constants.AuditResourceOrder: {
Type: constants.AuditResourceOrder, Name: "订单",
IdentityFields: []string{"id", "order_no", "buyer_type", "buyer_id", "asset_identifier", "total_amount", "payment_method", "payment_status"},
},
constants.AuditResourceRefund: {
Type: constants.AuditResourceRefund, Name: "退款单",
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "asset_identifier", "shop_id", "requested_refund_amount", "status"},
},
constants.AuditResourceEnterprise: {
Type: constants.AuditResourceEnterprise, Name: "企业",
IdentityFields: []string{"id", "enterprise_code", "enterprise_name", "owner_shop_id"},
},
constants.AuditResourceDeviceSIMBinding: {
Type: constants.AuditResourceDeviceSIMBinding, Name: "设备卡槽绑定",
IdentityFields: []string{"id", "device_id", "device_virtual_no", "slot_position", "iot_card_id", "iccid", "virtual_no", "is_current"},
},
constants.AuditResourceAssetAllocationRecord: {
Type: constants.AuditResourceAssetAllocationRecord, Name: "资产分配记录",
IdentityFields: []string{"id", "allocation_no", "asset_type", "asset_id", "asset_identifier", "from_owner_type", "from_owner_id", "to_owner_type", "to_owner_id"},
},
constants.AuditResourceExchangeOrder: {
Type: constants.AuditResourceExchangeOrder, Name: "换货单",
IdentityFields: []string{"id", "exchange_no", "old_asset_type", "old_asset_id", "new_asset_type", "new_asset_id", "shop_id", "status"},
},
constants.AuditResourceAgentRecharge: {
Type: constants.AuditResourceAgentRecharge, Name: "代理充值单",
IdentityFields: []string{"id", "recharge_no", "shop_id", "agent_wallet_id", "approval_instance_id", "status"},
},
constants.AuditResourceAssetWallet: {
Type: constants.AuditResourceAssetWallet, Name: "资产钱包",
IdentityFields: []string{"id", "resource_type", "resource_id", "currency"},
},
constants.AuditResourceApprovalInstance: {
Type: constants.AuditResourceApprovalInstance, Name: "审批实例",
IdentityFields: []string{"id", "business_type", "business_id", "provider", "external_ref", "status"},
},
constants.AuditResourceWeComApplication: {
Type: constants.AuditResourceWeComApplication, Name: "企业微信应用配置",
IdentityFields: []string{"id", "corp_id", "agent_id", "name", "status", "credentials_configured"},
},
constants.AuditResourceAuthentication: {
Type: constants.AuditResourceAuthentication, Name: "认证状态",
IdentityFields: []string{"account_id", "device", "auth_method", "state", "wecom_corp_id", "wecom_userid", "wecom_name"},
},
constants.AuditResourceEnterpriseCardAuthorization: {
Type: constants.AuditResourceEnterpriseCardAuthorization, Name: "企业卡授权记录",
IdentityFields: []string{"id", "enterprise_id", "card_id", "authorized_by", "authorizer_type", "authorized_at", "revoked_by", "revoked_at", "device_auth_id"},
},
constants.AuditResourceEnterpriseDeviceAuthorization: {
Type: constants.AuditResourceEnterpriseDeviceAuthorization, Name: "企业设备授权记录",
IdentityFields: []string{"id", "enterprise_id", "device_id", "authorized_by", "authorizer_type", "authorized_at", "revoked_by", "revoked_at"},
},
constants.AuditResourcePersonalCustomer: {
Type: constants.AuditResourcePersonalCustomer, Name: "个人客户",
IdentityFields: []string{"id", "nickname", "wx_open_id", "wx_union_id", "status"},
},
constants.AuditResourcePersonalCustomerPhone: {
Type: constants.AuditResourcePersonalCustomerPhone, Name: "个人客户手机号",
IdentityFields: []string{"id", "customer_id", "phone", "is_primary", "verified_at", "status"},
},
constants.AuditResourcePersonalCustomerOpenID: {
Type: constants.AuditResourcePersonalCustomerOpenID, Name: "个人客户微信主体",
IdentityFields: []string{"id", "customer_id", "app_id", "open_id", "union_id", "app_type"},
},
},
}
}
func accountSecurityAction(code, name, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategorySecurity, Risk: risk,
PrimaryResource: constants.AuditResourceAccount, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func accessAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func shopIdentityAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceShop, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func shopStateAction(code, name, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: risk,
PrimaryResource: constants.AuditResourceShop, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func enterpriseAction(code, name, category, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: category, Risk: risk,
PrimaryResource: constants.AuditResourceEnterprise, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func enterpriseCardAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceEnterprise, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func personalAction(code, name string, subjectFields []string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryIdentity, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourcePersonalCustomer, AllowedActor: constants.AuditActorPersonalCustomer,
Source: constants.AuditSourcePersonalAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectDetail,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectDetail},
SubjectFields: subjectFields,
}
}
func accountLifecycleAction(code, name, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryIdentity, Risk: risk,
PrimaryResource: constants.AuditResourceAccount, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func deviceBatchAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorSystemTask,
Source: constants.AuditSourceWorker, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func outboxRecoveryAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryReliability, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceOutboxEvent, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
// Action 返回已注册动作定义。
func (r *Registry) Action(code string) (ActionDefinition, bool) {
if r == nil {
return ActionDefinition{}, false
}
action, ok := r.actionsByCode[code]
return action, ok
}
// ActionByOperation 返回旧应用接缝操作类型对应的受控动作。
func (r *Registry) ActionByOperation(operation string) (ActionDefinition, bool) {
if r == nil {
return ActionDefinition{}, false
}
action, ok := r.actionsByOperation[operation]
return action, ok
}
// Resource 返回已注册资源定义。
func (r *Registry) Resource(resourceType string) (ResourceDefinition, bool) {
if r == nil {
return ResourceDefinition{}, false
}
resource, ok := r.resources[resourceType]
return resource, ok
}

View File

@@ -0,0 +1,57 @@
package audit
import (
"testing"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// TestSecurityActionsRegistered 验证账号安全动作不会绕过注册表。
func TestSecurityActionsRegistered(t *testing.T) {
registry := NewRegistry()
for _, code := range []string{
constants.AuditActionAccountPasswordReset,
constants.AuditActionAccountPasswordChanged,
constants.AuditActionAccountWeComBound,
constants.AuditActionAuthLogin,
constants.AuditActionAuthLogout,
constants.AuditActionAuthTokenRefreshed,
} {
action, ok := registry.Action(code)
if !ok {
t.Fatalf("安全动作未注册:%s", code)
}
if action.PrimaryResource != constants.AuditResourceAccount || action.Category != constants.AuditCategorySecurity {
t.Fatalf("安全动作注册错误:%s", code)
}
}
}
// TestSecurityAuditRemovesCredentials 验证安全凭据不会进入审计 JSON。
func TestSecurityAuditRemovesCredentials(t *testing.T) {
encoded, err := safeObject(map[string]any{
"password": "secret",
"verification_code": "123456",
"access_token": "token",
"cookie": "session=value",
"credentials_configured": true,
"state": "changed",
})
if err != nil {
t.Fatalf("清理审计 JSON 失败:%v", err)
}
var value map[string]any
if err := sonic.Unmarshal(encoded, &value); err != nil {
t.Fatalf("解析审计 JSON 失败:%v", err)
}
for _, field := range []string{"password", "verification_code", "access_token", "cookie"} {
if _, exists := value[field]; exists {
t.Fatalf("安全凭据未删除:%s", field)
}
}
if value["credentials_configured"] != true || value["state"] != "changed" {
t.Fatalf("安全业务事实被错误删除:%v", value)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import (
"encoding/hex"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"gorm.io/datatypes"
@@ -46,15 +47,18 @@ type Attempt struct {
// Completion 描述外部尝试从待处理状态进入终态的结果。
type Completion struct {
Result string
HTTPStatus int
ProviderCode string
ProviderMessage string
ResponseSummary any
DurationMS int64
StateChanged bool
AuditEventID *uint
RecoveryStrategy string
Result string
HTTPStatus int
ProviderCode string
ProviderMessage string
SafeProviderMessage string
ResponseSummary any
DurationMS int64
StateChanged bool
ResourceID *string
ResourceKey *string
AuditEventID *uint
RecoveryStrategy string
}
// InboundAttempt 描述业务处理前必须保存的入站回调安全事实。
@@ -105,6 +109,13 @@ func (r *Repository) Start(ctx context.Context, input Attempt) (*model.Integrati
}
if input.Attempt <= 0 {
input.Attempt = 1
if input.TriggerSeries != nil {
if err := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Select("COALESCE(MAX(attempt), 0) + 1").
Where("trigger_series = ?", *input.TriggerSeries).Scan(&input.Attempt).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "计算 Integration Log 尝试序号失败")
}
}
}
if input.StartedAt == nil {
startedAt := r.now().UTC()
@@ -136,12 +147,19 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
if r == nil || r.db == nil {
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if integrationID == "" || !isTerminalResult(completion.Result) {
if !validRequiredString(integrationID, constants.IntegrationIDMaxLength) ||
!validOptionalString(completion.ResourceID, constants.IntegrationResourceIDMaxLength) ||
!validOptionalString(completion.ResourceKey, constants.IntegrationResourceKeyMaxLength) ||
!isTerminalResult(completion.Result) {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 终态参数无效")
}
if completion.Result == constants.IntegrationResultUnknown && strings.TrimSpace(completion.RecoveryStrategy) == "" {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "结果未知必须记录明确恢复策略")
}
safeProviderMessage := strings.TrimSpace(completion.SafeProviderMessage)
if safeProviderMessage != "" && utf8.RuneCountInString(constants.IntegrationSafeMessagePrefix+safeProviderMessage) > constants.IntegrationProviderMessageMaxLength {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 安全结果摘要过长")
}
responseSummary, err := marshalSummary(completion.ResponseSummary)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 响应摘要无效")
@@ -157,12 +175,20 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
if completion.ProviderCode != "" {
updates["provider_code"] = completion.ProviderCode
}
if completion.ProviderMessage != "" {
if safeProviderMessage != "" {
updates["provider_message"] = constants.IntegrationSafeMessagePrefix + safeProviderMessage
} else if completion.ProviderMessage != "" {
updates["provider_message"] = sanitizer.TextSummary(completion.ProviderMessage)
}
if completion.AuditEventID != nil {
updates["audit_event_id"] = completion.AuditEventID
}
if completion.ResourceID != nil {
updates["resource_id"] = completion.ResourceID
}
if completion.ResourceKey != nil {
updates["resource_key"] = completion.ResourceKey
}
if completion.RecoveryStrategy != "" {
updates["recovery_strategy"] = completion.RecoveryStrategy
}
@@ -187,12 +213,17 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
if r == nil || r.db == nil {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" {
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" ||
!validGeneratedString(input.IntegrationID, constants.IntegrationIDMaxLength) ||
!validOptionalString(input.ResourceID, constants.IntegrationResourceIDMaxLength) ||
!validOptionalString(input.ResourceKey, constants.IntegrationResourceKeyMaxLength) ||
!validOptionalString(input.CorrelationID, constants.IntegrationCorrelationIDMaxLength) {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
}
if input.IntegrationID == "" {
input.IntegrationID = uuid.NewString()
}
triggerSeries := input.IntegrationID
hash := sha256.Sum256(input.RawPayload)
summary, err := marshalSummary(map[string]any{
"content_type": input.ContentType,
@@ -208,7 +239,8 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
Provider: input.Provider, Direction: constants.IntegrationDirectionInbound, Operation: input.Operation,
ExternalID: optionalString(input.ExternalID), ResourceType: optionalString(input.ResourceType),
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, StartedAt: &now, Attempt: 1,
Result: constants.IntegrationResultPending, RequestSummary: summary,
TriggerSeries: &triggerSeries,
Result: constants.IntegrationResultPending, RequestSummary: summary,
ContentHash: hex.EncodeToString(hash[:]), RequestID: input.RequestID, CorrelationID: input.CorrelationID,
}
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
@@ -289,9 +321,28 @@ func validateAttempt(input Attempt) error {
if input.InitialResult != "" && input.InitialResult != constants.IntegrationResultPending && !isUnsentResult(input.InitialResult) {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 初始结果只能是待处理或未发送终态")
}
if !validGeneratedString(input.IntegrationID, constants.IntegrationIDMaxLength) ||
!validOptionalString(input.TriggerSeries, constants.IntegrationTriggerSeriesMaxLength) ||
!validOptionalString(input.CorrelationID, constants.IntegrationCorrelationIDMaxLength) ||
!validOptionalString(input.ResourceID, constants.IntegrationResourceIDMaxLength) ||
!validOptionalString(input.ResourceKey, constants.IntegrationResourceKeyMaxLength) {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 链路或资源标识无效")
}
return nil
}
func validGeneratedString(value string, maxLength int) bool {
return value == "" || validRequiredString(value, maxLength)
}
func validRequiredString(value string, maxLength int) bool {
return strings.TrimSpace(value) == value && value != "" && utf8.RuneCountInString(value) <= maxLength
}
func validOptionalString(value *string, maxLength int) bool {
return value == nil || validRequiredString(*value, maxLength)
}
func isTerminalResult(result string) bool {
switch result {
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,

View File

@@ -14,6 +14,7 @@ import (
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -29,6 +30,7 @@ type DeliveryEnvelope struct {
BusinessKey string `json:"business_key,omitempty"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
Payload sonic.NoCopyRawMessage `json:"payload"`
}
@@ -253,7 +255,7 @@ func deliveryEnvelope(event model.OutboxEvent) DeliveryEnvelope {
EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion,
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
RequestID: event.RequestID, CorrelationID: event.CorrelationID, ParentEventID: event.ParentEventID,
Payload: sonic.NoCopyRawMessage(event.Payload),
}
}
@@ -326,5 +328,10 @@ func (h *Handler) Handle(ctx context.Context, task *asynq.Task) error {
if envelope.EventID == "" || envelope.EventType == "" || envelope.PayloadVersion <= 0 {
return stderrors.New("Outbox 事件信封不完整")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: envelope.EventType,
ActorName: "Outbox 消费任务", Source: constants.AuditSourceWorker,
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, ParentEventID: envelope.ParentEventID,
})
return h.consumer.Consume(ctx, envelope)
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -30,6 +31,7 @@ type Envelope struct {
BusinessKey string `json:"business_key,omitempty"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
Payload any `json:"payload"`
}
@@ -73,13 +75,24 @@ func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope,
if envelope.PayloadVersion <= 0 {
envelope.PayloadVersion = 1
}
linkage := auditcontext.From(ctx)
if envelope.RequestID == "" {
envelope.RequestID = linkage.RequestID
}
if envelope.CorrelationID == "" {
envelope.CorrelationID = linkage.CorrelationID
}
if envelope.ParentEventID == "" {
envelope.ParentEventID = linkage.ParentEventID
}
now := time.Now().UTC()
event := &model.OutboxEvent{
EventID: envelope.EventID, EventType: envelope.EventType, PayloadVersion: envelope.PayloadVersion,
AggregateType: envelope.AggregateType, AggregateID: envelope.AggregateID,
ResourceType: envelope.ResourceType, ResourceID: envelope.ResourceID, BusinessKey: envelope.BusinessKey,
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, ParentEventID: envelope.ParentEventID,
Payload: datatypes.JSON(payload),
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
}
create := tx.WithContext(ctx)
if idempotent {

View File

@@ -32,7 +32,8 @@ func (w *AgentRechargePaymentEventWriter) Append(ctx context.Context, tx *gorm.D
PayloadVersion: constants.AgentRechargePaymentConfirmedPayloadVersionV1,
AggregateType: "agent_recharge", AggregateID: strconv.FormatUint(uint64(event.RechargeID), 10),
ResourceType: "payment", ResourceID: strconv.FormatUint(uint64(event.PaymentID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
ParentEventID: event.ParentEventID, Payload: event,
})
return err
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/hibiken/asynq"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -20,10 +22,18 @@ func NewAgentRechargeRecoveryTaskHandler(service *agentrecharge.RecoverOnlinePay
}
// Handle 扫描长期待支付记录并复用原支付单号收敛渠道状态。
func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复任务未配置")
}
taskType := constants.TaskTypeAgentRechargeRecovery
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "代理在线充值支付恢复计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.ProcessBatch(ctx)
return err
}

View File

@@ -118,7 +118,7 @@ func (a *AlipayWapAdapter) completeUnknown(ctx context.Context, integrationID st
a.logger.Warn("支付宝支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
}
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "支付宝支付请求结果未知",
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "支付宝支付请求结果未知",
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})

View File

@@ -184,7 +184,7 @@ func (a *WechatWebAdapter) completeUnknown(ctx context.Context, integrationID st
a.logger.Warn("微信支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
}
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "微信支付请求结果未知",
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "微信支付请求结果未知",
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})

View File

@@ -92,10 +92,13 @@ func (u *ApprovalAttachmentUploader) Upload(ctx context.Context, applicationID,
return "", err
}
resourceID := strconv.FormatUint(uint64(instanceID), 10)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(nil)
attempt, err := u.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
ResourceID: &resourceID, TriggerSeries: triggerSeries, CorrelationID: correlationID,
RequestSummary: map[string]any{
"application_id": applicationID, "file_name": fileName, "file_bytes": info.Size(),
},
})

View File

@@ -42,7 +42,7 @@ func NewApprovalDetailClient(tokens DirectoryTokenProvider, integration TokenInt
}
// Get 获取审批详情并记录一次真实外呼。
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string) (ApprovalDetail, error) {
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string, resourceID *string, correlationID string) (ApprovalDetail, error) {
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return ApprovalDetail{}, err
@@ -51,11 +51,14 @@ func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo
if err != nil {
return ApprovalDetail{}, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
resourceKey := strings.TrimSpace(spNo)
triggerSeries := "wecom-approval-detail:" + resourceKey
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalDetail, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, ExternalID: &spNo, RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
ResourceID: resourceID, ResourceKey: &resourceKey, ExternalID: &spNo,
TriggerSeries: &triggerSeries, CorrelationID: optionalIntegrationString(correlationID),
RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
})
if err != nil {
return ApprovalDetail{}, err

View File

@@ -2,6 +2,7 @@ package wecom
import (
"context"
"strconv"
"strings"
"time"
@@ -39,11 +40,18 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
if !validApprovalSyncSource(payload.Source) {
return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步来源无效")
}
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo)
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
if err != nil {
return err
}
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
correlationID := payload.SPNo
var resourceID *string
if record != nil {
value := strconv.FormatUint(uint64(record.Instance.ID), 10)
resourceID = &value
correlationID = record.Instance.CorrelationID
}
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo, resourceID, correlationID)
if err != nil {
return err
}
@@ -68,11 +76,14 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
}
}
if strings.TrimSpace(payload.IntegrationID) != "" {
resolvedResourceID := strconv.FormatUint(uint64(record.Instance.ID), 10)
resolvedResourceKey := strings.TrimSpace(payload.SPNo)
_, err = h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultCompleted, ProviderCode: "processed",
ProviderMessage: "企业微信审批回调已完成权威详情同步",
ResponseSummary: map[string]any{"sp_no": payload.SPNo, "sp_status": detail.SPStatus, "decisions": decisions},
DurationMS: 0, StateChanged: len(decisions) > 0,
ResourceID: &resolvedResourceID, ResourceKey: &resolvedResourceKey,
})
}
return err

View File

@@ -71,14 +71,16 @@ func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery)
return ApprovalInfoPage{}, err
}
resourceID := strconv.FormatUint(uint64(input.ApplicationID), 10)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(nil)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalInfo, ResourceType: constants.WeComApprovalInstanceResourceType,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalInfo, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": input.ApplicationID, "starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(),
"template_id": input.TemplateID, "creator_userid": input.CreatorUserID, "size": input.Size,
"cursor_present": strings.TrimSpace(input.Cursor) != "",
},
}, TriggerSeries: triggerSeries, CorrelationID: correlationID,
})
if err != nil {
return ApprovalInfoPage{}, err

View File

@@ -76,13 +76,14 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
}
resourceID := strconv.FormatUint(uint64(input.InstanceID), 10)
correlationID := strings.TrimSpace(input.CorrelationID)
triggerSeries := "wecom-approval-submit:" + resourceID
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalSubmit, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": input.ApplicationID, "template_id": input.TemplateID,
"creator_source_configured": input.CreatorUserID != "", "control_count": len(input.Contents),
}, CorrelationID: optionalIntegrationString(correlationID),
}, CorrelationID: optionalIntegrationString(correlationID), TriggerSeries: &triggerSeries,
})
if err != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "写入企业微信审批提交日志失败", SafeToRetry: true}, err

View File

@@ -10,6 +10,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"go.uber.org/zap"
)
@@ -84,12 +85,13 @@ func (s *CallbackService) Receive(ctx context.Context, applicationID uint, signa
if err := xml.Unmarshal(plaintext, &event); err != nil || event.Event != "sys_approval_change" || strings.TrimSpace(event.SPNo) == "" {
return errors.New(errors.CodeInvalidParam, "企业微信审批回调事件无效")
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
resourceKey := strings.TrimSpace(event.SPNo)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := s.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: applicationCallbackIdempotencyKey(applicationID, signature),
Provider: constants.IntegrationProviderWeCom, Operation: constants.IntegrationOperationWeComApprovalCallback,
ExternalID: event.SPNo, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceID: &resourceID,
RawPayload: body, ContentType: "application/xml",
ExternalID: event.SPNo, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceKey: &resourceKey,
RawPayload: body, ContentType: "application/xml", RequestID: requestID, CorrelationID: &resourceKey,
})
if err != nil {
return err

View File

@@ -75,11 +75,13 @@ func (c *DirectoryClient) listVisibleDepartments(ctx context.Context, applicatio
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComVisibleDepartments, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{"application_id": applicationID},
RequestID: requestID, CorrelationID: requestID,
RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
})
if err != nil {
return nil, err
@@ -115,12 +117,14 @@ func (c *DirectoryClient) listDepartmentMembers(ctx context.Context, application
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "department_id": departmentID, "fetch_child": true,
}, RequestID: requestID, CorrelationID: requestID,
}, RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
})
if err != nil {
return nil, err

View File

@@ -0,0 +1,11 @@
package wecom
import "github.com/google/uuid"
func singleIntegrationLinkage(correlationID *string) (string, *string, *string) {
integrationID := uuid.NewString()
if correlationID == nil {
correlationID = &integrationID
}
return integrationID, &integrationID, correlationID
}

View File

@@ -54,12 +54,14 @@ func (c *TemplateClient) GetTemplateDetail(ctx context.Context, applicationID ui
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComTemplateDetail, ResourceType: constants.WeComApprovalSceneResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "template_id": templateID,
}, RequestID: requestID, CorrelationID: requestID,
}, RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
})
if err != nil {
return wecomapp.TemplateDefinition{}, err

View File

@@ -161,12 +161,14 @@ func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, c
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
attempt, err := p.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAccessToken, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "corp_id": application.CorpID, "agent_id": application.AgentID,
}, RequestID: requestID, CorrelationID: requestID,
}, RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
})
if err != nil {
return "", err

View File

@@ -14,6 +14,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"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/errors"
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -245,5 +246,13 @@ func injectAgentOpenAPIContext(c *fiber.Ctx, shopStore *postgres.ShopStore, acco
SubordinateShopIDs: shopIDs,
}
pkgmiddleware.SetUserToFiberContext(c, info)
var actorShopID *uint
if shopID > 0 {
actorShopID = &shopID
}
c.SetUserContext(auditcontext.With(c.UserContext(), auditcontext.Context{
ActorKind: constants.AuditActorOpenAPI, ActorID: strconv.FormatUint(uint64(account.ID), 10),
ActorName: account.Username, ActorShopID: actorShopID, Source: constants.AuditSourceOpenAPI,
}))
return nil
}

View File

@@ -0,0 +1,76 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// AuditEvent 是不可变内部业务审计事件。
type AuditEvent struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex" json:"event_id"`
OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"`
Category string `gorm:"column:category;type:varchar(64);not null" json:"category"`
ActionCode string `gorm:"column:action_code;type:varchar(100);not null" json:"action_code"`
ActionName string `gorm:"column:action_name;type:varchar(200);not null" json:"action_name"`
Summary string `gorm:"column:summary;type:varchar(500);not null" json:"summary"`
ActorKind string `gorm:"column:actor_kind;type:varchar(32);not null" json:"actor_kind"`
ActorID string `gorm:"column:actor_id;type:varchar(128);not null" json:"actor_id"`
ActorName string `gorm:"column:actor_name;type:varchar(200);not null" json:"actor_name"`
ActorShopID *uint `gorm:"column:actor_shop_id" json:"actor_shop_id,omitempty"`
ActorShopName string `gorm:"column:actor_shop_name;type:varchar(200);not null;default:''" json:"actor_shop_name,omitempty"`
ActorEnterpriseID *uint `gorm:"column:actor_enterprise_id" json:"actor_enterprise_id,omitempty"`
ActorEnterpriseName string `gorm:"column:actor_enterprise_name;type:varchar(200);not null;default:''" json:"actor_enterprise_name,omitempty"`
Source string `gorm:"column:source;type:varchar(32);not null" json:"source"`
RequestPath string `gorm:"column:request_path;type:varchar(300);not null;default:''" json:"request_path,omitempty"`
RequestMethod string `gorm:"column:request_method;type:varchar(16);not null;default:''" json:"request_method,omitempty"`
IPAddress string `gorm:"column:ip_address;type:varchar(64);not null;default:''" json:"ip_address,omitempty"`
UserAgent string `gorm:"column:user_agent;type:varchar(500);not null;default:''" json:"user_agent,omitempty"`
ScopeType string `gorm:"column:scope_type;type:varchar(32);not null" json:"scope_type"`
ScopeID string `gorm:"column:scope_id;type:varchar(128);not null;default:''" json:"scope_id,omitempty"`
ScopeName string `gorm:"column:scope_name;type:varchar(200);not null;default:''" json:"scope_name,omitempty"`
Result string `gorm:"column:result;type:varchar(16);not null" json:"result"`
RiskLevel string `gorm:"column:risk_level;type:varchar(16);not null" json:"risk_level"`
ErrorCode string `gorm:"column:error_code;type:varchar(100);not null;default:''" json:"error_code,omitempty"`
ErrorSummary string `gorm:"column:error_summary;type:varchar(500);not null;default:''" json:"error_summary,omitempty"`
RequestID string `gorm:"column:request_id;type:varchar(100);not null;default:''" json:"request_id,omitempty"`
CorrelationID string `gorm:"column:correlation_id;type:varchar(100);not null;default:''" json:"correlation_id,omitempty"`
ParentEventID string `gorm:"column:parent_event_id;type:varchar(64);not null;default:''" json:"parent_event_id,omitempty"`
BatchTotal int `gorm:"column:batch_total;not null;default:0" json:"batch_total"`
SuccessCount int `gorm:"column:success_count;not null;default:0" json:"success_count"`
FailCount int `gorm:"column:fail_count;not null;default:0" json:"fail_count"`
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;not null;default:'{}'" json:"metadata"`
ContentHash string `gorm:"column:content_hash;type:varchar(64);not null" json:"content_hash"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
}
// TableName 返回统一审计事件表名。
func (AuditEvent) TableName() string {
return "tb_audit_event"
}
// AuditEventResource 是审计事件发生时的独立资源快照。
type AuditEventResource struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
AuditEventID uint `gorm:"column:audit_event_id;not null" json:"audit_event_id"`
ResourceType string `gorm:"column:resource_type;type:varchar(64);not null" json:"resource_type"`
ResourceID *string `gorm:"column:resource_id;type:varchar(128)" json:"resource_id,omitempty"`
ResourceKey string `gorm:"column:resource_key;type:varchar(200);not null" json:"resource_key"`
DisplayName string `gorm:"column:display_name;type:varchar(255);not null;default:''" json:"display_name"`
Relation string `gorm:"column:relation;type:varchar(16);not null" json:"relation"`
Role string `gorm:"column:role;type:varchar(64);not null" json:"role"`
IdentitySnapshot datatypes.JSON `gorm:"column:identity_snapshot;type:jsonb;not null;default:'{}'" json:"identity_snapshot"`
BeforeData datatypes.JSON `gorm:"column:before_data;type:jsonb;not null;default:'{}'" json:"before_data"`
AfterData datatypes.JSON `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"`
SubjectVisibility string `gorm:"column:subject_visibility;type:varchar(24);not null" json:"subject_visibility"`
SubjectSummary string `gorm:"column:subject_summary;type:varchar(500);not null;default:''" json:"subject_summary,omitempty"`
SubjectData datatypes.JSON `gorm:"column:subject_data;type:jsonb;not null;default:'{}'" json:"subject_data"`
SortOrder int `gorm:"column:sort_order;not null;default:0" json:"sort_order"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
}
// TableName 返回审计事件资源表名。
func (AuditEventResource) TableName() string {
return "tb_audit_event_resource"
}

View File

@@ -19,7 +19,7 @@ type AgentRechargeOnlineResponse struct {
RechargeSource string `json:"recharge_source" description:"充值来源 (agent_online:代理在线自充)"`
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
Amount int64 `json:"amount" description:"在线充值金额范围10000分~100000000分"`
QRContent string `json:"qr_content" description:"支付渠道原始扫码付款内容,由前端渲染二维码"`
QRContent string `json:"qr_content" description:"支付链接HTTPS URL,由前端渲染二维码"`
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
}

View File

@@ -0,0 +1,113 @@
package dto
// AuditEventListRequest 是平台审计事件列表的组合筛选参数。
type AuditEventListRequest struct {
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间RFC3339含时区"`
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间RFC3339含时区不包含该时刻"`
Action string `json:"action" query:"action" description:"稳定动作编码"`
Category string `json:"category" query:"category" description:"动作类别"`
ActorKind string `json:"actor_kind" query:"actor_kind" description:"操作者类型"`
ActorID string `json:"actor_id" query:"actor_id" description:"操作者稳定ID"`
Source string `json:"source" query:"source" description:"操作入口来源"`
Result string `json:"result" query:"result" description:"结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
Risk string `json:"risk" query:"risk" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
ScopeType string `json:"scope_type" query:"scope_type" description:"业务范围类型"`
ScopeID string `json:"scope_id" query:"scope_id" description:"业务范围稳定ID"`
ResourceType string `json:"resource_type" query:"resource_type" description:"Resource Registry 注册类型"`
ResourceID string `json:"resource_id" query:"resource_id" description:"资源内部稳定ID"`
ResourceKey string `json:"resource_key" query:"resource_key" description:"资源业务稳定Key"`
RequestID string `json:"request_id" query:"request_id" description:"HTTP请求关联ID"`
CorrelationID string `json:"correlation_id" query:"correlation_id" description:"跨步骤业务链路ID"`
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"`
}
// AuditEventIDParams 是审计事件详情路径参数。
type AuditEventIDParams struct {
EventID string `json:"event_id" path:"event_id" required:"true" description:"稳定审计事件ID"`
}
// AuditActorEventsRequest 是操作者行为时间线参数。
type AuditActorEventsRequest struct {
Kind string `json:"kind" path:"kind" required:"true" description:"操作者类型 (account:人工账号, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统)"`
ID string `json:"id" path:"id" required:"true" description:"操作者稳定ID"`
Action string `json:"action" query:"action" description:"稳定动作编码"`
Result string `json:"result" query:"result" description:"事件结果"`
Risk string `json:"risk" query:"risk" description:"风险等级"`
ResourceType string `json:"resource_type" query:"resource_type" description:"资源类型"`
ResourceID string `json:"resource_id" query:"resource_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"`
}
// AuditResourceSearchRequest 是首批注册资源的精确搜索参数。
type AuditResourceSearchRequest struct {
ResourceType string `json:"resource_type" query:"resource_type" required:"true" description:"资源类型 (iot_card:IoT卡, device:设备, shop:店铺, order:订单, refund:退款单)"`
Keyword string `json:"keyword" query:"keyword" required:"true" description:"精确业务标识卡支持ICCID/VirtualNo设备支持VirtualNo/IMEI/SN"`
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"`
}
// AuditResourceTimelineRequest 是通用资源时间线参数。
type AuditResourceTimelineRequest struct {
ResourceType string `json:"resource_type" path:"resource_type" required:"true" description:"Resource Registry 注册类型"`
ResourceID string `json:"resource_id" path:"resource_id" required:"true" description:"资源内部稳定ID"`
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间RFC3339含时区"`
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间RFC3339含时区不包含该时刻"`
Action string `json:"action" query:"action" description:"稳定动作编码"`
Result string `json:"result" query:"result" description:"事件结果"`
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 是代理和企业安全资源活动的路径及分页参数。
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:企业)"`
Identifier string `json:"identifier" path:"identifier" required:"true" description:"业务稳定标识卡使用ICCID设备使用VirtualNo其他资源使用对应业务编号"`
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"`
}
// IntegrationFilterRequest 是外部集成调查的公共受控筛选参数。
type IntegrationFilterRequest struct {
CreatedFrom string `json:"created_from" query:"created_from" required:"true" description:"开始时间RFC3339含时区必填"`
CreatedTo string `json:"created_to" query:"created_to" required:"true" description:"结束时间RFC3339含时区不包含该时刻必填"`
IntegrationID string `json:"integration_id" query:"integration_id" description:"稳定外部集成记录ID"`
Provider string `json:"provider" query:"provider" description:"外部服务提供方稳定编码"`
Direction string `json:"direction" query:"direction" description:"交互方向 (inbound:入站, outbound:出站)"`
Operation string `json:"operation" query:"operation" description:"外部操作稳定编码"`
Result string `json:"result" query:"result" description:"原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消)"`
ResultCategory string `json:"result_category" query:"result_category" description:"派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送)"`
ExternalID string `json:"external_id" query:"external_id" description:"外部系统业务或请求标识"`
ResourceType string `json:"resource_type" query:"resource_type" description:"本地主要资源类型"`
ResourceID string `json:"resource_id" query:"resource_id" description:"本地主要资源稳定ID"`
ResourceKey string `json:"resource_key" query:"resource_key" description:"本地主要资源稳定Key"`
TriggerSource string `json:"trigger_source" query:"trigger_source" description:"触发来源稳定编码"`
TriggerScene string `json:"trigger_scene" query:"trigger_scene" description:"触发业务场景"`
TriggerSeries string `json:"trigger_series" query:"trigger_series" description:"显式技术尝试序列ID"`
StateChanged *bool `json:"state_changed" query:"state_changed" description:"是否改变本地业务状态"`
HTTPStatus *int `json:"http_status" query:"http_status" minimum:"100" maximum:"599" description:"外部HTTP响应状态码"`
ProviderCode string `json:"provider_code" query:"provider_code" description:"外部服务稳定结果码"`
RequestID string `json:"request_id" query:"request_id" description:"来源HTTP请求ID"`
CorrelationID string `json:"correlation_id" query:"correlation_id" description:"跨步骤业务链路ID"`
}
// IntegrationOverviewRequest 是外部集成交互总览参数。
type IntegrationOverviewRequest struct {
IntegrationFilterRequest
Bucket string `json:"bucket" query:"bucket" description:"趋势时间粒度 (hour:小时, day:自然日)默认hour"`
}
// IntegrationListRequest 是外部集成交互列表参数。
type IntegrationListRequest struct {
IntegrationFilterRequest
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"`
}
// IntegrationIDParams 是外部集成详情路径参数。
type IntegrationIDParams struct {
IntegrationID string `json:"integration_id" path:"integration_id" required:"true" description:"稳定外部集成记录ID来自列表、通知target_key或调查节点"`
}

View File

@@ -163,7 +163,6 @@ type ShopBusinessOwnerCandidatePageResult struct {
type UpdateShopCreditLimitRequest struct {
CreditEnabled *bool `json:"credit_enabled" validate:"required" required:"true" description:"是否启用实际信用额度"`
CreditLimit *int64 `json:"credit_limit" validate:"required,min=0" required:"true" minimum:"0" description:"实际信用额度关闭时必须为0"`
Version *int `json:"version" validate:"required,min=0" required:"true" minimum:"0" description:"主钱包乐观锁版本"`
}
// UpdateShopCreditLimitParams 调整既有店铺实际信用额度参数。

View File

@@ -0,0 +1,18 @@
package dto
import (
"reflect"
"strings"
"testing"
)
// TestUpdateShopCreditLimitRequestDoesNotExposeVersion 验证乐观锁版本不属于前端调额契约。
func TestUpdateShopCreditLimitRequestDoesNotExposeVersion(t *testing.T) {
typeOfRequest := reflect.TypeOf(UpdateShopCreditLimitRequest{})
for i := 0; i < typeOfRequest.NumField(); i++ {
jsonName := strings.Split(typeOfRequest.Field(i).Tag.Get("json"), ",")[0]
if jsonName == "version" {
t.Fatal("调额请求不应暴露由服务端管理的乐观锁版本")
}
}
}

View File

@@ -36,7 +36,7 @@ type IntegrationLog struct {
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
RecoveryStrategy *string `gorm:"column:recovery_strategy;type:varchar(255)" json:"recovery_strategy,omitempty"`
RequestID *string `gorm:"column:request_id;type:varchar(64)" json:"request_id,omitempty"`
CorrelationID *string `gorm:"column:correlation_id;type:varchar(64)" json:"correlation_id,omitempty"`
CorrelationID *string `gorm:"column:correlation_id;type:varchar(100)" json:"correlation_id,omitempty"`
AuditEventID *uint `gorm:"column:audit_event_id" json:"audit_event_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`

View File

@@ -19,6 +19,7 @@ type OutboxEvent struct {
BusinessKey string `gorm:"column:business_key;type:varchar(150);not null;default:''" json:"business_key,omitempty"`
RequestID string `gorm:"column:request_id;type:varchar(100);not null;default:'';index" json:"request_id,omitempty"`
CorrelationID string `gorm:"column:correlation_id;type:varchar(100);not null;default:'';index" json:"correlation_id,omitempty"`
ParentEventID string `gorm:"column:parent_event_id;type:varchar(64);not null;default:'';index" json:"parent_event_id,omitempty"`
Payload datatypes.JSON `gorm:"column:payload;type:jsonb;not null" json:"payload"`
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_outbox_event_type_status,priority:2" json:"status"`
RetryCount int `gorm:"column:retry_count;type:int;not null;default:0" json:"retry_count"`

View File

@@ -0,0 +1,49 @@
package audit
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ActorEventFilter 定义操作者行为视角的受控筛选。
type ActorEventFilter struct {
Kind string
ID string
Action string
Result string
Risk string
ResourceType string
ResourceID string
CreatedFrom *time.Time
CreatedTo *time.Time
Page int
PageSize int
}
// ListActorEvents 查询指定人工账号、OpenAPI、系统任务或外部系统的历史行为。
func (q *Query) ListActorEvents(ctx context.Context, filter ActorEventFilter) (*EventPage, error) {
if filter.ID == "" || !validActorKind(filter.Kind) {
return nil, errors.New(errors.CodeInvalidParam)
}
return q.List(ctx, EventFilter{
ActorKind: filter.Kind, ActorID: filter.ID,
Action: filter.Action, Result: filter.Result, Risk: filter.Risk,
ResourceType: filter.ResourceType, ResourceID: filter.ResourceID,
CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo,
Page: filter.Page, PageSize: filter.PageSize,
})
}
func validActorKind(kind string) bool {
switch kind {
case constants.AuditActorAccount, constants.AuditActorPersonalCustomer,
constants.AuditActorOpenAPI, constants.AuditActorSystemTask,
constants.AuditActorScheduledJob, constants.AuditActorExternalSystem:
return true
default:
return false
}
}

View File

@@ -0,0 +1,396 @@
// Package audit 提供统一审计事件的只读调查查询。
package audit
import (
"context"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// EventFilter 定义平台全局事件列表的受控组合筛选。
type EventFilter struct {
CreatedFrom *time.Time
CreatedTo *time.Time
Action string
Category string
ActorKind string
ActorID string
Source string
Result string
Risk string
ScopeType string
ScopeID string
ResourceType string
ResourceID string
ResourceKey string
RequestID string
CorrelationID string
Page int
PageSize int
}
// EventPage 是平台全局事件稳定分页结果。
type EventPage struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []EventView `json:"items"`
}
// EventView 是不暴露 GORM Model 的审计事件投影。
type EventView struct {
EventID string `json:"event_id"`
OccurredAt time.Time `json:"occurred_at"`
Category string `json:"category"`
ActionCode string `json:"action_code"`
ActionName string `json:"action_name"`
Summary string `json:"summary"`
ActorKind string `json:"actor_kind"`
ActorID string `json:"actor_id"`
ActorName string `json:"actor_name"`
ActorShopID *uint `json:"actor_shop_id,omitempty"`
ActorShopName string `json:"actor_shop_name"`
ActorEnterpriseID *uint `json:"actor_enterprise_id,omitempty"`
ActorEnterpriseName string `json:"actor_enterprise_name"`
Source string `json:"source"`
RequestPath string `json:"request_path"`
RequestMethod string `json:"request_method"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
ScopeType string `json:"scope_type"`
ScopeID string `json:"scope_id"`
ScopeName string `json:"scope_name"`
Result string `json:"result"`
RiskLevel string `json:"risk_level"`
ErrorCode string `json:"error_code"`
ErrorSummary string `json:"error_summary"`
RequestID string `json:"request_id"`
CorrelationID string `json:"correlation_id"`
ParentEventID string `json:"parent_event_id"`
BatchTotal int `json:"batch_total"`
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
Metadata map[string]any `json:"metadata"`
ContentHash string `json:"content_hash"`
CreatedAt time.Time `json:"created_at"`
Resources []ResourceView `json:"resources"`
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
}
// InvestigationRefs 是平台调查视角间唯一允许使用的稳定跳转引用。
type InvestigationRefs struct {
EventID *string `json:"event_id"`
ActorRef *ActorRef `json:"actor_ref"`
ResourceRefs []InvestigationResourceRef `json:"resource_refs"`
RequestID *string `json:"request_id"`
CorrelationID *string `json:"correlation_id"`
IntegrationRefs []IntegrationRef `json:"integration_refs"`
}
// ActorRef 是操作者时间线的稳定引用。
type ActorRef struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// InvestigationResourceRef 是通用资源时间线的稳定引用。
type InvestigationResourceRef struct {
ResourceType string `json:"resource_type"`
ResourceID *string `json:"resource_id"`
ResourceKey string `json:"resource_key"`
DisplayName string `json:"display_name"`
}
// IntegrationRef 是 Integration 详情的稳定引用。
type IntegrationRef struct {
IntegrationID string `json:"integration_id"`
}
// ResourceView 是事件发生时独立资源身份与变化的只读投影。
type ResourceView struct {
ResourceType string `json:"resource_type"`
ResourceID *string `json:"resource_id,omitempty"`
ResourceKey string `json:"resource_key"`
DisplayName string `json:"display_name"`
Relation string `json:"relation"`
Role string `json:"role"`
IdentitySnapshot map[string]any `json:"identity_snapshot"`
BeforeData map[string]any `json:"before_data"`
AfterData map[string]any `json:"after_data"`
SubjectVisibility string `json:"subject_visibility"`
SubjectSummary string `json:"subject_summary"`
SubjectData map[string]any `json:"subject_data"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
}
// Query 提供平台统一审计事件列表与详情读取。
type Query struct {
db *gorm.DB
}
// New 创建统一审计事件 Query。
func New(db *gorm.DB) *Query {
return &Query{db: db}
}
// List 查询平台范围的全局审计事件。
func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if !validEventFilter(filter) {
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
query := q.applyFilters(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 := make([]model.AuditEvent, 0, filter.PageSize)
if err := query.Order("occurred_at DESC, id DESC").
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件失败")
}
items, err := q.project(ctx, rows)
if err != nil {
return nil, err
}
return &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
}
func validEventFilter(filter EventFilter) bool {
return validOptionalValue(filter.Result, constants.AuditResultSuccess, constants.AuditResultFailed,
constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown) &&
validOptionalValue(filter.Risk, constants.AuditRiskLow, constants.AuditRiskNormal,
constants.AuditRiskHigh, constants.AuditRiskCritical) &&
validOptionalValue(filter.Source, constants.AuditSourceAdminAPI, constants.AuditSourcePersonalAPI,
constants.AuditSourceOpenAPI, constants.AuditSourceWorker, constants.AuditSourceScheduler, constants.AuditSourceCallback) &&
(filter.ActorKind == "" || validActorKind(filter.ActorKind)) &&
filter.Page >= 0 && filter.PageSize >= 0 && filter.PageSize <= constants.MaxPageSize
}
func validOptionalValue(value string, allowed ...string) bool {
if value == "" {
return true
}
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
// Get 查询平台范围的单个稳定审计事件详情。
func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if eventID == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
var row model.AuditEvent
if err := q.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "审计事件不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件详情失败")
}
items, err := q.project(ctx, []model.AuditEvent{row})
if err != nil {
return nil, err
}
return &items[0], nil
}
func (q *Query) authorize(ctx context.Context) error {
if q == nil || q.db == nil {
return errors.New(errors.CodeServiceUnavailable, "审计查询能力未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil
}
func (q *Query) applyFilters(query *gorm.DB, filter EventFilter) *gorm.DB {
if filter.CreatedFrom != nil {
query = query.Where("occurred_at >= ?", filter.CreatedFrom.UTC())
}
if filter.CreatedTo != nil {
query = query.Where("occurred_at < ?", filter.CreatedTo.UTC())
}
for column, value := range map[string]string{
"action_code": filter.Action, "category": filter.Category,
"actor_kind": filter.ActorKind, "actor_id": filter.ActorID,
"source": filter.Source, "result": filter.Result, "risk_level": filter.Risk,
"scope_type": filter.ScopeType, "scope_id": filter.ScopeID,
"request_id": filter.RequestID, "correlation_id": filter.CorrelationID,
} {
if value != "" {
query = query.Where(column+" = ?", value)
}
}
if filter.ResourceType != "" || filter.ResourceID != "" || filter.ResourceKey != "" {
resource := q.db.Table("tb_audit_event_resource AS aer").Select("1").
Where("aer.audit_event_id = tb_audit_event.id")
if filter.ResourceType != "" {
resource = resource.Where("aer.resource_type = ?", filter.ResourceType)
}
if filter.ResourceID != "" {
resource = resource.Where("aer.resource_id = ?", filter.ResourceID)
}
if filter.ResourceKey != "" {
resource = resource.Where("aer.resource_key = ?", filter.ResourceKey)
}
query = query.Where("EXISTS (?)", resource)
}
return query
}
func (q *Query) project(ctx context.Context, rows []model.AuditEvent) ([]EventView, error) {
items := make([]EventView, len(rows))
if len(rows) == 0 {
return items, nil
}
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
var resources []model.AuditEventResource
if err := q.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, errors.Wrap(errors.CodeDatabaseError, err, "批量查询审计事件资源失败")
}
resourcesByEvent := make(map[uint][]ResourceView, len(rows))
for _, resource := range resources {
view, err := projectResource(resource)
if err != nil {
return nil, err
}
resourcesByEvent[resource.AuditEventID] = append(resourcesByEvent[resource.AuditEventID], view)
}
for index, row := range rows {
metadata, err := decodeObject(row.Metadata)
if err != nil {
return nil, err
}
items[index] = EventView{
EventID: row.EventID, OccurredAt: row.OccurredAt, Category: row.Category,
ActionCode: row.ActionCode, ActionName: row.ActionName, Summary: row.Summary,
ActorKind: row.ActorKind, ActorID: row.ActorID, ActorName: row.ActorName,
ActorShopID: row.ActorShopID, ActorShopName: row.ActorShopName,
ActorEnterpriseID: row.ActorEnterpriseID, ActorEnterpriseName: row.ActorEnterpriseName,
Source: row.Source, RequestPath: row.RequestPath, RequestMethod: row.RequestMethod,
IPAddress: row.IPAddress, UserAgent: row.UserAgent,
ScopeType: row.ScopeType, ScopeID: row.ScopeID, ScopeName: row.ScopeName,
Result: row.Result, RiskLevel: row.RiskLevel, ErrorCode: row.ErrorCode, ErrorSummary: row.ErrorSummary,
RequestID: row.RequestID, CorrelationID: row.CorrelationID, ParentEventID: row.ParentEventID,
BatchTotal: row.BatchTotal, SuccessCount: row.SuccessCount, FailCount: row.FailCount,
Metadata: metadata, ContentHash: row.ContentHash, CreatedAt: row.CreatedAt,
Resources: resourcesByEvent[row.ID],
}
if items[index].Resources == nil {
items[index].Resources = []ResourceView{}
}
items[index].InvestigationRefs = investigationRefs(row, items[index].Resources)
}
return items, nil
}
func investigationRefs(event model.AuditEvent, resources []ResourceView) InvestigationRefs {
refs := InvestigationRefs{
EventID: stringPointer(event.EventID), ActorRef: investigationActorRef(event.ActorKind, event.ActorID),
ResourceRefs: make([]InvestigationResourceRef, 0, len(resources)),
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
IntegrationRefs: []IntegrationRef{},
}
for _, resource := range resources {
refs.ResourceRefs = append(refs.ResourceRefs, InvestigationResourceRef{
ResourceType: resource.ResourceType, ResourceID: resource.ResourceID,
ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName,
})
}
return refs
}
func investigationActorRef(kind, id string) *ActorRef {
if id == "" {
return nil
}
switch kind {
case constants.AuditActorAccount, constants.AuditActorOpenAPI, constants.AuditActorSystemTask,
constants.AuditActorScheduledJob, constants.AuditActorExternalSystem:
return &ActorRef{Kind: kind, ID: id}
default:
return nil
}
}
func stringPointer(value string) *string {
if value == "" {
return nil
}
return &value
}
func projectResource(row model.AuditEventResource) (ResourceView, error) {
identity, err := decodeObject(row.IdentitySnapshot)
if err != nil {
return ResourceView{}, err
}
before, err := decodeObject(row.BeforeData)
if err != nil {
return ResourceView{}, err
}
after, err := decodeObject(row.AfterData)
if err != nil {
return ResourceView{}, err
}
subject, err := decodeObject(row.SubjectData)
if err != nil {
return ResourceView{}, err
}
return ResourceView{
ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResourceKey: row.ResourceKey,
DisplayName: row.DisplayName, Relation: row.Relation, Role: row.Role,
IdentitySnapshot: identity, BeforeData: before, AfterData: after,
SubjectVisibility: row.SubjectVisibility, SubjectSummary: row.SubjectSummary, SubjectData: subject,
SortOrder: row.SortOrder, CreatedAt: row.CreatedAt,
}, nil
}
func decodeObject(value datatypes.JSON) (map[string]any, error) {
result := map[string]any{}
if len(value) == 0 {
return result, nil
}
if err := sonic.Unmarshal(value, &result); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析审计结构化字段失败")
}
return result, nil
}
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}

View File

@@ -0,0 +1,247 @@
package audit
import (
"context"
"strconv"
"time"
"gorm.io/datatypes"
"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"
)
// ResourceSearchFilter 定义注册资源的精确标识搜索。
type ResourceSearchFilter struct {
ResourceType string
Keyword string
Page int
PageSize int
}
// ResourceSearchPage 是资源候选稳定分页结果。
type ResourceSearchPage struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []ResourceCandidate `json:"items"`
}
// ResourceCandidate 是当前业务表或历史事件快照解析出的稳定资源候选。
type ResourceCandidate struct {
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
ResourceKey string `json:"resource_key"`
DisplayName string `json:"display_name"`
IdentitySnapshot map[string]any `json:"identity_snapshot"`
Historical bool `json:"historical"`
}
// ResourceTimelineFilter 定义通用资源时间线筛选。
type ResourceTimelineFilter struct {
ResourceType string
ResourceID string
CreatedFrom *time.Time
CreatedTo *time.Time
Action string
Result string
Page int
PageSize int
}
// SearchResources 按 Resource Registry 声明的稳定标识精确搜索资源。
func (q *Query) SearchResources(ctx context.Context, filter ResourceSearchFilter) (*ResourceSearchPage, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if filter.Keyword == "" || !searchableResourceType(filter.ResourceType) {
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
items, total, err := q.searchCurrent(ctx, filter)
if err != nil {
return nil, err
}
if total == 0 {
items, total, err = q.searchHistorical(ctx, filter)
if err != nil {
return nil, err
}
}
return &ResourceSearchPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
}
// ResourceTimeline 查询注册资源作为任意关系参与的统一事件时间线。
func (q *Query) ResourceTimeline(ctx context.Context, filter ResourceTimelineFilter) (*EventPage, error) {
if filter.ResourceID == "" || !timelineResourceType(filter.ResourceType) {
return nil, errors.New(errors.CodeInvalidParam)
}
return q.List(ctx, EventFilter{
ResourceType: filter.ResourceType, ResourceID: filter.ResourceID,
CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo,
Action: filter.Action, Result: filter.Result,
Page: filter.Page, PageSize: filter.PageSize,
})
}
func timelineResourceType(resourceType string) bool {
switch resourceType {
case constants.AuditResourceAccount, constants.AuditResourceShop, constants.AuditResourceEnterprise,
constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceDeviceSIMBinding,
constants.AuditResourceAssetAllocationRecord, constants.AuditResourceExchangeOrder, constants.AuditResourceOrder,
constants.AuditResourceRefund, constants.AuditResourceAgentRecharge, constants.AuditResourceAssetWallet,
constants.AuditResourceApprovalInstance:
return true
default:
return false
}
}
func (q *Query) searchCurrent(ctx context.Context, filter ResourceSearchFilter) ([]ResourceCandidate, int64, error) {
switch filter.ResourceType {
case constants.AuditResourceIotCard:
var rows []model.IotCard
query := q.db.WithContext(ctx).Where("iccid = ? OR virtual_no = ? OR iccid_19 = ? OR iccid_20 = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
return searchModels(query, filter, &rows, func(row model.IotCard) ResourceCandidate {
return candidate(filter.ResourceType, row.ID, row.ICCID, row.ICCID, map[string]any{
"id": row.ID, "iccid": row.ICCID, "virtual_no": row.VirtualNo, "msisdn": row.MSISDN,
"carrier_type": row.CarrierType, "shop_id": row.ShopID, "series_id": row.SeriesID, "generation": row.Generation,
})
})
case constants.AuditResourceDevice:
var rows []model.Device
query := q.db.WithContext(ctx).Where("virtual_no = ? OR imei = ? OR sn = ?", filter.Keyword, filter.Keyword, filter.Keyword)
return searchModels(query, filter, &rows, func(row model.Device) ResourceCandidate {
return candidate(filter.ResourceType, row.ID, deviceCandidateKey(row), deviceCandidateKey(row), map[string]any{
"id": row.ID, "virtual_no": row.VirtualNo, "imei": row.IMEI, "sn": row.SN,
"device_name": row.DeviceName, "device_model": row.DeviceModel, "shop_id": row.ShopID,
"series_id": row.SeriesID, "generation": row.Generation,
})
})
case constants.AuditResourceShop:
var rows []model.Shop
return searchModels(q.db.WithContext(ctx).Where("shop_code = ?", filter.Keyword), filter, &rows, func(row model.Shop) ResourceCandidate {
return candidate(filter.ResourceType, row.ID, row.ShopCode, row.ShopName, map[string]any{
"id": row.ID, "shop_code": row.ShopCode, "shop_name": row.ShopName, "parent_id": row.ParentID, "level": row.Level,
})
})
case constants.AuditResourceOrder:
var rows []model.Order
return searchModels(q.db.WithContext(ctx).Where("order_no = ?", filter.Keyword), filter, &rows, func(row model.Order) ResourceCandidate {
return candidate(filter.ResourceType, row.ID, row.OrderNo, row.OrderNo, map[string]any{
"id": row.ID, "order_no": row.OrderNo, "buyer_type": row.BuyerType, "buyer_id": row.BuyerID,
"asset_identifier": row.AssetIdentifier, "total_amount": row.TotalAmount,
"payment_method": row.PaymentMethod, "payment_status": row.PaymentStatus,
})
})
case constants.AuditResourceRefund:
var rows []model.RefundRequest
return searchModels(q.db.WithContext(ctx).Where("refund_no = ?", filter.Keyword), filter, &rows, func(row model.RefundRequest) ResourceCandidate {
return candidate(filter.ResourceType, row.ID, row.RefundNo, row.RefundNo, map[string]any{
"id": row.ID, "refund_no": row.RefundNo, "order_id": row.OrderID, "order_no": row.OrderNo,
"asset_identifier": row.AssetIdentifier, "shop_id": row.ShopID,
"requested_refund_amount": row.RequestedRefundAmount, "status": row.Status,
})
})
default:
return nil, 0, errors.New(errors.CodeInvalidParam)
}
}
func searchModels[T any](query *gorm.DB, filter ResourceSearchFilter, rows *[]T, project func(T) ResourceCandidate) ([]ResourceCandidate, int64, error) {
var total int64
if err := query.Model(new(T)).Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计资源候选失败")
}
if err := query.Order("id ASC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(rows).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资源候选失败")
}
items := make([]ResourceCandidate, 0, len(*rows))
for _, row := range *rows {
items = append(items, project(row))
}
return items, total, nil
}
func (q *Query) searchHistorical(ctx context.Context, filter ResourceSearchFilter) ([]ResourceCandidate, int64, error) {
base := q.historicalIdentifierQuery(ctx, filter)
var total int64
if err := base.Distinct("resource_id").Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计历史资源候选失败")
}
latest := q.historicalIdentifierQuery(ctx, filter).
Select("DISTINCT ON (resource_id) resource_id, resource_key, display_name, identity_snapshot, created_at, id").
Order("resource_id ASC, created_at DESC, id DESC")
var rows []historicalResourceRow
if err := q.db.WithContext(ctx).Table("(?) AS historical", latest).
Order("resource_id ASC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询历史资源候选失败")
}
items := make([]ResourceCandidate, 0, len(rows))
for _, row := range rows {
identity, err := decodeObject(row.IdentitySnapshot)
if err != nil {
return nil, 0, err
}
items = append(items, ResourceCandidate{
ResourceType: filter.ResourceType, ResourceID: row.ResourceID,
ResourceKey: row.ResourceKey, DisplayName: row.DisplayName,
IdentitySnapshot: identity, Historical: true,
})
}
return items, total, nil
}
func (q *Query) historicalIdentifierQuery(ctx context.Context, filter ResourceSearchFilter) *gorm.DB {
query := q.db.WithContext(ctx).Model(&model.AuditEventResource{}).
Where("resource_type = ? AND resource_id IS NOT NULL", filter.ResourceType)
switch filter.ResourceType {
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)
case constants.AuditResourceDevice:
return query.Where("resource_key = ? OR identity_snapshot ->> 'virtual_no' = ? OR identity_snapshot ->> 'imei' = ? OR identity_snapshot ->> 'sn' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
case constants.AuditResourceShop:
return query.Where("resource_key = ? OR identity_snapshot ->> 'shop_code' = ?", filter.Keyword, filter.Keyword)
case constants.AuditResourceOrder:
return query.Where("resource_key = ? OR identity_snapshot ->> 'order_no' = ?", filter.Keyword, filter.Keyword)
case constants.AuditResourceRefund:
return query.Where("resource_key = ? OR identity_snapshot ->> 'refund_no' = ?", filter.Keyword, filter.Keyword)
default:
return query.Where("1 = 0")
}
}
type historicalResourceRow struct {
ResourceID string
ResourceKey string
DisplayName string
IdentitySnapshot datatypes.JSON
}
func candidate(resourceType string, id uint, key, name string, identity map[string]any) ResourceCandidate {
return ResourceCandidate{
ResourceType: resourceType, ResourceID: strconv.FormatUint(uint64(id), 10),
ResourceKey: key, DisplayName: name, IdentitySnapshot: identity,
}
}
func searchableResourceType(resourceType string) bool {
switch resourceType {
case constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceShop,
constants.AuditResourceOrder, constants.AuditResourceRefund:
return true
default:
return false
}
}
func deviceCandidateKey(row model.Device) string {
for _, value := range []string{row.VirtualNo, row.IMEI, row.SN} {
if value != "" {
return value
}
}
return strconv.FormatUint(uint64(row.ID), 10)
}

View File

@@ -0,0 +1,395 @@
package audit
import (
"context"
"strconv"
"time"
"gorm.io/datatypes"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// SubjectActivityFilter 定义代理资源活动的稳定标识和分页参数。
type SubjectActivityFilter struct {
ResourceType string
Identifier string
Page int
PageSize int
}
// SubjectActivityPage 是不包含平台调查字段的代理资源活动分页结果。
type SubjectActivityPage struct {
Resource SubjectResourceSummary `json:"resource"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []SubjectActivity `json:"items"`
}
// SubjectActivity 是写入时已生成的主体安全活动投影。
type SubjectActivity struct {
ActionCode string `json:"action_code"`
ActionName string `json:"action_name"`
SubjectSummary string `json:"subject_summary"`
SubjectData map[string]any `json:"subject_data"`
Result string `json:"result"`
OccurredAt time.Time `json:"occurred_at"`
RelatedResources []SubjectResourceSummary `json:"related_resources"`
}
// SubjectResourceSummary 是主体活动允许公开的资源摘要。
type SubjectResourceSummary struct {
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
ResourceKey string `json:"resource_key"`
DisplayName string `json:"display_name"`
}
type subjectTarget struct {
summary SubjectResourceSummary
id string
}
type subjectActivityRow struct {
ID uint
ActionCode string
ActionName string
Result string
OccurredAt time.Time
SubjectSummary string
SubjectData datatypes.JSON
TargetResourceID uint
}
type subjectResourceAuthorizer func(context.Context, []model.AuditEventResource) (map[string]bool, error)
// AgentResourceActivities 查询代理自身及下级店铺范围内的安全资源活动。
func (q *Query) AgentResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) {
shopIDs, err := q.agentShopScope(ctx)
if err != nil {
return nil, err
}
if filter.Identifier == "" || !agentActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs)
if err != nil {
return nil, err
}
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.agentAllowedResourceIDs(ctx, resources, shopIDs)
})
}
// EnterpriseResourceActivities 查询企业当前有效授权卡或设备的安全资源活动。
func (q *Query) EnterpriseResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) {
if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
if enterpriseID == 0 {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if filter.Identifier == "" || !enterpriseActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID)
if err != nil {
return nil, err
}
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.enterpriseAllowedResourceIDs(ctx, resources, enterpriseID)
})
}
func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectActivityFilter, target subjectTarget, authorize subjectResourceAuthorizer) (*SubjectActivityPage, error) {
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.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail})
base := q.db.WithContext(ctx).Model(&model.AuditEvent{}).Where("EXISTS (?)", resourceMatch)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理资源活动失败")
}
rows := make([]subjectActivityRow, 0, filter.PageSize)
if err := base.Select("tb_audit_event.id, action_code, action_name, result, occurred_at, target.subject_summary, target.subject_data, target.id AS target_resource_id").
Joins("JOIN tb_audit_event_resource AS target ON 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}).
Order("occurred_at DESC, tb_audit_event.id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理资源活动失败")
}
items, err := q.projectSubjectActivities(ctx, rows, authorize)
if err != nil {
return nil, err
}
return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
}
func (q *Query) resolveEnterpriseTarget(ctx context.Context, resourceType, identifier string, enterpriseID uint) (subjectTarget, error) {
var target subjectTarget
switch resourceType {
case constants.AuditResourceIotCard:
var row model.IotCard
err := q.db.WithContext(ctx).Table("tb_iot_card AS card").
Joins("JOIN tb_enterprise_card_authorization AS auth ON auth.card_id = card.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL").
Where("card.iccid = ? AND auth.enterprise_id = ? AND card.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error
if err != nil {
return target, q.subjectTargetError(err)
}
target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID)
case constants.AuditResourceDevice:
var row model.Device
err := q.db.WithContext(ctx).Table("tb_device AS device").
Joins("JOIN tb_enterprise_device_authorization AS auth ON auth.device_id = device.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL").
Where("device.virtual_no = ? AND auth.enterprise_id = ? AND device.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error
if err != nil {
return target, q.subjectTargetError(err)
}
target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo)
}
return target, nil
}
func (q *Query) agentShopScope(ctx context.Context) ([]uint, error) {
if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
shopIDs := middleware.GetSubordinateShopIDs(ctx)
if len(shopIDs) == 0 {
if shopID := middleware.GetShopIDFromContext(ctx); shopID > 0 {
return []uint{shopID}, nil
}
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return shopIDs, nil
}
func (q *Query) resolveAgentTarget(ctx context.Context, resourceType, identifier string, shopIDs []uint) (subjectTarget, error) {
var target subjectTarget
query := q.db.WithContext(ctx)
switch resourceType {
case constants.AuditResourceIotCard:
var row model.IotCard
if err := query.Where("iccid = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceDevice:
var row model.Device
if err := query.Where("virtual_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceShop:
var row model.Shop
if err := query.Where("shop_code = ? AND id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.ShopCode, row.ShopName)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceEnterprise:
var row model.Enterprise
if err := query.Where("enterprise_code = ? AND owner_shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.EnterpriseCode, row.EnterpriseName)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceExchangeOrder:
var row model.ExchangeOrder
if err := query.Where("exchange_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.ExchangeNo, row.ExchangeNo)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceAssetAllocationRecord:
var row model.AssetAllocationRecord
err := agentAllocationScope(query.Where("allocation_no = ?", identifier), shopIDs).Order("id DESC").First(&row).Error
if err != nil {
return target, q.subjectTargetError(err)
}
target = newSubjectTarget(resourceType, row.ID, row.AllocationNo, row.AllocationNo)
}
return target, nil
}
func agentAllocationScope(query *gorm.DB, shopIDs []uint) *gorm.DB {
return query.Where(`
(from_owner_type = 'shop' AND from_owner_id IN ?) OR
(to_owner_type = 'shop' AND to_owner_id IN ?) OR
(asset_type = 'iot_card' AND EXISTS (SELECT 1 FROM tb_iot_card c WHERE c.id = asset_id AND c.deleted_at IS NULL AND c.shop_id IN ?)) OR
(asset_type = 'device' AND EXISTS (SELECT 1 FROM tb_device d WHERE d.id = asset_id AND d.deleted_at IS NULL AND d.shop_id IN ?))`,
shopIDs, shopIDs, shopIDs, shopIDs)
}
func (q *Query) subjectTargetError(err error) error {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "校验代理资源范围失败")
}
func newSubjectTarget(resourceType string, id uint, key, name string) subjectTarget {
resourceID := strconv.FormatUint(uint64(id), 10)
return subjectTarget{summary: SubjectResourceSummary{ResourceType: resourceType, ResourceID: resourceID, ResourceKey: key, DisplayName: name}, id: resourceID}
}
func (q *Query) projectSubjectActivities(ctx context.Context, rows []subjectActivityRow, authorize subjectResourceAuthorizer) ([]SubjectActivity, error) {
items := make([]SubjectActivity, 0, len(rows))
if len(rows) == 0 {
return items, nil
}
eventIDs := make([]uint, 0, len(rows))
for _, row := range rows {
eventIDs = append(eventIDs, row.ID)
}
var resources []model.AuditEventResource
if err := q.db.WithContext(ctx).Where("audit_event_id IN ? AND subject_visibility IN ?", eventIDs, []string{constants.AuditSubjectResult, constants.AuditSubjectDetail}).
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询主体可见关联资源失败")
}
allowed, err := authorize(ctx, resources)
if err != nil {
return nil, err
}
related := make(map[uint][]SubjectResourceSummary, len(rows))
for _, resource := range resources {
if resource.ResourceID == nil || !allowed[resourceAccessKey(resource.ResourceType, *resource.ResourceID)] {
continue
}
related[resource.AuditEventID] = append(related[resource.AuditEventID], SubjectResourceSummary{
ResourceType: resource.ResourceType, ResourceID: *resource.ResourceID,
ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName,
})
}
for _, row := range rows {
data, err := decodeObject(row.SubjectData)
if err != nil {
return nil, err
}
items = append(items, SubjectActivity{ActionCode: row.ActionCode, ActionName: row.ActionName,
SubjectSummary: row.SubjectSummary, SubjectData: data, Result: row.Result,
OccurredAt: row.OccurredAt, RelatedResources: related[row.ID]})
if items[len(items)-1].RelatedResources == nil {
items[len(items)-1].RelatedResources = []SubjectResourceSummary{}
}
}
return items, nil
}
func (q *Query) enterpriseAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, enterpriseID uint) (map[string]bool, error) {
idsByType := collectResourceIDs(resources)
allowed := make(map[string]bool)
queries := []struct {
resourceType string
table string
resourceID string
}{
{constants.AuditResourceIotCard, "tb_enterprise_card_authorization", "card_id"},
{constants.AuditResourceDevice, "tb_enterprise_device_authorization", "device_id"},
}
for _, spec := range queries {
ids := idsByType[spec.resourceType]
if len(ids) == 0 {
continue
}
var visible []uint
if err := q.db.WithContext(ctx).Table(spec.table).
Where("enterprise_id = ? AND "+spec.resourceID+" IN ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID, ids).
Pluck(spec.resourceID, &visible).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验企业关联资源授权失败")
}
markAllowedIDs(allowed, spec.resourceType, visible)
}
return allowed, nil
}
func (q *Query) agentAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, shopIDs []uint) (map[string]bool, error) {
idsByType := collectResourceIDs(resources)
allowed := make(map[string]bool)
queries := []struct {
resourceType string
table string
condition string
}{
{constants.AuditResourceIotCard, "tb_iot_card", "shop_id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceDevice, "tb_device", "shop_id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceShop, "tb_shop", "id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceEnterprise, "tb_enterprise", "owner_shop_id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceExchangeOrder, "tb_exchange_order", "shop_id IN ? AND deleted_at IS NULL"},
}
for _, spec := range queries {
if err := q.collectAgentAllowedIDs(ctx, allowed, spec.resourceType, spec.table, spec.condition, idsByType[spec.resourceType], shopIDs); err != nil {
return nil, err
}
}
if ids := idsByType[constants.AuditResourceAssetAllocationRecord]; len(ids) > 0 {
var visible []uint
if err := agentAllocationScope(q.db.WithContext(ctx).Table("tb_asset_allocation_record").Where("id IN ? AND deleted_at IS NULL", ids), shopIDs).
Pluck("id", &visible).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验代理分配记录关联资源失败")
}
markAllowedIDs(allowed, constants.AuditResourceAssetAllocationRecord, visible)
}
return allowed, nil
}
func collectResourceIDs(resources []model.AuditEventResource) map[string][]uint {
idsByType := make(map[string][]uint)
for _, resource := range resources {
if resource.ResourceID == nil {
continue
}
id, err := strconv.ParseUint(*resource.ResourceID, 10, 64)
if err == nil {
idsByType[resource.ResourceType] = append(idsByType[resource.ResourceType], uint(id))
}
}
return idsByType
}
func (q *Query) collectAgentAllowedIDs(ctx context.Context, allowed map[string]bool, resourceType, table, condition string, ids, shopIDs []uint) error {
if len(ids) == 0 {
return nil
}
var visible []uint
if err := q.db.WithContext(ctx).Table(table).Where("id IN ?", ids).Where(condition, shopIDs).Pluck("id", &visible).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验代理关联资源范围失败")
}
markAllowedIDs(allowed, resourceType, visible)
return nil
}
func markAllowedIDs(allowed map[string]bool, resourceType string, ids []uint) {
for _, id := range ids {
allowed[resourceAccessKey(resourceType, strconv.FormatUint(uint64(id), 10))] = true
}
}
func resourceAccessKey(resourceType, resourceID string) string {
return resourceType + ":" + resourceID
}
func agentActivityResourceType(resourceType string) bool {
switch resourceType {
case constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceAssetAllocationRecord,
constants.AuditResourceExchangeOrder, constants.AuditResourceShop, constants.AuditResourceEnterprise:
return true
default:
return false
}
}
func enterpriseActivityResourceType(resourceType string) bool {
return resourceType == constants.AuditResourceIotCard || resourceType == constants.AuditResourceDevice
}

View File

@@ -0,0 +1,389 @@
// Package integration 提供 Integration Log 只读调查投影。
package integration
import (
"context"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
// ListFilter 定义 Integration Log 组合筛选。
type ListFilter struct {
CreatedFrom, CreatedTo *time.Time
IntegrationID, Provider, Direction string
Operation, Result, ResultCategory string
ExternalID, ResourceType, ResourceID string
ResourceKey, TriggerSource, TriggerScene string
TriggerSeries, ProviderCode string
RequestID, CorrelationID string
StateChanged *bool
HTTPStatus *int
Page, PageSize int
}
// ListPage 是按创建时间和主键稳定倒序的分页结果。
type ListPage struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []ListItem `json:"items"`
}
// ListItem 是 Integration Log 列表投影。
type ListItem struct {
IntegrationID string `json:"integration_id"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
Direction string `json:"direction"`
DirectionName string `json:"direction_name"`
Operation string `json:"operation"`
OperationName string `json:"operation_name"`
Resource ResourceView `json:"resource"`
Result string `json:"result"`
ResultName string `json:"result_name"`
ResultCategory string `json:"result_category"`
DurationMS int64 `json:"duration_ms"`
StateChanged bool `json:"state_changed"`
RequestID *string `json:"request_id"`
CorrelationID *string `json:"correlation_id"`
CreatedAt time.Time `json:"created_at"`
}
// ResourceView 是外部交互直接主资源投影。
type ResourceView struct {
Type *string `json:"type"`
ID *string `json:"id"`
Key *string `json:"key"`
}
// Detail 是按稳定 integration_id 返回的结构化详情。
type Detail struct {
Identity IdentityView `json:"identity"`
Resource ResourceView `json:"resource"`
Trigger TriggerView `json:"trigger"`
Result ResultView `json:"result"`
Content ContentView `json:"content"`
Linkage LinkageView `json:"linkage"`
Timestamps TimestampView `json:"timestamps"`
Attempts []AttemptView `json:"attempts"`
Fidelity FidelityView `json:"fidelity"`
}
// AttemptView 是显式 trigger_series 下的单次技术尝试。
type AttemptView struct {
IntegrationID string `json:"integration_id"`
Attempt int `json:"attempt"`
Sent bool `json:"sent"`
Result string `json:"result"`
ResultName string `json:"result_name"`
ResultCategory string `json:"result_category"`
DurationMS int64 `json:"duration_ms"`
StateChanged bool `json:"state_changed"`
CreatedAt time.Time `json:"created_at"`
}
// FidelityView 明确历史记录可关联能力,不推断缺失字段。
type FidelityView struct {
TriggerSeriesAvailable bool `json:"trigger_series_available"`
CorrelationAvailable bool `json:"correlation_available"`
ResourceIDAvailable bool `json:"resource_id_available"`
ProviderMessageFidelity string `json:"provider_message_fidelity"`
}
// IdentityView 是外部交互身份分组。
type IdentityView struct {
IntegrationID string `json:"integration_id"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
Direction string `json:"direction"`
DirectionName string `json:"direction_name"`
Operation string `json:"operation"`
OperationName string `json:"operation_name"`
ExternalID *string `json:"external_id"`
}
// TriggerView 是外部交互触发分组。
type TriggerView struct {
Source *string `json:"source"`
Scene *string `json:"scene"`
Series *string `json:"series"`
Attempt int `json:"attempt"`
}
// ResultView 是外部交互结果分组。
type ResultView struct {
Code string `json:"code"`
Name string `json:"name"`
Category string `json:"category"`
HTTPStatus *int `json:"http_status"`
ProviderCode *string `json:"provider_code"`
ProviderMessage *string `json:"provider_message"`
DurationMS int64 `json:"duration_ms"`
StateChanged bool `json:"state_changed"`
RecoveryStrategy *string `json:"recovery_strategy"`
}
// ContentView 是已持久化安全摘要分组。
type ContentView struct {
RequestSummary map[string]any `json:"request_summary"`
ResponseSummary map[string]any `json:"response_summary"`
Metadata map[string]any `json:"metadata"`
ContentHash string `json:"content_hash"`
}
// LinkageView 是外部交互关联分组。
type LinkageView struct {
RequestID *string `json:"request_id"`
CorrelationID *string `json:"correlation_id"`
AuditEventID *uint `json:"audit_event_id"`
}
// TimestampView 是外部交互时间分组。
type TimestampView struct {
ScheduledAt *time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Query 提供平台 Integration Log 列表和详情读取。
type Query struct{ db *gorm.DB }
// New 创建 Integration Log 调查 Query。
func New(db *gorm.DB) *Query { return &Query{db: db} }
// List 查询受时间范围约束的 Integration Log 列表。
func (q *Query) List(ctx context.Context, filter ListFilter) (*ListPage, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if !validFilter(filter) {
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
query := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计外部交互日志失败")
}
rows := make([]model.IntegrationLog, 0, 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 {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志失败")
}
items := make([]ListItem, len(rows))
for i, row := range rows {
items[i] = projectListItem(row)
}
return &ListPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
}
// Get 使用稳定 integration_id 查询结构化详情。
func (q *Query) Get(ctx context.Context, integrationID string) (*Detail, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if integrationID == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
var row model.IntegrationLog
if err := q.db.WithContext(ctx).Where("integration_id = ?", integrationID).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "外部交互日志不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志详情失败")
}
requestSummary, err := decodeObject(row.RequestSummary)
if err != nil {
return nil, err
}
responseSummary, err := decodeObject(row.ResponseSummary)
if err != nil {
return nil, err
}
metadata, err := decodeObject(row.Metadata)
if err != nil {
return nil, err
}
attempts, err := q.loadAttempts(ctx, row)
if err != nil {
return nil, err
}
providerMessage, providerMessageFidelity := safeProviderMessage(row.ProviderMessage)
return &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},
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},
Content: ContentView{RequestSummary: requestSummary, ResponseSummary: responseSummary, Metadata: metadata, ContentHash: row.ContentHash},
Linkage: LinkageView{RequestID: row.RequestID, CorrelationID: row.CorrelationID, AuditEventID: row.AuditEventID},
Timestamps: TimestampView{ScheduledAt: row.ScheduledAt, StartedAt: row.StartedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt},
Attempts: attempts,
Fidelity: FidelityView{
TriggerSeriesAvailable: row.TriggerSeries != nil && *row.TriggerSeries != "",
CorrelationAvailable: row.CorrelationID != nil && *row.CorrelationID != "",
ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "",
ProviderMessageFidelity: providerMessageFidelity,
},
}, nil
}
func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog) ([]AttemptView, error) {
rows := []model.IntegrationLog{current}
if current.TriggerSeries != nil && *current.TriggerSeries != "" {
if err := q.db.WithContext(ctx).Where("trigger_series = ?", *current.TriggerSeries).
Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败")
}
}
items := make([]AttemptView, len(rows))
for index, row := range rows {
category := constants.IntegrationResultCategory(row.Result)
items[index] = AttemptView{
IntegrationID: row.IntegrationID, Attempt: row.Attempt,
Sent: category != constants.IntegrationResultCategoryNotSent,
Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: category,
DurationMS: row.DurationMS, StateChanged: row.StateChanged, CreatedAt: row.CreatedAt,
}
}
return items, nil
}
func (q *Query) authorize(ctx context.Context) error {
if q == nil || q.db == nil {
return errors.New(errors.CodeServiceUnavailable, "外部交互调查能力未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil
}
func validFilter(filter ListFilter) bool {
if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) || filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.IntegrationQueryMaxRange {
return false
}
if filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
return false
}
if filter.Direction != "" && filter.Direction != constants.IntegrationDirectionInbound && filter.Direction != constants.IntegrationDirectionOutbound {
return false
}
if filter.Result != "" && constants.IntegrationResultName(filter.Result) == "" {
return false
}
if filter.ResultCategory != "" && len(categoryResults(filter.ResultCategory)) == 0 {
return false
}
return filter.HTTPStatus == nil || (*filter.HTTPStatus >= 100 && *filter.HTTPStatus <= 599)
}
func applyFilters(query *gorm.DB, filter ListFilter) *gorm.DB {
query = query.Where("created_at >= ? AND created_at < ?", filter.CreatedFrom.UTC(), filter.CreatedTo.UTC())
for column, value := range map[string]string{
"integration_id": filter.IntegrationID, "provider": filter.Provider, "direction": filter.Direction,
"operation": filter.Operation, "result": filter.Result, "external_id": filter.ExternalID,
"resource_type": filter.ResourceType, "resource_id": filter.ResourceID,
"trigger_source": filter.TriggerSource, "trigger_scene": filter.TriggerScene, "trigger_series": filter.TriggerSeries,
"provider_code": filter.ProviderCode, "request_id": filter.RequestID, "correlation_id": filter.CorrelationID,
} {
if value != "" {
query = query.Where(column+" = ?", value)
}
}
if filter.ResourceKey != "" {
query = query.Where("resource_key IN ?", compatibleResourceKeys(filter.ResourceType, filter.ResourceKey))
}
if filter.ResultCategory != "" {
query = query.Where("result IN ?", categoryResults(filter.ResultCategory))
}
if filter.StateChanged != nil {
query = query.Where("state_changed = ?", *filter.StateChanged)
}
if filter.HTTPStatus != nil {
query = query.Where("http_status = ?", *filter.HTTPStatus)
}
return query
}
func compatibleResourceKeys(resourceType, resourceKey string) []string {
keys := []string{resourceKey}
if resourceType != constants.AssetTypeIotCard || !pkgvalidator.ValidateICCIDWithoutCarrier(resourceKey).Valid {
return keys
}
sum := sha256.Sum256([]byte(resourceKey))
return append(keys, "iccid-sha256:"+hex.EncodeToString(sum[:])[:32])
}
func categoryResults(category string) []string {
switch category {
case constants.IntegrationResultCategoryProcessing:
return []string{constants.IntegrationResultPending}
case constants.IntegrationResultCategorySucceeded:
return []string{constants.IntegrationResultSuccess}
case constants.IntegrationResultCategoryIndeterminate:
return []string{constants.IntegrationResultUnknown}
case constants.IntegrationResultCategoryFailed:
return []string{constants.IntegrationResultFailed, constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultConflict}
case constants.IntegrationResultCategoryNotSent:
return []string{constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted, constants.IntegrationResultCancelled}
default:
return nil
}
}
func projectListItem(row model.IntegrationLog) ListItem {
return ListItem{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), Resource: resourceView(row), Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: constants.IntegrationResultCategory(row.Result), DurationMS: row.DurationMS, StateChanged: row.StateChanged, RequestID: row.RequestID, CorrelationID: row.CorrelationID, CreatedAt: row.CreatedAt}
}
func resourceView(row model.IntegrationLog) ResourceView {
return ResourceView{Type: row.ResourceType, ID: row.ResourceID, Key: row.ResourceKey}
}
func decodeObject(value datatypes.JSON) (map[string]any, error) {
result := map[string]any{}
if len(value) == 0 {
return result, nil
}
if err := sonic.Unmarshal(value, &result); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析外部交互结构化摘要失败")
}
sanitizer.RemoveForbiddenFields(result)
return result, nil
}
func safeProviderMessage(value *string) (*string, string) {
if value == nil || *value == "" {
return nil, "missing"
}
if len(*value) >= len("外部文本摘要") && (*value)[:len("外部文本摘要")] == "外部文本摘要" {
return value, "historical_summary"
}
if len(*value) >= len(constants.IntegrationSafeMessagePrefix) && (*value)[:len(constants.IntegrationSafeMessagePrefix)] == constants.IntegrationSafeMessagePrefix {
message := (*value)[len(constants.IntegrationSafeMessagePrefix):]
return &message, "readable"
}
summary := sanitizer.TextSummary(*value)
return &summary, "historical_redacted"
}
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = constants.DefaultPage
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
return page, pageSize
}

View File

@@ -0,0 +1,171 @@
package integration
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"
)
// OverviewFilter 定义外部交互总览的受控筛选和时间粒度。
type OverviewFilter struct {
ListFilter
Bucket string
}
// Overview 是外部交互固定维度聚合结果。
type Overview struct {
Total int64 `json:"total"`
AnomalyCount int64 `json:"anomaly_count"`
UnknownCount int64 `json:"unknown_count"`
StalePendingCount int64 `json:"stale_pending_count"`
StateChangedCount int64 `json:"state_changed_count"`
AverageDurationMS float64 `json:"average_duration_ms"`
P95DurationMS float64 `json:"p95_duration_ms"`
Results []ResultCount `json:"results"`
Providers []NamedCount `json:"providers"`
Directions []NamedCount `json:"directions"`
Trend []TrendPoint `json:"trend"`
}
// ResultCount 是原始结果及其派生类别计数。
type ResultCount struct {
Code string `json:"code"`
Name string `json:"name"`
Category string `json:"category"`
Count int64 `json:"count"`
}
// NamedCount 是稳定编码、中文名称和数量。
type NamedCount struct {
Code string `json:"code"`
Name string `json:"name"`
Count int64 `json:"count"`
}
// TrendPoint 是固定时间桶内的结果类别趋势。
type TrendPoint struct {
BucketAt time.Time `json:"bucket_at"`
Total int64 `json:"total"`
Succeeded int64 `json:"succeeded"`
Processing int64 `json:"processing"`
Indeterminate int64 `json:"indeterminate"`
Failed int64 `json:"failed"`
NotSent int64 `json:"not_sent"`
}
// Overview 查询指定时间范围的固定维度外部交互总览。
func (q *Query) Overview(ctx context.Context, filter OverviewFilter) (*Overview, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if filter.Bucket == "" {
filter.Bucket = "hour"
}
if !validFilter(filter.ListFilter) || (filter.Bucket != "hour" && filter.Bucket != "day") {
return nil, errors.New(errors.CodeInvalidParam)
}
base := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter.ListFilter)
result := &Overview{Results: []ResultCount{}, Providers: []NamedCount{}, Directions: []NamedCount{}, Trend: []TrendPoint{}}
if err := loadOverviewMetrics(base, result); err != nil {
return nil, err
}
if err := loadResultCounts(base, result); err != nil {
return nil, err
}
if err := loadNamedCounts(base, "provider", result); err != nil {
return nil, err
}
if err := loadNamedCounts(base, "direction", result); err != nil {
return nil, err
}
if err := loadTrend(base, filter.Bucket, result); err != nil {
return nil, err
}
return result, nil
}
func loadOverviewMetrics(query *gorm.DB, result *Overview) error {
failed := categoryResults(constants.IntegrationResultCategoryFailed)
var row struct {
Total, AnomalyCount, UnknownCount, StalePendingCount, StateChangedCount int64
AverageDurationMS, P95DurationMS float64
}
err := query.Select(`COUNT(*) AS total,
COUNT(*) FILTER (WHERE result IN ? OR result = ?) AS anomaly_count,
COUNT(*) FILTER (WHERE result = ?) AS unknown_count,
COUNT(*) FILTER (WHERE result = ? AND created_at < ?) AS stale_pending_count,
COUNT(*) FILTER (WHERE state_changed) AS state_changed_count,
COALESCE(AVG(duration_ms), 0)::float8 AS average_duration_ms,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p95_duration_ms`,
failed, constants.IntegrationResultUnknown, constants.IntegrationResultUnknown,
constants.IntegrationResultPending, time.Now().UTC().Add(-constants.IntegrationPendingStaleAfter)).Scan(&row).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互总览失败")
}
result.Total, result.AnomalyCount, result.UnknownCount = row.Total, row.AnomalyCount, row.UnknownCount
result.StalePendingCount, result.StateChangedCount = row.StalePendingCount, row.StateChangedCount
result.AverageDurationMS, result.P95DurationMS = row.AverageDurationMS, row.P95DurationMS
return nil
}
func loadResultCounts(query *gorm.DB, result *Overview) error {
var rows []struct {
Code string
Count int64
}
if err := query.Select("result AS code, COUNT(*) AS count").Group("result").Order("result ASC").Scan(&rows).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互结果分布失败")
}
for _, row := range rows {
result.Results = append(result.Results, ResultCount{Code: row.Code, Name: constants.IntegrationResultName(row.Code), Category: constants.IntegrationResultCategory(row.Code), Count: row.Count})
}
return nil
}
func loadNamedCounts(query *gorm.DB, column string, result *Overview) error {
var rows []struct {
Code string
Count int64
}
if err := query.Select(column + " AS code, COUNT(*) AS count").Group(column).Order(column + " ASC").Scan(&rows).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互维度分布失败")
}
items := make([]NamedCount, 0, len(rows))
for _, row := range rows {
name := constants.IntegrationProviderName(row.Code)
if column == "direction" {
name = constants.IntegrationDirectionName(row.Code)
}
items = append(items, NamedCount{Code: row.Code, Name: name, Count: row.Count})
}
if column == "provider" {
result.Providers = items
} else {
result.Directions = items
}
return nil
}
func loadTrend(query *gorm.DB, bucket string, result *Overview) error {
failed, notSent := categoryResults(constants.IntegrationResultCategoryFailed), categoryResults(constants.IntegrationResultCategoryNotSent)
return wrapTrendError(query.Select(`date_trunc(?, created_at) AS bucket_at, COUNT(*) AS total,
COUNT(*) FILTER (WHERE result = ?) AS succeeded,
COUNT(*) FILTER (WHERE result = ?) AS processing,
COUNT(*) FILTER (WHERE result = ?) AS indeterminate,
COUNT(*) FILTER (WHERE result IN ?) AS failed,
COUNT(*) FILTER (WHERE result IN ?) AS not_sent`, bucket, constants.IntegrationResultSuccess,
constants.IntegrationResultPending, constants.IntegrationResultUnknown, failed, notSent).
Group("bucket_at").Order("bucket_at ASC").Scan(&result.Trend).Error)
}
func wrapTrendError(err error) error {
if err == nil {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互趋势失败")
}

View File

@@ -144,6 +144,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.SystemConfig != nil {
registerSystemConfigRoutes(authGroup, handlers.SystemConfig, doc, basePath)
}
if handlers.Audit != nil {
registerAuditRoutes(authGroup, handlers.Audit, doc, basePath)
}
if handlers.WeCom != nil {
registerWeComRoutes(authGroup, handlers.WeCom, doc, basePath)
}

View File

@@ -129,8 +129,8 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
})
Register(assets, doc, groupPath, "GET", "/:identifier/operation-logs", handler.OperationLogs, RouteSpec{
Summary: "资产操作审计日志",
Description: "通过资产标识符查询审计日志,支持分页和操作类型/结果状态筛选。",
Summary: "查询平台旧资产操作日志",
Description: "仅超级管理员和平台账号可查询切换前旧资产日志;代理和企业必须使用独立资源活动接口。旧记录不接入统一审计时间线。",
Tags: []string{"资产管理"},
Input: new(dto.AssetOperationLogListRequest),
Output: new(dto.AssetOperationLogListResponse),

73
internal/routes/audit.go Normal file
View File

@@ -0,0 +1,73 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
auditquery "github.com/break/junhong_cmp_fiber/internal/query/audit"
integrationquery "github.com/break/junhong_cmp_fiber/internal/query/integration"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerAuditRoutes 注册平台基础审计调查只读路由。
func registerAuditRoutes(router fiber.Router, handler *admin.AuditHandler, doc *openapi.Generator, basePath string) {
agent := router.Group("/agent/resource-activities")
Register(agent, doc, basePath+"/agent/resource-activities", "GET", "/:resource_type/:identifier", handler.AgentResourceActivities, RouteSpec{
Summary: "查询代理资源活动",
Description: "resource_type/identifier 来自代理当前业务页面稳定字段;店铺范围只读取认证上下文。仅返回写入时生成的安全业务结论和白名单详情,越权与不存在同错。",
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
})
enterprise := router.Group("/enterprise/resource-activities")
Register(enterprise, doc, basePath+"/enterprise/resource-activities", "GET", "/:resource_type/:identifier", handler.EnterpriseResourceActivities, RouteSpec{
Summary: "查询企业资源活动",
Description: "仅支持企业当前有效授权的卡和设备;企业身份只读取认证上下文,授权撤销后立即不可读取。响应不包含平台操作者、风险、内部前后值或外部交互内容。",
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
})
audit := router.Group("/audit")
groupPath := basePath + "/audit"
Register(audit, doc, groupPath, "GET", "/events", handler.ListEvents, RouteSpec{
Summary: "查询全局审计事件",
Description: "筛选值来自调查人员输入或其他调查节点的稳定引用身份范围只读取认证上下文。固定按发生时间和事件ID倒序不提供导出、修改或删除。",
Tags: []string{"审计调查"}, Input: new(dto.AuditEventListRequest), Output: new(auditquery.EventPage), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/events/:event_id", handler.GetEvent, RouteSpec{
Summary: "查询审计事件详情",
Description: "event_id 来自事件、资源、操作者或链路节点的 investigation_refs返回全部资源快照和各资源 before/after。",
Tags: []string{"审计调查"}, Input: new(dto.AuditEventIDParams), Output: new(auditquery.EventView), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/actors/:kind/:id/events", handler.ListActorEvents, RouteSpec{
Summary: "查询操作者行为时间线",
Description: "kind/id 来自事件 actor_ref 或平台账号选择器;历史名称直接使用事件快照,不查询当前账号名称覆盖历史。",
Tags: []string{"审计调查"}, Input: new(dto.AuditActorEventsRequest), Output: new(auditquery.EventPage), Auth: true,
})
// 资源搜索静态路径必须先于资源动态时间线路径,避免被动态参数吞掉。
Register(audit, doc, groupPath, "GET", "/resources/search", handler.SearchResources, RouteSpec{
Summary: "精确搜索注册资源",
Description: "卡支持 ICCID/VirtualNo设备支持 VirtualNo/IMEI/SN店铺、订单、退款使用各自稳定编号。当前资源不存在时仅按 Registry 白名单快照字段精确查找历史,不做任意 JSON 模糊搜索。",
Tags: []string{"审计调查"}, Input: new(dto.AuditResourceSearchRequest), Output: new(auditquery.ResourceSearchPage), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/resources/:resource_type/:resource_id/timeline", handler.ResourceTimeline, RouteSpec{
Summary: "查询通用资源时间线",
Description: "resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。",
Tags: []string{"审计调查"}, Input: new(dto.AuditResourceTimelineRequest), Output: new(auditquery.EventPage), Auth: true,
})
// Integration 总览静态路径必须先于动态详情路径,避免 overview 被当作 integration_id。
Register(audit, doc, groupPath, "GET", "/integrations/overview", handler.IntegrationOverview, RouteSpec{
Summary: "查询外部集成交互总览",
Description: "筛选和时间范围来自调查输入或关联视角跳转,身份只来自认证上下文。总览区分成功、处理中、结果不确定、失败和未发送终态。",
Tags: []string{"审计调查"}, Input: new(dto.IntegrationOverviewRequest), Output: new(integrationquery.Overview), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/integrations", handler.ListIntegrations, RouteSpec{
Summary: "查询外部集成交互列表",
Description: "组合筛选来自调查输入或关联视角稳定引用固定按创建时间和记录ID倒序分页不提供任意摘要搜索。",
Tags: []string{"审计调查"}, Input: new(dto.IntegrationListRequest), Output: new(integrationquery.ListPage), Auth: true,
})
Register(audit, doc, groupPath, "GET", "/integrations/:integration_id", handler.GetIntegration, RouteSpec{
Summary: "查询外部集成交互详情",
Description: "integration_id 来自列表、通知目标 target_key 或调查节点稳定引用;只展示结构化详情和显式尝试序列,不提供重试、补偿、确认、绑定、恢复、修改、删除或导出。",
Tags: []string{"审计调查"}, Input: new(dto.IntegrationIDParams), Output: new(integrationquery.Detail), Auth: true,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,25 @@ package auth
import (
"context"
"sort"
"strconv"
accountauditapp "github.com/break/junhong_cmp_fiber/internal/application/accountaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
securityAudit accountauditapp.Writer
accountStore *postgres.AccountStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
@@ -25,6 +31,12 @@ type Service struct {
logger *zap.Logger
}
// SetSecurityAudit 注入后台认证安全状态的统一审计接缝。
func (s *Service) SetSecurityAudit(db *gorm.DB, writer accountauditapp.Writer) {
s.db = db
s.securityAudit = writer
}
func New(
accountStore *postgres.AccountStore,
accountRoleStore *postgres.AccountRoleStore,
@@ -54,15 +66,23 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询账号失败")
}
device := req.Device
if device == "" {
device = "web"
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.Password)); err != nil {
s.logger.Warn("登录失败:密码错误", zap.String("username", req.Username), zap.String("ip", clientIP))
return nil, errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
appErr := errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
if account.Status != 1 {
s.logger.Warn("登录失败:账号已禁用", zap.String("username", req.Username), zap.Uint("user_id", account.ID))
return nil, errors.New(errors.CodeAccountDisabled, "账号已禁用")
appErr := errors.New(errors.CodeAccountDisabled, "账号已禁用")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
// 检查店铺状态(代理账号必须关联店铺且店铺必须启用)
@@ -71,21 +91,21 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
if err != nil {
if err == gorm.ErrRecordNotFound {
s.logger.Warn("登录失败:关联店铺不存在", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
return nil, errors.New(errors.CodeShopNotFound, "关联店铺不存在")
appErr := errors.New(errors.CodeShopNotFound, "关联店铺不存在")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺失败")
}
if shop.Status != constants.StatusEnabled {
s.logger.Warn("登录失败:关联店铺已禁用", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
return nil, errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录")
appErr := errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
}
device := req.Device
if device == "" {
device = "web"
}
var shopID, enterpriseID uint
if account.ShopID != nil {
shopID = *account.ShopID
@@ -106,8 +126,16 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
if err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
return nil, err
}
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAuthLogin, Summary: "后台账号登录", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":" + device,
Authentication: authenticationData(account.ID, device, "password", "authenticated"),
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogin, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
}
permissions, menus, buttons, err := s.getUserPermissionsAndMenus(ctx, account.ID, account.UserType, device)
if err != nil {
@@ -139,6 +167,9 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string) error {
if err := s.tokenManager.RevokeToken(ctx, accessToken); err != nil {
if account := s.loadAuditAccount(ctx); account != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogout, "后台账号退出登录失败", constants.AuditResultFailed, "", err)
}
return err
}
@@ -147,6 +178,15 @@ func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string)
s.logger.Warn("撤销 refresh token 失败", zap.Error(err))
}
}
if account := s.loadAuditAccount(ctx); account != nil {
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAuthLogout, Summary: "后台账号退出登录", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":session",
Authentication: authenticationData(account.ID, "", "token", "revoked"),
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogout, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
}
}
return nil
}
@@ -185,15 +225,34 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword,
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
return errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
appErr := errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "拒绝修改账号密码", constants.AuditResultDenied, "", appErr)
return appErr
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
return errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
}
if err := s.accountStore.UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
if s.db == nil || s.securityAudit == nil {
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewAccountStore(tx, nil).UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
return err
}
return s.securityAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAccountPasswordChanged, Summary: "修改账号密码", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, Account: account,
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":password",
Authentication: authenticationData(account.ID, "", "password", "changed"),
BeforeData: map[string]any{"credentials_configured": account.Password != ""},
AfterData: map[string]any{"credentials_configured": true},
})
}); err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
return errors.Wrap(errors.CodeInternalError, err, "更新密码失败")
}
@@ -206,6 +265,63 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword,
return nil
}
func (s *Service) writeSecurityAudit(ctx context.Context, account *model.Account, audit accountauditapp.SecurityAudit) error {
if s.db == nil || s.securityAudit == nil || account == nil || account.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
}
audit.Account = account
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.securityAudit.WriteAccountSecurity(ctx, tx, audit)
})
}
func (s *Service) recordSecurityFailure(ctx context.Context, account *model.Account, actionCode, summary, result, device string, originalErr error) {
if account == nil || account.ID == 0 {
return
}
errorCode := strconv.Itoa(errors.CodeInternalError)
if appErr, ok := originalErr.(*errors.AppError); ok {
errorCode = strconv.Itoa(appErr.Code)
}
err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: actionCode, Summary: summary, Result: result, ErrorCode: errorCode, ErrorSummary: summary,
ActorID: account.ID, ActorName: account.Username,
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":security",
Authentication: authenticationData(account.ID, device, "password", result),
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(actionCode, account.Username, contextRequestID(ctx), contextRequestID(ctx), errorCode, err)
}
}
func (s *Service) loadAuditAccount(ctx context.Context) *model.Account {
userID := middleware.GetUserIDFromContext(ctx)
return s.loadAuditAccountByID(ctx, userID)
}
func (s *Service) loadAuditAccountByID(ctx context.Context, userID uint) *model.Account {
if userID == 0 || s.db == nil {
return nil
}
var account model.Account
if err := s.db.WithContext(ctx).Unscoped().First(&account, userID).Error; err != nil {
return nil
}
return &account
}
func authenticationData(accountID uint, device, method, state string) map[string]any {
return map[string]any{"account_id": accountID, "device": device, "auth_method": method, "state": state}
}
func contextRequestID(ctx context.Context) string {
value := middleware.GetRequestIDFromContext(ctx)
if value == nil {
return ""
}
return *value
}
func (s *Service) getUserPermissions(ctx context.Context, userID uint) ([]string, error) {
accountRoles, err := s.accountRoleStore.GetByAccountID(ctx, userID)
if err != nil {

View File

@@ -4,10 +4,12 @@ package client_auth
import (
"context"
stderrors "errors"
"regexp"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
@@ -23,6 +25,7 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
@@ -52,6 +55,7 @@ type Service struct {
logger *zap.Logger
wechatCache kernel.CacheInterface
customerBinding *customerBinding.Service
accessAudit accessauditapp.Writer
}
// New 创建 C 端认证服务实例
@@ -68,6 +72,7 @@ func New(
redisClient *redis.Client,
logger *zap.Logger,
binding *customerBinding.Service,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
@@ -83,6 +88,7 @@ func New(
logger: logger,
wechatCache: wechat.NewRedisCache(redisClient),
customerBinding: binding,
accessAudit: accessAudit,
}
}
@@ -293,24 +299,38 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
return nil, errors.New(errors.CodeAlreadyBoundPhone)
appErr := errors.New(errors.CodeAlreadyBoundPhone)
if customer, loadErr := s.customerStore.GetByID(ctx, customerID); loadErr == nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
}
return nil, appErr
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
customer, err := s.customerStore.GetByID(ctx, customerID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
}
if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
appErr := errors.New(errors.CodeAlreadyBoundPhone)
if existed.CustomerID != customerID {
return nil, errors.New(errors.CodePhoneAlreadyBound)
appErr = errors.New(errors.CodePhoneAlreadyBound)
}
return nil, errors.New(errors.CodeAlreadyBoundPhone)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, appErr)
return nil, appErr
}
now := time.Now()
@@ -321,8 +341,38 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
VerifiedAt: &now,
Status: 1,
}
if err := s.phoneStore.Create(ctx, record); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
var count int64
if err := tx.Model(&model.PersonalCustomerPhone{}).
Where("customer_id = ? AND is_primary = ? AND status = ?", customerID, true, 1).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if count > 0 {
return errors.New(errors.CodeAlreadyBoundPhone)
}
var existed model.PersonalCustomerPhone
if err := tx.Where("phone = ? AND status = ?", req.Phone, 1).First(&existed).Error; err == nil {
if existed.CustomerID != customerID {
return errors.New(errors.CodePhoneAlreadyBound)
}
return errors.New(errors.CodeAlreadyBoundPhone)
} else if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
}
if err := tx.Create(record).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
}
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil,
map[string]any{"phone": record.Phone}, "手机号已绑定", constants.AuditResultSuccess,
))
})
if err != nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, err)
return nil, err
}
return &dto.BindPhoneResponse{
@@ -336,41 +386,86 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
customer, err := s.customerStore.GetByID(ctx, customerID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeOldPhoneMismatch)
appErr := errors.New(errors.CodeOldPhoneMismatch)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
}
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, nil, appErr)
return nil, appErr
}
if primary.Phone != req.OldPhone {
return nil, errors.New(errors.CodeOldPhoneMismatch)
appErr := errors.New(errors.CodeOldPhoneMismatch)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID {
return nil, errors.New(errors.CodePhoneAlreadyBound)
} else if err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
Where("id = ? AND customer_id = ?", primary.ID, customerID).
Updates(map[string]any{
var beforeData map[string]any
var failurePhone *model.PersonalCustomerPhone
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customerID, true, 1).
First(primary).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeOldPhoneMismatch)
}
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if primary.Phone != req.OldPhone {
return errors.New(errors.CodeOldPhoneMismatch)
}
current := *primary
failurePhone = &current
beforeData = map[string]any{"phone": primary.Phone}
var existed model.PersonalCustomerPhone
if err := tx.Where("phone = ? AND status = ?", req.NewPhone, 1).First(&existed).Error; err == nil && existed.CustomerID != customerID {
return errors.New(errors.CodePhoneAlreadyBound)
} else if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
}
if err := tx.Model(primary).Updates(map[string]any{
"phone": req.NewPhone,
"verified_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
return errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
}
primary.Phone = req.NewPhone
primary.VerifiedAt = &now
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData,
map[string]any{"phone": primary.Phone}, "手机号已更换", constants.AuditResultSuccess,
))
})
if err != nil {
if failurePhone == nil {
failurePhone = primary
}
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, failurePhone, err)
return nil, err
}
return &dto.ChangePhoneResponse{
@@ -379,6 +474,56 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
}, nil
}
func personalPhoneAudit(
actionCode, summary string,
customer *model.PersonalCustomer,
phone *model.PersonalCustomerPhone,
beforeData, afterData map[string]any,
subjectSummary, result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, SubjectVisibility: constants.AuditSubjectDetail,
SubjectSummary: subjectSummary, SubjectData: afterData,
}
if phone != nil && phone.ID != 0 {
change.PersonalPhones = []accessauditapp.PersonalCustomerPhoneChange{{
Phone: phone, BeforeData: beforeData, AfterData: afterData,
}}
}
return change
}
func (s *Service) recordPersonalFailure(
ctx context.Context,
actionCode, summary string,
customer *model.PersonalCustomer,
phone *model.PersonalCustomerPhone,
originalErr error,
) {
if customer == nil || customer.ID == 0 {
return
}
subjectSummary := "个人身份资料操作失败"
change := personalPhoneAudit(actionCode, summary, customer, phone, nil, nil, subjectSummary, personalAuditFailureResult(originalErr))
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, change, originalErr)
}
func personalAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound,
errors.CodeAlreadyBoundPhone, errors.CodePhoneAlreadyBound, errors.CodeOldPhoneMismatch,
errors.CodeVerificationCodeInvalid:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
// Logout A7 退出登录
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
@@ -509,13 +654,20 @@ func (s *Service) loginByOpenID(
avatar string,
appType string,
) (uint, bool, error) {
if s.db == nil || s.accessAudit == nil {
return 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
var (
customerID uint
isNewUser bool
customerID uint
isNewUser bool
identityAudit *accessauditapp.ChangeAudit
)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
cid, created, change, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
customerID = cid
identityAudit = change
isNewUser = created
if findErr != nil {
return findErr
}
@@ -523,11 +675,26 @@ func (s *Service) loginByOpenID(
return bindErr
}
customerID = cid
isNewUser = created
if identityAudit != nil {
return s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit)
}
return nil
})
if err != nil {
if identityAudit != nil && customerID != 0 && !isNewUser {
if identityAudit.ActionCode == constants.AuditActionPersonalCustomerProfileUpdated {
identityAudit.Summary = "同步个人资料失败"
identityAudit.SubjectSummary = "个人资料同步失败"
} else {
identityAudit.Summary = "同步个人微信主体失败"
identityAudit.SubjectSummary = "微信登录身份同步失败"
}
identityAudit.Result = personalAuditFailureResult(err)
identityAudit.SubjectData = nil
identityAudit.PersonalOpenIDs = nil
restorePersonalCustomerSnapshot(identityAudit)
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, *identityAudit, err)
}
return 0, false, err
}
@@ -544,7 +711,7 @@ func (s *Service) findOrCreateCustomer(
nickname string,
avatar string,
appType string,
) (uint, bool, error) {
) (uint, bool, *accessauditapp.ChangeAudit, error) {
openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)
@@ -552,26 +719,36 @@ func (s *Service) findOrCreateCustomer(
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, errors.New(errors.CodeCustomerNotFound)
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
}
beforeData := personalCustomerProfileData(customer)
changed := false
if nickname != "" && customer.Nickname != nickname {
customer.Nickname = nickname
changed = true
}
if avatar != "" && customer.AvatarURL != avatar {
customer.AvatarURL = avatar
changed = true
}
var change *accessauditapp.ChangeAudit
if changed {
pending := personalProfileSyncAudit(customer, beforeData)
change = &pending
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
return customer.ID, false, change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
return customer.ID, false, nil
return customer.ID, false, change, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
}
if unionID != "" {
@@ -579,14 +756,16 @@ func (s *Service) findOrCreateCustomer(
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, errors.New(errors.CodeCustomerNotFound)
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
}
beforeData := personalCustomerProfileData(customer)
record := &model.PersonalCustomerOpenID{
CustomerID: customer.ID,
AppID: appID,
@@ -594,8 +773,9 @@ func (s *Service) findOrCreateCustomer(
UnionID: unionID,
AppType: appType,
}
change := personalWechatAudit(customer, record, beforeData, nil, appID, appType, constants.AuditResultSuccess)
if createErr := openidStore.Create(ctx, record); createErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
}
if nickname != "" && customer.Nickname != nickname {
@@ -605,12 +785,14 @@ func (s *Service) findOrCreateCustomer(
customer.AvatarURL = avatar
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
return customer.ID, false, nil
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
return customer.ID, false, &change, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
}
}
@@ -622,7 +804,7 @@ func (s *Service) findOrCreateCustomer(
Status: 1,
}
if err := customerStore.Create(ctx, newCustomer); err != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
}
record := &model.PersonalCustomerOpenID{
@@ -632,11 +814,64 @@ func (s *Service) findOrCreateCustomer(
UnionID: unionID,
AppType: appType,
}
change := personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
if err := openidStore.Create(ctx, record); err != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
return newCustomer.ID, true, &change, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
}
return newCustomer.ID, true, nil
change = personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
return newCustomer.ID, true, &change, nil
}
func personalProfileSyncAudit(customer *model.PersonalCustomer, beforeData map[string]any) accessauditapp.ChangeAudit {
return accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "同步个人资料", Result: constants.AuditResultSuccess,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalCustomerProfileData(customer),
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已同步",
SubjectData: personalCustomerProfileData(customer),
}
}
func personalWechatAudit(
customer *model.PersonalCustomer,
openID *model.PersonalCustomerOpenID,
beforeData, afterData map[string]any,
appID, appType, result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerWechatIdentityUpdated, Summary: "同步个人微信主体", Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "微信登录身份已同步",
SubjectData: map[string]any{"app_id": appID, "app_type": appType},
}
if openID != nil && openID.ID != 0 {
change.PersonalOpenIDs = []accessauditapp.PersonalCustomerOpenIDChange{{
OpenID: openID, AfterData: map[string]any{"app_id": openID.AppID, "app_type": openID.AppType},
}}
}
return change
}
func personalCustomerProfileData(customer *model.PersonalCustomer) map[string]any {
return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL}
}
func restorePersonalCustomerSnapshot(change *accessauditapp.ChangeAudit) {
if change.PersonalCustomer == nil || change.BeforeData == nil {
return
}
customer := *change.PersonalCustomer
if nickname, ok := change.BeforeData["nickname"].(string); ok {
customer.Nickname = nickname
}
if avatarURL, ok := change.BeforeData["avatar_url"].(string); ok {
customer.AvatarURL = avatarURL
}
change.PersonalCustomer = &customer
}
// checkCardBoundToDevice 检查卡是否绑定了设备
@@ -703,6 +938,9 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp
// 根据资产标识符查找或创建测试客户并直接签发 JWT无需微信 OAuth
// ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用
func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) {
if s.db == nil || s.accessAudit == nil {
return "", 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
assetType, assetID, _, err := s.resolveAsset(ctx, identifier)
if err != nil {
return "", 0, false, err
@@ -719,13 +957,18 @@ func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint
devOpenID := "dev_test_" + identifier
devAppID := "dev_test_app"
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
cid, created, identityAudit, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
if findErr != nil {
return findErr
}
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
return bindErr
}
if identityAudit != nil {
if auditErr := s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit); auditErr != nil {
return auditErr
}
}
customerID = cid
isNewUser = created
return nil

View File

@@ -0,0 +1,117 @@
package device
import (
"context"
"strconv"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func (s *Service) appendCSVBatchAllocationAudit(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
succeededIDs []uint,
failedItems []dto.AllocationDeviceFailedItem,
targetShopID uint,
) error {
linkage := auditcontext.From(ctx)
if s.auditWriter == nil || linkage.ActorKind != constants.AuditActorSystemTask ||
linkage.ActorID != constants.TaskTypeDeviceImport || linkage.Source != constants.AuditSourceWorker ||
linkage.CorrelationID == "" {
return nil
}
devicesByID := make(map[uint]*model.Device, len(devices))
for _, device := range devices {
if device != nil {
devicesByID[device.ID] = device
}
}
rootEventID := stableBatchEventID("root", linkage.CorrelationID)
children := make([]audit.AppendInput, 0, len(succeededIDs)+len(failedItems))
for _, deviceID := range succeededIDs {
if device := devicesByID[deviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, true, ""))
}
}
for _, item := range failedItems {
if device := devicesByID[item.DeviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, false, item.Reason))
}
}
result := constants.AuditResultSuccess
if len(succeededIDs) > 0 && len(failedItems) > 0 {
result = constants.AuditResultPartial
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootEventID, ActionCode: constants.AuditActionDeviceBatchAllocationCompleted,
Summary: "设备CSV批量分配完成", Result: result,
CorrelationID: linkage.CorrelationID,
BatchTotal: len(succeededIDs) + len(failedItems), SuccessCount: len(succeededIDs), FailCount: len(failedItems),
Metadata: map[string]any{"operation_type": constants.DeviceImportOperationAssignShop, "target_shop_id": targetShopID},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDeviceBatchTask, Key: linkage.CorrelationID, DisplayName: linkage.CorrelationID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{"task_no": linkage.CorrelationID, "operation_type": constants.DeviceImportOperationAssignShop},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func deviceBatchChild(
device *model.Device,
parentEventID string,
correlationID string,
targetShopID uint,
succeeded bool,
reason string,
) audit.AppendInput {
resourceID := strconv.FormatUint(uint64(device.ID), 10)
before := map[string]any{"shop_id": device.ShopID, "status": device.Status}
after := before
result := constants.AuditResultFailed
summary := "设备批量分配失败"
if succeeded {
after = map[string]any{"shop_id": targetShopID, "status": constants.DeviceStatusDistributed}
result = constants.AuditResultSuccess
summary = "设备批量分配成功"
}
return audit.AppendInput{
EventID: stableBatchEventID("device", correlationID+":"+resourceID),
ActionCode: constants.AuditActionDeviceBatchAllocationItem, Summary: summary,
Result: result, ErrorSummary: reason, CorrelationID: correlationID, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &resourceID, Key: deviceAuditKey(device), DisplayName: deviceAuditKey(device),
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchItem,
IdentitySnapshot: map[string]any{
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN,
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
},
BeforeData: before, AfterData: after,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}},
}
}
func stableBatchEventID(kind, key string) string {
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device-batch:"+kind+":"+key)).String()
}
func deviceAuditKey(device *model.Device) string {
for _, value := range []string{device.VirtualNo, device.IMEI, device.SN} {
if value != "" {
return value
}
}
return strconv.FormatUint(uint64(device.ID), 10)
}

View File

@@ -12,6 +12,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -42,6 +43,7 @@ type Service struct {
packageExpiryQuery *packageexpiry.Query
observationSeriesEvents cardObservationApp.SeriesEventWriter
observationSeries cardObservationApp.BestEffortSeriesDispatcher
auditWriter *auditinfra.Writer
}
// SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。
@@ -168,6 +170,7 @@ func New(
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore,
packageExpiryQuery: packageexpiry.NewQuery(db),
auditWriter: auditinfra.NewWriter(nil, nil),
}
}
@@ -678,7 +681,10 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
if err := txRecordStore.BatchCreate(ctx, records); err != nil {
return err
}
return s.appendCSVBatchAllocationAudit(ctx, tx, devices, deviceIDs, failedItems, targetShopID)
})
if err != nil {

View File

@@ -2,7 +2,9 @@ package enterprise
import (
"context"
stderrors "errors"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -12,6 +14,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
@@ -19,14 +22,23 @@ type Service struct {
enterpriseStore *postgres.EnterpriseStore
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
accessAudit accessauditapp.Writer
}
func New(db *gorm.DB, enterpriseStore *postgres.EnterpriseStore, shopStore *postgres.ShopStore, accountStore *postgres.AccountStore) *Service {
// New 创建企业生命周期服务。
func New(
db *gorm.DB,
enterpriseStore *postgres.EnterpriseStore,
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
enterpriseStore: enterpriseStore,
shopStore: shopStore,
accountStore: accountStore,
accessAudit: accessAudit,
}
}
@@ -35,52 +47,71 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
enterprise := &model.Enterprise{
EnterpriseName: req.EnterpriseName, EnterpriseCode: req.EnterpriseCode, OwnerShopID: req.OwnerShopID,
LegalPerson: req.LegalPerson, ContactName: req.ContactName, ContactPhone: req.ContactPhone,
BusinessLicense: req.BusinessLicense, Province: req.Province, City: req.City,
District: req.District, Address: req.Address, Status: constants.StatusEnabled,
}
enterprise.Creator = currentUserID
enterprise.Updater = currentUserID
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
err := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeAgent && req.OwnerShopID == nil {
err := errors.New(errors.CodeForbidden, "代理账号不能创建平台主管企业")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
if req.EnterpriseCode != "" {
existing, _ := s.enterpriseStore.GetByCode(ctx, req.EnterpriseCode)
if existing != nil {
return nil, errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
err := errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
}
existingAccount, _ := s.accountStore.GetByPhone(ctx, req.LoginPhone)
if existingAccount != nil {
return nil, errors.New(errors.CodePhoneExists, "手机号已被使用")
err := errors.New(errors.CodePhoneExists, "手机号已被使用")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
var ownerShop *model.Shop
if req.OwnerShopID != nil {
_, err := s.shopStore.GetByID(ctx, *req.OwnerShopID)
if err := middleware.CanManageShop(ctx, *req.OwnerShopID); err != nil {
err = errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
var err error
ownerShop, err = s.shopStore.GetByID(ctx, *req.OwnerShopID)
if err != nil {
return nil, errors.New(errors.CodeShopNotFound, "归属店铺不存在或无效")
err = errors.New(errors.CodeShopNotFound, "归属店铺不存在或无效")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, ownerShop, nil, appErr)
return nil, appErr
}
var enterprise *model.Enterprise
var account *model.Account
err = s.db.Transaction(func(tx *gorm.DB) error {
enterprise = &model.Enterprise{
EnterpriseName: req.EnterpriseName,
EnterpriseCode: req.EnterpriseCode,
OwnerShopID: req.OwnerShopID,
LegalPerson: req.LegalPerson,
ContactName: req.ContactName,
ContactPhone: req.ContactPhone,
BusinessLicense: req.BusinessLicense,
Province: req.Province,
City: req.City,
District: req.District,
Address: req.Address,
Status: constants.StatusEnabled,
}
enterprise.Creator = currentUserID
enterprise.Updater = currentUserID
if err := tx.WithContext(ctx).Create(enterprise).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建企业失败")
}
@@ -100,18 +131,27 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
return errors.Wrap(errors.CodeInternalError, err, "创建企业账号失败")
}
return nil
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCreated, Summary: "创建企业", OperatorID: currentUserID,
Enterprise: enterprise, Shop: ownerShop,
Accounts: []accessauditapp.AccountChange{{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
AfterData: map[string]any{"status": account.Status, "credentials_configured": true},
}},
AfterData: enterpriseProfileData(enterprise),
})
})
if err != nil {
failureEnterprise := *enterprise
failureEnterprise.ID = 0
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", &failureEnterprise, ownerShop, nil, err)
return nil, err
}
ownerShopName := ""
if enterprise.OwnerShopID != nil {
if shop, err := s.shopStore.GetByID(ctx, *enterprise.OwnerShopID); err == nil {
ownerShopName = shop.ShopName
}
if ownerShop != nil {
ownerShopName = ownerShop.ShopName
}
return &dto.CreateEnterpriseResp{
@@ -140,28 +180,205 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
// Update 更新企业信息
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateEnterpriseRequest) (*model.Enterprise, error) {
// 获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 查询企业
var enterprise *model.Enterprise
var before *model.Enterprise
var ownerShop *model.Shop
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
locked, err := lockEnterprise(ctx, tx, id)
if err != nil {
return err
}
beforeValue := *locked
before = &beforeValue
enterprise = locked
ownerShop = loadEnterpriseOwnerShop(tx, locked.OwnerShopID)
if req.EnterpriseCode != nil && *req.EnterpriseCode != locked.EnterpriseCode {
var count int64
if err := tx.Model(&model.Enterprise{}).Where("enterprise_code = ? AND id <> ?", *req.EnterpriseCode, id).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "检查企业编号失败")
}
if count > 0 {
return errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
}
locked.EnterpriseCode = *req.EnterpriseCode
}
applyEnterpriseUpdate(locked, req)
locked.Updater = currentUserID
if err := tx.Save(locked).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业失败")
}
if !enterpriseProfileChanged(before, locked) {
return nil
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseUpdated, Summary: "更新企业基础资料", OperatorID: currentUserID,
Enterprise: locked, Shop: ownerShop,
BeforeData: enterpriseProfileData(before), AfterData: enterpriseProfileData(locked),
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业更新审计失败")
}
return nil
})
if err != nil {
if before != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseUpdated, "更新企业基础资料失败", before, ownerShop, nil, err)
}
return nil, err
}
return enterprise, nil
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
var before *model.Enterprise
var ownerShop *model.Shop
var accounts []*model.Account
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
enterprise, err := lockEnterprise(ctx, tx, id)
if err != nil {
return err
}
beforeValue := *enterprise
before = &beforeValue
ownerShop = loadEnterpriseOwnerShop(tx, enterprise.OwnerShopID)
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("enterprise_id = ?", id).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询企业账号失败")
}
enterprise.Status = status
enterprise.Updater = currentUserID
if err := tx.Save(enterprise).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新企业状态失败")
}
if err := tx.Model(&model.Account{}).
Where("enterprise_id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updater": currentUserID,
}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "同步更新企业账号状态失败")
}
if before.Status == status {
return nil
}
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
for _, account := range accounts {
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
BeforeData: map[string]any{"status": account.Status}, AfterData: map[string]any{"status": status},
})
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseStatusUpdated, Summary: "更新企业状态", OperatorID: currentUserID,
Enterprise: enterprise, Shop: ownerShop, Accounts: accountChanges,
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": status},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业状态审计失败")
}
return nil
})
if err != nil {
if before != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseStatusUpdated, "更新企业状态失败", before, ownerShop, accounts, err)
}
return err
}
return nil
}
func (s *Service) UpdatePassword(ctx context.Context, id uint, password string) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterprise, err := s.enterpriseStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
var ownerShop *model.Shop
if enterprise.OwnerShopID != nil {
ownerShop, _ = s.shopStore.GetByID(ctx, *enterprise.OwnerShopID)
}
// 检查企业编号唯一性(如果修改了编号)
if req.EnterpriseCode != nil && *req.EnterpriseCode != enterprise.EnterpriseCode {
existing, err := s.enterpriseStore.GetByCode(ctx, *req.EnterpriseCode)
if err == nil && existing != nil && existing.ID != id {
return nil, errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
s.recordFailure(ctx, constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码失败", enterprise, ownerShop, nil, appErr)
return appErr
}
var accounts []*model.Account
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
enterprise, err = lockEnterprise(ctx, tx, id)
if err != nil {
return err
}
enterprise.EnterpriseCode = *req.EnterpriseCode
ownerShop = loadEnterpriseOwnerShop(tx, enterprise.OwnerShopID)
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("enterprise_id = ?", id).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询企业账号失败")
}
if err := tx.Model(&model.Account{}).Where("enterprise_id = ?", id).Updates(map[string]interface{}{
"password": string(hashedPassword), "updater": currentUserID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业账号密码失败")
}
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
for _, account := range accounts {
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
BeforeData: map[string]any{"credentials_configured": account.Password != ""},
AfterData: map[string]any{"credentials_configured": true, "state": "changed"},
})
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterprisePasswordUpdated, Summary: "更新企业账号密码", OperatorID: currentUserID,
Enterprise: enterprise, Shop: ownerShop, Accounts: accountChanges,
BeforeData: map[string]any{"credentials_configured": len(accounts) > 0},
AfterData: map[string]any{"credentials_configured": len(accounts) > 0, "state": "changed"},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业改密审计失败")
}
return nil
})
if err != nil {
if enterprise != nil {
s.recordFailure(ctx, constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码失败", enterprise, ownerShop, accounts, err)
}
return err
}
return nil
}
// 更新字段
func applyEnterpriseUpdate(enterprise *model.Enterprise, req *dto.UpdateEnterpriseRequest) {
if req.EnterpriseName != nil {
enterprise.EnterpriseName = *req.EnterpriseName
}
@@ -189,69 +406,81 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateEnterprise
if req.Address != nil {
enterprise.Address = *req.Address
}
enterprise.Updater = currentUserID
if err := s.enterpriseStore.Update(ctx, enterprise); err != nil {
return nil, err
}
return enterprise, nil
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
func enterpriseProfileData(enterprise *model.Enterprise) map[string]any {
return map[string]any{
"enterprise_name": enterprise.EnterpriseName, "enterprise_code": enterprise.EnterpriseCode,
"owner_shop_id": enterprise.OwnerShopID, "legal_person": enterprise.LegalPerson,
"contact_name": enterprise.ContactName, "contact_phone": enterprise.ContactPhone,
"business_license": enterprise.BusinessLicense, "province": enterprise.Province,
"city": enterprise.City, "district": enterprise.District, "address": enterprise.Address,
"status": enterprise.Status,
}
}
enterprise, err := s.enterpriseStore.GetByID(ctx, id)
if err != nil {
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
func enterpriseProfileChanged(before, after *model.Enterprise) bool {
return before.EnterpriseName != after.EnterpriseName || before.EnterpriseCode != after.EnterpriseCode ||
before.LegalPerson != after.LegalPerson || before.ContactName != after.ContactName ||
before.ContactPhone != after.ContactPhone || before.BusinessLicense != after.BusinessLicense ||
before.Province != after.Province || before.City != after.City || before.District != after.District ||
before.Address != after.Address
}
func lockEnterprise(ctx context.Context, tx *gorm.DB, id uint) (*model.Enterprise, error) {
var enterprise model.Enterprise
query := middleware.ApplyOwnerShopFilter(ctx, tx.WithContext(ctx).Where("id = ?", id))
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).First(&enterprise).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业失败")
}
return &enterprise, nil
}
return s.db.Transaction(func(tx *gorm.DB) error {
enterprise.Status = status
enterprise.Updater = currentUserID
if err := tx.WithContext(ctx).Save(enterprise).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新企业状态失败")
}
if err := tx.WithContext(ctx).Model(&model.Account{}).
Where("enterprise_id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updater": currentUserID,
}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "同步更新企业账号状态失败")
}
func loadEnterpriseOwnerShop(tx *gorm.DB, ownerShopID *uint) *model.Shop {
if ownerShopID == nil {
return nil
})
}
var shop model.Shop
if err := tx.Unscoped().First(&shop, *ownerShopID).Error; err != nil {
return nil
}
return &shop
}
func (s *Service) UpdatePassword(ctx context.Context, id uint, password string) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary string,
enterprise *model.Enterprise,
ownerShop *model.Shop,
accounts []*model.Account,
originalErr error,
) {
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
for _, account := range accounts {
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
})
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: enterpriseAuditFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
Accounts: accountChanges, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
_, err := s.enterpriseStore.GetByID(ctx, id)
if err != nil {
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
func enterpriseAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound,
errors.CodeEnterpriseCodeExists, errors.CodePhoneExists, errors.CodeShopNotFound:
return constants.AuditResultDenied
}
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
}
return s.db.WithContext(ctx).Model(&model.Account{}).
Where("enterprise_id = ?", id).
Updates(map[string]interface{}{
"password": string(hashedPassword),
"updater": currentUserID,
}).Error
return constants.AuditResultFailed
}
func (s *Service) GetByID(ctx context.Context, id uint) (*model.Enterprise, error) {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -12,26 +13,33 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AuthorizationService struct {
db *gorm.DB
enterpriseStore *postgres.EnterpriseStore
iotCardStore *postgres.IotCardStore
authorizationStore *postgres.EnterpriseCardAuthorizationStore
logger *zap.Logger
accessAudit accessauditapp.Writer
}
func NewAuthorizationService(
db *gorm.DB,
enterpriseStore *postgres.EnterpriseStore,
iotCardStore *postgres.IotCardStore,
authorizationStore *postgres.EnterpriseCardAuthorizationStore,
logger *zap.Logger,
accessAudit accessauditapp.Writer,
) *AuthorizationService {
return &AuthorizationService{
db: db,
enterpriseStore: enterpriseStore,
iotCardStore: iotCardStore,
authorizationStore: authorizationStore,
logger: logger,
accessAudit: accessAudit,
}
}
@@ -405,6 +413,9 @@ func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint,
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "用户信息无效")
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
}
record, err := s.authorizationStore.GetByIDWithJoin(ctx, id)
if err != nil {
@@ -420,23 +431,97 @@ func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint,
case constants.UserTypeAgent:
// 代理用户: 只能修改自己创建的授权记录
if record.AuthorizedBy != userID {
return nil, errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
err := errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
s.recordRemarkFailure(ctx, record, err)
return nil, err
}
case constants.UserTypeEnterprise:
// 企业用户: 禁止修改授权记录备注
return nil, errors.New(errors.CodeForbidden, "企业用户不允许修改授权记录备注")
err := errors.New(errors.CodeForbidden, "企业用户不允许修改授权记录备注")
s.recordRemarkFailure(ctx, record, err)
return nil, err
default:
return nil, errors.New(errors.CodeForbidden, "无权限修改授权记录备注")
}
if err := s.authorizationStore.UpdateRemarkWithConstraint(ctx, id, remark, record.AuthorizedBy); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
}
err := errors.New(errors.CodeForbidden, "无权限修改授权记录备注")
s.recordRemarkFailure(ctx, record, err)
return nil, err
}
return s.GetRecordDetail(ctx, id)
var enterprise model.Enterprise
var card model.IotCard
var auth model.EnterpriseCardAuthorization
var ownerShop *model.Shop
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&auth, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "授权记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
}
if userType == constants.UserTypeAgent && auth.AuthorizedBy != userID {
return errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
}
if err := tx.First(&enterprise, auth.EnterpriseID).Error; err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err := tx.First(&card, auth.CardID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权卡失败")
}
if enterprise.OwnerShopID != nil {
var shop model.Shop
if err := tx.Unscoped().First(&shop, *enterprise.OwnerShopID).Error; err == nil {
ownerShop = &shop
}
}
beforeRemark := auth.Remark
if beforeRemark == remark {
return nil
}
if err := tx.Model(&model.EnterpriseCardAuthorization{}).Where("id = ?", auth.ID).Update("remark", remark).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新授权备注失败")
}
auth.Remark = remark
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注",
OperatorID: userID, Enterprise: &enterprise, Shop: ownerShop,
Cards: []accessauditapp.IotCardChange{{
Card: &card, Relation: constants.AuditResourceRelationReference,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{
Authorization: &auth,
BeforeData: map[string]any{"remark": beforeRemark}, AfterData: map[string]any{"remark": remark},
}},
})
})
if err != nil {
s.recordRemarkFailure(ctx, record, err)
return nil, err
}
result, err := s.GetRecordDetail(ctx, id)
if err != nil {
return nil, err
}
return result, nil
}
func (s *AuthorizationService) recordRemarkFailure(ctx context.Context, record *postgres.AuthorizationWithJoin, originalErr error) {
if record == nil {
return
}
enterprise, _ := s.enterpriseStore.GetByID(ctx, record.EnterpriseID)
card, _ := s.iotCardStore.GetByID(ctx, record.CardID)
auth := &model.EnterpriseCardAuthorization{
ID: record.ID, EnterpriseID: record.EnterpriseID, CardID: record.CardID,
AuthorizedBy: record.AuthorizedBy, AuthorizerType: record.AuthorizerType,
AuthorizedAt: record.AuthorizedAt, RevokedBy: record.RevokedBy, RevokedAt: record.RevokedAt, Remark: record.Remark,
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注失败",
Result: enterpriseCardFailureResult(originalErr), OperatorID: middleware.GetUserIDFromContext(ctx),
Enterprise: enterprise, Cards: []accessauditapp.IotCardChange{{Card: card}},
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{Authorization: auth}},
}, originalErr)
}
func parseDate(dateStr string) (time.Time, error) {

View File

@@ -2,8 +2,10 @@ package enterprise_card
import (
"context"
stderrors "errors"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -11,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
@@ -18,6 +21,7 @@ type Service struct {
enterpriseStore *postgres.EnterpriseStore
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
iotCardStore *postgres.IotCardStore
accessAudit accessauditapp.Writer
}
func New(
@@ -25,12 +29,14 @@ func New(
enterpriseStore *postgres.EnterpriseStore,
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
iotCardStore *postgres.IotCardStore,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
enterpriseStore: enterpriseStore,
enterpriseCardAuthStore: enterpriseCardAuthStore,
iotCardStore: iotCardStore,
accessAudit: accessAudit,
}
}
@@ -204,10 +210,20 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
}
if err := validateEnterpriseCardActor(ctx); err != nil {
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := s.loadOwnerShop(ctx, enterprise.OwnerShopID)
iccids, err := s.resolveICCIDsForAllocate(ctx, req)
if err != nil {
@@ -231,6 +247,14 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
cardIDToICCID[card.IotCardID] = card.ICCID
allCandidateIDs = append(allCandidateIDs, card.IotCardID)
}
auditCardList, err := s.iotCardStore.GetByIDs(ctx, allCandidateIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡审计快照失败")
}
cardIDMap := make(map[uint]*model.IotCard, len(auditCardList))
for _, card := range auditCardList {
cardIDMap[card.ID] = card
}
// 检测已被其他企业授权的卡,阻止重复授权
conflictAuths, err := s.enterpriseCardAuthStore.GetConflictingAuthsByCardIDs(ctx, enterpriseID, allCandidateIDs)
@@ -239,7 +263,12 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
}
cardIDsToAllocate := make([]uint, 0, len(allCandidateIDs))
seenAllocate := make(map[uint]struct{}, len(allCandidateIDs))
for _, cardID := range allCandidateIDs {
if _, seen := seenAllocate[cardID]; seen {
continue
}
seenAllocate[cardID] = struct{}{}
if _, conflict := conflictAuths[cardID]; conflict {
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
ICCID: cardIDToICCID[cardID],
@@ -274,12 +303,41 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
}
if len(auths) > 0 {
if err := s.enterpriseCardAuthStore.BatchCreate(ctx, auths); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建授权记录失败")
auditResult := constants.AuditResultSuccess
if resp.FailCount > 0 {
auditResult = constants.AuditResultPartial
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.CreateInBatches(auths, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建授权记录失败")
}
cards := make([]accessauditapp.IotCardChange, 0, len(auths))
authorizations := make([]accessauditapp.EnterpriseCardAuthorizationChange, 0, len(auths))
for _, auth := range auths {
card := cardIDMap[auth.CardID]
cards = append(cards, accessauditapp.IotCardChange{
Card: card, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡已授权给企业",
})
authorizations = append(authorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth, AfterData: map[string]any{"authorized": true, "remark": auth.Remark},
})
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardsAllocated, Summary: "向企业授权卡",
Result: auditResult, OperatorID: currentUserID, Enterprise: enterprise, Shop: ownerShop,
Cards: cards, CardAuthorizations: authorizations,
BeforeData: map[string]any{"authorized_card_count": 0},
AfterData: map[string]any{"authorized_card_count": len(auths)},
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseCardsAllocated, "向企业授权卡失败", enterprise, ownerShop, cardChanges(cardIDMap, allCandidateIDs), err)
return nil, err
}
}
resp.SuccessCount = len(cardIDsToAllocate)
resp.SuccessCount = len(auths)
return resp, nil
}
@@ -289,10 +347,20 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
}
if err := validateEnterpriseCardActor(ctx); err != nil {
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := s.loadOwnerShop(ctx, enterprise.OwnerShopID)
iccids, err := s.resolveICCIDsForRecall(ctx, enterpriseID, req)
if err != nil {
@@ -324,6 +392,7 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
}
cardIDsToRecall := make([]uint, 0)
seenRecall := make(map[uint]struct{}, len(iccids))
for _, iccid := range iccids {
card, exists := cardMap[iccid]
if !exists {
@@ -340,20 +409,130 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
})
continue
}
if _, seen := seenRecall[card.ID]; seen {
continue
}
seenRecall[card.ID] = struct{}{}
cardIDsToRecall = append(cardIDsToRecall, card.ID)
}
if len(cardIDsToRecall) > 0 {
if err := s.enterpriseCardAuthStore.BatchUpdateStatus(ctx, enterpriseID, cardIDsToRecall, 0); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "回收授权失败")
var recalled []*model.EnterpriseCardAuthorization
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("enterprise_id = ? AND card_id IN ? AND revoked_at IS NULL", enterpriseID, cardIDsToRecall).
Find(&recalled).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询有效卡授权失败")
}
if len(recalled) == 0 {
return nil
}
now := time.Now()
ids := make([]uint, 0, len(recalled))
cards := make([]accessauditapp.IotCardChange, 0, len(recalled))
authorizations := make([]accessauditapp.EnterpriseCardAuthorizationChange, 0, len(recalled))
for _, auth := range recalled {
ids = append(ids, auth.ID)
before := *auth
auth.RevokedBy = &currentUserID
auth.RevokedAt = &now
cards = append(cards, accessauditapp.IotCardChange{
Card: cardIDMap[auth.CardID],
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡授权已回收",
})
authorizations = append(authorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth,
BeforeData: map[string]any{"revoked_by": before.RevokedBy, "revoked_at": before.RevokedAt},
AfterData: map[string]any{"revoked_by": currentUserID, "revoked_at": now},
})
}
if err := tx.Model(&model.EnterpriseCardAuthorization{}).Where("id IN ? AND revoked_at IS NULL", ids).
Updates(map[string]any{"revoked_by": currentUserID, "revoked_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "回收卡授权失败")
}
result := constants.AuditResultSuccess
if len(resp.FailedItems) > 0 {
result = constants.AuditResultPartial
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardsRecalled, Summary: "回收企业卡授权",
Result: result, OperatorID: currentUserID, Enterprise: enterprise, Shop: ownerShop,
Cards: cards, CardAuthorizations: authorizations,
BeforeData: map[string]any{"authorized_card_count": len(recalled)},
AfterData: map[string]any{"authorized_card_count": 0},
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseCardsRecalled, "回收企业卡授权失败", enterprise, ownerShop, cardChanges(cardIDMap, cardIDsToRecall), err)
return nil, err
}
resp.SuccessCount = len(recalled)
}
resp.SuccessCount = len(cardIDsToRecall)
resp.FailCount = len(resp.FailedItems)
return resp, nil
}
func validateEnterpriseCardActor(ctx context.Context) error {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent:
return nil
default:
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
}
func (s *Service) loadOwnerShop(ctx context.Context, ownerShopID *uint) *model.Shop {
if ownerShopID == nil {
return nil
}
shop, err := postgres.NewShopStore(s.db, nil).GetByID(ctx, *ownerShopID)
if err != nil {
return nil
}
return shop
}
func cardChanges(cardMap map[uint]*model.IotCard, ids []uint) []accessauditapp.IotCardChange {
changes := make([]accessauditapp.IotCardChange, 0, len(ids))
for _, id := range ids {
if card := cardMap[id]; card != nil {
changes = append(changes, accessauditapp.IotCardChange{Card: card})
}
}
return changes
}
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary string,
enterprise *model.Enterprise,
ownerShop *model.Shop,
cards []accessauditapp.IotCardChange,
originalErr error,
) {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: enterpriseCardFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
Cards: cards, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
func enterpriseCardFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound,
errors.CodeIotCardNotFound, errors.CodeIotCardStatusNotAllowed, errors.CodeCannotAuthorizeToOthersEnterprise,
errors.CodeCannotAuthorizeOthersCard, errors.CodeCannotAuthorizeBoundCard, errors.CodeCardAlreadyAuthorized,
errors.CodeCardNotAuthorized, errors.CodeCannotRevokeOthersAuthorization:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
func (s *Service) ListCards(ctx context.Context, enterpriseID uint, req *dto.EnterpriseCardListReq) (*dto.EnterpriseCardPageResult, error) {
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {

View File

@@ -4,7 +4,9 @@ import (
"context"
stderrors "errors"
"time"
"unicode/utf8"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -14,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
@@ -24,6 +27,7 @@ type Service struct {
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
logger *zap.Logger
accessAudit accessauditapp.Writer
}
func New(
@@ -34,6 +38,7 @@ func New(
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
logger *zap.Logger,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
@@ -43,6 +48,7 @@ func New(
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseCardAuthStore: enterpriseCardAuthStore,
logger: logger,
accessAudit: accessAudit,
}
}
@@ -52,12 +58,26 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 验证企业存在
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err := validateAllocateDevicesRequest(req); err != nil {
return nil, err
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
}
if err := validateEnterpriseDeviceActor(ctx); err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err)
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr)
return nil, permissionErr
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := loadEnterpriseDeviceOwnerShop(ctx, s.db, enterprise.OwnerShopID)
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForAllocate(ctx, req)
@@ -67,7 +87,7 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 查询所有设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
if err := enterpriseDeviceQuery(ctx, s.db).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -93,172 +113,221 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
AuthorizedDevices: make([]dto.AuthorizedDeviceItem, 0),
}
devicesToAllocate := make([]*model.Device, 0)
devicesToAllocate := selectDevicesForAllocate(
deviceNos, deviceMap, activeAuthEnterpriseMap, enterpriseID, userType, currentShopID, resp,
)
if len(devicesToAllocate) > 0 {
items, err := s.allocateDevices(ctx, enterprise, ownerShop, devicesToAllocate, req, currentUserID, userType, resp)
if err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备失败", enterprise, ownerShop, devicesToAllocate, err)
return nil, err
}
resp.AuthorizedDevices = append(resp.AuthorizedDevices, items...)
}
resp.SuccessCount = len(resp.AuthorizedDevices)
resp.FailCount = len(resp.FailedItems)
if resp.SuccessCount == 0 {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", enterprise, ownerShop, devicePointers(deviceMap, deviceIDs), errors.New(errors.CodeInvalidStatus, "没有设备满足授权条件"))
}
return resp, nil
}
// selectDevicesForAllocate 按既有设备状态、归属和有效授权规则筛选可授权设备。
func selectDevicesForAllocate(
deviceNos []string,
deviceMap map[string]*model.Device,
activeAuthEnterpriseMap map[uint]uint,
enterpriseID uint,
userType int,
currentShopID uint,
resp *dto.AllocateDevicesResp,
) []*model.Device {
devices := make([]*model.Device, 0, len(deviceNos))
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
for _, deviceNo := range deviceNos {
if _, exists := seenDeviceNos[deviceNo]; exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "请求中设备号重复",
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "请求中设备号重复"})
continue
}
seenDeviceNos[deviceNo] = struct{}{}
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备不存在",
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
continue
}
// 验证设备状态(必须是"已分销"状态)
if device.Status != 2 {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备状态不正确,必须是已分销状态",
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "设备状态不正确,必须是已分销状态"})
continue
}
// 验证设备所有权(除非是超级管理员或平台用户)
if userType == constants.UserTypeAgent {
if device.ShopID == nil || *device.ShopID != currentShopID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "无权操作此设备",
})
continue
}
if userType == constants.UserTypeAgent && (device.ShopID == nil || *device.ShopID != currentShopID) {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
continue
}
// 检查是否已授权(同企业 / 其他企业)
if authEnterpriseID, exists := activeAuthEnterpriseMap[device.ID]; exists {
reason := "设备已授权给其他企业"
if authEnterpriseID == enterpriseID {
reason = "设备已授权给此企业"
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: reason,
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: reason})
continue
}
devicesToAllocate = append(devicesToAllocate, device)
devices = append(devices, device)
}
// 在事务中处理授权
if len(devicesToAllocate) > 0 {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
authorizerType := userType
// 1. 创建设备授权记录(逐条处理,避免并发冲突导致整批失败)
deviceAuthIDMap := make(map[uint]uint, len(devicesToAllocate))
successDevices := make([]*model.Device, 0, len(devicesToAllocate))
for _, device := range devicesToAllocate {
deviceAuth := &model.EnterpriseDeviceAuthorization{
EnterpriseID: enterpriseID,
DeviceID: device.ID,
AuthorizedBy: currentUserID,
AuthorizedAt: now,
AuthorizerType: authorizerType,
Remark: req.Remark,
}
if err := tx.Create(deviceAuth).Error; err != nil {
if isUniqueConstraintViolation(err, "uq_active_device_auth") {
reason := "设备已授权给其他企业"
var existingAuth model.EnterpriseDeviceAuthorization
queryErr := tx.Select("enterprise_id").
Where("device_id = ? AND revoked_at IS NULL", device.ID).
First(&existingAuth).Error
if queryErr == nil && existingAuth.EnterpriseID == enterpriseID {
reason = "设备已授权给此企业"
}
if queryErr != nil && !stderrors.Is(queryErr, gorm.ErrRecordNotFound) {
return errors.Wrap(errors.CodeInternalError, queryErr, "查询冲突授权记录失败")
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: device.VirtualNo,
Reason: reason,
})
continue
}
return errors.Wrap(errors.CodeInternalError, err, "创建设备授权记录失败")
}
deviceAuthIDMap[device.ID] = deviceAuth.ID
successDevices = append(successDevices, device)
}
// 2. 查询所有设备绑定的卡
deviceIDsToQuery := make([]uint, 0, len(successDevices))
for _, device := range successDevices {
deviceIDsToQuery = append(deviceIDsToQuery, device.ID)
}
var bindings []model.DeviceSimBinding
if len(deviceIDsToQuery) > 0 {
if err := tx.Where("device_id IN ? AND bind_status = 1", deviceIDsToQuery).Find(&bindings).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
}
// 3. 为每张绑定的卡创建授权记录
if len(bindings) > 0 {
cardAuths := make([]*model.EnterpriseCardAuthorization, 0, len(bindings))
for _, binding := range bindings {
deviceAuthID := deviceAuthIDMap[binding.DeviceID]
cardAuths = append(cardAuths, &model.EnterpriseCardAuthorization{
EnterpriseID: enterpriseID,
CardID: binding.IotCardID,
DeviceAuthID: &deviceAuthID,
AuthorizedBy: currentUserID,
AuthorizedAt: now,
AuthorizerType: authorizerType,
Remark: req.Remark,
})
}
if err := tx.Create(cardAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建卡授权记录失败")
}
}
// 4. 统计每个设备的绑定卡数量
deviceCardCount := make(map[uint]int)
for _, binding := range bindings {
deviceCardCount[binding.DeviceID]++
}
// 5. 构建响应
for _, device := range successDevices {
resp.AuthorizedDevices = append(resp.AuthorizedDevices, dto.AuthorizedDeviceItem{
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
CardCount: deviceCardCount[device.ID],
})
}
return nil
})
if err != nil {
return nil, err
}
}
resp.SuccessCount = len(resp.AuthorizedDevices)
resp.FailCount = len(resp.FailedItems)
return resp, nil
return devices
}
// allocateDevices 在同一事务内创建设备、随设备卡授权和统一审计事实。
func (s *Service) allocateDevices(
ctx context.Context,
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices []*model.Device,
req *dto.AllocateDevicesReq,
operatorID uint,
userType int,
resp *dto.AllocateDevicesResp,
) ([]dto.AuthorizedDeviceItem, error) {
items := make([]dto.AuthorizedDeviceItem, 0, len(devices))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", deviceIDs(devices)).Find(&[]model.Device{}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "锁定待授权设备失败")
}
now := time.Now()
deviceAuthByDevice := make(map[uint]*model.EnterpriseDeviceAuthorization, len(devices))
successDevices := make([]*model.Device, 0, len(devices))
for _, device := range devices {
auth := &model.EnterpriseDeviceAuthorization{
EnterpriseID: enterprise.ID, DeviceID: device.ID, AuthorizedBy: operatorID,
AuthorizedAt: now, AuthorizerType: userType, Remark: req.Remark,
}
if err := tx.Transaction(func(itemTx *gorm.DB) error { return itemTx.Create(auth).Error }); err != nil {
if !isUniqueConstraintViolation(err, "uq_active_device_auth") {
return errors.Wrap(errors.CodeInternalError, err, "创建设备授权记录失败")
}
reason, err := deviceAuthorizationConflictReason(tx, device.ID, enterprise.ID)
if err != nil {
return err
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: device.VirtualNo, Reason: reason})
continue
}
deviceAuthByDevice[device.ID] = auth
successDevices = append(successDevices, device)
}
if len(successDevices) == 0 {
return nil
}
successDeviceIDs := deviceIDs(successDevices)
bindings, err := loadDeviceBindings(tx, successDeviceIDs, true)
if err != nil {
return err
}
cardAuths := make([]*model.EnterpriseCardAuthorization, 0, len(bindings))
for _, binding := range bindings {
deviceAuthID := deviceAuthByDevice[binding.DeviceID].ID
cardAuths = append(cardAuths, &model.EnterpriseCardAuthorization{
EnterpriseID: enterprise.ID, CardID: binding.IotCardID, DeviceAuthID: &deviceAuthID,
AuthorizedBy: operatorID, AuthorizedAt: now, AuthorizerType: userType, Remark: req.Remark,
})
}
if len(cardAuths) > 0 {
if err := tx.Create(cardAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建卡授权记录失败")
}
}
cards, err := loadAuditCards(tx, cardAuths)
if err != nil {
return err
}
result := constants.AuditResultSuccess
if len(resp.FailedItems) > 0 {
result = constants.AuditResultPartial
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, enterpriseDeviceAllocateAudit(
enterprise, ownerShop, successDevices, deviceAuthByDevice, bindings, cards, cardAuths, operatorID, result,
)); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业设备授权审计失败")
}
cardCount := make(map[uint]int, len(successDevices))
for _, binding := range bindings {
cardCount[binding.DeviceID]++
}
for _, device := range successDevices {
items = append(items, dto.AuthorizedDeviceItem{
DeviceID: device.ID, VirtualNo: device.VirtualNo, CardCount: cardCount[device.ID],
})
}
return nil
})
return items, err
}
func deviceAuthorizationConflictReason(tx *gorm.DB, deviceID, enterpriseID uint) (string, error) {
reason := "设备已授权给其他企业"
var existing model.EnterpriseDeviceAuthorization
err := tx.Select("enterprise_id").Where("device_id = ? AND revoked_at IS NULL", deviceID).First(&existing).Error
if err == nil && existing.EnterpriseID == enterpriseID {
return "设备已授权给此企业", nil
}
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return "", errors.Wrap(errors.CodeInternalError, err, "查询冲突授权记录失败")
}
return reason, nil
}
// enterpriseDeviceAllocateAudit 装配企业、设备、卡槽、卡及授权记录的资源关系。
func enterpriseDeviceAllocateAudit(
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices []*model.Device,
deviceAuthByDevice map[uint]*model.EnterpriseDeviceAuthorization,
bindings []*model.DeviceSimBinding,
cards map[uint]*model.IotCard,
cardAuths []*model.EnterpriseCardAuthorization,
operatorID uint,
result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseDevicesAllocated, Summary: "向企业授权设备",
Result: result, OperatorID: operatorID, Enterprise: enterprise, Shop: ownerShop,
BeforeData: map[string]any{"authorized_device_count": 0},
AfterData: map[string]any{"authorized_device_count": len(devices)},
}
for _, device := range devices {
auth := deviceAuthByDevice[device.ID]
change.Devices = append(change.Devices, accessauditapp.DeviceChange{
Device: device, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备已授权给企业",
})
change.DeviceAuthorizations = append(change.DeviceAuthorizations, accessauditapp.EnterpriseDeviceAuthorizationChange{
Authorization: auth, AfterData: map[string]any{"authorized": true},
})
}
for _, binding := range bindings {
change.DeviceBindings = append(change.DeviceBindings, accessauditapp.DeviceSimBindingChange{Binding: binding})
}
for _, auth := range cardAuths {
card := cards[auth.CardID]
change.Cards = append(change.Cards, accessauditapp.IotCardChange{
Card: card, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡已随设备授权给企业",
})
change.CardAuthorizations = append(change.CardAuthorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth, AfterData: map[string]any{"authorized": true},
})
}
return change
}
// isUniqueConstraintViolation 判断 PostgreSQL 唯一约束冲突并可限定约束名称。
func isUniqueConstraintViolation(err error, constraintName string) bool {
var pgErr *pgconn.PgError
if stderrors.As(err, &pgErr) {
@@ -296,6 +365,9 @@ func (s *Service) resolveDeviceNosForAllocate(ctx context.Context, req *dto.Allo
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
if !canManageEnterpriseDevice(ctx, d) {
continue
}
nos = append(nos, d.VirtualNo)
}
return nos, nil
@@ -323,6 +395,9 @@ func (s *Service) resolveDeviceNosForRecall(ctx context.Context, enterpriseID ui
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
if !canManageEnterpriseDevice(ctx, d) {
continue
}
nos = append(nos, d.VirtualNo)
}
return nos, nil
@@ -334,12 +409,27 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
if err := validateRecallDevicesRequest(req); err != nil {
return nil, err
}
// 验证企业存在
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
}
if err := validateEnterpriseDeviceActor(ctx); err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err)
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr)
return nil, permissionErr
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := loadEnterpriseDeviceOwnerShop(ctx, s.db, enterprise.OwnerShopID)
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForRecall(ctx, enterpriseID, req)
@@ -349,7 +439,7 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
// 查询设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
if err := enterpriseDeviceQuery(ctx, s.db).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -370,66 +460,386 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
FailedItems: make([]dto.FailedDeviceItem, 0),
}
deviceAuthsToRevoke := make([]uint, 0)
for _, deviceNo := range deviceNos {
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备不存在",
})
continue
}
if !existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备未授权给此企业",
})
continue
}
// 获取授权记录ID
auth, err := s.enterpriseDeviceAuthStore.GetByDeviceID(ctx, device.ID)
if err != nil || auth.EnterpriseID != enterpriseID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "授权记录不存在",
})
continue
}
deviceAuthsToRevoke = append(deviceAuthsToRevoke, auth.ID)
}
// 在事务中处理撤销
if len(deviceAuthsToRevoke) > 0 {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 撤销设备授权
if err := s.enterpriseDeviceAuthStore.RevokeByIDs(ctx, deviceAuthsToRevoke, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销设备授权失败")
}
// 2. 级联撤销卡授权
for _, authID := range deviceAuthsToRevoke {
if err := s.enterpriseCardAuthStore.RevokeByDeviceAuthID(ctx, authID, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销卡授权失败")
}
}
return nil
})
deviceIDsToRecall := selectDeviceIDsForRecall(deviceNos, deviceMap, existingAuths, resp)
if len(deviceIDsToRecall) > 0 {
recalledIDs, err := s.recallDevices(ctx, enterprise, ownerShop, deviceIDsToRecall, deviceMap, currentUserID, len(resp.FailedItems) > 0)
if err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权失败", enterprise, ownerShop, devicePointers(deviceMap, deviceIDsToRecall), err)
return nil, err
}
recalled := make(map[uint]struct{}, len(recalledIDs))
for _, deviceID := range recalledIDs {
recalled[deviceID] = struct{}{}
}
devicesByID := devicesByID(deviceMap)
for _, deviceID := range deviceIDsToRecall {
if _, ok := recalled[deviceID]; ok {
continue
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: devicesByID[deviceID].VirtualNo,
Reason: "设备未授权给此企业",
})
}
resp.SuccessCount = len(recalledIDs)
}
resp.SuccessCount = len(deviceAuthsToRevoke)
resp.FailCount = len(resp.FailedItems)
if resp.SuccessCount == 0 {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", enterprise, ownerShop, devicePointers(deviceMap, deviceIDs), errors.New(errors.CodeInvalidStatus, "没有设备满足回收条件"))
}
return resp, nil
}
// selectDeviceIDsForRecall 按当前有效授权筛选回收目标,并保持越权与不存在同错。
func selectDeviceIDsForRecall(
deviceNos []string,
deviceMap map[string]*model.Device,
existingAuths map[uint]bool,
resp *dto.RecallDevicesResp,
) []uint {
deviceIDs := make([]uint, 0, len(deviceNos))
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
for _, deviceNo := range deviceNos {
if _, seen := seenDeviceNos[deviceNo]; seen {
continue
}
seenDeviceNos[deviceNo] = struct{}{}
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
continue
}
if !existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "设备未授权给此企业"})
continue
}
deviceIDs = append(deviceIDs, device.ID)
}
return deviceIDs
}
// recallDevices 在锁定有效授权后撤销实际命中项,并返回真实回收设备 ID。
func (s *Service) recallDevices(
ctx context.Context,
enterprise *model.Enterprise,
ownerShop *model.Shop,
requestedDeviceIDs []uint,
deviceMap map[string]*model.Device,
operatorID uint,
partial bool,
) ([]uint, error) {
recalledDeviceIDs := make([]uint, 0, len(requestedDeviceIDs))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var deviceAuths []*model.EnterpriseDeviceAuthorization
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("enterprise_id = ? AND device_id IN ? AND revoked_at IS NULL", enterprise.ID, requestedDeviceIDs).
Find(&deviceAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询有效设备授权失败")
}
if len(deviceAuths) == 0 {
return nil
}
authIDs := make([]uint, 0, len(deviceAuths))
actualDeviceIDs := make([]uint, 0, len(deviceAuths))
for _, auth := range deviceAuths {
authIDs = append(authIDs, auth.ID)
actualDeviceIDs = append(actualDeviceIDs, auth.DeviceID)
}
bindings, err := loadDeviceBindings(tx, actualDeviceIDs, true)
if err != nil {
return err
}
var cardAuths []*model.EnterpriseCardAuthorization
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("device_auth_id IN ? AND revoked_at IS NULL", authIDs).
Find(&cardAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询有效卡授权失败")
}
cards, err := loadAuditCards(tx, cardAuths)
if err != nil {
return err
}
now := time.Now()
if err := tx.Model(&model.EnterpriseDeviceAuthorization{}).
Where("id IN ? AND revoked_at IS NULL", authIDs).
Updates(map[string]any{"revoked_by": operatorID, "revoked_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销设备授权失败")
}
if len(cardAuths) > 0 {
cardAuthIDs := make([]uint, 0, len(cardAuths))
for _, auth := range cardAuths {
cardAuthIDs = append(cardAuthIDs, auth.ID)
}
if err := tx.Model(&model.EnterpriseCardAuthorization{}).
Where("id IN ? AND revoked_at IS NULL", cardAuthIDs).
Updates(map[string]any{"revoked_by": operatorID, "revoked_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销卡授权失败")
}
}
devicesByID := devicesByID(deviceMap)
result := constants.AuditResultSuccess
if partial || len(deviceAuths) < len(requestedDeviceIDs) {
result = constants.AuditResultPartial
}
change := enterpriseDeviceRecallAudit(
enterprise, ownerShop, devicesByID, deviceAuths, bindings, cards, cardAuths, operatorID, now, result,
)
if err := s.accessAudit.WriteAccessChange(ctx, tx, change); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业设备回收审计失败")
}
recalledDeviceIDs = actualDeviceIDs
return nil
})
return recalledDeviceIDs, err
}
// enterpriseDeviceRecallAudit 装配回收操作涉及的设备、卡槽、卡和授权记录变化。
func enterpriseDeviceRecallAudit(
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices map[uint]*model.Device,
deviceAuths []*model.EnterpriseDeviceAuthorization,
bindings []*model.DeviceSimBinding,
cards map[uint]*model.IotCard,
cardAuths []*model.EnterpriseCardAuthorization,
operatorID uint,
now time.Time,
result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseDevicesRecalled, Summary: "回收企业设备授权",
Result: result, OperatorID: operatorID, Enterprise: enterprise, Shop: ownerShop,
BeforeData: map[string]any{"authorized_device_count": len(deviceAuths)},
AfterData: map[string]any{"authorized_device_count": 0},
}
for _, auth := range deviceAuths {
device := devices[auth.DeviceID]
change.Devices = append(change.Devices, accessauditapp.DeviceChange{
Device: device,
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备授权已回收",
})
beforeRevokedBy, beforeRevokedAt := auth.RevokedBy, auth.RevokedAt
auth.RevokedBy, auth.RevokedAt = &operatorID, &now
change.DeviceAuthorizations = append(change.DeviceAuthorizations, accessauditapp.EnterpriseDeviceAuthorizationChange{
Authorization: auth,
BeforeData: map[string]any{"revoked_by": beforeRevokedBy, "revoked_at": beforeRevokedAt},
AfterData: map[string]any{"revoked_by": operatorID, "revoked_at": now},
})
}
for _, binding := range bindings {
change.DeviceBindings = append(change.DeviceBindings, accessauditapp.DeviceSimBindingChange{Binding: binding})
}
for _, auth := range cardAuths {
card := cards[auth.CardID]
change.Cards = append(change.Cards, accessauditapp.IotCardChange{
Card: card,
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡授权已随设备回收",
})
beforeRevokedBy, beforeRevokedAt := auth.RevokedBy, auth.RevokedAt
auth.RevokedBy, auth.RevokedAt = &operatorID, &now
change.CardAuthorizations = append(change.CardAuthorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth,
BeforeData: map[string]any{"revoked_by": beforeRevokedBy, "revoked_at": beforeRevokedAt},
AfterData: map[string]any{"revoked_by": operatorID, "revoked_at": now},
})
}
return change
}
func validateEnterpriseDeviceActor(ctx context.Context) error {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent:
return nil
default:
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
}
// validateAllocateDevicesRequest 校验 Service 边界的授权选取条件,防止空筛选扩散为全量操作。
func validateAllocateDevicesRequest(req *dto.AllocateDevicesReq) error {
if req == nil || utf8.RuneCountInString(req.VirtualNo) > 100 || utf8.RuneCountInString(req.BatchNo) > 100 || utf8.RuneCountInString(req.Remark) > 500 {
return errors.New(errors.CodeInvalidParam)
}
if req.SelectionType == "filter" {
if req.VirtualNo == "" && req.BatchNo == "" && req.ShopID == nil {
return errors.New(errors.CodeInvalidParam)
}
if req.ShopID != nil && *req.ShopID == 0 {
return errors.New(errors.CodeInvalidParam)
}
return nil
}
if req.SelectionType != "list" || len(req.DeviceNos) == 0 || len(req.DeviceNos) > 100 {
return errors.New(errors.CodeInvalidParam)
}
for _, deviceNo := range req.DeviceNos {
if deviceNo == "" {
return errors.New(errors.CodeInvalidParam)
}
}
return nil
}
// validateRecallDevicesRequest 校验 Service 边界的回收选取条件,防止空筛选扩散为全量操作。
func validateRecallDevicesRequest(req *dto.RecallDevicesReq) error {
if req == nil || utf8.RuneCountInString(req.VirtualNo) > 100 || utf8.RuneCountInString(req.BatchNo) > 100 {
return errors.New(errors.CodeInvalidParam)
}
if req.SelectionType == "filter" {
if req.VirtualNo == "" && req.BatchNo == "" {
return errors.New(errors.CodeInvalidParam)
}
return nil
}
if req.SelectionType != "list" || len(req.DeviceNos) == 0 || len(req.DeviceNos) > 100 {
return errors.New(errors.CodeInvalidParam)
}
for _, deviceNo := range req.DeviceNos {
if deviceNo == "" {
return errors.New(errors.CodeInvalidParam)
}
}
return nil
}
func enterpriseDeviceQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
query := db.WithContext(ctx).Model(&model.Device{})
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return query
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return query.Where("1 = 0")
}
return query.Where("shop_id = ?", shopID)
}
func canManageEnterpriseDevice(ctx context.Context, device *model.Device) bool {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return true
}
shopID := middleware.GetShopIDFromContext(ctx)
return shopID > 0 && device.ShopID != nil && *device.ShopID == shopID
}
func loadEnterpriseDeviceOwnerShop(ctx context.Context, db *gorm.DB, ownerShopID *uint) *model.Shop {
if ownerShopID == nil {
return nil
}
var shop model.Shop
if err := db.WithContext(ctx).Unscoped().First(&shop, *ownerShopID).Error; err != nil {
return nil
}
return &shop
}
func loadDeviceBindings(tx *gorm.DB, deviceIDs []uint, lock bool) ([]*model.DeviceSimBinding, error) {
bindings := make([]*model.DeviceSimBinding, 0)
if len(deviceIDs) == 0 {
return bindings, nil
}
query := tx.Where("device_id IN ? AND bind_status = 1", deviceIDs)
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
if err := query.Find(&bindings).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
return bindings, nil
}
// loadAuditCards 使用非作用域查询保留软删除卡的稳定审计身份快照。
func loadAuditCards(tx *gorm.DB, auths []*model.EnterpriseCardAuthorization) (map[uint]*model.IotCard, error) {
cardIDs := make([]uint, 0, len(auths))
for _, auth := range auths {
cardIDs = append(cardIDs, auth.CardID)
}
cards := make(map[uint]*model.IotCard, len(cardIDs))
if len(cardIDs) == 0 {
return cards, nil
}
var values []*model.IotCard
if err := tx.Unscoped().Where("id IN ?", cardIDs).Find(&values).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡审计快照失败")
}
for _, card := range values {
cards[card.ID] = card
}
return cards, nil
}
func deviceIDs(devices []*model.Device) []uint {
ids := make([]uint, 0, len(devices))
for _, device := range devices {
ids = append(ids, device.ID)
}
return ids
}
func devicesByID(deviceMap map[string]*model.Device) map[uint]*model.Device {
result := make(map[uint]*model.Device, len(deviceMap))
for _, device := range deviceMap {
result[device.ID] = device
}
return result
}
func devicePointers(deviceMap map[string]*model.Device, ids []uint) []*model.Device {
wanted := make(map[uint]struct{}, len(ids))
for _, id := range ids {
wanted[id] = struct{}{}
}
devices := make([]*model.Device, 0, len(ids))
for _, device := range deviceMap {
if _, ok := wanted[device.ID]; ok {
devices = append(devices, device)
}
}
return devices
}
// recordDeviceFailure 在业务事务结束后使用统一 Writer 记录失败或拒绝事实。
func (s *Service) recordDeviceFailure(
ctx context.Context,
actionCode, summary string,
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices []*model.Device,
originalErr error,
) {
changes := make([]accessauditapp.DeviceChange, 0, len(devices))
for _, device := range devices {
changes = append(changes, accessauditapp.DeviceChange{Device: device})
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: enterpriseDeviceFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
Devices: changes, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
func enterpriseDeviceFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound:
return constants.AuditResultDenied
case errors.CodeInvalidStatus:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
// ListDevices 查询企业授权设备列表(后台管理)
func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.EnterpriseDeviceListReq) (*dto.EnterpriseDeviceListResp, error) {
// 验证企业存在

View File

@@ -52,6 +52,7 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
ResourceKey: &card.ICCID,
RequestID: requestID,
CorrelationID: requestID,
TriggerSeries: requestID,
RequestSummary: map[string]any{
"iot_card_id": card.ID,
"iccid": card.ICCID,
@@ -70,6 +71,13 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
CardNo: card.ICCID,
Code: strconv.Itoa(*code),
})
if gatewayErr != nil && s.logger != nil {
s.logger.Warn("Gateway 卡限速请求失败",
zap.Uint("iot_card_id", card.ID),
zap.String("integration_id", attempt.IntegrationID),
zap.Error(gatewayErr),
)
}
completion := speedTierCompletion(gatewayErr, time.Since(startedAt))
if _, completeErr := s.speedTierIntegration.Complete(ctx, attempt.IntegrationID, completion); completeErr != nil {
if s.logger != nil {
@@ -120,7 +128,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl
}
completion.Result = constants.IntegrationResultFailed
completion.StateChanged = false
completion.ProviderMessage = err.Error()
completion.SafeProviderMessage = "Gateway 卡限速请求失败"
completion.ResponseSummary = map[string]any{"result": "failed"}
if isGatewayTimeout(err) {
completion.Result = constants.IntegrationResultUnknown

View File

@@ -8,6 +8,7 @@ import (
"regexp"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -28,6 +29,8 @@ type AccountServiceInterface interface {
// Service 权限业务服务
type Service struct {
db *gorm.DB
accessAudit accessauditapp.Writer
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
@@ -35,6 +38,12 @@ type Service struct {
redisClient *redis.Client
}
// SetAccessAudit 注入权限定义变更的事务审计接缝。
func (s *Service) SetAccessAudit(db *gorm.DB, writer accessauditapp.Writer) {
s.db = db
s.accessAudit = writer
}
// New 创建权限服务
func New(
permissionStore *postgres.PermissionStore,
@@ -60,26 +69,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePermissionRequest)
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 验证权限编码格式
if !permCodeRegex.MatchString(req.PermCode) {
return nil, errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
}
// 检查权限编码唯一性
existing, err := s.permissionStore.GetByCode(ctx, req.PermCode)
if err == nil && existing != nil {
return nil, errors.New(errors.CodePermCodeExists, "权限编码已存在")
}
// 验证 parent_id 存在(如果提供)
if req.ParentID != nil {
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
if err != nil || parent == nil {
return nil, errors.New(errors.CodeNotFound, "上级权限不存在")
}
}
// 创建权限
permission := &model.Permission{
PermName: req.PermName,
PermCode: req.PermCode,
@@ -89,14 +78,59 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePermissionRequest)
ParentID: req.ParentID,
Sort: req.Sort,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: currentUserID,
Updater: currentUserID,
},
}
// 如果未指定 platform默认为 all
if permission.Platform == "" {
permission.Platform = constants.PlatformAll
}
if err := s.permissionStore.Create(ctx, permission); err != nil {
// 验证权限编码格式
if !permCodeRegex.MatchString(req.PermCode) {
appErr := errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建非法权限编码", constants.AuditResultDenied, permission, nil, appErr)
return nil, appErr
}
// 检查权限编码唯一性
existing, err := s.permissionStore.GetByCode(ctx, req.PermCode)
if err == nil && existing != nil {
appErr := errors.New(errors.CodePermCodeExists, "权限编码已存在")
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建重复权限编码", constants.AuditResultDenied, permission, nil, appErr)
return nil, appErr
}
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查权限编码失败")
}
// 验证 parent_id 存在(如果提供)
if req.ParentID != nil {
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查上级权限失败")
}
if err == gorm.ErrRecordNotFound || parent == nil {
appErr := errors.New(errors.CodeNotFound, "上级权限不存在")
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建上级不存在的权限", constants.AuditResultDenied, permission, nil, appErr)
return nil, appErr
}
}
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewPermissionStore(tx).Create(ctx, permission); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPermissionCreated, Summary: "创建权限", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Permissions: permissionChanges(permission, nil, permissionAuditData(permission)),
})
}); err != nil {
permission.ID = 0
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建权限失败")
}
@@ -131,6 +165,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
}
beforeData := permissionAuditData(permission)
// 更新字段
if req.PermName != nil {
@@ -139,12 +174,20 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
if req.PermCode != nil {
// 验证权限编码格式
if !permCodeRegex.MatchString(*req.PermCode) {
return nil, errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
appErr := errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新非法权限编码", constants.AuditResultDenied, permission, beforeData, appErr)
return nil, appErr
}
// 检查新权限编码唯一性
existing, err := s.permissionStore.GetByCode(ctx, *req.PermCode)
if err == nil && existing != nil && existing.ID != id {
return nil, errors.New(errors.CodePermCodeExists, "权限编码已存在")
appErr := errors.New(errors.CodePermCodeExists, "权限编码已存在")
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新重复权限编码", constants.AuditResultDenied, permission, beforeData, appErr)
return nil, appErr
}
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查权限编码失败")
}
permission.PermCode = *req.PermCode
}
@@ -157,8 +200,14 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
if req.ParentID != nil {
// 验证 parent_id 存在
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
if err != nil || parent == nil {
return nil, errors.New(errors.CodeNotFound, "上级权限不存在")
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查上级权限失败")
}
if err == gorm.ErrRecordNotFound || parent == nil {
appErr := errors.New(errors.CodeNotFound, "上级权限不存在")
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新不存在的上级权限", constants.AuditResultDenied, permission, beforeData, appErr)
return nil, appErr
}
permission.ParentID = req.ParentID
}
@@ -171,9 +220,25 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
permission.Updater = currentUserID
if err := s.permissionStore.Update(ctx, permission); err != nil {
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewPermissionStore(tx).Update(ctx, permission); err != nil {
return err
}
var err error
accountIDs, err = permissionCacheAccountIDs(ctx, tx, permission.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPermissionUpdated, Summary: "更新权限", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Permissions: permissionChanges(permission, beforeData, permissionAuditData(permission)),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新权限失败")
}
s.clearPermissionCaches(ctx, accountIDs)
return permission, nil
}
@@ -181,7 +246,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
// Delete 软删除权限
func (s *Service) Delete(ctx context.Context, id uint) error {
// 检查权限存在
_, err := s.permissionStore.GetByID(ctx, id)
permission, err := s.permissionStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodePermissionNotFound, "权限不存在")
@@ -189,9 +254,27 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
return errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
}
if err := s.permissionStore.Delete(ctx, id); err != nil {
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := permissionAuditData(permission)
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewPermissionStore(tx).Delete(ctx, id); err != nil {
return err
}
var err error
accountIDs, err = permissionCacheAccountIDs(ctx, tx, permission.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPermissionDeleted, Summary: "删除权限", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Permissions: permissionChanges(permission, beforeData, map[string]any{"deleted": true}),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionPermissionDeleted, "删除权限失败", constants.AuditResultFailed, permission, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "删除权限失败")
}
s.clearPermissionCaches(ctx, accountIDs)
return nil
}
@@ -351,3 +434,70 @@ func (s *Service) matchPermission(permissions []permissionCacheItem, permCode st
}
return false
}
func (s *Service) runAccessTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "权限审计接缝未配置")
}
return s.db.WithContext(ctx).Transaction(fn)
}
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary, result string,
permission *model.Permission,
beforeData map[string]any,
originalErr error,
) {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: result,
OperatorID: middleware.GetUserIDFromContext(ctx),
Permissions: permissionChanges(permission, beforeData, nil),
}, originalErr)
}
func permissionChanges(permission *model.Permission, beforeData, afterData map[string]any) []accessauditapp.PermissionChange {
if permission == nil {
return nil
}
return []accessauditapp.PermissionChange{{Permission: permission, BeforeData: beforeData, AfterData: afterData}}
}
func permissionAuditData(permission *model.Permission) map[string]any {
if permission == nil {
return nil
}
return map[string]any{
"perm_name": permission.PermName, "perm_code": permission.PermCode, "perm_type": permission.PermType,
"platform": permission.Platform, "available_for_role_types": permission.AvailableForRoleTypes,
"url": permission.URL, "parent_id": permission.ParentID, "sort": permission.Sort, "status": permission.Status,
}
}
func permissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, permissionID uint) ([]uint, error) {
var roleIDs []uint
if err := tx.WithContext(ctx).Model(&model.RolePermission{}).
Where("perm_id = ?", permissionID).Pluck("role_id", &roleIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询权限关联角色失败")
}
if len(roleIDs) == 0 {
return nil, nil
}
var accountIDs []uint
if err := tx.WithContext(ctx).Model(&model.AccountRole{}).
Where("role_id IN ?", roleIDs).Distinct().Pluck("account_id", &accountIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询权限关联账号失败")
}
return accountIDs, nil
}
func (s *Service) clearPermissionCaches(ctx context.Context, accountIDs []uint) {
if len(accountIDs) == 0 || s.redisClient == nil {
return
}
pipe := s.redisClient.Pipeline()
for _, accountID := range accountIDs {
pipe.Del(ctx, constants.RedisUserPermissionsKey(accountID))
}
_, _ = pipe.Exec(ctx)
}

View File

@@ -3,59 +3,88 @@ package personal_customer
import (
"context"
stderrors "errors"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Service 个人客户服务
type Service struct {
store *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
logger *zap.Logger
db *gorm.DB
store *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
logger *zap.Logger
accessAudit accessauditapp.Writer
}
// NewService 创建个人客户服务实例
func NewService(
db *gorm.DB,
store *postgres.PersonalCustomerStore,
phoneStore *postgres.PersonalCustomerPhoneStore,
logger *zap.Logger,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
store: store,
phoneStore: phoneStore,
logger: logger,
db: db,
store: store,
phoneStore: phoneStore,
logger: logger,
accessAudit: accessAudit,
}
}
// UpdateProfile 更新个人资料
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
customer, err := s.store.GetByID(ctx, customerID)
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
customer := &model.PersonalCustomer{Model: gorm.Model{ID: customerID}}
failureCustomer := customer
var beforeData map[string]any
loaded := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
loaded = true
beforeData = personalProfileAuditData(customer)
current := *customer
failureCustomer = &current
if nickname != "" {
customer.Nickname = nickname
}
if avatarURL != "" {
customer.AvatarURL = avatarURL
}
if err := tx.Save(customer).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败")
}
return s.accessAudit.WriteAccessChange(ctx, tx, personalProfileAudit(customer, beforeData, constants.AuditResultSuccess))
})
if err != nil {
s.logger.Error("查询个人客户失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
// 更新资料
if nickname != "" {
customer.Nickname = nickname
}
if avatarURL != "" {
customer.AvatarURL = avatarURL
}
if err := s.store.Update(ctx, customer); err != nil {
if !loaded {
s.logger.Error("查询个人客户失败", zap.Uint("customer_id", customerID), zap.Error(err))
return err
}
s.logger.Error("更新个人资料失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败")
failure := personalProfileAudit(failureCustomer, beforeData, personalAuditFailureResult(err))
failure.Summary = "更新个人资料失败"
failure.SubjectSummary = "个人资料更新失败"
failure.SubjectData = nil
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, failure, err)
return err
}
s.logger.Info("更新个人资料成功",
@@ -65,6 +94,32 @@ func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname,
return nil
}
func personalProfileAudit(customer *model.PersonalCustomer, beforeData map[string]any, result string) accessauditapp.ChangeAudit {
return accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "更新个人资料", Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalProfileAuditData(customer),
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已更新",
SubjectData: map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL},
}
}
func personalProfileAuditData(customer *model.PersonalCustomer) map[string]any {
return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL}
}
func personalAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
// GetProfileWithPhone 获取个人资料(包含主手机号)
func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) {
// 获取客户信息

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -15,11 +16,15 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// Service 角色业务服务
type Service struct {
db *gorm.DB
redisClient *redis.Client
accessAudit accessauditapp.Writer
roleStore *postgres.RoleStore
permissionStore *postgres.PermissionStore
rolePermissionStore *postgres.RolePermissionStore
@@ -27,6 +32,13 @@ type Service struct {
shopRoleStore *postgres.ShopRoleStore
}
// SetAccessAudit 注入角色与权限配置的事务审计接缝。
func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) {
s.db = db
s.redisClient = redisClient
s.accessAudit = writer
}
// New 创建角色服务
func New(roleStore *postgres.RoleStore, permissionStore *postgres.PermissionStore, rolePermissionStore *postgres.RolePermissionStore, accountRoleStore *postgres.AccountRoleStore, shopRoleStore *postgres.ShopRoleStore) *Service {
return &Service{
@@ -46,24 +58,40 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRoleRequest) (*dto.
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 检查角色名是否已存在
exists, err := s.roleStore.ExistsByName(ctx, req.RoleName, 0)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
}
if exists {
return nil, errors.New(errors.CodeRoleNameExists)
}
// 创建角色
role := &model.Role{
RoleName: req.RoleName,
RoleDesc: req.RoleDesc,
RoleType: req.RoleType,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: currentUserID,
Updater: currentUserID,
},
}
if err := s.roleStore.Create(ctx, role); err != nil {
// 检查角色名是否已存在
exists, err := s.roleStore.ExistsByName(ctx, req.RoleName, 0)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleCreated, "创建角色失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
}
if exists {
appErr := errors.New(errors.CodeRoleNameExists)
s.recordFailure(ctx, constants.AuditActionRoleCreated, "拒绝创建重复角色", constants.AuditResultDenied, role, nil, nil, appErr)
return nil, appErr
}
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Create(ctx, role); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleCreated, Summary: "创建角色", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, AfterData: roleAuditData(role),
})
}); err != nil {
role.ID = 0
s.recordFailure(ctx, constants.AuditActionRoleCreated, "创建角色失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色失败")
}
@@ -98,15 +126,19 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
}
beforeData := roleAuditData(role)
// 如果修改了角色名,检查是否与其他角色重复
if req.RoleName != nil && *req.RoleName != role.RoleName {
exists, err := s.roleStore.ExistsByName(ctx, *req.RoleName, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
}
if exists {
return nil, errors.New(errors.CodeRoleNameExists)
appErr := errors.New(errors.CodeRoleNameExists)
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "拒绝更新重复角色名", constants.AuditResultDenied, role, beforeData, nil, appErr)
return nil, appErr
}
role.RoleName = *req.RoleName
}
@@ -121,7 +153,16 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
role.Updater = currentUserID
if err := s.roleStore.Update(ctx, role); err != nil {
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Update(ctx, role); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleUpdated, Summary: "更新角色", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新角色失败")
}
@@ -130,7 +171,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
// Delete 软删除角色
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.roleStore.GetByID(ctx, id)
role, err := s.roleStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound, "角色不存在")
@@ -140,19 +181,34 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
if accountCount > 0 || shopCount > 0 {
return 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)
return appErr
}
if err := s.roleStore.Delete(ctx, id); err != nil {
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := roleAuditData(role)
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Delete(ctx, id); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDeleted, Summary: "删除角色", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: role, BeforeData: beforeData, AfterData: map[string]any{"deleted": true},
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "删除角色失败")
}
@@ -212,11 +268,14 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
}
if len(permissions) != len(permIDs) {
return nil, errors.New(errors.CodePermissionNotFound, "部分权限不存在")
appErr := errors.New(errors.CodePermissionNotFound, "部分权限不存在")
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "拒绝分配不存在的权限", constants.AuditResultDenied, role, nil, nil, appErr)
return nil, appErr
}
roleTypeStr := fmt.Sprintf("%d", role.RoleType)
@@ -228,12 +287,15 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
}
if len(invalidPermIDs) > 0 {
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("权限 %v 不适用于此角色类型", invalidPermIDs))
appErr := errors.New(errors.CodeInvalidParam, fmt.Sprintf("权限 %v 不适用于此角色类型", invalidPermIDs))
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "拒绝分配不适用的权限", constants.AuditResultDenied, role, permissionAuditChanges(permissions, nil, nil), nil, appErr)
return nil, appErr
}
// 批量获取已有权限集合,避免逐条 Exists 查询
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "获取已有权限失败")
}
existingSet := make(map[uint]bool, len(existingPermIDs))
@@ -242,6 +304,11 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
}
var rps []*model.RolePermission
changedPermissions := make([]*model.Permission, 0, len(permissions))
permissionByID := make(map[uint]*model.Permission, len(permissions))
for _, permission := range permissions {
permissionByID[permission.ID] = permission
}
for _, permID := range permIDs {
if existingSet[permID] {
continue
@@ -252,11 +319,37 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
PermID: permID,
Status: constants.StatusEnabled,
}
if err := s.rolePermissionStore.Create(ctx, rp); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色-权限关联失败")
}
rps = append(rps, rp)
changedPermissions = append(changedPermissions, permissionByID[permID])
}
beforeData := map[string]any{"permission_ids": existingPermIDs}
afterData := map[string]any{"permission_ids": appendPermissionIDs(existingPermIDs, rps)}
if len(rps) == 0 {
return rps, nil
}
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
store := postgres.NewRolePermissionStore(tx, nil)
for _, rp := range rps {
if err := store.Create(ctx, rp); err != nil {
return err
}
}
var err error
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRolePermissionsAssigned, Summary: "分配角色权限", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, Permissions: permissionAuditChanges(changedPermissions, map[string]any{"assigned": false}, map[string]any{"assigned": true}),
BeforeData: beforeData, AfterData: afterData,
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, permissionAuditChanges(changedPermissions, map[string]any{"assigned": false}, nil), beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色-权限关联失败")
}
s.clearRolePermissionCaches(ctx, accountIDs)
return rps, nil
}
@@ -288,7 +381,7 @@ func (s *Service) GetPermissions(ctx context.Context, roleID uint) ([]*model.Per
// RemovePermission 移除角色的权限
func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) error {
_, err := s.roleStore.GetByID(ctx, roleID)
role, err := s.roleStore.GetByID(ctx, roleID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound, "角色不存在")
@@ -296,16 +389,52 @@ func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) err
return errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
}
if err := s.rolePermissionStore.Delete(ctx, roleID, permID); err != nil {
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "获取角色权限失败")
}
if !containsUint(existingPermIDs, permID) {
return nil
}
permission, permissionErr := s.permissionStore.GetByID(ctx, permID)
if permissionErr != nil && permissionErr != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, nil, map[string]any{"permission_ids": existingPermIDs}, permissionErr)
return errors.Wrap(errors.CodeInternalError, permissionErr, "获取待移除权限失败")
}
changes := []accessauditapp.PermissionChange(nil)
if permissionErr == nil {
changes = permissionAuditChanges([]*model.Permission{permission}, map[string]any{"assigned": true}, map[string]any{"assigned": false})
}
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := map[string]any{"permission_ids": existingPermIDs}
afterData := map[string]any{"permission_ids": removePermissionIDs(existingPermIDs, map[uint]struct{}{permID: {}})}
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRolePermissionStore(tx, nil).Delete(ctx, roleID, permID); err != nil {
return err
}
var err error
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRolePermissionRemoved, Summary: "移除角色权限", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: role, Permissions: changes, BeforeData: beforeData, AfterData: afterData,
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, changes, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "删除角色-权限关联失败")
}
s.clearRolePermissionCaches(ctx, accountIDs)
return nil
}
// BatchRemovePermissions 批量移除角色的权限
func (s *Service) BatchRemovePermissions(ctx context.Context, roleID uint, permIDs []uint) error {
_, err := s.roleStore.GetByID(ctx, roleID)
role, err := s.roleStore.GetByID(ctx, roleID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound, "角色不存在")
@@ -313,9 +442,50 @@ func (s *Service) BatchRemovePermissions(ctx context.Context, roleID uint, permI
return errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
}
if err := s.rolePermissionStore.BatchDelete(ctx, roleID, permIDs); err != nil {
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "获取角色权限失败")
}
removeSet := make(map[uint]struct{}, len(permIDs))
actualIDs := make([]uint, 0, len(permIDs))
for _, permID := range permIDs {
removeSet[permID] = struct{}{}
if containsUint(existingPermIDs, permID) {
actualIDs = append(actualIDs, permID)
}
}
if len(actualIDs) == 0 {
return nil
}
permissions, err := s.permissionStore.GetByIDs(ctx, actualIDs)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, nil, map[string]any{"permission_ids": existingPermIDs}, err)
return errors.Wrap(errors.CodeInternalError, err, "获取待移除权限失败")
}
changes := permissionAuditChanges(permissions, map[string]any{"assigned": true}, map[string]any{"assigned": false})
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := map[string]any{"permission_ids": existingPermIDs}
afterData := map[string]any{"permission_ids": removePermissionIDs(existingPermIDs, removeSet)}
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRolePermissionStore(tx, nil).BatchDelete(ctx, roleID, permIDs); err != nil {
return err
}
var err error
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRolePermissionsBatchRemoved, Summary: "批量移除角色权限", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: role, Permissions: changes, BeforeData: beforeData, AfterData: afterData,
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, changes, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "批量删除角色-权限关联失败")
}
s.clearRolePermissionCaches(ctx, accountIDs)
return nil
}
@@ -340,23 +510,37 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
if status == constants.StatusDisabled {
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
if accountCount > 0 || shopCount > 0 {
return 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)
return appErr
}
}
beforeData := roleAuditData(role)
role.Status = status
role.Updater = currentUserID
if err := s.roleStore.Update(ctx, role); err != nil {
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Update(ctx, role); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleStatusUpdated, Summary: "更新角色状态", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, beforeData, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "更新角色状态失败")
}
@@ -390,3 +574,95 @@ func contains(availableForRoleTypes, roleTypeStr string) bool {
}
return false
}
func (s *Service) runAccessTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "角色权限审计接缝未配置")
}
return s.db.WithContext(ctx).Transaction(fn)
}
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary, result string,
role *model.Role,
permissions []accessauditapp.PermissionChange,
beforeData map[string]any,
originalErr error,
) {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: result,
OperatorID: middleware.GetUserIDFromContext(ctx), Role: role, Permissions: permissions,
BeforeData: beforeData,
}, originalErr)
}
func roleAuditData(role *model.Role) map[string]any {
if role == nil {
return nil
}
return map[string]any{
"role_name": role.RoleName, "role_desc": role.RoleDesc, "role_type": role.RoleType, "status": role.Status,
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
}
}
func permissionAuditChanges(permissions []*model.Permission, beforeData, afterData map[string]any) []accessauditapp.PermissionChange {
changes := make([]accessauditapp.PermissionChange, 0, len(permissions))
for _, permission := range permissions {
if permission == nil {
continue
}
changes = append(changes, accessauditapp.PermissionChange{
Permission: permission, BeforeData: beforeData, AfterData: afterData,
})
}
return changes
}
func appendPermissionIDs(existing []uint, additions []*model.RolePermission) []uint {
result := append([]uint(nil), existing...)
for _, addition := range additions {
result = append(result, addition.PermID)
}
return result
}
func removePermissionIDs(existing []uint, removeSet map[uint]struct{}) []uint {
result := make([]uint, 0, len(existing))
for _, permissionID := range existing {
if _, removed := removeSet[permissionID]; !removed {
result = append(result, permissionID)
}
}
return result
}
func containsUint(values []uint, target uint) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func rolePermissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, roleID uint) ([]uint, error) {
var accountIDs []uint
if err := tx.WithContext(ctx).Model(&model.AccountRole{}).
Where("role_id = ?", roleID).Distinct().Pluck("account_id", &accountIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询角色关联账号失败")
}
return accountIDs, nil
}
func (s *Service) clearRolePermissionCaches(ctx context.Context, accountIDs []uint) {
if len(accountIDs) == 0 || s.redisClient == nil {
return
}
pipe := s.redisClient.Pipeline()
for _, accountID := range accountIDs {
pipe.Del(ctx, constants.RedisUserPermissionsKey(accountID))
}
_, _ = pipe.Exec(ctx)
}

View File

@@ -3,6 +3,7 @@ package shop
import (
"context"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -10,16 +11,28 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
db *gorm.DB
redisClient *redis.Client
accessAudit accessauditapp.Writer
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
shopRoleStore *postgres.ShopRoleStore
roleStore *postgres.RoleStore
}
// SetAccessAudit 注入店铺角色授权的事务、缓存和统一审计边界。
func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) {
s.db = db
s.redisClient = redisClient
s.accessAudit = writer
}
func New(
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
@@ -290,36 +303,90 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
shop, err := s.shopStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeShopNotFound, "店铺不存在")
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "店铺删除审计接缝未配置")
}
var shop *model.Shop
var parent *model.Shop
var accounts []*model.Account
var accountIDs []uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.Shop
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeShopNotFound, "店铺不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺失败")
}
return errors.Wrap(errors.CodeInternalError, err, "获取店铺失败")
}
accounts, err := s.accountStore.GetByShopID(ctx, shop.ID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询店铺账号失败")
}
if len(accounts) > 0 {
accountIDs := make([]uint, 0, len(accounts))
shop = &locked
parent = loadDeletedShopParent(tx, locked.ParentID)
if err := tx.Where("shop_id = ?", locked.ID).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺账号失败")
}
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
accountIDs = make([]uint, 0, len(accounts))
for _, account := range accounts {
accountIDs = append(accountIDs, account.ID)
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, BeforeData: map[string]any{"status": account.Status}, AfterData: map[string]any{"status": constants.StatusDisabled},
})
}
if err := s.accountStore.BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "禁用店铺账号失败")
if len(accountIDs) > 0 {
if err := postgres.NewAccountStore(tx, nil).BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "禁用店铺账号失败")
}
}
if err := tx.Delete(&model.Shop{}, id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺失败")
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺", OperatorID: currentUserID,
Shop: shop, ParentShop: parent, Accounts: accountChanges,
BeforeData: map[string]any{"deleted": false}, AfterData: map[string]any{"deleted": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺已删除",
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺删除审计失败")
}
return nil
})
if err != nil {
if shop != nil {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺失败", Result: shopRoleFailureResult(err),
OperatorID: currentUserID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, err)
}
return err
}
if err := s.shopStore.Delete(ctx, id); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除店铺失败")
}
s.clearDeletedShopCaches(ctx, shop.ID, shop.ParentID, accountIDs)
return nil
}
func (s *Service) clearDeletedShopCaches(ctx context.Context, shopID uint, parentID *uint, accountIDs []uint) {
if s.redisClient == nil {
return
}
keys := []string{constants.RedisShopSubordinatesKey(shopID)}
if parentID != nil {
keys = append(keys, constants.RedisShopSubordinatesKey(*parentID))
}
for _, accountID := range accountIDs {
keys = append(keys, constants.RedisUserPermissionsKey(accountID))
}
_ = s.redisClient.Del(ctx, keys...).Err()
}
func loadDeletedShopParent(tx *gorm.DB, parentID *uint) *model.Shop {
if parentID == nil {
return nil
}
var parent model.Shop
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
return nil
}
return &parent
}
// GetSubordinateShopIDs 获取下级店铺 ID 列表(包含自己)
func (s *Service) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) {
return s.shopStore.GetSubordinateShopIDs(ctx, shopID)

View File

@@ -2,12 +2,18 @@ package shop
import (
"context"
stderrors "errors"
"slices"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) AssignRolesToShop(ctx context.Context, shopID uint, roleIDs []uint) ([]*model.ShopRole, error) {
@@ -20,52 +26,11 @@ func (s *Service) AssignRolesToShop(ctx context.Context, shopID uint, roleIDs []
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
}
currentUserID := middleware.GetUserIDFromContext(ctx)
if len(roleIDs) == 0 {
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "清空店铺角色失败")
}
return []*model.ShopRole{}, nil
}
roles, err := s.roleStore.GetByIDs(ctx, roleIDs)
shopRoles, changedRoles, err := s.assignShopRoles(ctx, shop, middleware.GetUserIDFromContext(ctx), roleIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询角色失败")
s.recordShopRoleFailure(ctx, constants.AuditActionShopRolesAssigned, shop, changedRoles, err)
return nil, err
}
if len(roles) != len(roleIDs) {
return nil, errors.New(errors.CodeNotFound, "部分角色不存在")
}
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return nil, errors.New(errors.CodeInvalidParam, "店铺只能分配客户角色")
}
if role.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "角色已禁用")
}
}
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "删除现有店铺角色失败")
}
shopRoles := make([]*model.ShopRole, 0, len(roleIDs))
for _, roleID := range roleIDs {
shopRole := &model.ShopRole{
ShopID: shop.ID,
RoleID: roleID,
Status: constants.StatusEnabled,
Creator: currentUserID,
Updater: currentUserID,
}
shopRoles = append(shopRoles, shopRole)
}
if err := s.shopRoleStore.BatchCreate(ctx, shopRoles); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "批量创建店铺角色失败")
}
return shopRoles, nil
}
@@ -132,14 +97,234 @@ func (s *Service) DeleteShopRole(ctx context.Context, shopID, roleID uint) error
return err
}
_, err := s.shopStore.GetByID(ctx, shopID)
shop, err := s.shopStore.GetByID(ctx, shopID)
if err != nil {
return errors.New(errors.CodeNotFound, "店铺不存在")
}
if err := s.shopRoleStore.Delete(ctx, shopID, roleID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除店铺角色失败")
role, err := s.removeShopRole(ctx, shop, middleware.GetUserIDFromContext(ctx), roleID)
if err != nil {
roles := []*model.Role(nil)
if role != nil {
roles = []*model.Role{role}
}
s.recordShopRoleFailure(ctx, constants.AuditActionShopRoleDeleted, shop, roles, err)
return err
}
return nil
}
func (s *Service) assignShopRoles(ctx context.Context, shop *model.Shop, operatorID uint, requested []uint) ([]*model.ShopRole, []*model.Role, error) {
if s.db == nil || s.accessAudit == nil {
return nil, nil, errors.New(errors.CodeInvalidStatus, "店铺角色审计接缝未配置")
}
var shopRoles []*model.ShopRole
var changedRoles []*model.Role
var accountIDs []uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Shop{}, shop.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺角色关系失败")
}
shopRoleStore := postgres.NewShopRoleStore(tx, nil)
roleStore := postgres.NewRoleStore(tx)
beforeIDs, err := shopRoleStore.GetRoleIDsByShopID(ctx, shop.ID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺现有角色失败")
}
requestedRoles, err := validateShopRoles(ctx, roleStore, requested)
if err != nil {
return err
}
changedRoles, err = loadChangedRoles(ctx, roleStore, beforeIDs, requested, requestedRoles)
if err != nil {
return err
}
if err := shopRoleStore.DeleteByShopID(ctx, shop.ID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除现有店铺角色失败")
}
shopRoles = make([]*model.ShopRole, 0, len(requested))
for _, roleID := range requested {
shopRoles = append(shopRoles, &model.ShopRole{ShopID: shop.ID, RoleID: roleID, Status: constants.StatusEnabled, Creator: operatorID, Updater: operatorID})
}
if err := shopRoleStore.BatchCreate(ctx, shopRoles); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "批量创建店铺角色失败")
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopRolesAssigned, Summary: "分配店铺默认角色",
OperatorID: operatorID, Shop: shop, Roles: shopRoleChanges(changedRoles, beforeIDs, requested),
BeforeData: map[string]any{"role_ids": sortedShopRoleIDs(beforeIDs)},
AfterData: map[string]any{"role_ids": sortedShopRoleIDs(requested)},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺角色审计失败")
}
accountIDs, err = shopPermissionCacheAccountIDs(ctx, tx, shop.ID)
return err
})
if err != nil {
return nil, changedRoles, err
}
s.clearShopPermissionCaches(ctx, accountIDs)
return shopRoles, changedRoles, nil
}
func (s *Service) removeShopRole(ctx context.Context, shop *model.Shop, operatorID, roleID uint) (*model.Role, error) {
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺角色审计接缝未配置")
}
var removed *model.Role
var accountIDs []uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Shop{}, shop.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺角色关系失败")
}
shopRoles := postgres.NewShopRoleStore(tx, nil)
beforeIDs, err := shopRoles.GetRoleIDsByShopID(ctx, shop.ID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺现有角色失败")
}
if !slices.Contains(beforeIDs, roleID) {
return nil
}
removed, err = postgres.NewRoleStore(tx).GetByID(ctx, roleID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待移除店铺角色失败")
}
if err := shopRoles.Delete(ctx, shop.ID, roleID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺角色失败")
}
afterIDs := removeShopRoleID(beforeIDs, roleID)
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopRoleDeleted, Summary: "删除店铺默认角色",
OperatorID: operatorID, Shop: shop,
Roles: []accessauditapp.RoleChange{{Role: removed, BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false}}},
BeforeData: map[string]any{"role_ids": sortedShopRoleIDs(beforeIDs)},
AfterData: map[string]any{"role_ids": sortedShopRoleIDs(afterIDs)},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺角色审计失败")
}
accountIDs, err = shopPermissionCacheAccountIDs(ctx, tx, shop.ID)
return err
})
if err == nil {
s.clearShopPermissionCaches(ctx, accountIDs)
}
return removed, err
}
func validateShopRoles(ctx context.Context, store *postgres.RoleStore, roleIDs []uint) ([]*model.Role, error) {
if len(roleIDs) == 0 {
return nil, nil
}
roles, err := store.GetByIDs(ctx, roleIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询角色失败")
}
if len(roles) != len(roleIDs) {
return nil, errors.New(errors.CodeNotFound, "部分角色不存在")
}
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return nil, errors.New(errors.CodeInvalidParam, "店铺只能分配客户角色")
}
if role.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "角色已禁用")
}
}
return roles, nil
}
func loadChangedRoles(ctx context.Context, store *postgres.RoleStore, beforeIDs, afterIDs []uint, afterRoles []*model.Role) ([]*model.Role, error) {
changedIDs := make([]uint, 0, len(beforeIDs)+len(afterIDs))
before, after := shopRoleIDSet(beforeIDs), shopRoleIDSet(afterIDs)
for _, id := range beforeIDs {
if !after[id] {
changedIDs = append(changedIDs, id)
}
}
for _, role := range afterRoles {
if !before[role.ID] {
changedIDs = append(changedIDs, role.ID)
}
}
roles, err := store.GetByIDs(ctx, changedIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询变更角色失败")
}
return roles, nil
}
func shopPermissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, shopID uint) ([]uint, error) {
var accountIDs []uint
if err := tx.WithContext(ctx).Model(&model.Account{}).Where("shop_id = ?", shopID).Pluck("id", &accountIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺账号失败")
}
return accountIDs, nil
}
func (s *Service) clearShopPermissionCaches(ctx context.Context, accountIDs []uint) {
if len(accountIDs) == 0 || s.redisClient == nil {
return
}
keys := make([]string, 0, len(accountIDs))
for _, accountID := range accountIDs {
keys = append(keys, constants.RedisUserPermissionsKey(accountID))
}
_ = s.redisClient.Del(ctx, keys...).Err()
}
func (s *Service) recordShopRoleFailure(ctx context.Context, action string, shop *model.Shop, roles []*model.Role, originalErr error) {
changes := make([]accessauditapp.RoleChange, 0, len(roles))
for _, role := range roles {
changes = append(changes, accessauditapp.RoleChange{Role: role})
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: action, Summary: "店铺角色操作失败", Result: shopRoleFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, Roles: changes,
}, originalErr)
}
func shopRoleChanges(roles []*model.Role, beforeIDs, afterIDs []uint) []accessauditapp.RoleChange {
before, after := shopRoleIDSet(beforeIDs), shopRoleIDSet(afterIDs)
changes := make([]accessauditapp.RoleChange, 0, len(roles))
for _, role := range roles {
changes = append(changes, accessauditapp.RoleChange{
Role: role, BeforeData: map[string]any{"assigned": before[role.ID]}, AfterData: map[string]any{"assigned": after[role.ID]},
})
}
return changes
}
func shopRoleFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeRoleNotFound:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
func shopRoleIDSet(ids []uint) map[uint]bool {
set := make(map[uint]bool, len(ids))
for _, id := range ids {
set[id] = true
}
return set
}
func sortedShopRoleIDs(ids []uint) []uint {
result := append([]uint(nil), ids...)
slices.Sort(result)
return result
}
func removeShopRoleID(ids []uint, removed uint) []uint {
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id != removed {
result = append(result, id)
}
}
return result
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -42,6 +43,11 @@ func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, t
UserID: task.Creator, UserType: task.OperatorType, Username: task.CreatorName,
ShopID: valueOrZero(task.OperatorShopID), SubordinateShopIDs: shopScope,
})
workerCtx = auditcontext.With(workerCtx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeDeviceImport,
ActorName: "设备CSV批量操作任务", Source: constants.AuditSourceWorker,
CorrelationID: task.TaskNo,
})
result, err := h.executeDeviceBatchAllocation(workerCtx, task, rows)
if err != nil {
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error())

View File

@@ -5,6 +5,8 @@ import (
"strconv"
"time"
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -17,12 +19,15 @@ func startGatewayAttempt(ctx context.Context, repository *integrationlog.Reposit
return nil, errors.New(errors.CodeInternalError, "Gateway Integration Log 未配置")
}
resourceID := strconv.FormatUint(uint64(cardID), 10)
integrationID := uuid.NewString()
triggerSource := constants.CardObservationSourcePolling
triggerScene := scene
return repository.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID,
TriggerSource: &triggerSource, TriggerScene: &triggerScene,
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &integrationID,
CorrelationID: &integrationID,
RequestSummary: map[string]any{"card_id": cardID}, Metadata: map[string]any{"scene": scene},
InitialResult: constants.IntegrationResultPending,
})

View File

@@ -0,0 +1,18 @@
BEGIN;
LOCK TABLE tb_audit_event, tb_audit_event_resource IN ACCESS EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM tb_audit_event LIMIT 1)
OR EXISTS (SELECT 1 FROM tb_audit_event_resource LIMIT 1) THEN
RAISE EXCEPTION '统一审计表已存在事实,禁止删表回滚;请停止生产者后向前修复';
END IF;
END
$$;
DROP TABLE tb_audit_event_resource;
DROP TABLE tb_audit_event;
DROP FUNCTION reject_audit_fact_update();
COMMIT;

View File

@@ -0,0 +1,154 @@
CREATE TABLE tb_audit_event (
id bigserial PRIMARY KEY,
event_id varchar(64) NOT NULL UNIQUE,
occurred_at timestamptz NOT NULL,
category varchar(64) NOT NULL,
action_code varchar(100) NOT NULL,
action_name varchar(200) NOT NULL,
summary varchar(500) NOT NULL,
actor_kind varchar(32) NOT NULL,
actor_id varchar(128) NOT NULL,
actor_name varchar(200) NOT NULL,
actor_shop_id bigint,
actor_shop_name varchar(200) NOT NULL DEFAULT '',
actor_enterprise_id bigint,
actor_enterprise_name varchar(200) NOT NULL DEFAULT '',
source varchar(32) NOT NULL,
request_path varchar(300) NOT NULL DEFAULT '',
request_method varchar(16) NOT NULL DEFAULT '',
ip_address varchar(64) NOT NULL DEFAULT '',
user_agent varchar(500) NOT NULL DEFAULT '',
scope_type varchar(32) NOT NULL,
scope_id varchar(128) NOT NULL DEFAULT '',
scope_name varchar(200) NOT NULL DEFAULT '',
result varchar(16) NOT NULL,
risk_level varchar(16) NOT NULL,
error_code varchar(100) NOT NULL DEFAULT '',
error_summary varchar(500) NOT NULL DEFAULT '',
request_id varchar(100) NOT NULL DEFAULT '',
correlation_id varchar(100) NOT NULL DEFAULT '',
parent_event_id varchar(64) NOT NULL DEFAULT '',
batch_total integer NOT NULL DEFAULT 0,
success_count integer NOT NULL DEFAULT 0,
fail_count integer NOT NULL DEFAULT 0,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
content_hash varchar(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT ck_audit_event_identity CHECK (event_id <> '' AND action_code <> '' AND action_name <> ''),
CONSTRAINT ck_audit_event_result CHECK (result IN ('success', 'failed', 'denied', 'partial', 'unknown')),
CONSTRAINT ck_audit_event_risk CHECK (risk_level IN ('low', 'normal', 'high', 'critical')),
CONSTRAINT ck_audit_event_batch CHECK (
batch_total >= 0 AND success_count >= 0 AND fail_count >= 0
AND success_count + fail_count <= batch_total
),
CONSTRAINT ck_audit_event_metadata CHECK (jsonb_typeof(metadata) = 'object')
);
CREATE TABLE tb_audit_event_resource (
id bigserial PRIMARY KEY,
audit_event_id bigint NOT NULL,
resource_type varchar(64) NOT NULL,
resource_id varchar(128),
resource_key varchar(200) NOT NULL,
display_name varchar(255) NOT NULL DEFAULT '',
relation varchar(16) NOT NULL,
role varchar(64) NOT NULL,
identity_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
before_data jsonb NOT NULL DEFAULT '{}'::jsonb,
after_data jsonb NOT NULL DEFAULT '{}'::jsonb,
subject_visibility varchar(24) NOT NULL,
subject_summary varchar(500) NOT NULL DEFAULT '',
subject_data jsonb NOT NULL DEFAULT '{}'::jsonb,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT ck_audit_resource_identity CHECK (resource_type <> '' AND resource_key <> ''),
CONSTRAINT ck_audit_resource_relation CHECK (relation IN ('primary', 'affected', 'reference')),
CONSTRAINT ck_audit_resource_visibility CHECK (subject_visibility IN ('internal_only', 'subject_result', 'subject_detail')),
CONSTRAINT ck_audit_resource_identity_snapshot CHECK (jsonb_typeof(identity_snapshot) = 'object'),
CONSTRAINT ck_audit_resource_before_data CHECK (jsonb_typeof(before_data) = 'object'),
CONSTRAINT ck_audit_resource_after_data CHECK (jsonb_typeof(after_data) = 'object'),
CONSTRAINT ck_audit_resource_subject_data CHECK (jsonb_typeof(subject_data) = 'object')
);
CREATE UNIQUE INDEX uq_audit_event_resource_role
ON tb_audit_event_resource (audit_event_id, resource_type, resource_key, relation, role);
CREATE INDEX idx_audit_event_occurred ON tb_audit_event (occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_actor ON tb_audit_event (actor_kind, actor_id, occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_action_result_risk ON tb_audit_event (action_code, result, risk_level, occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_scope ON tb_audit_event (scope_type, scope_id, occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_request ON tb_audit_event (request_id) WHERE request_id <> '';
CREATE INDEX idx_audit_event_correlation ON tb_audit_event (correlation_id, occurred_at DESC, id DESC) WHERE correlation_id <> '';
CREATE INDEX idx_audit_event_parent ON tb_audit_event (parent_event_id) WHERE parent_event_id <> '';
CREATE INDEX idx_audit_resource_event ON tb_audit_event_resource (audit_event_id, sort_order, id);
CREATE INDEX idx_audit_resource_id_timeline
ON tb_audit_event_resource (resource_type, resource_id, created_at DESC, id DESC)
WHERE resource_id IS NOT NULL;
CREATE INDEX idx_audit_resource_key_timeline
ON tb_audit_event_resource (resource_type, resource_key, created_at DESC, id DESC);
CREATE FUNCTION reject_audit_fact_update() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION '审计事实不可修改;业务修正必须追加新事件';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_audit_event_immutable
BEFORE UPDATE ON tb_audit_event
FOR EACH ROW EXECUTE FUNCTION reject_audit_fact_update();
CREATE TRIGGER trg_audit_event_resource_immutable
BEFORE UPDATE ON tb_audit_event_resource
FOR EACH ROW EXECUTE FUNCTION reject_audit_fact_update();
COMMENT ON TABLE tb_audit_event IS '统一不可变业务审计事件';
COMMENT ON TABLE tb_audit_event_resource IS '审计事件发生时的独立资源快照';
COMMENT ON COLUMN tb_audit_event.id IS '审计事件数据库主键';
COMMENT ON COLUMN tb_audit_event.event_id IS '对外稳定审计事件ID';
COMMENT ON COLUMN tb_audit_event.occurred_at IS '业务事实实际发生时间';
COMMENT ON COLUMN tb_audit_event.category IS '动作所属稳定业务类别';
COMMENT ON COLUMN tb_audit_event.action_code IS 'Action Registry 注册的稳定动作编码';
COMMENT ON COLUMN tb_audit_event.action_name IS '事件发生时的中文动作名称快照';
COMMENT ON COLUMN tb_audit_event.summary IS '平台内部可读业务摘要';
COMMENT ON COLUMN tb_audit_event.actor_kind IS '真实操作者类型';
COMMENT ON COLUMN tb_audit_event.actor_id IS '真实操作者稳定ID';
COMMENT ON COLUMN tb_audit_event.actor_name IS '事件发生时的操作者名称快照';
COMMENT ON COLUMN tb_audit_event.actor_shop_id IS '操作者所属店铺ID快照';
COMMENT ON COLUMN tb_audit_event.actor_shop_name IS '操作者所属店铺名称快照';
COMMENT ON COLUMN tb_audit_event.actor_enterprise_id IS '操作者所属企业ID快照';
COMMENT ON COLUMN tb_audit_event.actor_enterprise_name IS '操作者所属企业名称快照';
COMMENT ON COLUMN tb_audit_event.source IS '操作入口来源';
COMMENT ON COLUMN tb_audit_event.request_path IS 'HTTP请求路径摘要';
COMMENT ON COLUMN tb_audit_event.request_method IS 'HTTP请求方法';
COMMENT ON COLUMN tb_audit_event.ip_address IS '操作者请求IP';
COMMENT ON COLUMN tb_audit_event.user_agent IS '操作者User-Agent摘要';
COMMENT ON COLUMN tb_audit_event.scope_type IS '事件主要业务范围类型';
COMMENT ON COLUMN tb_audit_event.scope_id IS '事件主要业务范围ID';
COMMENT ON COLUMN tb_audit_event.scope_name IS '事件主要业务范围名称快照';
COMMENT ON COLUMN tb_audit_event.result IS '事件结果success、failed、denied、partial或unknown';
COMMENT ON COLUMN tb_audit_event.risk_level IS 'Action Registry 注册的风险等级';
COMMENT ON COLUMN tb_audit_event.error_code IS '稳定业务错误码';
COMMENT ON COLUMN tb_audit_event.error_summary IS '不含底层敏感信息的错误摘要';
COMMENT ON COLUMN tb_audit_event.request_id IS '同一HTTP请求关联ID';
COMMENT ON COLUMN tb_audit_event.correlation_id IS '跨请求与异步业务链路关联ID';
COMMENT ON COLUMN tb_audit_event.parent_event_id IS '直接父审计事件稳定ID';
COMMENT ON COLUMN tb_audit_event.batch_total IS '批量根事件输入总数';
COMMENT ON COLUMN tb_audit_event.success_count IS '批量根事件成功数';
COMMENT ON COLUMN tb_audit_event.fail_count IS '批量根事件失败数';
COMMENT ON COLUMN tb_audit_event.metadata IS '清理且有界的业务补充参数';
COMMENT ON COLUMN tb_audit_event.content_hash IS '清理并标准化后的事件与资源内容 SHA-256';
COMMENT ON COLUMN tb_audit_event.created_at IS '审计事件数据库写入时间';
COMMENT ON COLUMN tb_audit_event_resource.id IS '审计事件资源数据库主键';
COMMENT ON COLUMN tb_audit_event_resource.audit_event_id IS '审计事件内部 ID仅普通索引不建立外键';
COMMENT ON COLUMN tb_audit_event_resource.resource_type IS 'Resource Registry 注册的资源类型';
COMMENT ON COLUMN tb_audit_event_resource.resource_id IS '可空的资源内部稳定ID';
COMMENT ON COLUMN tb_audit_event_resource.resource_key IS '事件发生时的稳定业务Key';
COMMENT ON COLUMN tb_audit_event_resource.display_name IS '事件发生时的资源显示名称';
COMMENT ON COLUMN tb_audit_event_resource.relation IS '资源关系primary、affected或reference';
COMMENT ON COLUMN tb_audit_event_resource.role IS '资源在本次业务动作中的稳定角色';
COMMENT ON COLUMN tb_audit_event_resource.identity_snapshot IS '事件发生时的资源身份快照';
COMMENT ON COLUMN tb_audit_event_resource.before_data IS '该资源本次操作前的直接业务字段';
COMMENT ON COLUMN tb_audit_event_resource.after_data IS '该资源本次操作后的直接业务字段';
COMMENT ON COLUMN tb_audit_event_resource.subject_visibility IS '主体可见级别';
COMMENT ON COLUMN tb_audit_event_resource.subject_summary IS '代理或企业可见的安全业务结论';
COMMENT ON COLUMN tb_audit_event_resource.subject_data IS '按Action Registry白名单生成的主体业务字段';
COMMENT ON COLUMN tb_audit_event_resource.sort_order IS '同一事件内资源稳定展示顺序';
COMMENT ON COLUMN tb_audit_event_resource.created_at IS '资源快照数据库写入时间';

View File

@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_outbox_event_parent;
ALTER TABLE tb_outbox_event
DROP COLUMN IF EXISTS parent_event_id;

View File

@@ -0,0 +1,9 @@
ALTER TABLE tb_outbox_event
ADD COLUMN parent_event_id varchar(64) NOT NULL DEFAULT '';
CREATE INDEX idx_outbox_event_parent
ON tb_outbox_event (parent_event_id)
WHERE parent_event_id <> '';
COMMENT ON TABLE tb_outbox_event IS '公共可靠事件 Outbox';
COMMENT ON COLUMN tb_outbox_event.parent_event_id IS '直接触发该可靠事件的真实审计事件ID空字符串表示无已落库父事件';

View File

@@ -0,0 +1,17 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM tb_integration_log
WHERE length(correlation_id) > 64
) THEN
RAISE EXCEPTION 'tb_integration_log.correlation_id 已存在超过64字符的业务链路ID禁止缩短字段回滚';
END IF;
END;
$$;
ALTER TABLE tb_integration_log
ALTER COLUMN correlation_id TYPE varchar(64);
COMMENT ON TABLE tb_integration_log IS '外部集成调用、回调及未发送尝试记录';
COMMENT ON COLUMN tb_integration_log.correlation_id IS '跨事务和异步链路关联ID最长64字符';

View File

@@ -0,0 +1,5 @@
ALTER TABLE tb_integration_log
ALTER COLUMN correlation_id TYPE varchar(100);
COMMENT ON TABLE tb_integration_log IS '外部集成调用、回调及未发送尝试记录';
COMMENT ON COLUMN tb_integration_log.correlation_id IS '跨请求、异步任务与业务后续步骤的关联ID最长100字符';

View File

@@ -0,0 +1,25 @@
-- 回滚外部集成调查组合索引,并恢复原有技术序列与业务链路索引定义。
DROP INDEX idx_integration_log_result_created;
DROP INDEX idx_integration_log_provider_created;
DROP INDEX idx_integration_log_external_created;
DROP INDEX idx_integration_log_audit_event_created;
DROP INDEX idx_integration_log_trigger;
CREATE INDEX idx_integration_log_trigger
ON tb_integration_log (trigger_series, attempt);
DROP INDEX idx_integration_log_correlation;
CREATE INDEX idx_integration_log_correlation
ON tb_integration_log (correlation_id, created_at ASC)
WHERE correlation_id IS NOT NULL;
COMMENT ON TABLE tb_integration_log IS '外部集成调用、回调及未发送尝试记录';
COMMENT ON COLUMN tb_integration_log.id IS '外部集成记录主键ID';
COMMENT ON COLUMN tb_integration_log.result IS '尝试结果pending仅为内部执行态其余为公开终态';
COMMENT ON COLUMN tb_integration_log.provider IS '外部服务提供方稳定编码';
COMMENT ON COLUMN tb_integration_log.external_id IS '外部系统返回的业务或请求标识';
COMMENT ON COLUMN tb_integration_log.audit_event_id IS '关联审计事件ID仅代码显式维护且不建立外键';
COMMENT ON COLUMN tb_integration_log.trigger_series IS '同一次外部操作显式技术尝试序列的稳定标识';
COMMENT ON COLUMN tb_integration_log.attempt IS '同一技术尝试序列内的单调尝试序号';
COMMENT ON COLUMN tb_integration_log.correlation_id IS '跨请求、异步任务与业务后续步骤的关联ID最长100字符';
COMMENT ON COLUMN tb_integration_log.created_at IS '外部集成记录创建时间';

View File

@@ -0,0 +1,35 @@
-- 为外部集成调查中心增加受控组合查询索引,不为 JSONB 摘要增加任意搜索能力。
CREATE INDEX idx_integration_log_result_created
ON tb_integration_log (result, created_at DESC, id DESC);
CREATE INDEX idx_integration_log_provider_created
ON tb_integration_log (provider, created_at DESC, id DESC);
CREATE INDEX idx_integration_log_external_created
ON tb_integration_log (external_id, created_at DESC, id DESC)
WHERE external_id IS NOT NULL;
CREATE INDEX idx_integration_log_audit_event_created
ON tb_integration_log (audit_event_id, created_at DESC, id DESC)
WHERE audit_event_id IS NOT NULL;
DROP INDEX idx_integration_log_trigger;
CREATE INDEX idx_integration_log_trigger
ON tb_integration_log (trigger_series, attempt, created_at, id)
WHERE trigger_series IS NOT NULL;
DROP INDEX idx_integration_log_correlation;
CREATE INDEX idx_integration_log_correlation
ON tb_integration_log (correlation_id, created_at DESC, id DESC)
WHERE correlation_id IS NOT NULL;
COMMENT ON TABLE tb_integration_log IS '外部集成调用、回调及未发送尝试记录';
COMMENT ON COLUMN tb_integration_log.id IS '外部集成记录主键ID';
COMMENT ON COLUMN tb_integration_log.result IS '尝试结果pending仅为内部执行态其余为公开终态';
COMMENT ON COLUMN tb_integration_log.provider IS '外部服务提供方稳定编码';
COMMENT ON COLUMN tb_integration_log.external_id IS '外部系统返回的业务或请求标识';
COMMENT ON COLUMN tb_integration_log.audit_event_id IS '关联审计事件ID仅代码显式维护且不建立外键';
COMMENT ON COLUMN tb_integration_log.trigger_series IS '同一次外部操作显式技术尝试序列的稳定标识';
COMMENT ON COLUMN tb_integration_log.attempt IS '同一技术尝试序列内的单调尝试序号';
COMMENT ON COLUMN tb_integration_log.correlation_id IS '跨请求、异步任务与业务后续步骤的关联ID最长100字符';
COMMENT ON COLUMN tb_integration_log.created_at IS '外部集成记录创建时间';

View File

@@ -318,7 +318,7 @@ HTTP 中间件只构造 Audit Context操作者、来源、IP、User-Agent、
### 12. 旧日志只停写,不迁移到新审计中心
1. **Expand**先创建新表、Registry、Writer、统一 Query 和主体活动接口;旧 Writer 继续服务未迁移用例。
2. **Migrate**:按完整纵向用例迁移调用方,每个切片同时完成资源快照、成功/失败/拒绝查询可见性和测试;同一用例不得长期双写。
2. **Migrate**:按完整纵向用例迁移调用方,每个切片同时完成资源快照、成功/失败/拒绝查询可见性核对;同一用例不得长期双写。
3. **Contract**:生产装配和静态清单证明旧账号/资产 Writer 调用归零后停止旧表新增;旧表原样保留,不删除、不在线回填、不转换、不进入统一 Query。
新审计中心的时间范围和完整性从切换点开始。旧账号/资产历史仍由原表和既有独立查询能力承担;不为兼容新 DTO 解析中文描述、补造资源、结果、风险或 correlation。现有旧资产 operation-log 接口可收缩为平台历史入口,代理/企业改用新的安全资源活动接口,但该旧接口不会并入 `/api/admin/audit/*`
@@ -327,11 +327,13 @@ HTTP 中间件只构造 Audit Context操作者、来源、IP、User-Agent、
### 13. 不可变性、性能与验证策略
本 Change 明确禁止新增、修改或运行任何自动化测试也不创建测试任务。后续实现不得以补充单元、集成、HTTP 或端到端测试为由扩大改动范围。
Audit 业务 Repository 只有 Append/Read应用运行账号不获得审计 Update/Delete 路径Retention Worker 使用独立最小权限删除入口。后续业务修正通过新的业务动作产生新事件并以 correlation/parent 关联原链路,第一阶段不提供专用 `audit_event.corrected` 写接口。`content_hash` 基于清理、标准化后的主事件和资源内容计算,不包含数据库自增 ID。单个 JSON 字段设置有界大小,超限保存截断标志、原字节数和摘要;批量明细进入子事件或原业务任务表。
主要索引覆盖事件时间、actor、action/result/risk、request、correlation、parent、scope以及资源 type+id/key+event time。Query 先分页事件 ID 再批量加载资源和必要业务投影,禁止逐事件 N+1公共关键词只查有索引的编码、名称和 Key不对 JSONB 全表模糊搜索。目标仍为数据库查询 <50ms、API P95<200ms、P99<500ms。
验证按风险分层Writer 单元测试覆盖 Registry、资源约束、标准化、凭据删除、哈希和截断真实 PostgreSQL 集成测试覆盖同事务回滚、失败短事务、幂等和索引HTTP 集成测试覆盖统一响应、平台身份、代理/企业归属、字段隔离、入参来源和所有查询视角;跨链路场景覆盖换货、多卡设备、支付回调、退款资金、批量资产和系统任务。实施结束前执行格式化、静态覆盖门禁、目标测试、构建 OpenAPI 两条生成路径验证
按用户确认,本 Change 的完成证据仅使用代码格式化、LSP/静态扫描、Registry 与覆盖基线比对、数据库结构和抽样数据核对、迁移检查、EXPLAIN/性能观测、接口人工调用、业务页面跳转核对、构建以及 OpenAPI 两条生成路径。
## Risks / Trade-offs

View File

@@ -6,6 +6,7 @@
## What Changes
- 明确本 Change 禁止新增、修改或运行任何自动化测试,也不得创建测试任务;完成证据仅使用静态检查、数据核对、接口人工核对及既定制品检查。
- 新建统一 `Audit Event + Event Resource` 写入能力:所有非查询业务操作,以及已识别主要业务资源后的成功、失败、拒绝、部分成功、系统自动状态变化和外部回调引发的内部变化,均按明确事务策略记录;普通查询和参数解析前失败不产生 Audit EventAction Registry 明确登记的敏感读取除外。
- 建立动作、资源类型、资源角色、结果、风险、来源和外部可见性注册契约;每个事件至少关联一个主要资源,多资源操作为各资源保存事件发生时的业务标识快照及各自的前后变化。
- 建立平台多视角审计调查接口,覆盖全局事件、操作者、资源时间线、请求链路、业务关联链路、资金审计和风险事件;平台账号第一阶段均可读取完整业务审计数据,不实施细粒度权限码或平台数据范围过滤。

View File

@@ -1,120 +1,117 @@
## 0. 测试与现行契约准备
> 实施约束:本 Change 禁止新增、修改或运行任何自动化测试,也不得创建测试任务;完成证据仅使用静态检查、数据核对、接口人工核对及既定制品检查。
- [ ] 0.1 重新扫描当前 RouteSpec、Application、旧 Service、Worker、Scheduler、Callback、Outbox、Integration Log 和旧 Writer生成逐入口领域/动作/资源/事务/可见性矩阵,并逐项标明 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 理由;旧 490 项仅用于遗漏比对,并增量维护 `.scratch/tech-global-audit/审计覆盖基线.md`。【主治理边界当前生产写入口及敏感读取不迁移业务实现验证清单无空白、敏感读取已单列、Setter/普通查询/装配误报已剔除并经业务/研发/安全复核】
- [ ] 0.2 为不可变事件、多资源约束、Action/Resource Registry、`subject_data` 白名单、安全凭据删除、内容哈希、JSON 大小限制、成功同事务、敏感读取失败关闭和失败短事务生成单元及真实 PostgreSQL 集成测试暂不实现生产代码。【主Infrastructure + Application边界统一写入闭环不迁移领域调用方验证运行目标测试并确认因能力缺失全部 FAIL】
- [ ] 0.3 为平台全局/操作者/资源/request/correlation/资金/风险查询、Integration overview/list/detail/attempts及代理/企业安全活动生成 Fiber + GORM 集成测试并覆盖筛选输入、视角跳转、认证上下文和服务端派生四类入参来源。【主Query/API边界第一阶段只读接口不迁移旧 operation log、前端页面与导出验证运行目标测试并确认路由或查询缺失导致 FAIL
- [ ] 0.4 为账号权限、设备多卡槽、换货旧新资产、钱包扣款退款、支付回调、批量部分成功、系统任务和企业资源授权生成端到端业务流程测试,断言 HTTP 响应、Audit Event、Event Resource、Domain Ledger、Integration/Outbox 关联及外部安全投影。【主:验收|边界:代表性跨领域流程|不迁移:未触碰业务规则|验证:运行流程测试并确认审计断言 FAIL、既有业务断言保持现状】
- [ ] 0.5 固化第一阶段测试基线:记录所有新增测试的预期失败原因,确认没有因编译错误、环境误配或既有回归造成的伪失败,并冻结后续逐切片转绿顺序。【主:测试门禁|边界:新增审计测试|不迁移:生产代码|验证:测试报告逐项对应 specs Requirement/Scenario】
- [ ] 0.6 为 Audit/Integration 每日归档、manifest/hash、重复任务、Integration 月度最终 revision、缺日、对象损坏、月度物理删除阻断、断点续删和在线窗口错误生成单元及真实 PostgreSQL/对象存储集成测试暂不实现生产代码。【主Infrastructure + Application边界冷归档与留存闭环不迁移Access Log、对象存储历史查询/恢复|验证:运行目标测试并确认因能力缺失全部 FAIL既有日志写入不受影响】
## 0. 现行契约准备
- [x] 0.1 重新扫描当前 RouteSpec、Application、旧 Service、Worker、Scheduler、Callback、Outbox、Integration Log 和旧 Writer生成逐入口领域/动作/资源/事务/可见性矩阵,并逐项标明 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 理由;旧 490 项仅用于遗漏比对,并增量维护 `.scratch/tech-global-audit/审计覆盖基线.md`。【主治理边界当前生产写入口及敏感读取不迁移业务实现验证清单无空白、敏感读取已单列、Setter/普通查询/装配误报已剔除并经业务/研发/安全复核
## 1. 统一 Audit Event 首个纵向闭环
- [ ] 1.1 以“受控系统配置更新”为首个简单写纵向切片交付无外键迁移、Audit Event/Event Resource Model、Action/Resource Registry、凭据删除、不可变 Append Writer、结构体注入和同事务失败回滚配置凭据只记录 `credentials_configured` 等安全事实。【主:简单写 + Application/Port/AdapterInfrastructure边界系统配置更新 + 公共写接缝|不迁移:其他配置页面与其他领域|验证:迁移 up/down、Registry/哈希/凭据测试和配置事务集成测试 PASSLSP 无诊断】
- [ ] 1.2 以“Outbox 人工恢复裁决”为第二个高风险纵向切片,接入操作者、原因、批次、事件前后状态和 Audit Event 同事务失败关闭证明多资源与高风险策略可复用。【主Application + Port/AdapterInfrastructure边界既有 Outbox 恢复用例不迁移Outbox Relay 与消费者|验证:成功、重复恢复、有效租约拒绝、审计失败回滚测试 PASSLSP 无诊断】
- [ ] 1.3 交付已确定主要业务资源后的 `failed/denied` 独立短事务和二次失败 critical 日志/指标,以系统配置非法更新及 Outbox 不可恢复事件为代表用例;资源 ID 可空但必须有稳定 Key/快照,只有 actor 而无资源时进入 Access/Security Log不使用裸 goroutine。【主ApplicationInfrastructure/Observability边界失败审计公共接缝不迁移全局错误处理语义验证原业务错误保留、primary 约束、无资源分流、短事务失败指标测试 PASSLSP 无诊断】
- [ ] 1.4 交付 HTTP、OpenAPI、Worker、Scheduler 和 Callback 的 Audit Context 及 `request_id/correlation_id/parent_event_id` 传播接缝,以一个 HTTP→Outbox→Worker 流程证明系统子事件不伪造人工操作者。【主Application + Port/AdapterMiddleware/Asynq边界审计上下文与链路字段不迁移业务状态机验证上下文、载荷结构、父子链路和真实 actor 测试 PASSLSP 无诊断】
- [ ] 1.5 交付批次根事件、资源子事件和 `partial` 统计接缝以现有设备批量分配或资产批量购包小场景验证每个实际变化资源进入自身时间线未处理项不伪造成功。【主ApplicationAsynq/Infrastructure边界公共批量审计形态不迁移批量业务规则验证根子计数、父子链路、部分成功和幂等测试 PASSLSP 无诊断】
- [x] 1.1 以“受控系统配置更新”为首个简单写纵向切片交付无外键迁移、Audit Event/Event Resource Model、Action/Resource Registry、凭据删除、不可变 Append Writer、结构体注入和同事务失败回滚配置凭据只记录 `credentials_configured` 等安全事实。【主:简单写 + Application/Port/AdapterInfrastructure边界系统配置更新 + 公共写接缝|不迁移:其他配置页面与其他领域|验证:迁移 up/down、Registry/哈希/凭据和配置事务数据核对通过LSP 无诊断】
- [x] 1.2 以“Outbox 人工恢复裁决”为第二个高风险纵向切片,接入操作者、原因、批次、事件前后状态和 Audit Event 同事务失败关闭证明多资源与高风险策略可复用。【主Application + Port/AdapterInfrastructure边界既有 Outbox 恢复用例不迁移Outbox Relay 与消费者|验证:成功、重复恢复、有效租约拒绝、审计失败回滚核对通过LSP 无诊断】
- [x] 1.3 交付已确定主要业务资源后的 `failed/denied` 独立短事务和二次失败 critical 日志/指标,以系统配置非法更新及 Outbox 不可恢复事件为代表用例;资源 ID 可空但必须有稳定 Key/快照,只有 actor 而无资源时进入 Access/Security Log不使用裸 goroutine。【主ApplicationInfrastructure/Observability边界失败审计公共接缝不迁移全局错误处理语义验证原业务错误保留、primary 约束、无资源分流、短事务失败指标核对通过LSP 无诊断】
- [x] 1.4 交付 HTTP、OpenAPI、Worker、Scheduler 和 Callback 的 Audit Context 及 `request_id/correlation_id/parent_event_id` 传播接缝,以一个 HTTP→Outbox→Worker 流程证明系统子事件不伪造人工操作者。【主Application + Port/AdapterMiddleware/Asynq边界审计上下文与链路字段不迁移业务状态机验证上下文、载荷结构、父子链路和真实 actor 核对通过LSP 无诊断】
- [x] 1.5 交付批次根事件、资源子事件和 `partial` 统计接缝以现有设备批量分配或资产批量购包小场景验证每个实际变化资源进入自身时间线未处理项不伪造成功。【主ApplicationAsynq/Infrastructure边界公共批量审计形态不迁移批量业务规则验证根子计数、父子链路、部分成功和幂等核对通过LSP 无诊断】
## 2. 平台基础审计调查纵向切片
- [ ] 2.1 交付平台身份保护的事件列表和详情纵向切片,包含组合筛选、稳定分页、操作者/入口/结果/风险、多资源角色、身份快照及资源级 before/after平台不做数据行过滤代理/企业/个人后端拒绝。【主QueryAPI/Infrastructure边界全局事件与详情不迁移细粒度权限码、导出和修改验证Fiber 响应、身份隔离、筛选、分页、无 N+1 和数据库 <50ms 测试 PASSLSP 无诊断】
- [ ] 2.2 交付操作者行为视角支持人工账号、OpenAPI、系统任务和外部系统 actor返回历史名称快照及 action/result/risk/resource 筛选。【主Query边界actor 时间线|不迁移:用户画像或风控处置|验证:账号改名/删除、系统 actor、不伪造发起人和稳定分页测试 PASSLSP 无诊断】
- [ ] 2.3 交付 Resource Registry 搜索与通用资源时间线,以卡 ICCID/VirtualNo、设备 VirtualNo/IMEI/SN、店铺、订单和退款为首批 resolver资源删除或换号后使用事件快照解释历史。【主QueryResource Resolver边界注册资源精确/索引搜索与 timeline不迁移任意 JSON 模糊搜索和关系图验证资源搜索、primary/affected/reference、历史快照、稳定分页和查询计划测试 PASSLSP 无诊断】
- [ ] 2.4 新增平台审计 Handler/DTO/RouteSpec 并完成生产结构体注入、路由注册、共享文档 Handler、`cmd/api/docs.go``cmd/gendocs/main.go` 和中文错误码/文档;按 design 8.1-8.4 固化卡、设备、设备卡槽、统一资产、分配、换货、账号、店铺、企业、订单、退款、充值和钱包页面的 `response.data` 字段到目标接口参数映射,并冻结 `investigation_refs` 与降级规则,保证两条 OpenAPI 生成路径包含全部基础调查接口。【主API/Infrastructure边界2.1-2.3 Handler 契约|不迁移:前端实现|验证:逐矩阵 HTTP 参数映射、缺字段隐藏、伪造身份、调查节点跳转、静态/动态路由顺序、两份 OpenAPI 和构建检查 PASSLSP 无诊断】
- [ ] 2.5 交付 Action Registry 标记的敏感读取审计,以受控审计详情或明文业务凭证读取为代表:返回结果前写入读取者、目标资源和字段类别,写入失败则不返回结果;普通列表/详情仍按 N/A 契约执行。【主Query + Application/Port边界敏感读取例外不迁移细粒度权限码与敏感二次查看 UI验证成功读取、审计失败关闭、普通读取不写和安全凭据永不返回测试 PASSLSP 无诊断】
- [x] 2.1 交付平台身份保护的事件列表和详情纵向切片,包含组合筛选、稳定分页、操作者/入口/结果/风险、多资源角色、身份快照及资源级 before/after平台不做数据行过滤代理/企业/个人后端拒绝。【主QueryAPI/Infrastructure边界全局事件与详情不迁移细粒度权限码、导出和修改验证Fiber 响应、身份隔离、筛选、分页、无 N+1 和数据库 <50ms 核对通过LSP 无诊断】
- [x] 2.2 交付操作者行为视角支持人工账号、OpenAPI、系统任务和外部系统 actor返回历史名称快照及 action/result/risk/resource 筛选。【主Query边界actor 时间线|不迁移:用户画像或风控处置|验证:账号改名/删除、系统 actor、不伪造发起人和稳定分页核对通过LSP 无诊断】
- [x] 2.3 交付 Resource Registry 搜索与通用资源时间线,以卡 ICCID/VirtualNo、设备 VirtualNo/IMEI/SN、店铺、订单和退款为首批 resolver资源删除或换号后使用事件快照解释历史。【主QueryResource Resolver边界注册资源精确/索引搜索与 timeline不迁移任意 JSON 模糊搜索和关系图验证资源搜索、primary/affected/reference、历史快照、稳定分页和查询计划核对通过LSP 无诊断】
- [x] 2.4 新增平台审计 Handler/DTO/RouteSpec 并完成生产结构体注入、路由注册、共享文档 Handler、`cmd/api/docs.go``cmd/gendocs/main.go` 和中文错误码/文档;按 design 8.1-8.4 固化卡、设备、设备卡槽、统一资产、分配、换货、账号、店铺、企业、订单、退款、充值和钱包页面的 `response.data` 字段到目标接口参数映射,并冻结 `investigation_refs` 与降级规则,保证两条 OpenAPI 生成路径包含全部基础调查接口。【主API/Infrastructure边界2.1-2.3 Handler 契约|不迁移:前端实现|验证:逐矩阵 HTTP 参数映射、缺字段隐藏、伪造身份、调查节点跳转、静态/动态路由顺序、两份 OpenAPI 和构建检查 PASSLSP 无诊断】
- [x] 2.5 交付 Action Registry 标记的敏感读取审计,以受控审计详情或明文业务凭证读取为代表:返回结果前写入读取者、目标资源和字段类别,写入失败则不返回结果;普通列表/详情仍按 N/A 契约执行。【主Query + Application/Port边界敏感读取例外不迁移细粒度权限码与敏感二次查看 UI验证成功读取、审计失败关闭、普通读取不写和安全凭据永不返回核对通过LSP 无诊断】
## 3. Integration Log 只读调查中心
- [ ] 3.1 交付 Integration Log 组合筛选列表与稳定 `integration_id` 详情纵向切片,返回 identity/resource/trigger/result/content/linkage/timestamps 分组、原始结果、中文名和派生类别,不直接暴露 GORM Model。【主QueryAPI边界现有 Integration Log 只读投影不迁移Writer 业务语义与恢复操作|验证:全部筛选、空页、不存在、统一响应和无 N+1 测试 PASSLSP 无诊断】
- [ ] 3.2 交付受时间范围约束的 Integration overview覆盖总量、结果/provider/direction、异常、unknown、陈旧 pending、状态变化、平均/P95 耗时与趋势,严格区分 success、processing、indeterminate、failed 和 not_sent。【主Query边界固定聚合维度不迁移告警处置和自动恢复验证聚合语义、时间边界、completed 不计 success 和查询计划测试 PASSLSP 无诊断】
- [ ] 3.3 交付显式 `trigger_series + attempt` 尝试序列,并增量修正新外呼的 series/correlation 传播、correlation 长度、真实资源 ID 和安全可读错误摘要;历史缺字段时标记受限,旧 `request_summary/response_summary/metadata` 返回前按白名单和凭据删除规则重新清理,禁止直接透传或按相似资源/时间猜测重试。【主Infrastructure Adapter + Query边界Integration 链路可解释性与历史读取安全不迁移历史伪回填与多资源子表验证序列排序、correlation 非重试、ICCID hash 兼容、历史 fidelity、敌对历史 JSON 和凭据不返回测试 PASSLSP 无诊断】
- [ ] 3.4 为现有 Integration 表补受控 B-tree 索引并交付 overview/list/detail Handler、RouteSpec、生产装配和两条 OpenAPI 生成入口;明确筛选来自调查输入或关联视角跳转、`integration_id` 来自列表/通知 target/调查节点、身份来自认证上下文,只注册 GET不提供重试、补偿、确认、绑定、恢复、修改、删除或导出路由。【主Query/API + Migration边界Integration 调查中心不迁移JSONB GIN 与任意全文搜索|验证:迁移 up/down、索引命中、通知 `target_key→integration_id`、不可用目标、HTTP/OpenAPI、只读路由扫描和性能目标 PASSLSP 无诊断】
- [x] 3.1 交付 Integration Log 组合筛选列表与稳定 `integration_id` 详情纵向切片,返回 identity/resource/trigger/result/content/linkage/timestamps 分组、原始结果、中文名和派生类别,不直接暴露 GORM Model。【主QueryAPI边界现有 Integration Log 只读投影不迁移Writer 业务语义与恢复操作|验证:全部筛选、空页、不存在、统一响应和无 N+1 核对通过LSP 无诊断】
- [x] 3.2 交付受时间范围约束的 Integration overview覆盖总量、结果/provider/direction、异常、unknown、陈旧 pending、状态变化、平均/P95 耗时与趋势,严格区分 success、processing、indeterminate、failed 和 not_sent。【主Query边界固定聚合维度不迁移告警处置和自动恢复验证聚合语义、时间边界、completed 不计 success 和查询计划核对通过LSP 无诊断】
- [x] 3.3 交付显式 `trigger_series + attempt` 尝试序列,并增量修正新外呼的 series/correlation 传播、correlation 长度、真实资源 ID 和安全可读错误摘要;历史缺字段时标记受限,旧 `request_summary/response_summary/metadata` 返回前按白名单和凭据删除规则重新清理,禁止直接透传或按相似资源/时间猜测重试。【主Infrastructure Adapter + Query边界Integration 链路可解释性与历史读取安全不迁移历史伪回填与多资源子表验证序列排序、correlation 非重试、ICCID hash 兼容、历史 fidelity、敌对历史 JSON 和凭据不返回核对通过LSP 无诊断】
- [x] 3.4 为现有 Integration 表补受控 B-tree 索引并交付 overview/list/detail Handler、RouteSpec、生产装配和两条 OpenAPI 生成入口;明确筛选来自调查输入或关联视角跳转、`integration_id` 来自列表/通知 target/调查节点、身份来自认证上下文,只注册 GET不提供重试、补偿、确认、绑定、恢复、修改、删除或导出路由。【主Query/API + Migration边界Integration 调查中心不迁移JSONB GIN 与任意全文搜索|验证:迁移 up/down、索引命中、通知 `target_key→integration_id`、不可用目标、HTTP/OpenAPI、只读路由扫描和性能目标 PASSLSP 无诊断】
## 4. 代理/企业安全资源活动与旧历史入口隔离
- [ ] 4.1 交付代理资源活动纵向切片:按当前资源店铺及自身/下级店铺范围校验卡、设备、分配、换货、店铺和归属企业,读取 `subject_result/subject_detail`,完全隐藏 `internal_only`、平台操作者、内部原因/备注、风险、内部 before/after、Audit Event ID 和 Integration 内容。【主QueryAPI/数据权限|边界:代理范围资源活动|不迁移:平台审计 DTO 与权限码|验证:各类稳定 identifier、有权、越权/不存在同错、分配详情独立归属、无事件存在性泄露、多资源部分可见测试 PASSLSP 无诊断】
- [ ] 4.2 交付企业资源活动纵向切片:卡与设备分别复用当前有效企业授权关系,禁止在企业缺少 SubordinateShopIDs 时退化为全量撤销授权后不可继续读取资源活动。【主QueryAPI/授权 Store边界企业卡/设备活动|不迁移:企业授权业务规则|验证:有效/撤销授权、无权同错、设备关联卡和安全字段测试 PASSLSP 无诊断】
- [ ] 4.3 交付资源关系级 `internal_only/subject_result/subject_detail`、稳定 `subject_summary` 和 Registry 白名单约束的 `subject_data` 写入契约,以平台退款处理、主体自身操作、内部补偿和外部回调为代表场景,查询时不读取内部 before/after 后删字段生成外部结果。【主Application + Query边界主体可见性快照不迁移前端文案系统验证三种级别、subject_data 白名单、平台过程不可感知、最终业务结论正确和凭据缺失测试 PASSLSP 无诊断】
- [ ] 4.5 新增 `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}``GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}` 的 Handler/DTO/RouteSpec冻结 `page/page_size`、资源摘要和 activity 字段;按 design 8.1 复用卡 ICCID、设备 VirtualNo、分配单号、换货单号、店铺编号、企业编号及企业受权资产列表字段主体范围只来自认证上下文缺少 identifier 时不展示入口。先上线安全接口,再把旧资产 operation-logs 收缩为仅平台历史入口,并同步生产装配、共享文档 Handler、两个 OpenAPI 生成入口和中文接口文档。【主API/Query边界expand 阶段兼容切换不迁移旧日志迁移、删除旧路由验证design 8.1 全部主体映射、企业禁止 resolve、缺字段隐藏、伪造范围、分配详情独立归属、不可见字段、授权撤销、旧接口身份收缩、两份 OpenAPI 和构建检查 PASSLSP 无诊断】
- [x] 4.1 交付代理资源活动纵向切片:按当前资源店铺及自身/下级店铺范围校验卡、设备、分配、换货、店铺和归属企业,读取 `subject_result/subject_detail`,完全隐藏 `internal_only`、平台操作者、内部原因/备注、风险、内部 before/after、Audit Event ID 和 Integration 内容。【主QueryAPI/数据权限|边界:代理范围资源活动|不迁移:平台审计 DTO 与权限码|验证:各类稳定 identifier、有权、越权/不存在同错、分配详情独立归属、无事件存在性泄露、多资源部分可见核对通过LSP 无诊断】
- [x] 4.2 交付企业资源活动纵向切片:卡与设备分别复用当前有效企业授权关系,禁止在企业缺少 SubordinateShopIDs 时退化为全量撤销授权后不可继续读取资源活动。【主QueryAPI/授权 Store边界企业卡/设备活动|不迁移:企业授权业务规则|验证:有效/撤销授权、无权同错、设备关联卡和安全字段核对通过LSP 无诊断】
- [x] 4.3 交付资源关系级 `internal_only/subject_result/subject_detail`、稳定 `subject_summary` 和 Registry 白名单约束的 `subject_data` 写入契约,以平台退款处理、主体自身操作、内部补偿和外部回调为代表场景,查询时不读取内部 before/after 后删字段生成外部结果。【主Application + Query边界主体可见性快照不迁移前端文案系统验证三种级别、subject_data 白名单、平台过程不可感知、最终业务结论正确和凭据缺失核对通过LSP 无诊断】
- [x] 4.5 新增 `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}``GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}` 的 Handler/DTO/RouteSpec冻结 `page/page_size`、资源摘要和 activity 字段;按 design 8.1 复用卡 ICCID、设备 VirtualNo、分配单号、换货单号、店铺编号、企业编号及企业受权资产列表字段主体范围只来自认证上下文缺少 identifier 时不展示入口。先上线安全接口,再把旧资产 operation-logs 收缩为仅平台历史入口,并同步生产装配、共享文档 Handler、两个 OpenAPI 生成入口和中文接口文档。【主API/Query边界expand 阶段兼容切换不迁移旧日志迁移、删除旧路由验证design 8.1 全部主体映射、企业禁止 resolve、缺字段隐藏、伪造范围、分配详情独立归属、不可见字段、授权撤销、旧接口身份收缩、两份 OpenAPI 和构建检查 PASSLSP 无诊断】
## 5. 账号、权限、组织与主体写入口迁移
- [ ] 5.1 迁移账号创建、基础资料更新、启停和软删除纵向用例到统一 Writer关联账号、店铺/企业和实际角色资源,已定位账号的失败/拒绝进入短事务;逐用例转绿后停止对应旧 account operation log 写入。【主:简单写/Application + 旧 Service Adapter边界账号生命周期不迁移凭据和第三方绑定验证各状态操作、资源快照、失败/拒绝、旧写逐项归零和 HTTP 测试 PASSLSP 无诊断】
- [ ] 5.2 迁移改密、手机号安全操作、企微绑定及登录/登出安全状态纵向用例记录账号与认证资源但删除密码、验证码、Token、Cookie 等凭据。【主:简单写/Application + 旧 Service Adapter边界账号安全操作不迁移认证协议重构验证成功/拒绝/失败、凭据数据库抽样、actor/request 关联和旧写归零测试 PASSLSP 无诊断】
- [ ] 5.3 迁移角色 CRUD、权限 CRUD 和角色权限配置纵向用例,保存角色/权限 code/name 和前后差异,高风险成功与业务事实同事务。【主:简单写 + Application边界角色权限配置不迁移账号角色分配与新权限体系验证CRUD/拒绝、before/after、同事务和资源时间线测试 PASSLSP 无诊断】
- [ ] 5.4 迁移账号角色、店铺角色的分配与移除纵向用例,分别关联目标账号、角色和店铺并停止对应旧账号日志写入。【主:简单写 + Application边界主体授权关系不迁移权限码重构验证分配/移除/重复/拒绝、资源角色、旧写归零和 actor 查询测试 PASSLSP 无诊断】
- [ ] 5.5 迁移店铺创建、基础资料和层级变更纵向用例,保存店铺编码/名称/上级/层级及操作者,不借审计改变层级规则。【主:简单写 + Application边界店铺身份与层级不迁移状态、业务员和登录限制验证创建/更新/层级拒绝、历史快照和店铺时间线测试 PASSLSP 无诊断】
- [ ] 5.6 迁移店铺启停、删除、业务员绑定和 C 端登录限制纵向用例,主体活动仅输出允许感知结论,逐用例停止旧账号日志借用写入。【主:简单写 + Application边界店铺状态与关键配置不迁移全模块 DDD验证状态/绑定/拒绝、subject 投影、旧写归零测试 PASSLSP 无诊断】
- [ ] 5.7 迁移企业创建、资料/状态/凭据管理纵向用例,关联企业和 owner shop保持现有企业规则并删除安全凭据。【主简单写 + Application边界企业生命周期不迁移资产授权验证CRUD/启停/改密、凭据删除、actor/scope 和时间线测试 PASSLSP 无诊断】
- [ ] 5.8 迁移企业卡授权/回收纵向用例关联企业、owner shop、卡和授权记录并保持现有有效授权语义。【主简单写 + Application边界企业卡授权不迁移设备授权验证授权/撤销/重复/越权、卡活动和企业范围测试 PASSLSP 无诊断】
- [ ] 5.9 迁移企业设备授权/回收纵向用例,关联企业、设备及实际随设备处理的绑定卡,保持现有授权语义。【主:复杂写或简单写事务脚本|边界:企业设备授权|不迁移:卡槽绑定规则|验证:多卡设备授权/撤销/越权、资源关系和企业活动测试 PASSLSP 无诊断】
- [ ] 5.10 迁移个人客户资料、手机号和微信主体纵向用例,记录真实个人 actor不把普通查询或 Token 刷新误记为业务操作。【主:简单写 + Application边界个人客户身份资料不迁移资产绑定验证资料/手机号/微信变更、失败、凭据删除和 subject_detail 测试 PASSLSP 无诊断】
- [ ] 5.11 迁移个人客户卡/设备绑定、解绑和换货绑定迁移纵向用例,关联客户、资产和绑定记录。【主:简单写或复杂写 Application边界客户资产关系不迁移换货其他资金/套餐步骤|验证:绑定/解绑/迁移、资源快照、越权和多资源活动测试 PASSLSP 无诊断】
- [x] 5.1 迁移账号创建、基础资料更新、启停和软删除纵向用例到统一 Writer关联账号、店铺/企业和实际角色资源,已定位账号的失败/拒绝进入短事务;逐用例核对完成后停止对应旧 account operation log 写入。【主:简单写/Application + 旧 Service Adapter边界账号生命周期不迁移凭据和第三方绑定验证各状态操作、资源快照、失败/拒绝、旧写逐项归零和 HTTP 核对通过LSP 无诊断】
- [x] 5.2 迁移改密、手机号安全操作、企微绑定及登录/登出安全状态纵向用例记录账号与认证资源但删除密码、验证码、Token、Cookie 等凭据。【主:简单写/Application + 旧 Service Adapter边界账号安全操作不迁移认证协议重构验证成功/拒绝/失败、凭据数据库抽样、actor/request 关联和旧写归零核对通过LSP 无诊断】
- [x] 5.3 迁移角色 CRUD、权限 CRUD 和角色权限配置纵向用例,保存角色/权限 code/name 和前后差异,高风险成功与业务事实同事务。【主:简单写 + Application边界角色权限配置不迁移账号角色分配与新权限体系验证CRUD/拒绝、before/after、同事务和资源时间线核对通过LSP 无诊断】
- [x] 5.4 迁移账号角色、店铺角色的分配与移除纵向用例,分别关联目标账号、角色和店铺并停止对应旧账号日志写入。【主:简单写 + Application边界主体授权关系不迁移权限码重构验证分配/移除/重复/拒绝、资源角色、旧写归零和 actor 查询核对通过LSP 无诊断】
- [x] 5.5 迁移店铺创建、基础资料和层级变更纵向用例,保存店铺编码/名称/上级/层级及操作者,不借审计改变层级规则。【主:简单写 + Application边界店铺身份与层级不迁移状态、业务员和登录限制验证创建/更新/层级拒绝、历史快照和店铺时间线核对通过LSP 无诊断】
- [x] 5.6 迁移店铺启停、删除、业务员绑定和 C 端登录限制纵向用例,主体活动仅输出允许感知结论,逐用例停止旧账号日志借用写入。【主:简单写 + Application边界店铺状态与关键配置不迁移全模块 DDD验证状态/绑定/拒绝、subject 投影、旧写归零核对通过LSP 无诊断】
- [x] 5.7 迁移企业创建、资料/状态/凭据管理纵向用例,关联企业和 owner shop保持现有企业规则并删除安全凭据。【主简单写 + Application边界企业生命周期不迁移资产授权验证CRUD/启停/改密、凭据删除、actor/scope 和时间线核对通过LSP 无诊断】
- [x] 5.8 迁移企业卡授权/回收纵向用例关联企业、owner shop、卡和授权记录并保持现有有效授权语义。【主简单写 + Application边界企业卡授权不迁移设备授权验证授权/撤销/重复/越权、卡活动和企业范围核对通过LSP 无诊断】
- [x] 5.9 迁移企业设备授权/回收纵向用例,关联企业、设备及实际随设备处理的绑定卡,保持现有授权语义。【主:复杂写或简单写事务脚本|边界:企业设备授权|不迁移:卡槽绑定规则|验证:多卡设备授权/撤销/越权、资源关系和企业活动核对通过LSP 无诊断】
- [x] 5.10 迁移个人客户资料、手机号和微信主体纵向用例,记录真实个人 actor不把普通查询或 Token 刷新误记为业务操作。【主:简单写 + Application边界个人客户身份资料不迁移资产绑定验证资料/手机号/微信变更、失败、凭据删除和 subject_detail 核对通过LSP 无诊断】
- [ ] 5.11 迁移个人客户卡/设备绑定、解绑和换货绑定迁移纵向用例,关联客户、资产和绑定记录。【主:简单写或复杂写 Application边界客户资产关系不迁移换货其他资金/套餐步骤|验证:绑定/解绑/迁移、资源快照、越权和多资源活动核对通过LSP 无诊断】
## 6. 卡、设备、绑定、分配与换货迁移
- [ ] 6.1 迁移 IoT 卡创建/导入落库、基础资料、删除纵向用例,快照含 ID/ICCID/VirtualNo/MSISDN/运营商/店铺/系列/generation。【主简单写 + 旧 Service Adapter边界卡身份生命周期不迁移状态与外部命令验证创建/更新/删除、历史快照、凭据规则和旧资产写归零测试 PASSLSP 无诊断】
- [ ] 6.2 迁移 IoT 卡分配、回收和系列绑定纵向用例,关联分配记录、来源/目标店铺、系列和必要设备关系。【主:复杂写或简单写事务脚本|边界:卡归属与系列|不迁移:卡状态/Gateway验证分配/回收/拒绝、多资源、主体活动和旧写归零测试 PASSLSP 无诊断】
- [ ] 6.3 迁移 IoT 卡停复机、实名策略/状态、限速和人工刷新纵向用例,实际外部尝试写 Integration Log内部状态变化写 Audit Eventunknown 不伪装失败或成功。【主:复杂写 + Gateway Adapter边界卡状态与外部命令不迁移卡归属验证success/failed/denied/unknown、Integration/Audit 分界、主体结论和旧写归零测试 PASSLSP 无诊断】
- [ ] 6.4 迁移设备创建/导入落库、基础资料和删除纵向用例,快照含 ID/VirtualNo/IMEI/SN/型号/店铺/系列/generation。【主简单写 + 旧 Service Adapter边界设备身份生命周期不迁移状态、卡槽与 Gateway验证创建/更新/删除、历史快照和旧资产写归零测试 PASSLSP 无诊断】
- [ ] 6.5 迁移设备分配、回收和系列/实名策略纵向用例,关联分配记录、来源/目标店铺及实际连带卡。【主:复杂写或简单写事务脚本|边界:设备归属与策略|不迁移:设备外部命令|验证:分配/回收/策略、设备卡关系、主体活动和旧写归零测试 PASSLSP 无诊断】
- [ ] 6.6 迁移设备停复机、Wi-Fi、切卡模式、重启和重置纵向用例外部尝试写 Integration Log实际内部变化写 Audit Event。【主复杂写 + Gateway Adapter边界设备外部命令不迁移设备归属与卡槽绑定验证success/failed/unknown、设备时间线、安全结论和旧写归零测试 PASSLSP 无诊断】
- [ ] 6.7 迁移设备绑卡、解绑和当前卡切换纵向用例,把设备、目标卡、旧/新当前卡及每个 binding 作为一等资源,保存 slot/is_current。【主复杂写边界设备卡槽关系完整用例不迁移设备/卡其他状态机验证1-4 卡槽、旧新卡角色、资源双向时间线、事务和主体范围测试 PASSLSP 无诊断】
- [ ] 6.8 迁移单笔资产分配/回收纵向用例,关联分配记录、来源/目标店铺、设备及实际连带卡。【主:复杂写 Application边界单笔资产流转不迁移批量分配和企业授权验证卡/设备流转、多资源、越权和每资源时间线测试 PASSLSP 无诊断】
- [ ] 6.9 迁移批量设备分配纵向用例,批次根事件与每台实际变化设备/绑定卡子事件计数一致。【主:复杂写 + Application/Asynq边界批量设备流转不迁移其他批量任务验证success/partial/failed、根子计数、幂等和店铺范围测试 PASSLSP 无诊断】
- [ ] 6.10 迁移卡换货完整用例,记录换货单、旧/新卡 ICCID+VirtualNo、客户绑定、钱包/流水、套餐权益、店铺和状态变化。【主:复杂写 Domain/ApplicationOutbox/Query边界卡换货创建至完成/取消/迁移|不迁移:设备换货|验证:资金/套餐/客户迁移、失败回滚、多资源时间线和外部安全结论测试 PASSLSP 无诊断】
- [ ] 6.11 迁移设备换货完整用例,记录旧/新设备 VirtualNo+IMEI+SN、实际绑定卡/卡槽、客户绑定、钱包/流水、套餐权益、店铺和换货单。【主:复杂写 Domain/ApplicationOutbox/Query边界设备换货完整流程不迁移无关订单规则验证多卡设备换货、资金/套餐/客户迁移、失败回滚和所有资源时间线测试 PASSLSP 无诊断】
- [ ] 6.1 迁移 IoT 卡创建/导入落库、基础资料、删除纵向用例,快照含 ID/ICCID/VirtualNo/MSISDN/运营商/店铺/系列/generation。【主简单写 + 旧 Service Adapter边界卡身份生命周期不迁移状态与外部命令验证创建/更新/删除、历史快照、凭据规则和旧资产写归零核对通过LSP 无诊断】
- [ ] 6.2 迁移 IoT 卡分配、回收和系列绑定纵向用例,关联分配记录、来源/目标店铺、系列和必要设备关系。【主:复杂写或简单写事务脚本|边界:卡归属与系列|不迁移:卡状态/Gateway验证分配/回收/拒绝、多资源、主体活动和旧写归零核对通过LSP 无诊断】
- [ ] 6.3 迁移 IoT 卡停复机、实名策略/状态、限速和人工刷新纵向用例,实际外部尝试写 Integration Log内部状态变化写 Audit Eventunknown 不伪装失败或成功。【主:复杂写 + Gateway Adapter边界卡状态与外部命令不迁移卡归属验证success/failed/denied/unknown、Integration/Audit 分界、主体结论和旧写归零核对通过LSP 无诊断】
- [ ] 6.4 迁移设备创建/导入落库、基础资料和删除纵向用例,快照含 ID/VirtualNo/IMEI/SN/型号/店铺/系列/generation。【主简单写 + 旧 Service Adapter边界设备身份生命周期不迁移状态、卡槽与 Gateway验证创建/更新/删除、历史快照和旧资产写归零核对通过LSP 无诊断】
- [ ] 6.5 迁移设备分配、回收和系列/实名策略纵向用例,关联分配记录、来源/目标店铺及实际连带卡。【主:复杂写或简单写事务脚本|边界:设备归属与策略|不迁移:设备外部命令|验证:分配/回收/策略、设备卡关系、主体活动和旧写归零核对通过LSP 无诊断】
- [ ] 6.6 迁移设备停复机、Wi-Fi、切卡模式、重启和重置纵向用例外部尝试写 Integration Log实际内部变化写 Audit Event。【主复杂写 + Gateway Adapter边界设备外部命令不迁移设备归属与卡槽绑定验证success/failed/unknown、设备时间线、安全结论和旧写归零核对通过LSP 无诊断】
- [ ] 6.7 迁移设备绑卡、解绑和当前卡切换纵向用例,把设备、目标卡、旧/新当前卡及每个 binding 作为一等资源,保存 slot/is_current。【主复杂写边界设备卡槽关系完整用例不迁移设备/卡其他状态机验证1-4 卡槽、旧新卡角色、资源双向时间线、事务和主体范围核对通过LSP 无诊断】
- [ ] 6.8 迁移单笔资产分配/回收纵向用例,关联分配记录、来源/目标店铺、设备及实际连带卡。【主:复杂写 Application边界单笔资产流转不迁移批量分配和企业授权验证卡/设备流转、多资源、越权和每资源时间线核对通过LSP 无诊断】
- [ ] 6.9 迁移批量设备分配纵向用例,批次根事件与每台实际变化设备/绑定卡子事件计数一致。【主:复杂写 + Application/Asynq边界批量设备流转不迁移其他批量任务验证success/partial/failed、根子计数、幂等和店铺范围核对通过LSP 无诊断】
- [ ] 6.10 迁移卡换货完整用例,记录换货单、旧/新卡 ICCID+VirtualNo、客户绑定、钱包/流水、套餐权益、店铺和状态变化。【主:复杂写 Domain/ApplicationOutbox/Query边界卡换货创建至完成/取消/迁移|不迁移:设备换货|验证:资金/套餐/客户迁移、失败回滚、多资源时间线和外部安全结论核对通过LSP 无诊断】
- [ ] 6.11 迁移设备换货完整用例,记录旧/新设备 VirtualNo+IMEI+SN、实际绑定卡/卡槽、客户绑定、钱包/流水、套餐权益、店铺和换货单。【主:复杂写 Domain/ApplicationOutbox/Query边界设备换货完整流程不迁移无关订单规则验证多卡设备换货、资金/套餐/客户迁移、失败回滚和所有资源时间线核对通过LSP 无诊断】
## 7. 套餐、交易与资金纵向迁移
- [ ] 7.1 迁移套餐系列、套餐商品、店铺系列/套餐分配、批量定价和关键价格配置纵向用例,关联店铺、系列、套餐和价格历史,保持现有上架/分配规则。【主:简单写 + Application边界套餐配置与授权不迁移套餐购买流程验证before/after、批量根子、店铺时间线、凭据规则和旧 account 借用写归零测试 PASSLSP 无诊断】
- [ ] 7.2 迁移套餐权益激活、排队、流量重置/扣减、退款失效、资产失效和换货迁移纵向用例,系统任务使用真实 actor权益、订单、套餐和资产作为独立资源。【主复杂写 Domain/ApplicationWorker/Outbox边界package_usage 生命周期|不迁移:套餐列表 Query验证状态条件、重复任务幂等、系统 actor、父子链路和资源时间线测试 PASSLSP 无诊断】
- [ ] 7.3 迁移订单创建、后台代购、C 端/OpenAPI 购买、取消、钱包支付和过期关闭纵向用例,关联买家/操作者、资产、套餐、金额、支付方式、钱包/流水和购买角色。【主:复杂写 Domain/ApplicationOutbox边界订单状态与支付准备不迁移订单只读列表验证各入口 actor、成功/拒绝/失败、资金同事务、系统过期和链路测试 PASSLSP 无诊断】
- [ ] 7.4 迁移支付创建、微信/支付宝/富友预下单、查单和回调确认纵向用例:每次外部尝试写 Integration Log实际改变支付/订单/充值事实时写 Audit Event关联支付单、业务单、渠道交易号和 correlation。【主复杂写 + Infrastructure Adapter边界支付外部与内部终态不迁移渠道协议重构验证success/unknown/迟到回调/重复回调、series/correlation、Domain Ledger 和时间线测试 PASSLSP 无诊断】
- [ ] 7.5 迁移退款申请、审批终态、钱包回充、佣金失效、套餐/资产后处理和通知纵向用例,关联退款、审批、订单、资产、钱包、原扣款/退款流水、佣金和套餐权益,资金事实与审计同事务。【主:复杂写 Domain/ApplicationOutbox/Integration边界退款完整业务链不迁移原路退款等未实现能力验证通过/拒绝/重提/重复终态/失败回滚、资金时间线和代理安全结论测试 PASSLSP 无诊断】
- [ ] 7.6 迁移个人资产充值和代理在线/线下充值纵向用例,关联充值单、提交人、店铺/资产、支付或审批、钱包、交易流水和自动购包;外部回调与系统恢复使用真实 actor。【主复杂写 Domain/ApplicationPayment/Approval/Outbox边界充值创建至入账不迁移新充值渠道验证在线/线下、回调/审批、重复入账、unknown、资金同事务和主体投影测试 PASSLSP 无诊断】
- [ ] 7.7 迁移代理主钱包订单扣款和预占/释放/完成纵向用例,关联订单、钱包、预占、唯一流水和余额前后值。【主:复杂写 Domain/ApplicationOutbox边界订单资金占用与扣款不迁移充值/退款/信用额度|验证:乐观锁/状态条件、唯一业务键、审计失败回滚和失败短事务测试 PASSLSP 无诊断】
- [ ] 7.8 迁移代理主钱包充值入账、人工调整、退款回充和信用额度纵向用例,逐项关联业务单、原流水/新流水和余额前后值。【主:复杂写 Domain/ApplicationOutbox边界代理钱包正向及回退资金不迁移资产钱包与佣金提现验证重复入账/退款上限/人工原因/信用变更、同事务和资金查询测试 PASSLSP 无诊断】
- [ ] 7.9 迁移卡/设备资产钱包充值、扣款、退款和换货迁移纵向用例,关联资产完整标识、钱包、业务单和唯一流水。【主:复杂写 Domain/Application边界资产钱包资金不迁移代理主钱包验证卡/设备、换货、重复业务键、余额前后值和资源时间线测试 PASSLSP 无诊断】
- [ ] 7.10 迁移佣金计算/入账/失效和提现申请/审批/驳回纵向用例,关联店铺、订单、系列、佣金记录、提现单、钱包/流水和金额状态。【主:复杂写 Domain/ApplicationWorker/Outbox边界佣金与提现完整状态机不迁移统计 Query验证计算幂等、审批终态、资金同事务、失败短事务和资金时间线测试 PASSLSP 无诊断】
- [ ] 7.1 迁移套餐系列、套餐商品、店铺系列/套餐分配、批量定价和关键价格配置纵向用例,关联店铺、系列、套餐和价格历史,保持现有上架/分配规则。【主:简单写 + Application边界套餐配置与授权不迁移套餐购买流程验证before/after、批量根子、店铺时间线、凭据规则和旧 account 借用写归零核对通过LSP 无诊断】
- [ ] 7.2 迁移套餐权益激活、排队、流量重置/扣减、退款失效、资产失效和换货迁移纵向用例,系统任务使用真实 actor权益、订单、套餐和资产作为独立资源。【主复杂写 Domain/ApplicationWorker/Outbox边界package_usage 生命周期|不迁移:套餐列表 Query验证状态条件、重复任务幂等、系统 actor、父子链路和资源时间线核对通过LSP 无诊断】
- [ ] 7.3 迁移订单创建、后台代购、C 端/OpenAPI 购买、取消、钱包支付和过期关闭纵向用例,关联买家/操作者、资产、套餐、金额、支付方式、钱包/流水和购买角色。【主:复杂写 Domain/ApplicationOutbox边界订单状态与支付准备不迁移订单只读列表验证各入口 actor、成功/拒绝/失败、资金同事务、系统过期和链路核对通过LSP 无诊断】
- [ ] 7.4 迁移支付创建、微信/支付宝/富友预下单、查单和回调确认纵向用例:每次外部尝试写 Integration Log实际改变支付/订单/充值事实时写 Audit Event关联支付单、业务单、渠道交易号和 correlation。【主复杂写 + Infrastructure Adapter边界支付外部与内部终态不迁移渠道协议重构验证success/unknown/迟到回调/重复回调、series/correlation、Domain Ledger 和时间线核对通过LSP 无诊断】
- [ ] 7.5 迁移退款申请、审批终态、钱包回充、佣金失效、套餐/资产后处理和通知纵向用例,关联退款、审批、订单、资产、钱包、原扣款/退款流水、佣金和套餐权益,资金事实与审计同事务。【主:复杂写 Domain/ApplicationOutbox/Integration边界退款完整业务链不迁移原路退款等未实现能力验证通过/拒绝/重提/重复终态/失败回滚、资金时间线和代理安全结论核对通过LSP 无诊断】
- [ ] 7.6 迁移个人资产充值和代理在线/线下充值纵向用例,关联充值单、提交人、店铺/资产、支付或审批、钱包、交易流水和自动购包;外部回调与系统恢复使用真实 actor。【主复杂写 Domain/ApplicationPayment/Approval/Outbox边界充值创建至入账不迁移新充值渠道验证在线/线下、回调/审批、重复入账、unknown、资金同事务和主体投影核对通过LSP 无诊断】
- [ ] 7.7 迁移代理主钱包订单扣款和预占/释放/完成纵向用例,关联订单、钱包、预占、唯一流水和余额前后值。【主:复杂写 Domain/ApplicationOutbox边界订单资金占用与扣款不迁移充值/退款/信用额度|验证:乐观锁/状态条件、唯一业务键、审计失败回滚和失败短事务核对通过LSP 无诊断】
- [ ] 7.8 迁移代理主钱包充值入账、人工调整、退款回充和信用额度纵向用例,逐项关联业务单、原流水/新流水和余额前后值。【主:复杂写 Domain/ApplicationOutbox边界代理钱包正向及回退资金不迁移资产钱包与佣金提现验证重复入账/退款上限/人工原因/信用变更、同事务和资金查询核对通过LSP 无诊断】
- [ ] 7.9 迁移卡/设备资产钱包充值、扣款、退款和换货迁移纵向用例,关联资产完整标识、钱包、业务单和唯一流水。【主:复杂写 Domain/Application边界资产钱包资金不迁移代理主钱包验证卡/设备、换货、重复业务键、余额前后值和资源时间线核对通过LSP 无诊断】
- [ ] 7.10 迁移佣金计算/入账/失效和提现申请/审批/驳回纵向用例,关联店铺、订单、系列、佣金记录、提现单、钱包/流水和金额状态。【主:复杂写 Domain/ApplicationWorker/Outbox边界佣金与提现完整状态机不迁移统计 Query验证计算幂等、审批终态、资金同事务、失败短事务和资金时间线核对通过LSP 无诊断】
## 8. 审批、配置、批量与自动入口迁移
- [ ] 8.1 迁移通用审批和企微申请提交、回调同步、主动恢复及终态分发纵向用例,记录真实提交人、外部系统/系统任务 actor、审批实例、业务单、Integration Log 和 Outbox不伪造本地审批人。【主复杂写 Domain/ApplicationWeCom Adapter/Outbox边界审批完整链路不迁移新审批引擎验证提交、unknown、回调、恢复、重复终态、actor/correlation 和资源时间线测试 PASSLSP 无诊断】
- [ ] 8.2 迁移支付配置、运营商、企微应用/成员/场景及其他关键连接配置纵向用例,记录配置身份、状态和“凭据是否已配置”,不记录 Secret、Token、AESKey、私钥或证书正文。【主简单写 + Infrastructure Adapter边界外部连接配置不迁移配置 UI 与渠道业务协议验证CRUD/启停/校验失败、同事务、凭据删除和平台完整业务字段测试 PASSLSP 无诊断】
- [ ] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计审计中心自身仍不提供导出。【主Application + AsynqObject Storage边界现有导入批量和业务导出任务动作不迁移导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线测试 PASSLSP 无诊断】
- [ ] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action系统清理使用系统 actorAudit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker边界通知状态变化不迁移通知查询验证人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联测试 PASSLSP 无诊断】
- [ ] 8.5 迁移轮询配置、并发配置、告警规则、人工触发/取消和状态变化纵向用例;手动轮询表继续承担进度/结果,实际 Gateway 尝试写 Integration Log人工触发动作另写 Audit Event。【主Application + AdapterScheduler/Query边界轮询配置与人工动作不迁移删除手动任务表和普通运行查询验证配置/触发/拒绝、任务 ledger 保留、Integration/Audit 分界和 actor 测试 PASSLSP 无诊断】
- [ ] 8.6 迁移支付、运营商实名和企微等外部 Callback 中实际改变内部事实的入口,使用 external actor无状态变化只保留 Integration Log。【主Application/Infrastructure Adapter边界当前外部回调不迁移渠道协议验证逐回调 action、幂等、资源解析、Integration/Audit 分界和链路测试 PASSLSP 无诊断】
- [ ] 8.1 迁移通用审批和企微申请提交、回调同步、主动恢复及终态分发纵向用例,记录真实提交人、外部系统/系统任务 actor、审批实例、业务单、Integration Log 和 Outbox不伪造本地审批人。【主复杂写 Domain/ApplicationWeCom Adapter/Outbox边界审批完整链路不迁移新审批引擎验证提交、unknown、回调、恢复、重复终态、actor/correlation 和资源时间线核对通过LSP 无诊断】
- [ ] 8.2 迁移支付配置、运营商、企微应用/成员/场景及其他关键连接配置纵向用例,记录配置身份、状态和“凭据是否已配置”,不记录 Secret、Token、AESKey、私钥或证书正文。【主简单写 + Infrastructure Adapter边界外部连接配置不迁移配置 UI 与渠道业务协议验证CRUD/启停/校验失败、同事务、凭据删除和平台完整业务字段核对通过LSP 无诊断】
- [ ] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计审计中心自身仍不提供导出。【主Application + AsynqObject Storage边界现有导入批量和业务导出任务动作不迁移导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线核对通过LSP 无诊断】
- [ ] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action系统清理使用系统 actorAudit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker边界通知状态变化不迁移通知查询验证人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联核对通过LSP 无诊断】
- [ ] 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 无诊断】
- [ ] 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 无诊断】
- [ ] 8.9 迁移当前 Outbox 消费者中实际形成新业务事实的入口,保留 Outbox 投递事实并为内部变化写 system_task Audit Event不把投递成功伪装成业务成功。【主Application/Outbox Consumer边界当前可靠事件消费者不迁移Relay 实现|验证:逐消费者覆盖、至少一次幂等、业务/投递结果分离和 correlation 测试 PASSLSP 无诊断】
- [ ] 8.9 迁移当前 Outbox 消费者中实际形成新业务事实的入口,保留 Outbox 投递事实并为内部变化写 system_task Audit Event不把投递成功伪装成业务成功。【主Application/Outbox Consumer边界当前可靠事件消费者不迁移Relay 实现|验证:逐消费者覆盖、至少一次幂等、业务/投递结果分离和 correlation 核对通过LSP 无诊断】
## 9. 跨视角调查与性能收口
- [ ] 9.1 交付 request 和 correlation 组合时间线,按 `record_source` 组合 Audit Event、Integration Log、Outbox/Asynq 摘要及 Domain Ledger 引用,不扫描 Access Log、不猜测历史链路或技术重试。【主Query边界跨事实只读投影不迁移Access Log 存储与关系图|验证:支付、退款、审批、异步链路、历史缺字段和稳定排序测试 PASSLSP 无诊断】
- [ ] 9.2 交付资金调查时间线,按店铺、钱包、订单、支付、退款、充值、审批、交易号、操作者、时间和 correlation 查询并明确金额权威来自钱包流水及业务表。【主Query边界资金多源投影不迁移资金重算或状态修改验证余额变化、退款、充值、佣金、来源冲突和权限身份测试 PASSLSP 无诊断】
- [ ] 9.3 交付风险 overview/events聚合高风险、资金、安全、失败、拒绝、partial 和 unknown支持跳转事件、资源、actor 和 correlation不建设处置工单或自动封禁。【主Query边界固定风险调查视角不迁移风控决策系统验证计数/趋势、低风险排除、跳转和时间范围性能测试 PASSLSP 无诊断】
- [ ] 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 和并发分页测试 PASSLSP 无诊断】
- [ ] 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 无诊断】
- [ ] 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 无诊断】
- [ ] 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.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、空日/大日、事件资源计数、对象损坏、重复投递、存储故障和业务写入隔离测试 PASSLSP 无诊断】
- [ ] 10.2 交付 Integration Log 每日归档及月度最终 revision 纵向切片:每日保存创建日快照,月初按数据库当前内容复核可变记录,内容变化时创建不可变新 revisionpending 或不一致阻止清理不覆盖旧对象。【主Infrastructure + Application/Asynq边界Integration 冷归档最终性不迁移Integration Writer 语义、恢复和对象存储查询验证pending→success/unknown、跨月更新、hash 变化、重复复核、无法终结和对象版本测试 PASSLSP 无诊断】
- [ ] 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 全部阻断,物理行数归零、批次中断续跑、窗口隔离、对象长期保留、清理事件和数据库膨胀指标测试 PASSLSP 无诊断】
- [ ] 10.4 为平台 Audit/Integration 及代理/企业活动 DTO 增加 `retention{online_from,archived_before,timezone}`显式时间范围早于或跨越在线边界时返回稳定已归档错误默认只查在线窗口ID-only 不存在仍按资源不存在处理不访问对象存储也不新增下载或恢复路由。【主Query/API边界在线查询留存语义不迁移冷热联合查询和前端页面实现验证在线、已归档、跨边界、默认范围、空在线月、路由/OpenAPI 无归档读写能力测试 PASSLSP 无诊断】
- [ ] 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 无诊断】
- [ ] 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.5 先以“只归档、不清理”开关运行并验收一个完整自然月记录每日成功率、积压、对象大小、压缩率、hash/计数差异、Integration revision、清理预估耗时和告警只有月度演练全部通过后才启用清理开关。【主Release/Observability边界留存能力灰度启用不迁移Access Log、自动恢复和分区改造验证完整月 manifest、故障重试、月度 dry-run、监控阈值、操作手册和启停回滚证据齐全】
## 11. 旧 Writer Contract 与发布门禁
- [ ] 11.1 完成账号、资产及借用旧 account audit 的充值/套餐/支付配置等调用清单逐项归零,删除生产组合根对旧 Writer 的注入和所有裸/双重 goroutine 审计写入;旧表原样保留且不接入新审计 Query既有旧资产历史入口与手动轮询运行写入显式白名单。【主Infrastructure/Governance边界旧 operation log contract不迁移旧数据回填/转换、删除旧表和历史数据|验证:静态扫描、生产装配、统一 Query 无旧表依赖、真实业务写入和旧表无新增测试 PASSLSP 无诊断】
- [ ] 11.1 完成账号、资产及借用旧 account audit 的充值/套餐/支付配置等调用清单逐项归零,删除生产组合根对旧 Writer 的注入和所有裸/双重 goroutine 审计写入;旧表原样保留且不接入新审计 Query既有旧资产历史入口与手动轮询运行写入显式白名单。【主Infrastructure/Governance边界旧 operation log contract不迁移旧数据回填/转换、删除旧表和历史数据|验证:静态扫描、生产装配、统一 Query 无旧表依赖、真实业务写入和旧表无新增核对通过LSP 无诊断】
- [ ] 11.2 执行最终 Action/Resource Registry 与当前代码覆盖比对并增量更新 `.scratch/tech-global-audit/审计覆盖基线.md`,确认所有非查询写入口和 Registry 标记的敏感读取均登记、普通查询/N/A 有理由,多资源/设备卡槽/换货/批量/资金/自动入口无遗漏未注册动作或资源使门禁失败。【主治理边界全仓当前入口不迁移未来未提交功能验证覆盖门禁、Registry 清单、基线文件和业务/研发/安全签字证据齐全】
- [ ] 11.3 执行安全与身份发布门禁:平台接口仅 SuperAdmin/Platform、代理店铺层级、企业有效授权、internal_only 不泄露、业务字段平台完整、所有系统安全凭据在库和响应中均不存在、无用户审计导出、对象存储查询/恢复或业务删除路由。【主Security/API边界第一阶段访问与字段契约不迁移细粒度权限码验证敌对 HTTP 场景、数据库抽样、路由/OpenAPI 扫描全部 PASS】
- [ ] 11.4 执行事务、幂等与跨链路发布门禁关键成功审计失败回滚、失败短事务保留原错、批量根子计数一致、correlation/parent/series 正确、Audit/Integration/Domain Ledger/Outbox 边界没有混用。【主Verification边界高风险和代表性流程不迁移业务规则优化验证全部验收测试和业务流程测试 PASS,已知错误为零】
- [ ] 11.4 执行事务、幂等与跨链路发布门禁关键成功审计失败回滚、失败短事务保留原错、批量根子计数一致、correlation/parent/series 正确、Audit/Integration/Domain Ledger/Outbox 边界没有混用。【主Verification边界高风险和代表性流程不迁移业务规则优化验证代表性业务流程的接口、数据库与日志证据核对通过,已知错误为零】
- [ ] 11.5 执行迁移、性能、OpenAPI、归档留存和回滚演练增量迁移可在无事实环境 down业务回滚保留在线窗口内事实归档失败阻止清理清理后不恢复旧 Writer或伪造在线历史记录监控阈值和操作手册。【主Release/Infrastructure边界一次 contract 发布|不迁移:数据库分区、冷热联合查询与自动恢复|验证:迁移演练、性能目标、归档 dry-run、两份文档生成、构建、监控和回滚演练全部 PASS】
## 12. 最终文档与交付确认
- [ ] 12.1 在 `docs/feature-504-multi-view-audit-center/` 编写中文总结,包含四类事实边界、字段字典、当前领域/资源/动作矩阵、各资源快照、所有查询视角与 DTO、平台和主体可见性、多卡设备/换货/资金/批量样例、每日归档与月度清理、在线窗口、异常闭环、发布回滚、监控和明确未实现项,并更新 README 索引。【主Documentation边界本 Change 完整交付不迁移旧历史评审文档验证文档逐项引用实现、OpenAPI 和验收证据,无过时字段或路径】
- [ ] 12.2 汇总测试、静态扫描、LSP、迁移、构建、OpenAPI、数据库性能、API P95/P99、身份隔离、凭据抽样、旧写归零、归档完整性、月度清理 dry-run 和业务流程证据,逐条核对 proposal/design/specs/tasks只有全部完成且无已知错误时才标记 Change 可归档。【主:最终验收|边界:全部能力|不迁移:后续权限码、用户导出、对象存储历史查询/恢复、分区、风险处置和自动恢复|验证:全部验收测试 PASS + 全部流程测试 PASS + OpenSpec strict validate PASS】
- [ ] 12.2 汇总静态扫描、LSP、迁移、构建、OpenAPI、数据库性能、API P95/P99、身份隔离、凭据抽样、旧写归零、归档完整性、月度清理 dry-run 和业务流程证据,逐条核对 proposal/design/specs/tasks只有全部完成且无已知错误时才标记 Change 可归档。【主:最终验收|边界:全部能力|不迁移:后续权限码、用户导出、对象存储历史查询/恢复、分区、风险处置和自动恢复|验证:制品与人工/数据证据核对通过 + OpenSpec strict validate PASS】

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