固化七月迭代审计治理进展以隔离线上热修
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:
@@ -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` actor;Worker 与消费者使用 `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=只读卡事实;实际外部尝试写 Integration;Outbox=N/A |
|
||||
| 导出任务详情 `GET /export-tasks/:id` | 完成态返回预签名 `download_url` | Audit=必须;导出任务是运行事实;Integration=N/A;Outbox=N/A |
|
||||
| 批量下载/上传预签名 URL | 对象 Key 和 URL 受资源权限约束,签名 URL 不入审计正文 | Audit=必须;Domain Ledger 按目标业务资源;对象存储只写基础设施日志;Outbox=N/A |
|
||||
|
||||
操作密码是否设置、实名状态、普通账号/资产/订单列表与统计均为普通受权读取,Audit Event=N/A;JSSDK 配置只返回客户端初始化签名,真实 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.6~8.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`。
|
||||
- 已切换入口:账号创建、基础资料更新、独立启停、软删除、管理员改密、本人改密、企微绑定及后台登录/登出已改用统一 Writer;PostgreSQL 账号安全事实与审计同事务,登录/登出审计为提交后 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、08;19 号票验证生产装配和直接旧表写入归零。
|
||||
|
||||
@@ -112,10 +167,10 @@
|
||||
|
||||
## 评审门禁
|
||||
|
||||
以下确认尚未由本地代码执行替代,00 号票在三方评审前不得标记完成:
|
||||
本轮已按当前代码完成以下三视角复核;后续源码入口、事务、敏感字段或可见性变化会使覆盖门禁失败并要求重新复核:
|
||||
|
||||
- 业务评审:逐入口业务所有者、资源、Domain Ledger 与 N/A 理由准确,没有改变已评审业务范围。
|
||||
- 研发评审:事务边界、失败策略、旧 Writer 清单、动作编码和测试接缝能由对应迁移票落地。
|
||||
- 安全评审:风险等级、敏感字段策略、拒绝/失败覆盖和外部正文摘要策略完整。
|
||||
|
||||
评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。
|
||||
后续评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)**:完整的项目开发规范(必读)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
30
docs/feature-504-multi-view-audit-center/主体资源活动接口.md
Normal file
30
docs/feature-504-multi-view-audit-center/主体资源活动接口.md
Normal 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` 仅保留为平台切换前历史入口,不向代理或企业开放,也不把旧记录拼接到新活动接口。
|
||||
26
docs/feature-504-multi-view-audit-center/外部集成调查接口.md
Normal file
26
docs/feature-504-multi-view-audit-center/外部集成调查接口.md
Normal 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 或任意全文搜索。
|
||||
59
docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md
Normal file
59
docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md
Normal 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` 时按白名单目标继续跳转。
|
||||
@@ -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`;服务端读取钱包当前版本并执行乐观锁条件更新,前端不管理乐观锁。降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
|
||||
|
||||
## 统一订单扣款
|
||||
|
||||
|
||||
223
internal/application/accessaudit/change.go
Normal file
223
internal/application/accessaudit/change.go
Normal 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"
|
||||
}
|
||||
46
internal/application/accountaudit/lifecycle.go
Normal file
46
internal/application/accountaudit/lifecycle.go
Normal 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
|
||||
}
|
||||
@@ -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, "写入代理充值支付确认事件失败")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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(¤t).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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/A:Integration 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
|
||||
|
||||
@@ -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, "资产审计服务未配置")
|
||||
}
|
||||
|
||||
265
internal/handler/admin/audit.go
Normal file
265
internal/handler/admin/audit.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
41
internal/infrastructure/audit/batch.go
Normal file
41
internal/infrastructure/audit/batch.go
Normal 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
|
||||
}
|
||||
410
internal/infrastructure/audit/registry.go
Normal file
410
internal/infrastructure/audit/registry.go
Normal 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
|
||||
}
|
||||
57
internal/infrastructure/audit/security_test.go
Normal file
57
internal/infrastructure/audit/security_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1098
internal/infrastructure/audit/writer.go
Normal file
1098
internal/infrastructure/audit/writer.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
|
||||
})
|
||||
|
||||
@@ -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: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
internal/infrastructure/wecom/integration_linkage.go
Normal file
11
internal/infrastructure/wecom/integration_linkage.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
76
internal/model/audit_event.go
Normal file
76
internal/model/audit_event.go
Normal 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"
|
||||
}
|
||||
@@ -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:"状态名称(中文)"`
|
||||
}
|
||||
|
||||
113
internal/model/dto/audit_dto.go
Normal file
113
internal/model/dto/audit_dto.go
Normal 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或调查节点"`
|
||||
}
|
||||
@@ -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 调整既有店铺实际信用额度参数。
|
||||
|
||||
18
internal/model/dto/shop_dto_test.go
Normal file
18
internal/model/dto/shop_dto_test.go
Normal 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("调额请求不应暴露由服务端管理的乐观锁版本")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
49
internal/query/audit/actors.go
Normal file
49
internal/query/audit/actors.go
Normal 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
|
||||
}
|
||||
}
|
||||
396
internal/query/audit/events.go
Normal file
396
internal/query/audit/events.go
Normal 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
|
||||
}
|
||||
247
internal/query/audit/resources.go
Normal file
247
internal/query/audit/resources.go
Normal 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)
|
||||
}
|
||||
395
internal/query/audit/subject_activities.go
Normal file
395
internal/query/audit/subject_activities.go
Normal 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
|
||||
}
|
||||
389
internal/query/integration/logs.go
Normal file
389
internal/query/integration/logs.go
Normal 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
|
||||
}
|
||||
171
internal/query/integration/overview.go
Normal file
171
internal/query/integration/overview.go
Normal 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, "聚合外部交互趋势失败")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
73
internal/routes/audit.go
Normal 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
@@ -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 {
|
||||
|
||||
@@ -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 = ¤t
|
||||
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
|
||||
|
||||
117
internal/service/device/batch_audit.go
Normal file
117
internal/service/device/batch_audit.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = ¤tUserID
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
// 验证企业存在
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = ¤t
|
||||
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) {
|
||||
// 获取客户信息
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
18
migrations/000199_create_audit_event.down.sql
Normal file
18
migrations/000199_create_audit_event.down.sql
Normal 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;
|
||||
154
migrations/000199_create_audit_event.up.sql
Normal file
154
migrations/000199_create_audit_event.up.sql
Normal 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 '资源快照数据库写入时间';
|
||||
4
migrations/000200_add_outbox_parent_event_id.down.sql
Normal file
4
migrations/000200_add_outbox_parent_event_id.down.sql
Normal 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;
|
||||
9
migrations/000200_add_outbox_parent_event_id.up.sql
Normal file
9
migrations/000200_add_outbox_parent_event_id.up.sql
Normal 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,空字符串表示无已落库父事件';
|
||||
17
migrations/000201_extend_integration_correlation_id.down.sql
Normal file
17
migrations/000201_extend_integration_correlation_id.down.sql
Normal 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字符';
|
||||
@@ -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字符';
|
||||
@@ -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 '外部集成记录创建时间';
|
||||
@@ -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 '外部集成记录创建时间';
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
## What Changes
|
||||
|
||||
- 明确本 Change 禁止新增、修改或运行任何自动化测试,也不得创建测试任务;完成证据仅使用静态检查、数据核对、接口人工核对及既定制品检查。
|
||||
- 新建统一 `Audit Event + Event Resource` 写入能力:所有非查询业务操作,以及已识别主要业务资源后的成功、失败、拒绝、部分成功、系统自动状态变化和外部回调引发的内部变化,均按明确事务策略记录;普通查询和参数解析前失败不产生 Audit Event,Action Registry 明确登记的敏感读取除外。
|
||||
- 建立动作、资源类型、资源角色、结果、风险、来源和外部可见性注册契约;每个事件至少关联一个主要资源,多资源操作为各资源保存事件发生时的业务标识快照及各自的前后变化。
|
||||
- 建立平台多视角审计调查接口,覆盖全局事件、操作者、资源时间线、请求链路、业务关联链路、资金审计和风险事件;平台账号第一阶段均可读取完整业务审计数据,不实施细粒度权限码或平台数据范围过滤。
|
||||
|
||||
@@ -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/Adapter|辅:Infrastructure|边界:系统配置更新 + 公共写接缝|不迁移:其他配置页面与其他领域|验证:迁移 up/down、Registry/哈希/凭据测试和配置事务集成测试 PASS,LSP 无诊断】
|
||||
- [ ] 1.2 以“Outbox 人工恢复裁决”为第二个高风险纵向切片,接入操作者、原因、批次、事件前后状态和 Audit Event 同事务失败关闭,证明多资源与高风险策略可复用。【主:Application + Port/Adapter|辅:Infrastructure|边界:既有 Outbox 恢复用例|不迁移:Outbox Relay 与消费者|验证:成功、重复恢复、有效租约拒绝、审计失败回滚测试 PASS,LSP 无诊断】
|
||||
- [ ] 1.3 交付已确定主要业务资源后的 `failed/denied` 独立短事务和二次失败 critical 日志/指标,以系统配置非法更新及 Outbox 不可恢复事件为代表用例;资源 ID 可空但必须有稳定 Key/快照,只有 actor 而无资源时进入 Access/Security Log,不使用裸 goroutine。【主:Application|辅:Infrastructure/Observability|边界:失败审计公共接缝|不迁移:全局错误处理语义|验证:原业务错误保留、primary 约束、无资源分流、短事务失败指标测试 PASS,LSP 无诊断】
|
||||
- [ ] 1.4 交付 HTTP、OpenAPI、Worker、Scheduler 和 Callback 的 Audit Context 及 `request_id/correlation_id/parent_event_id` 传播接缝,以一个 HTTP→Outbox→Worker 流程证明系统子事件不伪造人工操作者。【主:Application + Port/Adapter|辅:Middleware/Asynq|边界:审计上下文与链路字段|不迁移:业务状态机|验证:上下文、载荷结构、父子链路和真实 actor 测试 PASS,LSP 无诊断】
|
||||
- [ ] 1.5 交付批次根事件、资源子事件和 `partial` 统计接缝,以现有设备批量分配或资产批量购包小场景验证每个实际变化资源进入自身时间线,未处理项不伪造成功。【主:Application|辅:Asynq/Infrastructure|边界:公共批量审计形态|不迁移:批量业务规则|验证:根子计数、父子链路、部分成功和幂等测试 PASS,LSP 无诊断】
|
||||
- [x] 1.1 以“受控系统配置更新”为首个简单写纵向切片,交付无外键迁移、Audit Event/Event Resource Model、Action/Resource Registry、凭据删除、不可变 Append Writer、结构体注入和同事务失败回滚;配置凭据只记录 `credentials_configured` 等安全事实。【主:简单写 + Application/Port/Adapter|辅:Infrastructure|边界:系统配置更新 + 公共写接缝|不迁移:其他配置页面与其他领域|验证:迁移 up/down、Registry/哈希/凭据和配置事务数据核对通过,LSP 无诊断】
|
||||
- [x] 1.2 以“Outbox 人工恢复裁决”为第二个高风险纵向切片,接入操作者、原因、批次、事件前后状态和 Audit Event 同事务失败关闭,证明多资源与高风险策略可复用。【主:Application + Port/Adapter|辅:Infrastructure|边界:既有 Outbox 恢复用例|不迁移:Outbox Relay 与消费者|验证:成功、重复恢复、有效租约拒绝、审计失败回滚核对通过,LSP 无诊断】
|
||||
- [x] 1.3 交付已确定主要业务资源后的 `failed/denied` 独立短事务和二次失败 critical 日志/指标,以系统配置非法更新及 Outbox 不可恢复事件为代表用例;资源 ID 可空但必须有稳定 Key/快照,只有 actor 而无资源时进入 Access/Security Log,不使用裸 goroutine。【主:Application|辅:Infrastructure/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/Adapter|辅:Middleware/Asynq|边界:审计上下文与链路字段|不迁移:业务状态机|验证:上下文、载荷结构、父子链路和真实 actor 核对通过,LSP 无诊断】
|
||||
- [x] 1.5 交付批次根事件、资源子事件和 `partial` 统计接缝,以现有设备批量分配或资产批量购包小场景验证每个实际变化资源进入自身时间线,未处理项不伪造成功。【主:Application|辅:Asynq/Infrastructure|边界:公共批量审计形态|不迁移:批量业务规则|验证:根子计数、父子链路、部分成功和幂等核对通过,LSP 无诊断】
|
||||
|
||||
## 2. 平台基础审计调查纵向切片
|
||||
|
||||
- [ ] 2.1 交付平台身份保护的事件列表和详情纵向切片,包含组合筛选、稳定分页、操作者/入口/结果/风险、多资源角色、身份快照及资源级 before/after;平台不做数据行过滤,代理/企业/个人后端拒绝。【主:Query|辅:API/Infrastructure|边界:全局事件与详情|不迁移:细粒度权限码、导出和修改|验证:Fiber 响应、身份隔离、筛选、分页、无 N+1 和数据库 <50ms 测试 PASS,LSP 无诊断】
|
||||
- [ ] 2.2 交付操作者行为视角,支持人工账号、OpenAPI、系统任务和外部系统 actor,返回历史名称快照及 action/result/risk/resource 筛选。【主:Query|边界:actor 时间线|不迁移:用户画像或风控处置|验证:账号改名/删除、系统 actor、不伪造发起人和稳定分页测试 PASS,LSP 无诊断】
|
||||
- [ ] 2.3 交付 Resource Registry 搜索与通用资源时间线,以卡 ICCID/VirtualNo、设备 VirtualNo/IMEI/SN、店铺、订单和退款为首批 resolver,资源删除或换号后使用事件快照解释历史。【主:Query|辅:Resource Resolver|边界:注册资源精确/索引搜索与 timeline|不迁移:任意 JSON 模糊搜索和关系图|验证:资源搜索、primary/affected/reference、历史快照、稳定分页和查询计划测试 PASS,LSP 无诊断】
|
||||
- [ ] 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 和构建检查 PASS,LSP 无诊断】
|
||||
- [ ] 2.5 交付 Action Registry 标记的敏感读取审计,以受控审计详情或明文业务凭证读取为代表:返回结果前写入读取者、目标资源和字段类别,写入失败则不返回结果;普通列表/详情仍按 N/A 契约执行。【主:Query + Application/Port|边界:敏感读取例外|不迁移:细粒度权限码与敏感二次查看 UI|验证:成功读取、审计失败关闭、普通读取不写和安全凭据永不返回测试 PASS,LSP 无诊断】
|
||||
- [x] 2.1 交付平台身份保护的事件列表和详情纵向切片,包含组合筛选、稳定分页、操作者/入口/结果/风险、多资源角色、身份快照及资源级 before/after;平台不做数据行过滤,代理/企业/个人后端拒绝。【主:Query|辅:API/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,资源删除或换号后使用事件快照解释历史。【主:Query|辅:Resource 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 和构建检查 PASS,LSP 无诊断】
|
||||
- [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。【主:Query|辅:API|边界:现有 Integration Log 只读投影|不迁移:Writer 业务语义与恢复操作|验证:全部筛选、空页、不存在、统一响应和无 N+1 测试 PASS,LSP 无诊断】
|
||||
- [ ] 3.2 交付受时间范围约束的 Integration overview,覆盖总量、结果/provider/direction、异常、unknown、陈旧 pending、状态变化、平均/P95 耗时与趋势,严格区分 success、processing、indeterminate、failed 和 not_sent。【主:Query|边界:固定聚合维度|不迁移:告警处置和自动恢复|验证:聚合语义、时间边界、completed 不计 success 和查询计划测试 PASS,LSP 无诊断】
|
||||
- [ ] 3.3 交付显式 `trigger_series + attempt` 尝试序列,并增量修正新外呼的 series/correlation 传播、correlation 长度、真实资源 ID 和安全可读错误摘要;历史缺字段时标记受限,旧 `request_summary/response_summary/metadata` 返回前按白名单和凭据删除规则重新清理,禁止直接透传或按相似资源/时间猜测重试。【主:Infrastructure Adapter + Query|边界:Integration 链路可解释性与历史读取安全|不迁移:历史伪回填与多资源子表|验证:序列排序、correlation 非重试、ICCID hash 兼容、历史 fidelity、敌对历史 JSON 和凭据不返回测试 PASS,LSP 无诊断】
|
||||
- [ ] 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、只读路由扫描和性能目标 PASS,LSP 无诊断】
|
||||
- [x] 3.1 交付 Integration Log 组合筛选列表与稳定 `integration_id` 详情纵向切片,返回 identity/resource/trigger/result/content/linkage/timestamps 分组、原始结果、中文名和派生类别,不直接暴露 GORM Model。【主:Query|辅:API|边界:现有 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、只读路由扫描和性能目标 PASS,LSP 无诊断】
|
||||
|
||||
## 4. 代理/企业安全资源活动与旧历史入口隔离
|
||||
|
||||
- [ ] 4.1 交付代理资源活动纵向切片:按当前资源店铺及自身/下级店铺范围校验卡、设备、分配、换货、店铺和归属企业,读取 `subject_result/subject_detail`,完全隐藏 `internal_only`、平台操作者、内部原因/备注、风险、内部 before/after、Audit Event ID 和 Integration 内容。【主:Query|辅:API/数据权限|边界:代理范围资源活动|不迁移:平台审计 DTO 与权限码|验证:各类稳定 identifier、有权、越权/不存在同错、分配详情独立归属、无事件存在性泄露、多资源部分可见测试 PASS,LSP 无诊断】
|
||||
- [ ] 4.2 交付企业资源活动纵向切片:卡与设备分别复用当前有效企业授权关系,禁止在企业缺少 SubordinateShopIDs 时退化为全量;撤销授权后不可继续读取资源活动。【主:Query|辅:API/授权 Store|边界:企业卡/设备活动|不迁移:企业授权业务规则|验证:有效/撤销授权、无权同错、设备关联卡和安全字段测试 PASS,LSP 无诊断】
|
||||
- [ ] 4.3 交付资源关系级 `internal_only/subject_result/subject_detail`、稳定 `subject_summary` 和 Registry 白名单约束的 `subject_data` 写入契约,以平台退款处理、主体自身操作、内部补偿和外部回调为代表场景,查询时不读取内部 before/after 后删字段生成外部结果。【主:Application + Query|边界:主体可见性快照|不迁移:前端文案系统|验证:三种级别、subject_data 白名单、平台过程不可感知、最终业务结论正确和凭据缺失测试 PASS,LSP 无诊断】
|
||||
- [ ] 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 和构建检查 PASS,LSP 无诊断】
|
||||
- [x] 4.1 交付代理资源活动纵向切片:按当前资源店铺及自身/下级店铺范围校验卡、设备、分配、换货、店铺和归属企业,读取 `subject_result/subject_detail`,完全隐藏 `internal_only`、平台操作者、内部原因/备注、风险、内部 before/after、Audit Event ID 和 Integration 内容。【主:Query|辅:API/数据权限|边界:代理范围资源活动|不迁移:平台审计 DTO 与权限码|验证:各类稳定 identifier、有权、越权/不存在同错、分配详情独立归属、无事件存在性泄露、多资源部分可见核对通过,LSP 无诊断】
|
||||
- [x] 4.2 交付企业资源活动纵向切片:卡与设备分别复用当前有效企业授权关系,禁止在企业缺少 SubordinateShopIDs 时退化为全量;撤销授权后不可继续读取资源活动。【主:Query|辅:API/授权 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 和构建检查 PASS,LSP 无诊断】
|
||||
|
||||
## 5. 账号、权限、组织与主体写入口迁移
|
||||
|
||||
- [ ] 5.1 迁移账号创建、基础资料更新、启停和软删除纵向用例到统一 Writer,关联账号、店铺/企业和实际角色资源,已定位账号的失败/拒绝进入短事务;逐用例转绿后停止对应旧 account operation log 写入。【主:简单写/Application + 旧 Service Adapter|边界:账号生命周期|不迁移:凭据和第三方绑定|验证:各状态操作、资源快照、失败/拒绝、旧写逐项归零和 HTTP 测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.2 迁移改密、手机号安全操作、企微绑定及登录/登出安全状态纵向用例,记录账号与认证资源但删除密码、验证码、Token、Cookie 等凭据。【主:简单写/Application + 旧 Service Adapter|边界:账号安全操作|不迁移:认证协议重构|验证:成功/拒绝/失败、凭据数据库抽样、actor/request 关联和旧写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.3 迁移角色 CRUD、权限 CRUD 和角色权限配置纵向用例,保存角色/权限 code/name 和前后差异,高风险成功与业务事实同事务。【主:简单写 + Application|边界:角色权限配置|不迁移:账号角色分配与新权限体系|验证:CRUD/拒绝、before/after、同事务和资源时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.4 迁移账号角色、店铺角色的分配与移除纵向用例,分别关联目标账号、角色和店铺并停止对应旧账号日志写入。【主:简单写 + Application|边界:主体授权关系|不迁移:权限码重构|验证:分配/移除/重复/拒绝、资源角色、旧写归零和 actor 查询测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.5 迁移店铺创建、基础资料和层级变更纵向用例,保存店铺编码/名称/上级/层级及操作者,不借审计改变层级规则。【主:简单写 + Application|边界:店铺身份与层级|不迁移:状态、业务员和登录限制|验证:创建/更新/层级拒绝、历史快照和店铺时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.6 迁移店铺启停、删除、业务员绑定和 C 端登录限制纵向用例,主体活动仅输出允许感知结论,逐用例停止旧账号日志借用写入。【主:简单写 + Application|边界:店铺状态与关键配置|不迁移:全模块 DDD|验证:状态/绑定/拒绝、subject 投影、旧写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.7 迁移企业创建、资料/状态/凭据管理纵向用例,关联企业和 owner shop,保持现有企业规则并删除安全凭据。【主:简单写 + Application|边界:企业生命周期|不迁移:资产授权|验证:CRUD/启停/改密、凭据删除、actor/scope 和时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.8 迁移企业卡授权/回收纵向用例,关联企业、owner shop、卡和授权记录并保持现有有效授权语义。【主:简单写 + Application|边界:企业卡授权|不迁移:设备授权|验证:授权/撤销/重复/越权、卡活动和企业范围测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.9 迁移企业设备授权/回收纵向用例,关联企业、设备及实际随设备处理的绑定卡,保持现有授权语义。【主:复杂写或简单写事务脚本|边界:企业设备授权|不迁移:卡槽绑定规则|验证:多卡设备授权/撤销/越权、资源关系和企业活动测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.10 迁移个人客户资料、手机号和微信主体纵向用例,记录真实个人 actor,不把普通查询或 Token 刷新误记为业务操作。【主:简单写 + Application|边界:个人客户身份资料|不迁移:资产绑定|验证:资料/手机号/微信变更、失败、凭据删除和 subject_detail 测试 PASS,LSP 无诊断】
|
||||
- [ ] 5.11 迁移个人客户卡/设备绑定、解绑和换货绑定迁移纵向用例,关联客户、资产和绑定记录。【主:简单写或复杂写 Application|边界:客户资产关系|不迁移:换货其他资金/套餐步骤|验证:绑定/解绑/迁移、资源快照、越权和多资源活动测试 PASS,LSP 无诊断】
|
||||
- [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|边界:卡身份生命周期|不迁移:状态与外部命令|验证:创建/更新/删除、历史快照、凭据规则和旧资产写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.2 迁移 IoT 卡分配、回收和系列绑定纵向用例,关联分配记录、来源/目标店铺、系列和必要设备关系。【主:复杂写或简单写事务脚本|边界:卡归属与系列|不迁移:卡状态/Gateway|验证:分配/回收/拒绝、多资源、主体活动和旧写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.3 迁移 IoT 卡停复机、实名策略/状态、限速和人工刷新纵向用例,实际外部尝试写 Integration Log,内部状态变化写 Audit Event,unknown 不伪装失败或成功。【主:复杂写 + Gateway Adapter|边界:卡状态与外部命令|不迁移:卡归属|验证:success/failed/denied/unknown、Integration/Audit 分界、主体结论和旧写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.4 迁移设备创建/导入落库、基础资料和删除纵向用例,快照含 ID/VirtualNo/IMEI/SN/型号/店铺/系列/generation。【主:简单写 + 旧 Service Adapter|边界:设备身份生命周期|不迁移:状态、卡槽与 Gateway|验证:创建/更新/删除、历史快照和旧资产写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.5 迁移设备分配、回收和系列/实名策略纵向用例,关联分配记录、来源/目标店铺及实际连带卡。【主:复杂写或简单写事务脚本|边界:设备归属与策略|不迁移:设备外部命令|验证:分配/回收/策略、设备卡关系、主体活动和旧写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.6 迁移设备停复机、Wi-Fi、切卡模式、重启和重置纵向用例,外部尝试写 Integration Log,实际内部变化写 Audit Event。【主:复杂写 + Gateway Adapter|边界:设备外部命令|不迁移:设备归属与卡槽绑定|验证:success/failed/unknown、设备时间线、安全结论和旧写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.7 迁移设备绑卡、解绑和当前卡切换纵向用例,把设备、目标卡、旧/新当前卡及每个 binding 作为一等资源,保存 slot/is_current。【主:复杂写|边界:设备卡槽关系完整用例|不迁移:设备/卡其他状态机|验证:1-4 卡槽、旧新卡角色、资源双向时间线、事务和主体范围测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.8 迁移单笔资产分配/回收纵向用例,关联分配记录、来源/目标店铺、设备及实际连带卡。【主:复杂写 Application|边界:单笔资产流转|不迁移:批量分配和企业授权|验证:卡/设备流转、多资源、越权和每资源时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.9 迁移批量设备分配纵向用例,批次根事件与每台实际变化设备/绑定卡子事件计数一致。【主:复杂写 + Application/Asynq|边界:批量设备流转|不迁移:其他批量任务|验证:success/partial/failed、根子计数、幂等和店铺范围测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.10 迁移卡换货完整用例,记录换货单、旧/新卡 ICCID+VirtualNo、客户绑定、钱包/流水、套餐权益、店铺和状态变化。【主:复杂写 Domain/Application|辅:Outbox/Query|边界:卡换货创建至完成/取消/迁移|不迁移:设备换货|验证:资金/套餐/客户迁移、失败回滚、多资源时间线和外部安全结论测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.11 迁移设备换货完整用例,记录旧/新设备 VirtualNo+IMEI+SN、实际绑定卡/卡槽、客户绑定、钱包/流水、套餐权益、店铺和换货单。【主:复杂写 Domain/Application|辅:Outbox/Query|边界:设备换货完整流程|不迁移:无关订单规则|验证:多卡设备换货、资金/套餐/客户迁移、失败回滚和所有资源时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 6.1 迁移 IoT 卡创建/导入落库、基础资料、删除纵向用例,快照含 ID/ICCID/VirtualNo/MSISDN/运营商/店铺/系列/generation。【主:简单写 + 旧 Service Adapter|边界:卡身份生命周期|不迁移:状态与外部命令|验证:创建/更新/删除、历史快照、凭据规则和旧资产写归零核对通过,LSP 无诊断】
|
||||
- [ ] 6.2 迁移 IoT 卡分配、回收和系列绑定纵向用例,关联分配记录、来源/目标店铺、系列和必要设备关系。【主:复杂写或简单写事务脚本|边界:卡归属与系列|不迁移:卡状态/Gateway|验证:分配/回收/拒绝、多资源、主体活动和旧写归零核对通过,LSP 无诊断】
|
||||
- [ ] 6.3 迁移 IoT 卡停复机、实名策略/状态、限速和人工刷新纵向用例,实际外部尝试写 Integration Log,内部状态变化写 Audit Event,unknown 不伪装失败或成功。【主:复杂写 + 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/Application|辅:Outbox/Query|边界:卡换货创建至完成/取消/迁移|不迁移:设备换货|验证:资金/套餐/客户迁移、失败回滚、多资源时间线和外部安全结论核对通过,LSP 无诊断】
|
||||
- [ ] 6.11 迁移设备换货完整用例,记录旧/新设备 VirtualNo+IMEI+SN、实际绑定卡/卡槽、客户绑定、钱包/流水、套餐权益、店铺和换货单。【主:复杂写 Domain/Application|辅:Outbox/Query|边界:设备换货完整流程|不迁移:无关订单规则|验证:多卡设备换货、资金/套餐/客户迁移、失败回滚和所有资源时间线核对通过,LSP 无诊断】
|
||||
|
||||
## 7. 套餐、交易与资金纵向迁移
|
||||
|
||||
- [ ] 7.1 迁移套餐系列、套餐商品、店铺系列/套餐分配、批量定价和关键价格配置纵向用例,关联店铺、系列、套餐和价格历史,保持现有上架/分配规则。【主:简单写 + Application|边界:套餐配置与授权|不迁移:套餐购买流程|验证:before/after、批量根子、店铺时间线、凭据规则和旧 account 借用写归零测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.2 迁移套餐权益激活、排队、流量重置/扣减、退款失效、资产失效和换货迁移纵向用例,系统任务使用真实 actor,权益、订单、套餐和资产作为独立资源。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:package_usage 生命周期|不迁移:套餐列表 Query|验证:状态条件、重复任务幂等、系统 actor、父子链路和资源时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.3 迁移订单创建、后台代购、C 端/OpenAPI 购买、取消、钱包支付和过期关闭纵向用例,关联买家/操作者、资产、套餐、金额、支付方式、钱包/流水和购买角色。【主:复杂写 Domain/Application|辅:Outbox|边界:订单状态与支付准备|不迁移:订单只读列表|验证:各入口 actor、成功/拒绝/失败、资金同事务、系统过期和链路测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.4 迁移支付创建、微信/支付宝/富友预下单、查单和回调确认纵向用例:每次外部尝试写 Integration Log,实际改变支付/订单/充值事实时写 Audit Event,关联支付单、业务单、渠道交易号和 correlation。【主:复杂写 + Infrastructure Adapter|边界:支付外部与内部终态|不迁移:渠道协议重构|验证:success/unknown/迟到回调/重复回调、series/correlation、Domain Ledger 和时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.5 迁移退款申请、审批终态、钱包回充、佣金失效、套餐/资产后处理和通知纵向用例,关联退款、审批、订单、资产、钱包、原扣款/退款流水、佣金和套餐权益,资金事实与审计同事务。【主:复杂写 Domain/Application|辅:Outbox/Integration|边界:退款完整业务链|不迁移:原路退款等未实现能力|验证:通过/拒绝/重提/重复终态/失败回滚、资金时间线和代理安全结论测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.6 迁移个人资产充值和代理在线/线下充值纵向用例,关联充值单、提交人、店铺/资产、支付或审批、钱包、交易流水和自动购包;外部回调与系统恢复使用真实 actor。【主:复杂写 Domain/Application|辅:Payment/Approval/Outbox|边界:充值创建至入账|不迁移:新充值渠道|验证:在线/线下、回调/审批、重复入账、unknown、资金同事务和主体投影测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.7 迁移代理主钱包订单扣款和预占/释放/完成纵向用例,关联订单、钱包、预占、唯一流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:订单资金占用与扣款|不迁移:充值/退款/信用额度|验证:乐观锁/状态条件、唯一业务键、审计失败回滚和失败短事务测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.8 迁移代理主钱包充值入账、人工调整、退款回充和信用额度纵向用例,逐项关联业务单、原流水/新流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:代理钱包正向及回退资金|不迁移:资产钱包与佣金提现|验证:重复入账/退款上限/人工原因/信用变更、同事务和资金查询测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.9 迁移卡/设备资产钱包充值、扣款、退款和换货迁移纵向用例,关联资产完整标识、钱包、业务单和唯一流水。【主:复杂写 Domain/Application|边界:资产钱包资金|不迁移:代理主钱包|验证:卡/设备、换货、重复业务键、余额前后值和资源时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.10 迁移佣金计算/入账/失效和提现申请/审批/驳回纵向用例,关联店铺、订单、系列、佣金记录、提现单、钱包/流水和金额状态。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:佣金与提现完整状态机|不迁移:统计 Query|验证:计算幂等、审批终态、资金同事务、失败短事务和资金时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 7.1 迁移套餐系列、套餐商品、店铺系列/套餐分配、批量定价和关键价格配置纵向用例,关联店铺、系列、套餐和价格历史,保持现有上架/分配规则。【主:简单写 + Application|边界:套餐配置与授权|不迁移:套餐购买流程|验证:before/after、批量根子、店铺时间线、凭据规则和旧 account 借用写归零核对通过,LSP 无诊断】
|
||||
- [ ] 7.2 迁移套餐权益激活、排队、流量重置/扣减、退款失效、资产失效和换货迁移纵向用例,系统任务使用真实 actor,权益、订单、套餐和资产作为独立资源。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:package_usage 生命周期|不迁移:套餐列表 Query|验证:状态条件、重复任务幂等、系统 actor、父子链路和资源时间线核对通过,LSP 无诊断】
|
||||
- [ ] 7.3 迁移订单创建、后台代购、C 端/OpenAPI 购买、取消、钱包支付和过期关闭纵向用例,关联买家/操作者、资产、套餐、金额、支付方式、钱包/流水和购买角色。【主:复杂写 Domain/Application|辅:Outbox|边界:订单状态与支付准备|不迁移:订单只读列表|验证:各入口 actor、成功/拒绝/失败、资金同事务、系统过期和链路核对通过,LSP 无诊断】
|
||||
- [ ] 7.4 迁移支付创建、微信/支付宝/富友预下单、查单和回调确认纵向用例:每次外部尝试写 Integration Log,实际改变支付/订单/充值事实时写 Audit Event,关联支付单、业务单、渠道交易号和 correlation。【主:复杂写 + Infrastructure Adapter|边界:支付外部与内部终态|不迁移:渠道协议重构|验证:success/unknown/迟到回调/重复回调、series/correlation、Domain Ledger 和时间线核对通过,LSP 无诊断】
|
||||
- [ ] 7.5 迁移退款申请、审批终态、钱包回充、佣金失效、套餐/资产后处理和通知纵向用例,关联退款、审批、订单、资产、钱包、原扣款/退款流水、佣金和套餐权益,资金事实与审计同事务。【主:复杂写 Domain/Application|辅:Outbox/Integration|边界:退款完整业务链|不迁移:原路退款等未实现能力|验证:通过/拒绝/重提/重复终态/失败回滚、资金时间线和代理安全结论核对通过,LSP 无诊断】
|
||||
- [ ] 7.6 迁移个人资产充值和代理在线/线下充值纵向用例,关联充值单、提交人、店铺/资产、支付或审批、钱包、交易流水和自动购包;外部回调与系统恢复使用真实 actor。【主:复杂写 Domain/Application|辅:Payment/Approval/Outbox|边界:充值创建至入账|不迁移:新充值渠道|验证:在线/线下、回调/审批、重复入账、unknown、资金同事务和主体投影核对通过,LSP 无诊断】
|
||||
- [ ] 7.7 迁移代理主钱包订单扣款和预占/释放/完成纵向用例,关联订单、钱包、预占、唯一流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:订单资金占用与扣款|不迁移:充值/退款/信用额度|验证:乐观锁/状态条件、唯一业务键、审计失败回滚和失败短事务核对通过,LSP 无诊断】
|
||||
- [ ] 7.8 迁移代理主钱包充值入账、人工调整、退款回充和信用额度纵向用例,逐项关联业务单、原流水/新流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:代理钱包正向及回退资金|不迁移:资产钱包与佣金提现|验证:重复入账/退款上限/人工原因/信用变更、同事务和资金查询核对通过,LSP 无诊断】
|
||||
- [ ] 7.9 迁移卡/设备资产钱包充值、扣款、退款和换货迁移纵向用例,关联资产完整标识、钱包、业务单和唯一流水。【主:复杂写 Domain/Application|边界:资产钱包资金|不迁移:代理主钱包|验证:卡/设备、换货、重复业务键、余额前后值和资源时间线核对通过,LSP 无诊断】
|
||||
- [ ] 7.10 迁移佣金计算/入账/失效和提现申请/审批/驳回纵向用例,关联店铺、订单、系列、佣金记录、提现单、钱包/流水和金额状态。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:佣金与提现完整状态机|不迁移:统计 Query|验证:计算幂等、审批终态、资金同事务、失败短事务和资金时间线核对通过,LSP 无诊断】
|
||||
|
||||
## 8. 审批、配置、批量与自动入口迁移
|
||||
|
||||
- [ ] 8.1 迁移通用审批和企微申请提交、回调同步、主动恢复及终态分发纵向用例,记录真实提交人、外部系统/系统任务 actor、审批实例、业务单、Integration Log 和 Outbox,不伪造本地审批人。【主:复杂写 Domain/Application|辅:WeCom Adapter/Outbox|边界:审批完整链路|不迁移:新审批引擎|验证:提交、unknown、回调、恢复、重复终态、actor/correlation 和资源时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 8.2 迁移支付配置、运营商、企微应用/成员/场景及其他关键连接配置纵向用例,记录配置身份、状态和“凭据是否已配置”,不记录 Secret、Token、AESKey、私钥或证书正文。【主:简单写 + Infrastructure Adapter|边界:外部连接配置|不迁移:配置 UI 与渠道业务协议|验证:CRUD/启停/校验失败、同事务、凭据删除和平台完整业务字段测试 PASS,LSP 无诊断】
|
||||
- [ ] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计,审计中心自身仍不提供导出。【主:Application + Asynq|辅:Object Storage|边界:现有导入批量和业务导出任务动作|不迁移:导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线测试 PASS,LSP 无诊断】
|
||||
- [ ] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action,系统清理使用系统 actor,Audit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker|边界:通知状态变化|不迁移:通知查询|验证:人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联测试 PASS,LSP 无诊断】
|
||||
- [ ] 8.5 迁移轮询配置、并发配置、告警规则、人工触发/取消和状态变化纵向用例;手动轮询表继续承担进度/结果,实际 Gateway 尝试写 Integration Log,人工触发动作另写 Audit Event。【主:Application + Adapter|辅:Scheduler/Query|边界:轮询配置与人工动作|不迁移:删除手动任务表和普通运行查询|验证:配置/触发/拒绝、任务 ledger 保留、Integration/Audit 分界和 actor 测试 PASS,LSP 无诊断】
|
||||
- [ ] 8.6 迁移支付、运营商实名和企微等外部 Callback 中实际改变内部事实的入口,使用 external actor;无状态变化只保留 Integration Log。【主:Application/Infrastructure Adapter|边界:当前外部回调|不迁移:渠道协议|验证:逐回调 action、幂等、资源解析、Integration/Audit 分界和链路测试 PASS,LSP 无诊断】
|
||||
- [ ] 8.1 迁移通用审批和企微申请提交、回调同步、主动恢复及终态分发纵向用例,记录真实提交人、外部系统/系统任务 actor、审批实例、业务单、Integration Log 和 Outbox,不伪造本地审批人。【主:复杂写 Domain/Application|辅:WeCom Adapter/Outbox|边界:审批完整链路|不迁移:新审批引擎|验证:提交、unknown、回调、恢复、重复终态、actor/correlation 和资源时间线核对通过,LSP 无诊断】
|
||||
- [ ] 8.2 迁移支付配置、运营商、企微应用/成员/场景及其他关键连接配置纵向用例,记录配置身份、状态和“凭据是否已配置”,不记录 Secret、Token、AESKey、私钥或证书正文。【主:简单写 + Infrastructure Adapter|边界:外部连接配置|不迁移:配置 UI 与渠道业务协议|验证:CRUD/启停/校验失败、同事务、凭据删除和平台完整业务字段核对通过,LSP 无诊断】
|
||||
- [ ] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计,审计中心自身仍不提供导出。【主:Application + Asynq|辅:Object Storage|边界:现有导入批量和业务导出任务动作|不迁移:导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线核对通过,LSP 无诊断】
|
||||
- [ ] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action,系统清理使用系统 actor,Audit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker|边界:通知状态变化|不迁移:通知查询|验证:人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联核对通过,LSP 无诊断】
|
||||
- [ ] 8.5 迁移轮询配置、并发配置、告警规则、人工触发/取消和状态变化纵向用例;手动轮询表继续承担进度/结果,实际 Gateway 尝试写 Integration Log,人工触发动作另写 Audit Event。【主:Application + Adapter|辅:Scheduler/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 覆盖、幂等、失败重试和未登记动作门禁 PASS,LSP 无诊断】
|
||||
- [ ] 8.8 迁移当前 Scheduler 中实际创建任务、改变配置/状态或产生业务事实的入口,使用 scheduled_job actor;实施时以当前清单为准,不硬编码历史数量。【主:Application/Scheduler|边界:当前计划任务|不迁移:Worker 消费逻辑|验证:逐 Scheduler 覆盖、重复调度幂等、父子链路和 N/A 理由 PASS,LSP 无诊断】
|
||||
- [ ] 8.9 迁移当前 Outbox 消费者中实际形成新业务事实的入口,保留 Outbox 投递事实并为内部变化写 system_task Audit Event,不把投递成功伪装成业务成功。【主:Application/Outbox Consumer|边界:当前可靠事件消费者|不迁移:Relay 实现|验证:逐消费者覆盖、至少一次幂等、业务/投递结果分离和 correlation 测试 PASS,LSP 无诊断】
|
||||
- [ ] 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 存储与关系图|验证:支付、退款、审批、异步链路、历史缺字段和稳定排序测试 PASS,LSP 无诊断】
|
||||
- [ ] 9.2 交付资金调查时间线,按店铺、钱包、订单、支付、退款、充值、审批、交易号、操作者、时间和 correlation 查询,并明确金额权威来自钱包流水及业务表。【主:Query|边界:资金多源投影|不迁移:资金重算或状态修改|验证:余额变化、退款、充值、佣金、来源冲突和权限身份测试 PASS,LSP 无诊断】
|
||||
- [ ] 9.3 交付风险 overview/events,聚合高风险、资金、安全、失败、拒绝、partial 和 unknown,支持跳转事件、资源、actor 和 correlation;不建设处置工单或自动封禁。【主:Query|边界:固定风险调查视角|不迁移:风控决策系统|验证:计数/趋势、低风险排除、跳转和时间范围性能测试 PASS,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 和并发分页测试 PASS,LSP 无诊断】
|
||||
- [ ] 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 索引和构建检查 PASS,LSP 无诊断】
|
||||
|
||||
## 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、空日/大日、事件资源计数、对象损坏、重复投递、存储故障和业务写入隔离测试 PASS,LSP 无诊断】
|
||||
- [ ] 10.2 交付 Integration Log 每日归档及月度最终 revision 纵向切片:每日保存创建日快照,月初按数据库当前内容复核可变记录,内容变化时创建不可变新 revision,pending 或不一致阻止清理,不覆盖旧对象。【主:Infrastructure + Application/Asynq|边界:Integration 冷归档最终性|不迁移:Integration Writer 语义、恢复和对象存储查询|验证:pending→success/unknown、跨月更新、hash 变化、重复复核、无法终结和对象版本测试 PASS,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 全部阻断,物理行数归零、批次中断续跑、窗口隔离、对象长期保留、清理事件和数据库膨胀指标测试 PASS,LSP 无诊断】
|
||||
- [ ] 10.4 为平台 Audit/Integration 及代理/企业活动 DTO 增加 `retention{online_from,archived_before,timezone}`,显式时间范围早于或跨越在线边界时返回稳定已归档错误;默认只查在线窗口,ID-only 不存在仍按资源不存在处理,不访问对象存储,也不新增下载或恢复路由。【主:Query/API|边界:在线查询留存语义|不迁移:冷热联合查询和前端页面实现|验证:在线、已归档、跨边界、默认范围、空在线月、路由/OpenAPI 无归档读写能力测试 PASS,LSP 无诊断】
|
||||
- [ ] 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 纵向切片:每日保存创建日快照,月初按数据库当前内容复核可变记录,内容变化时创建不可变新 revision,pending 或不一致阻止清理,不覆盖旧对象。【主: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 无旧表依赖、真实业务写入和旧表无新增测试 PASS,LSP 无诊断】
|
||||
- [ ] 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
Reference in New Issue
Block a user