From b3499adfca1471613ed9ccfde42366b301255c23 Mon Sep 17 00:00:00 2001 From: break Date: Mon, 3 Aug 2026 09:47:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E5=8C=96=E4=B8=83=E6=9C=88=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3=E5=AE=A1=E8=AE=A1=E6=B2=BB=E7=90=86=E8=BF=9B=E5=B1=95?= =?UTF-8?q?=E4=BB=A5=E9=9A=94=E7=A6=BB=E7=BA=BF=E4=B8=8A=E7=83=AD=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 参数类型错误未通过。 --- .scratch/tech-global-audit/审计覆盖基线.md | 73 +- .scratch/tech-global-audit/审计覆盖清单.json | 7288 +++++++++++++---- README.md | 5 + cmd/api/docs.go | 1 + cmd/gendocs/main.go | 1 + docs/admin-openapi.yaml | 1841 ++++- .../主体资源活动接口.md | 30 + .../外部集成调查接口.md | 26 + .../平台基础审计调查接口.md | 59 + .../ur38-agent-main-wallet-credit/功能总结.md | 2 +- internal/application/accessaudit/change.go | 223 + .../application/accountaudit/lifecycle.go | 46 + .../agentrecharge/confirm_online_payment.go | 5 + internal/application/outbox/recovery.go | 109 +- internal/application/role/default_credit.go | 44 +- internal/application/shop/create.go | 93 +- internal/application/shop/update.go | 159 +- internal/application/systemconfig/update.go | 72 +- internal/application/wallet/change_credit.go | 12 +- internal/application/wecom/connection.go | 69 +- internal/bootstrap/dependencies.go | 2 +- internal/bootstrap/handlers.go | 21 +- internal/bootstrap/services.go | 35 +- internal/bootstrap/types.go | 1 + internal/governance/auditcoverage/scanner.go | 128 +- internal/handler/admin/asset.go | 4 + internal/handler/admin/audit.go | 265 + internal/handler/admin/shop.go | 2 +- internal/handler/callback/carrier_switch.go | 10 + internal/handler/callback/cmcc_realname.go | 2 +- internal/handler/callback/ctcc_realname.go | 2 +- internal/handler/callback/cucc_realname.go | 2 +- .../handler/callback/cucc_realname_removal.go | 2 +- internal/handler/callback/payment.go | 30 +- internal/infrastructure/audit/batch.go | 41 + internal/infrastructure/audit/registry.go | 410 + .../infrastructure/audit/security_test.go | 57 + internal/infrastructure/audit/writer.go | 1098 +++ .../integrationlog/repository.go | 77 +- .../infrastructure/messaging/outbox/relay.go | 9 +- .../messaging/outbox/repository.go | 17 +- .../payment/agent_recharge_event.go | 3 +- .../payment/agent_recharge_recovery_task.go | 12 +- internal/infrastructure/payment/alipay_wap.go | 2 +- internal/infrastructure/payment/wechat_web.go | 2 +- .../wecom/approval_attachment_uploader.go | 7 +- .../wecom/approval_detail_client.go | 9 +- .../wecom/approval_detail_task.go | 15 +- .../wecom/approval_info_client.go | 8 +- .../wecom/approval_submission_client.go | 3 +- .../infrastructure/wecom/callback_service.go | 8 +- .../infrastructure/wecom/directory_client.go | 12 +- .../wecom/integration_linkage.go | 11 + .../infrastructure/wecom/template_client.go | 6 +- .../infrastructure/wecom/token_provider.go | 6 +- internal/middleware/agent_open_api_auth.go | 9 + internal/model/audit_event.go | 76 + internal/model/dto/agent_recharge_dto.go | 2 +- internal/model/dto/audit_dto.go | 113 + internal/model/dto/shop_dto.go | 1 - internal/model/dto/shop_dto_test.go | 18 + internal/model/integration_log.go | 2 +- internal/model/outbox_event.go | 1 + internal/query/audit/actors.go | 49 + internal/query/audit/events.go | 396 + internal/query/audit/resources.go | 247 + internal/query/audit/subject_activities.go | 395 + internal/query/integration/logs.go | 389 + internal/query/integration/overview.go | 171 + internal/routes/admin.go | 3 + internal/routes/asset.go | 4 +- internal/routes/audit.go | 73 + internal/service/account/service.go | 863 +- internal/service/auth/service.go | 138 +- internal/service/client_auth/service.go | 343 +- internal/service/device/batch_audit.go | 117 + internal/service/device/service.go | 8 +- internal/service/enterprise/service.go | 409 +- .../enterprise_card/authorization_service.go | 105 +- internal/service/enterprise_card/service.go | 195 +- internal/service/enterprise_device/service.go | 806 +- internal/service/iot_card/speed_tier.go | 10 +- internal/service/permission/service.go | 210 +- internal/service/personal_customer/service.go | 103 +- internal/service/role/service.go | 330 +- internal/service/shop/service.go | 109 +- internal/service/shop/shop_role.go | 281 +- internal/task/device_batch_allocation.go | 6 + internal/task/polling_integration_log.go | 9 +- migrations/000199_create_audit_event.down.sql | 18 + migrations/000199_create_audit_event.up.sql | 154 + ...000200_add_outbox_parent_event_id.down.sql | 4 + .../000200_add_outbox_parent_event_id.up.sql | 9 + ...extend_integration_correlation_id.down.sql | 17 + ...1_extend_integration_correlation_id.up.sql | 5 + ...integration_investigation_indexes.down.sql | 25 + ...d_integration_investigation_indexes.up.sql | 35 + .../build-multi-view-audit-center/design.md | 6 +- .../build-multi-view-audit-center/proposal.md | 1 + .../build-multi-view-audit-center/tasks.md | 147 +- .../tasks.md | 4 +- .../.openspec.yaml | 2 + .../design.md | 120 + .../proposal.md | 36 + .../specs/package-queue-activation/spec.md | 99 + .../tasks.md | 29 + pkg/auditcontext/context.go | 84 + pkg/auditfailure/observer.go | 33 + pkg/constants/audit.go | 303 + pkg/constants/integration_log.go | 105 + pkg/logger/middleware.go | 12 +- pkg/middleware/auth.go | 23 + pkg/openapi/handlers.go | 1 + pkg/sanitizer/sanitizer.go | 3 + 114 files changed, 16961 insertions(+), 2782 deletions(-) create mode 100644 docs/feature-504-multi-view-audit-center/主体资源活动接口.md create mode 100644 docs/feature-504-multi-view-audit-center/外部集成调查接口.md create mode 100644 docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md create mode 100644 internal/application/accessaudit/change.go create mode 100644 internal/application/accountaudit/lifecycle.go create mode 100644 internal/handler/admin/audit.go create mode 100644 internal/infrastructure/audit/batch.go create mode 100644 internal/infrastructure/audit/registry.go create mode 100644 internal/infrastructure/audit/security_test.go create mode 100644 internal/infrastructure/audit/writer.go create mode 100644 internal/infrastructure/wecom/integration_linkage.go create mode 100644 internal/model/audit_event.go create mode 100644 internal/model/dto/audit_dto.go create mode 100644 internal/model/dto/shop_dto_test.go create mode 100644 internal/query/audit/actors.go create mode 100644 internal/query/audit/events.go create mode 100644 internal/query/audit/resources.go create mode 100644 internal/query/audit/subject_activities.go create mode 100644 internal/query/integration/logs.go create mode 100644 internal/query/integration/overview.go create mode 100644 internal/routes/audit.go create mode 100644 internal/service/device/batch_audit.go create mode 100644 migrations/000199_create_audit_event.down.sql create mode 100644 migrations/000199_create_audit_event.up.sql create mode 100644 migrations/000200_add_outbox_parent_event_id.down.sql create mode 100644 migrations/000200_add_outbox_parent_event_id.up.sql create mode 100644 migrations/000201_extend_integration_correlation_id.down.sql create mode 100644 migrations/000201_extend_integration_correlation_id.up.sql create mode 100644 migrations/000202_add_integration_investigation_indexes.down.sql create mode 100644 migrations/000202_add_integration_investigation_indexes.up.sql create mode 100644 openspec/changes/fix-package-activation-starvation/.openspec.yaml create mode 100644 openspec/changes/fix-package-activation-starvation/design.md create mode 100644 openspec/changes/fix-package-activation-starvation/proposal.md create mode 100644 openspec/changes/fix-package-activation-starvation/specs/package-queue-activation/spec.md create mode 100644 openspec/changes/fix-package-activation-starvation/tasks.md create mode 100644 pkg/auditcontext/context.go create mode 100644 pkg/auditfailure/observer.go create mode 100644 pkg/constants/audit.go diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index b7e19f4..6be7be4 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -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 清单、动作编码和测试接缝能由对应迁移票落地。 - 安全评审:风险等级、敏感字段策略、拒绝/失败覆盖和外部正文摘要策略完整。 -评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。 +后续评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。 diff --git a/.scratch/tech-global-audit/审计覆盖清单.json b/.scratch/tech-global-audit/审计覆盖清单.json index 7c0a4cd..4efe40d 100644 --- a/.scratch/tech-global-audit/审计覆盖清单.json +++ b/.scratch/tech-global-audit/审计覆盖清单.json @@ -1,8 +1,272 @@ [ { - "key": "application:internal/application/outbox/recovery.go:102:ReleaseExpiredLeases", + "key": "application:internal/application/agentrecharge/approval_decision.go:29:Handle", "kind": "application", - "code_entry": "internal/application/outbox/recovery.go:102 ReleaseExpiredLeases", + "code_entry": "internal/application/agentrecharge/approval_decision.go:29 Handle", + "owner": "agentrecharge", + "summary": "业务方法 Handle", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "agentrecharge.handle", + "action_name": "执行业务方法(Handle)", + "category": "finance", + "risk": "high", + "primary_resource": "agentrecharge", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/agentrecharge/confirm_online_payment.go:75:Execute", + "kind": "application", + "code_entry": "internal/application/agentrecharge/confirm_online_payment.go:75 Execute", + "owner": "agentrecharge", + "summary": "业务方法 Execute", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "agentrecharge.execute", + "action_name": "执行业务方法(Execute)", + "category": "finance", + "risk": "high", + "primary_resource": "agentrecharge", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/agentrecharge/offline_creation.go:49:Execute", + "kind": "application", + "code_entry": "internal/application/agentrecharge/offline_creation.go:49 Execute", + "owner": "agentrecharge", + "summary": "业务方法 Execute", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "agentrecharge.execute", + "action_name": "执行业务方法(Execute)", + "category": "finance", + "risk": "high", + "primary_resource": "agentrecharge", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/agentrecharge/online_creation.go:60:Execute", + "kind": "application", + "code_entry": "internal/application/agentrecharge/online_creation.go:60 Execute", + "owner": "agentrecharge", + "summary": "业务方法 Execute", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "agentrecharge.execute", + "action_name": "执行业务方法(Execute)", + "category": "finance", + "risk": "high", + "primary_resource": "agentrecharge", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/agentrecharge/recover_online_payment.go:29:ProcessBatch", + "kind": "application", + "code_entry": "internal/application/agentrecharge/recover_online_payment.go:29 ProcessBatch", + "owner": "agentrecharge", + "summary": "业务方法 ProcessBatch", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "agentrecharge.process_batch", + "action_name": "执行业务方法(ProcessBatch)", + "category": "finance", + "risk": "high", + "primary_resource": "agentrecharge", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/approval/create.go:125:CreateContextInTx", + "kind": "application", + "code_entry": "internal/application/approval/create.go:125 CreateContextInTx", + "owner": "approval", + "summary": "业务方法 CreateContextInTx", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "approval.create_context_in_tx", + "action_name": "执行业务方法(CreateContextInTx)", + "category": "business", + "risk": "normal", + "primary_resource": "approval", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/approval/create.go:64:CreateInTx", + "kind": "application", + "code_entry": "internal/application/approval/create.go:64 CreateInTx", + "owner": "approval", + "summary": "业务方法 CreateInTx", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "approval.create_in_tx", + "action_name": "执行业务方法(CreateInTx)", + "category": "business", + "risk": "normal", + "primary_resource": "approval", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/approval/sync_decision.go:83:Execute", + "kind": "application", + "code_entry": "internal/application/approval/sync_decision.go:83 Execute", + "owner": "approval", + "summary": "业务方法 Execute", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "approval.execute", + "action_name": "执行业务方法(Execute)", + "category": "business", + "risk": "normal", + "primary_resource": "approval", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/cardobservation/series.go:124:Trigger", + "kind": "application", + "code_entry": "internal/application/cardobservation/series.go:124 Trigger", + "owner": "cardobservation", + "summary": "业务方法 Trigger", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "cardobservation.trigger", + "action_name": "执行业务方法(Trigger)", + "category": "asset", + "risk": "normal", + "primary_resource": "cardobservation", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/cardobservation/series.go:129:TriggerEvent", + "kind": "application", + "code_entry": "internal/application/cardobservation/series.go:129 TriggerEvent", + "owner": "cardobservation", + "summary": "业务方法 TriggerEvent", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "cardobservation.trigger_event", + "action_name": "执行业务方法(TriggerEvent)", + "category": "asset", + "risk": "normal", + "primary_resource": "cardobservation", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/cardobservation/series.go:201:Execute", + "kind": "application", + "code_entry": "internal/application/cardobservation/series.go:201 Execute", + "owner": "cardobservation", + "summary": "业务方法 Execute", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "cardobservation.execute", + "action_name": "执行业务方法(Execute)", + "category": "asset", + "risk": "normal", + "primary_resource": "cardobservation", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/outbox/recovery.go:146:ReleaseExpiredLeases", + "kind": "application", + "code_entry": "internal/application/outbox/recovery.go:146 ReleaseExpiredLeases", "owner": "outbox", "summary": "业务方法 ReleaseExpiredLeases", "audit_event": "必须", @@ -16,6 +280,7 @@ "primary_resource": "outbox", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -23,9 +288,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "application:internal/application/outbox/recovery.go:62:Replay", + "key": "application:internal/application/outbox/recovery.go:88:Replay", "kind": "application", - "code_entry": "internal/application/outbox/recovery.go:62 Replay", + "code_entry": "internal/application/outbox/recovery.go:88 Replay", "owner": "outbox", "summary": "业务方法 Replay", "audit_event": "必须", @@ -39,6 +304,7 @@ "primary_resource": "outbox", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -46,9 +312,105 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "application:internal/application/systemconfig/update.go:76:Execute", + "key": "application:internal/application/refundapproval/creation.go:43:Execute", "kind": "application", - "code_entry": "internal/application/systemconfig/update.go:76 Execute", + "code_entry": "internal/application/refundapproval/creation.go:43 Execute", + "owner": "refundapproval", + "summary": "业务方法 Execute", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "refundapproval.execute", + "action_name": "执行业务方法(Execute)", + "category": "finance", + "risk": "high", + "primary_resource": "refundapproval", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/role/default_credit.go:31:Update", + "kind": "application", + "code_entry": "internal/application/role/default_credit.go:31 Update", + "owner": "role", + "summary": "业务方法 Update", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "role.update_default_credit", + "action_name": "更新客户角色的新建代理默认信用模板", + "category": "security", + "risk": "high", + "primary_resource": "role", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/shop/create.go:29:Create", + "kind": "application", + "code_entry": "internal/application/shop/create.go:29 Create", + "owner": "shop", + "summary": "业务方法 Create", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "shop.create", + "action_name": "创建店铺", + "category": "business", + "risk": "normal", + "primary_resource": "shop", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/shop/update.go:27:Update", + "kind": "application", + "code_entry": "internal/application/shop/update.go:27 Update", + "owner": "shop", + "summary": "业务方法 Update", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "shop.update", + "action_name": "更新店铺基础资料", + "category": "business", + "risk": "normal", + "primary_resource": "shop", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/systemconfig/update.go:68:Execute", + "kind": "application", + "code_entry": "internal/application/systemconfig/update.go:68 Execute", "owner": "systemconfig", "summary": "业务方法 Execute", "audit_event": "必须", @@ -62,6 +424,7 @@ "primary_resource": "systemconfig", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -69,9 +432,351 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/account.go:103:GET:/:id/roles", + "key": "application:internal/application/wallet/change_credit.go:26:Execute", + "kind": "application", + "code_entry": "internal/application/wallet/change_credit.go:26 Execute", + "owner": "wallet", + "summary": "业务方法 Execute", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wallet.execute", + "action_name": "执行业务方法(Execute)", + "category": "finance", + "risk": "high", + "primary_resource": "wallet", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wallet/debit.go:82:DebitInTx", + "kind": "application", + "code_entry": "internal/application/wallet/debit.go:82 DebitInTx", + "owner": "wallet", + "summary": "业务方法 DebitInTx", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wallet.debit_in_tx", + "action_name": "执行业务方法(DebitInTx)", + "category": "finance", + "risk": "high", + "primary_resource": "wallet", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wallet/refund.go:89:RefundInTx", + "kind": "application", + "code_entry": "internal/application/wallet/refund.go:89 RefundInTx", + "owner": "wallet", + "summary": "业务方法 RefundInTx", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wallet.refund_in_tx", + "action_name": "执行业务方法(RefundInTx)", + "category": "finance", + "risk": "high", + "primary_resource": "wallet", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wallet/reservation.go:108:ReleaseInTx", + "kind": "application", + "code_entry": "internal/application/wallet/reservation.go:108 ReleaseInTx", + "owner": "wallet", + "summary": "业务方法 ReleaseInTx", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wallet.release_in_tx", + "action_name": "执行业务方法(ReleaseInTx)", + "category": "finance", + "risk": "high", + "primary_resource": "wallet", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wallet/reservation.go:70:FreezeInTx", + "kind": "application", + "code_entry": "internal/application/wallet/reservation.go:70 FreezeInTx", + "owner": "wallet", + "summary": "业务方法 FreezeInTx", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wallet.freeze_in_tx", + "action_name": "执行业务方法(FreezeInTx)", + "category": "finance", + "risk": "high", + "primary_resource": "wallet", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wecom/connection.go:242:SaveDefaultCreator", + "kind": "application", + "code_entry": "internal/application/wecom/connection.go:242 SaveDefaultCreator", + "owner": "wecom", + "summary": "业务方法 SaveDefaultCreator", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wecom.save_default_creator", + "action_name": "执行业务方法(SaveDefaultCreator)", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wecom/connection.go:92:Save", + "kind": "application", + "code_entry": "internal/application/wecom/connection.go:92 Save", + "owner": "wecom", + "summary": "业务方法 Save", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wecom.save", + "action_name": "执行业务方法(Save)", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wecom/directory.go:52:Sync", + "kind": "application", + "code_entry": "internal/application/wecom/directory.go:52 Sync", + "owner": "wecom", + "summary": "业务方法 Sync", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wecom.sync", + "action_name": "执行业务方法(Sync)", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "application:internal/application/wecom/scene.go:110:Save", + "kind": "application", + "code_entry": "internal/application/wecom/scene.go:110 Save", + "owner": "wecom", + "summary": "业务方法 Save", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "wecom.save", + "action_name": "执行业务方法(Save)", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "domain:internal/domain/wallet/wallet.go:128:ChangeCredit", + "kind": "domain", + "code_entry": "internal/domain/wallet/wallet.go:128 ChangeCredit", + "owner": "wallet", + "summary": "业务方法 ChangeCredit", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "由 Application 组合根负责", + "failure_strategy": "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口", + "na_reason": "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入" + }, + { + "key": "domain:internal/domain/wallet/wallet.go:26:Debit", + "kind": "domain", + "code_entry": "internal/domain/wallet/wallet.go:26 Debit", + "owner": "wallet", + "summary": "业务方法 Debit", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "由 Application 组合根负责", + "failure_strategy": "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口", + "na_reason": "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入" + }, + { + "key": "domain:internal/domain/wallet/wallet.go:44:Credit", + "kind": "domain", + "code_entry": "internal/domain/wallet/wallet.go:44 Credit", + "owner": "wallet", + "summary": "业务方法 Credit", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "由 Application 组合根负责", + "failure_strategy": "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口", + "na_reason": "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入" + }, + { + "key": "domain:internal/domain/wallet/wallet.go:62:Freeze", + "kind": "domain", + "code_entry": "internal/domain/wallet/wallet.go:62 Freeze", + "owner": "wallet", + "summary": "业务方法 Freeze", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "由 Application 组合根负责", + "failure_strategy": "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口", + "na_reason": "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入" + }, + { + "key": "domain:internal/domain/wallet/wallet.go:80:Release", + "kind": "domain", + "code_entry": "internal/domain/wallet/wallet.go:80 Release", + "owner": "wallet", + "summary": "业务方法 Release", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "由 Application 组合根负责", + "failure_strategy": "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口", + "na_reason": "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入" + }, + { + "key": "http:internal/routes/account.go:102:POST:/:id/roles", "kind": "http", - "code_entry": "internal/routes/account.go:103 h.GetRoles", + "code_entry": "internal/routes/account.go:102 h.AssignRoles", + "owner": "account", + "method": "POST", + "path": "/:id/roles", + "summary": "为账号分配角色", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "account.assign_roles", + "action_name": "为账号分配角色", + "category": "security", + "risk": "high", + "primary_resource": "account", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "参数错误不写 Audit Event;权限或全项规则拒绝写 denied 短事务;业务回滚后的 failed 使用独立短事务,二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/account.go:111:GET:/:id/roles", + "kind": "http", + "code_entry": "internal/routes/account.go:111 h.GetRoles", "owner": "account", "method": "GET", "path": "/:id/roles", @@ -81,6 +786,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -89,9 +795,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/account.go:112:DELETE:/:account_id/roles/:role_id", + "key": "http:internal/routes/account.go:120:DELETE:/:account_id/roles/:role_id", "kind": "http", - "code_entry": "internal/routes/account.go:112 h.RemoveRole", + "code_entry": "internal/routes/account.go:120 h.RemoveRole", "owner": "account", "method": "DELETE", "path": "/:account_id/roles/:role_id", @@ -107,8 +813,9 @@ "primary_resource": "account", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "failure_strategy": "参数错误不写 Audit Event;权限、全项规则拒绝或并发零命中写 denied 短事务;业务回滚后的 failed 使用独立短事务,二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" @@ -131,6 +838,7 @@ "primary_resource": "account", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -149,6 +857,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -169,6 +878,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -195,6 +905,7 @@ "primary_resource": "account", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -202,9 +913,35 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/account.go:67:DELETE:/:id", + "key": "http:internal/routes/account.go:66:PUT:/:id/wecom-binding", "kind": "http", - "code_entry": "internal/routes/account.go:67 h.Delete", + "code_entry": "internal/routes/account.go:66 h.BindWeCom", + "owner": "account", + "method": "PUT", + "path": "/:id/wecom-binding", + "summary": "绑定账号企业微信成员", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "account.bind_we_com", + "action_name": "绑定账号企业微信成员", + "category": "security", + "risk": "normal", + "primary_resource": "account", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/account.go:75:DELETE:/:id", + "kind": "http", + "code_entry": "internal/routes/account.go:75 h.Delete", "owner": "account", "method": "DELETE", "path": "/:id", @@ -220,6 +957,7 @@ "primary_resource": "account", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -227,9 +965,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/account.go:76:PUT:/:id/password", + "key": "http:internal/routes/account.go:84:PUT:/:id/password", "kind": "http", - "code_entry": "internal/routes/account.go:76 h.UpdatePassword", + "code_entry": "internal/routes/account.go:84 h.UpdatePassword", "owner": "account", "method": "PUT", "path": "/:id/password", @@ -245,6 +983,7 @@ "primary_resource": "account", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -252,9 +991,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/account.go:85:PUT:/:id/status", + "key": "http:internal/routes/account.go:93:PUT:/:id/status", "kind": "http", - "code_entry": "internal/routes/account.go:85 h.UpdateStatus", + "code_entry": "internal/routes/account.go:93 h.UpdateStatus", "owner": "account", "method": "PUT", "path": "/:id/status", @@ -270,31 +1009,7 @@ "primary_resource": "account", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, - { - "key": "http:internal/routes/account.go:94:POST:/:id/roles", - "kind": "http", - "code_entry": "internal/routes/account.go:94 h.AssignRoles", - "owner": "account", - "method": "POST", - "path": "/:id/roles", - "summary": "为账号分配角色", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "account.assign_roles", - "action_name": "为账号分配角色", - "category": "security", - "risk": "high", - "primary_resource": "account", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -319,6 +1034,7 @@ "primary_resource": "agent_recharge", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -337,6 +1053,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -345,9 +1062,51 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/agent_recharge.go:40:GET:/:id", + "key": "http:internal/routes/agent_recharge.go:41:GET:/payment-methods", "kind": "http", - "code_entry": "internal/routes/agent_recharge.go:40 handler.Get", + "code_entry": "internal/routes/agent_recharge.go:41 handler.PaymentMethods", + "owner": "agent_recharge", + "method": "GET", + "path": "/payment-methods", + "summary": "查询代理在线充值可用支付方式", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/agent_recharge.go:48:GET:/:id/payment-status", + "kind": "http", + "code_entry": "internal/routes/agent_recharge.go:48 handler.PaymentStatus", + "owner": "agent_recharge", + "method": "GET", + "path": "/:id/payment-status", + "summary": "查询代理充值本地支付与到账状态", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/agent_recharge.go:56:GET:/:id", + "kind": "http", + "code_entry": "internal/routes/agent_recharge.go:56 handler.Get", "owner": "agent_recharge", "method": "GET", "path": "/:id", @@ -357,6 +1116,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -365,9 +1125,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/agent_recharge.go:48:POST:/:id/offline-pay", + "key": "http:internal/routes/agent_recharge.go:64:POST:/:id/offline-pay", "kind": "http", - "code_entry": "internal/routes/agent_recharge.go:48 handler.OfflinePay", + "code_entry": "internal/routes/agent_recharge.go:64 handler.OfflinePay", "owner": "agent_recharge", "method": "POST", "path": "/:id/offline-pay", @@ -383,6 +1143,7 @@ "primary_resource": "agent_recharge", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -390,9 +1151,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/agent_recharge.go:56:POST:/:id/reject", + "key": "http:internal/routes/agent_recharge.go:72:POST:/:id/reject", "kind": "http", - "code_entry": "internal/routes/agent_recharge.go:56 handler.Reject", + "code_entry": "internal/routes/agent_recharge.go:72 handler.Reject", "owner": "agent_recharge", "method": "POST", "path": "/:id/reject", @@ -408,6 +1169,7 @@ "primary_resource": "agent_recharge", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -427,6 +1189,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -447,6 +1210,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -467,6 +1231,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -481,12 +1246,13 @@ "owner": "asset", "method": "GET", "path": "/:identifier/operation-logs", - "summary": "资产操作审计日志", + "summary": "查询平台旧资产操作日志", "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -513,6 +1279,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -538,6 +1305,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -563,6 +1331,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -582,6 +1351,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -597,17 +1367,23 @@ "method": "GET", "path": "/:identifier/realtime-status", "summary": "资产实时状态", - "audit_event": "N/A", + "audit_event": "必须", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "asset.realtime_status", + "action_name": "资产实时状态", + "category": "asset", + "risk": "normal", + "primary_resource": "asset", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", - "transaction": "N/A", - "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", - "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { "key": "http:internal/routes/asset.go:33:POST:/:identifier/refresh", @@ -628,6 +1404,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -647,6 +1424,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -667,6 +1445,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -693,6 +1472,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -718,6 +1498,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -743,6 +1524,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -768,6 +1550,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -793,6 +1576,7 @@ "primary_resource": "asset", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -811,6 +1595,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -831,6 +1616,285 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/asset_package_batch_order.go:13:POST:/asset-package-batch-orders", + "kind": "http", + "code_entry": "internal/routes/asset_package_batch_order.go:13 handler.Create", + "owner": "asset_package_batch_order", + "method": "POST", + "path": "/asset-package-batch-orders", + "summary": "创建资产套餐批量订购任务", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "asset_package_batch_order.create", + "action_name": "创建资产套餐批量订购任务", + "category": "finance", + "risk": "normal", + "primary_resource": "asset_package_batch_order", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/asset_package_batch_order.go:17:GET:/asset-package-batch-orders", + "kind": "http", + "code_entry": "internal/routes/asset_package_batch_order.go:17 handler.List", + "owner": "asset_package_batch_order", + "method": "GET", + "path": "/asset-package-batch-orders", + "summary": "查询资产套餐批量订购任务列表", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/asset_package_batch_order.go:21:GET:/asset-package-batch-orders/:id", + "kind": "http", + "code_entry": "internal/routes/asset_package_batch_order.go:21 handler.Get", + "owner": "asset_package_batch_order", + "method": "GET", + "path": "/asset-package-batch-orders/:id", + "summary": "查询资产套餐批量订购任务详情", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:16:GET:/:resource_type/:identifier", + "kind": "http", + "code_entry": "internal/routes/audit.go:16 handler.AgentResourceActivities", + "owner": "audit", + "method": "GET", + "path": "/:resource_type/:identifier", + "summary": "查询代理资源活动", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:22:GET:/:resource_type/:identifier", + "kind": "http", + "code_entry": "internal/routes/audit.go:22 handler.EnterpriseResourceActivities", + "owner": "audit", + "method": "GET", + "path": "/:resource_type/:identifier", + "summary": "查询企业资源活动", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:31:GET:/events", + "kind": "http", + "code_entry": "internal/routes/audit.go:31 handler.ListEvents", + "owner": "audit", + "method": "GET", + "path": "/events", + "summary": "查询全局审计事件", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:36:GET:/events/:event_id", + "kind": "http", + "code_entry": "internal/routes/audit.go:36 handler.GetEvent", + "owner": "audit", + "method": "GET", + "path": "/events/:event_id", + "summary": "查询审计事件详情", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:41:GET:/actors/:kind/:id/events", + "kind": "http", + "code_entry": "internal/routes/audit.go:41 handler.ListActorEvents", + "owner": "audit", + "method": "GET", + "path": "/actors/:kind/:id/events", + "summary": "查询操作者行为时间线", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:47:GET:/resources/search", + "kind": "http", + "code_entry": "internal/routes/audit.go:47 handler.SearchResources", + "owner": "audit", + "method": "GET", + "path": "/resources/search", + "summary": "精确搜索注册资源", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:52:GET:/resources/:resource_type/:resource_id/timeline", + "kind": "http", + "code_entry": "internal/routes/audit.go:52 handler.ResourceTimeline", + "owner": "audit", + "method": "GET", + "path": "/resources/:resource_type/:resource_id/timeline", + "summary": "查询通用资源时间线", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:58:GET:/integrations/overview", + "kind": "http", + "code_entry": "internal/routes/audit.go:58 handler.IntegrationOverview", + "owner": "audit", + "method": "GET", + "path": "/integrations/overview", + "summary": "查询外部集成交互总览", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:63:GET:/integrations", + "kind": "http", + "code_entry": "internal/routes/audit.go:63 handler.ListIntegrations", + "owner": "audit", + "method": "GET", + "path": "/integrations", + "summary": "查询外部集成交互列表", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/audit.go:68:GET:/integrations/:integration_id", + "kind": "http", + "code_entry": "internal/routes/audit.go:68 handler.GetIntegration", + "owner": "audit", + "method": "GET", + "path": "/integrations/:integration_id", + "summary": "查询外部集成交互详情", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "仅超级管理员和平台账号可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -857,6 +1921,7 @@ "primary_resource": "auth", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -882,6 +1947,7 @@ "primary_resource": "auth", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -907,6 +1973,7 @@ "primary_resource": "auth", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -926,6 +1993,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -952,6 +2020,7 @@ "primary_resource": "auth", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -970,6 +2039,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -990,6 +2060,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1016,6 +2087,7 @@ "primary_resource": "authorization", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1034,6 +2106,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1059,6 +2132,7 @@ "primary_resource": "carrier", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1078,6 +2152,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1104,6 +2179,7 @@ "primary_resource": "carrier", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1129,6 +2205,7 @@ "primary_resource": "carrier", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1154,6 +2231,7 @@ "primary_resource": "carrier", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1173,6 +2251,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1199,6 +2278,7 @@ "primary_resource": "commission", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1224,6 +2304,7 @@ "primary_resource": "commission", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1249,6 +2330,7 @@ "primary_resource": "commission", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1268,6 +2350,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1288,6 +2371,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1296,9 +2380,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/device.go:109:GET:/import/tasks", + "key": "http:internal/routes/device.go:118:GET:/import/tasks", "kind": "http", - "code_entry": "internal/routes/device.go:109 importHandler.List", + "code_entry": "internal/routes/device.go:118 importHandler.List", "owner": "device", "method": "GET", "path": "/import/tasks", @@ -1308,6 +2392,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1316,9 +2401,35 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/device.go:118:GET:/import/tasks/:id", + "key": "http:internal/routes/device.go:127:POST:/import/allocations", "kind": "http", - "code_entry": "internal/routes/device.go:118 importHandler.GetByID", + "code_entry": "internal/routes/device.go:127 importHandler.CreateAllocation", + "owner": "device", + "method": "POST", + "path": "/import/allocations", + "summary": "创建CSV设备批量分配或回收任务", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "device.create_allocation", + "action_name": "创建CSV设备批量分配或回收任务", + "category": "asset", + "risk": "normal", + "primary_resource": "device", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/device.go:136:GET:/import/tasks/:id", + "kind": "http", + "code_entry": "internal/routes/device.go:136 importHandler.GetByID", "owner": "device", "method": "GET", "path": "/import/tasks/:id", @@ -1328,6 +2439,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1336,9 +2448,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/device.go:127:PATCH:/series-binding", + "key": "http:internal/routes/device.go:145:PATCH:/series-binding", "kind": "http", - "code_entry": "internal/routes/device.go:127 handler.BatchSetSeriesBinding", + "code_entry": "internal/routes/device.go:145 handler.BatchSetSeriesBinding", "owner": "device", "method": "PATCH", "path": "/series-binding", @@ -1354,6 +2466,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1361,9 +2474,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:136:GET:/by-identifier/:identifier/gateway-slots", + "key": "http:internal/routes/device.go:154:GET:/by-identifier/:identifier/gateway-slots", "kind": "http", - "code_entry": "internal/routes/device.go:136 handler.GetGatewaySlots", + "code_entry": "internal/routes/device.go:154 handler.GetGatewaySlots", "owner": "device", "method": "GET", "path": "/by-identifier/:identifier/gateway-slots", @@ -1373,6 +2486,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1381,34 +2495,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/device.go:145:PUT:/by-identifier/:identifier/speed-limit", + "key": "http:internal/routes/device.go:163:PUT:/by-identifier/:identifier/wifi", "kind": "http", - "code_entry": "internal/routes/device.go:145 handler.SetSpeedLimit", - "owner": "device", - "method": "PUT", - "path": "/by-identifier/:identifier/speed-limit", - "summary": "设置限速", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "device.set_speed_limit", - "action_name": "设置限速", - "category": "asset", - "risk": "normal", - "primary_resource": "device", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, - { - "key": "http:internal/routes/device.go:154:PUT:/by-identifier/:identifier/wifi", - "kind": "http", - "code_entry": "internal/routes/device.go:154 handler.SetWiFi", + "code_entry": "internal/routes/device.go:163 handler.SetWiFi", "owner": "device", "method": "PUT", "path": "/by-identifier/:identifier/wifi", @@ -1424,31 +2513,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, - { - "key": "http:internal/routes/device.go:163:POST:/by-identifier/:identifier/switch-card", - "kind": "http", - "code_entry": "internal/routes/device.go:163 handler.SwitchCard", - "owner": "device", - "method": "POST", - "path": "/by-identifier/:identifier/switch-card", - "summary": "切卡", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "device.switch_card", - "action_name": "切卡", - "category": "asset", - "risk": "normal", - "primary_resource": "device", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1467,6 +2532,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1475,9 +2541,35 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/device.go:172:POST:/by-identifier/:identifier/switch-mode", + "key": "http:internal/routes/device.go:172:POST:/by-identifier/:identifier/switch-card", "kind": "http", - "code_entry": "internal/routes/device.go:172 handler.SetSwitchMode", + "code_entry": "internal/routes/device.go:172 handler.SwitchCard", + "owner": "device", + "method": "POST", + "path": "/by-identifier/:identifier/switch-card", + "summary": "切卡", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "device.switch_card", + "action_name": "切卡", + "category": "asset", + "risk": "normal", + "primary_resource": "device", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/device.go:181:POST:/by-identifier/:identifier/switch-mode", + "kind": "http", + "code_entry": "internal/routes/device.go:181 handler.SetSwitchMode", "owner": "device", "method": "POST", "path": "/by-identifier/:identifier/switch-mode", @@ -1493,6 +2585,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1500,9 +2593,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:181:POST:/by-identifier/:identifier/reboot", + "key": "http:internal/routes/device.go:190:POST:/by-identifier/:identifier/reboot", "kind": "http", - "code_entry": "internal/routes/device.go:181 handler.RebootDevice", + "code_entry": "internal/routes/device.go:190 handler.RebootDevice", "owner": "device", "method": "POST", "path": "/by-identifier/:identifier/reboot", @@ -1518,6 +2611,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1525,9 +2619,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:190:POST:/by-identifier/:identifier/reset", + "key": "http:internal/routes/device.go:199:POST:/by-identifier/:identifier/reset", "kind": "http", - "code_entry": "internal/routes/device.go:190 handler.ResetDevice", + "code_entry": "internal/routes/device.go:199 handler.ResetDevice", "owner": "device", "method": "POST", "path": "/by-identifier/:identifier/reset", @@ -1543,6 +2637,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1550,9 +2645,35 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:24:DELETE:/:virtual_no", + "key": "http:internal/routes/device.go:24:POST:/batch-update-realname-policy", "kind": "http", - "code_entry": "internal/routes/device.go:24 handler.Delete", + "code_entry": "internal/routes/device.go:24 handler.BatchUpdateRealnamePolicy", + "owner": "device", + "method": "POST", + "path": "/batch-update-realname-policy", + "summary": "批量更新设备实名认证策略", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "device.batch_update_realname_policy", + "action_name": "批量更新设备实名认证策略", + "category": "asset", + "risk": "normal", + "primary_resource": "device", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/device.go:33:DELETE:/:virtual_no", + "kind": "http", + "code_entry": "internal/routes/device.go:33 handler.Delete", "owner": "device", "method": "DELETE", "path": "/:virtual_no", @@ -1568,6 +2689,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1575,9 +2697,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:33:GET:/:virtual_no/cards", + "key": "http:internal/routes/device.go:42:GET:/:virtual_no/cards", "kind": "http", - "code_entry": "internal/routes/device.go:33 handler.ListCards", + "code_entry": "internal/routes/device.go:42 handler.ListCards", "owner": "device", "method": "GET", "path": "/:virtual_no/cards", @@ -1587,6 +2709,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1595,9 +2718,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/device.go:41:POST:/:virtual_no/cards", + "key": "http:internal/routes/device.go:50:POST:/:virtual_no/cards", "kind": "http", - "code_entry": "internal/routes/device.go:41 handler.BindCard", + "code_entry": "internal/routes/device.go:50 handler.BindCard", "owner": "device", "method": "POST", "path": "/:virtual_no/cards", @@ -1613,6 +2736,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1620,9 +2744,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:50:DELETE:/:virtual_no/cards/:iccid", + "key": "http:internal/routes/device.go:59:DELETE:/:virtual_no/cards/:iccid", "kind": "http", - "code_entry": "internal/routes/device.go:50 handler.UnbindCard", + "code_entry": "internal/routes/device.go:59 handler.UnbindCard", "owner": "device", "method": "DELETE", "path": "/:virtual_no/cards/:iccid", @@ -1638,6 +2762,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1645,9 +2770,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:59:POST:/allocate", + "key": "http:internal/routes/device.go:68:POST:/allocate", "kind": "http", - "code_entry": "internal/routes/device.go:59 handler.Allocate", + "code_entry": "internal/routes/device.go:68 handler.Allocate", "owner": "device", "method": "POST", "path": "/allocate", @@ -1663,6 +2788,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1670,9 +2796,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:68:POST:/recall", + "key": "http:internal/routes/device.go:77:POST:/recall", "kind": "http", - "code_entry": "internal/routes/device.go:68 handler.Recall", + "code_entry": "internal/routes/device.go:77 handler.Recall", "owner": "device", "method": "POST", "path": "/recall", @@ -1688,6 +2814,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1695,9 +2822,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/device.go:77:POST:/import", + "key": "http:internal/routes/device.go:86:POST:/import", "kind": "http", - "code_entry": "internal/routes/device.go:77 importHandler.Import", + "code_entry": "internal/routes/device.go:86 importHandler.Import", "owner": "device", "method": "POST", "path": "/import", @@ -1713,6 +2840,7 @@ "primary_resource": "device", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1735,8 +2863,9 @@ "category": "business", "risk": "normal", "primary_resource": "enterprise", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "affected_resource": "企业初始账号;归属店铺作为 reference", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1755,6 +2884,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1779,8 +2909,9 @@ "category": "business", "risk": "normal", "primary_resource": "enterprise", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "affected_resource": "归属店铺作为 reference;不改变 owner_shop_id", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1804,8 +2935,9 @@ "category": "business", "risk": "normal", "primary_resource": "enterprise", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "affected_resource": "实际同步状态的企业账号;归属店铺作为 reference", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1829,11 +2961,12 @@ "category": "business", "risk": "high", "primary_resource": "enterprise", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "affected_resource": "实际改密的企业账号;归属店铺作为 reference", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "sensitive_policy": "密码明文与散列均禁止进入审计;只保存 credentials_configured/state 安全事实", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, @@ -1856,6 +2989,7 @@ "primary_resource": "enterprise_card", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1881,6 +3015,7 @@ "primary_resource": "enterprise_card", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1900,6 +3035,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1923,11 +3059,12 @@ "action_name": "授权设备给企业", "category": "asset", "risk": "normal", - "primary_resource": "enterprise_device", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "primary_resource": "enterprise", + "affected_resource": "实际授权设备、设备授权记录及随设备授权的绑定卡/卡授权记录;owner shop 和卡槽绑定作为 reference", "actor_source": "登录账号快照/admin_api", + "visibility": "设备和卡仅保存 subject_result;授权记录与卡槽绑定 internal_only", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "failure_strategy": "参数错误不写 Audit Event;权限或全项规则拒绝写 denied 短事务;业务回滚后的 failed 使用独立短事务,二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" @@ -1948,11 +3085,12 @@ "action_name": "撤销设备授权", "category": "asset", "risk": "normal", - "primary_resource": "enterprise_device", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "primary_resource": "enterprise", + "affected_resource": "实际撤销的设备授权记录及其 device_auth_id 关联卡授权、设备和卡;owner shop 和实际卡槽绑定作为 reference", "actor_source": "登录账号快照/admin_api", + "visibility": "设备和卡仅保存 subject_result;授权记录与卡槽绑定 internal_only", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "failure_strategy": "参数错误不写 Audit Event;权限、全项规则拒绝或并发零命中写 denied 短事务;业务回滚后的 failed 使用独立短事务,二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" @@ -1970,6 +3108,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "企业认证上下文范围内可见;内部审计字段不可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -1996,6 +3135,7 @@ "primary_resource": "exchange", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2015,6 +3155,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2035,6 +3176,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2061,6 +3203,7 @@ "primary_resource": "exchange", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2086,6 +3229,7 @@ "primary_resource": "exchange", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2111,6 +3255,7 @@ "primary_resource": "exchange", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2136,6 +3281,7 @@ "primary_resource": "exchange", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2160,6 +3306,7 @@ "primary_resource": "export_task", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2173,22 +3320,18 @@ "owner": "export_task", "method": "GET", "summary": "导出任务列表", - "audit_event": "必须", + "audit_event": "N/A", "domain_ledger": "业务任务及明细表是批量结果权威", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "export_task.list", - "action_name": "导出任务列表", - "category": "business", - "risk": "normal", - "primary_resource": "export_task", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { "key": "http:internal/routes/export_task.go:33:GET:/:id", @@ -2209,6 +3352,7 @@ "primary_resource": "export_task", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2234,6 +3378,7 @@ "primary_resource": "export_task", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2253,6 +3398,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2273,6 +3419,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2281,9 +3428,35 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/iot_card.go:104:PATCH:/series-binding", + "key": "http:internal/routes/iot_card.go:105:POST:/standalone/recall", "kind": "http", - "code_entry": "internal/routes/iot_card.go:104 handler.BatchSetSeriesBinding", + "code_entry": "internal/routes/iot_card.go:105 handler.RecallCards", + "owner": "iot_card", + "method": "POST", + "path": "/standalone/recall", + "summary": "批量回收单卡", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "iot_card.recall_cards", + "action_name": "批量回收单卡", + "category": "asset", + "risk": "normal", + "primary_resource": "iot_card", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/iot_card.go:113:PATCH:/series-binding", + "kind": "http", + "code_entry": "internal/routes/iot_card.go:113 handler.BatchSetSeriesBinding", "owner": "iot_card", "method": "PATCH", "path": "/series-binding", @@ -2299,6 +3472,7 @@ "primary_resource": "iot_card", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2306,9 +3480,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/iot_card.go:113:GET:/:iccid/realname-link", + "key": "http:internal/routes/iot_card.go:122:GET:/:iccid/realname-link", "kind": "http", - "code_entry": "internal/routes/iot_card.go:113 handler.GetRealnameLink", + "code_entry": "internal/routes/iot_card.go:122 handler.GetRealnameLink", "owner": "iot_card", "method": "GET", "path": "/:iccid/realname-link", @@ -2324,6 +3498,33 @@ "primary_resource": "iot_card", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/iot_card.go:130:PUT:/:iccid/speed-tier", + "kind": "http", + "code_entry": "internal/routes/iot_card.go:130 handler.SetSpeedTier", + "owner": "iot_card", + "method": "PUT", + "path": "/:iccid/speed-tier", + "summary": "设置卡固定限速档位", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "iot_card.set_speed_tier", + "action_name": "设置卡固定限速档位", + "category": "asset", + "risk": "normal", + "primary_resource": "iot_card", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2343,6 +3544,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2351,9 +3553,35 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/iot_card.go:24:POST:/import", + "key": "http:internal/routes/iot_card.go:24:POST:/batch-update-realname-policy", "kind": "http", - "code_entry": "internal/routes/iot_card.go:24 importHandler.Import", + "code_entry": "internal/routes/iot_card.go:24 handler.BatchUpdateRealnamePolicy", + "owner": "iot_card", + "method": "POST", + "path": "/batch-update-realname-policy", + "summary": "批量更新卡实名认证策略", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "iot_card.batch_update_realname_policy", + "action_name": "批量更新卡实名认证策略", + "category": "asset", + "risk": "normal", + "primary_resource": "iot_card", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/iot_card.go:33:POST:/import", + "kind": "http", + "code_entry": "internal/routes/iot_card.go:33 importHandler.Import", "owner": "iot_card", "method": "POST", "path": "/import", @@ -2369,6 +3597,7 @@ "primary_resource": "iot_card", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2376,9 +3605,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/iot_card.go:70:GET:/import-tasks", + "key": "http:internal/routes/iot_card.go:79:GET:/import-tasks", "kind": "http", - "code_entry": "internal/routes/iot_card.go:70 importHandler.List", + "code_entry": "internal/routes/iot_card.go:79 importHandler.List", "owner": "iot_card", "method": "GET", "path": "/import-tasks", @@ -2388,6 +3617,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2396,9 +3626,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/iot_card.go:79:GET:/import-tasks/:id", + "key": "http:internal/routes/iot_card.go:88:GET:/import-tasks/:id", "kind": "http", - "code_entry": "internal/routes/iot_card.go:79 importHandler.GetByID", + "code_entry": "internal/routes/iot_card.go:88 importHandler.GetByID", "owner": "iot_card", "method": "GET", "path": "/import-tasks/:id", @@ -2408,6 +3638,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2416,9 +3647,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/iot_card.go:88:POST:/standalone/allocate", + "key": "http:internal/routes/iot_card.go:97:POST:/standalone/allocate", "kind": "http", - "code_entry": "internal/routes/iot_card.go:88 handler.AllocateCards", + "code_entry": "internal/routes/iot_card.go:97 handler.AllocateCards", "owner": "iot_card", "method": "POST", "path": "/standalone/allocate", @@ -2434,6 +3665,7 @@ "primary_resource": "iot_card", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2441,24 +3673,134 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/iot_card.go:96:POST:/standalone/recall", + "key": "http:internal/routes/notification.go:17:GET:/unread-count", "kind": "http", - "code_entry": "internal/routes/iot_card.go:96 handler.RecallCards", - "owner": "iot_card", - "method": "POST", - "path": "/standalone/recall", - "summary": "批量回收单卡", + "code_entry": "internal/routes/notification.go:17 handler.UnreadCount", + "owner": "notification", + "method": "GET", + "path": "/unread-count", + "summary": "查询通知未读数", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/notification.go:25:GET:/unread-summary", + "kind": "http", + "code_entry": "internal/routes/notification.go:25 handler.UnreadSummary", + "owner": "notification", + "method": "GET", + "path": "/unread-summary", + "summary": "查询通知未读分类汇总", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/notification.go:33:GET:", + "kind": "http", + "code_entry": "internal/routes/notification.go:33 handler.List", + "owner": "notification", + "method": "GET", + "summary": "查询通知列表", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/notification.go:42:PUT:/read-all", + "kind": "http", + "code_entry": "internal/routes/notification.go:42 handler.MarkAllRead", + "owner": "notification", + "method": "PUT", + "path": "/read-all", + "summary": "批量标记通知已读", "audit_event": "必须", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "iot_card.recall_cards", - "action_name": "批量回收单卡", - "category": "asset", + "action_code": "notification.mark_all_read", + "action_name": "批量标记通知已读", + "category": "business", "risk": "normal", - "primary_resource": "iot_card", + "primary_resource": "notification", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/notification.go:51:GET:/:id/target", + "kind": "http", + "code_entry": "internal/routes/notification.go:51 handler.Target", + "owner": "notification", + "method": "GET", + "path": "/:id/target", + "summary": "解析通知受控目标", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/notification.go:60:PUT:/:id/read", + "kind": "http", + "code_entry": "internal/routes/notification.go:60 handler.MarkRead", + "owner": "notification", + "method": "PUT", + "path": "/:id/read", + "summary": "标记单条通知已读", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "notification.mark_read", + "action_name": "标记单条通知已读", + "category": "business", + "risk": "normal", + "primary_resource": "notification", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2484,6 +3826,7 @@ "primary_resource": "open", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2503,6 +3846,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2529,6 +3873,7 @@ "primary_resource": "open", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2548,6 +3893,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2568,6 +3914,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2588,6 +3935,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2614,6 +3962,7 @@ "primary_resource": "open", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2639,6 +3988,7 @@ "primary_resource": "open", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2664,6 +4014,7 @@ "primary_resource": "open", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2683,6 +4034,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2703,6 +4055,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2718,17 +4071,65 @@ "method": "GET", "path": "/cards/realname-status", "summary": "查询单卡实名状态", - "audit_event": "必须", + "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "open.get_realname_status", - "action_name": "查询单卡实名状态", - "category": "business", - "risk": "normal", - "primary_resource": "open", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/order.go:101:POST:/alipay", + "kind": "http", + "code_entry": "internal/routes/order.go:101 handler.AlipayCallback", + "owner": "order", + "method": "POST", + "path": "/alipay", + "summary": "支付宝回调", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "必须:业务处理前保存入站安全摘要与幂等标识", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "order.alipay_callback", + "action_name": "支付宝回调", + "category": "finance", + "risk": "normal", + "primary_resource": "order", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "external_system/callback", + "visibility": "外部回调入口;只记录内部完整事实,不直接向外部主体展示", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/order.go:109:POST:/fuiou-pay", + "kind": "http", + "code_entry": "internal/routes/order.go:109 handler.FuiouPayCallback", + "owner": "order", + "method": "POST", + "path": "/fuiou-pay", + "summary": "富友支付回调", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "必须:业务处理前保存入站安全摘要与幂等标识", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "order.fuiou_pay_callback", + "action_name": "富友支付回调", + "category": "finance", + "risk": "normal", + "primary_resource": "order", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "external_system/callback", + "visibility": "外部回调入口;只记录内部完整事实,不直接向外部主体展示", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2754,6 +4155,7 @@ "primary_resource": "order", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2773,6 +4175,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2793,6 +4196,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2819,6 +4223,7 @@ "primary_resource": "order", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2833,17 +4238,39 @@ "method": "POST", "path": "/orders/purchase-check", "summary": "套餐购买预检", + "audit_event": "N/A", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/order.go:57:POST:/carriers/ctcc/realname", + "kind": "http", + "code_entry": "internal/routes/order.go:57 handler.Realname", + "owner": "order", + "method": "POST", + "path": "/carriers/ctcc/realname", + "summary": "电信实名结果回调", "audit_event": "必须", "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "order.purchase_check", - "action_name": "套餐购买预检", + "action_code": "order.realname", + "action_name": "电信实名结果回调", "category": "finance", "risk": "normal", "primary_resource": "order", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "actor_source": "external_system/callback", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2851,9 +4278,87 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/order.go:57:POST:/wechat-pay", + "key": "http:internal/routes/order.go:69:POST:/carriers/cmcc/realname", "kind": "http", - "code_entry": "internal/routes/order.go:57 handler.WechatPayCallback", + "code_entry": "internal/routes/order.go:69 handler.Realname", + "owner": "order", + "method": "POST", + "path": "/carriers/cmcc/realname", + "summary": "移动实名结果回调", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "order.realname", + "action_name": "移动实名结果回调", + "category": "finance", + "risk": "normal", + "primary_resource": "order", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "external_system/callback", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/order.go:77:POST:/carriers/cucc/realname", + "kind": "http", + "code_entry": "internal/routes/order.go:77 handler.Realname", + "owner": "order", + "method": "POST", + "path": "/carriers/cucc/realname", + "summary": "联通实名结果回调", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "order.realname", + "action_name": "联通实名结果回调", + "category": "finance", + "risk": "normal", + "primary_resource": "order", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "external_system/callback", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/order.go:85:POST:/carriers/cucc/realname/remove", + "kind": "http", + "code_entry": "internal/routes/order.go:85 handler.Remove", + "owner": "order", + "method": "POST", + "path": "/carriers/cucc/realname/remove", + "summary": "联通解除实名回调", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "order.remove", + "action_name": "联通解除实名回调", + "category": "finance", + "risk": "normal", + "primary_resource": "order", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "external_system/callback", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/order.go:93:POST:/wechat-pay", + "kind": "http", + "code_entry": "internal/routes/order.go:93 handler.WechatPayCallback", "owner": "order", "method": "POST", "path": "/wechat-pay", @@ -2868,57 +4373,8 @@ "risk": "normal", "primary_resource": "order", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, - { - "key": "http:internal/routes/order.go:65:POST:/alipay", - "kind": "http", - "code_entry": "internal/routes/order.go:65 handler.AlipayCallback", - "owner": "order", - "method": "POST", - "path": "/alipay", - "summary": "支付宝回调", - "audit_event": "必须", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "必须:业务处理前保存入站安全摘要与幂等标识", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "order.alipay_callback", - "action_name": "支付宝回调", - "category": "finance", - "risk": "normal", - "primary_resource": "order", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, - { - "key": "http:internal/routes/order.go:73:POST:/fuiou-pay", - "kind": "http", - "code_entry": "internal/routes/order.go:73 handler.FuiouPayCallback", - "owner": "order", - "method": "POST", - "path": "/fuiou-pay", - "summary": "富友支付回调", - "audit_event": "必须", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "必须:业务处理前保存入站安全摘要与幂等标识", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "order.fuiou_pay_callback", - "action_name": "富友支付回调", - "category": "finance", - "risk": "normal", - "primary_resource": "order", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "按路由分为 admin_user/admin_api 或外部回调入口", + "actor_source": "external_system/callback", + "visibility": "外部回调入口;只记录内部完整事实,不直接向外部主体展示", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2943,6 +4399,7 @@ "primary_resource": "order_package_invalidate", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2961,6 +4418,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -2981,6 +4439,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3000,6 +4459,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3025,6 +4485,7 @@ "primary_resource": "package", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3044,6 +4505,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3070,6 +4532,7 @@ "primary_resource": "package", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3095,6 +4558,7 @@ "primary_resource": "package", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3120,6 +4584,7 @@ "primary_resource": "package", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3145,6 +4610,7 @@ "primary_resource": "package", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3170,6 +4636,54 @@ "primary_resource": "package", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/package_expiry.go:13:GET:/expiring-assets", + "kind": "http", + "code_entry": "internal/routes/package_expiry.go:13 handler.ListExpiring", + "owner": "package_expiry", + "method": "GET", + "path": "/expiring-assets", + "summary": "查询临期资产列表", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/package_expiry.go:22:POST:/expiring-assets/reminder-scan", + "kind": "http", + "code_entry": "internal/routes/package_expiry.go:22 handler.TriggerPackageExpiryReminder", + "owner": "package_expiry", + "method": "POST", + "path": "/expiring-assets/reminder-scan", + "summary": "手动触发套餐临期提醒扫描", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "package_expiry.trigger_package_expiry_reminder", + "action_name": "手动触发套餐临期提醒扫描", + "category": "business", + "risk": "normal", + "primary_resource": "package_expiry", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3188,6 +4702,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3213,6 +4728,7 @@ "primary_resource": "package_series", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3232,6 +4748,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3258,6 +4775,7 @@ "primary_resource": "package_series", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3283,6 +4801,7 @@ "primary_resource": "package_series", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3308,6 +4827,7 @@ "primary_resource": "package_series", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3327,6 +4847,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3352,6 +4873,7 @@ "primary_resource": "permission", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3370,6 +4892,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3390,6 +4913,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3410,6 +4934,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3436,6 +4961,7 @@ "primary_resource": "permission", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3461,6 +4987,7 @@ "primary_resource": "permission", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3486,6 +5013,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3493,9 +5021,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:120:GET:/profile", + "key": "http:internal/routes/personal.go:123:GET:/profile", "kind": "http", - "code_entry": "internal/routes/personal.go:120 handlers.PersonalCustomer.GetProfile", + "code_entry": "internal/routes/personal.go:123 handlers.PersonalCustomer.GetProfile", "owner": "personal", "method": "GET", "path": "/profile", @@ -3505,6 +5033,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3513,9 +5042,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:130:PUT:/profile", + "key": "http:internal/routes/personal.go:133:PUT:/profile", "kind": "http", - "code_entry": "internal/routes/personal.go:130 handlers.PersonalCustomer.UpdateProfile", + "code_entry": "internal/routes/personal.go:133 handlers.PersonalCustomer.UpdateProfile", "owner": "personal", "method": "PUT", "path": "/profile", @@ -3524,13 +5053,14 @@ "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "personal.update_profile", + "action_code": "personal_customer.update_profile", "action_name": "更新个人资料", - "category": "business", + "category": "identity", "risk": "normal", - "primary_resource": "personal", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "primary_resource": "personal_customer", + "affected_resource": "N/A:资料字段直接属于个人客户主要资源", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3538,9 +5068,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:139:GET:/asset/info", + "key": "http:internal/routes/personal.go:142:GET:/asset/info", "kind": "http", - "code_entry": "internal/routes/personal.go:139 handlers.ClientAsset.GetAssetInfo", + "code_entry": "internal/routes/personal.go:142 handlers.ClientAsset.GetAssetInfo", "owner": "personal", "method": "GET", "path": "/asset/info", @@ -3550,6 +5080,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3558,9 +5089,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:147:GET:/asset/packages", + "key": "http:internal/routes/personal.go:150:GET:/asset/packages", "kind": "http", - "code_entry": "internal/routes/personal.go:147 handlers.ClientAsset.GetAvailablePackages", + "code_entry": "internal/routes/personal.go:150 handlers.ClientAsset.GetAvailablePackages", "owner": "personal", "method": "GET", "path": "/asset/packages", @@ -3570,6 +5101,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3578,9 +5110,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:155:GET:/asset/package-history", + "key": "http:internal/routes/personal.go:158:GET:/asset/package-history", "kind": "http", - "code_entry": "internal/routes/personal.go:155 handlers.ClientAsset.GetPackageHistory", + "code_entry": "internal/routes/personal.go:158 handlers.ClientAsset.GetPackageHistory", "owner": "personal", "method": "GET", "path": "/asset/package-history", @@ -3590,6 +5122,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3598,9 +5131,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:163:POST:/asset/refresh", + "key": "http:internal/routes/personal.go:166:POST:/asset/refresh", "kind": "http", - "code_entry": "internal/routes/personal.go:163 handlers.ClientAsset.RefreshAsset", + "code_entry": "internal/routes/personal.go:166 handlers.ClientAsset.RefreshAsset", "owner": "personal", "method": "POST", "path": "/asset/refresh", @@ -3616,6 +5149,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3623,9 +5157,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:171:GET:/wallet/detail", + "key": "http:internal/routes/personal.go:174:GET:/wallet/detail", "kind": "http", - "code_entry": "internal/routes/personal.go:171 handlers.ClientWallet.GetWalletDetail", + "code_entry": "internal/routes/personal.go:174 handlers.ClientWallet.GetWalletDetail", "owner": "personal", "method": "GET", "path": "/wallet/detail", @@ -3635,6 +5169,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3643,9 +5178,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:179:GET:/wallet/transactions", + "key": "http:internal/routes/personal.go:182:GET:/wallet/transactions", "kind": "http", - "code_entry": "internal/routes/personal.go:179 handlers.ClientWallet.GetWalletTransactions", + "code_entry": "internal/routes/personal.go:182 handlers.ClientWallet.GetWalletTransactions", "owner": "personal", "method": "GET", "path": "/wallet/transactions", @@ -3655,6 +5190,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3663,9 +5199,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:187:GET:/wallet/recharge-check", + "key": "http:internal/routes/personal.go:190:GET:/wallet/recharge-check", "kind": "http", - "code_entry": "internal/routes/personal.go:187 handlers.ClientWallet.GetRechargeCheck", + "code_entry": "internal/routes/personal.go:190 handlers.ClientWallet.GetRechargeCheck", "owner": "personal", "method": "GET", "path": "/wallet/recharge-check", @@ -3675,6 +5211,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3683,9 +5220,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:195:POST:/wallet/recharge", + "key": "http:internal/routes/personal.go:198:POST:/wallet/recharge", "kind": "http", - "code_entry": "internal/routes/personal.go:195 handlers.ClientWallet.CreateRecharge", + "code_entry": "internal/routes/personal.go:198 handlers.ClientWallet.CreateRecharge", "owner": "personal", "method": "POST", "path": "/wallet/recharge", @@ -3701,6 +5238,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3708,9 +5246,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:203:GET:/wallet/recharges", + "key": "http:internal/routes/personal.go:206:GET:/wallet/recharges", "kind": "http", - "code_entry": "internal/routes/personal.go:203 handlers.ClientWallet.GetRechargeList", + "code_entry": "internal/routes/personal.go:206 handlers.ClientWallet.GetRechargeList", "owner": "personal", "method": "GET", "path": "/wallet/recharges", @@ -3720,6 +5258,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3728,9 +5267,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:211:GET:/recharge-orders", + "key": "http:internal/routes/personal.go:214:GET:/recharge-orders", "kind": "http", - "code_entry": "internal/routes/personal.go:211 handlers.ClientRechargeOrder.ListRechargeOrders", + "code_entry": "internal/routes/personal.go:214 handlers.ClientRechargeOrder.ListRechargeOrders", "owner": "personal", "method": "GET", "path": "/recharge-orders", @@ -3740,6 +5279,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3748,9 +5288,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:219:GET:/recharge-orders/:id", + "key": "http:internal/routes/personal.go:222:GET:/recharge-orders/:id", "kind": "http", - "code_entry": "internal/routes/personal.go:219 handlers.ClientRechargeOrder.GetRechargeOrderDetail", + "code_entry": "internal/routes/personal.go:222 handlers.ClientRechargeOrder.GetRechargeOrderDetail", "owner": "personal", "method": "GET", "path": "/recharge-orders/:id", @@ -3760,6 +5300,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3768,9 +5309,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:227:POST:/orders/create", + "key": "http:internal/routes/personal.go:230:POST:/orders/create", "kind": "http", - "code_entry": "internal/routes/personal.go:227 handlers.ClientOrder.CreateOrder", + "code_entry": "internal/routes/personal.go:230 handlers.ClientOrder.CreateOrder", "owner": "personal", "method": "POST", "path": "/orders/create", @@ -3786,6 +5327,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3793,9 +5335,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:235:GET:/orders", + "key": "http:internal/routes/personal.go:238:GET:/orders", "kind": "http", - "code_entry": "internal/routes/personal.go:235 handlers.ClientOrder.ListOrders", + "code_entry": "internal/routes/personal.go:238 handlers.ClientOrder.ListOrders", "owner": "personal", "method": "GET", "path": "/orders", @@ -3805,6 +5347,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3813,9 +5356,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:243:GET:/orders/:id", + "key": "http:internal/routes/personal.go:246:GET:/orders/:id", "kind": "http", - "code_entry": "internal/routes/personal.go:243 handlers.ClientOrder.GetOrderDetail", + "code_entry": "internal/routes/personal.go:246 handlers.ClientOrder.GetOrderDetail", "owner": "personal", "method": "GET", "path": "/orders/:id", @@ -3825,6 +5368,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3833,9 +5377,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:251:POST:/orders/:id/pay", + "key": "http:internal/routes/personal.go:254:POST:/orders/:id/pay", "kind": "http", - "code_entry": "internal/routes/personal.go:251 handlers.ClientOrder.PayOrder", + "code_entry": "internal/routes/personal.go:254 handlers.ClientOrder.PayOrder", "owner": "personal", "method": "POST", "path": "/orders/:id/pay", @@ -3851,32 +5395,13 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, - { - "key": "http:internal/routes/personal.go:259:GET:/exchange/pending", - "kind": "http", - "code_entry": "internal/routes/personal.go:259 handlers.ClientExchange.GetPending", - "owner": "personal", - "method": "GET", - "path": "/exchange/pending", - "summary": "查询待处理换货单", - "audit_event": "N/A", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "actor_source": "personal_customer/personal_api", - "transaction": "N/A", - "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", - "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" - }, { "key": "http:internal/routes/personal.go:25:GET:/wechat/appid", "kind": "http", @@ -3890,6 +5415,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3898,9 +5424,30 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:267:POST:/exchange/:id/shipping-info", + "key": "http:internal/routes/personal.go:262:GET:/exchange/pending", "kind": "http", - "code_entry": "internal/routes/personal.go:267 handlers.ClientExchange.SubmitShippingInfo", + "code_entry": "internal/routes/personal.go:262 handlers.ClientExchange.GetPending", + "owner": "personal", + "method": "GET", + "path": "/exchange/pending", + "summary": "查询待处理换货单", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/personal.go:270:POST:/exchange/:id/shipping-info", + "kind": "http", + "code_entry": "internal/routes/personal.go:270 handlers.ClientExchange.SubmitShippingInfo", "owner": "personal", "method": "POST", "path": "/exchange/:id/shipping-info", @@ -3916,6 +5463,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3923,9 +5471,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:275:GET:/realname/link", + "key": "http:internal/routes/personal.go:278:GET:/realname/link", "kind": "http", - "code_entry": "internal/routes/personal.go:275 handlers.ClientRealname.GetRealnameLink", + "code_entry": "internal/routes/personal.go:278 handlers.ClientRealname.GetRealnameLink", "owner": "personal", "method": "GET", "path": "/realname/link", @@ -3941,6 +5489,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3948,9 +5497,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:283:GET:/device/cards", + "key": "http:internal/routes/personal.go:286:GET:/device/cards", "kind": "http", - "code_entry": "internal/routes/personal.go:283 handlers.ClientDevice.GetDeviceCards", + "code_entry": "internal/routes/personal.go:286 handlers.ClientDevice.GetDeviceCards", "owner": "personal", "method": "GET", "path": "/device/cards", @@ -3960,6 +5509,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3968,9 +5518,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/personal.go:291:POST:/device/reboot", + "key": "http:internal/routes/personal.go:294:POST:/device/reboot", "kind": "http", - "code_entry": "internal/routes/personal.go:291 handlers.ClientDevice.RebootDevice", + "code_entry": "internal/routes/personal.go:294 handlers.ClientDevice.RebootDevice", "owner": "personal", "method": "POST", "path": "/device/reboot", @@ -3986,6 +5536,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -3993,9 +5544,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:299:POST:/device/factory-reset", + "key": "http:internal/routes/personal.go:302:POST:/device/factory-reset", "kind": "http", - "code_entry": "internal/routes/personal.go:299 handlers.ClientDevice.FactoryResetDevice", + "code_entry": "internal/routes/personal.go:302 handlers.ClientDevice.FactoryResetDevice", "owner": "personal", "method": "POST", "path": "/device/factory-reset", @@ -4011,6 +5562,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4018,9 +5570,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:307:POST:/device/wifi", + "key": "http:internal/routes/personal.go:310:POST:/device/wifi", "kind": "http", - "code_entry": "internal/routes/personal.go:307 handlers.ClientDevice.SetWiFi", + "code_entry": "internal/routes/personal.go:310 handlers.ClientDevice.SetWiFi", "owner": "personal", "method": "POST", "path": "/device/wifi", @@ -4036,6 +5588,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4043,9 +5596,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/personal.go:315:POST:/device/switch-card", + "key": "http:internal/routes/personal.go:318:POST:/device/switch-card", "kind": "http", - "code_entry": "internal/routes/personal.go:315 handlers.ClientDevice.SwitchCard", + "code_entry": "internal/routes/personal.go:318 handlers.ClientDevice.SwitchCard", "owner": "personal", "method": "POST", "path": "/device/switch-card", @@ -4061,6 +5614,7 @@ "primary_resource": "personal", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4080,6 +5634,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4087,6 +5642,126 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, + { + "key": "http:internal/routes/personal.go:43:POST:authBasePath+\"/verify-asset\"", + "kind": "http", + "code_entry": "internal/routes/personal.go:43 handlers.ClientAuth.VerifyAsset", + "owner": "personal", + "method": "POST", + "path": "authBasePath+\"/verify-asset\"", + "summary": "资产验证", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/personal.go:51:POST:authBasePath+\"/wechat-login\"", + "kind": "http", + "code_entry": "internal/routes/personal.go:51 handlers.ClientAuth.WechatLogin", + "owner": "personal", + "method": "POST", + "path": "authBasePath+\"/wechat-login\"", + "summary": "公众号登录", + "audit_event": "客户资料或微信主体实际变化时必须;仅签发 Token 时 N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "personal_customer.update_wechat_identity / personal_customer.update_profile", + "action_name": "同步个人微信主体 / 同步个人资料", + "category": "identity", + "risk": "normal", + "primary_resource": "personal_customer", + "affected_resource": "实际新建的 personal_customer_openid;资料同步只关联个人客户", + "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "客户/OpenID 实际变化与 Audit Event 同一 GORM 事务;重复登录无变化不写资料事件", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/personal.go:59:POST:authBasePath+\"/miniapp-login\"", + "kind": "http", + "code_entry": "internal/routes/personal.go:59 handlers.ClientAuth.MiniappLogin", + "owner": "personal", + "method": "POST", + "path": "authBasePath+\"/miniapp-login\"", + "summary": "小程序登录", + "audit_event": "客户资料或微信主体实际变化时必须;仅签发 Token 时 N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "personal_customer.update_wechat_identity / personal_customer.update_profile", + "action_name": "同步个人微信主体 / 同步个人资料", + "category": "identity", + "risk": "normal", + "primary_resource": "personal_customer", + "affected_resource": "实际新建的 personal_customer_openid;资料同步只关联个人客户", + "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "客户/OpenID 实际变化与 Audit Event 同一 GORM 事务;重复登录无变化不写资料事件", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/personal.go:67:POST:authBasePath+\"/send-code\"", + "kind": "http", + "code_entry": "internal/routes/personal.go:67 handlers.ClientAuth.SendCode", + "owner": "personal", + "method": "POST", + "path": "authBasePath+\"/send-code\"", + "summary": "发送验证码", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "N/A", + "failure_strategy": "验证码发送和限流进入 Access/Integration/Security 事实,不创建个人资料 Audit Event", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "验证码是短期安全凭据,不属于个人客户资料、手机号绑定事实或微信主体变化,禁止写入审计" + }, + { + "key": "http:internal/routes/personal.go:77:POST:authBasePath+\"/dev-login\"", + "kind": "http", + "code_entry": "internal/routes/personal.go:77 handlers.ClientAuth.DevLogin", + "owner": "personal", + "method": "POST", + "path": "authBasePath+\"/dev-login\"", + "summary": "开发环境测试登录", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "personal.dev_login", + "action_name": "开发环境测试登录", + "category": "business", + "risk": "normal", + "primary_resource": "personal", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, { "key": "http:internal/routes/personal.go:91:POST:/bind-phone", "kind": "http", @@ -4099,13 +5774,14 @@ "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "personal.bind_phone", + "action_code": "personal_customer.bind_phone", "action_name": "绑定手机号", - "category": "business", + "category": "identity", "risk": "normal", - "primary_resource": "personal", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "primary_resource": "personal_customer", + "affected_resource": "personal_customer_phone", "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4124,13 +5800,107 @@ "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "personal.change_phone", + "action_code": "personal_customer.change_phone", "action_name": "更换手机号", + "category": "identity", + "risk": "normal", + "primary_resource": "personal_customer", + "affected_resource": "personal_customer_phone", + "actor_source": "personal_customer/personal_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/personal_notification.go:16:GET:/unread-count", + "kind": "http", + "code_entry": "internal/routes/personal_notification.go:16 handler.UnreadCount", + "owner": "personal_notification", + "method": "GET", + "path": "/unread-count", + "summary": "查询个人客户通知未读数", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/personal_notification.go:24:GET:", + "kind": "http", + "code_entry": "internal/routes/personal_notification.go:24 handler.List", + "owner": "personal_notification", + "method": "GET", + "summary": "查询个人客户通知列表", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/personal_notification.go:34:PUT:/read-all", + "kind": "http", + "code_entry": "internal/routes/personal_notification.go:34 handler.MarkAllRead", + "owner": "personal_notification", + "method": "PUT", + "path": "/read-all", + "summary": "全部标记个人客户通知已读", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "personal_notification.mark_all_read", + "action_name": "全部标记个人客户通知已读", "category": "business", "risk": "normal", - "primary_resource": "personal", + "primary_resource": "personal_notification", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "personal_customer/personal_api", + "actor_source": "登录账号快照/admin_api", + "visibility": "当前个人客户本人范围内可见", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/personal_notification.go:42:PUT:/:id/read", + "kind": "http", + "code_entry": "internal/routes/personal_notification.go:42 handler.MarkRead", + "owner": "personal_notification", + "method": "PUT", + "path": "/:id/read", + "summary": "标记个人客户单条通知已读", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "personal_notification.mark_read", + "action_name": "标记个人客户单条通知已读", + "category": "business", + "risk": "normal", + "primary_resource": "personal_notification", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "当前个人客户本人范围内可见", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4155,6 +5925,7 @@ "primary_resource": "polling_alert", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4173,6 +5944,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4193,6 +5965,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4219,6 +5992,7 @@ "primary_resource": "polling_alert", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4244,6 +6018,7 @@ "primary_resource": "polling_alert", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4263,6 +6038,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4288,6 +6064,7 @@ "primary_resource": "polling_cleanup", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4306,6 +6083,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4326,6 +6104,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4352,6 +6131,7 @@ "primary_resource": "polling_cleanup", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4377,6 +6157,7 @@ "primary_resource": "polling_cleanup", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4396,6 +6177,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4416,6 +6198,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4436,6 +6219,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4462,6 +6246,7 @@ "primary_resource": "polling_cleanup", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4480,6 +6265,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4506,6 +6292,7 @@ "primary_resource": "polling_concurrency", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4525,6 +6312,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4551,6 +6339,7 @@ "primary_resource": "polling_concurrency", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4569,6 +6358,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4594,6 +6384,7 @@ "primary_resource": "polling_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4613,6 +6404,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4633,6 +6425,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4659,6 +6452,7 @@ "primary_resource": "polling_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4684,6 +6478,7 @@ "primary_resource": "polling_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4709,6 +6504,7 @@ "primary_resource": "polling_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4734,6 +6530,7 @@ "primary_resource": "polling_manual_trigger", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4759,6 +6556,7 @@ "primary_resource": "polling_manual_trigger", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4784,6 +6582,7 @@ "primary_resource": "polling_manual_trigger", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4803,6 +6602,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4823,6 +6623,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4849,6 +6650,7 @@ "primary_resource": "polling_manual_trigger", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4867,6 +6669,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4887,6 +6690,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4907,6 +6711,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4927,6 +6732,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4952,6 +6758,7 @@ "primary_resource": "refund", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4970,6 +6777,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -4990,6 +6798,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5016,6 +6825,7 @@ "primary_resource": "refund", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5041,6 +6851,7 @@ "primary_resource": "refund", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5066,6 +6877,7 @@ "primary_resource": "refund", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5091,6 +6903,7 @@ "primary_resource": "refund", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5115,6 +6928,7 @@ "primary_resource": "role", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5133,6 +6947,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5153,6 +6968,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5179,6 +6995,7 @@ "primary_resource": "role", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5204,6 +7021,7 @@ "primary_resource": "role", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5211,9 +7029,35 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/role.go:58:DELETE:/:id", + "key": "http:internal/routes/role.go:58:PUT:/:id/default-credit", "kind": "http", - "code_entry": "internal/routes/role.go:58 h.Delete", + "code_entry": "internal/routes/role.go:58 h.UpdateDefaultCredit", + "owner": "role", + "method": "PUT", + "path": "/:id/default-credit", + "summary": "更新客户角色的新建代理默认信用模板", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "role.update_default_credit", + "action_name": "更新客户角色的新建代理默认信用模板", + "category": "security", + "risk": "high", + "primary_resource": "role", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/role.go:66:DELETE:/:id", + "kind": "http", + "code_entry": "internal/routes/role.go:66 h.Delete", "owner": "role", "method": "DELETE", "path": "/:id", @@ -5229,6 +7073,7 @@ "primary_resource": "role", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5236,9 +7081,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/role.go:67:POST:/:id/permissions", + "key": "http:internal/routes/role.go:75:POST:/:id/permissions", "kind": "http", - "code_entry": "internal/routes/role.go:67 h.AssignPermissions", + "code_entry": "internal/routes/role.go:75 h.AssignPermissions", "owner": "role", "method": "POST", "path": "/:id/permissions", @@ -5254,6 +7099,7 @@ "primary_resource": "role", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5261,9 +7107,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/role.go:75:GET:/:id/permissions", + "key": "http:internal/routes/role.go:83:GET:/:id/permissions", "kind": "http", - "code_entry": "internal/routes/role.go:75 h.GetPermissions", + "code_entry": "internal/routes/role.go:83 h.GetPermissions", "owner": "role", "method": "GET", "path": "/:id/permissions", @@ -5273,6 +7119,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5281,9 +7128,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/role.go:83:DELETE:/:role_id/permissions/:perm_id", + "key": "http:internal/routes/role.go:91:DELETE:/:role_id/permissions/:perm_id", "kind": "http", - "code_entry": "internal/routes/role.go:83 h.RemovePermission", + "code_entry": "internal/routes/role.go:91 h.RemovePermission", "owner": "role", "method": "DELETE", "path": "/:role_id/permissions/:perm_id", @@ -5299,6 +7146,7 @@ "primary_resource": "role", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5306,9 +7154,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/role.go:91:DELETE:/:role_id/permissions", + "key": "http:internal/routes/role.go:99:DELETE:/:role_id/permissions", "kind": "http", - "code_entry": "internal/routes/role.go:91 h.BatchRemovePermissions", + "code_entry": "internal/routes/role.go:99 h.BatchRemovePermissions", "owner": "role", "method": "DELETE", "path": "/:role_id/permissions", @@ -5324,6 +7172,7 @@ "primary_resource": "role", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5331,9 +7180,82 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/shop.go:106:GET:/fund-summary", + "key": "http:internal/routes/shop.go:108:POST:/:shop_id/roles", "kind": "http", - "code_entry": "internal/routes/shop.go:106 handler.ListFundSummary", + "code_entry": "internal/routes/shop.go:108 handler.AssignShopRoles", + "owner": "shop", + "method": "POST", + "path": "/:shop_id/roles", + "summary": "分配店铺默认角色", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "shop.assign_shop_roles", + "action_name": "分配店铺默认角色", + "category": "business", + "risk": "high", + "primary_resource": "shop", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/shop.go:116:GET:/:shop_id/roles", + "kind": "http", + "code_entry": "internal/routes/shop.go:116 handler.GetShopRoles", + "owner": "shop", + "method": "GET", + "path": "/:shop_id/roles", + "summary": "查询店铺默认角色", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/shop.go:124:DELETE:/:shop_id/roles/:role_id", + "kind": "http", + "code_entry": "internal/routes/shop.go:124 handler.DeleteShopRole", + "owner": "shop", + "method": "DELETE", + "path": "/:shop_id/roles/:role_id", + "summary": "删除店铺默认角色", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "shop.delete_shop_role", + "action_name": "删除店铺默认角色", + "category": "business", + "risk": "high", + "primary_resource": "shop", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/shop.go:137:GET:/fund-summary", + "kind": "http", + "code_entry": "internal/routes/shop.go:137 agentFundManagement", "owner": "shop", "method": "GET", "path": "/fund-summary", @@ -5343,6 +7265,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5351,9 +7274,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/shop.go:114:GET:/:shop_id/withdrawal-requests", + "key": "http:internal/routes/shop.go:146:GET:/:shop_id/withdrawal-requests", "kind": "http", - "code_entry": "internal/routes/shop.go:114 handler.ListWithdrawalRequests", + "code_entry": "internal/routes/shop.go:146 handler.ListWithdrawalRequests", "owner": "shop", "method": "GET", "path": "/:shop_id/withdrawal-requests", @@ -5363,6 +7286,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5371,9 +7295,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/shop.go:122:GET:/:shop_id/commission-records", + "key": "http:internal/routes/shop.go:154:GET:/:shop_id/commission-records", "kind": "http", - "code_entry": "internal/routes/shop.go:122 handler.ListCommissionRecords", + "code_entry": "internal/routes/shop.go:154 handler.ListCommissionRecords", "owner": "shop", "method": "GET", "path": "/:shop_id/commission-records", @@ -5383,6 +7307,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5391,9 +7316,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/shop.go:130:GET:/:shop_id/main-wallet/transactions", + "key": "http:internal/routes/shop.go:162:GET:/:shop_id/main-wallet/transactions", "kind": "http", - "code_entry": "internal/routes/shop.go:130 handler.ListMainWalletTransactions", + "code_entry": "internal/routes/shop.go:162 handler.ListMainWalletTransactions", "owner": "shop", "method": "GET", "path": "/:shop_id/main-wallet/transactions", @@ -5403,6 +7328,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5411,9 +7337,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/shop.go:138:GET:/:shop_id/commission-stats", + "key": "http:internal/routes/shop.go:170:GET:/:shop_id/commission-stats", "kind": "http", - "code_entry": "internal/routes/shop.go:138 handler.GetCommissionStats", + "code_entry": "internal/routes/shop.go:170 handler.GetCommissionStats", "owner": "shop", "method": "GET", "path": "/:shop_id/commission-stats", @@ -5423,6 +7349,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5431,9 +7358,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/shop.go:146:GET:/:shop_id/commission-daily-stats", + "key": "http:internal/routes/shop.go:178:GET:/:shop_id/commission-daily-stats", "kind": "http", - "code_entry": "internal/routes/shop.go:146 handler.GetCommissionDailyStats", + "code_entry": "internal/routes/shop.go:178 handler.GetCommissionDailyStats", "owner": "shop", "method": "GET", "path": "/:shop_id/commission-daily-stats", @@ -5443,6 +7370,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5451,9 +7379,9 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/shop.go:154:POST:/:shop_id/withdrawal-requests", + "key": "http:internal/routes/shop.go:186:POST:/:shop_id/withdrawal-requests", "kind": "http", - "code_entry": "internal/routes/shop.go:154 handler.CreateWithdrawal", + "code_entry": "internal/routes/shop.go:186 handler.CreateWithdrawal", "owner": "shop", "method": "POST", "path": "/:shop_id/withdrawal-requests", @@ -5469,31 +7397,7 @@ "primary_resource": "shop", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, - { - "key": "http:internal/routes/shop.go:165:POST:/:id/resolve", - "kind": "http", - "code_entry": "internal/routes/shop.go:165 handler.ResolveCommissionRecord", - "owner": "shop", - "method": "POST", - "path": "/:id/resolve", - "summary": "修正待审佣金记录", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "shop.resolve_commission_record", - "action_name": "修正待审佣金记录", - "category": "business", - "risk": "normal", - "primary_resource": "shop", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5512,6 +7416,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5519,6 +7424,32 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, + { + "key": "http:internal/routes/shop.go:197:POST:/:id/resolve", + "kind": "http", + "code_entry": "internal/routes/shop.go:197 handler.ResolveCommissionRecord", + "owner": "shop", + "method": "POST", + "path": "/:id/resolve", + "summary": "修正待审佣金记录", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "shop.resolve_commission_record", + "action_name": "修正待审佣金记录", + "category": "business", + "risk": "normal", + "primary_resource": "shop", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, { "key": "http:internal/routes/shop.go:27:POST:", "kind": "http", @@ -5530,13 +7461,14 @@ "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "shop.core_shop_management", + "action_code": "shop.create", "action_name": "创建店铺", "category": "business", "risk": "normal", "primary_resource": "shop", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5544,10 +7476,31 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/shop.go:36:PUT:/:id", + "key": "http:internal/routes/shop.go:36:GET:/business-owner-candidates", "kind": "http", "code_entry": "internal/routes/shop.go:36 coreShopManagement", "owner": "shop", + "method": "GET", + "path": "/business-owner-candidates", + "summary": "查询店铺业务员候选", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/shop.go:45:PUT:/:id", + "kind": "http", + "code_entry": "internal/routes/shop.go:45 coreShopManagement", + "owner": "shop", "method": "PUT", "path": "/:id", "summary": "更新店铺", @@ -5555,13 +7508,14 @@ "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "shop.core_shop_management", - "action_name": "更新店铺", + "action_code": "shop.update", + "action_name": "更新店铺基础资料", "category": "business", "risk": "normal", "primary_resource": "shop", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5569,9 +7523,35 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/shop.go:45:DELETE:/:id", + "key": "http:internal/routes/shop.go:54:PUT:/:id/credit-limit", "kind": "http", - "code_entry": "internal/routes/shop.go:45 coreShopManagement", + "code_entry": "internal/routes/shop.go:54 handler.UpdateCreditLimit", + "owner": "shop", + "method": "PUT", + "path": "/:id/credit-limit", + "summary": "调整既有店铺实际信用额度", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "shop.update_credit_limit", + "action_name": "调整既有店铺实际信用额度", + "category": "business", + "risk": "normal", + "primary_resource": "shop", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/shop.go:63:DELETE:/:id", + "kind": "http", + "code_entry": "internal/routes/shop.go:63 coreShopManagement", "owner": "shop", "method": "DELETE", "path": "/:id", @@ -5580,13 +7560,14 @@ "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "shop.core_shop_management", + "action_code": "shop.delete", "action_name": "删除店铺", "category": "business", - "risk": "normal", + "risk": "high", "primary_resource": "shop", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5594,9 +7575,9 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "http:internal/routes/shop.go:54:GET:/cascade", + "key": "http:internal/routes/shop.go:72:GET:/cascade", "kind": "http", - "code_entry": "internal/routes/shop.go:54 coreShopManagement", + "code_entry": "internal/routes/shop.go:72 coreShopManagement", "owner": "shop", "method": "GET", "path": "/cascade", @@ -5606,6 +7587,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5614,43 +7596,19 @@ "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { - "key": "http:internal/routes/shop.go:77:POST:/:shop_id/roles", + "key": "http:internal/routes/shop.go:85:GET:/:id", "kind": "http", - "code_entry": "internal/routes/shop.go:77 handler.AssignShopRoles", - "owner": "shop", - "method": "POST", - "path": "/:shop_id/roles", - "summary": "分配店铺默认角色", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "shop.assign_shop_roles", - "action_name": "分配店铺默认角色", - "category": "business", - "risk": "high", - "primary_resource": "shop", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, - { - "key": "http:internal/routes/shop.go:85:GET:/:shop_id/roles", - "kind": "http", - "code_entry": "internal/routes/shop.go:85 handler.GetShopRoles", + "code_entry": "internal/routes/shop.go:85 coreShopManagement", "owner": "shop", "method": "GET", - "path": "/:shop_id/roles", - "summary": "查询店铺默认角色", + "path": "/:id", + "summary": "查询店铺详情", "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5658,31 +7616,6 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, - { - "key": "http:internal/routes/shop.go:93:DELETE:/:shop_id/roles/:role_id", - "kind": "http", - "code_entry": "internal/routes/shop.go:93 handler.DeleteShopRole", - "owner": "shop", - "method": "DELETE", - "path": "/:shop_id/roles/:role_id", - "summary": "删除店铺默认角色", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", - "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "shop.delete_shop_role", - "action_name": "删除店铺默认角色", - "category": "business", - "risk": "high", - "primary_resource": "shop", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", - "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", - "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", - "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" - }, { "key": "http:internal/routes/shop_package_batch_allocation.go:15:POST:", "kind": "http", @@ -5701,6 +7634,7 @@ "primary_resource": "shop_package_batch_allocation", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5726,6 +7660,7 @@ "primary_resource": "shop_package_batch_allocation", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5750,6 +7685,7 @@ "primary_resource": "shop_package_batch_pricing", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5768,6 +7704,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5793,6 +7730,7 @@ "primary_resource": "shop_series_grant", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5812,6 +7750,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5838,6 +7777,7 @@ "primary_resource": "shop_series_grant", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5863,6 +7803,7 @@ "primary_resource": "shop_series_grant", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5888,6 +7829,7 @@ "primary_resource": "shop_series_grant", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5913,6 +7855,7 @@ "primary_resource": "storage", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5938,6 +7881,7 @@ "primary_resource": "storage", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5963,6 +7907,7 @@ "primary_resource": "super_admin", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -5977,22 +7922,18 @@ "method": "GET", "path": "/operation-password/status", "summary": "查询操作密码是否已设置", - "audit_event": "必须", + "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", - "action_code": "super_admin.get_operation_password_status", - "action_name": "查询操作密码是否已设置", - "category": "business", - "risk": "high", - "primary_resource": "super_admin", - "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", - "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", - "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" }, { "key": "http:internal/routes/system_config.go:16:GET:", @@ -6006,6 +7947,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6032,6 +7974,7 @@ "primary_resource": "system_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6051,6 +7994,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6070,6 +8014,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6095,6 +8040,7 @@ "primary_resource": "wechat_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6114,6 +8060,7 @@ "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "N/A", "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6140,6 +8087,7 @@ "primary_resource": "wechat_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6165,6 +8113,7 @@ "primary_resource": "wechat_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6190,6 +8139,7 @@ "primary_resource": "wechat_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6215,6 +8165,7 @@ "primary_resource": "wechat_config", "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", @@ -6222,9 +8173,1763 @@ "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" }, { - "key": "schedule:cmd/worker/main.go:456:constants.TaskTypeOrderExpire", + "key": "http:internal/routes/wecom.go:24:POST:/applications", + "kind": "http", + "code_entry": "internal/routes/wecom.go:24 handler.Save", + "owner": "wecom", + "method": "POST", + "path": "/applications", + "summary": "创建或更新企业微信应用配置", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom.save", + "action_name": "创建或更新企业微信应用配置", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/wecom.go:31:GET:/applications", + "kind": "http", + "code_entry": "internal/routes/wecom.go:31 handler.List", + "owner": "wecom", + "method": "GET", + "path": "/applications", + "summary": "查询企业微信应用配置", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom.list", + "action_name": "查询企业微信应用配置", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/wecom.go:38:POST:/applications/:id/test", + "kind": "http", + "code_entry": "internal/routes/wecom.go:38 handler.Test", + "owner": "wecom", + "method": "POST", + "path": "/applications/:id/test", + "summary": "测试企业微信应用连接", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom.test", + "action_name": "测试企业微信应用连接", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/wecom.go:45:PUT:/applications/:id/default-creator", + "kind": "http", + "code_entry": "internal/routes/wecom.go:45 handler.SaveDefaultCreator", + "owner": "wecom", + "method": "PUT", + "path": "/applications/:id/default-creator", + "summary": "保存企业微信应用默认审批发起人", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom.save_default_creator", + "action_name": "保存企业微信应用默认审批发起人", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/wecom.go:52:POST:/applications/:id/members/sync", + "kind": "http", + "code_entry": "internal/routes/wecom.go:52 handler.SyncMembers", + "owner": "wecom", + "method": "POST", + "path": "/applications/:id/members/sync", + "summary": "同步企业微信应用可见成员", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom.sync_members", + "action_name": "同步企业微信应用可见成员", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/wecom.go:59:GET:/applications/:id/members", + "kind": "http", + "code_entry": "internal/routes/wecom.go:59 handler.ListMembers", + "owner": "wecom", + "method": "GET", + "path": "/applications/:id/members", + "summary": "分页查询企业微信应用可见成员", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/wecom.go:66:POST:/applications/:id/templates/inspect", + "kind": "http", + "code_entry": "internal/routes/wecom.go:66 handler.InspectTemplate", + "owner": "wecom", + "method": "POST", + "path": "/applications/:id/templates/inspect", + "summary": "读取企业微信审批模板控件", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom.inspect_template", + "action_name": "读取企业微信审批模板控件", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/wecom.go:73:GET:/scenes/:business_type/fields", + "kind": "http", + "code_entry": "internal/routes/wecom.go:73 handler.ListSceneFields", + "owner": "wecom", + "method": "GET", + "path": "/scenes/:business_type/fields", + "summary": "查询企业微信审批场景可映射字段", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/wecom.go:80:PUT:/scenes/:business_type", + "kind": "http", + "code_entry": "internal/routes/wecom.go:80 handler.SaveScene", + "owner": "wecom", + "method": "PUT", + "path": "/scenes/:business_type", + "summary": "保存并校验企业微信审批场景模板映射", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom.save_scene", + "action_name": "保存并校验企业微信审批场景模板映射", + "category": "business", + "risk": "normal", + "primary_resource": "wecom", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "http:internal/routes/wecom.go:87:GET:/scenes", + "kind": "http", + "code_entry": "internal/routes/wecom.go:87 handler.ListScenes", + "owner": "wecom", + "method": "GET", + "path": "/scenes", + "summary": "分页查询企业微信审批场景配置", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "登录账号快照/admin_api", + "visibility": "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/wecom_callback.go:12:GET:/wecom/approval/:application_id", + "kind": "http", + "code_entry": "internal/routes/wecom_callback.go:12 handler.Verify", + "owner": "wecom_callback", + "method": "GET", + "path": "/wecom/approval/:application_id", + "summary": "验证企业微信审批回调地址", + "audit_event": "N/A", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "必须:业务处理前保存入站安全摘要与幂等标识", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "actor_source": "external_system/callback", + "visibility": "外部回调入口;只记录内部完整事实,不直接向外部主体展示", + "transaction": "N/A", + "failure_strategy": "Access Log 记录统一错误;普通读取不创建业务审计", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口", + "na_reason": "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值" + }, + { + "key": "http:internal/routes/wecom_callback.go:18:POST:/wecom/approval/:application_id", + "kind": "http", + "code_entry": "internal/routes/wecom_callback.go:18 handler.Receive", + "owner": "wecom_callback", + "method": "POST", + "path": "/wecom/approval/:application_id", + "summary": "接收企业微信审批状态变化回调", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "必须:业务处理前保存入站安全摘要与幂等标识", + "outbox": "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A", + "action_code": "wecom_callback.receive", + "action_name": "接收企业微信审批状态变化回调", + "category": "business", + "risk": "normal", + "primary_resource": "wecom_callback", + "affected_resource": "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展", + "actor_source": "external_system/callback", + "visibility": "外部回调入口;只记录内部完整事实,不直接向外部主体展示", + "transaction": "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical", + "sensitive_policy": "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限", + "before_after_policy": "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用", + "test_seam": "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口" + }, + { + "key": "integration_log:internal/handler/callback/cmcc_realname.go:123:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cmcc_realname.go:123 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cmcc_realname.go:140:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cmcc_realname.go:140 h.integration.Complete", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cmcc_realname.go:67:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cmcc_realname.go:67 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/ctcc_realname.go:156:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/ctcc_realname.go:156 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/ctcc_realname.go:183:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/handler/callback/ctcc_realname.go:183 h.integration.Complete", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/ctcc_realname.go:90:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/ctcc_realname.go:90 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cucc_realname.go:122:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cucc_realname.go:122 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cucc_realname.go:155:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cucc_realname.go:155 h.integration.Complete", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cucc_realname.go:68:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cucc_realname.go:68 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cucc_realname_removal.go:101:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cucc_realname_removal.go:101 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cucc_realname_removal.go:127:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cucc_realname_removal.go:127 h.integration.Complete", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/cucc_realname_removal.go:65:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/cucc_realname_removal.go:65 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/payment.go:230:h.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/handler/callback/payment.go:230 h.integration.RecordInbound", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/payment.go:260:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/handler/callback/payment.go:260 h.integration.Complete", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/handler/callback/payment.go:276:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/handler/callback/payment.go:276 h.integration.Complete", + "owner": "callback", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/cardobservation/series_runner.go:168:r.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/cardobservation/series_runner.go:168 r.integration.Complete", + "owner": "cardobservation", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/cardobservation/series_runner.go:201:r.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/cardobservation/series_runner.go:201 r.integration.Complete", + "owner": "cardobservation", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/cardobservation/series_runner.go:214:r.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/cardobservation/series_runner.go:214 r.integration.Start", + "owner": "cardobservation", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/cardobservation/series_runner.go:353:r.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/cardobservation/series_runner.go:353 r.integration.Start", + "owner": "cardobservation", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/alipay_wap.go:107:a.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/alipay_wap.go:107 a.integration.Start", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/alipay_wap.go:120:a.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/alipay_wap.go:120 a.integration.Complete", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/alipay_wap.go:65:a.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/alipay_wap.go:65 a.integration.Complete", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/alipay_wap.go:81:a.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/alipay_wap.go:81 a.integration.Complete", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/wechat_web.go:173:a.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/wechat_web.go:173 a.integration.Start", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/wechat_web.go:186:a.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/wechat_web.go:186 a.integration.Complete", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/wechat_web.go:52:a.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/wechat_web.go:52 a.integration.Complete", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/payment/wechat_web.go:78:a.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/payment/wechat_web.go:78 a.integration.Complete", + "owner": "payment", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_attachment_uploader.go:112:u.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_attachment_uploader.go:112 u.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_attachment_uploader.go:131:u.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_attachment_uploader.go:131 u.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_attachment_uploader.go:138:u.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_attachment_uploader.go:138 u.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_attachment_uploader.go:96:u.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_attachment_uploader.go:96 u.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_detail_client.go:125:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_detail_client.go:125 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_detail_client.go:56:c.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_detail_client.go:56 c.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_detail_client.go:91:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_detail_client.go:91 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_detail_task.go:114:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_detail_task.go:114 h.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_detail_task.go:81:h.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_detail_task.go:81 h.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_info_client.go:111:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_info_client.go:111 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_info_client.go:171:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_info_client.go:171 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_info_client.go:75:c.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_info_client.go:75 c.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_submission_client.go:109:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_submission_client.go:109 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_submission_client.go:138:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_submission_client.go:138 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_submission_client.go:148:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_submission_client.go:148 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_submission_client.go:80:c.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_submission_client.go:80 c.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/approval_submission_client.go:95:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/approval_submission_client.go:95 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/callback_service.go:90:s.integration.RecordInbound", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/callback_service.go:90 s.integration.RecordInbound", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/directory_client.go:121:c.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/directory_client.go:121 c.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/directory_client.go:266:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/directory_client.go:266 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/directory_client.go:278:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/directory_client.go:278 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/directory_client.go:79:c.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/directory_client.go:79 c.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/template_client.go:233:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/template_client.go:233 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/template_client.go:58:c.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/template_client.go:58 c.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/template_client.go:87:c.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/template_client.go:87 c.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/token_provider.go:165:p.integration.Start", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/token_provider.go:165 p.integration.Start", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/token_provider.go:250:p.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/token_provider.go:250 p.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/infrastructure/wecom/token_provider.go:263:p.integration.Complete", + "kind": "integration_log", + "code_entry": "internal/infrastructure/wecom/token_provider.go:263 p.integration.Complete", + "owner": "wecom", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/service/iot_card/speed_tier.go:45:s.speedTierIntegration.Start", + "kind": "integration_log", + "code_entry": "internal/service/iot_card/speed_tier.go:45 s.speedTierIntegration.Start", + "owner": "iot_card", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "integration_log:internal/service/iot_card/speed_tier.go:82:s.speedTierIntegration.Complete", + "kind": "integration_log", + "code_entry": "internal/service/iot_card/speed_tier.go:82 s.speedTierIntegration.Complete", + "owner": "iot_card", + "summary": "记录外部交互尝试或终态", + "audit_event": "N/A", + "domain_ledger": "N/A:Integration Log 只记录外部交互事实,不替代内部业务表", + "integration_log": "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要", + "outbox": "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox", + "actor_source": "external_system 或发起外呼的真实 Application/Worker/Callback", + "visibility": "仅平台内部调查完整可见;代理/企业不得读取外部交互细节", + "transaction": "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event", + "failure_strategy": "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功", + "sensitive_policy": "请求、响应和 metadata 写入前删除凭据,历史读取再次清理", + "before_after_policy": "N/A:保存外部尝试结构化摘要和本地状态是否变化", + "test_seam": "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对", + "na_reason": "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event" + }, + { + "key": "legacy_writer:internal/service/agent_recharge/service.go:235:s.auditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/agent_recharge/service.go:235 s.auditService.LogOperation", + "owner": "agent_recharge", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "agent_recharge.log_operation", + "action_name": "迁移旧审计写入", + "category": "finance", + "risk": "high", + "primary_resource": "agent_recharge", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/asset/lifecycle_service.go:50:s.assetAuditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/asset/lifecycle_service.go:50 s.assetAuditService.LogOperation", + "owner": "asset", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "asset.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "asset", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/asset/service.go:1223:s.assetAuditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/asset/service.go:1223 s.assetAuditService.LogOperation", + "owner": "asset", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "asset.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "asset", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/device/audit.go:28:s.assetAuditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/device/audit.go:28 s.assetAuditService.LogOperation", + "owner": "device", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "device.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "device", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/device_import/audit.go:31:s.assetAudit.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/device_import/audit.go:31 s.assetAudit.LogOperation", + "owner": "device_import", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "device_import.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "device_import", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/iot_card/audit.go:28:s.assetAuditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/iot_card/audit.go:28 s.assetAuditService.LogOperation", + "owner": "iot_card", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "iot_card.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "iot_card", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/iot_card/audit.go:42:s.assetAuditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/iot_card/audit.go:42 s.assetAuditService.LogOperation", + "owner": "iot_card", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "iot_card.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "iot_card", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/iot_card_import/audit.go:31:s.assetAudit.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/iot_card_import/audit.go:31 s.assetAudit.LogOperation", + "owner": "iot_card_import", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "iot_card_import.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "iot_card_import", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/polling/asset_polling_service.go:57:s.assetAuditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/polling/asset_polling_service.go:57 s.assetAuditService.LogOperation", + "owner": "polling", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "polling.log_operation", + "action_name": "迁移旧审计写入", + "category": "asset", + "risk": "normal", + "primary_resource": "polling", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/shop_package_batch_allocation/service.go:94:s.auditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/shop_package_batch_allocation/service.go:94 s.auditService.LogOperation", + "owner": "shop_package_batch_allocation", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "shop_package_batch_allocation.log_operation", + "action_name": "迁移旧审计写入", + "category": "business", + "risk": "normal", + "primary_resource": "shop_package_batch_allocation", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/wechat_config/service.go:131:s.auditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/wechat_config/service.go:131 s.auditService.LogOperation", + "owner": "wechat_config", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "wechat_config.log_operation", + "action_name": "迁移旧审计写入", + "category": "business", + "risk": "high", + "primary_resource": "wechat_config", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/wechat_config/service.go:274:s.auditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/wechat_config/service.go:274 s.auditService.LogOperation", + "owner": "wechat_config", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "wechat_config.log_operation", + "action_name": "迁移旧审计写入", + "category": "business", + "risk": "high", + "primary_resource": "wechat_config", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/wechat_config/service.go:331:s.auditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/wechat_config/service.go:331 s.auditService.LogOperation", + "owner": "wechat_config", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "wechat_config.log_operation", + "action_name": "迁移旧审计写入", + "category": "business", + "risk": "high", + "primary_resource": "wechat_config", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/wechat_config/service.go:381:s.auditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/wechat_config/service.go:381 s.auditService.LogOperation", + "owner": "wechat_config", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "wechat_config.log_operation", + "action_name": "迁移旧审计写入", + "category": "business", + "risk": "high", + "primary_resource": "wechat_config", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "legacy_writer:internal/service/wechat_config/service.go:416:s.auditService.LogOperation", + "kind": "legacy_writer", + "code_entry": "internal/service/wechat_config/service.go:416 s.auditService.LogOperation", + "owner": "wechat_config", + "summary": "调用旧 Operation Log Writer", + "audit_event": "必须迁移到统一 Audit Event 后停写旧表", + "domain_ledger": "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger", + "integration_log": "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录", + "outbox": "由原完整用例决定,旧 Writer 不得替代 Outbox", + "action_code": "wechat_config.log_operation", + "action_name": "迁移旧审计写入", + "category": "business", + "risk": "high", + "primary_resource": "wechat_config", + "affected_resource": "按原完整业务用例登记实际资源", + "actor_source": "沿用原调用入口真实操作者", + "visibility": "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影", + "transaction": "迁移后关键成功与业务事实同事务,旧异步 Writer 停写", + "failure_strategy": "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine", + "sensitive_policy": "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据", + "before_after_policy": "按资源保存本次直接变化,不复制旧单体 JSON", + "test_seam": "静态调用归零扫描 + 对应业务入口与数据库抽样核对" + }, + { + "key": "schedule:cmd/worker/main.go:680:constants.TaskTypeAgentRechargeRecovery", "kind": "scheduled_job", - "code_entry": "cmd/worker/main.go:456", + "code_entry": "cmd/worker/main.go:680", + "owner": "agent_recharge_recovery", + "summary": "按 @every 1m 调度 constants.TaskTypeAgentRechargeRecovery", + "audit_event": "N/A", + "domain_ledger": "N/A", + "integration_log": "N/A", + "outbox": "N/A", + "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", + "transaction": "N/A", + "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", + "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", + "before_after_policy": "N/A:调度入口不修改业务事实", + "test_seam": "调度注册公开函数 + 覆盖门禁静态比对本入口", + "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" + }, + { + "key": "schedule:cmd/worker/main.go:690:constants.TaskTypeOrderExpire", + "kind": "scheduled_job", + "code_entry": "cmd/worker/main.go:690", "owner": "order_expire", "summary": "按 @every 1m 调度 constants.TaskTypeOrderExpire", "audit_event": "N/A", @@ -6232,6 +9937,7 @@ "integration_log": "N/A", "outbox": "N/A", "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", "transaction": "N/A", "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", @@ -6240,9 +9946,9 @@ "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" }, { - "key": "schedule:cmd/worker/main.go:463:constants.TaskTypeAlertCheck", + "key": "schedule:cmd/worker/main.go:697:constants.TaskTypeAlertCheck", "kind": "scheduled_job", - "code_entry": "cmd/worker/main.go:463", + "code_entry": "cmd/worker/main.go:697", "owner": "alert_check", "summary": "按 @every 1m 调度 constants.TaskTypeAlertCheck", "audit_event": "N/A", @@ -6250,6 +9956,7 @@ "integration_log": "N/A", "outbox": "N/A", "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", "transaction": "N/A", "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", @@ -6258,9 +9965,28 @@ "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" }, { - "key": "schedule:cmd/worker/main.go:470:constants.TaskTypeDataCleanup", + "key": "schedule:cmd/worker/main.go:704:constants.TaskTypeWeComApprovalRecovery", "kind": "scheduled_job", - "code_entry": "cmd/worker/main.go:470", + "code_entry": "cmd/worker/main.go:704", + "owner": "we_com_approval_recovery", + "summary": "按 @every 2m 调度 constants.TaskTypeWeComApprovalRecovery", + "audit_event": "N/A", + "domain_ledger": "N/A", + "integration_log": "N/A", + "outbox": "N/A", + "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", + "transaction": "N/A", + "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", + "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", + "before_after_policy": "N/A:调度入口不修改业务事实", + "test_seam": "调度注册公开函数 + 覆盖门禁静态比对本入口", + "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" + }, + { + "key": "schedule:cmd/worker/main.go:713:constants.TaskTypeDataCleanup", + "kind": "scheduled_job", + "code_entry": "cmd/worker/main.go:713", "owner": "data_cleanup", "summary": "按 0 2 * * * 调度 constants.TaskTypeDataCleanup", "audit_event": "N/A", @@ -6268,6 +9994,7 @@ "integration_log": "N/A", "outbox": "N/A", "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", "transaction": "N/A", "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", @@ -6276,9 +10003,47 @@ "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" }, { - "key": "schedule:cmd/worker/main.go:477:constants.TaskTypeDailyTrafficFlush", + "key": "schedule:cmd/worker/main.go:720:constants.TaskTypeNotificationCleanup", "kind": "scheduled_job", - "code_entry": "cmd/worker/main.go:477", + "code_entry": "cmd/worker/main.go:720", + "owner": "notification_cleanup", + "summary": "按 15 2 * * * 调度 constants.TaskTypeNotificationCleanup", + "audit_event": "N/A", + "domain_ledger": "N/A", + "integration_log": "N/A", + "outbox": "N/A", + "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", + "transaction": "N/A", + "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", + "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", + "before_after_policy": "N/A:调度入口不修改业务事实", + "test_seam": "调度注册公开函数 + 覆盖门禁静态比对本入口", + "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" + }, + { + "key": "schedule:cmd/worker/main.go:728:constants.TaskTypePackageExpiryReminder", + "kind": "scheduled_job", + "code_entry": "cmd/worker/main.go:728", + "owner": "package_expiry_reminder", + "summary": "按 CRON_TZ=Asia/Shanghai 0 3 * * * 调度 constants.TaskTypePackageExpiryReminder", + "audit_event": "N/A", + "domain_ledger": "N/A", + "integration_log": "N/A", + "outbox": "N/A", + "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", + "transaction": "N/A", + "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", + "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", + "before_after_policy": "N/A:调度入口不修改业务事实", + "test_seam": "调度注册公开函数 + 覆盖门禁静态比对本入口", + "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" + }, + { + "key": "schedule:cmd/worker/main.go:737:constants.TaskTypeDailyTrafficFlush", + "kind": "scheduled_job", + "code_entry": "cmd/worker/main.go:737", "owner": "daily_traffic_flush", "summary": "按 0 2 * * * 调度 constants.TaskTypeDailyTrafficFlush", "audit_event": "N/A", @@ -6286,6 +10051,7 @@ "integration_log": "N/A", "outbox": "N/A", "actor_source": "system_task/scheduled_job", + "visibility": "内部系统入口,不直接对用户展示", "transaction": "N/A", "failure_strategy": "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责", "sensitive_policy": "调度日志仅记录任务类型与安全时间信息", @@ -6294,9 +10060,33 @@ "na_reason": "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker" }, { - "key": "service:internal/service/account/service.go:195:Update", + "key": "service:internal/service/account/service.go:198:BindWeCom", "kind": "service", - "code_entry": "internal/service/account/service.go:195 Update", + "code_entry": "internal/service/account/service.go:198 BindWeCom", + "owner": "account", + "summary": "业务方法 BindWeCom", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "account.bind_we_com", + "action_name": "执行业务方法(BindWeCom)", + "category": "security", + "risk": "normal", + "primary_resource": "account", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/account/service.go:268:Update", + "kind": "service", + "code_entry": "internal/service/account/service.go:268 Update", "owner": "account", "summary": "业务方法 Update", "audit_event": "必须", @@ -6310,6 +10100,7 @@ "primary_resource": "account", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6317,9 +10108,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/account/service.go:296:Delete", + "key": "service:internal/service/account/service.go:365:Delete", "kind": "service", - "code_entry": "internal/service/account/service.go:296 Delete", + "code_entry": "internal/service/account/service.go:365 Delete", "owner": "account", "summary": "业务方法 Delete", "audit_event": "必须", @@ -6333,6 +10124,7 @@ "primary_resource": "account", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6340,9 +10132,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/account/service.go:412:AssignRoles", + "key": "service:internal/service/account/service.go:465:AssignRoles", "kind": "service", - "code_entry": "internal/service/account/service.go:412 AssignRoles", + "code_entry": "internal/service/account/service.go:465 AssignRoles", "owner": "account", "summary": "业务方法 AssignRoles", "audit_event": "必须", @@ -6350,12 +10142,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "account.assign_roles", - "action_name": "执行业务方法(AssignRoles)", + "action_name": "为账号分配角色", "category": "security", "risk": "high", "primary_resource": "account", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6363,9 +10156,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/account/service.go:563:RemoveRole", + "key": "service:internal/service/account/service.go:616:RemoveRole", "kind": "service", - "code_entry": "internal/service/account/service.go:563 RemoveRole", + "code_entry": "internal/service/account/service.go:616 RemoveRole", "owner": "account", "summary": "业务方法 RemoveRole", "audit_event": "必须", @@ -6373,12 +10166,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "account.remove_role", - "action_name": "执行业务方法(RemoveRole)", + "action_name": "移除账号角色", "category": "security", "risk": "high", "primary_resource": "account", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6386,32 +10180,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/account/service.go:62:Create", + "key": "service:internal/service/account/service.go:684:UpdatePassword", "kind": "service", - "code_entry": "internal/service/account/service.go:62 Create", - "owner": "account", - "summary": "业务方法 Create", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "account.create", - "action_name": "执行业务方法(Create)", - "category": "security", - "risk": "normal", - "primary_resource": "account", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/account/service.go:631:UpdatePassword", - "kind": "service", - "code_entry": "internal/service/account/service.go:631 UpdatePassword", + "code_entry": "internal/service/account/service.go:684 UpdatePassword", "owner": "account", "summary": "业务方法 UpdatePassword", "audit_event": "必须", @@ -6425,6 +10196,7 @@ "primary_resource": "account", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6432,9 +10204,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/account/service.go:658:UpdateStatus", + "key": "service:internal/service/account/service.go:766:UpdateStatus", "kind": "service", - "code_entry": "internal/service/account/service.go:658 UpdateStatus", + "code_entry": "internal/service/account/service.go:766 UpdateStatus", "owner": "account", "summary": "业务方法 UpdateStatus", "audit_event": "必须", @@ -6448,6 +10220,7 @@ "primary_resource": "account", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6455,9 +10228,33 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/account/service.go:708:CreateSystemAccount", + "key": "service:internal/service/account/service.go:87:Create", "kind": "service", - "code_entry": "internal/service/account/service.go:708 CreateSystemAccount", + "code_entry": "internal/service/account/service.go:87 Create", + "owner": "account", + "summary": "业务方法 Create", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "account.create", + "action_name": "执行业务方法(Create)", + "category": "security", + "risk": "normal", + "primary_resource": "account", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/account/service.go:919:CreateSystemAccount", + "kind": "service", + "code_entry": "internal/service/account/service.go:919 CreateSystemAccount", "owner": "account", "summary": "业务方法 CreateSystemAccount", "audit_event": "必须", @@ -6471,6 +10268,7 @@ "primary_resource": "account", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6478,9 +10276,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/agent_open_api/service.go:193:ResumeCard", + "key": "service:internal/service/agent_open_api/service.go:218:ResumeCard", "kind": "service", - "code_entry": "internal/service/agent_open_api/service.go:193 ResumeCard", + "code_entry": "internal/service/agent_open_api/service.go:218 ResumeCard", "owner": "agent_open_api", "summary": "业务方法 ResumeCard", "audit_event": "必须", @@ -6494,6 +10292,7 @@ "primary_resource": "agent_open_api", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6501,9 +10300,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/agent_open_api/service.go:296:CreateWalletPackageOrders", + "key": "service:internal/service/agent_open_api/service.go:348:CreateWalletPackageOrders", "kind": "service", - "code_entry": "internal/service/agent_open_api/service.go:296 CreateWalletPackageOrders", + "code_entry": "internal/service/agent_open_api/service.go:348 CreateWalletPackageOrders", "owner": "agent_open_api", "summary": "业务方法 CreateWalletPackageOrders", "audit_event": "必须", @@ -6517,6 +10316,7 @@ "primary_resource": "agent_open_api", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6524,9 +10324,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/agent_open_api/service.go:470:ResetDevice", + "key": "service:internal/service/agent_open_api/service.go:558:ResetDevice", "kind": "service", - "code_entry": "internal/service/agent_open_api/service.go:470 ResetDevice", + "code_entry": "internal/service/agent_open_api/service.go:558 ResetDevice", "owner": "agent_open_api", "summary": "业务方法 ResetDevice", "audit_event": "必须", @@ -6540,6 +10340,7 @@ "primary_resource": "agent_open_api", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6547,9 +10348,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/agent_recharge/service.go:296:HandlePaymentCallback", + "key": "service:internal/service/agent_recharge/service.go:273:HandlePaymentCallback", "kind": "service", - "code_entry": "internal/service/agent_recharge/service.go:296 HandlePaymentCallback", + "code_entry": "internal/service/agent_recharge/service.go:273 HandlePaymentCallback", "owner": "agent_recharge", "summary": "业务方法 HandlePaymentCallback", "audit_event": "必须", @@ -6563,6 +10364,7 @@ "primary_resource": "agent_recharge", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6570,9 +10372,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/agent_recharge/service.go:392:Reject", + "key": "service:internal/service/agent_recharge/service.go:378:Reject", "kind": "service", - "code_entry": "internal/service/agent_recharge/service.go:392 Reject", + "code_entry": "internal/service/agent_recharge/service.go:378 Reject", "owner": "agent_recharge", "summary": "业务方法 Reject", "audit_event": "必须", @@ -6586,6 +10388,7 @@ "primary_resource": "agent_recharge", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6593,9 +10396,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/agent_recharge/service.go:82:Create", + "key": "service:internal/service/agent_recharge/service.go:95:Create", "kind": "service", - "code_entry": "internal/service/agent_recharge/service.go:82 Create", + "code_entry": "internal/service/agent_recharge/service.go:95 Create", "owner": "agent_recharge", "summary": "业务方法 Create", "audit_event": "必须", @@ -6609,6 +10412,7 @@ "primary_resource": "agent_recharge", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6632,6 +10436,7 @@ "primary_resource": "asset", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6655,6 +10460,7 @@ "primary_resource": "asset", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6678,6 +10484,7 @@ "primary_resource": "asset", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6701,29 +10508,7 @@ "primary_resource": "asset", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/asset/service.go:60:SetPackageExpiryQuery", - "kind": "service", - "code_entry": "internal/service/asset/service.go:60 SetPackageExpiryQuery", - "owner": "asset", - "summary": "业务方法 SetPackageExpiryQuery", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "asset.set_package_expiry_query", - "action_name": "执行业务方法(SetPackageExpiryQuery)", - "category": "asset", - "risk": "normal", - "primary_resource": "asset", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6747,6 +10532,7 @@ "primary_resource": "asset", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6770,6 +10556,7 @@ "primary_resource": "asset", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6777,9 +10564,33 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/auth/service.go:140:Logout", + "key": "service:internal/service/asset_package_batch_order/service.go:42:Create", "kind": "service", - "code_entry": "internal/service/auth/service.go:140 Logout", + "code_entry": "internal/service/asset_package_batch_order/service.go:42 Create", + "owner": "asset_package_batch_order", + "summary": "业务方法 Create", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "asset_package_batch_order.create", + "action_name": "执行业务方法(Create)", + "category": "finance", + "risk": "normal", + "primary_resource": "asset_package_batch_order", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/auth/service.go:170:Logout", + "kind": "service", + "code_entry": "internal/service/auth/service.go:170 Logout", "owner": "auth", "summary": "业务方法 Logout", "audit_event": "必须", @@ -6793,6 +10604,7 @@ "primary_resource": "auth", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6800,9 +10612,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/auth/service.go:154:RefreshToken", + "key": "service:internal/service/auth/service.go:197:RefreshToken", "kind": "service", - "code_entry": "internal/service/auth/service.go:154 RefreshToken", + "code_entry": "internal/service/auth/service.go:197 RefreshToken", "owner": "auth", "summary": "业务方法 RefreshToken", "audit_event": "必须", @@ -6816,6 +10628,7 @@ "primary_resource": "auth", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6823,9 +10636,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/auth/service.go:178:ChangePassword", + "key": "service:internal/service/auth/service.go:243:ChangePassword", "kind": "service", - "code_entry": "internal/service/auth/service.go:178 ChangePassword", + "code_entry": "internal/service/auth/service.go:243 ChangePassword", "owner": "auth", "summary": "业务方法 ChangePassword", "audit_event": "必须", @@ -6839,6 +10652,7 @@ "primary_resource": "auth", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6846,9 +10660,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/auth/service.go:48:Login", + "key": "service:internal/service/auth/service.go:60:Login", "kind": "service", - "code_entry": "internal/service/auth/service.go:48 Login", + "code_entry": "internal/service/auth/service.go:60 Login", "owner": "auth", "summary": "业务方法 Login", "audit_event": "必须", @@ -6862,6 +10676,7 @@ "primary_resource": "auth", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6885,6 +10700,7 @@ "primary_resource": "carrier", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6908,6 +10724,7 @@ "primary_resource": "carrier", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6931,6 +10748,7 @@ "primary_resource": "carrier", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6954,6 +10772,7 @@ "primary_resource": "carrier", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -6961,45 +10780,42 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/client_auth/service.go:265:SendCode", + "key": "service:internal/service/client_auth/service.go:268:SendCode", "kind": "service", - "code_entry": "internal/service/client_auth/service.go:265 SendCode", + "code_entry": "internal/service/client_auth/service.go:268 SendCode", "owner": "client_auth", "summary": "业务方法 SendCode", - "audit_event": "必须", + "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "client_auth.send_code", - "action_name": "执行业务方法(SendCode)", - "category": "security", - "risk": "normal", - "primary_resource": "client_auth", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "actor_source": "未认证个人入口/personal_api", + "visibility": "验证码不进入 Audit Event", + "transaction": "N/A", + "failure_strategy": "验证码发送和限流进入 Access/Integration/Security 事实", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口", + "na_reason": "验证码是禁止写入审计的短期安全凭据,不属于个人身份资料变化" }, { - "key": "service:internal/service/client_auth/service.go:289:BindPhone", + "key": "service:internal/service/client_auth/service.go:292:BindPhone", "kind": "service", - "code_entry": "internal/service/client_auth/service.go:289 BindPhone", + "code_entry": "internal/service/client_auth/service.go:292 BindPhone", "owner": "client_auth", "summary": "业务方法 BindPhone", "audit_event": "必须", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "client_auth.bind_phone", - "action_name": "执行业务方法(BindPhone)", - "category": "security", + "action_code": "personal_customer.bind_phone", + "action_name": "绑定个人手机号", + "category": "identity", "risk": "normal", - "primary_resource": "client_auth", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", + "primary_resource": "personal_customer", + "affected_resource": "personal_customer_phone", + "actor_source": "personal_customer/personal_api", + "visibility": "subject_detail,仅返回 Registry 白名单 phone 字段", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7007,22 +10823,23 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/client_auth/service.go:332:ChangePhone", + "key": "service:internal/service/client_auth/service.go:335:ChangePhone", "kind": "service", - "code_entry": "internal/service/client_auth/service.go:332 ChangePhone", + "code_entry": "internal/service/client_auth/service.go:335 ChangePhone", "owner": "client_auth", "summary": "业务方法 ChangePhone", "audit_event": "必须", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "client_auth.change_phone", - "action_name": "执行业务方法(ChangePhone)", - "category": "security", + "action_code": "personal_customer.change_phone", + "action_name": "更换个人手机号", + "category": "identity", "risk": "normal", - "primary_resource": "client_auth", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", + "primary_resource": "personal_customer", + "affected_resource": "personal_customer_phone", + "actor_source": "personal_customer/personal_api", + "visibility": "subject_detail,仅返回 Registry 白名单 phone 字段", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7030,9 +10847,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/client_auth/service.go:380:Logout", + "key": "service:internal/service/client_auth/service.go:383:Logout", "kind": "service", - "code_entry": "internal/service/client_auth/service.go:380 Logout", + "code_entry": "internal/service/client_auth/service.go:383 Logout", "owner": "client_auth", "summary": "业务方法 Logout", "audit_event": "必须", @@ -7046,6 +10863,7 @@ "primary_resource": "client_auth", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7069,6 +10887,7 @@ "primary_resource": "client_order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7092,6 +10911,7 @@ "primary_resource": "client_order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7115,6 +10935,7 @@ "primary_resource": "client_order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7122,9 +10943,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/client_order/service.go:1208:PayOrder", + "key": "service:internal/service/client_order/service.go:1293:PayOrder", "kind": "service", - "code_entry": "internal/service/client_order/service.go:1208 PayOrder", + "code_entry": "internal/service/client_order/service.go:1293 PayOrder", "owner": "client_order", "summary": "业务方法 PayOrder", "audit_event": "必须", @@ -7138,6 +10959,7 @@ "primary_resource": "client_order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7145,9 +10967,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/client_order/service.go:122:CreateOrder", + "key": "service:internal/service/client_order/service.go:134:CreateOrder", "kind": "service", - "code_entry": "internal/service/client_order/service.go:122 CreateOrder", + "code_entry": "internal/service/client_order/service.go:134 CreateOrder", "owner": "client_order", "summary": "业务方法 CreateOrder", "audit_event": "必须", @@ -7161,6 +10983,7 @@ "primary_resource": "client_order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7184,6 +11007,7 @@ "primary_resource": "commission_calculation", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7207,6 +11031,7 @@ "primary_resource": "commission_calculation", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7230,6 +11055,7 @@ "primary_resource": "commission_calculation", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7253,6 +11079,7 @@ "primary_resource": "commission_stats", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7276,6 +11103,7 @@ "primary_resource": "commission_stats", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7299,6 +11127,7 @@ "primary_resource": "commission_withdrawal", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7322,6 +11151,7 @@ "primary_resource": "commission_withdrawal", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7345,6 +11175,7 @@ "primary_resource": "commission_withdrawal_setting", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7352,9 +11183,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/customer_binding/service.go:150:Bind", + "key": "service:internal/service/customer_binding/service.go:218:Bind", "kind": "service", - "code_entry": "internal/service/customer_binding/service.go:150 Bind", + "code_entry": "internal/service/customer_binding/service.go:218 Bind", "owner": "customer_binding", "summary": "业务方法 Bind", "audit_event": "必须", @@ -7368,6 +11199,7 @@ "primary_resource": "customer_binding", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7375,9 +11207,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/customer_binding/service.go:253:Migrate", + "key": "service:internal/service/customer_binding/service.go:321:Migrate", "kind": "service", - "code_entry": "internal/service/customer_binding/service.go:253 Migrate", + "code_entry": "internal/service/customer_binding/service.go:321 Migrate", "owner": "customer_binding", "summary": "业务方法 Migrate", "audit_event": "必须", @@ -7391,6 +11223,7 @@ "primary_resource": "customer_binding", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7414,6 +11247,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7437,6 +11271,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7444,9 +11279,33 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/device/service.go:1090:BatchSetSeriesBinding", + "key": "service:internal/service/device/realname_policy_batch.go:16:BatchUpdateRealnamePolicy", "kind": "service", - "code_entry": "internal/service/device/service.go:1090 BatchSetSeriesBinding", + "code_entry": "internal/service/device/realname_policy_batch.go:16 BatchUpdateRealnamePolicy", + "owner": "device", + "summary": "业务方法 BatchUpdateRealnamePolicy", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "device.batch_update_realname_policy", + "action_name": "执行业务方法(BatchUpdateRealnamePolicy)", + "category": "asset", + "risk": "normal", + "primary_resource": "device", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/device/service.go:1190:BatchSetSeriesBinding", + "kind": "service", + "code_entry": "internal/service/device/service.go:1190 BatchSetSeriesBinding", "owner": "device", "summary": "业务方法 BatchSetSeriesBinding", "audit_event": "必须", @@ -7460,6 +11319,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7467,9 +11327,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/device/service.go:1513:StopDevice", + "key": "service:internal/service/device/service.go:1613:StopDevice", "kind": "service", - "code_entry": "internal/service/device/service.go:1513 StopDevice", + "code_entry": "internal/service/device/service.go:1613 StopDevice", "owner": "device", "summary": "业务方法 StopDevice", "audit_event": "必须", @@ -7483,6 +11343,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7490,9 +11351,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/device/service.go:1982:UpdateRealnamePolicy", + "key": "service:internal/service/device/service.go:2082:UpdateRealnamePolicy", "kind": "service", - "code_entry": "internal/service/device/service.go:1982 UpdateRealnamePolicy", + "code_entry": "internal/service/device/service.go:2082 UpdateRealnamePolicy", "owner": "device", "summary": "业务方法 UpdateRealnamePolicy", "audit_event": "必须", @@ -7506,6 +11367,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7513,9 +11375,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/device/service.go:320:Delete", + "key": "service:internal/service/device/service.go:416:Delete", "kind": "service", - "code_entry": "internal/service/device/service.go:320 Delete", + "code_entry": "internal/service/device/service.go:416 Delete", "owner": "device", "summary": "业务方法 Delete", "audit_event": "必须", @@ -7529,6 +11391,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7536,9 +11399,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/device/service.go:434:AllocateDevices", + "key": "service:internal/service/device/service.go:530:AllocateDevices", "kind": "service", - "code_entry": "internal/service/device/service.go:434 AllocateDevices", + "code_entry": "internal/service/device/service.go:530 AllocateDevices", "owner": "device", "summary": "业务方法 AllocateDevices", "audit_event": "必须", @@ -7552,6 +11415,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7559,32 +11423,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/device/service.go:43:SetPackageExpiryQuery", + "key": "service:internal/service/device/service.go:739:RecallDevices", "kind": "service", - "code_entry": "internal/service/device/service.go:43 SetPackageExpiryQuery", - "owner": "device", - "summary": "业务方法 SetPackageExpiryQuery", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "device.set_package_expiry_query", - "action_name": "执行业务方法(SetPackageExpiryQuery)", - "category": "asset", - "risk": "normal", - "primary_resource": "device", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/device/service.go:640:RecallDevices", - "kind": "service", - "code_entry": "internal/service/device/service.go:640 RecallDevices", + "code_entry": "internal/service/device/service.go:739 RecallDevices", "owner": "device", "summary": "业务方法 RecallDevices", "audit_event": "必须", @@ -7598,6 +11439,7 @@ "primary_resource": "device", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7605,9 +11447,33 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/device_import/service.go:45:CreateImportTask", + "key": "service:internal/service/device_import/service.go:100:CreateBatchAllocationTask", "kind": "service", - "code_entry": "internal/service/device_import/service.go:45 CreateImportTask", + "code_entry": "internal/service/device_import/service.go:100 CreateBatchAllocationTask", + "owner": "device_import", + "summary": "业务方法 CreateBatchAllocationTask", + "audit_event": "必须", + "domain_ledger": "业务任务及明细表是批量结果权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "device_import.create_batch_allocation_task", + "action_name": "执行业务方法(CreateBatchAllocationTask)", + "category": "asset", + "risk": "normal", + "primary_resource": "device_import", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/device_import/service.go:46:CreateImportTask", + "kind": "service", + "code_entry": "internal/service/device_import/service.go:46 CreateImportTask", "owner": "device_import", "summary": "业务方法 CreateImportTask", "audit_event": "必须", @@ -7621,6 +11487,7 @@ "primary_resource": "device_import", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7644,6 +11511,7 @@ "primary_resource": "email", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7667,6 +11535,7 @@ "primary_resource": "email", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7690,6 +11559,7 @@ "primary_resource": "email", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7697,9 +11567,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise/service.go:142:Update", + "key": "service:internal/service/enterprise/service.go:189:Update", "kind": "service", - "code_entry": "internal/service/enterprise/service.go:142 Update", + "code_entry": "internal/service/enterprise/service.go:189 Update", "owner": "enterprise", "summary": "业务方法 Update", "audit_event": "必须", @@ -7711,8 +11581,9 @@ "category": "business", "risk": "normal", "primary_resource": "enterprise", - "affected_resource": "完整用例直接修改或引用的资源", + "affected_resource": "归属店铺作为 reference;不改变 owner_shop_id", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7720,9 +11591,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise/service.go:202:UpdateStatus", + "key": "service:internal/service/enterprise/service.go:250:UpdateStatus", "kind": "service", - "code_entry": "internal/service/enterprise/service.go:202 UpdateStatus", + "code_entry": "internal/service/enterprise/service.go:250 UpdateStatus", "owner": "enterprise", "summary": "业务方法 UpdateStatus", "audit_event": "必须", @@ -7734,8 +11605,9 @@ "category": "business", "risk": "normal", "primary_resource": "enterprise", - "affected_resource": "完整用例直接修改或引用的资源", + "affected_resource": "实际同步状态的企业账号;归属店铺作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7743,9 +11615,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise/service.go:233:UpdatePassword", + "key": "service:internal/service/enterprise/service.go:318:UpdatePassword", "kind": "service", - "code_entry": "internal/service/enterprise/service.go:233 UpdatePassword", + "code_entry": "internal/service/enterprise/service.go:318 UpdatePassword", "owner": "enterprise", "summary": "业务方法 UpdatePassword", "audit_event": "必须", @@ -7757,18 +11629,19 @@ "category": "business", "risk": "high", "primary_resource": "enterprise", - "affected_resource": "完整用例直接修改或引用的资源", + "affected_resource": "实际改密的企业账号;归属店铺作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "sensitive_policy": "密码明文与散列均禁止进入审计;只保存 credentials_configured/state 安全事实", "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise/service.go:33:Create", + "key": "service:internal/service/enterprise/service.go:52:Create", "kind": "service", - "code_entry": "internal/service/enterprise/service.go:33 Create", + "code_entry": "internal/service/enterprise/service.go:52 Create", "owner": "enterprise", "summary": "业务方法 Create", "audit_event": "必须", @@ -7780,8 +11653,9 @@ "category": "business", "risk": "normal", "primary_resource": "enterprise", - "affected_resource": "完整用例直接修改或引用的资源", + "affected_resource": "企业初始账号;归属店铺作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7789,32 +11663,29 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise_card/authorization_service.go:146:RevokeAuthorizations", + "key": "service:internal/service/enterprise_card/authorization_service.go:154:RevokeAuthorizations", "kind": "service", - "code_entry": "internal/service/enterprise_card/authorization_service.go:146 RevokeAuthorizations", + "code_entry": "internal/service/enterprise_card/authorization_service.go:154 RevokeAuthorizations", "owner": "enterprise_card", "summary": "业务方法 RevokeAuthorizations", - "audit_event": "必须", + "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "enterprise_card.revoke_authorizations", - "action_name": "执行业务方法(RevokeAuthorizations)", - "category": "asset", - "risk": "normal", - "primary_resource": "enterprise_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "outbox": "N/A:无生产调用方", + "affected_resource": "N/A:遗留未调用方法", + "actor_source": "N/A:无生产调用入口", + "visibility": "N/A:无生产调用入口", + "transaction": "N/A:无生产调用方", + "failure_strategy": "N/A:无生产调用方", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + "test_seam": "静态扫描生产调用关系", + "na_reason": "无生产调用方,仅为遗留未调用方法;生产回收统一由 Service.RecallCards 承担" }, { - "key": "service:internal/service/enterprise_card/authorization_service.go:401:UpdateRecordRemark", + "key": "service:internal/service/enterprise_card/authorization_service.go:409:UpdateRecordRemark", "kind": "service", - "code_entry": "internal/service/enterprise_card/authorization_service.go:401 UpdateRecordRemark", + "code_entry": "internal/service/enterprise_card/authorization_service.go:409 UpdateRecordRemark", "owner": "enterprise_card", "summary": "业务方法 UpdateRecordRemark", "audit_event": "必须", @@ -7822,12 +11693,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "enterprise_card.update_record_remark", - "action_name": "执行业务方法(UpdateRecordRemark)", + "action_name": "更新企业卡授权备注", "category": "asset", "risk": "normal", - "primary_resource": "enterprise_card", - "affected_resource": "完整用例直接修改或引用的资源", + "primary_resource": "enterprise", + "affected_resource": "企业卡授权记录;IoT 卡与 owner shop 作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "授权记录与备注仅 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7835,32 +11707,29 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise_card/authorization_service.go:46:BatchAuthorize", + "key": "service:internal/service/enterprise_card/authorization_service.go:54:BatchAuthorize", "kind": "service", - "code_entry": "internal/service/enterprise_card/authorization_service.go:46 BatchAuthorize", + "code_entry": "internal/service/enterprise_card/authorization_service.go:54 BatchAuthorize", "owner": "enterprise_card", "summary": "业务方法 BatchAuthorize", - "audit_event": "必须", + "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "enterprise_card.batch_authorize", - "action_name": "执行业务方法(BatchAuthorize)", - "category": "asset", - "risk": "normal", - "primary_resource": "enterprise_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "outbox": "N/A:无生产调用方", + "affected_resource": "N/A:遗留未调用方法", + "actor_source": "N/A:无生产调用入口", + "visibility": "N/A:无生产调用入口", + "transaction": "N/A:无生产调用方", + "failure_strategy": "N/A:无生产调用方", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + "test_seam": "静态扫描生产调用关系", + "na_reason": "无生产调用方,仅为遗留未调用方法;生产授权统一由 Service.AllocateCards 承担" }, { - "key": "service:internal/service/enterprise_card/service.go:201:AllocateCards", + "key": "service:internal/service/enterprise_card/service.go:207:AllocateCards", "kind": "service", - "code_entry": "internal/service/enterprise_card/service.go:201 AllocateCards", + "code_entry": "internal/service/enterprise_card/service.go:207 AllocateCards", "owner": "enterprise_card", "summary": "业务方法 AllocateCards", "audit_event": "必须", @@ -7868,12 +11737,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "enterprise_card.allocate_cards", - "action_name": "执行业务方法(AllocateCards)", + "action_name": "向企业授权卡", "category": "asset", "risk": "normal", - "primary_resource": "enterprise_card", - "affected_resource": "完整用例直接修改或引用的资源", + "primary_resource": "enterprise", + "affected_resource": "实际新增授权的 IoT 卡和企业卡授权记录;owner shop 作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "IoT 卡保存 subject_result;授权记录 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7881,9 +11751,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise_card/service.go:286:RecallCards", + "key": "service:internal/service/enterprise_card/service.go:344:RecallCards", "kind": "service", - "code_entry": "internal/service/enterprise_card/service.go:286 RecallCards", + "code_entry": "internal/service/enterprise_card/service.go:344 RecallCards", "owner": "enterprise_card", "summary": "业务方法 RecallCards", "audit_event": "必须", @@ -7891,12 +11761,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "enterprise_card.recall_cards", - "action_name": "执行业务方法(RecallCards)", + "action_name": "回收企业卡授权", "category": "asset", "risk": "normal", - "primary_resource": "enterprise_card", - "affected_resource": "完整用例直接修改或引用的资源", + "primary_resource": "enterprise", + "affected_resource": "实际撤销授权的 IoT 卡和企业卡授权记录;owner shop 作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "IoT 卡保存 subject_result;授权记录 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7904,27 +11775,24 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise_card/service.go:37:AllocateCardsPreview", + "key": "service:internal/service/enterprise_card/service.go:43:AllocateCardsPreview", "kind": "service", - "code_entry": "internal/service/enterprise_card/service.go:37 AllocateCardsPreview", + "code_entry": "internal/service/enterprise_card/service.go:43 AllocateCardsPreview", "owner": "enterprise_card", "summary": "业务方法 AllocateCardsPreview", - "audit_event": "必须", + "audit_event": "N/A", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "enterprise_card.allocate_cards_preview", - "action_name": "执行业务方法(AllocateCardsPreview)", - "category": "asset", - "risk": "normal", - "primary_resource": "enterprise_card", - "affected_resource": "完整用例直接修改或引用的资源", + "outbox": "N/A:纯读预览不产生可靠副作用", + "affected_resource": "N/A:仅读取企业和卡的当前状态", "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "N/A:普通查询", + "failure_strategy": "N/A:查询失败由 Access Log 记录", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + "test_seam": "静态覆盖清单与纯读调用链比对", + "na_reason": "授权预览是普通查询,不改变业务事实,且未被 Action Registry 标记为敏感读取" }, { "key": "service:internal/service/enterprise_card/service.go:482:ResumeCard", @@ -7943,6 +11811,7 @@ "primary_resource": "enterprise_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -7950,9 +11819,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise_device/service.go:332:RecallDevices", + "key": "service:internal/service/enterprise_device/service.go:407:RecallDevices", "kind": "service", - "code_entry": "internal/service/enterprise_device/service.go:332 RecallDevices", + "code_entry": "internal/service/enterprise_device/service.go:407 RecallDevices", "owner": "enterprise_device", "summary": "业务方法 RecallDevices", "audit_event": "必须", @@ -7960,22 +11829,23 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "enterprise_device.recall_devices", - "action_name": "执行业务方法(RecallDevices)", + "action_name": "回收企业设备授权", "category": "asset", "risk": "normal", - "primary_resource": "enterprise_device", - "affected_resource": "完整用例直接修改或引用的资源", + "primary_resource": "enterprise", + "affected_resource": "实际撤销的设备授权记录及其 device_auth_id 关联卡授权、设备和卡;owner shop 和卡槽绑定作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "设备和卡仅保存 subject_result;授权记录与卡槽绑定 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "failure_strategy": "参数错误不写 Audit Event;权限、全项规则拒绝或并发零命中写 denied 短事务;业务回滚后的 failed 使用独立短事务,审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/enterprise_device/service.go:50:AllocateDevices", + "key": "service:internal/service/enterprise_device/service.go:56:AllocateDevices", "kind": "service", - "code_entry": "internal/service/enterprise_device/service.go:50 AllocateDevices", + "code_entry": "internal/service/enterprise_device/service.go:56 AllocateDevices", "owner": "enterprise_device", "summary": "业务方法 AllocateDevices", "audit_event": "必须", @@ -7983,22 +11853,23 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "enterprise_device.allocate_devices", - "action_name": "执行业务方法(AllocateDevices)", + "action_name": "向企业授权设备", "category": "asset", "risk": "normal", - "primary_resource": "enterprise_device", - "affected_resource": "完整用例直接修改或引用的资源", + "primary_resource": "enterprise", + "affected_resource": "实际授权设备、设备授权记录及随设备授权的绑定卡/卡授权记录;owner shop 和卡槽绑定作为 reference", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "设备和卡仅保存 subject_result;授权记录与卡槽绑定 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "failure_strategy": "参数错误不写 Audit Event;权限、全项规则拒绝或并发零命中写 denied 短事务;业务回滚后的 failed 使用独立短事务,审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/exchange/service.go:178:Cancel", + "key": "service:internal/service/exchange/service.go:224:Cancel", "kind": "service", - "code_entry": "internal/service/exchange/service.go:178 Cancel", + "code_entry": "internal/service/exchange/service.go:224 Cancel", "owner": "exchange", "summary": "业务方法 Cancel", "audit_event": "必须", @@ -8012,6 +11883,7 @@ "primary_resource": "exchange", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8019,9 +11891,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/exchange/service.go:340:SubmitShippingInfo", + "key": "service:internal/service/exchange/service.go:386:SubmitShippingInfo", "kind": "service", - "code_entry": "internal/service/exchange/service.go:340 SubmitShippingInfo", + "code_entry": "internal/service/exchange/service.go:386 SubmitShippingInfo", "owner": "exchange", "summary": "业务方法 SubmitShippingInfo", "audit_event": "必须", @@ -8035,6 +11907,7 @@ "primary_resource": "exchange", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8042,9 +11915,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/exchange/service.go:62:Create", + "key": "service:internal/service/exchange/service.go:71:Create", "kind": "service", - "code_entry": "internal/service/exchange/service.go:62 Create", + "code_entry": "internal/service/exchange/service.go:71 Create", "owner": "exchange", "summary": "业务方法 Create", "audit_event": "必须", @@ -8058,6 +11931,7 @@ "primary_resource": "exchange", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8081,6 +11955,7 @@ "primary_resource": "export_task", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8104,6 +11979,7 @@ "primary_resource": "export_task", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8111,9 +11987,33 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:1101:BatchSetSeriesBinding", + "key": "service:internal/service/iot_card/realname_policy_batch.go:17:BatchUpdateRealnamePolicy", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:1101 BatchSetSeriesBinding", + "code_entry": "internal/service/iot_card/realname_policy_batch.go:17 BatchUpdateRealnamePolicy", + "owner": "iot_card", + "summary": "业务方法 BatchUpdateRealnamePolicy", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "iot_card.batch_update_realname_policy", + "action_name": "执行业务方法(BatchUpdateRealnamePolicy)", + "category": "asset", + "risk": "normal", + "primary_resource": "iot_card", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/iot_card/service.go:1107:BatchSetSeriesBinding", + "kind": "service", + "code_entry": "internal/service/iot_card/service.go:1107 BatchSetSeriesBinding", "owner": "iot_card", "summary": "业务方法 BatchSetSeriesBinding", "audit_event": "必须", @@ -8127,6 +12027,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8134,170 +12035,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:113:SetPollingCallback", + "key": "service:internal/service/iot_card/service.go:1522:RefreshCardDataByID", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:113 SetPollingCallback", - "owner": "iot_card", - "summary": "业务方法 SetPollingCallback", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_polling_callback", - "action_name": "执行业务方法(SetPollingCallback)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:118:SetAssetIdentifierStore", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:118 SetAssetIdentifierStore", - "owner": "iot_card", - "summary": "业务方法 SetAssetIdentifierStore", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_asset_identifier_store", - "action_name": "执行业务方法(SetAssetIdentifierStore)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:123:SetEnterpriseCardAuthStore", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:123 SetEnterpriseCardAuthStore", - "owner": "iot_card", - "summary": "业务方法 SetEnterpriseCardAuthStore", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_enterprise_card_auth_store", - "action_name": "执行业务方法(SetEnterpriseCardAuthStore)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:128:SetEnterpriseStore", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:128 SetEnterpriseStore", - "owner": "iot_card", - "summary": "业务方法 SetEnterpriseStore", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_enterprise_store", - "action_name": "执行业务方法(SetEnterpriseStore)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:134:SetDataDeductor", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:134 SetDataDeductor", - "owner": "iot_card", - "summary": "业务方法 SetDataDeductor", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_data_deductor", - "action_name": "执行业务方法(SetDataDeductor)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:140:SetRealnameActivator", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:140 SetRealnameActivator", - "owner": "iot_card", - "summary": "业务方法 SetRealnameActivator", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_realname_activator", - "action_name": "执行业务方法(SetRealnameActivator)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:146:SetStopResumeService", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:146 SetStopResumeService", - "owner": "iot_card", - "summary": "业务方法 SetStopResumeService", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_stop_resume_service", - "action_name": "执行业务方法(SetStopResumeService)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:1516:RefreshCardDataByID", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:1516 RefreshCardDataByID", + "code_entry": "internal/service/iot_card/service.go:1522 RefreshCardDataByID", "owner": "iot_card", "summary": "业务方法 RefreshCardDataByID", "audit_event": "必须", @@ -8311,6 +12051,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8318,32 +12059,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:152:SetDeviceSimBindingStore", + "key": "service:internal/service/iot_card/service.go:1536:RefreshCardDataFromGateway", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:152 SetDeviceSimBindingStore", - "owner": "iot_card", - "summary": "业务方法 SetDeviceSimBindingStore", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_device_sim_binding_store", - "action_name": "执行业务方法(SetDeviceSimBindingStore)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:1530:RefreshCardDataFromGateway", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:1530 RefreshCardDataFromGateway", + "code_entry": "internal/service/iot_card/service.go:1536 RefreshCardDataFromGateway", "owner": "iot_card", "summary": "业务方法 RefreshCardDataFromGateway", "audit_event": "必须", @@ -8357,6 +12075,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8364,32 +12083,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:158:SetRedisClient", + "key": "service:internal/service/iot_card/service.go:1677:UpdatePollingStatus", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:158 SetRedisClient", - "owner": "iot_card", - "summary": "业务方法 SetRedisClient", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_redis_client", - "action_name": "执行业务方法(SetRedisClient)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/service.go:1734:UpdatePollingStatus", - "kind": "service", - "code_entry": "internal/service/iot_card/service.go:1734 UpdatePollingStatus", + "code_entry": "internal/service/iot_card/service.go:1677 UpdatePollingStatus", "owner": "iot_card", "summary": "业务方法 UpdatePollingStatus", "audit_event": "必须", @@ -8403,6 +12099,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8410,9 +12107,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:1841:BatchUpdatePollingStatus", + "key": "service:internal/service/iot_card/service.go:1784:BatchUpdatePollingStatus", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:1841 BatchUpdatePollingStatus", + "code_entry": "internal/service/iot_card/service.go:1784 BatchUpdatePollingStatus", "owner": "iot_card", "summary": "业务方法 BatchUpdatePollingStatus", "audit_event": "必须", @@ -8426,6 +12123,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8433,9 +12131,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:1899:DeleteCard", + "key": "service:internal/service/iot_card/service.go:1842:DeleteCard", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:1899 DeleteCard", + "code_entry": "internal/service/iot_card/service.go:1842 DeleteCard", "owner": "iot_card", "summary": "业务方法 DeleteCard", "audit_event": "必须", @@ -8449,6 +12147,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8456,9 +12155,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:1968:BatchDeleteCards", + "key": "service:internal/service/iot_card/service.go:1911:BatchDeleteCards", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:1968 BatchDeleteCards", + "code_entry": "internal/service/iot_card/service.go:1911 BatchDeleteCards", "owner": "iot_card", "summary": "业务方法 BatchDeleteCards", "audit_event": "必须", @@ -8472,6 +12171,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8479,9 +12179,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:2039:UpdateRealnamePolicy", + "key": "service:internal/service/iot_card/service.go:1982:UpdateRealnamePolicy", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:2039 UpdateRealnamePolicy", + "code_entry": "internal/service/iot_card/service.go:1982 UpdateRealnamePolicy", "owner": "iot_card", "summary": "业务方法 UpdateRealnamePolicy", "audit_event": "必须", @@ -8495,6 +12195,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8502,9 +12203,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:508:AllocateCards", + "key": "service:internal/service/iot_card/service.go:514:AllocateCards", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:508 AllocateCards", + "code_entry": "internal/service/iot_card/service.go:514 AllocateCards", "owner": "iot_card", "summary": "业务方法 AllocateCards", "audit_event": "必须", @@ -8518,6 +12219,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8525,9 +12227,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:702:RecallCards", + "key": "service:internal/service/iot_card/service.go:708:RecallCards", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:702 RecallCards", + "code_entry": "internal/service/iot_card/service.go:708 RecallCards", "owner": "iot_card", "summary": "业务方法 RecallCards", "audit_event": "必须", @@ -8541,6 +12243,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8548,22 +12251,23 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/service.go:81:SetPackageExpiryQuery", + "key": "service:internal/service/iot_card/speed_tier.go:26:SetSpeedTier", "kind": "service", - "code_entry": "internal/service/iot_card/service.go:81 SetPackageExpiryQuery", + "code_entry": "internal/service/iot_card/speed_tier.go:26 SetSpeedTier", "owner": "iot_card", - "summary": "业务方法 SetPackageExpiryQuery", + "summary": "业务方法 SetSpeedTier", "audit_event": "必须", "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_package_expiry_query", - "action_name": "执行业务方法(SetPackageExpiryQuery)", + "action_code": "iot_card.set_speed_tier", + "action_name": "执行业务方法(SetSpeedTier)", "category": "asset", "risk": "normal", "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8571,9 +12275,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/iot_card/stop_resume_service.go:373:ResumeCardIfStopped", + "key": "service:internal/service/iot_card/stop_resume_service.go:400:ResumeCardIfStopped", "kind": "service", - "code_entry": "internal/service/iot_card/stop_resume_service.go:373 ResumeCardIfStopped", + "code_entry": "internal/service/iot_card/stop_resume_service.go:400 ResumeCardIfStopped", "owner": "iot_card", "summary": "业务方法 ResumeCardIfStopped", "audit_event": "必须", @@ -8587,29 +12291,7 @@ "primary_resource": "iot_card", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/iot_card/stop_resume_service.go:72:SetPollingCallback", - "kind": "service", - "code_entry": "internal/service/iot_card/stop_resume_service.go:72 SetPollingCallback", - "owner": "iot_card", - "summary": "业务方法 SetPollingCallback", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "调用外部系统或处理回调时必须;纯本地分支 N/A", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "iot_card.set_polling_callback", - "action_name": "执行业务方法(SetPollingCallback)", - "category": "asset", - "risk": "normal", - "primary_resource": "iot_card", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8633,6 +12315,7 @@ "primary_resource": "iot_card_import", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8656,6 +12339,7 @@ "primary_resource": "operation_password", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8663,55 +12347,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/order/service.go:118:SetResumeCallback", + "key": "service:internal/service/order/service.go:1387:Cancel", "kind": "service", - "code_entry": "internal/service/order/service.go:118 SetResumeCallback", - "owner": "order", - "summary": "业务方法 SetResumeCallback", - "audit_event": "必须", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "order.set_resume_callback", - "action_name": "执行业务方法(SetResumeCallback)", - "category": "finance", - "risk": "normal", - "primary_resource": "order", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/order/service.go:125:CreateAdminOrder", - "kind": "service", - "code_entry": "internal/service/order/service.go:125 CreateAdminOrder", - "owner": "order", - "summary": "业务方法 CreateAdminOrder", - "audit_event": "必须", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "order.create_admin_order", - "action_name": "执行业务方法(CreateAdminOrder)", - "category": "finance", - "risk": "normal", - "primary_resource": "order", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/order/service.go:1390:Cancel", - "kind": "service", - "code_entry": "internal/service/order/service.go:1390 Cancel", + "code_entry": "internal/service/order/service.go:1387 Cancel", "owner": "order", "summary": "业务方法 Cancel", "audit_event": "必须", @@ -8725,6 +12363,7 @@ "primary_resource": "order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8732,9 +12371,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/order/service.go:1412:CancelExpiredOrders", + "key": "service:internal/service/order/service.go:1409:CancelExpiredOrders", "kind": "service", - "code_entry": "internal/service/order/service.go:1412 CancelExpiredOrders", + "code_entry": "internal/service/order/service.go:1409 CancelExpiredOrders", "owner": "order", "summary": "业务方法 CancelExpiredOrders", "audit_event": "必须", @@ -8748,6 +12387,7 @@ "primary_resource": "order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8755,9 +12395,33 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/order/service.go:1763:HandlePaymentCallback", + "key": "service:internal/service/order/service.go:148:CreateAdminOrder", "kind": "service", - "code_entry": "internal/service/order/service.go:1763 HandlePaymentCallback", + "code_entry": "internal/service/order/service.go:148 CreateAdminOrder", + "owner": "order", + "summary": "业务方法 CreateAdminOrder", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "order.create_admin_order", + "action_name": "执行业务方法(CreateAdminOrder)", + "category": "finance", + "risk": "normal", + "primary_resource": "order", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/order/service.go:1753:HandlePaymentCallback", + "kind": "service", + "code_entry": "internal/service/order/service.go:1753 HandlePaymentCallback", "owner": "order", "summary": "业务方法 HandlePaymentCallback", "audit_event": "必须", @@ -8771,6 +12435,7 @@ "primary_resource": "order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8778,9 +12443,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/order/service.go:1830:HandlePaymentRecordCallback", + "key": "service:internal/service/order/service.go:1820:HandlePaymentRecordCallback", "kind": "service", - "code_entry": "internal/service/order/service.go:1830 HandlePaymentRecordCallback", + "code_entry": "internal/service/order/service.go:1820 HandlePaymentRecordCallback", "owner": "order", "summary": "业务方法 HandlePaymentRecordCallback", "audit_event": "必须", @@ -8794,6 +12459,7 @@ "primary_resource": "order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8801,9 +12467,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/order/service.go:522:CreateH5Order", + "key": "service:internal/service/order/service.go:552:CreateH5Order", "kind": "service", - "code_entry": "internal/service/order/service.go:522 CreateH5Order", + "code_entry": "internal/service/order/service.go:552 CreateH5Order", "owner": "order", "summary": "业务方法 CreateH5Order", "audit_event": "必须", @@ -8817,6 +12483,7 @@ "primary_resource": "order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8840,6 +12507,7 @@ "primary_resource": "order_package_invalidate", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8847,9 +12515,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/package/activation_service.go:174:ActivateQueuedPackage", + "key": "service:internal/service/package/activation_service.go:184:ActivateQueuedPackage", "kind": "service", - "code_entry": "internal/service/package/activation_service.go:174 ActivateQueuedPackage", + "code_entry": "internal/service/package/activation_service.go:184 ActivateQueuedPackage", "owner": "package", "summary": "业务方法 ActivateQueuedPackage", "audit_event": "必须", @@ -8863,6 +12531,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8870,9 +12539,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/package/activation_service.go:235:ActivateSpecificPackage", + "key": "service:internal/service/package/activation_service.go:255:ActivateSpecificPackage", "kind": "service", - "code_entry": "internal/service/package/activation_service.go:235 ActivateSpecificPackage", + "code_entry": "internal/service/package/activation_service.go:255 ActivateSpecificPackage", "owner": "package", "summary": "业务方法 ActivateSpecificPackage", "audit_event": "必须", @@ -8886,6 +12555,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8893,9 +12563,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/package/activation_service.go:345:ActivateNextPendingMainPackage", + "key": "service:internal/service/package/activation_service.go:361:ActivateNextPendingMainPackage", "kind": "service", - "code_entry": "internal/service/package/activation_service.go:345 ActivateNextPendingMainPackage", + "code_entry": "internal/service/package/activation_service.go:361 ActivateNextPendingMainPackage", "owner": "package", "summary": "业务方法 ActivateNextPendingMainPackage", "audit_event": "必须", @@ -8909,6 +12579,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8916,32 +12587,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/package/activation_service.go:52:SetResumeCallback", + "key": "service:internal/service/package/activation_service.go:66:ActivateByRealname", "kind": "service", - "code_entry": "internal/service/package/activation_service.go:52 SetResumeCallback", - "owner": "package", - "summary": "业务方法 SetResumeCallback", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "package.set_resume_callback", - "action_name": "执行业务方法(SetResumeCallback)", - "category": "business", - "risk": "normal", - "primary_resource": "package", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/package/activation_service.go:58:ActivateByRealname", - "kind": "service", - "code_entry": "internal/service/package/activation_service.go:58 ActivateByRealname", + "code_entry": "internal/service/package/activation_service.go:66 ActivateByRealname", "owner": "package", "summary": "业务方法 ActivateByRealname", "audit_event": "必须", @@ -8955,6 +12603,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8962,9 +12611,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/package/activation_service.go:650:InvalidatePackagesForRefund", + "key": "service:internal/service/package/activation_service.go:700:InvalidatePackagesForRefund", "kind": "service", - "code_entry": "internal/service/package/activation_service.go:650 InvalidatePackagesForRefund", + "code_entry": "internal/service/package/activation_service.go:700 InvalidatePackagesForRefund", "owner": "package", "summary": "业务方法 InvalidatePackagesForRefund", "audit_event": "必须", @@ -8978,6 +12627,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -8985,9 +12635,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/package/activation_service.go:781:InvalidateAllPackagesByAsset", + "key": "service:internal/service/package/activation_service.go:818:InvalidateAllPackagesByAsset", "kind": "service", - "code_entry": "internal/service/package/activation_service.go:781 InvalidateAllPackagesByAsset", + "code_entry": "internal/service/package/activation_service.go:818 InvalidateAllPackagesByAsset", "owner": "package", "summary": "业务方法 InvalidateAllPackagesByAsset", "audit_event": "必须", @@ -9001,6 +12651,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9024,6 +12675,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9047,29 +12699,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/package/reset_service.go:39:SetResumeCallback", - "kind": "service", - "code_entry": "internal/service/package/reset_service.go:39 SetResumeCallback", - "owner": "package", - "summary": "业务方法 SetResumeCallback", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "package.set_resume_callback", - "action_name": "执行业务方法(SetResumeCallback)", - "category": "business", - "risk": "normal", - "primary_resource": "package", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9093,6 +12723,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9116,6 +12747,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9139,6 +12771,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9162,6 +12795,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9185,6 +12819,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9208,6 +12843,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9231,29 +12867,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/package/usage_service.go:63:SetStopResumeCallback", - "kind": "service", - "code_entry": "internal/service/package/usage_service.go:63 SetStopResumeCallback", - "owner": "package", - "summary": "业务方法 SetStopResumeCallback", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "package.set_stop_resume_callback", - "action_name": "执行业务方法(SetStopResumeCallback)", - "category": "business", - "risk": "normal", - "primary_resource": "package", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9277,6 +12891,7 @@ "primary_resource": "package", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9300,6 +12915,7 @@ "primary_resource": "package_series", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9323,6 +12939,7 @@ "primary_resource": "package_series", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9346,6 +12963,7 @@ "primary_resource": "package_series", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9369,6 +12987,7 @@ "primary_resource": "package_series", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9392,6 +13011,7 @@ "primary_resource": "permission", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9415,6 +13035,7 @@ "primary_resource": "permission", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9438,6 +13059,7 @@ "primary_resource": "permission", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9455,12 +13077,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "personal_customer.update_profile", - "action_name": "执行业务方法(UpdateProfile)", - "category": "business", + "action_name": "更新个人资料", + "category": "identity", "risk": "normal", "primary_resource": "personal_customer", "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", + "actor_source": "personal_customer/personal_api", + "visibility": "subject_detail,仅返回 Registry 白名单 nickname/avatar_url 字段", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9484,6 +13107,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9507,6 +13131,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9530,6 +13155,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9553,6 +13179,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9576,6 +13203,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9599,6 +13227,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9622,6 +13251,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9645,6 +13275,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9668,6 +13299,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9691,6 +13323,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9714,6 +13347,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9737,6 +13371,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9760,6 +13395,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9783,6 +13419,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9806,6 +13443,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9829,6 +13467,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9852,6 +13491,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9875,6 +13515,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9898,6 +13539,7 @@ "primary_resource": "polling", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9921,6 +13563,7 @@ "primary_resource": "recharge_order", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9928,22 +13571,23 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/refund/service.go:198:Approve", + "key": "service:internal/service/refund/approval_decision.go:17:Handle", "kind": "service", - "code_entry": "internal/service/refund/service.go:198 Approve", + "code_entry": "internal/service/refund/approval_decision.go:17 Handle", "owner": "refund", - "summary": "业务方法 Approve", + "summary": "业务方法 Handle", "audit_event": "必须", "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "refund.approve", - "action_name": "执行业务方法(Approve)", + "action_code": "refund.handle", + "action_name": "执行业务方法(Handle)", "category": "finance", "risk": "high", "primary_resource": "refund", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -9951,32 +13595,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/refund/service.go:528:Reject", + "key": "service:internal/service/refund/service.go:106:Create", "kind": "service", - "code_entry": "internal/service/refund/service.go:528 Reject", - "owner": "refund", - "summary": "业务方法 Reject", - "audit_event": "必须", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "refund.reject", - "action_name": "执行业务方法(Reject)", - "category": "finance", - "risk": "high", - "primary_resource": "refund", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/refund/service.go:82:Create", - "kind": "service", - "code_entry": "internal/service/refund/service.go:82 Create", + "code_entry": "internal/service/refund/service.go:106 Create", "owner": "refund", "summary": "业务方法 Create", "audit_event": "必须", @@ -9990,6 +13611,55 @@ "primary_resource": "refund", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/refund/service.go:245:Approve", + "kind": "service", + "code_entry": "internal/service/refund/service.go:245 Approve", + "owner": "refund", + "summary": "业务方法 Approve", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "refund.approve", + "action_name": "执行业务方法(Approve)", + "category": "finance", + "risk": "high", + "primary_resource": "refund", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/refund/service.go:552:Reject", + "kind": "service", + "code_entry": "internal/service/refund/service.go:552 Reject", + "owner": "refund", + "summary": "业务方法 Reject", + "audit_event": "必须", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "refund.reject", + "action_name": "执行业务方法(Reject)", + "category": "finance", + "risk": "high", + "primary_resource": "refund", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10013,6 +13683,7 @@ "primary_resource": "role", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10020,9 +13691,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/role/service.go:191:AssignPermissions", + "key": "service:internal/service/role/service.go:199:AssignPermissions", "kind": "service", - "code_entry": "internal/service/role/service.go:191 AssignPermissions", + "code_entry": "internal/service/role/service.go:199 AssignPermissions", "owner": "role", "summary": "业务方法 AssignPermissions", "audit_event": "必须", @@ -10036,6 +13707,7 @@ "primary_resource": "role", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10043,9 +13715,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/role/service.go:282:RemovePermission", + "key": "service:internal/service/role/service.go:290:RemovePermission", "kind": "service", - "code_entry": "internal/service/role/service.go:282 RemovePermission", + "code_entry": "internal/service/role/service.go:290 RemovePermission", "owner": "role", "summary": "业务方法 RemovePermission", "audit_event": "必须", @@ -10059,6 +13731,7 @@ "primary_resource": "role", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10066,9 +13739,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/role/service.go:299:BatchRemovePermissions", + "key": "service:internal/service/role/service.go:307:BatchRemovePermissions", "kind": "service", - "code_entry": "internal/service/role/service.go:299 BatchRemovePermissions", + "code_entry": "internal/service/role/service.go:307 BatchRemovePermissions", "owner": "role", "summary": "业务方法 BatchRemovePermissions", "audit_event": "必须", @@ -10082,6 +13755,7 @@ "primary_resource": "role", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10089,9 +13763,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/role/service.go:317:UpdateStatus", + "key": "service:internal/service/role/service.go:325:UpdateStatus", "kind": "service", - "code_entry": "internal/service/role/service.go:317 UpdateStatus", + "code_entry": "internal/service/role/service.go:325 UpdateStatus", "owner": "role", "summary": "业务方法 UpdateStatus", "audit_event": "必须", @@ -10105,6 +13779,7 @@ "primary_resource": "role", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10128,6 +13803,7 @@ "primary_resource": "role", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10151,6 +13827,7 @@ "primary_resource": "role", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10158,9 +13835,57 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/shop/service.go:202:Update", + "key": "service:internal/service/shop/service.go:112:Enable", "kind": "service", - "code_entry": "internal/service/shop/service.go:202 Update", + "code_entry": "internal/service/shop/service.go:112 Enable", + "owner": "shop", + "summary": "业务方法 Enable", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "shop.enable", + "action_name": "启用店铺", + "category": "business", + "risk": "normal", + "primary_resource": "shop", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/shop/service.go:287:Delete", + "kind": "service", + "code_entry": "internal/service/shop/service.go:287 Delete", + "owner": "shop", + "summary": "业务方法 Delete", + "audit_event": "必须", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", + "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", + "action_code": "shop.delete", + "action_name": "删除店铺", + "category": "business", + "risk": "high", + "primary_resource": "shop", + "affected_resource": "完整用例直接修改或引用的资源", + "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", + "transaction": "关键成功事件与业务事实同一 GORM 事务", + "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", + "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", + "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", + "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" + }, + { + "key": "service:internal/service/shop/service.go:37:Update", + "kind": "service", + "code_entry": "internal/service/shop/service.go:37 Update", "owner": "shop", "summary": "业务方法 Update", "audit_event": "必须", @@ -10174,6 +13899,7 @@ "primary_resource": "shop", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10181,9 +13907,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/shop/service.go:256:Disable", + "key": "service:internal/service/shop/service.go:91:Disable", "kind": "service", - "code_entry": "internal/service/shop/service.go:256 Disable", + "code_entry": "internal/service/shop/service.go:91 Disable", "owner": "shop", "summary": "业务方法 Disable", "audit_event": "必须", @@ -10191,81 +13917,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "shop.disable", - "action_name": "执行业务方法(Disable)", - "category": "business", - "risk": "normal", - "primary_resource": "shop", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/shop/service.go:277:Enable", - "kind": "service", - "code_entry": "internal/service/shop/service.go:277 Enable", - "owner": "shop", - "summary": "业务方法 Enable", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "shop.enable", - "action_name": "执行业务方法(Enable)", - "category": "business", - "risk": "normal", - "primary_resource": "shop", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/shop/service.go:44:Create", - "kind": "service", - "code_entry": "internal/service/shop/service.go:44 Create", - "owner": "shop", - "summary": "业务方法 Create", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "shop.create", - "action_name": "执行业务方法(Create)", - "category": "business", - "risk": "normal", - "primary_resource": "shop", - "affected_resource": "完整用例直接修改或引用的资源", - "actor_source": "由调用入口传入操作者与来源快照", - "transaction": "关键成功事件与业务事实同一 GORM 事务", - "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", - "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", - "before_after_policy": "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影", - "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" - }, - { - "key": "service:internal/service/shop/service.go:452:Delete", - "kind": "service", - "code_entry": "internal/service/shop/service.go:452 Delete", - "owner": "shop", - "summary": "业务方法 Delete", - "audit_event": "必须", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", - "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "shop.delete", - "action_name": "执行业务方法(Delete)", + "action_name": "禁用店铺", "category": "business", "risk": "normal", "primary_resource": "shop", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10283,12 +13941,13 @@ "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", "action_code": "shop.delete_shop_role", - "action_name": "执行业务方法(DeleteShopRole)", + "action_name": "删除店铺默认角色", "category": "business", "risk": "high", "primary_resource": "shop", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10305,13 +13964,14 @@ "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", "integration_log": "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类", "outbox": "存在提交后可靠副作用时必须在同一事务追加;否则 N/A", - "action_code": "shop.assign_roles_to_shop", - "action_name": "执行业务方法(AssignRolesToShop)", + "action_code": "shop.assign_shop_roles", + "action_name": "分配店铺默认角色", "category": "business", "risk": "high", "primary_resource": "shop", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10319,9 +13979,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/shop_commission/service.go:546:CreateWithdrawalRequest", + "key": "service:internal/service/shop_commission/service.go:405:CreateWithdrawalRequest", "kind": "service", - "code_entry": "internal/service/shop_commission/service.go:546 CreateWithdrawalRequest", + "code_entry": "internal/service/shop_commission/service.go:405 CreateWithdrawalRequest", "owner": "shop_commission", "summary": "业务方法 CreateWithdrawalRequest", "audit_event": "必须", @@ -10335,6 +13995,7 @@ "primary_resource": "shop_commission", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10342,9 +14003,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/shop_commission/service.go:762:ResolveCommissionRecord", + "key": "service:internal/service/shop_commission/service.go:621:ResolveCommissionRecord", "kind": "service", - "code_entry": "internal/service/shop_commission/service.go:762 ResolveCommissionRecord", + "code_entry": "internal/service/shop_commission/service.go:621 ResolveCommissionRecord", "owner": "shop_commission", "summary": "业务方法 ResolveCommissionRecord", "audit_event": "必须", @@ -10358,6 +14019,7 @@ "primary_resource": "shop_commission", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10381,6 +14043,7 @@ "primary_resource": "shop_package_batch_allocation", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10404,6 +14067,7 @@ "primary_resource": "shop_package_batch_allocation", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10427,6 +14091,7 @@ "primary_resource": "shop_package_batch_pricing", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10434,9 +14099,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/shop_series_grant/service.go:211:Create", + "key": "service:internal/service/shop_series_grant/service.go:302:Create", "kind": "service", - "code_entry": "internal/service/shop_series_grant/service.go:211 Create", + "code_entry": "internal/service/shop_series_grant/service.go:302 Create", "owner": "shop_series_grant", "summary": "业务方法 Create", "audit_event": "必须", @@ -10450,6 +14115,7 @@ "primary_resource": "shop_series_grant", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10457,9 +14123,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/shop_series_grant/service.go:531:Update", + "key": "service:internal/service/shop_series_grant/service.go:641:Update", "kind": "service", - "code_entry": "internal/service/shop_series_grant/service.go:531 Update", + "code_entry": "internal/service/shop_series_grant/service.go:641 Update", "owner": "shop_series_grant", "summary": "业务方法 Update", "audit_event": "必须", @@ -10473,6 +14139,7 @@ "primary_resource": "shop_series_grant", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10480,9 +14147,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "service:internal/service/shop_series_grant/service.go:735:Delete", + "key": "service:internal/service/shop_series_grant/service.go:858:Delete", "kind": "service", - "code_entry": "internal/service/shop_series_grant/service.go:735 Delete", + "code_entry": "internal/service/shop_series_grant/service.go:858 Delete", "owner": "shop_series_grant", "summary": "业务方法 Delete", "audit_event": "必须", @@ -10496,6 +14163,7 @@ "primary_resource": "shop_series_grant", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10519,6 +14187,7 @@ "primary_resource": "verification", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10542,6 +14211,7 @@ "primary_resource": "wechat_config", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10565,6 +14235,7 @@ "primary_resource": "wechat_config", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10588,6 +14259,7 @@ "primary_resource": "wechat_config", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10611,6 +14283,7 @@ "primary_resource": "wechat_config", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10634,6 +14307,7 @@ "primary_resource": "wechat_config", "affected_resource": "完整用例直接修改或引用的资源", "actor_source": "由调用入口传入操作者与来源快照", + "visibility": "由完整用例决定平台完整视图、主体安全投影或 internal_only", "transaction": "关键成功事件与业务事实同一 GORM 事务", "failure_strategy": "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical", "sensitive_policy": "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品", @@ -10641,9 +14315,9 @@ "test_seam": "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口" }, { - "key": "worker:cmd/worker/main.go:132:constants.TaskTypeOutboxDeliver", + "key": "worker:cmd/worker/main.go:149:constants.TaskTypeOutboxDeliver", "kind": "worker", - "code_entry": "cmd/worker/main.go:132 outboxHandler.Handle", + "code_entry": "cmd/worker/main.go:149 outboxHandler.Handle", "owner": "outbox_deliver", "summary": "处理异步任务 constants.TaskTypeOutboxDeliver", "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", @@ -10657,6 +14331,7 @@ "primary_resource": "outbox_deliver", "affected_resource": "任务载荷定位的直接业务资源", "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", @@ -10664,459 +14339,71 @@ "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" }, { - "key": "worker:pkg/queue/handler.go:108:constants.TaskTypeOrderPackageInvalidate", + "key": "worker:cmd/worker/main.go:405:constants.TaskTypeWeComApprovalSync", "kind": "worker", - "code_entry": "pkg/queue/handler.go:108 orderPkgHandler.Handle", - "owner": "order_package_invalidate", - "summary": "处理异步任务 constants.TaskTypeOrderPackageInvalidate", + "code_entry": "cmd/worker/main.go:405 detailHandler.Handle", + "owner": "we_com_approval_sync", + "summary": "处理异步任务 constants.TaskTypeWeComApprovalSync", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "we_com_approval_sync.handle", + "action_name": "处理异步任务(constants.TaskTypeWeComApprovalSync)", + "category": "business", + "risk": "normal", + "primary_resource": "we_com_approval_sync", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:cmd/worker/main.go:406:constants.TaskTypeWeComApprovalRecovery", + "kind": "worker", + "code_entry": "cmd/worker/main.go:406 recoveryHandler.Handle", + "owner": "we_com_approval_recovery", + "summary": "处理异步任务 constants.TaskTypeWeComApprovalRecovery", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "we_com_approval_recovery.handle", + "action_name": "处理异步任务(constants.TaskTypeWeComApprovalRecovery)", + "category": "business", + "risk": "normal", + "primary_resource": "we_com_approval_recovery", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:cmd/worker/main.go:425:constants.TaskTypeAgentRechargeRecovery", + "kind": "worker", + "code_entry": "cmd/worker/main.go:425 handler.Handle", + "owner": "agent_recharge_recovery", + "summary": "处理异步任务 constants.TaskTypeAgentRechargeRecovery", "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "order_package_invalidate.handle", - "action_name": "处理异步任务(constants.TaskTypeOrderPackageInvalidate)", - "category": "finance", - "risk": "normal", - "primary_resource": "order_package_invalidate", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:126:constants.TaskTypeDeviceImport", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:126 deviceImportHandler.HandleDeviceImport", - "owner": "device_import", - "summary": "处理异步任务 constants.TaskTypeDeviceImport", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "业务任务及明细表是批量结果权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "device_import.handle_device_import", - "action_name": "处理异步任务(constants.TaskTypeDeviceImport)", - "category": "asset", - "risk": "normal", - "primary_resource": "device_import", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:160:constants.TaskTypeExportDispatch", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:160 dispatchHandler.HandleExportDispatch", - "owner": "export_dispatch", - "summary": "处理异步任务 constants.TaskTypeExportDispatch", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "业务任务及明细表是批量结果权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "export_dispatch.handle_export_dispatch", - "action_name": "处理异步任务(constants.TaskTypeExportDispatch)", - "category": "business", - "risk": "normal", - "primary_resource": "export_dispatch", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:163:constants.TaskTypeExportShard", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:163 shardHandler.HandleExportShard", - "owner": "export_shard", - "summary": "处理异步任务 constants.TaskTypeExportShard", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "业务任务及明细表是批量结果权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "export_shard.handle_export_shard", - "action_name": "处理异步任务(constants.TaskTypeExportShard)", - "category": "business", - "risk": "normal", - "primary_resource": "export_shard", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:166:constants.TaskTypeExportFinalize", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:166 finalizeHandler.HandleExportFinalize", - "owner": "export_finalize", - "summary": "处理异步任务 constants.TaskTypeExportFinalize", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "业务任务及明细表是批量结果权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "export_finalize.handle_export_finalize", - "action_name": "处理异步任务(constants.TaskTypeExportFinalize)", - "category": "business", - "risk": "normal", - "primary_resource": "export_finalize", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:190:constants.TaskTypeCommissionStatsUpdate", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:190 updateHandler.HandleCommissionStatsUpdate", - "owner": "commission_stats_update", - "summary": "处理异步任务 constants.TaskTypeCommissionStatsUpdate", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "commission_stats_update.handle_commission_stats_update", - "action_name": "处理异步任务(constants.TaskTypeCommissionStatsUpdate)", - "category": "finance", - "risk": "normal", - "primary_resource": "commission_stats_update", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:193:constants.TaskTypeCommissionStatsSync", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:193 syncHandler.HandleCommissionStatsSync", - "owner": "commission_stats_sync", - "summary": "处理异步任务 constants.TaskTypeCommissionStatsSync", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "commission_stats_sync.handle_commission_stats_sync", - "action_name": "处理异步任务(constants.TaskTypeCommissionStatsSync)", - "category": "finance", - "risk": "normal", - "primary_resource": "commission_stats_sync", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:196:constants.TaskTypeCommissionStatsArchive", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:196 archiveHandler.HandleCommissionStatsArchive", - "owner": "commission_stats_archive", - "summary": "处理异步任务 constants.TaskTypeCommissionStatsArchive", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "commission_stats_archive.handle_commission_stats_archive", - "action_name": "处理异步任务(constants.TaskTypeCommissionStatsArchive)", - "category": "finance", - "risk": "normal", - "primary_resource": "commission_stats_archive", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:206:constants.TaskTypeCommission", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:206 commissionCalculationHandler.HandleCommissionCalculation", - "owner": "commission", - "summary": "处理异步任务 constants.TaskTypeCommission", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "commission.handle_commission_calculation", - "action_name": "处理异步任务(constants.TaskTypeCommission)", - "category": "finance", - "risk": "normal", - "primary_resource": "commission", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:227:constants.TaskTypePollingRealname", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:227 realnameHandler.Handle", - "owner": "polling_realname", - "summary": "处理异步任务 constants.TaskTypePollingRealname", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "必须:每次实际请求或未发送裁决均记录", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "polling_realname.handle", - "action_name": "处理异步任务(constants.TaskTypePollingRealname)", - "category": "asset", - "risk": "normal", - "primary_resource": "polling_realname", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:228:constants.TaskTypePollingCarddata", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:228 carddataHandler.Handle", - "owner": "polling_carddata", - "summary": "处理异步任务 constants.TaskTypePollingCarddata", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "必须:每次实际请求或未发送裁决均记录", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "polling_carddata.handle", - "action_name": "处理异步任务(constants.TaskTypePollingCarddata)", - "category": "asset", - "risk": "normal", - "primary_resource": "polling_carddata", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:229:constants.TaskTypePollingPackage", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:229 packageHandler.Handle", - "owner": "polling_package", - "summary": "处理异步任务 constants.TaskTypePollingPackage", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "必须:每次实际请求或未发送裁决均记录", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "polling_package.handle", - "action_name": "处理异步任务(constants.TaskTypePollingPackage)", - "category": "asset", - "risk": "normal", - "primary_resource": "polling_package", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:230:constants.TaskTypePollingProtect", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:230 protectHandler.Handle", - "owner": "polling_protect", - "summary": "处理异步任务 constants.TaskTypePollingProtect", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "必须:每次实际请求或未发送裁决均记录", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "polling_protect.handle", - "action_name": "处理异步任务(constants.TaskTypePollingProtect)", - "category": "asset", - "risk": "normal", - "primary_resource": "polling_protect", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:231:constants.TaskTypePollingCardStatus", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:231 cardStatusHandler.Handle", - "owner": "polling_card_status", - "summary": "处理异步任务 constants.TaskTypePollingCardStatus", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "必须:每次实际请求或未发送裁决均记录", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "polling_card_status.handle", - "action_name": "处理异步任务(constants.TaskTypePollingCardStatus)", - "category": "asset", - "risk": "normal", - "primary_resource": "polling_card_status", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:245:constants.TaskTypePackageFirstActivation", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:245 packageActivationHandler.HandlePackageFirstActivation", - "owner": "package_first_activation", - "summary": "处理异步任务 constants.TaskTypePackageFirstActivation", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "package_first_activation.handle_package_first_activation", - "action_name": "处理异步任务(constants.TaskTypePackageFirstActivation)", - "category": "business", - "risk": "normal", - "primary_resource": "package_first_activation", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:248:constants.TaskTypePackageQueueActivation", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:248 packageActivationHandler.HandlePackageQueueActivation", - "owner": "package_queue_activation", - "summary": "处理异步任务 constants.TaskTypePackageQueueActivation", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "package_queue_activation.handle_package_queue_activation", - "action_name": "处理异步任务(constants.TaskTypePackageQueueActivation)", - "category": "business", - "risk": "normal", - "primary_resource": "package_queue_activation", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:254:constants.TaskTypeOrderExpire", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:254 orderExpireHandler.HandleOrderExpire", - "owner": "order_expire", - "summary": "处理异步任务 constants.TaskTypeOrderExpire", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "order_expire.handle_order_expire", - "action_name": "处理异步任务(constants.TaskTypeOrderExpire)", - "category": "finance", - "risk": "normal", - "primary_resource": "order_expire", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:260:constants.TaskTypeAlertCheck", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:260 alertCheckHandler.HandleAlertCheck", - "owner": "alert_check", - "summary": "处理异步任务 constants.TaskTypeAlertCheck", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "alert_check.handle_alert_check", - "action_name": "处理异步任务(constants.TaskTypeAlertCheck)", - "category": "business", - "risk": "normal", - "primary_resource": "alert_check", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:266:constants.TaskTypeDataCleanup", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:266 dataCleanupHandler.HandleDataCleanup", - "owner": "data_cleanup", - "summary": "处理异步任务 constants.TaskTypeDataCleanup", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "data_cleanup.handle_data_cleanup", - "action_name": "处理异步任务(constants.TaskTypeDataCleanup)", - "category": "business", - "risk": "normal", - "primary_resource": "data_cleanup", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:283:constants.TaskTypeAutoPurchaseAfterRecharge", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:283 autoPurchaseHandler.ProcessTask", - "owner": "auto_purchase_after_recharge", - "summary": "处理异步任务 constants.TaskTypeAutoPurchaseAfterRecharge", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "auto_purchase_after_recharge.process_task", - "action_name": "处理异步任务(constants.TaskTypeAutoPurchaseAfterRecharge)", + "action_code": "agent_recharge_recovery.handle", + "action_name": "处理异步任务(constants.TaskTypeAgentRechargeRecovery)", "category": "finance", "risk": "high", - "primary_resource": "auto_purchase_after_recharge", + "primary_resource": "agent_recharge_recovery", "affected_resource": "任务载荷定位的直接业务资源", "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", @@ -11124,55 +14411,9 @@ "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" }, { - "key": "worker:pkg/queue/handler.go:290:constants.TaskTypeDailyTrafficFlush", + "key": "worker:pkg/queue/handler.go:112:constants.TaskTypeIotCardImport", "kind": "worker", - "code_entry": "pkg/queue/handler.go:290 dailyTrafficFlushHandler.HandleDailyTrafficFlush", - "owner": "daily_traffic_flush", - "summary": "处理异步任务 constants.TaskTypeDailyTrafficFlush", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "daily_traffic_flush.handle_daily_traffic_flush", - "action_name": "处理异步任务(constants.TaskTypeDailyTrafficFlush)", - "category": "business", - "risk": "normal", - "primary_resource": "daily_traffic_flush", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:63:constants.TaskTypeEmailSend", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:63 emailHandler.HandleEmailSend", - "owner": "email_send", - "summary": "处理异步任务 constants.TaskTypeEmailSend", - "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", - "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", - "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", - "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", - "action_code": "email_send.handle_email_send", - "action_name": "处理异步任务(constants.TaskTypeEmailSend)", - "category": "business", - "risk": "normal", - "primary_resource": "email_send", - "affected_resource": "任务载荷定位的直接业务资源", - "actor_source": "system_task/asynq", - "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", - "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", - "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", - "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", - "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" - }, - { - "key": "worker:pkg/queue/handler.go:96:constants.TaskTypeIotCardImport", - "kind": "worker", - "code_entry": "pkg/queue/handler.go:96 iotCardImportHandler.HandleIotCardImport", + "code_entry": "pkg/queue/handler.go:112 iotCardImportHandler.HandleIotCardImport", "owner": "iot_card_import", "summary": "处理异步任务 constants.TaskTypeIotCardImport", "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", @@ -11186,6 +14427,631 @@ "primary_resource": "iot_card_import", "affected_resource": "任务载荷定位的直接业务资源", "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:124:constants.TaskTypeOrderPackageInvalidate", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:124 orderPkgHandler.Handle", + "owner": "order_package_invalidate", + "summary": "处理异步任务 constants.TaskTypeOrderPackageInvalidate", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "order_package_invalidate.handle", + "action_name": "处理异步任务(constants.TaskTypeOrderPackageInvalidate)", + "category": "finance", + "risk": "normal", + "primary_resource": "order_package_invalidate", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:136:constants.TaskTypeAssetPackageBatchOrder", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:136 handler.Handle", + "owner": "asset_package_batch_order", + "summary": "处理异步任务 constants.TaskTypeAssetPackageBatchOrder", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "asset_package_batch_order.handle", + "action_name": "处理异步任务(constants.TaskTypeAssetPackageBatchOrder)", + "category": "finance", + "risk": "normal", + "primary_resource": "asset_package_batch_order", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:155:constants.TaskTypeDeviceImport", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:155 deviceImportHandler.HandleDeviceImport", + "owner": "device_import", + "summary": "处理异步任务 constants.TaskTypeDeviceImport", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "业务任务及明细表是批量结果权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "device_import.handle_device_import", + "action_name": "处理异步任务(constants.TaskTypeDeviceImport)", + "category": "asset", + "risk": "normal", + "primary_resource": "device_import", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:189:constants.TaskTypeExportDispatch", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:189 dispatchHandler.HandleExportDispatch", + "owner": "export_dispatch", + "summary": "处理异步任务 constants.TaskTypeExportDispatch", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "业务任务及明细表是批量结果权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "export_dispatch.handle_export_dispatch", + "action_name": "处理异步任务(constants.TaskTypeExportDispatch)", + "category": "business", + "risk": "normal", + "primary_resource": "export_dispatch", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:192:constants.TaskTypeExportShard", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:192 shardHandler.HandleExportShard", + "owner": "export_shard", + "summary": "处理异步任务 constants.TaskTypeExportShard", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "业务任务及明细表是批量结果权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "export_shard.handle_export_shard", + "action_name": "处理异步任务(constants.TaskTypeExportShard)", + "category": "business", + "risk": "normal", + "primary_resource": "export_shard", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:195:constants.TaskTypeExportFinalize", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:195 finalizeHandler.HandleExportFinalize", + "owner": "export_finalize", + "summary": "处理异步任务 constants.TaskTypeExportFinalize", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "业务任务及明细表是批量结果权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "export_finalize.handle_export_finalize", + "action_name": "处理异步任务(constants.TaskTypeExportFinalize)", + "category": "business", + "risk": "normal", + "primary_resource": "export_finalize", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:219:constants.TaskTypeCommissionStatsUpdate", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:219 updateHandler.HandleCommissionStatsUpdate", + "owner": "commission_stats_update", + "summary": "处理异步任务 constants.TaskTypeCommissionStatsUpdate", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "commission_stats_update.handle_commission_stats_update", + "action_name": "处理异步任务(constants.TaskTypeCommissionStatsUpdate)", + "category": "finance", + "risk": "normal", + "primary_resource": "commission_stats_update", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:222:constants.TaskTypeCommissionStatsSync", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:222 syncHandler.HandleCommissionStatsSync", + "owner": "commission_stats_sync", + "summary": "处理异步任务 constants.TaskTypeCommissionStatsSync", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "commission_stats_sync.handle_commission_stats_sync", + "action_name": "处理异步任务(constants.TaskTypeCommissionStatsSync)", + "category": "finance", + "risk": "normal", + "primary_resource": "commission_stats_sync", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:225:constants.TaskTypeCommissionStatsArchive", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:225 archiveHandler.HandleCommissionStatsArchive", + "owner": "commission_stats_archive", + "summary": "处理异步任务 constants.TaskTypeCommissionStatsArchive", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "commission_stats_archive.handle_commission_stats_archive", + "action_name": "处理异步任务(constants.TaskTypeCommissionStatsArchive)", + "category": "finance", + "risk": "normal", + "primary_resource": "commission_stats_archive", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:235:constants.TaskTypeCommission", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:235 commissionCalculationHandler.HandleCommissionCalculation", + "owner": "commission", + "summary": "处理异步任务 constants.TaskTypeCommission", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "commission.handle_commission_calculation", + "action_name": "处理异步任务(constants.TaskTypeCommission)", + "category": "finance", + "risk": "normal", + "primary_resource": "commission", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:256:constants.TaskTypePollingRealname", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:256 realnameHandler.Handle", + "owner": "polling_realname", + "summary": "处理异步任务 constants.TaskTypePollingRealname", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "必须:每次实际请求或未发送裁决均记录", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "polling_realname.handle", + "action_name": "处理异步任务(constants.TaskTypePollingRealname)", + "category": "asset", + "risk": "normal", + "primary_resource": "polling_realname", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:257:constants.TaskTypePollingCarddata", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:257 carddataHandler.Handle", + "owner": "polling_carddata", + "summary": "处理异步任务 constants.TaskTypePollingCarddata", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "必须:每次实际请求或未发送裁决均记录", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "polling_carddata.handle", + "action_name": "处理异步任务(constants.TaskTypePollingCarddata)", + "category": "asset", + "risk": "normal", + "primary_resource": "polling_carddata", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:258:constants.TaskTypePollingPackage", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:258 packageHandler.Handle", + "owner": "polling_package", + "summary": "处理异步任务 constants.TaskTypePollingPackage", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "必须:每次实际请求或未发送裁决均记录", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "polling_package.handle", + "action_name": "处理异步任务(constants.TaskTypePollingPackage)", + "category": "asset", + "risk": "normal", + "primary_resource": "polling_package", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:259:constants.TaskTypePollingProtect", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:259 protectHandler.Handle", + "owner": "polling_protect", + "summary": "处理异步任务 constants.TaskTypePollingProtect", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "必须:每次实际请求或未发送裁决均记录", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "polling_protect.handle", + "action_name": "处理异步任务(constants.TaskTypePollingProtect)", + "category": "asset", + "risk": "normal", + "primary_resource": "polling_protect", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:260:constants.TaskTypePollingCardStatus", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:260 cardStatusHandler.Handle", + "owner": "polling_card_status", + "summary": "处理异步任务 constants.TaskTypePollingCardStatus", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "必须:每次实际请求或未发送裁决均记录", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "polling_card_status.handle", + "action_name": "处理异步任务(constants.TaskTypePollingCardStatus)", + "category": "asset", + "risk": "normal", + "primary_resource": "polling_card_status", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:274:constants.TaskTypePackageFirstActivation", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:274 packageActivationHandler.HandlePackageFirstActivation", + "owner": "package_first_activation", + "summary": "处理异步任务 constants.TaskTypePackageFirstActivation", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "package_first_activation.handle_package_first_activation", + "action_name": "处理异步任务(constants.TaskTypePackageFirstActivation)", + "category": "business", + "risk": "normal", + "primary_resource": "package_first_activation", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:277:constants.TaskTypePackageQueueActivation", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:277 packageActivationHandler.HandlePackageQueueActivation", + "owner": "package_queue_activation", + "summary": "处理异步任务 constants.TaskTypePackageQueueActivation", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "package_queue_activation.handle_package_queue_activation", + "action_name": "处理异步任务(constants.TaskTypePackageQueueActivation)", + "category": "business", + "risk": "normal", + "primary_resource": "package_queue_activation", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:283:constants.TaskTypeOrderExpire", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:283 orderExpireHandler.HandleOrderExpire", + "owner": "order_expire", + "summary": "处理异步任务 constants.TaskTypeOrderExpire", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "order_expire.handle_order_expire", + "action_name": "处理异步任务(constants.TaskTypeOrderExpire)", + "category": "finance", + "risk": "normal", + "primary_resource": "order_expire", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:289:constants.TaskTypeAlertCheck", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:289 alertCheckHandler.HandleAlertCheck", + "owner": "alert_check", + "summary": "处理异步任务 constants.TaskTypeAlertCheck", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "alert_check.handle_alert_check", + "action_name": "处理异步任务(constants.TaskTypeAlertCheck)", + "category": "business", + "risk": "normal", + "primary_resource": "alert_check", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:295:constants.TaskTypeDataCleanup", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:295 dataCleanupHandler.HandleDataCleanup", + "owner": "data_cleanup", + "summary": "处理异步任务 constants.TaskTypeDataCleanup", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "data_cleanup.handle_data_cleanup", + "action_name": "处理异步任务(constants.TaskTypeDataCleanup)", + "category": "business", + "risk": "normal", + "primary_resource": "data_cleanup", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:302:constants.TaskTypeNotificationCleanup", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:302 cleanupHandler.Handle", + "owner": "notification_cleanup", + "summary": "处理异步任务 constants.TaskTypeNotificationCleanup", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "notification_cleanup.handle", + "action_name": "处理异步任务(constants.TaskTypeNotificationCleanup)", + "category": "business", + "risk": "normal", + "primary_resource": "notification_cleanup", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:311:constants.TaskTypePackageExpiryReminder", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:311 handler.Handle", + "owner": "package_expiry_reminder", + "summary": "处理异步任务 constants.TaskTypePackageExpiryReminder", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "package_expiry_reminder.handle", + "action_name": "处理异步任务(constants.TaskTypePackageExpiryReminder)", + "category": "business", + "risk": "normal", + "primary_resource": "package_expiry_reminder", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:329:constants.TaskTypeAutoPurchaseAfterRecharge", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:329 autoPurchaseHandler.ProcessTask", + "owner": "auto_purchase_after_recharge", + "summary": "处理异步任务 constants.TaskTypeAutoPurchaseAfterRecharge", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "必须:订单、充值、退款、钱包流水等既有业务表是领域权威", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "auto_purchase_after_recharge.process_task", + "action_name": "处理异步任务(constants.TaskTypeAutoPurchaseAfterRecharge)", + "category": "finance", + "risk": "high", + "primary_resource": "auto_purchase_after_recharge", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:336:constants.TaskTypeDailyTrafficFlush", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:336 dailyTrafficFlushHandler.HandleDailyTrafficFlush", + "owner": "daily_traffic_flush", + "summary": "处理异步任务 constants.TaskTypeDailyTrafficFlush", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "daily_traffic_flush.handle_daily_traffic_flush", + "action_name": "处理异步任务(constants.TaskTypeDailyTrafficFlush)", + "category": "business", + "risk": "normal", + "primary_resource": "daily_traffic_flush", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:69:constants.TaskTypeEmailSend", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:69 emailHandler.HandleEmailSend", + "owner": "email_send", + "summary": "处理异步任务 constants.TaskTypeEmailSend", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "email_send.handle_email_send", + "action_name": "处理异步任务(constants.TaskTypeEmailSend)", + "category": "business", + "risk": "normal", + "primary_resource": "email_send", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", + "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", + "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", + "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", + "before_after_policy": "状态变化保存直接前后值;无业务变化时仅保留 Integration Log", + "test_seam": "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口" + }, + { + "key": "worker:pkg/queue/handler.go:96:constants.TaskTypeCardObservationSeries", + "kind": "worker", + "code_entry": "pkg/queue/handler.go:96 handler.Handle", + "owner": "card_observation_series", + "summary": "处理异步任务 constants.TaskTypeCardObservationSeries", + "audit_event": "按状态变化、人工触发、连续失败或高风险异常决定", + "domain_ledger": "既有业务表是状态事实;Audit Event 不替代业务模型", + "integration_log": "Worker 调用外部系统时必须;纯本地处理 N/A", + "outbox": "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等", + "action_code": "card_observation_series.handle", + "action_name": "处理异步任务(constants.TaskTypeCardObservationSeries)", + "category": "asset", + "risk": "normal", + "primary_resource": "card_observation_series", + "affected_resource": "任务载荷定位的直接业务资源", + "actor_source": "system_task/asynq", + "visibility": "内部系统入口;外部主体只读取对应业务安全投影", "transaction": "业务状态变化、领域流水和 Audit Event 按用例原子提交", "failure_strategy": "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计", "sensitive_policy": "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL", diff --git a/README.md b/README.md index 7e72ffa..d160334 100644 --- a/README.md +++ b/README.md @@ -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)**:完整的项目开发规范(必读) diff --git a/cmd/api/docs.go b/cmd/api/docs.go index fac2aae..a9474f8 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -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) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 69340d4..09d570c 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -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) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 98f4f76..83f8d92 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -23,6 +23,276 @@ components: nickname: type: string type: object + AuditActorRef: + properties: + id: + type: string + kind: + type: string + type: object + AuditEventPage: + properties: + items: + items: + $ref: '#/components/schemas/AuditEventView' + nullable: true + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + AuditEventView: + properties: + action_code: + type: string + action_name: + type: string + actor_enterprise_id: + minimum: 0 + nullable: true + type: integer + actor_enterprise_name: + type: string + actor_id: + type: string + actor_kind: + type: string + actor_name: + type: string + actor_shop_id: + minimum: 0 + nullable: true + type: integer + actor_shop_name: + type: string + batch_total: + type: integer + category: + type: string + content_hash: + type: string + correlation_id: + type: string + created_at: + format: date-time + type: string + error_code: + type: string + error_summary: + type: string + event_id: + type: string + fail_count: + type: integer + investigation_refs: + $ref: '#/components/schemas/AuditInvestigationRefs' + ip_address: + type: string + metadata: + additionalProperties: {} + nullable: true + type: object + occurred_at: + format: date-time + type: string + parent_event_id: + type: string + request_id: + type: string + request_method: + type: string + request_path: + type: string + resources: + items: + $ref: '#/components/schemas/AuditResourceView' + nullable: true + type: array + result: + type: string + risk_level: + type: string + scope_id: + type: string + scope_name: + type: string + scope_type: + type: string + source: + type: string + success_count: + type: integer + summary: + type: string + user_agent: + type: string + type: object + AuditIntegrationRef: + properties: + integration_id: + type: string + type: object + AuditInvestigationRefs: + properties: + actor_ref: + $ref: '#/components/schemas/AuditActorRef' + correlation_id: + nullable: true + type: string + event_id: + nullable: true + type: string + integration_refs: + items: + $ref: '#/components/schemas/AuditIntegrationRef' + nullable: true + type: array + request_id: + nullable: true + type: string + resource_refs: + items: + $ref: '#/components/schemas/AuditInvestigationResourceRef' + nullable: true + type: array + type: object + AuditInvestigationResourceRef: + properties: + display_name: + type: string + resource_id: + nullable: true + type: string + resource_key: + type: string + resource_type: + type: string + type: object + AuditResourceCandidate: + properties: + display_name: + type: string + historical: + type: boolean + identity_snapshot: + additionalProperties: {} + nullable: true + type: object + resource_id: + type: string + resource_key: + type: string + resource_type: + type: string + type: object + AuditResourceSearchPage: + properties: + items: + items: + $ref: '#/components/schemas/AuditResourceCandidate' + nullable: true + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + AuditResourceView: + properties: + after_data: + additionalProperties: {} + nullable: true + type: object + before_data: + additionalProperties: {} + nullable: true + type: object + created_at: + format: date-time + type: string + display_name: + type: string + identity_snapshot: + additionalProperties: {} + nullable: true + type: object + relation: + type: string + resource_id: + nullable: true + type: string + resource_key: + type: string + resource_type: + type: string + role: + type: string + sort_order: + type: integer + subject_data: + additionalProperties: {} + nullable: true + type: object + subject_summary: + type: string + subject_visibility: + type: string + type: object + AuditSubjectActivity: + properties: + action_code: + type: string + action_name: + type: string + occurred_at: + format: date-time + type: string + related_resources: + items: + $ref: '#/components/schemas/AuditSubjectResourceSummary' + nullable: true + type: array + result: + type: string + subject_data: + additionalProperties: {} + nullable: true + type: object + subject_summary: + type: string + type: object + AuditSubjectActivityPage: + properties: + items: + items: + $ref: '#/components/schemas/AuditSubjectActivity' + nullable: true + type: array + page: + type: integer + page_size: + type: integer + resource: + $ref: '#/components/schemas/AuditSubjectResourceSummary' + total: + type: integer + type: object + AuditSubjectResourceSummary: + properties: + display_name: + type: string + resource_id: + type: string + resource_key: + type: string + resource_type: + type: string + type: object DtoAccountPageResult: properties: items: @@ -502,7 +772,7 @@ components: description: 支付单号(PAY前缀) type: string qr_content: - description: 支付渠道原始扫码付款内容,由前端渲染二维码 + description: 支付链接(HTTPS URL),由前端渲染二维码 type: string recharge_id: description: 充值记录ID @@ -7024,11 +7294,6 @@ components: type: string category: description: 通知类别 (approval:审批, expiry:临期, sync:同步, system:系统) - enum: - - approval - - expiry - - sync - - system type: string created_at: description: 创建时间(ISO 8601) @@ -7057,24 +7322,12 @@ components: type: string severity: description: 通知级别 (info:提示, warning:警告, error:错误, critical:严重) - enum: - - info - - warning - - error - - critical type: string title: description: 纯文本标题 type: string type: description: 稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额) - enum: - - system.notice - - package.expiring - - agent.recharge.completed - - refund.completed - - exchange.shipping.created - - agent.main_wallet.low_balance type: string type: object DtoNotificationListResponse: @@ -10534,15 +10787,9 @@ components: minimum: 0 nullable: true type: integer - version: - description: 主钱包乐观锁版本 - minimum: 0 - nullable: true - type: integer required: - credit_enabled - credit_limit - - version type: object DtoUpdateShopParams: properties: @@ -11575,6 +11822,308 @@ components: nullable: true type: array type: object + IntegrationAttemptView: + properties: + attempt: + type: integer + created_at: + format: date-time + type: string + duration_ms: + type: integer + integration_id: + type: string + result: + type: string + result_category: + type: string + result_name: + type: string + sent: + type: boolean + state_changed: + type: boolean + type: object + IntegrationContentView: + properties: + content_hash: + type: string + metadata: + additionalProperties: {} + nullable: true + type: object + request_summary: + additionalProperties: {} + nullable: true + type: object + response_summary: + additionalProperties: {} + nullable: true + type: object + type: object + IntegrationDetail: + properties: + attempts: + items: + $ref: '#/components/schemas/IntegrationAttemptView' + nullable: true + type: array + content: + $ref: '#/components/schemas/IntegrationContentView' + fidelity: + $ref: '#/components/schemas/IntegrationFidelityView' + identity: + $ref: '#/components/schemas/IntegrationIdentityView' + linkage: + $ref: '#/components/schemas/IntegrationLinkageView' + resource: + $ref: '#/components/schemas/IntegrationResourceView' + result: + $ref: '#/components/schemas/IntegrationResultView' + timestamps: + $ref: '#/components/schemas/IntegrationTimestampView' + trigger: + $ref: '#/components/schemas/IntegrationTriggerView' + type: object + IntegrationFidelityView: + properties: + correlation_available: + type: boolean + provider_message_fidelity: + type: string + resource_id_available: + type: boolean + trigger_series_available: + type: boolean + type: object + IntegrationIdentityView: + properties: + direction: + type: string + direction_name: + type: string + external_id: + nullable: true + type: string + integration_id: + type: string + operation: + type: string + operation_name: + type: string + provider: + type: string + provider_name: + type: string + type: object + IntegrationLinkageView: + properties: + audit_event_id: + minimum: 0 + nullable: true + type: integer + correlation_id: + nullable: true + type: string + request_id: + nullable: true + type: string + type: object + IntegrationListItem: + properties: + correlation_id: + nullable: true + type: string + created_at: + format: date-time + type: string + direction: + type: string + direction_name: + type: string + duration_ms: + type: integer + integration_id: + type: string + operation: + type: string + operation_name: + type: string + provider: + type: string + provider_name: + type: string + request_id: + nullable: true + type: string + resource: + $ref: '#/components/schemas/IntegrationResourceView' + result: + type: string + result_category: + type: string + result_name: + type: string + state_changed: + type: boolean + type: object + IntegrationListPage: + properties: + items: + items: + $ref: '#/components/schemas/IntegrationListItem' + nullable: true + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + IntegrationNamedCount: + properties: + code: + type: string + count: + type: integer + name: + type: string + type: object + IntegrationOverview: + properties: + anomaly_count: + type: integer + average_duration_ms: + type: number + directions: + items: + $ref: '#/components/schemas/IntegrationNamedCount' + nullable: true + type: array + p95_duration_ms: + type: number + providers: + items: + $ref: '#/components/schemas/IntegrationNamedCount' + nullable: true + type: array + results: + items: + $ref: '#/components/schemas/IntegrationResultCount' + nullable: true + type: array + stale_pending_count: + type: integer + state_changed_count: + type: integer + total: + type: integer + trend: + items: + $ref: '#/components/schemas/IntegrationTrendPoint' + nullable: true + type: array + unknown_count: + type: integer + type: object + IntegrationResourceView: + properties: + id: + nullable: true + type: string + key: + nullable: true + type: string + type: + nullable: true + type: string + type: object + IntegrationResultCount: + properties: + category: + type: string + code: + type: string + count: + type: integer + name: + type: string + type: object + IntegrationResultView: + properties: + category: + type: string + code: + type: string + duration_ms: + type: integer + http_status: + nullable: true + type: integer + name: + type: string + provider_code: + nullable: true + type: string + provider_message: + nullable: true + type: string + recovery_strategy: + nullable: true + type: string + state_changed: + type: boolean + type: object + IntegrationTimestampView: + properties: + created_at: + format: date-time + type: string + scheduled_at: + format: date-time + nullable: true + type: string + started_at: + format: date-time + nullable: true + type: string + updated_at: + format: date-time + type: string + type: object + IntegrationTrendPoint: + properties: + bucket_at: + format: date-time + type: string + failed: + type: integer + indeterminate: + type: integer + not_sent: + type: integer + processing: + type: integer + succeeded: + type: integer + total: + type: integer + type: object + IntegrationTriggerView: + properties: + attempt: + type: integer + scene: + nullable: true + type: string + series: + nullable: true + type: string + source: + nullable: true + type: string + type: object ModelPermission: properties: available_for_role_types: @@ -12844,6 +13393,95 @@ paths: summary: 查询代理在线充值可用支付方式 tags: - 代理预充值 + /api/admin/agent/resource-activities/{resource_type}/{identifier}: + get: + description: resource_type/identifier 来自代理当前业务页面稳定字段;店铺范围只读取认证上下文。仅返回写入时生成的安全业务结论和白名单详情,越权与不存在同错。 + parameters: + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: page_size + schema: + description: 每页数量,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + - description: 资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业) + in: path + name: resource_type + required: true + schema: + description: 资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业) + type: string + - description: 业务稳定标识;卡使用ICCID,设备使用VirtualNo,其他资源使用对应业务编号 + in: path + name: identifier + required: true + schema: + description: 业务稳定标识;卡使用ICCID,设备使用VirtualNo,其他资源使用对应业务编号 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/AuditSubjectActivityPage' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询代理资源活动 + tags: + - 资源活动 /api/admin/asset-allocation-records: get: parameters: @@ -13379,7 +14017,7 @@ paths: - 资产管理 /api/admin/assets/{identifier}/operation-logs: get: - description: 通过资产标识符查询审计日志,支持分页和操作类型/结果状态筛选。 + description: 仅超级管理员和平台账号可查询切换前旧资产日志;代理和企业必须使用独立资源活动接口。旧记录不接入统一审计时间线。 parameters: - description: 页码(默认1) in: query @@ -13468,7 +14106,7 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 资产操作审计日志 + summary: 查询平台旧资产操作日志 tags: - 资产管理 /api/admin/assets/{identifier}/orders: @@ -14484,6 +15122,1037 @@ paths: summary: 解析资产 tags: - 资产管理 + /api/admin/audit/actors/{kind}/{id}/events: + get: + description: kind/id 来自事件 actor_ref 或平台账号选择器;历史名称直接使用事件快照,不查询当前账号名称覆盖历史。 + parameters: + - description: 稳定动作编码 + in: query + name: action + schema: + description: 稳定动作编码 + type: string + - description: 事件结果 + in: query + name: result + schema: + description: 事件结果 + type: string + - description: 风险等级 + in: query + name: risk + schema: + description: 风险等级 + type: string + - description: 资源类型 + in: query + name: resource_type + schema: + description: 资源类型 + type: string + - description: 资源内部稳定ID + in: query + name: resource_id + schema: + description: 资源内部稳定ID + type: string + - description: 开始时间(RFC3339,含时区) + in: query + name: created_from + schema: + description: 开始时间(RFC3339,含时区) + type: string + - description: 结束时间(RFC3339,含时区,不包含该时刻) + in: query + name: created_to + schema: + description: 结束时间(RFC3339,含时区,不包含该时刻) + type: string + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: page_size + schema: + description: 每页数量,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + - description: 操作者类型 (account:人工账号, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统) + in: path + name: kind + required: true + schema: + description: 操作者类型 (account:人工账号, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统) + type: string + - description: 操作者稳定ID + in: path + name: id + required: true + schema: + description: 操作者稳定ID + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/AuditEventPage' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询操作者行为时间线 + tags: + - 审计调查 + /api/admin/audit/events: + get: + description: 筛选值来自调查人员输入或其他调查节点的稳定引用;身份范围只读取认证上下文。固定按发生时间和事件ID倒序,不提供导出、修改或删除。 + parameters: + - description: 开始时间(RFC3339,含时区) + in: query + name: created_from + schema: + description: 开始时间(RFC3339,含时区) + type: string + - description: 结束时间(RFC3339,含时区,不包含该时刻) + in: query + name: created_to + schema: + description: 结束时间(RFC3339,含时区,不包含该时刻) + type: string + - description: 稳定动作编码 + in: query + name: action + schema: + description: 稳定动作编码 + type: string + - description: 动作类别 + in: query + name: category + schema: + description: 动作类别 + type: string + - description: 操作者类型 + in: query + name: actor_kind + schema: + description: 操作者类型 + type: string + - description: 操作者稳定ID + in: query + name: actor_id + schema: + description: 操作者稳定ID + type: string + - description: 操作入口来源 + in: query + name: source + schema: + description: 操作入口来源 + type: string + - description: 结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知) + in: query + name: result + schema: + description: 结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知) + type: string + - description: 风险等级 (low:低, normal:普通, high:高, critical:严重) + in: query + name: risk + schema: + description: 风险等级 (low:低, normal:普通, high:高, critical:严重) + type: string + - description: 业务范围类型 + in: query + name: scope_type + schema: + description: 业务范围类型 + type: string + - description: 业务范围稳定ID + in: query + name: scope_id + schema: + description: 业务范围稳定ID + type: string + - description: Resource Registry 注册类型 + in: query + name: resource_type + schema: + description: Resource Registry 注册类型 + type: string + - description: 资源内部稳定ID + in: query + name: resource_id + schema: + description: 资源内部稳定ID + type: string + - description: 资源业务稳定Key + in: query + name: resource_key + schema: + description: 资源业务稳定Key + type: string + - description: HTTP请求关联ID + in: query + name: request_id + schema: + description: HTTP请求关联ID + type: string + - description: 跨步骤业务链路ID + in: query + name: correlation_id + schema: + description: 跨步骤业务链路ID + type: string + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: page_size + schema: + description: 每页数量,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/AuditEventPage' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询全局审计事件 + tags: + - 审计调查 + /api/admin/audit/events/{event_id}: + get: + description: event_id 来自事件、资源、操作者或链路节点的 investigation_refs;返回全部资源快照和各资源 before/after。 + parameters: + - description: 稳定审计事件ID + in: path + name: event_id + required: true + schema: + description: 稳定审计事件ID + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/AuditEventView' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询审计事件详情 + tags: + - 审计调查 + /api/admin/audit/integrations: + get: + description: 组合筛选来自调查输入或关联视角稳定引用,固定按创建时间和记录ID倒序分页,不提供任意摘要搜索。 + parameters: + - description: 开始时间(RFC3339,含时区,必填) + in: query + name: created_from + required: true + schema: + description: 开始时间(RFC3339,含时区,必填) + type: string + - description: 结束时间(RFC3339,含时区,不包含该时刻,必填) + in: query + name: created_to + required: true + schema: + description: 结束时间(RFC3339,含时区,不包含该时刻,必填) + type: string + - description: 稳定外部集成记录ID + in: query + name: integration_id + schema: + description: 稳定外部集成记录ID + type: string + - description: 外部服务提供方稳定编码 + in: query + name: provider + schema: + description: 外部服务提供方稳定编码 + type: string + - description: 交互方向 (inbound:入站, outbound:出站) + in: query + name: direction + schema: + description: 交互方向 (inbound:入站, outbound:出站) + type: string + - description: 外部操作稳定编码 + in: query + name: operation + schema: + description: 外部操作稳定编码 + type: string + - description: 原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消) + in: query + name: result + schema: + description: 原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消) + type: string + - description: 派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送) + in: query + name: result_category + schema: + description: 派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送) + type: string + - description: 外部系统业务或请求标识 + in: query + name: external_id + schema: + description: 外部系统业务或请求标识 + type: string + - description: 本地主要资源类型 + in: query + name: resource_type + schema: + description: 本地主要资源类型 + type: string + - description: 本地主要资源稳定ID + in: query + name: resource_id + schema: + description: 本地主要资源稳定ID + type: string + - description: 本地主要资源稳定Key + in: query + name: resource_key + schema: + description: 本地主要资源稳定Key + type: string + - description: 触发来源稳定编码 + in: query + name: trigger_source + schema: + description: 触发来源稳定编码 + type: string + - description: 触发业务场景 + in: query + name: trigger_scene + schema: + description: 触发业务场景 + type: string + - description: 显式技术尝试序列ID + in: query + name: trigger_series + schema: + description: 显式技术尝试序列ID + type: string + - description: 是否改变本地业务状态 + in: query + name: state_changed + schema: + description: 是否改变本地业务状态 + nullable: true + type: boolean + - description: 外部HTTP响应状态码 + in: query + name: http_status + schema: + description: 外部HTTP响应状态码 + maximum: 599 + minimum: 100 + nullable: true + type: integer + - description: 外部服务稳定结果码 + in: query + name: provider_code + schema: + description: 外部服务稳定结果码 + type: string + - description: 来源HTTP请求ID + in: query + name: request_id + schema: + description: 来源HTTP请求ID + type: string + - description: 跨步骤业务链路ID + in: query + name: correlation_id + schema: + description: 跨步骤业务链路ID + type: string + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: page_size + schema: + description: 每页数量,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/IntegrationListPage' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询外部集成交互列表 + tags: + - 审计调查 + /api/admin/audit/integrations/{integration_id}: + get: + description: integration_id 来自列表、通知目标 target_key 或调查节点稳定引用;只展示结构化详情和显式尝试序列,不提供重试、补偿、确认、绑定、恢复、修改、删除或导出。 + parameters: + - description: 稳定外部集成记录ID,来自列表、通知target_key或调查节点 + in: path + name: integration_id + required: true + schema: + description: 稳定外部集成记录ID,来自列表、通知target_key或调查节点 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/IntegrationDetail' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询外部集成交互详情 + tags: + - 审计调查 + /api/admin/audit/integrations/overview: + get: + description: 筛选和时间范围来自调查输入或关联视角跳转,身份只来自认证上下文。总览区分成功、处理中、结果不确定、失败和未发送终态。 + parameters: + - description: 开始时间(RFC3339,含时区,必填) + in: query + name: created_from + required: true + schema: + description: 开始时间(RFC3339,含时区,必填) + type: string + - description: 结束时间(RFC3339,含时区,不包含该时刻,必填) + in: query + name: created_to + required: true + schema: + description: 结束时间(RFC3339,含时区,不包含该时刻,必填) + type: string + - description: 稳定外部集成记录ID + in: query + name: integration_id + schema: + description: 稳定外部集成记录ID + type: string + - description: 外部服务提供方稳定编码 + in: query + name: provider + schema: + description: 外部服务提供方稳定编码 + type: string + - description: 交互方向 (inbound:入站, outbound:出站) + in: query + name: direction + schema: + description: 交互方向 (inbound:入站, outbound:出站) + type: string + - description: 外部操作稳定编码 + in: query + name: operation + schema: + description: 外部操作稳定编码 + type: string + - description: 原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消) + in: query + name: result + schema: + description: 原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消) + type: string + - description: 派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送) + in: query + name: result_category + schema: + description: 派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送) + type: string + - description: 外部系统业务或请求标识 + in: query + name: external_id + schema: + description: 外部系统业务或请求标识 + type: string + - description: 本地主要资源类型 + in: query + name: resource_type + schema: + description: 本地主要资源类型 + type: string + - description: 本地主要资源稳定ID + in: query + name: resource_id + schema: + description: 本地主要资源稳定ID + type: string + - description: 本地主要资源稳定Key + in: query + name: resource_key + schema: + description: 本地主要资源稳定Key + type: string + - description: 触发来源稳定编码 + in: query + name: trigger_source + schema: + description: 触发来源稳定编码 + type: string + - description: 触发业务场景 + in: query + name: trigger_scene + schema: + description: 触发业务场景 + type: string + - description: 显式技术尝试序列ID + in: query + name: trigger_series + schema: + description: 显式技术尝试序列ID + type: string + - description: 是否改变本地业务状态 + in: query + name: state_changed + schema: + description: 是否改变本地业务状态 + nullable: true + type: boolean + - description: 外部HTTP响应状态码 + in: query + name: http_status + schema: + description: 外部HTTP响应状态码 + maximum: 599 + minimum: 100 + nullable: true + type: integer + - description: 外部服务稳定结果码 + in: query + name: provider_code + schema: + description: 外部服务稳定结果码 + type: string + - description: 来源HTTP请求ID + in: query + name: request_id + schema: + description: 来源HTTP请求ID + type: string + - description: 跨步骤业务链路ID + in: query + name: correlation_id + schema: + description: 跨步骤业务链路ID + type: string + - description: 趋势时间粒度 (hour:小时, day:自然日),默认hour + in: query + name: bucket + schema: + description: 趋势时间粒度 (hour:小时, day:自然日),默认hour + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/IntegrationOverview' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询外部集成交互总览 + tags: + - 审计调查 + /api/admin/audit/resources/{resource_type}/{resource_id}/timeline: + get: + description: resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。 + parameters: + - description: 开始时间(RFC3339,含时区) + in: query + name: created_from + schema: + description: 开始时间(RFC3339,含时区) + type: string + - description: 结束时间(RFC3339,含时区,不包含该时刻) + in: query + name: created_to + schema: + description: 结束时间(RFC3339,含时区,不包含该时刻) + type: string + - description: 稳定动作编码 + in: query + name: action + schema: + description: 稳定动作编码 + type: string + - description: 事件结果 + in: query + name: result + schema: + description: 事件结果 + type: string + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: page_size + schema: + description: 每页数量,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + - description: Resource Registry 注册类型 + in: path + name: resource_type + required: true + schema: + description: Resource Registry 注册类型 + type: string + - description: 资源内部稳定ID + in: path + name: resource_id + required: true + schema: + description: 资源内部稳定ID + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/AuditEventPage' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询通用资源时间线 + tags: + - 审计调查 + /api/admin/audit/resources/search: + get: + description: 卡支持 ICCID/VirtualNo,设备支持 VirtualNo/IMEI/SN,店铺、订单、退款使用各自稳定编号。当前资源不存在时仅按 Registry 白名单快照字段精确查找历史,不做任意 JSON 模糊搜索。 + parameters: + - description: 资源类型 (iot_card:IoT卡, device:设备, shop:店铺, order:订单, refund:退款单) + in: query + name: resource_type + required: true + schema: + description: 资源类型 (iot_card:IoT卡, device:设备, shop:店铺, order:订单, refund:退款单) + type: string + - description: 精确业务标识;卡支持ICCID/VirtualNo,设备支持VirtualNo/IMEI/SN + in: query + name: keyword + required: true + schema: + description: 精确业务标识;卡支持ICCID/VirtualNo,设备支持VirtualNo/IMEI/SN + type: string + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: page_size + schema: + description: 每页数量,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/AuditResourceSearchPage' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 精确搜索注册资源 + tags: + - 审计调查 /api/admin/authorizations: get: parameters: @@ -17590,6 +19259,95 @@ paths: summary: 批量设置设备的套餐系列绑定 tags: - 设备管理 + /api/admin/enterprise/resource-activities/{resource_type}/{identifier}: + get: + description: 仅支持企业当前有效授权的卡和设备;企业身份只读取认证上下文,授权撤销后立即不可读取。响应不包含平台操作者、风险、内部前后值或外部交互内容。 + parameters: + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: page_size + schema: + description: 每页数量,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + - description: 资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业) + in: path + name: resource_type + required: true + schema: + description: 资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业) + type: string + - description: 业务稳定标识;卡使用ICCID,设备使用VirtualNo,其他资源使用对应业务编号 + in: path + name: identifier + required: true + schema: + description: 业务稳定标识;卡使用ICCID,设备使用VirtualNo,其他资源使用对应业务编号 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/AuditSubjectActivityPage' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询企业资源活动 + tags: + - 资源活动 /api/admin/enterprises: get: parameters: @@ -20283,35 +22041,18 @@ paths: name: category schema: description: 通知类别 (approval:审批, expiry:临期, sync:同步, system:系统) - enum: - - approval - - expiry - - sync - - system type: string - description: 稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额) in: query name: type schema: description: 稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额) - enum: - - system.notice - - package.expiring - - agent.recharge.completed - - refund.completed - - exchange.shipping.created - - agent.main_wallet.low_balance type: string - description: 通知级别 (info:提示, warning:警告, error:错误, critical:严重) in: query name: severity schema: description: 通知级别 (info:提示, warning:警告, error:错误, critical:严重) - enum: - - info - - warning - - error - - critical type: string - description: 已读状态;不传时查询全部 in: query @@ -29405,13 +31146,6 @@ paths: maxLength: 50 minLength: 1 type: string - - description: 套餐类型 (formal:正式套餐, addon:附加套餐) - in: query - name: package_type - schema: - description: 套餐类型 (formal:正式套餐, addon:附加套餐) - nullable: true - type: string responses: "200": content: @@ -29581,6 +31315,13 @@ paths: maxLength: 50 minLength: 1 type: string + - description: 套餐类型 (formal:正式套餐, addon:附加套餐) + in: query + name: package_type + schema: + description: 套餐类型 (formal:正式套餐, addon:附加套餐) + nullable: true + type: string responses: "200": content: diff --git a/docs/feature-504-multi-view-audit-center/主体资源活动接口.md b/docs/feature-504-multi-view-audit-center/主体资源活动接口.md new file mode 100644 index 0000000..156a4f1 --- /dev/null +++ b/docs/feature-504-multi-view-audit-center/主体资源活动接口.md @@ -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` 仅保留为平台切换前历史入口,不向代理或企业开放,也不把旧记录拼接到新活动接口。 diff --git a/docs/feature-504-multi-view-audit-center/外部集成调查接口.md b/docs/feature-504-multi-view-audit-center/外部集成调查接口.md new file mode 100644 index 0000000..2860f90 --- /dev/null +++ b/docs/feature-504-multi-view-audit-center/外部集成调查接口.md @@ -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 或任意全文搜索。 diff --git a/docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md b/docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md new file mode 100644 index 0000000..2bd7904 --- /dev/null +++ b/docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md @@ -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` 时按白名单目标继续跳转。 diff --git a/docs/ur38-agent-main-wallet-credit/功能总结.md b/docs/ur38-agent-main-wallet-credit/功能总结.md index 5456b6d..b5554cd 100644 --- a/docs/ur38-agent-main-wallet-credit/功能总结.md +++ b/docs/ur38-agent-main-wallet-credit/功能总结.md @@ -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`;服务端读取钱包当前版本并执行乐观锁条件更新,前端不管理乐观锁。降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。 ## 统一订单扣款 diff --git a/internal/application/accessaudit/change.go b/internal/application/accessaudit/change.go new file mode 100644 index 0000000..38ac3b1 --- /dev/null +++ b/internal/application/accessaudit/change.go @@ -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" +} diff --git a/internal/application/accountaudit/lifecycle.go b/internal/application/accountaudit/lifecycle.go new file mode 100644 index 0000000..8d5c41d --- /dev/null +++ b/internal/application/accountaudit/lifecycle.go @@ -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 +} diff --git a/internal/application/agentrecharge/confirm_online_payment.go b/internal/application/agentrecharge/confirm_online_payment.go index 6e71438..a9fa231 100644 --- a/internal/application/agentrecharge/confirm_online_payment.go +++ b/internal/application/agentrecharge/confirm_online_payment.go @@ -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, "写入代理充值支付确认事件失败") diff --git a/internal/application/outbox/recovery.go b/internal/application/outbox/recovery.go index 44e0e3e..e4024e0 100644 --- a/internal/application/outbox/recovery.go +++ b/internal/application/outbox/recovery.go @@ -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 +} diff --git a/internal/application/role/default_credit.go b/internal/application/role/default_credit.go index 6e5103e..204b98e 100644 --- a/internal/application/role/default_credit.go +++ b/internal/application/role/default_credit.go @@ -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 { diff --git a/internal/application/shop/create.go b/internal/application/shop/create.go index dd86562..389d164 100644 --- a/internal/application/shop/create.go +++ b/internal/application/shop/create.go @@ -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 diff --git a/internal/application/shop/update.go b/internal/application/shop/update.go index e2f60ea..9f45ce9 100644 --- a/internal/application/shop/update.go +++ b/internal/application/shop/update.go @@ -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 diff --git a/internal/application/systemconfig/update.go b/internal/application/systemconfig/update.go index 1872f6f..81336ce 100644 --- a/internal/application/systemconfig/update.go +++ b/internal/application/systemconfig/update.go @@ -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} } diff --git a/internal/application/wallet/change_credit.go b/internal/application/wallet/change_credit.go index d992657..719be60 100644 --- a/internal/application/wallet/change_credit.go +++ b/internal/application/wallet/change_credit.go @@ -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 diff --git a/internal/application/wecom/connection.go b/internal/application/wecom/connection.go index fb7a055..ce64824 100644 --- a/internal/application/wecom/connection.go +++ b/internal/application/wecom/connection.go @@ -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)) diff --git a/internal/bootstrap/dependencies.go b/internal/bootstrap/dependencies.go index ccc7126..d04f921 100644 --- a/internal/bootstrap/dependencies.go +++ b/internal/bootstrap/dependencies.go @@ -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 } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 8cedce4..731486d 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index d3fdd60..90316b4 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index a958958..ac2caf2 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -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 } diff --git a/internal/governance/auditcoverage/scanner.go b/internal/governance/auditcoverage/scanner.go index 4f15b8e..f68054d 100644 --- a/internal/governance/auditcoverage/scanner.go +++ b/internal/governance/auditcoverage/scanner.go @@ -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 diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index b1ac8b1..596b7cf 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -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, "资产审计服务未配置") } diff --git a/internal/handler/admin/audit.go b/internal/handler/admin/audit.go new file mode 100644 index 0000000..71885e5 --- /dev/null +++ b/internal/handler/admin/audit.go @@ -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 +} diff --git a/internal/handler/admin/shop.go b/internal/handler/admin/shop.go index 15c7a7f..59565be 100644 --- a/internal/handler/admin/shop.go +++ b/internal/handler/admin/shop.go @@ -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 } diff --git a/internal/handler/callback/carrier_switch.go b/internal/handler/callback/carrier_switch.go index 84a3686..c240dc1 100644 --- a/internal/handler/callback/carrier_switch.go +++ b/internal/handler/callback/carrier_switch.go @@ -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 +} diff --git a/internal/handler/callback/cmcc_realname.go b/internal/handler/callback/cmcc_realname.go index 231723c..b4a2d0f 100644 --- a/internal/handler/callback/cmcc_realname.go +++ b/internal/handler/callback/cmcc_realname.go @@ -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 { diff --git a/internal/handler/callback/ctcc_realname.go b/internal/handler/callback/ctcc_realname.go index 2f86ec9..9244a0e 100644 --- a/internal/handler/callback/ctcc_realname.go +++ b/internal/handler/callback/ctcc_realname.go @@ -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 { diff --git a/internal/handler/callback/cucc_realname.go b/internal/handler/callback/cucc_realname.go index a3fec31..a810095 100644 --- a/internal/handler/callback/cucc_realname.go +++ b/internal/handler/callback/cucc_realname.go @@ -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 { diff --git a/internal/handler/callback/cucc_realname_removal.go b/internal/handler/callback/cucc_realname_removal.go index 279f099..a97c918 100644 --- a/internal/handler/callback/cucc_realname_removal.go +++ b/internal/handler/callback/cucc_realname_removal.go @@ -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 { diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index c089caa..dad7c72 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -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 != "" { diff --git a/internal/infrastructure/audit/batch.go b/internal/infrastructure/audit/batch.go new file mode 100644 index 0000000..44fd4ad --- /dev/null +++ b/internal/infrastructure/audit/batch.go @@ -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 +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go new file mode 100644 index 0000000..7b31042 --- /dev/null +++ b/internal/infrastructure/audit/registry.go @@ -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 +} diff --git a/internal/infrastructure/audit/security_test.go b/internal/infrastructure/audit/security_test.go new file mode 100644 index 0000000..501a490 --- /dev/null +++ b/internal/infrastructure/audit/security_test.go @@ -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) + } +} diff --git a/internal/infrastructure/audit/writer.go b/internal/infrastructure/audit/writer.go new file mode 100644 index 0000000..b78bd34 --- /dev/null +++ b/internal/infrastructure/audit/writer.go @@ -0,0 +1,1098 @@ +package audit + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strconv" + "time" + + "github.com/bytedance/sonic" + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" + accountauditapp "github.com/break/junhong_cmp_fiber/internal/application/accountaudit" + outboxapp "github.com/break/junhong_cmp_fiber/internal/application/outbox" + systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" + wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/sanitizer" +) + +// WriteAccountLifecycle 将账号生命周期业务事实转换为统一 Audit Event。 +func (w *Writer) WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit accountauditapp.LifecycleAudit) error { + if audit.Account == nil || audit.Account.Username == "" { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号生命周期审计资源不完整") + } + resources := []ResourceInput{{ + Type: constants.AuditResourceAccount, ID: optionalResourceID(audit.Account.ID), + Key: accountResourceKey(audit.Account), DisplayName: audit.Account.Username, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget, + IdentitySnapshot: accountIdentity(audit.Account), BeforeData: audit.BeforeData, AfterData: audit.AfterData, + }} + if audit.Shop != nil { + shopID := strconv.FormatUint(uint64(audit.Shop.ID), 10) + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceShop, ID: &shopID, Key: shopID, DisplayName: audit.Shop.ShopName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope, + IdentitySnapshot: map[string]any{"id": audit.Shop.ID, "shop_code": audit.Shop.ShopCode, "shop_name": audit.Shop.ShopName, "parent_id": audit.Shop.ParentID, "level": audit.Shop.Level}, + }) + } + if audit.Enterprise != nil { + enterpriseID := strconv.FormatUint(uint64(audit.Enterprise.ID), 10) + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceEnterprise, ID: &enterpriseID, Key: enterpriseID, DisplayName: audit.Enterprise.EnterpriseName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope, + IdentitySnapshot: map[string]any{"id": audit.Enterprise.ID, "enterprise_code": audit.Enterprise.EnterpriseCode, "enterprise_name": audit.Enterprise.EnterpriseName, "owner_shop_id": audit.Enterprise.OwnerShopID}, + }) + } + for _, role := range audit.Roles { + roleID := strconv.FormatUint(uint64(role.ID), 10) + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceRole, ID: &roleID, Key: roleID, DisplayName: role.RoleName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountRole, + IdentitySnapshot: map[string]any{"id": role.ID, "role_name": role.RoleName, "role_type": role.RoleType, "status": role.Status}, + }) + } + return w.Append(ctx, tx, AppendInput{ + ActionCode: audit.ActionCode, Summary: audit.Summary, + Actor: ActorInput{Kind: constants.AuditActorAccount}, Source: constants.AuditSourceAdminAPI, + ScopeType: constants.AuditScopePlatform, Result: audit.Result, + ErrorCode: audit.ErrorCode, ErrorSummary: audit.ErrorSummary, Resources: resources, + }) +} + +// WriteAccountSecurity 将不含密码、验证码、Token 或 Cookie 的账号安全事实转换为统一 Audit Event。 +func (w *Writer) WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit accountauditapp.SecurityAudit) error { + if audit.Account == nil || audit.Account.ID == 0 || audit.Account.Username == "" || audit.AuthenticationKey == "" { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号安全审计资源不完整") + } + accountID := strconv.FormatUint(uint64(audit.Account.ID), 10) + actorID := audit.ActorID + if actorID == 0 { + actorID = audit.Account.ID + } + resources := []ResourceInput{ + { + Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: audit.Account.Username, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget, + IdentitySnapshot: accountIdentity(audit.Account), BeforeData: audit.BeforeData, AfterData: audit.AfterData, + }, + { + Type: constants.AuditResourceAuthentication, Key: audit.AuthenticationKey, DisplayName: "认证状态", + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAuthentication, + IdentitySnapshot: audit.Authentication, + }, + } + return w.Append(ctx, tx, AppendInput{ + ActionCode: audit.ActionCode, Summary: audit.Summary, + Actor: ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(actorID), 10), Name: audit.ActorName}, + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + Result: audit.Result, ErrorCode: audit.ErrorCode, ErrorSummary: audit.ErrorSummary, + Resources: resources, + }) +} + +func optionalResourceID(id uint) *string { + if id == 0 { + return nil + } + value := strconv.FormatUint(uint64(id), 10) + return &value +} + +func accountResourceKey(account *model.Account) string { + if account.ID == 0 { + return account.Username + } + return strconv.FormatUint(uint64(account.ID), 10) +} + +func accountIdentity(account *model.Account) map[string]any { + return map[string]any{ + "id": account.ID, "username": account.Username, "phone": account.Phone, "user_type": account.UserType, + "shop_id": account.ShopID, "enterprise_id": account.EnterpriseID, + "wecom_userid": account.WeComUserID, "wecom_name": account.WeComName, + } +} + +// WriteAccessChange 将账号权限与组织变化转换为统一 Audit Event。 +func (w *Writer) WriteAccessChange(ctx context.Context, tx *gorm.DB, change accessauditapp.ChangeAudit) error { + action, ok := w.registry.Action(change.ActionCode) + if !ok { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计动作未注册") + } + if change.OperatorID == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计操作者不完整") + } + resources, err := accessResources(change, action.PrimaryResource) + if err != nil { + return err + } + result := change.Result + if result == "" { + result = constants.AuditResultSuccess + } + actorKind := change.ActorKind + if actorKind == "" { + actorKind = constants.AuditActorAccount + } + actorName := change.ActorName + if actorName == "" { + actorName = middleware.GetUsernameFromContext(ctx) + } + source := change.Source + if source == "" { + source = constants.AuditSourceAdminAPI + } + scopeType := change.ScopeType + if scopeType == "" { + scopeType = constants.AuditScopePlatform + } + return w.Append(ctx, tx, AppendInput{ + ActionCode: action.Code, Summary: change.Summary, + Actor: ActorInput{ + Kind: actorKind, ID: strconv.FormatUint(uint64(change.OperatorID), 10), Name: actorName, + }, + Source: source, ScopeType: scopeType, + Result: result, ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary, Resources: resources, + }) +} + +func accessResources(change accessauditapp.ChangeAudit, primaryResource string) ([]ResourceInput, error) { + resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.Roles)+len(change.Permissions)) + switch primaryResource { + case constants.AuditResourceAccount: + if change.Account == nil || (change.Account.ID == 0 && change.Account.Username == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "账号授权审计资源不完整") + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceAccount, ID: optionalResourceID(change.Account.ID), + Key: accountResourceKey(change.Account), DisplayName: change.Account.Username, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget, + IdentitySnapshot: accountIdentity(change.Account), BeforeData: change.BeforeData, AfterData: change.AfterData, + }) + if change.Shop != nil { + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID), + Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope, + IdentitySnapshot: map[string]any{ + "id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName, + "parent_id": change.Shop.ParentID, "level": change.Shop.Level, + }, + }) + } + case constants.AuditResourceShop: + if change.Shop == nil || (change.Shop.ID == 0 && change.Shop.ShopCode == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "店铺授权审计资源不完整") + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID), + Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleShopTarget, + IdentitySnapshot: map[string]any{ + "id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName, + "parent_id": change.Shop.ParentID, "level": change.Shop.Level, + }, + BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: change.SubjectVisibility, SubjectSummary: change.SubjectSummary, SubjectData: change.SubjectData, + }) + if change.ParentShop != nil { + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceShop, ID: optionalResourceID(change.ParentShop.ID), + Key: shopResourceKey(change.ParentShop), DisplayName: change.ParentShop.ShopName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopParent, + SubjectVisibility: constants.AuditSubjectInternalOnly, + IdentitySnapshot: map[string]any{ + "id": change.ParentShop.ID, "shop_code": change.ParentShop.ShopCode, "shop_name": change.ParentShop.ShopName, + "parent_id": change.ParentShop.ParentID, "level": change.ParentShop.Level, + }, + }) + } + case constants.AuditResourceEnterprise: + if change.Enterprise == nil || (change.Enterprise.ID == 0 && change.Enterprise.EnterpriseCode == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业审计资源不完整") + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceEnterprise, ID: optionalResourceID(change.Enterprise.ID), + Key: enterpriseResourceKey(change.Enterprise), DisplayName: change.Enterprise.EnterpriseName, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleEnterpriseTarget, + IdentitySnapshot: enterpriseIdentity(change.Enterprise), BeforeData: change.BeforeData, AfterData: change.AfterData, + }) + if change.Shop != nil { + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID), + Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleEnterpriseOwnerShop, + IdentitySnapshot: map[string]any{ + "id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName, + "parent_id": change.Shop.ParentID, "level": change.Shop.Level, + }, + }) + } + case constants.AuditResourcePersonalCustomer: + if change.PersonalCustomer == nil || change.PersonalCustomer.ID == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户审计资源不完整") + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourcePersonalCustomer, ID: optionalResourceID(change.PersonalCustomer.ID), + Key: strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10), DisplayName: change.PersonalCustomer.Nickname, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePersonalCustomerTarget, + IdentitySnapshot: personalCustomerIdentity(change.PersonalCustomer), BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: change.SubjectVisibility, SubjectSummary: change.SubjectSummary, SubjectData: change.SubjectData, + }) + } + for index, item := range change.Accounts { + if item.Account == nil || (item.Account.ID == 0 && item.Account.Username == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "组织关联账号审计资源不完整") + } + relation := item.Relation + if relation == "" { + relation = constants.AuditResourceRelationAffected + } + resourceRole := item.Role + if resourceRole == "" { + resourceRole = constants.AuditResourceRoleShopAccount + if primaryResource == constants.AuditResourceEnterprise { + resourceRole = constants.AuditResourceRoleEnterpriseAccount + } + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceAccount, ID: optionalResourceID(item.Account.ID), + Key: accountResourceKey(item.Account), DisplayName: item.Account.Username, + Relation: relation, Role: resourceRole, IdentitySnapshot: accountIdentity(item.Account), + BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + } + for index, item := range change.Cards { + if item.Card == nil || (item.Card.ID == 0 && item.Card.ICCID == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业关联卡审计资源不完整") + } + relation := item.Relation + if relation == "" { + relation = constants.AuditResourceRelationAffected + } + role := item.Role + if role == "" { + role = constants.AuditResourceRoleEnterpriseAuthorizedCard + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceIotCard, ID: optionalResourceID(item.Card.ID), + Key: iotCardResourceKey(item.Card), DisplayName: item.Card.ICCID, + Relation: relation, Role: role, IdentitySnapshot: iotCardIdentity(item.Card), + BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: item.SubjectVisibility, SubjectSummary: item.SubjectSummary, + SubjectData: item.SubjectData, SortOrder: index + 1, + }) + } + for index, item := range change.CardAuthorizations { + if item.Authorization == nil || item.Authorization.ID == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业卡授权审计资源不完整") + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceEnterpriseCardAuthorization, ID: optionalResourceID(item.Authorization.ID), + Key: strconv.FormatUint(uint64(item.Authorization.ID), 10), DisplayName: "企业卡授权记录", + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleEnterpriseCardAuthorization, + IdentitySnapshot: enterpriseCardAuthorizationIdentity(item.Authorization), + BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + } + for index, item := range change.Devices { + if item.Device == nil || (item.Device.ID == 0 && item.Device.VirtualNo == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业关联设备审计资源不完整") + } + relation := item.Relation + if relation == "" { + relation = constants.AuditResourceRelationAffected + } + role := item.Role + if role == "" { + role = constants.AuditResourceRoleEnterpriseAuthorizedDevice + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceDevice, ID: optionalResourceID(item.Device.ID), + Key: deviceResourceKey(item.Device), DisplayName: item.Device.VirtualNo, + Relation: relation, Role: role, IdentitySnapshot: deviceIdentity(item.Device), + BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: item.SubjectVisibility, SubjectSummary: item.SubjectSummary, + SubjectData: item.SubjectData, SortOrder: index + 1, + }) + } + for index, item := range change.DeviceBindings { + if item.Binding == nil || item.Binding.ID == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业设备卡槽绑定审计资源不完整") + } + relation := item.Relation + if relation == "" { + relation = constants.AuditResourceRelationReference + } + role := item.Role + if role == "" { + role = constants.AuditResourceRoleEnterpriseDeviceBinding + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceDeviceSIMBinding, ID: optionalResourceID(item.Binding.ID), + Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: "设备卡槽绑定", + Relation: relation, Role: role, IdentitySnapshot: deviceSimBindingIdentity(item.Binding), + BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + } + for index, item := range change.DeviceAuthorizations { + if item.Authorization == nil || item.Authorization.ID == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业设备授权审计资源不完整") + } + relation := item.Relation + if relation == "" { + relation = constants.AuditResourceRelationAffected + } + role := item.Role + if role == "" { + role = constants.AuditResourceRoleEnterpriseDeviceAuthorization + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceEnterpriseDeviceAuthorization, ID: optionalResourceID(item.Authorization.ID), + Key: strconv.FormatUint(uint64(item.Authorization.ID), 10), DisplayName: "企业设备授权记录", + Relation: relation, Role: role, IdentitySnapshot: enterpriseDeviceAuthorizationIdentity(item.Authorization), + BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + } + for index, item := range change.PersonalPhones { + if item.Phone == nil || item.Phone.ID == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户手机号审计资源不完整") + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourcePersonalCustomerPhone, ID: optionalResourceID(item.Phone.ID), + Key: strconv.FormatUint(uint64(item.Phone.ID), 10), DisplayName: item.Phone.Phone, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerPhone, + IdentitySnapshot: personalCustomerPhoneIdentity(item.Phone), BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + } + for index, item := range change.PersonalOpenIDs { + if item.OpenID == nil || item.OpenID.ID == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户微信主体审计资源不完整") + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourcePersonalCustomerOpenID, ID: optionalResourceID(item.OpenID.ID), + Key: strconv.FormatUint(uint64(item.OpenID.ID), 10), DisplayName: item.OpenID.AppType, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerWechatIdentity, + IdentitySnapshot: personalCustomerOpenIDIdentity(item.OpenID), BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + } + if primaryResource == constants.AuditResourceRole { + if change.Role == nil || (change.Role.ID == 0 && change.Role.RoleName == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "角色审计资源不完整") + } + resources = append(resources, roleResource(change.Role, change.BeforeData, change.AfterData)) + } + for index, item := range change.Roles { + if item.Role == nil || (item.Role.ID == 0 && item.Role.RoleName == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体授权角色审计资源不完整") + } + resourceRole := constants.AuditResourceRoleAccountRole + if primaryResource == constants.AuditResourceShop { + resourceRole = constants.AuditResourceRoleShopRole + } + resources = append(resources, authorizationRoleResource(item, resourceRole, index+1)) + } + for index, item := range change.Permissions { + if item.Permission == nil || (item.Permission.ID == 0 && item.Permission.PermCode == "") { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "权限审计资源不完整") + } + relation := constants.AuditResourceRelationAffected + if primaryResource == constants.AuditResourcePermission && index == 0 { + relation = constants.AuditResourceRelationPrimary + } + beforeData, afterData := item.BeforeData, item.AfterData + if primaryResource == constants.AuditResourcePermission && index == 0 { + if beforeData == nil { + beforeData = change.BeforeData + } + if afterData == nil { + afterData = change.AfterData + } + } + resources = append(resources, permissionResource(item.Permission, relation, beforeData, afterData, index+1)) + } + if primaryResource == constants.AuditResourcePermission && len(resources) != 1 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "权限 CRUD 审计必须包含一个权限资源") + } + return resources, nil +} + +func authorizationRoleResource(change accessauditapp.RoleChange, resourceRole string, sortOrder int) ResourceInput { + return ResourceInput{ + Type: constants.AuditResourceRole, ID: optionalResourceID(change.Role.ID), + Key: roleResourceKey(change.Role), DisplayName: change.Role.RoleName, + Relation: constants.AuditResourceRelationAffected, Role: resourceRole, + IdentitySnapshot: map[string]any{ + "id": change.Role.ID, "role_name": change.Role.RoleName, + "role_type": change.Role.RoleType, "status": change.Role.Status, + }, + BeforeData: change.BeforeData, AfterData: change.AfterData, SortOrder: sortOrder, + } +} + +func shopResourceKey(shop *model.Shop) string { + if shop.ID != 0 { + return strconv.FormatUint(uint64(shop.ID), 10) + } + return shop.ShopCode +} + +func enterpriseResourceKey(enterprise *model.Enterprise) string { + if enterprise.ID != 0 { + return strconv.FormatUint(uint64(enterprise.ID), 10) + } + return enterprise.EnterpriseCode +} + +func enterpriseIdentity(enterprise *model.Enterprise) map[string]any { + return map[string]any{ + "id": enterprise.ID, "enterprise_code": enterprise.EnterpriseCode, + "enterprise_name": enterprise.EnterpriseName, "owner_shop_id": enterprise.OwnerShopID, + } +} + +func iotCardResourceKey(card *model.IotCard) string { + if card.ID != 0 { + return strconv.FormatUint(uint64(card.ID), 10) + } + return card.ICCID +} + +func iotCardIdentity(card *model.IotCard) map[string]any { + return map[string]any{ + "id": card.ID, "iccid": card.ICCID, "iccid_19": card.ICCID19, "iccid_20": card.ICCID20, + "virtual_no": card.VirtualNo, "msisdn": card.MSISDN, "carrier_type": card.CarrierType, + "shop_id": card.ShopID, "series_id": card.SeriesID, "generation": card.Generation, + } +} + +func deviceResourceKey(device *model.Device) string { + if device.ID != 0 { + return strconv.FormatUint(uint64(device.ID), 10) + } + return device.VirtualNo +} + +func deviceIdentity(device *model.Device) map[string]any { + return map[string]any{ + "id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, + "sn": device.SN, "generation": device.Generation, + } +} + +func deviceSimBindingIdentity(binding *model.DeviceSimBinding) map[string]any { + return map[string]any{ + "id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition, + "iot_card_id": binding.IotCardID, "is_current": binding.IsCurrent, + } +} + +func enterpriseCardAuthorizationIdentity(auth *model.EnterpriseCardAuthorization) map[string]any { + return map[string]any{ + "id": auth.ID, "enterprise_id": auth.EnterpriseID, "card_id": auth.CardID, + "authorized_by": auth.AuthorizedBy, "authorizer_type": auth.AuthorizerType, + "authorized_at": auth.AuthorizedAt, "revoked_by": auth.RevokedBy, + "revoked_at": auth.RevokedAt, "device_auth_id": auth.DeviceAuthID, + } +} + +func enterpriseDeviceAuthorizationIdentity(auth *model.EnterpriseDeviceAuthorization) map[string]any { + return map[string]any{ + "id": auth.ID, "enterprise_id": auth.EnterpriseID, "device_id": auth.DeviceID, + "authorized_by": auth.AuthorizedBy, "authorizer_type": auth.AuthorizerType, + "authorized_at": auth.AuthorizedAt, "revoked_by": auth.RevokedBy, "revoked_at": auth.RevokedAt, + } +} + +func personalCustomerIdentity(customer *model.PersonalCustomer) map[string]any { + return map[string]any{ + "id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID, + "wx_union_id": customer.WxUnionID, "status": customer.Status, + } +} + +func personalCustomerPhoneIdentity(phone *model.PersonalCustomerPhone) map[string]any { + return map[string]any{ + "id": phone.ID, "customer_id": phone.CustomerID, "phone": phone.Phone, + "is_primary": phone.IsPrimary, "verified_at": phone.VerifiedAt, "status": phone.Status, + } +} + +func personalCustomerOpenIDIdentity(openID *model.PersonalCustomerOpenID) map[string]any { + return map[string]any{ + "id": openID.ID, "customer_id": openID.CustomerID, "app_id": openID.AppID, + "open_id": openID.OpenID, "union_id": openID.UnionID, "app_type": openID.AppType, + } +} + +func roleResource(role *model.Role, beforeData, afterData map[string]any) ResourceInput { + return ResourceInput{ + Type: constants.AuditResourceRole, ID: optionalResourceID(role.ID), Key: roleResourceKey(role), DisplayName: role.RoleName, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccessRole, + IdentitySnapshot: map[string]any{ + "id": role.ID, "role_name": role.RoleName, "role_type": role.RoleType, "status": role.Status, + "default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit, + }, + BeforeData: beforeData, AfterData: afterData, + } +} + +func permissionResource(permission *model.Permission, relation string, beforeData, afterData map[string]any, sortOrder int) ResourceInput { + return ResourceInput{ + Type: constants.AuditResourcePermission, ID: optionalResourceID(permission.ID), Key: permissionResourceKey(permission), DisplayName: permission.PermName, + Relation: relation, Role: constants.AuditResourceRoleAccessPermission, + IdentitySnapshot: map[string]any{ + "id": permission.ID, "perm_name": permission.PermName, "perm_code": permission.PermCode, + "perm_type": permission.PermType, "platform": permission.Platform, + "available_for_role_types": permission.AvailableForRoleTypes, "parent_id": permission.ParentID, "status": permission.Status, + }, + BeforeData: beforeData, AfterData: afterData, SortOrder: sortOrder, + } +} + +func roleResourceKey(role *model.Role) string { + if role.ID != 0 { + return strconv.FormatUint(uint64(role.ID), 10) + } + return role.RoleName +} + +func permissionResourceKey(permission *model.Permission) string { + if permission.ID != 0 { + return strconv.FormatUint(uint64(permission.ID), 10) + } + return permission.PermCode +} + +// ActorInput 是事件发生时的真实操作者快照。 +type ActorInput struct { + Kind string + ID string + Name string + ShopID *uint + ShopName string + EnterpriseID *uint + EnterpriseName string +} + +// WriteSensitiveRead 在返回企业微信明文凭据前同步追加读取审计。 +func (w *Writer) WriteSensitiveRead(ctx context.Context, tx *gorm.DB, read wecomapp.SensitiveReadAudit) error { + action, ok := w.registry.Action(constants.AuditActionWeComCredentialsRead) + if !ok || !action.SensitiveRead { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "敏感读取动作未注册") + } + if read.OperatorID == 0 || len(read.Applications) == 0 || len(read.FieldClasses) == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "敏感读取审计事实不完整") + } + resources := make([]ResourceInput, 0, len(read.Applications)) + for index, application := range read.Applications { + relation := constants.AuditResourceRelationReference + if index == 0 { + relation = constants.AuditResourceRelationPrimary + } + resourceID := strconv.FormatUint(uint64(application.ID), 10) + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceWeComApplication, ID: &resourceID, + Key: resourceID, DisplayName: application.Name, Relation: relation, + Role: constants.AuditResourceRoleSensitiveReadTarget, + IdentitySnapshot: map[string]any{ + "id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID, + "name": application.Name, "status": application.Status, + "credentials_configured": application.CredentialsConfigured, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index, + }) + } + return w.Append(ctx, tx, AppendInput{ + ActionCode: action.Code, Summary: "读取企业微信应用明文凭据", + Actor: ActorInput{ + Kind: constants.AuditActorAccount, + ID: strconv.FormatUint(uint64(read.OperatorID), 10), Name: middleware.GetUsernameFromContext(ctx), + }, + Source: action.Source, ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + RequestID: read.RequestID, CorrelationID: read.CorrelationID, + Metadata: map[string]any{"field_classes": read.FieldClasses}, Resources: resources, + }) +} + +// ResourceInput 是一个独立审计资源的写入事实。 +type ResourceInput struct { + Type string + ID *string + Key string + DisplayName string + Relation string + Role string + IdentitySnapshot map[string]any + BeforeData map[string]any + AfterData map[string]any + SubjectVisibility string + SubjectSummary string + SubjectData map[string]any + SortOrder int +} + +// AppendInput 是统一 Append Writer 的最小事件输入。 +type AppendInput struct { + EventID string + OccurredAt time.Time + ActionCode string + Summary string + Actor ActorInput + Source string + RequestPath string + RequestMethod string + IPAddress string + UserAgent string + ScopeType string + ScopeID string + ScopeName string + Result string + ErrorCode string + ErrorSummary string + RequestID string + CorrelationID string + ParentEventID string + BatchTotal int + SuccessCount int + FailCount int + Metadata map[string]any + Resources []ResourceInput +} + +// Writer 只提供不可变 Audit Event 追加能力。 +type Writer struct { + registry *Registry + now func() time.Time +} + +// NewWriter 创建统一 Audit Event Append Writer。 +func NewWriter(registry *Registry, now func() time.Time) *Writer { + if registry == nil { + registry = NewRegistry() + } + if now == nil { + now = time.Now + } + return &Writer{registry: registry, now: now} +} + +// WriteConfigChange 将受控系统配置变化转换为统一 Audit Event。 +func (w *Writer) WriteConfigChange(ctx context.Context, tx *gorm.DB, change systemconfigapp.ChangeAudit) error { + action, ok := w.registry.ActionByOperation(change.OperationType) + if !ok { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册") + } + if change.OperatorID == 0 || change.ConfigKey == "" { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "系统配置审计资源不完整") + } + result := change.Result + if result == "" { + result = constants.AuditResultSuccess + } + return w.Append(ctx, tx, AppendInput{ + ActionCode: action.Code, Summary: change.Description, + Actor: ActorInput{ + Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(change.OperatorID), 10), + Name: middleware.GetUsernameFromContext(ctx), + }, + Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)), + RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)), + IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)), + ScopeType: constants.AuditScopePlatform, Result: result, + ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary, + RequestID: change.RequestID, CorrelationID: change.CorrelationID, + Resources: []ResourceInput{{ + Type: action.PrimaryResource, Key: change.ConfigKey, DisplayName: change.ConfigKey, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleConfig, + IdentitySnapshot: map[string]any{"config_key": change.ConfigKey, "module": change.Module}, + BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: action.DefaultVisibility, + }}, + }) +} + +// WriteRecovery 将 Outbox 人工恢复裁决转换为统一 Audit Event。 +func (w *Writer) WriteRecovery(ctx context.Context, tx *gorm.DB, recovery outboxapp.RecoveryAudit) error { + action, ok := w.registry.ActionByOperation(recovery.OperationType) + if !ok { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册") + } + if recovery.OperatorID == 0 || recovery.BatchID == "" || recovery.Reason == "" || len(recovery.Events) == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "Outbox 恢复审计事实不完整") + } + result := recovery.Result + if result == "" { + result = constants.AuditResultSuccess + } + resources := make([]ResourceInput, 0, len(recovery.Events)) + for index, event := range recovery.Events { + if event.ID == 0 || event.EventID == "" || event.EventType == "" { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "Outbox 恢复审计资源不完整") + } + relation := constants.AuditResourceRelationAffected + if index == 0 { + relation = constants.AuditResourceRelationPrimary + } + resourceID := strconv.FormatUint(uint64(event.ID), 10) + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceOutboxEvent, ID: &resourceID, + Key: event.EventID, DisplayName: event.EventID, + Relation: relation, Role: constants.AuditResourceRoleRecoveryTarget, + IdentitySnapshot: map[string]any{ + "event_id": event.EventID, "event_type": event.EventType, + "aggregate_type": event.AggregateType, "aggregate_id": event.AggregateID, + "resource_type": event.ResourceType, "resource_id": event.ResourceID, + "business_key": event.BusinessKey, + }, + BeforeData: recoveryStateData(event.BeforeStatus, event.BeforeNextAttempt, event.BeforeLeaseOwner, event.BeforeLeaseExpires), + AfterData: recoveryStateData(event.AfterStatus, event.AfterNextAttempt, event.AfterLeaseOwner, event.AfterLeaseExpires), + SubjectVisibility: action.DefaultVisibility, SortOrder: index, + }) + } + return w.Append(ctx, tx, AppendInput{ + ActionCode: action.Code, Summary: recovery.Description, + Actor: ActorInput{ + Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(recovery.OperatorID), 10), + Name: middleware.GetUsernameFromContext(ctx), + }, + Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)), + RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)), + IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)), + ScopeType: constants.AuditScopePlatform, Result: result, + ErrorCode: recovery.ErrorCode, ErrorSummary: recovery.ErrorSummary, + RequestID: recovery.RequestID, CorrelationID: recovery.CorrelationID, + BatchTotal: len(resources), SuccessCount: recoverySuccessCount(result, len(resources)), + FailCount: recoveryFailCount(result, len(resources)), + Metadata: map[string]any{"batch_id": recovery.BatchID, "reason": recovery.Reason}, + Resources: resources, + }) +} + +// Append 在调用方提供的 GORM 事务中顺序追加事件及资源。 +func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) error { + if w == nil || w.registry == nil || tx == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "统一审计 Writer 未正确配置") + } + input = fillFromContext(ctx, input) + action, ok := w.registry.Action(input.ActionCode) + if !ok { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册") + } + if input.Actor.Kind != action.AllowedActor || input.Actor.ID == "" || input.Source != action.Source { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计操作者或入口不符合动作注册规则") + } + if !validResult(input.Result) || len(input.Resources) == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计结果非法或缺少资源") + } + resources, err := w.buildResources(input.Resources, action) + if err != nil { + return err + } + metadata, err := safeObject(input.Metadata) + if err != nil { + return err + } + occurredAt := input.OccurredAt + if occurredAt.IsZero() { + occurredAt = w.now().UTC() + } + event := model.AuditEvent{ + OccurredAt: occurredAt, Category: action.Category, ActionCode: action.Code, ActionName: action.Name, + Summary: input.Summary, ActorKind: input.Actor.Kind, ActorID: input.Actor.ID, ActorName: input.Actor.Name, + ActorShopID: input.Actor.ShopID, ActorShopName: input.Actor.ShopName, + ActorEnterpriseID: input.Actor.EnterpriseID, ActorEnterpriseName: input.Actor.EnterpriseName, + Source: input.Source, RequestPath: input.RequestPath, RequestMethod: input.RequestMethod, + IPAddress: input.IPAddress, UserAgent: input.UserAgent, + ScopeType: input.ScopeType, ScopeID: input.ScopeID, ScopeName: input.ScopeName, + Result: input.Result, RiskLevel: action.Risk, RequestID: input.RequestID, + ErrorCode: input.ErrorCode, ErrorSummary: input.ErrorSummary, + CorrelationID: input.CorrelationID, ParentEventID: input.ParentEventID, Metadata: metadata, + BatchTotal: input.BatchTotal, SuccessCount: input.SuccessCount, FailCount: input.FailCount, + } + event.ContentHash, err = contentHash(event, resources) + if err != nil { + return pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "计算审计内容哈希失败") + } + event.EventID = input.EventID + if event.EventID == "" { + event.EventID = "evt_" + uuid.NewString() + } + create := tx.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "event_id"}}, DoNothing: true, + }).Create(&event) + if create.Error != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, create.Error, "写入审计事件失败") + } + if create.RowsAffected == 0 { + return nil + } + for index := range resources { + resources[index].AuditEventID = event.ID + resources[index].CreatedAt = event.CreatedAt + } + if err := tx.WithContext(ctx).Create(&resources).Error; err != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "写入审计事件资源失败") + } + return nil +} + +func fillFromContext(ctx context.Context, input AppendInput) AppendInput { + value := auditcontext.From(ctx) + if input.Actor.Kind == "" { + input.Actor.Kind = value.ActorKind + } + if input.Actor.ID == "" { + input.Actor.ID = value.ActorID + } + if input.Actor.Name == "" { + input.Actor.Name = value.ActorName + } + if input.Actor.ShopID == nil { + input.Actor.ShopID = value.ActorShopID + } + if input.Actor.EnterpriseID == nil { + input.Actor.EnterpriseID = value.ActorEnterpriseID + } + if input.Source == "" { + input.Source = value.Source + } + if input.RequestID == "" { + input.RequestID = value.RequestID + } + if input.CorrelationID == "" { + input.CorrelationID = value.CorrelationID + } + if input.ParentEventID == "" { + input.ParentEventID = value.ParentEventID + } + if input.RequestPath == "" { + input.RequestPath = value.RequestPath + } + if input.RequestMethod == "" { + input.RequestMethod = value.RequestMethod + } + if input.IPAddress == "" { + input.IPAddress = value.IPAddress + } + if input.UserAgent == "" { + input.UserAgent = value.UserAgent + } + return input +} + +func (w *Writer) buildResources(inputs []ResourceInput, action ActionDefinition) ([]model.AuditEventResource, error) { + resources := make([]model.AuditEventResource, 0, len(inputs)) + primaryCount := 0 + for _, input := range inputs { + definition, ok := w.registry.Resource(input.Type) + if !ok { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计资源未注册") + } + if input.Key == "" || input.Relation == "" || input.Role == "" { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计资源关系不完整") + } + if input.Relation == constants.AuditResourceRelationPrimary { + primaryCount++ + if input.Type != action.PrimaryResource { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主要资源不符合动作注册规则") + } + } + identity, err := registeredIdentity(input.IdentitySnapshot, definition.IdentityFields) + if err != nil { + return nil, err + } + before, err := safeObject(input.BeforeData) + if err != nil { + return nil, err + } + after, err := safeObject(input.AfterData) + if err != nil { + return nil, err + } + visibility := input.SubjectVisibility + if visibility == "" { + visibility = action.DefaultVisibility + } + subjectDataInput, err := validateSubjectProjection(input, action, visibility) + if err != nil { + return nil, err + } + subjectData, err := safeObject(subjectDataInput) + if err != nil { + return nil, err + } + resources = append(resources, model.AuditEventResource{ + ResourceType: input.Type, ResourceID: input.ID, ResourceKey: input.Key, DisplayName: input.DisplayName, + Relation: input.Relation, Role: input.Role, IdentitySnapshot: identity, + BeforeData: before, AfterData: after, SubjectVisibility: visibility, + SubjectSummary: input.SubjectSummary, SubjectData: subjectData, SortOrder: input.SortOrder, + }) + } + if primaryCount != 1 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计事件必须且只能有一个主要资源") + } + return resources, nil +} + +func validateSubjectProjection(input ResourceInput, action ActionDefinition, visibility string) (map[string]any, error) { + if !containsString(action.AllowedVisibility, visibility) { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体可见级别不符合动作注册规则") + } + switch visibility { + case constants.AuditSubjectInternalOnly: + if input.SubjectSummary != "" || len(input.SubjectData) > 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "内部事件不得写入主体投影") + } + return nil, nil + case constants.AuditSubjectResult: + if input.SubjectSummary == "" || len(input.SubjectData) > 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体结论投影必须仅包含安全摘要") + } + return nil, nil + case constants.AuditSubjectDetail: + if input.SubjectSummary == "" { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体详情投影缺少安全摘要") + } + for field := range input.SubjectData { + if !containsString(action.SubjectFields, field) { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体详情包含未注册字段") + } + } + return input.SubjectData, nil + default: + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体可见级别非法") + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func registeredIdentity(value map[string]any, fields []string) (datatypes.JSON, error) { + registered := make(map[string]any, len(fields)) + for _, field := range fields { + if item, ok := value[field]; ok { + registered[field] = item + } + } + return boundedObject(registered) +} + +func safeObject(value map[string]any) (datatypes.JSON, error) { + if value == nil { + value = map[string]any{} + } + encoded, err := sanitizer.MarshalSummary(value) + if err != nil { + return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 清理失败") + } + var object map[string]any + if err := sonic.Unmarshal(encoded, &object); err != nil { + return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 必须是对象") + } + return boundedObject(object) +} + +func boundedObject(value map[string]any) (datatypes.JSON, error) { + encoded, err := sonic.ConfigStd.Marshal(value) + if err != nil { + return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 编码失败") + } + if len(encoded) <= constants.AuditJSONMaxBytes { + return datatypes.JSON(encoded), nil + } + sum := sha256.Sum256(encoded) + truncated, err := sonic.ConfigStd.Marshal(map[string]any{ + "truncated": true, "original_bytes": len(encoded), "sha256": hex.EncodeToString(sum[:]), + }) + if err != nil { + return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 摘要编码失败") + } + return datatypes.JSON(truncated), nil +} + +func contentHash(event model.AuditEvent, resources []model.AuditEventResource) (string, error) { + event.EventID = "" + event.ContentHash = "" + event.ID = 0 + event.CreatedAt = time.Time{} + for index := range resources { + resources[index].ID = 0 + resources[index].AuditEventID = 0 + resources[index].CreatedAt = time.Time{} + } + encoded, err := sonic.ConfigStd.Marshal(struct { + Event model.AuditEvent `json:"event"` + Resources []model.AuditEventResource `json:"resources"` + }{Event: event, Resources: resources}) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + +func contextString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func recoveryStateData(status int, nextAttempt time.Time, leaseOwner *string, leaseExpiresAt *time.Time) map[string]any { + return map[string]any{ + "status": status, "status_name": constants.GetOutboxStatusName(status), + "next_attempt_at": nextAttempt, "lease_owner": leaseOwner, "lease_expires_at": leaseExpiresAt, + } +} + +func validResult(result string) bool { + switch result { + case constants.AuditResultSuccess, constants.AuditResultFailed, constants.AuditResultDenied, + constants.AuditResultPartial, constants.AuditResultUnknown: + return true + default: + return false + } +} + +func recoverySuccessCount(result string, count int) int { + if result == constants.AuditResultSuccess { + return count + } + return 0 +} + +func recoveryFailCount(result string, count int) int { + if result == constants.AuditResultFailed || result == constants.AuditResultDenied { + return count + } + return 0 +} + +var _ systemconfigapp.AuditWriter = (*Writer)(nil) +var _ outboxapp.AuditWriter = (*Writer)(nil) +var _ accountauditapp.Writer = (*Writer)(nil) +var _ accessauditapp.Writer = (*Writer)(nil) diff --git a/internal/infrastructure/integrationlog/repository.go b/internal/infrastructure/integrationlog/repository.go index 8ffbdc3..7bcad22 100644 --- a/internal/infrastructure/integrationlog/repository.go +++ b/internal/infrastructure/integrationlog/repository.go @@ -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, diff --git a/internal/infrastructure/messaging/outbox/relay.go b/internal/infrastructure/messaging/outbox/relay.go index 3f1adc4..1556b61 100644 --- a/internal/infrastructure/messaging/outbox/relay.go +++ b/internal/infrastructure/messaging/outbox/relay.go @@ -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) } diff --git a/internal/infrastructure/messaging/outbox/repository.go b/internal/infrastructure/messaging/outbox/repository.go index d71da9c..d3f9edd 100644 --- a/internal/infrastructure/messaging/outbox/repository.go +++ b/internal/infrastructure/messaging/outbox/repository.go @@ -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 { diff --git a/internal/infrastructure/payment/agent_recharge_event.go b/internal/infrastructure/payment/agent_recharge_event.go index fb921f9..7fc4e8a 100644 --- a/internal/infrastructure/payment/agent_recharge_event.go +++ b/internal/infrastructure/payment/agent_recharge_event.go @@ -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 } diff --git a/internal/infrastructure/payment/agent_recharge_recovery_task.go b/internal/infrastructure/payment/agent_recharge_recovery_task.go index 88b8d1a..10e6339 100644 --- a/internal/infrastructure/payment/agent_recharge_recovery_task.go +++ b/internal/infrastructure/payment/agent_recharge_recovery_task.go @@ -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 } diff --git a/internal/infrastructure/payment/alipay_wap.go b/internal/infrastructure/payment/alipay_wap.go index a0d1c9a..104786e 100644 --- a/internal/infrastructure/payment/alipay_wap.go +++ b/internal/infrastructure/payment/alipay_wap.go @@ -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: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单", }) diff --git a/internal/infrastructure/payment/wechat_web.go b/internal/infrastructure/payment/wechat_web.go index 6c65915..2aa4a42 100644 --- a/internal/infrastructure/payment/wechat_web.go +++ b/internal/infrastructure/payment/wechat_web.go @@ -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: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单", }) diff --git a/internal/infrastructure/wecom/approval_attachment_uploader.go b/internal/infrastructure/wecom/approval_attachment_uploader.go index 999ea17..3b38314 100644 --- a/internal/infrastructure/wecom/approval_attachment_uploader.go +++ b/internal/infrastructure/wecom/approval_attachment_uploader.go @@ -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(), }, }) diff --git a/internal/infrastructure/wecom/approval_detail_client.go b/internal/infrastructure/wecom/approval_detail_client.go index 898257a..3e15ca8 100644 --- a/internal/infrastructure/wecom/approval_detail_client.go +++ b/internal/infrastructure/wecom/approval_detail_client.go @@ -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 diff --git a/internal/infrastructure/wecom/approval_detail_task.go b/internal/infrastructure/wecom/approval_detail_task.go index 269fbd4..4f69b75 100644 --- a/internal/infrastructure/wecom/approval_detail_task.go +++ b/internal/infrastructure/wecom/approval_detail_task.go @@ -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 diff --git a/internal/infrastructure/wecom/approval_info_client.go b/internal/infrastructure/wecom/approval_info_client.go index a6f9dcf..aefb37f 100644 --- a/internal/infrastructure/wecom/approval_info_client.go +++ b/internal/infrastructure/wecom/approval_info_client.go @@ -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 diff --git a/internal/infrastructure/wecom/approval_submission_client.go b/internal/infrastructure/wecom/approval_submission_client.go index 6256fba..457063c 100644 --- a/internal/infrastructure/wecom/approval_submission_client.go +++ b/internal/infrastructure/wecom/approval_submission_client.go @@ -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 diff --git a/internal/infrastructure/wecom/callback_service.go b/internal/infrastructure/wecom/callback_service.go index ed63b32..1206e71 100644 --- a/internal/infrastructure/wecom/callback_service.go +++ b/internal/infrastructure/wecom/callback_service.go @@ -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 diff --git a/internal/infrastructure/wecom/directory_client.go b/internal/infrastructure/wecom/directory_client.go index 4455853..055dfc7 100644 --- a/internal/infrastructure/wecom/directory_client.go +++ b/internal/infrastructure/wecom/directory_client.go @@ -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 diff --git a/internal/infrastructure/wecom/integration_linkage.go b/internal/infrastructure/wecom/integration_linkage.go new file mode 100644 index 0000000..9012fa9 --- /dev/null +++ b/internal/infrastructure/wecom/integration_linkage.go @@ -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 +} diff --git a/internal/infrastructure/wecom/template_client.go b/internal/infrastructure/wecom/template_client.go index 6142085..c0f6965 100644 --- a/internal/infrastructure/wecom/template_client.go +++ b/internal/infrastructure/wecom/template_client.go @@ -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 diff --git a/internal/infrastructure/wecom/token_provider.go b/internal/infrastructure/wecom/token_provider.go index 398fa75..60033d8 100644 --- a/internal/infrastructure/wecom/token_provider.go +++ b/internal/infrastructure/wecom/token_provider.go @@ -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 diff --git a/internal/middleware/agent_open_api_auth.go b/internal/middleware/agent_open_api_auth.go index 8d3213a..d120d05 100644 --- a/internal/middleware/agent_open_api_auth.go +++ b/internal/middleware/agent_open_api_auth.go @@ -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 } diff --git a/internal/model/audit_event.go b/internal/model/audit_event.go new file mode 100644 index 0000000..8e19cf4 --- /dev/null +++ b/internal/model/audit_event.go @@ -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" +} diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index b25f9da..34c4ea6 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -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:"状态名称(中文)"` } diff --git a/internal/model/dto/audit_dto.go b/internal/model/dto/audit_dto.go new file mode 100644 index 0000000..ea8754a --- /dev/null +++ b/internal/model/dto/audit_dto.go @@ -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或调查节点"` +} diff --git a/internal/model/dto/shop_dto.go b/internal/model/dto/shop_dto.go index caccfdf..4771947 100644 --- a/internal/model/dto/shop_dto.go +++ b/internal/model/dto/shop_dto.go @@ -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 调整既有店铺实际信用额度参数。 diff --git a/internal/model/dto/shop_dto_test.go b/internal/model/dto/shop_dto_test.go new file mode 100644 index 0000000..d3517c1 --- /dev/null +++ b/internal/model/dto/shop_dto_test.go @@ -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("调额请求不应暴露由服务端管理的乐观锁版本") + } + } +} diff --git a/internal/model/integration_log.go b/internal/model/integration_log.go index 6c29d66..122ce65 100644 --- a/internal/model/integration_log.go +++ b/internal/model/integration_log.go @@ -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"` diff --git a/internal/model/outbox_event.go b/internal/model/outbox_event.go index 57554a7..6e96ea4 100644 --- a/internal/model/outbox_event.go +++ b/internal/model/outbox_event.go @@ -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"` diff --git a/internal/query/audit/actors.go b/internal/query/audit/actors.go new file mode 100644 index 0000000..33bd8ad --- /dev/null +++ b/internal/query/audit/actors.go @@ -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 + } +} diff --git a/internal/query/audit/events.go b/internal/query/audit/events.go new file mode 100644 index 0000000..dd7a766 --- /dev/null +++ b/internal/query/audit/events.go @@ -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 +} diff --git a/internal/query/audit/resources.go b/internal/query/audit/resources.go new file mode 100644 index 0000000..f2a5bf3 --- /dev/null +++ b/internal/query/audit/resources.go @@ -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) +} diff --git a/internal/query/audit/subject_activities.go b/internal/query/audit/subject_activities.go new file mode 100644 index 0000000..aaa58b6 --- /dev/null +++ b/internal/query/audit/subject_activities.go @@ -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 +} diff --git a/internal/query/integration/logs.go b/internal/query/integration/logs.go new file mode 100644 index 0000000..b763662 --- /dev/null +++ b/internal/query/integration/logs.go @@ -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 +} diff --git a/internal/query/integration/overview.go b/internal/query/integration/overview.go new file mode 100644 index 0000000..a7ebd3e --- /dev/null +++ b/internal/query/integration/overview.go @@ -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, "聚合外部交互趋势失败") +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 3cae80d..253390b 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -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) } diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 6bbc04d..7450605 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -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), diff --git a/internal/routes/audit.go b/internal/routes/audit.go new file mode 100644 index 0000000..e547f40 --- /dev/null +++ b/internal/routes/audit.go @@ -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, + }) +} diff --git a/internal/service/account/service.go b/internal/service/account/service.go index da7c29a..f2400aa 100644 --- a/internal/service/account/service.go +++ b/internal/service/account/service.go @@ -6,18 +6,28 @@ import ( "context" stdErrors "errors" "fmt" + "slices" + "strconv" "strings" + accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" + 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" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/auditfailure" + pkgAuth "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/logger" "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/jackc/pgx/v5/pgconn" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ShopStoreInterface 店铺存储接口(仅用于获取店铺信息) @@ -27,6 +37,10 @@ type ShopStoreInterface interface { // Service 账号业务服务 type Service struct { + db *gorm.DB + lifecycleAudit accountauditapp.Writer + accessAudit accessauditapp.Writer + redisClient *redis.Client accountStore *postgres.AccountStore roleStore *postgres.RoleStore accountRoleStore *postgres.AccountRoleStore @@ -35,6 +49,25 @@ type Service struct { enterpriseStore middleware.EnterpriseStoreInterface auditService AuditServiceInterface wecomMembers WeComMemberFinder + tokenManager *pkgAuth.TokenManager +} + +// SetLifecycleAudit 注入账号生命周期事务和统一审计边界。 +func (s *Service) SetLifecycleAudit(db *gorm.DB, writer accountauditapp.Writer) { + s.db = db + s.lifecycleAudit = writer +} + +// SetAccessAudit 注入账号角色授权的事务、缓存和统一审计边界。 +func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) { + s.db = db + s.redisClient = redisClient + s.accessAudit = writer +} + +// SetTokenManager 注入改密后撤销现有会话所需的令牌管理器。 +func (s *Service) SetTokenManager(tokenManager *pkgAuth.TokenManager) { + s.tokenManager = tokenManager } type AuditServiceInterface interface { @@ -134,62 +167,38 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*m Status: constants.StatusEnabled, } - if err := s.accountStore.Create(ctx, account); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建账号失败") - } - - // 代理账号自动分配该店铺的默认角色 - if req.UserType == constants.UserTypeAgent && req.ShopID != nil { - roleIDs, err := s.shopRoleStore.GetRoleIDsByShopID(ctx, *req.ShopID) - if err == nil { + if err := s.runLifecycleTransaction(ctx, func(tx *gorm.DB) error { + if err := postgres.NewAccountStore(tx, nil).Create(ctx, account); err != nil { + return err + } + if req.UserType == constants.UserTypeAgent && req.ShopID != nil { + var roleIDs []uint + _ = tx.Transaction(func(roleTx *gorm.DB) error { + var err error + roleIDs, err = postgres.NewShopRoleStore(roleTx, nil).GetRoleIDsByShopID(ctx, *req.ShopID) + return err + }) for _, roleID := range roleIDs { - ar := &model.AccountRole{ - AccountID: account.ID, - RoleID: roleID, - Status: constants.StatusEnabled, - Creator: currentUserID, - Updater: currentUserID, - } - _ = s.accountRoleStore.Create(ctx, ar) + accountRole := &model.AccountRole{AccountID: account.ID, RoleID: roleID, Status: constants.StatusEnabled, Creator: currentUserID, Updater: currentUserID} + _ = tx.Transaction(func(roleTx *gorm.DB) error { + return postgres.NewAccountRoleStore(roleTx, nil).Create(ctx, accountRole) + }) } } + shop, enterprise, roles, err := loadLifecycleResources(ctx, tx, account) + if err != nil { + return err + } + return s.lifecycleAudit.WriteAccountLifecycle(ctx, tx, accountauditapp.LifecycleAudit{ + ActionCode: constants.AuditActionAccountCreated, Summary: "创建账号", Result: constants.AuditResultSuccess, + Account: account, Shop: shop, Enterprise: enterprise, Roles: roles, AfterData: accountLifecycleData(account), + }) + }); err != nil { + account.ID = 0 + s.recordLifecycleFailure(ctx, constants.AuditActionAccountCreated, "创建账号失败", constants.AuditResultFailed, account, nil, nil, err) + return nil, errors.Wrap(errors.CodeInternalError, err, "创建账号失败") } - currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID) - operatorName := "" - if currentAccount != nil { - operatorName = currentAccount.Username - } - - afterData := model.JSONB{ - "id": account.ID, - "username": account.Username, - "phone": account.Phone, - "user_type": account.UserType, - "shop_id": account.ShopID, - "enterprise_id": account.EnterpriseID, - "status": account.Status, - } - - requestID := middleware.GetRequestIDFromContext(ctx) - ipAddress := middleware.GetIPFromContext(ctx) - userAgent := middleware.GetUserAgentFromContext(ctx) - - s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: currentUserID, - OperatorType: userType, - OperatorName: operatorName, - TargetAccountID: &account.ID, - TargetUsername: &account.Username, - TargetUserType: &account.UserType, - OperationType: "create", - OperationDesc: fmt.Sprintf("创建账号: %s", account.Username), - AfterData: afterData, - RequestID: requestID, - IPAddress: ipAddress, - UserAgent: userAgent, - }) - return account, nil } @@ -228,38 +237,52 @@ func (s *Service) BindWeCom(ctx context.Context, accountID uint, request dto.Bin } member, err := s.wecomMembers.GetVisible(ctx, request.ApplicationID, request.UserID) if err != nil { + s.recordSecurityFailure(ctx, constants.AuditActionAccountWeComBound, "绑定账号企业微信身份失败", constants.AuditResultFailed, account, map[string]any{ + "account_id": account.ID, "auth_method": "wecom", "state": "failed", + }, nil, nil, err) return nil, err } beforeData := model.JSONB{ "wecom_corp_id": account.WeComCorpID, "wecom_userid": account.WeComUserID, "wecom_name": account.WeComName, } - if err := s.accountStore.BindWeCom(ctx, accountID, member.CorpID, member.UserID, member.Name, operatorID); err != nil { + afterData := map[string]any{ + "wecom_corp_id": member.CorpID, "wecom_userid": member.UserID, "wecom_name": member.Name, + } + updatedAccount := *account + updatedAccount.WeComCorpID = member.CorpID + updatedAccount.WeComUserID = member.UserID + updatedAccount.WeComName = member.Name + updatedAccount.Updater = operatorID + if err := s.runLifecycleTransaction(ctx, func(tx *gorm.DB) error { + if err := postgres.NewAccountStore(tx, nil).BindWeCom(ctx, accountID, member.CorpID, member.UserID, member.Name, operatorID); err != nil { + return err + } + return s.lifecycleAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{ + ActionCode: constants.AuditActionAccountWeComBound, Summary: "绑定账号企业微信身份", + Result: constants.AuditResultSuccess, ActorID: operatorID, Account: &updatedAccount, + AuthenticationKey: fmt.Sprintf("account:%d:wecom", account.ID), + Authentication: map[string]any{ + "account_id": account.ID, "auth_method": "wecom", "state": "bound", + "wecom_corp_id": member.CorpID, "wecom_userid": member.UserID, "wecom_name": member.Name, + }, + BeforeData: beforeData, AfterData: afterData, + }) + }); err != nil { var pgErr *pgconn.PgError if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" { - return nil, errors.New(errors.CodeConflict, "该企业微信成员已绑定其他系统账号") + appErr := errors.New(errors.CodeConflict, "该企业微信成员已绑定其他系统账号") + s.recordSecurityFailure(ctx, constants.AuditActionAccountWeComBound, "拒绝绑定账号企业微信身份", constants.AuditResultDenied, account, map[string]any{ + "account_id": account.ID, "auth_method": "wecom", "state": "denied", + }, beforeData, nil, appErr) + return nil, appErr } + s.recordSecurityFailure(ctx, constants.AuditActionAccountWeComBound, "绑定账号企业微信身份失败", constants.AuditResultFailed, account, map[string]any{ + "account_id": account.ID, "auth_method": "wecom", "state": "failed", + }, beforeData, nil, err) return nil, errors.Wrap(errors.CodeDatabaseError, err, "绑定企业微信成员失败") } - account.WeComCorpID = member.CorpID - account.WeComUserID = member.UserID - account.WeComName = member.Name - account.Updater = operatorID - if s.auditService != nil { - operatorName := "" - if operator, getErr := s.accountStore.GetByID(ctx, operatorID); getErr == nil { - operatorName = operator.Username - } - s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: operatorID, OperatorType: operatorType, OperatorName: operatorName, - TargetAccountID: &account.ID, TargetUsername: &account.Username, TargetUserType: &account.UserType, - OperationType: "bind_wecom", OperationDesc: fmt.Sprintf("绑定账号企业微信成员: %s", account.Username), - BeforeData: beforeData, AfterData: model.JSONB{ - "wecom_corp_id": member.CorpID, "wecom_userid": member.UserID, "wecom_name": member.Name, - }, RequestID: middleware.GetRequestIDFromContext(ctx), IPAddress: middleware.GetIPFromContext(ctx), - UserAgent: middleware.GetUserAgentFromContext(ctx), - }) - } + account = &updatedAccount accounts := []*model.Account{account} return s.toAccountResponse(account, s.loadShopNames(ctx, accounts), s.loadEnterpriseNames(ctx, accounts)), nil } @@ -283,22 +306,21 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountReq if userType == constants.UserTypeAgent { if account.ShopID == nil { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountUpdated, "拒绝更新账号", constants.AuditResultDenied, account, nil, nil, errors.New(errors.CodeForbidden)) return nil, errors.New(errors.CodeForbidden, "无权限操作该账号") } if err := middleware.CanManageShop(ctx, *account.ShopID); err != nil { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountUpdated, "拒绝更新账号", constants.AuditResultDenied, account, nil, nil, err) return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } } - beforeData := model.JSONB{ - "username": account.Username, - "phone": account.Phone, - "status": account.Status, - } + beforeData := accountLifecycleData(account) if req.Username != nil { existing, err := s.accountStore.GetByUsername(ctx, *req.Username) if err == nil && existing != nil && existing.ID != id { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountUpdated, "拒绝更新重复用户名", constants.AuditResultDenied, account, beforeData, nil, errors.New(errors.CodeUsernameExists)) return nil, errors.New(errors.CodeUsernameExists, "用户名已存在") } account.Username = *req.Username @@ -307,6 +329,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountReq if req.Phone != nil { existing, err := s.accountStore.GetByPhone(ctx, *req.Phone) if err == nil && existing != nil && existing.ID != id { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountUpdated, "拒绝更新重复手机号", constants.AuditResultDenied, account, beforeData, nil, errors.New(errors.CodePhoneExists)) return nil, errors.New(errors.CodePhoneExists, "手机号已存在") } account.Phone = *req.Phone @@ -315,6 +338,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountReq if req.Password != nil { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { + s.recordSecurityFailure(ctx, constants.AuditActionAccountPasswordReset, "更新账号凭据处理失败", constants.AuditResultFailed, account, passwordAuthentication(account, "failed"), beforeData, nil, err) return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败") } account.Password = string(hashedPassword) @@ -326,42 +350,40 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountReq account.Updater = currentUserID - if err := s.accountStore.Update(ctx, account); err != nil { + if err := s.runLifecycleTransaction(ctx, func(tx *gorm.DB) error { + if err := postgres.NewAccountStore(tx, nil).Update(ctx, account); err != nil { + return err + } + if req.Password != nil { + return s.lifecycleAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{ + ActionCode: constants.AuditActionAccountPasswordReset, Summary: "更新账号安全资料", Result: constants.AuditResultSuccess, + ActorID: currentUserID, Account: account, + AuthenticationKey: fmt.Sprintf("account:%d:password", account.ID), + Authentication: passwordAuthentication(account, "changed"), + BeforeData: beforeData, AfterData: accountLifecycleData(account), + }) + } + shop, enterprise, roles, err := loadLifecycleResources(ctx, tx, account) + if err != nil { + return err + } + return s.lifecycleAudit.WriteAccountLifecycle(ctx, tx, accountauditapp.LifecycleAudit{ + ActionCode: constants.AuditActionAccountUpdated, Summary: "更新账号", Result: constants.AuditResultSuccess, + Account: account, Shop: shop, Enterprise: enterprise, Roles: roles, + BeforeData: beforeData, AfterData: accountLifecycleData(account), + }) + }); err != nil { + if req.Password != nil { + s.recordSecurityFailure(ctx, constants.AuditActionAccountPasswordReset, "更新账号安全资料失败", constants.AuditResultFailed, account, passwordAuthentication(account, "failed"), beforeData, nil, err) + } else { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountUpdated, "更新账号失败", constants.AuditResultFailed, account, beforeData, nil, err) + } return nil, errors.Wrap(errors.CodeInternalError, err, "更新账号失败") } - - currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID) - operatorName := "" - if currentAccount != nil { - operatorName = currentAccount.Username + if req.Password != nil { + s.revokeAccountTokens(ctx, account.ID) } - afterData := model.JSONB{ - "username": account.Username, - "phone": account.Phone, - "status": account.Status, - } - - requestID := middleware.GetRequestIDFromContext(ctx) - ipAddress := middleware.GetIPFromContext(ctx) - userAgent := middleware.GetUserAgentFromContext(ctx) - - s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: currentUserID, - OperatorType: userType, - OperatorName: operatorName, - TargetAccountID: &account.ID, - TargetUsername: &account.Username, - TargetUserType: &account.UserType, - OperationType: "update", - OperationDesc: fmt.Sprintf("更新账号: %s", account.Username), - BeforeData: beforeData, - AfterData: afterData, - RequestID: requestID, - IPAddress: ipAddress, - UserAgent: userAgent, - }) - return account, nil } @@ -384,49 +406,33 @@ func (s *Service) Delete(ctx context.Context, id uint) error { if userType == constants.UserTypeAgent { if account.ShopID == nil { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountDeleted, "拒绝删除账号", constants.AuditResultDenied, account, nil, nil, errors.New(errors.CodeForbidden)) return errors.New(errors.CodeForbidden, "无权限操作该账号") } if err := middleware.CanManageShop(ctx, *account.ShopID); err != nil { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountDeleted, "拒绝删除账号", constants.AuditResultDenied, account, nil, nil, err) return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } } - beforeData := model.JSONB{ - "id": account.ID, - "username": account.Username, - "phone": account.Phone, - "status": account.Status, - } - - if err := s.accountStore.Delete(ctx, id); err != nil { + beforeData := accountLifecycleData(account) + if err := s.runLifecycleTransaction(ctx, func(tx *gorm.DB) error { + shop, enterprise, roles, err := loadLifecycleResources(ctx, tx, account) + if err != nil { + return err + } + if err := postgres.NewAccountStore(tx, nil).Delete(ctx, id); err != nil { + return err + } + return s.lifecycleAudit.WriteAccountLifecycle(ctx, tx, accountauditapp.LifecycleAudit{ + ActionCode: constants.AuditActionAccountDeleted, Summary: "删除账号", Result: constants.AuditResultSuccess, + Account: account, Shop: shop, Enterprise: enterprise, Roles: roles, BeforeData: beforeData, + }) + }); err != nil { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountDeleted, "删除账号失败", constants.AuditResultFailed, account, beforeData, nil, err) return errors.Wrap(errors.CodeInternalError, err, "删除账号失败") } - currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID) - operatorName := "" - if currentAccount != nil { - operatorName = currentAccount.Username - } - - requestID := middleware.GetRequestIDFromContext(ctx) - ipAddress := middleware.GetIPFromContext(ctx) - userAgent := middleware.GetUserAgentFromContext(ctx) - - s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: currentUserID, - OperatorType: userType, - OperatorName: operatorName, - TargetAccountID: &account.ID, - TargetUsername: &account.Username, - TargetUserType: &account.UserType, - OperationType: "delete", - OperationDesc: fmt.Sprintf("删除账号: %s", account.Username), - BeforeData: beforeData, - RequestID: requestID, - IPAddress: ipAddress, - UserAgent: userAgent, - }) - return nil } @@ -500,111 +506,29 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin if userType == constants.UserTypeAgent { if account.ShopID == nil { - return nil, errors.New(errors.CodeForbidden, "无权限操作该账号") + err := errors.New(errors.CodeForbidden, "无权限操作该账号") + s.recordRoleAssignmentFailure(ctx, constants.AuditActionAccountRolesAssigned, account, nil, err) + return nil, err } if err := middleware.CanManageShop(ctx, *account.ShopID); err != nil { - return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + appErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + s.recordRoleAssignmentFailure(ctx, constants.AuditActionAccountRolesAssigned, account, nil, appErr) + return nil, appErr } } if account.UserType == constants.UserTypeSuperAdmin { - return nil, errors.New(errors.CodeInvalidParam, "超级管理员不允许分配角色") + err := errors.New(errors.CodeInvalidParam, "超级管理员不允许分配角色") + s.recordRoleAssignmentFailure(ctx, constants.AuditActionAccountRolesAssigned, account, nil, err) + return nil, err } - // 空数组:清空所有角色 - if len(roleIDs) == 0 { - if err := s.accountRoleStore.DeleteByAccountID(ctx, accountID); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "清空账号角色失败") - } - return []*model.AccountRole{}, nil - } - - maxRoles := constants.GetMaxRolesForUserType(account.UserType) - if maxRoles == 0 { - return nil, errors.New(errors.CodeInvalidParam, "该用户类型不需要分配角色") - } - - existingCount, err := s.accountRoleStore.CountByAccountID(ctx, accountID) + assigned, changedRoles, err := s.assignAccountRoles(ctx, account, currentUserID, roleIDs) if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "统计现有角色数量失败") + s.recordRoleAssignmentFailure(ctx, constants.AuditActionAccountRolesAssigned, account, changedRoles, err) + return nil, err } - - newRoleCount := 0 - for _, roleID := range roleIDs { - exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID) - if !exists { - newRoleCount++ - } - } - - if maxRoles != -1 && int(existingCount)+newRoleCount > maxRoles { - return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("该用户类型最多只能分配 %d 个角色", maxRoles)) - } - - for _, roleID := range roleIDs { - role, err := s.roleStore.GetByID(ctx, roleID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeRoleNotFound, fmt.Sprintf("角色 %d 不存在", roleID)) - } - return nil, errors.Wrap(errors.CodeInternalError, err, "获取角色失败") - } - - if !constants.IsRoleTypeMatchUserType(role.RoleType, account.UserType) { - return nil, errors.New(errors.CodeInvalidParam, "角色类型与账号类型不匹配") - } - } - - var ars []*model.AccountRole - for _, roleID := range roleIDs { - exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID) - if exists { - continue - } - - ar := &model.AccountRole{ - AccountID: accountID, - RoleID: roleID, - Status: constants.StatusEnabled, - Creator: currentUserID, - Updater: currentUserID, - } - if err := s.accountRoleStore.Create(ctx, ar); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建账号-角色关联失败") - } - ars = append(ars, ar) - } - - currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID) - operatorName := "" - if currentAccount != nil { - operatorName = currentAccount.Username - } - - afterData := model.JSONB{ - "role_ids": roleIDs, - } - - requestID := middleware.GetRequestIDFromContext(ctx) - ipAddress := middleware.GetIPFromContext(ctx) - userAgent := middleware.GetUserAgentFromContext(ctx) - - s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: currentUserID, - OperatorType: userType, - OperatorName: operatorName, - TargetAccountID: &account.ID, - TargetUsername: &account.Username, - TargetUserType: &account.UserType, - OperationType: "assign_roles", - OperationDesc: fmt.Sprintf("为账号 %s 分配角色", account.Username), - AfterData: afterData, - RequestID: requestID, - IPAddress: ipAddress, - UserAgent: userAgent, - }) - - return ars, nil + return assigned, nil } // GetRoles 获取账号的所有角色 @@ -651,49 +575,268 @@ func (s *Service) RemoveRole(ctx context.Context, accountID, roleID uint) error if userType == constants.UserTypeAgent { if account.ShopID == nil { - return errors.New(errors.CodeForbidden, "无权限操作该账号") + err := errors.New(errors.CodeForbidden, "无权限操作该账号") + s.recordRoleAssignmentFailure(ctx, constants.AuditActionAccountRoleRemoved, account, nil, err) + return err } if err := middleware.CanManageShop(ctx, *account.ShopID); err != nil { - return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + appErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + s.recordRoleAssignmentFailure(ctx, constants.AuditActionAccountRoleRemoved, account, nil, appErr) + return appErr } } - if err := s.accountRoleStore.Delete(ctx, accountID, roleID); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "删除账号-角色关联失败") + role, err := s.removeAccountRole(ctx, account, currentUserID, roleID) + if err != nil { + roles := []*model.Role(nil) + if role != nil { + roles = []*model.Role{role} + } + s.recordRoleAssignmentFailure(ctx, constants.AuditActionAccountRoleRemoved, account, roles, err) + return err } - - currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID) - operatorName := "" - if currentAccount != nil { - operatorName = currentAccount.Username - } - - afterData := model.JSONB{ - "removed_role_id": roleID, - } - - requestID := middleware.GetRequestIDFromContext(ctx) - ipAddress := middleware.GetIPFromContext(ctx) - userAgent := middleware.GetUserAgentFromContext(ctx) - - s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: currentUserID, - OperatorType: userType, - OperatorName: operatorName, - TargetAccountID: &account.ID, - TargetUsername: &account.Username, - TargetUserType: &account.UserType, - OperationType: "remove_role", - OperationDesc: fmt.Sprintf("移除账号 %s 的角色", account.Username), - AfterData: afterData, - RequestID: requestID, - IPAddress: ipAddress, - UserAgent: userAgent, - }) - return nil } +func (s *Service) assignAccountRoles(ctx context.Context, account *model.Account, operatorID uint, requested []uint) ([]*model.AccountRole, []*model.Role, error) { + if s.db == nil || s.accessAudit == nil { + return nil, nil, errors.New(errors.CodeInvalidStatus, "账号角色审计接缝未配置") + } + assigned := make([]*model.AccountRole, 0, len(requested)) + var changedRoles []*model.Role + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Account{}, account.ID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定账号角色关系失败") + } + accountRoles := postgres.NewAccountRoleStore(tx, nil) + roles := postgres.NewRoleStore(tx) + scopeShop, err := accountRoleScopeShop(ctx, tx, account.ShopID) + if err != nil { + return err + } + beforeIDs, err := accountRoles.GetRoleIDsByAccountID(ctx, account.ID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询账号现有角色失败") + } + afterIDs, changes, err := s.applyAccountRoleAssignment(ctx, accountRoles, roles, account, operatorID, beforeIDs, requested, &assigned) + if err != nil { + return err + } + changedRoles = changes + if len(changes) == 0 { + return nil + } + if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{ + ActionCode: constants.AuditActionAccountRolesAssigned, Summary: "为账号分配角色", + OperatorID: operatorID, Account: account, Shop: scopeShop, Roles: roleAssignmentChanges(changes, beforeIDs, afterIDs), + BeforeData: map[string]any{"role_ids": sortedRoleIDs(beforeIDs)}, + AfterData: map[string]any{"role_ids": sortedRoleIDs(afterIDs)}, + }); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "写入账号角色审计失败") + } + return nil + }) + if err != nil { + return nil, changedRoles, err + } + s.clearAccountPermissionCache(ctx, account.ID) + return assigned, changedRoles, nil +} + +func (s *Service) applyAccountRoleAssignment(ctx context.Context, accountRoles *postgres.AccountRoleStore, roles *postgres.RoleStore, account *model.Account, operatorID uint, beforeIDs, requested []uint, assigned *[]*model.AccountRole) ([]uint, []*model.Role, error) { + if len(requested) == 0 { + if len(beforeIDs) == 0 { + return []uint{}, nil, nil + } + removed, err := roles.GetByIDs(ctx, beforeIDs) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待移除角色失败") + } + if err := accountRoles.DeleteByAccountID(ctx, account.ID); err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "清空账号角色失败") + } + return []uint{}, removed, nil + } + requestedRoles := make([]*model.Role, 0, len(requested)) + for _, roleID := range requested { + role, err := roles.GetByID(ctx, roleID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeRoleNotFound, fmt.Sprintf("角色 %d 不存在", roleID)) + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询角色失败") + } + if !constants.IsRoleTypeMatchUserType(role.RoleType, account.UserType) { + return nil, nil, errors.New(errors.CodeInvalidParam, "角色类型与账号类型不匹配") + } + requestedRoles = append(requestedRoles, role) + } + existing := roleIDSet(beforeIDs) + newRoleCount := 0 + for _, roleID := range requested { + if !existing[roleID] { + newRoleCount++ + } + } + maxRoles := constants.GetMaxRolesForUserType(account.UserType) + if maxRoles == 0 { + return nil, nil, errors.New(errors.CodeInvalidParam, "该用户类型不需要分配角色") + } + if maxRoles != -1 && len(beforeIDs)+newRoleCount > maxRoles { + return nil, nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("该用户类型最多只能分配 %d 个角色", maxRoles)) + } + changed := make([]*model.Role, 0, newRoleCount) + addedIDs := make([]uint, 0, newRoleCount) + for index, roleID := range requested { + if existing[roleID] { + continue + } + accountRole := &model.AccountRole{AccountID: account.ID, RoleID: roleID, Status: constants.StatusEnabled, Creator: operatorID, Updater: operatorID} + if err := accountRoles.Create(ctx, accountRole); err != nil { + return nil, changed, errors.Wrap(errors.CodeDatabaseError, err, "创建账号-角色关联失败") + } + *assigned = append(*assigned, accountRole) + changed = append(changed, requestedRoles[index]) + addedIDs = append(addedIDs, roleID) + existing[roleID] = true + } + return append(append([]uint(nil), beforeIDs...), addedIDs...), changed, nil +} + +func (s *Service) removeAccountRole(ctx context.Context, account *model.Account, operatorID, roleID uint) (*model.Role, error) { + if s.db == nil || s.accessAudit == nil { + return nil, errors.New(errors.CodeInvalidStatus, "账号角色审计接缝未配置") + } + var removed *model.Role + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Account{}, account.ID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定账号角色关系失败") + } + accountRoles := postgres.NewAccountRoleStore(tx, nil) + scopeShop, err := accountRoleScopeShop(ctx, tx, account.ShopID) + if err != nil { + return err + } + beforeIDs, err := accountRoles.GetRoleIDsByAccountID(ctx, account.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 := accountRoles.Delete(ctx, account.ID, roleID); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "删除账号-角色关联失败") + } + afterIDs := removeRoleID(beforeIDs, roleID) + if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{ + ActionCode: constants.AuditActionAccountRoleRemoved, Summary: "移除账号角色", + OperatorID: operatorID, Account: account, Shop: scopeShop, + Roles: []accessauditapp.RoleChange{{Role: removed, BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false}}}, + BeforeData: map[string]any{"role_ids": sortedRoleIDs(beforeIDs)}, + AfterData: map[string]any{"role_ids": sortedRoleIDs(afterIDs)}, + }); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "写入账号角色审计失败") + } + return nil + }) + if err == nil && removed != nil { + s.clearAccountPermissionCache(ctx, account.ID) + } + return removed, err +} + +func (s *Service) clearAccountPermissionCache(ctx context.Context, accountID uint) { + if s.redisClient == nil { + return + } + if err := s.redisClient.Del(ctx, constants.RedisUserPermissionsKey(accountID)).Err(); err != nil { + logger.GetAppLogger().Warn("清理账号权限缓存失败", zap.Uint("account_id", accountID), zap.Error(err)) + } +} + +func (s *Service) recordRoleAssignmentFailure(ctx context.Context, action string, account *model.Account, 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: accessFailureResult(originalErr), + OperatorID: middleware.GetUserIDFromContext(ctx), Account: account, Shop: s.loadAccountRoleScopeShop(ctx, account), Roles: changes, + }, originalErr) +} + +func accountRoleScopeShop(ctx context.Context, tx *gorm.DB, shopID *uint) (*model.Shop, error) { + if shopID == nil { + return nil, nil + } + shop, err := postgres.NewShopStore(tx, nil).GetByID(ctx, *shopID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账号所属店铺失败") + } + return shop, nil +} + +func (s *Service) loadAccountRoleScopeShop(ctx context.Context, account *model.Account) *model.Shop { + if account == nil || account.ShopID == nil || s.shopStore == nil { + return nil + } + shops, err := s.shopStore.GetByIDs(ctx, []uint{*account.ShopID}) + if err != nil || len(shops) == 0 { + return nil + } + return shops[0] +} + +func roleAssignmentChanges(roles []*model.Role, beforeIDs, afterIDs []uint) []accessauditapp.RoleChange { + before, after := roleIDSet(beforeIDs), roleIDSet(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 accessFailureResult(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 roleIDSet(ids []uint) map[uint]bool { + set := make(map[uint]bool, len(ids)) + for _, id := range ids { + set[id] = true + } + return set +} + +func sortedRoleIDs(ids []uint) []uint { + result := append([]uint(nil), ids...) + slices.Sort(result) + return result +} + +func removeRoleID(ids []uint, removed uint) []uint { + result := make([]uint, 0, len(ids)) + for _, id := range ids { + if id != removed { + result = append(result, id) + } + } + return result +} + // ValidatePassword 验证密码 func (s *Service) ValidatePassword(plainPassword, hashedPassword string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword)) @@ -707,7 +850,7 @@ func (s *Service) UpdatePassword(ctx context.Context, accountID uint, newPasswor return errors.New(errors.CodeUnauthorized, "未授权访问") } - _, err := s.accountStore.GetByID(ctx, accountID) + account, err := s.accountStore.GetByID(ctx, accountID) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeAccountNotFound, "账号不存在") @@ -717,38 +860,206 @@ func (s *Service) UpdatePassword(ctx context.Context, accountID uint, newPasswor hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { + s.recordSecurityFailure(ctx, constants.AuditActionAccountPasswordReset, "重置账号密码失败", constants.AuditResultFailed, account, passwordAuthentication(account, "failed"), nil, nil, err) return errors.Wrap(errors.CodeInternalError, err, "密码哈希失败") } - if err := s.accountStore.UpdatePassword(ctx, accountID, string(hashedPassword), currentUserID); err != nil { + if err := s.runLifecycleTransaction(ctx, func(tx *gorm.DB) error { + if err := postgres.NewAccountStore(tx, nil).UpdatePassword(ctx, accountID, string(hashedPassword), currentUserID); err != nil { + return err + } + return s.lifecycleAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{ + ActionCode: constants.AuditActionAccountPasswordReset, Summary: "重置账号密码", + Result: constants.AuditResultSuccess, ActorID: currentUserID, Account: account, + AuthenticationKey: fmt.Sprintf("account:%d:password", account.ID), + Authentication: passwordAuthentication(account, "changed"), + BeforeData: map[string]any{"credentials_configured": account.Password != ""}, + AfterData: map[string]any{"credentials_configured": true}, + }) + }); err != nil { + s.recordSecurityFailure(ctx, constants.AuditActionAccountPasswordReset, "重置账号密码失败", constants.AuditResultFailed, account, passwordAuthentication(account, "failed"), nil, nil, err) return errors.Wrap(errors.CodeInternalError, err, "更新密码失败") } + s.revokeAccountTokens(ctx, account.ID) return nil } +func (s *Service) revokeAccountTokens(ctx context.Context, accountID uint) { + if s.tokenManager == nil { + return + } + if err := s.tokenManager.RevokeAllUserTokens(ctx, accountID); err != nil { + logger.GetAppLogger().Warn("改密后撤销账号令牌失败", zap.Uint("account_id", accountID), zap.Error(err)) + } +} + +func (s *Service) recordSecurityFailure( + ctx context.Context, + actionCode, summary, result string, + account *model.Account, + authentication, beforeData, afterData map[string]any, + originalErr error, +) { + if s.db == nil || s.lifecycleAudit == nil || account == nil || account.ID == 0 { + return + } + errorCode := strconv.Itoa(errors.CodeInternalError) + var appErr *errors.AppError + if stdErrors.As(originalErr, &appErr) { + errorCode = strconv.Itoa(appErr.Code) + } else if result == constants.AuditResultDenied { + errorCode = strconv.Itoa(errors.CodeForbidden) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + operatorID = account.ID + } + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.lifecycleAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{ + ActionCode: actionCode, Summary: summary, Result: result, ErrorCode: errorCode, ErrorSummary: summary, + ActorID: operatorID, Account: account, AuthenticationKey: fmt.Sprintf("account:%d:security", account.ID), + Authentication: authentication, BeforeData: beforeData, AfterData: afterData, + }) + }) + if err != nil { + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + auditfailure.RecordSecondaryWriteFailure(actionCode, account.Username, requestID, requestID, errorCode, err) + } +} + +func passwordAuthentication(account *model.Account, state string) map[string]any { + return map[string]any{"account_id": account.ID, "auth_method": "password", "state": state} +} + // UpdateStatus 修改账号状态(启用/禁用) func (s *Service) UpdateStatus(ctx context.Context, accountID uint, status int) error { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") } + if status != constants.StatusDisabled && status != constants.StatusEnabled { + return errors.New(errors.CodeInvalidParam, "账号状态无效") + } - _, err := s.accountStore.GetByID(ctx, accountID) + account, err := s.accountStore.GetByID(ctx, accountID) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeAccountNotFound, "账号不存在") } return errors.Wrap(errors.CodeInternalError, err, "获取账号失败") } - - if err := s.accountStore.UpdateStatus(ctx, accountID, status, currentUserID); err != nil { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeAgent { + if account.ShopID == nil || middleware.CanManageShop(ctx, *account.ShopID) != nil { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountUpdated, "拒绝更新账号状态", constants.AuditResultDenied, account, nil, nil, errors.New(errors.CodeForbidden)) + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + } + beforeData := accountLifecycleData(account) + if err := s.runLifecycleTransaction(ctx, func(tx *gorm.DB) error { + if err := postgres.NewAccountStore(tx, nil).UpdateStatus(ctx, accountID, status, currentUserID); err != nil { + return err + } + account.Status = status + account.Updater = currentUserID + shop, enterprise, roles, err := loadLifecycleResources(ctx, tx, account) + if err != nil { + return err + } + return s.lifecycleAudit.WriteAccountLifecycle(ctx, tx, accountauditapp.LifecycleAudit{ + ActionCode: constants.AuditActionAccountUpdated, Summary: "更新账号状态", Result: constants.AuditResultSuccess, + Account: account, Shop: shop, Enterprise: enterprise, Roles: roles, + BeforeData: beforeData, AfterData: accountLifecycleData(account), + }) + }); err != nil { + s.recordLifecycleFailure(ctx, constants.AuditActionAccountUpdated, "更新账号状态失败", constants.AuditResultFailed, account, beforeData, nil, err) return errors.Wrap(errors.CodeInternalError, err, "更新状态失败") } return nil } +func (s *Service) runLifecycleTransaction(ctx context.Context, fn func(*gorm.DB) error) error { + if s.db == nil || s.lifecycleAudit == nil { + return errors.New(errors.CodeInvalidStatus, "账号生命周期审计接缝未配置") + } + return s.db.WithContext(ctx).Transaction(fn) +} + +func (s *Service) recordLifecycleFailure( + ctx context.Context, + actionCode, summary, result string, + account *model.Account, + beforeData, afterData map[string]any, + originalErr error, +) { + if s.db == nil || s.lifecycleAudit == nil || account == nil { + return + } + errorCode := strconv.Itoa(errors.CodeInternalError) + var appErr *errors.AppError + if stdErrors.As(originalErr, &appErr) { + errorCode = strconv.Itoa(appErr.Code) + } else if result == constants.AuditResultDenied { + errorCode = strconv.Itoa(errors.CodeForbidden) + } + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + shop, enterprise, roles, loadErr := loadLifecycleResources(ctx, tx, account) + if loadErr != nil { + return loadErr + } + return s.lifecycleAudit.WriteAccountLifecycle(ctx, tx, accountauditapp.LifecycleAudit{ + ActionCode: actionCode, Summary: summary, Result: result, ErrorCode: errorCode, + ErrorSummary: summary, Account: account, Shop: shop, Enterprise: enterprise, Roles: roles, + BeforeData: beforeData, AfterData: afterData, + }) + }) + if err != nil { + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + auditfailure.RecordSecondaryWriteFailure(actionCode, account.Username, requestID, requestID, errorCode, err) + } +} + +func loadLifecycleResources(ctx context.Context, tx *gorm.DB, account *model.Account) (*model.Shop, *model.Enterprise, []*model.Role, error) { + var shop *model.Shop + if account.ShopID != nil { + shop = &model.Shop{} + if err := tx.WithContext(ctx).Unscoped().First(shop, *account.ShopID).Error; err != nil { + return nil, nil, nil, err + } + } + var enterprise *model.Enterprise + if account.EnterpriseID != nil { + enterprise = &model.Enterprise{} + if err := tx.WithContext(ctx).Unscoped().First(enterprise, *account.EnterpriseID).Error; err != nil { + return nil, nil, nil, err + } + } + var roles []*model.Role + if account.ID != 0 { + if err := tx.WithContext(ctx).Table("tb_role AS r"). + Joins("JOIN tb_account_role AS ar ON ar.role_id = r.id AND ar.deleted_at IS NULL"). + Where("ar.account_id = ?", account.ID).Order("r.id ASC").Find(&roles).Error; err != nil { + return nil, nil, nil, err + } + } + return shop, enterprise, roles, nil +} + +func accountLifecycleData(account *model.Account) map[string]any { + return map[string]any{ + "id": account.ID, "username": account.Username, "phone": account.Phone, + "user_type": account.UserType, "shop_id": account.ShopID, + "enterprise_id": account.EnterpriseID, "status": account.Status, + } +} + // ListPlatformAccounts 查询平台账号列表(自动筛选 user_type IN (1, 2)) func (s *Service) ListPlatformAccounts(ctx context.Context, req *dto.PlatformAccountListRequest) ([]*model.Account, int64, error) { opts := &store.QueryOptions{ diff --git a/internal/service/auth/service.go b/internal/service/auth/service.go index 3cca105..e975e31 100644 --- a/internal/service/auth/service.go +++ b/internal/service/auth/service.go @@ -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 { diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go index a816697..55f153f 100644 --- a/internal/service/client_auth/service.go +++ b/internal/service/client_auth/service.go @@ -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 diff --git a/internal/service/device/batch_audit.go b/internal/service/device/batch_audit.go new file mode 100644 index 0000000..7829f52 --- /dev/null +++ b/internal/service/device/batch_audit.go @@ -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) +} diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 4ee9585..6a91094 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -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 { diff --git a/internal/service/enterprise/service.go b/internal/service/enterprise/service.go index 294e332..94ad53f 100644 --- a/internal/service/enterprise/service.go +++ b/internal/service/enterprise/service.go @@ -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) { diff --git a/internal/service/enterprise_card/authorization_service.go b/internal/service/enterprise_card/authorization_service.go index eaf77b4..1620534 100644 --- a/internal/service/enterprise_card/authorization_service.go +++ b/internal/service/enterprise_card/authorization_service.go @@ -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) { diff --git a/internal/service/enterprise_card/service.go b/internal/service/enterprise_card/service.go index b8d3f29..12151d6 100644 --- a/internal/service/enterprise_card/service.go +++ b/internal/service/enterprise_card/service.go @@ -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 { diff --git a/internal/service/enterprise_device/service.go b/internal/service/enterprise_device/service.go index efe454c..f9484bd 100644 --- a/internal/service/enterprise_device/service.go +++ b/internal/service/enterprise_device/service.go @@ -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) { // 验证企业存在 diff --git a/internal/service/iot_card/speed_tier.go b/internal/service/iot_card/speed_tier.go index ad36e59..be8f118 100644 --- a/internal/service/iot_card/speed_tier.go +++ b/internal/service/iot_card/speed_tier.go @@ -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 diff --git a/internal/service/permission/service.go b/internal/service/permission/service.go index 8d0bf1a..7aea7e6 100644 --- a/internal/service/permission/service.go +++ b/internal/service/permission/service.go @@ -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) +} diff --git a/internal/service/personal_customer/service.go b/internal/service/personal_customer/service.go index 166bd30..e12d0d3 100644 --- a/internal/service/personal_customer/service.go +++ b/internal/service/personal_customer/service.go @@ -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) { // 获取客户信息 diff --git a/internal/service/role/service.go b/internal/service/role/service.go index 49639b9..eab5e39 100644 --- a/internal/service/role/service.go +++ b/internal/service/role/service.go @@ -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) +} diff --git a/internal/service/shop/service.go b/internal/service/shop/service.go index d99afd3..12d0755 100644 --- a/internal/service/shop/service.go +++ b/internal/service/shop/service.go @@ -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) diff --git a/internal/service/shop/shop_role.go b/internal/service/shop/shop_role.go index 80ab3e2..64dac80 100644 --- a/internal/service/shop/shop_role.go +++ b/internal/service/shop/shop_role.go @@ -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 +} diff --git a/internal/task/device_batch_allocation.go b/internal/task/device_batch_allocation.go index 33125d6..ac813af 100644 --- a/internal/task/device_batch_allocation.go +++ b/internal/task/device_batch_allocation.go @@ -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()) diff --git a/internal/task/polling_integration_log.go b/internal/task/polling_integration_log.go index b0b36af..18dfbf2 100644 --- a/internal/task/polling_integration_log.go +++ b/internal/task/polling_integration_log.go @@ -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, }) diff --git a/migrations/000199_create_audit_event.down.sql b/migrations/000199_create_audit_event.down.sql new file mode 100644 index 0000000..063cb21 --- /dev/null +++ b/migrations/000199_create_audit_event.down.sql @@ -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; diff --git a/migrations/000199_create_audit_event.up.sql b/migrations/000199_create_audit_event.up.sql new file mode 100644 index 0000000..ca305b6 --- /dev/null +++ b/migrations/000199_create_audit_event.up.sql @@ -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 '资源快照数据库写入时间'; diff --git a/migrations/000200_add_outbox_parent_event_id.down.sql b/migrations/000200_add_outbox_parent_event_id.down.sql new file mode 100644 index 0000000..0f42e13 --- /dev/null +++ b/migrations/000200_add_outbox_parent_event_id.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_outbox_event_parent; + +ALTER TABLE tb_outbox_event + DROP COLUMN IF EXISTS parent_event_id; diff --git a/migrations/000200_add_outbox_parent_event_id.up.sql b/migrations/000200_add_outbox_parent_event_id.up.sql new file mode 100644 index 0000000..15f75cf --- /dev/null +++ b/migrations/000200_add_outbox_parent_event_id.up.sql @@ -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,空字符串表示无已落库父事件'; diff --git a/migrations/000201_extend_integration_correlation_id.down.sql b/migrations/000201_extend_integration_correlation_id.down.sql new file mode 100644 index 0000000..f778e04 --- /dev/null +++ b/migrations/000201_extend_integration_correlation_id.down.sql @@ -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字符'; diff --git a/migrations/000201_extend_integration_correlation_id.up.sql b/migrations/000201_extend_integration_correlation_id.up.sql new file mode 100644 index 0000000..38895c2 --- /dev/null +++ b/migrations/000201_extend_integration_correlation_id.up.sql @@ -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字符'; diff --git a/migrations/000202_add_integration_investigation_indexes.down.sql b/migrations/000202_add_integration_investigation_indexes.down.sql new file mode 100644 index 0000000..5d76c02 --- /dev/null +++ b/migrations/000202_add_integration_investigation_indexes.down.sql @@ -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 '外部集成记录创建时间'; diff --git a/migrations/000202_add_integration_investigation_indexes.up.sql b/migrations/000202_add_integration_investigation_indexes.up.sql new file mode 100644 index 0000000..f14f823 --- /dev/null +++ b/migrations/000202_add_integration_investigation_indexes.up.sql @@ -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 '外部集成记录创建时间'; diff --git a/openspec/changes/build-multi-view-audit-center/design.md b/openspec/changes/build-multi-view-audit-center/design.md index 1c1ddaf..1202afe 100644 --- a/openspec/changes/build-multi-view-audit-center/design.md +++ b/openspec/changes/build-multi-view-audit-center/design.md @@ -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 diff --git a/openspec/changes/build-multi-view-audit-center/proposal.md b/openspec/changes/build-multi-view-audit-center/proposal.md index 38c5430..5166075 100644 --- a/openspec/changes/build-multi-view-audit-center/proposal.md +++ b/openspec/changes/build-multi-view-audit-center/proposal.md @@ -6,6 +6,7 @@ ## What Changes +- 明确本 Change 禁止新增、修改或运行任何自动化测试,也不得创建测试任务;完成证据仅使用静态检查、数据核对、接口人工核对及既定制品检查。 - 新建统一 `Audit Event + Event Resource` 写入能力:所有非查询业务操作,以及已识别主要业务资源后的成功、失败、拒绝、部分成功、系统自动状态变化和外部回调引发的内部变化,均按明确事务策略记录;普通查询和参数解析前失败不产生 Audit Event,Action Registry 明确登记的敏感读取除外。 - 建立动作、资源类型、资源角色、结果、风险、来源和外部可见性注册契约;每个事件至少关联一个主要资源,多资源操作为各资源保存事件发生时的业务标识快照及各自的前后变化。 - 建立平台多视角审计调查接口,覆盖全局事件、操作者、资源时间线、请求链路、业务关联链路、资金审计和风险事件;平台账号第一阶段均可读取完整业务审计数据,不实施细粒度权限码或平台数据范围过滤。 diff --git a/openspec/changes/build-multi-view-audit-center/tasks.md b/openspec/changes/build-multi-view-audit-center/tasks.md index 1fd8194..d071002 100644 --- a/openspec/changes/build-multi-view-audit-center/tasks.md +++ b/openspec/changes/build-multi-view-audit-center/tasks.md @@ -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】 diff --git a/openspec/changes/complete-july-iteration-test-release/tasks.md b/openspec/changes/complete-july-iteration-test-release/tasks.md index bd87678..32ba238 100644 --- a/openspec/changes/complete-july-iteration-test-release/tasks.md +++ b/openspec/changes/complete-july-iteration-test-release/tasks.md @@ -175,7 +175,7 @@ ### B3-01 UR34 代理充值 - [ ] 4.1 交付在线/线下充值共用的状态模型、幂等指纹、支付/审批/处理三套状态、迁移与 Query 投影。【主:复杂写 + Query|不迁移:C 端支付全模块|依赖:2.32】 -- [ ] 4.2 交付代理当前店铺可用支付方式与在线充值创建:100 元~100 万元、新 request_id 新业务单/支付单、微信 Native/支付宝 PreCreate、结果未知恢复和 qr_content 原样返回。【主:复杂写 + Payment Adapter|不迁移:二维码图片/多通道池|依赖:2.29、2.57】 +- [ ] 4.2 交付代理当前店铺可用支付方式与在线充值创建:100 元~100 万元、新 request_id 新业务单/支付单、按当前配置使用微信 v3 H5 或 v2 MWEB、复用支付宝 WAP 支付 URL、结果未知恢复和 qr_content 原样返回。【主:复杂写 + Payment Adapter|不迁移:二维码图片/多通道池|依赖:2.29、2.57】 - [ ] 4.3 交付支付回调与受控查单共用确认用例:渠道/配置/金额/交易号/业务关联校验,迟到成功优先,支付事实与入账 Outbox 同事务。【主:复杂写|辅:Integration Log/Outbox|不迁移:前端轮询触发查单|依赖:4.2】 - [ ] 4.4 交付钱包入账 Worker:唯一流水、版本、处理租约、状态恢复和目标主账号/真实提交人到账通知,支付已成功时失败不得回滚收款事实。【主:复杂写 Wallet Application|不迁移:人工重复入账|依赖:2.29、2.8、4.3】 - [ ] 4.5 交付平台/超管线下代充值业务核心:后端计算固定金额、保存 1~5 付款凭证原件和请求快照,通过 Approval Port 创建通用审批实例;消费标准决策完成通过入账、拒绝/删除/撤销终结和通过后撤销分流,旧 offline-pay/reject 仅在渠道装配可用后停机下线。【主:复杂写 Application/Domain|辅:Approval Port/Worker|边界:线下代充规则和终态处理|不迁移:企微 SDK/DTO/`sp_no`/状态码,不建本地审批流引擎或操作密码|依赖:2.9~2.12、2.29、4.1、4.4】 @@ -243,7 +243,7 @@ - [ ] 6.1 恢复并完成所有单元、集成、HTTP、业务流程、并发、迁移、权限、性能与回归自动化;补齐本轮未新增的 `_test.go`,运行完整 `go test ./...` 并保留最终代码证据。 - [ ] 6.2 完成真实企业微信模板、平台本人绑定、代理固定成员、附件、提交、加密回调、轮询、同意/拒绝/撤销/删除/结果未知恢复,以及退款和线下充值终态验收。 -- [ ] 6.3 完成真实 PostgreSQL、Redis/Asynq、对象存储、微信 Native、支付宝 PreCreate/TradeQuery/回调、支付成功未入账恢复与导出文件验收。 +- [ ] 6.3 完成真实 PostgreSQL、Redis/Asynq、对象存储、微信 v3 H5/v2 MWEB、支付宝 WAP/TradeQuery/回调、支付成功未入账恢复与导出文件验收。 - [ ] 6.4 完成真实 Gateway 限速账号/运营商档位、设备当前卡、失败/结果未知,以及移动/联通/电信运营商回调和 19/20 位 ICCID 验收。 - [x] 6.5 明确全局 Audit Event 治理移出本 Change:不实现 Writer、业务覆盖、历史投影、审计中心、敏感读取/导出/保留或一次性切换门禁,不阻塞本次实际上线;未来如需建设另开独立 Change。 - [ ] 6.6 完成后台、代理端、C 端全部页面与真实人工验收矩阵,包括异步部分成功、权限空态、通知、临期、换货历史抽样、余额预警和状态展示。 diff --git a/openspec/changes/fix-package-activation-starvation/.openspec.yaml b/openspec/changes/fix-package-activation-starvation/.openspec.yaml new file mode 100644 index 0000000..e08b5f8 --- /dev/null +++ b/openspec/changes/fix-package-activation-starvation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/fix-package-activation-starvation/design.md b/openspec/changes/fix-package-activation-starvation/design.md new file mode 100644 index 0000000..95b6114 --- /dev/null +++ b/openspec/changes/fix-package-activation-starvation/design.md @@ -0,0 +1,120 @@ +## Context + +线上诊断得到旧孤儿扫描窗口 `scanned_count=100`、`skipped_as_occupied=100`、`waiting_realname=0`。这证明“先取前 100 条待生效记录,再在 Go 中逐条排除占位载体”的实现会让窗口之外的真实孤儿永久饥饿。 + +七月迭代分支虽然仍保留旧 `enqueueActivationTask`,但已经具备更合适的本地接续边界:`ActivationService.ActivateNextPendingMainPackage` 负责载体锁、队首选择、条款快照、状态事务和提交后复机;实际激活事务还会原子追加 `card.observation.series.requested` Outbox。套餐状态推进本身是本地数据库用例,不需要再经过一次 Asynq 才能执行。 + +本设计仅适用于 `Iteration/7-11`,不包含任何 `main` 分支兼容、纯 Asynq 热修或跨分支移植决策。 + +## Goals / Non-Goals + +**Goals:** + +- 每轮最多 100 个恢复名额只用于无占位主套餐的真实孤儿载体,同一载体只选择队首套餐。 +- 旧主套餐过期事实提交后,直接调用七月分支现有接续能力推进下一套餐。 +- 孤儿恢复直接复用同一接续能力,不再创建第二条异步激活链。 +- 套餐激活状态与既有卡观测 Outbox 保持同一事务,后续观测继续可靠投递。 +- 日志准确区分实际激活、幂等、锁冲突和业务条件未满足。 + +**Non-Goals:** + +- 不涉及 `main`,不生成可向 `main` cherry-pick 的修复提交。 +- 不新增套餐激活 Outbox 事件、消费者、迁移或队列类型。 +- 不删除仍可能被其他旧入口使用的 `TaskTypePackageQueueActivation` 和 Handler;只停止过期接续与孤儿恢复继续走该路径。 +- 不修改购买、优先级分配、实名激活、流量扣减、退款失效和停复机规则。 +- 不新增 API、DTO、路由、依赖、数据库字段或索引。 +- 按用户要求,不新增、修改或运行自动化测试。 + +## Decisions + +### 决策 1:数据库先选择真实孤儿队首,再执行 LIMIT + +孤儿查询通过 GORM 执行 PostgreSQL CTE/窗口查询: + +1. 从 `status=0 AND master_usage_id IS NULL AND deleted_at IS NULL` 按卡或设备载体分组。 +2. 每个载体按 `priority ASC, created_at ASC, id ASC` 选择唯一队首。 +3. 通过相关 `NOT EXISTS` 排除仍有 `status IN (1,2)` 主套餐的载体。 +4. 对真实孤儿稳定排序后执行 `LIMIT 100`。 + +查询结果直接进入同步接续循环,删除逐条 `Count` 和 Go map 二次分组。 + +**拒绝:仅增大 LIMIT。** 数据增长后仍会复现,并放大 N+1。 + +**拒绝:游标扫描所有待生效记录。** 需要维护扫描状态,复杂度高于一次正确查询。 + +### 决策 2:过期事务提交后同步调用现有接续服务 + +`processExpiredPackage` 的事务继续负责旧主套餐 `status=3` 和关联加油包 `status=4`。事务成功提交后,Handler 调用 `ActivateNextPendingMainPackage(ctx, carrierType, carrierID)`,不再调用 `activateNextPackage` 投递 Asynq。 + +若进程在两次事务之间退出,数据库会留下“无占位主套餐 + 有待生效套餐”的持久状态,下一轮修正后的孤儿扫描会直接调用同一接续能力恢复,最长增加一个轮询周期。 + +**拒绝:事务内直接投递 Asynq。** 消费者可能早于事务提交读取旧状态。 + +**拒绝:新增套餐激活 Outbox。** 状态推进是本地同步用例,现有服务已经具备完整事务;新增事件只会形成第二套调度和消费状态。需要可靠投递的后续卡观测已经由激活事务写入现有 Outbox。 + +### 决策 3:孤儿恢复同步调用同一接续服务 + +每个真实孤儿候选只携带载体类型和 ID,调用 `ActivateNextPendingMainPackage`。服务在事务内重新检查占位状态和队首,避免依赖扫描快照执行写操作;载体级 Redis 锁防止多个轮询实例并发激活。 + +锁冲突返回现有 `CodePackageActivationConflict`。轮询记录原因后结束本次候选,下一轮扫描继续恢复,不依赖 Asynq 重试。 + +### 决策 4:复用现有卡观测 Outbox + +成功激活仍通过 `appendActivationObservation` 在套餐激活事务内追加稳定事件: + +- 事件类型:`card.observation.series.requested` +- 稳定事件 ID:`card-observation:package-usage:{usage_id}:activated` +- 同步类型:实名、流量、网络 +- 资源:实际卡或设备载体 + +Outbox 写入失败时套餐激活事务回滚,避免状态已生效但后续观测请求丢失。不新增套餐激活事件、Relay 或消费者。 + +### 决策 5:日志以实际结果为准 + +`ActivateNextPendingMainPackage` 已返回 `activated bool`。调用方仅在 `activated=true` 时记录本轮成功;`false,nil` 表示幂等或条件暂不满足,记录明确结果;锁冲突和数据库错误按错误路径记录,不伪造成功。 + +### 决策 6:公共能力决定 + +- **Audit Event:N/A。** 系统自动生命周期推进,不是人工敏感操作。 +- **Domain Ledger:N/A。** `tb_package_usage` 是套餐状态、激活和到期的权威事实。 +- **Integration Log:N/A。** 本修复不新增外部请求。 +- **Outbox:复用。** 成功激活继续在同一事务写 `card.observation.series.requested`;不新增事件类型。 + +实施时增量维护 `.scratch/tech-global-audit/审计覆盖基线.md`。 + +### 决策 7:不包含自动化测试 + +按用户明确要求,本 Change 不创建、修改或运行自动化测试。验证使用: + +- `gofmt` 和现有静态检查; +- `go build ./...`; +- 只读候选 SQL及 `EXPLAIN (ANALYZE, BUFFERS)`; +- 日志顺序和激活结果核验; +- 激活事务与卡观测 Outbox 记录的一致性查询。 + +## Risks / Trade-offs + +- **[风险] 候选查询扫描大量待生效记录** → 用现有索引和查询计划验证;没有性能证据前不新增索引。 +- **[风险] 旧套餐提交后、接续调用前进程退出** → 下一轮真实孤儿扫描从数据库权威状态恢复。 +- **[风险] 多实例重复处理同一载体** → 服务内载体级 Redis 锁、事务内占位复检和状态幂等共同收敛。 +- **[风险] 同步接续增加单轮耗时** → 单轮最多 100 个真实孤儿,接续仅执行本地 Redis/数据库事务;记录耗时后再决定是否需要批次调整。 +- **[权衡] 保留旧 Asynq Handler** → 避免扩大未触碰调用方;本 Change 只让过期和孤儿两条路径停止使用它。 +- **[权衡] 不新增自动化测试** → 遵循用户边界,以构建、SQL、查询计划和日志证据替代。 + +## Migration Plan + +1. 在 `Iteration/7-11` 实施真实孤儿查询和同步接续调用。 +2. 更新审计覆盖基线与功能总结,执行格式化、静态检查和 `go build ./...`。 +3. 用只读 SQL/查询计划验证候选公平性,用运行日志和数据库事实核验同步接续及卡观测 Outbox。 +4. 形成七月分支专属 Lore commit;该提交不面向 `main` 移植。 +5. 发布七月迭代时观察至少两个轮询周期,确认真实孤儿收敛且卡观测 Outbox 正常投递。 + +### 回滚 + +- 无数据库迁移,回滚七月专属修复提交并重新部署 Worker。 +- 已正确激活的套餐保持业务事实,不执行反向 SQL。 +- 回滚后出现孤儿时,使用带状态条件的单卡修复 SQL逐条处理。 + +## Open Questions + +无。本 Change 明确仅属于七月迭代分支。 diff --git a/openspec/changes/fix-package-activation-starvation/proposal.md b/openspec/changes/fix-package-activation-starvation/proposal.md new file mode 100644 index 0000000..fc8401f --- /dev/null +++ b/openspec/changes/fix-package-activation-starvation/proposal.md @@ -0,0 +1,36 @@ +## Why + +功能 ID:`feature-505-package-activation-recovery` + +生产环境已第二次出现“原主套餐已过期、队首待生效主套餐仍停留在 `status=0`”的问题。只读数据证明旧孤儿扫描每轮固定读取的 100 条记录全部仍有占位主套餐,真实孤儿因此永久无法进入恢复窗口;七月迭代分支需要按其现有同步接续能力和卡观测 Outbox 架构消除同类缺陷,避免未来覆盖发布后继续复现。 + +## What Changes + +- 将孤儿恢复改为由 PostgreSQL 在限流前完成“每个载体只选队首套餐”和“排除仍有 `status IN (1,2)` 占位主套餐”的筛选,避免固定前 100 条造成永久饥饿,并删除逐条占位检查的 N+1 查询。 +- 七月分支的过期接续不再投递 `package:queue:activation` Asynq 任务:旧套餐过期事务提交后,直接调用现有 `ActivateNextPendingMainPackage` 推进队首套餐。 +- 孤儿恢复同样直接调用 `ActivateNextPendingMainPackage`,复用载体级 Redis 锁、状态幂等、购买条款快照和激活事务。 +- 套餐激活事务继续原子写入现有 `card.observation.series.requested` Outbox,由七月分支公共 Outbox 可靠驱动后续实名、流量和网络观测;不新增第二种套餐激活事件。 +- 收紧结果日志:只有实际推进套餐状态时记录激活成功,锁冲突、占位阻塞、等待实名和幂等跳过分别记录稳定原因。 +- 按用户明确要求,本 Change 不新增、修改或运行自动化测试;使用全量构建、只读 SQL、查询计划和运行日志完成验证。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `package-queue-activation`:将七月迭代的过期接续与孤儿恢复收口为同步应用调用,并补充真实孤儿公平扫描和既有卡观测 Outbox 一致性要求。 + +## Impact + +- **适用分支**:仅 `Iteration/7-11`;本 Change 不描述、不实施、不引用 `main` 线上热修。 +- **架构通道**:主通道为套餐生命周期复杂写,沿用 `Polling Handler → Package Activation Service → GORM transaction`;辅助通道为 PostgreSQL 候选查询、Redis 载体锁和既有卡观测 Outbox。完整边界只覆盖“旧套餐过期后接续队首套餐及孤儿恢复”,不迁移购买、实名、流量、退款和停复机用例。 +- **主要代码**:`internal/polling/package_activation_handler.go`、必要时最小调整 `internal/service/package/activation_service.go` 的结果日志;复用现有 `internal/infrastructure/cardobservation/series_event.go` 和公共 Outbox 装配。 +- **数据库**:不新增表、字段、索引或迁移;只调整现有 `tb_package_usage` 查询和调用顺序。 +- **API/前端**:无接口、DTO、路由和前端改动。 +- **依赖**:不新增依赖,继续使用 GORM、PostgreSQL、Redis、Zap 及七月分支已有 Outbox。 +- **性能**:孤儿扫描从“最多 100 条候选 + 最多 100 次占位查询”收敛为一次数据库候选查询和最多 100 次有业务意义的同步激活调用;使用 `EXPLAIN (ANALYZE, BUFFERS)` 验证查询成本。 +- **审计与可靠性**:Audit Event N/A(系统自动生命周期推进);`tb_package_usage` 是状态权威事实;Integration Log N/A(本修复不新增外呼);复用现有 `card.observation.series.requested` Outbox,不新增事件类型。 +- **验证**:不写自动化测试;执行 `gofmt`、现有静态检查、`go build ./...`、只读 SQL、查询计划和日志核验。 diff --git a/openspec/changes/fix-package-activation-starvation/specs/package-queue-activation/spec.md b/openspec/changes/fix-package-activation-starvation/specs/package-queue-activation/spec.md new file mode 100644 index 0000000..a458f57 --- /dev/null +++ b/openspec/changes/fix-package-activation-starvation/specs/package-queue-activation/spec.md @@ -0,0 +1,99 @@ +## MODIFIED Requirements + +### Requirement: 当前主套餐过期后自动激活下一个 + +系统 SHALL 在生效中或已用完的主套餐到期时,先提交旧主套餐和关联加油包的状态变化,再通过现有套餐激活应用能力同步推进同一载体的队首待生效主套餐;过期接续路径 MUST NOT 在旧主套餐事务内投递 `package:queue:activation` 任务。 + +成功激活队首套餐时,系统 SHALL 在套餐激活事务内同步追加现有 `card.observation.series.requested` Outbox;任一写入失败时激活事务 MUST 回滚。 + +#### Scenario: 旧主套餐提交过期后同步接续队首套餐 + +- **WHEN** 轮询处理一个已到期的 `status=1` 或 `status=2` 主套餐,且同载体存在队首待生效套餐 +- **THEN** 系统先提交旧主套餐 `status=3` 和关联加油包失效的数据库事务 +- **AND** 事务提交后调用现有套餐激活应用能力 +- **AND** 应用能力在新事务内重新校验占位状态并激活队首套餐 +- **AND** 过期接续路径不投递 `package:queue:activation` 任务 + +#### Scenario: 激活与卡观测 Outbox 原子提交 + +- **WHEN** 队首套餐满足激活条件 +- **THEN** 系统在同一事务内把套餐推进为 `status=1`并写入激活、到期及适用的重置时间 +- **AND** 同一事务追加稳定的 `card.observation.series.requested` Outbox +- **AND** Outbox 写入失败时套餐状态回滚为待生效 + +#### Scenario: 过期事务失败时不接续 + +- **WHEN** 更新旧主套餐或级联失效加油包的事务失败 +- **THEN** 事务回滚 +- **AND** 系统不得调用队首套餐激活能力 +- **AND** 下一轮过期扫描仍可重新处理该旧主套餐 + +#### Scenario: 提交后进程退出由孤儿扫描恢复 + +- **WHEN** 旧主套餐过期事务已经提交,但进程在调用接续能力前退出 +- **THEN** 待生效套餐保持 `status=0` +- **AND** 下一轮孤儿扫描识别该载体并直接调用同一套餐激活能力 + +#### Scenario: 无待生效套餐时保持无套餐状态 + +- **WHEN** 旧主套餐过期事务提交后,同载体不存在有效的 `status=0` 主套餐 +- **THEN** 套餐激活应用能力不推进任何记录 +- **AND** 载体进入无主套餐状态并沿用既有停机检查流程 + +## ADDED Requirements + +### Requirement: 孤儿待生效套餐必须公平恢复 + +系统 SHALL 在数据库中先选出每个卡或设备载体唯一的队首待生效主套餐,并排除仍存在 `status IN (1,2)` 占位主套餐的载体,最后才对真实孤儿结果执行单轮上限;系统 MUST NOT 先从全库待生效记录截取固定窗口再逐条判断占位状态。 + +队首顺序 SHALL 为 `priority ASC, created_at ASC, id ASC`。单轮最多处理 100 个真实孤儿载体,同一载体不得占用多个恢复名额。每个候选 SHALL 直接调用现有套餐激活应用能力,不投递 `package:queue:activation` 任务。 + +#### Scenario: 前一百条待生效记录均有占位套餐 + +- **WHEN** 全库排序靠前的 100 条待生效记录所属载体均存在 `status IN (1,2)` 主套餐,且窗口之后存在一条无占位主套餐的真实孤儿 +- **THEN** 数据库先排除这 100 条非孤儿记录 +- **AND** 真实孤儿进入本轮恢复候选 +- **AND** 系统直接调用套餐激活应用能力推进其队首套餐 + +#### Scenario: 同一载体存在多条待生效套餐 + +- **WHEN** 一个无占位主套餐的载体存在多条 `status=0` 主套餐 +- **THEN** 本轮只选择 `priority` 最小、其次 `created_at` 最早、最后 `id` 最小的一条 +- **AND** 该载体只占用一个恢复名额 +- **AND** 后续套餐继续保持排队状态 + +#### Scenario: 生效中和已用完套餐均属于占位 + +- **WHEN** 待生效套餐所属载体仍有 `status=1` 或 `status=2` 的主套餐 +- **THEN** 该载体不得进入孤儿恢复候选 +- **AND** 待生效套餐保持排队状态 + +#### Scenario: 单轮真实孤儿超过上限 + +- **WHEN** 数据库存在超过 100 个真实孤儿载体 +- **THEN** 系统按稳定顺序选择前 100 个载体调用接续能力 +- **AND** 未选中的真实孤儿在后续轮询中继续具备候选资格 + +### Requirement: 同步接续结果必须准确记录 + +系统 SHALL 仅在套餐激活应用能力返回 `activated=true` 时记录本轮激活成功。锁冲突、占位状态变化、等待实名和已完成幂等结果不得记录为新的激活成功。 + +#### Scenario: 本轮成功激活 + +- **WHEN** 指定载体无占位主套餐且队首套餐满足激活条件 +- **THEN** 应用能力返回 `activated=true` +- **AND** 轮询记录包含套餐使用记录、载体和触发来源的成功结果 + +#### Scenario: 激活锁冲突 + +- **WHEN** 同步接续未获得载体级 Redis 激活锁 +- **THEN** 应用能力返回现有套餐激活冲突错误 +- **AND** 本轮不得记录激活成功 +- **AND** 下一轮过期或孤儿扫描继续恢复 + +#### Scenario: 条件暂不满足 + +- **WHEN** 应用能力复检发现载体已有占位主套餐或队首仍等待实名 +- **THEN** 应用能力不修改套餐状态 +- **AND** 轮询记录明确原因 +- **AND** 后续扫描仍可重新判断 diff --git a/openspec/changes/fix-package-activation-starvation/tasks.md b/openspec/changes/fix-package-activation-starvation/tasks.md new file mode 100644 index 0000000..40ce97d --- /dev/null +++ b/openspec/changes/fix-package-activation-starvation/tasks.md @@ -0,0 +1,29 @@ +## 0. 七月分支基线与边界 + +- [ ] 0.1 在 `Iteration/7-11` 记录套餐激活相关代码、现有卡观测 Outbox 装配和工作树状态,确认本 Change 只覆盖“旧套餐过期后接续队首套餐及孤儿恢复”,不涉及 `main`、线上纯 Asynq 热修或其他套餐用例。验证:相关文件差异摘要不包含跨分支移植计划。 +- [ ] 0.2 保存生产故障只读基线 `100/100/0`,准备真实孤儿候选 SQL和 `EXPLAIN (ANALYZE, BUFFERS)`;所有 SQL 仅允许读取。验证:SQL 能区分占位记录、真实孤儿和每个载体的稳定队首。 +- [ ] 0.3 增量更新 `.scratch/tech-global-audit/审计覆盖基线.md`:Audit Event N/A、Domain Ledger N/A、Integration Log N/A,Outbox 明确复用 `card.observation.series.requested` 且不新增事件类型。验证:四类公共能力均有明确决定。 + +## 1. 真实孤儿公平恢复切片 + +- [ ] 1.1 修改 `findAndActivateOrphanPackages`:通过 GORM 执行 PostgreSQL CTE/窗口查询,先按卡或设备选择 `priority ASC, created_at ASC, id ASC` 的唯一队首,再用 `NOT EXISTS` 排除 `status IN (1,2)` 的占位载体,最后限制 100 个真实孤儿;删除逐条 `Count` 和 Go map 二次分组。验证:只读 SQL 在前 100 条全部被占位时仍返回窗口后的真实孤儿,同一载体只返回一条队首。 +- [ ] 1.2 对每个真实孤儿直接调用 `ActivateNextPendingMainPackage`,根据 `activated bool` 和错误记录实际结果,不调用 `enqueueActivationTask`。验证:孤儿路径不存在 `package:queue:activation` 投递,锁冲突和条件未满足不记录成功。 +- [ ] 1.3 执行候选 SQL和 `EXPLAIN (ANALYZE, BUFFERS)`,记录真实孤儿数量、执行耗时和扫描行数;没有性能证据时不新增索引。验证:候选查询无逐载体 N+1,单轮最多返回 100 个载体。 + +## 2. 过期接续与 Outbox 一致性切片 + +- [ ] 2.1 修改 `processExpiredPackage`:事务内只提交旧主套餐过期和关联加油包失效,事务成功后直接调用 `ActivateNextPendingMainPackage`;删除该路径对 `activateNextPackage/enqueueActivationTask` 的调用。验证:旧状态未提交时不会执行新套餐激活,提交后进程退出可由孤儿扫描恢复。 +- [ ] 2.2 核对同步接续成功事务继续调用 `appendActivationObservation`,并由现有 Writer 原子追加 `card.observation.series.requested`;不得新增套餐激活 Outbox 事件或消费者。验证:激活状态和卡观测事件同事务成功或回滚,稳定事件 ID仍基于 package usage ID。 +- [ ] 2.3 收紧轮询日志:仅 `activated=true` 记录本轮成功;锁冲突、无队首、占位变化、等待实名和幂等分别记录明确结果。验证:日志包含载体类型、载体 ID、套餐使用记录或可定位的候选信息和触发来源。 + +## 3. 七月分支验证与文档 + +- [ ] 3.1 对受影响 Go 文件执行 `gofmt`、现有静态检查和 `go build ./...`;按用户要求不新增、修改或运行自动化测试。验证:全量构建退出码为 0,无临时调试标记。 +- [ ] 3.2 使用只读数据库事实核验:真实孤儿进入候选、同步接续后队首变为 `status=1`、同一载体无第二条生效主套餐、对应卡观测 Outbox 与激活事务一致。验证:保存 SQL、预期和实际结果,不执行批量修复。 +- [ ] 3.3 新建 `docs/feature-505-package-activation-recovery/功能总结.md` 并更新 README 功能索引,记录七月专属架构、根因、SQL、Outbox 一致性、发布观察、回滚和未执行自动化测试声明。验证:文档不出现 `main`、cherry-pick 或纯 Asynq 热修步骤。 +- [ ] 3.4 复核七月分支差异并使用中文 Lore commit 提交修复。验证:提交不包含线上 `main` 提案或实现,可独立 revert,`openspec validate fix-package-activation-starvation --strict` 通过。 + +## 4. 七月迭代发布观察 + +- [ ] 4.1 部署前记录真实孤儿数量、最老等待时间、同载体重复生效检查和卡观测 Outbox 待投递状态;部署后观察至少两个轮询周期。验证:真实孤儿持续收敛、无重复生效、Outbox 正常投递。 +- [ ] 4.2 准备回滚说明:无数据库迁移,仅 revert 七月专属修复提交并重新部署 Worker;已正确激活的套餐不反向修改。验证:说明包含观察停止条件和带状态保护的单卡人工恢复边界。 diff --git a/pkg/auditcontext/context.go b/pkg/auditcontext/context.go new file mode 100644 index 0000000..fa0810e --- /dev/null +++ b/pkg/auditcontext/context.go @@ -0,0 +1,84 @@ +// Package auditcontext 提供跨 HTTP、异步任务和外部回调传播的审计上下文。 +package auditcontext + +import "context" + +type contextKey struct{} + +// Context 保存入口提供的真实操作者与链路信息,不包含业务动作或资源事实。 +type Context struct { + ActorKind string + ActorID string + ActorName string + ActorShopID *uint + ActorEnterpriseID *uint + Source string + RequestID string + CorrelationID string + ParentEventID string + RequestPath string + RequestMethod string + IPAddress string + UserAgent string +} + +// With 合并审计上下文;非空新值覆盖旧值,便于认证中间件覆盖 HTTP 基础信息。 +func With(ctx context.Context, value Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + merged := From(ctx) + merge(&merged, value) + return context.WithValue(ctx, contextKey{}, merged) +} + +// From 读取审计上下文;未设置时返回零值。 +func From(ctx context.Context) Context { + if ctx == nil { + return Context{} + } + value, _ := ctx.Value(contextKey{}).(Context) + return value +} + +func merge(target *Context, value Context) { + if value.ActorKind != "" { + target.ActorKind = value.ActorKind + } + if value.ActorID != "" { + target.ActorID = value.ActorID + } + if value.ActorName != "" { + target.ActorName = value.ActorName + } + if value.ActorShopID != nil { + target.ActorShopID = value.ActorShopID + } + if value.ActorEnterpriseID != nil { + target.ActorEnterpriseID = value.ActorEnterpriseID + } + if value.Source != "" { + target.Source = value.Source + } + if value.RequestID != "" { + target.RequestID = value.RequestID + } + if value.CorrelationID != "" { + target.CorrelationID = value.CorrelationID + } + if value.ParentEventID != "" { + target.ParentEventID = value.ParentEventID + } + if value.RequestPath != "" { + target.RequestPath = value.RequestPath + } + if value.RequestMethod != "" { + target.RequestMethod = value.RequestMethod + } + if value.IPAddress != "" { + target.IPAddress = value.IPAddress + } + if value.UserAgent != "" { + target.UserAgent = value.UserAgent + } +} diff --git a/pkg/auditfailure/observer.go b/pkg/auditfailure/observer.go new file mode 100644 index 0000000..da9284f --- /dev/null +++ b/pkg/auditfailure/observer.go @@ -0,0 +1,33 @@ +// Package auditfailure 提供失败审计二次写入故障的最小进程内观测。 +package auditfailure + +import ( + "sync/atomic" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/pkg/logger" +) + +var secondaryWriteFailures atomic.Uint64 + +// RecordSecondaryWriteFailure 累计失败审计二次写入故障并记录 critical 安全日志。 +func RecordSecondaryWriteFailure(action, resourceKey, requestID, correlationID, originalErrorCode string, err error) { + count := secondaryWriteFailures.Add(1) + logger.GetAppLogger().Error( + "失败或拒绝审计二次写入失败", + zap.String("severity", "critical"), + zap.String("action", action), + zap.String("resource_key", resourceKey), + zap.String("request_id", requestID), + zap.String("correlation_id", correlationID), + zap.String("original_error_code", originalErrorCode), + zap.Uint64("secondary_write_failure_count", count), + zap.Error(err), + ) +} + +// SecondaryWriteFailureCount 返回当前进程累计的失败审计二次写入故障数。 +func SecondaryWriteFailureCount() uint64 { + return secondaryWriteFailures.Load() +} diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go new file mode 100644 index 0000000..8b8b1bf --- /dev/null +++ b/pkg/constants/audit.go @@ -0,0 +1,303 @@ +package constants + +const ( + // AuditActionAccountCreated 表示创建账号。 + AuditActionAccountCreated = "account.created" + // AuditActionAccountUpdated 表示更新账号基础资料或状态。 + AuditActionAccountUpdated = "account.updated" + // AuditActionAccountDeleted 表示软删除账号。 + AuditActionAccountDeleted = "account.deleted" + // AuditActionAccountPasswordReset 表示管理员重置账号密码。 + AuditActionAccountPasswordReset = "account.update_password" + // AuditActionAccountPasswordChanged 表示账号本人修改密码。 + AuditActionAccountPasswordChanged = "auth.change_password" + // AuditActionAccountWeComBound 表示绑定账号企业微信身份。 + AuditActionAccountWeComBound = "account.bind_we_com" + // AuditActionAuthLogin 表示后台账号登录。 + AuditActionAuthLogin = "auth.login" + // AuditActionAuthLogout 表示后台账号退出登录。 + AuditActionAuthLogout = "auth.logout" + // AuditActionAuthTokenRefreshed 表示后台账号刷新访问令牌。 + AuditActionAuthTokenRefreshed = "auth.refresh_token" + // AuditActionAccountRolesAssigned 表示为账号分配角色。 + AuditActionAccountRolesAssigned = "account.assign_roles" + // AuditActionAccountRoleRemoved 表示移除账号角色。 + AuditActionAccountRoleRemoved = "account.remove_role" + // AuditActionShopRolesAssigned 表示为店铺分配角色。 + AuditActionShopRolesAssigned = "shop.assign_shop_roles" + // AuditActionShopRoleDeleted 表示移除店铺角色。 + AuditActionShopRoleDeleted = "shop.delete_shop_role" + // AuditActionShopCreated 表示创建店铺。 + AuditActionShopCreated = "shop.create" + // AuditActionShopUpdated 表示更新店铺基础资料。 + AuditActionShopUpdated = "shop.update" + // AuditActionShopEnabled 表示启用店铺。 + AuditActionShopEnabled = "shop.enable" + // AuditActionShopDisabled 表示禁用店铺。 + AuditActionShopDisabled = "shop.disable" + // AuditActionShopDeleted 表示删除店铺。 + AuditActionShopDeleted = "shop.delete" + // AuditActionShopBusinessOwnerUpdated 表示更新店铺业务员归属。 + AuditActionShopBusinessOwnerUpdated = "shop.update_business_owner" + // AuditActionShopClientLoginLimitUpdated 表示更新店铺 C 端登录限制。 + AuditActionShopClientLoginLimitUpdated = "shop.update_client_login_limit" + // AuditActionEnterpriseCreated 表示创建企业及初始企业账号。 + AuditActionEnterpriseCreated = "enterprise.create" + // AuditActionEnterpriseUpdated 表示更新企业基础资料。 + AuditActionEnterpriseUpdated = "enterprise.update" + // AuditActionEnterpriseStatusUpdated 表示更新企业及企业账号状态。 + AuditActionEnterpriseStatusUpdated = "enterprise.update_status" + // AuditActionEnterprisePasswordUpdated 表示更新企业账号密码。 + AuditActionEnterprisePasswordUpdated = "enterprise.update_password" + // AuditActionEnterpriseCardsAllocated 表示向企业授权独立卡。 + AuditActionEnterpriseCardsAllocated = "enterprise_card.allocate_cards" + // AuditActionEnterpriseCardsRecalled 表示回收企业独立卡授权。 + AuditActionEnterpriseCardsRecalled = "enterprise_card.recall_cards" + // AuditActionEnterpriseCardRemarkUpdated 表示更新企业卡授权备注。 + AuditActionEnterpriseCardRemarkUpdated = "enterprise_card.update_record_remark" + // AuditActionEnterpriseDevicesAllocated 表示向企业授权设备及其绑定卡。 + AuditActionEnterpriseDevicesAllocated = "enterprise_device.allocate_devices" + // AuditActionEnterpriseDevicesRecalled 表示回收企业设备及其绑定卡授权。 + AuditActionEnterpriseDevicesRecalled = "enterprise_device.recall_devices" + // AuditActionPersonalCustomerProfileUpdated 表示个人客户更新资料。 + AuditActionPersonalCustomerProfileUpdated = "personal_customer.update_profile" + // AuditActionPersonalCustomerPhoneBound 表示个人客户绑定手机号。 + AuditActionPersonalCustomerPhoneBound = "personal_customer.bind_phone" + // AuditActionPersonalCustomerPhoneChanged 表示个人客户更换手机号。 + AuditActionPersonalCustomerPhoneChanged = "personal_customer.change_phone" + // AuditActionPersonalCustomerWechatIdentityUpdated 表示创建或同步个人客户微信主体。 + AuditActionPersonalCustomerWechatIdentityUpdated = "personal_customer.update_wechat_identity" + // AuditActionSystemConfigUpdated 表示更新受控系统配置。 + AuditActionSystemConfigUpdated = "system_config.updated" + // AuditActionOutboxReplayed 表示人工重放 Outbox 事件。 + AuditActionOutboxReplayed = "outbox.replayed" + // AuditActionOutboxExpiredLeaseReleased 表示人工释放 Outbox 过期租约。 + AuditActionOutboxExpiredLeaseReleased = "outbox.expired_lease_released" + // AuditActionDeviceBatchAllocationCompleted 表示设备批量分配根事件。 + AuditActionDeviceBatchAllocationCompleted = "device.batch_allocation.completed" + // AuditActionDeviceBatchAllocationItem 表示设备批量分配资源子事件。 + AuditActionDeviceBatchAllocationItem = "device.batch_allocation.item" + // AuditActionWeComCredentialsRead 表示读取企业微信应用明文凭据。 + AuditActionWeComCredentialsRead = "wecom.application.credentials_read" + // AuditActionRoleCreated 表示创建角色。 + AuditActionRoleCreated = "role.create" + // AuditActionRoleUpdated 表示更新角色。 + AuditActionRoleUpdated = "role.update" + // AuditActionRoleStatusUpdated 表示更新角色状态。 + AuditActionRoleStatusUpdated = "role.update_status" + // AuditActionRoleDefaultCreditUpdated 表示更新角色默认信用额度。 + AuditActionRoleDefaultCreditUpdated = "role.update_default_credit" + // AuditActionRoleDeleted 表示删除角色。 + AuditActionRoleDeleted = "role.delete" + // AuditActionRolePermissionsAssigned 表示配置角色权限。 + AuditActionRolePermissionsAssigned = "role.assign_permissions" + // AuditActionRolePermissionRemoved 表示移除单个角色权限。 + AuditActionRolePermissionRemoved = "role.remove_permission" + // AuditActionRolePermissionsBatchRemoved 表示批量移除角色权限。 + AuditActionRolePermissionsBatchRemoved = "role.batch_remove_permissions" + // AuditActionPermissionCreated 表示创建权限。 + AuditActionPermissionCreated = "permission.create" + // AuditActionPermissionUpdated 表示更新权限。 + AuditActionPermissionUpdated = "permission.update" + // AuditActionPermissionDeleted 表示删除权限。 + AuditActionPermissionDeleted = "permission.delete" + // AuditOperationSystemConfigUpdate 表示系统配置旧接缝传入的操作类型。 + AuditOperationSystemConfigUpdate = "system_config_update" + // AuditOperationOutboxReplay 表示 Outbox 人工重放接缝操作类型。 + AuditOperationOutboxReplay = "outbox_replay" + // AuditOperationOutboxReleaseExpiredLease 表示 Outbox 人工释放过期租约接缝操作类型。 + AuditOperationOutboxReleaseExpiredLease = "outbox_release_expired_lease" +) + +const ( + // AuditResourceSystemConfig 表示受控系统配置资源。 + AuditResourceSystemConfig = "system_config" + // AuditResourceOutboxEvent 表示公共 Outbox 事件资源。 + AuditResourceOutboxEvent = "outbox_event" + // AuditResourceDeviceBatchTask 表示设备批量分配任务资源。 + AuditResourceDeviceBatchTask = "device_batch_task" + // AuditResourceDevice 表示设备资源。 + AuditResourceDevice = "device" + // AuditResourceIotCard 表示 IoT 卡资源。 + AuditResourceIotCard = "iot_card" + // AuditResourceShop 表示店铺资源。 + AuditResourceShop = "shop" + // AuditResourceOrder 表示订单资源。 + AuditResourceOrder = "order" + // AuditResourceRefund 表示退款资源。 + AuditResourceRefund = "refund" + // AuditResourceAccount 表示后台账号资源。 + AuditResourceAccount = "account" + // AuditResourceRole 表示后台角色资源。 + AuditResourceRole = "role" + // AuditResourcePermission 表示后台权限资源。 + AuditResourcePermission = "permission" + // AuditResourceEnterprise 表示企业资源。 + AuditResourceEnterprise = "enterprise" + // AuditResourceDeviceSIMBinding 表示设备卡槽绑定资源。 + AuditResourceDeviceSIMBinding = "device_sim_binding" + // AuditResourceAssetAllocationRecord 表示资产分配记录资源。 + AuditResourceAssetAllocationRecord = "asset_allocation_record" + // AuditResourceExchangeOrder 表示换货单资源。 + AuditResourceExchangeOrder = "exchange_order" + // AuditResourceAgentRecharge 表示代理充值单资源。 + AuditResourceAgentRecharge = "agent_recharge" + // AuditResourceAssetWallet 表示资产钱包资源。 + AuditResourceAssetWallet = "asset_wallet" + // AuditResourceApprovalInstance 表示审批实例资源。 + AuditResourceApprovalInstance = "approval_instance" + // AuditResourceWeComApplication 表示企业微信应用配置资源。 + AuditResourceWeComApplication = "wecom_application" + // AuditResourceAuthentication 表示不含 Token 或 Cookie 的认证状态资源。 + AuditResourceAuthentication = "authentication" + // AuditResourceEnterpriseCardAuthorization 表示企业卡授权记录资源。 + AuditResourceEnterpriseCardAuthorization = "enterprise_card_authorization" + // AuditResourceEnterpriseDeviceAuthorization 表示企业设备授权记录资源。 + AuditResourceEnterpriseDeviceAuthorization = "enterprise_device_authorization" + // AuditResourcePersonalCustomer 表示个人客户资源。 + AuditResourcePersonalCustomer = "personal_customer" + // AuditResourcePersonalCustomerPhone 表示个人客户手机号资源。 + AuditResourcePersonalCustomerPhone = "personal_customer_phone" + // AuditResourcePersonalCustomerOpenID 表示个人客户微信 OpenID 资源。 + AuditResourcePersonalCustomerOpenID = "personal_customer_openid" + // AuditResourceRelationPrimary 表示事件的主要资源。 + AuditResourceRelationPrimary = "primary" + // AuditResourceRelationAffected 表示被本次动作改变的资源。 + AuditResourceRelationAffected = "affected" + // AuditResourceRelationReference 表示本次动作引用但未改变的资源。 + AuditResourceRelationReference = "reference" + // AuditResourceRoleConfig 表示配置资源角色。 + AuditResourceRoleConfig = "config" + // AuditResourceRoleRecoveryTarget 表示人工恢复裁决的目标事件。 + AuditResourceRoleRecoveryTarget = "recovery_target" + // AuditResourceRoleBatchTask 表示批量根事件的任务资源。 + AuditResourceRoleBatchTask = "batch_task" + // AuditResourceRoleBatchItem 表示批量子事件的单项资源。 + AuditResourceRoleBatchItem = "batch_item" + // AuditResourceRoleSensitiveReadTarget 表示敏感读取目标资源。 + AuditResourceRoleSensitiveReadTarget = "sensitive_read_target" + // AuditResourceRoleAccountTarget 表示账号生命周期的目标账号。 + AuditResourceRoleAccountTarget = "account_target" + // AuditResourceRoleAccountScope 表示账号归属的店铺或企业。 + AuditResourceRoleAccountScope = "account_scope" + // AuditResourceRoleAccountRole 表示账号实际持有的角色。 + AuditResourceRoleAccountRole = "account_role" + // AuditResourceRoleAuthentication 表示账号关联的认证状态。 + AuditResourceRoleAuthentication = "authentication" + // AuditResourceRoleAccessRole 表示角色权限配置中的角色目标。 + AuditResourceRoleAccessRole = "role_target" + // AuditResourceRoleAccessPermission 表示角色权限配置中的权限目标。 + AuditResourceRoleAccessPermission = "permission_target" + // AuditResourceRoleShopRole 表示店铺实际持有的角色。 + AuditResourceRoleShopRole = "shop_role" + // AuditResourceRoleShopTarget 表示店铺操作的主要目标。 + AuditResourceRoleShopTarget = "shop_target" + // AuditResourceRoleShopParent 表示店铺的上级店铺。 + AuditResourceRoleShopParent = "shop_parent" + // AuditResourceRoleShopAccount 表示店铺直接影响的账号。 + AuditResourceRoleShopAccount = "shop_account" + // AuditResourceRoleShopBusinessOwner 表示店铺当前业务员账号。 + AuditResourceRoleShopBusinessOwner = "shop_business_owner" + // AuditResourceRoleShopPreviousBusinessOwner 表示店铺原业务员账号。 + AuditResourceRoleShopPreviousBusinessOwner = "shop_previous_business_owner" + // AuditResourceRoleEnterpriseTarget 表示企业操作的主要目标。 + AuditResourceRoleEnterpriseTarget = "enterprise_target" + // AuditResourceRoleEnterpriseOwnerShop 表示企业归属店铺。 + AuditResourceRoleEnterpriseOwnerShop = "enterprise_owner_shop" + // AuditResourceRoleEnterpriseAccount 表示企业关联账号。 + AuditResourceRoleEnterpriseAccount = "enterprise_account" + // AuditResourceRoleEnterpriseAuthorizedCard 表示企业授权涉及的卡。 + AuditResourceRoleEnterpriseAuthorizedCard = "enterprise_authorized_card" + // AuditResourceRoleEnterpriseCardAuthorization 表示企业卡授权记录。 + AuditResourceRoleEnterpriseCardAuthorization = "enterprise_card_authorization" + // AuditResourceRoleEnterpriseAuthorizedDevice 表示企业授权涉及的设备。 + AuditResourceRoleEnterpriseAuthorizedDevice = "enterprise_authorized_device" + // AuditResourceRoleEnterpriseDeviceBinding 表示企业设备授权涉及的卡槽绑定。 + AuditResourceRoleEnterpriseDeviceBinding = "enterprise_device_binding" + // AuditResourceRoleEnterpriseDeviceAuthorization 表示企业设备授权记录。 + AuditResourceRoleEnterpriseDeviceAuthorization = "enterprise_device_authorization" + // AuditResourceRolePersonalCustomerTarget 表示个人客户操作的主体目标。 + AuditResourceRolePersonalCustomerTarget = "personal_customer_target" + // AuditResourceRolePersonalCustomerPhone 表示个人客户手机号关系。 + AuditResourceRolePersonalCustomerPhone = "personal_customer_phone" + // AuditResourceRolePersonalCustomerWechatIdentity 表示个人客户微信主体关系。 + AuditResourceRolePersonalCustomerWechatIdentity = "personal_customer_wechat_identity" +) + +const ( + // AuditActorAccount 表示已认证人工账号。 + AuditActorAccount = "account" + // AuditActorPersonalCustomer 表示已认证个人客户。 + AuditActorPersonalCustomer = "personal_customer" + // AuditActorOpenAPI 表示通过代理 OpenAPI 调用的账号。 + AuditActorOpenAPI = "openapi" + // AuditActorSystemTask 表示异步 Worker 执行的系统任务。 + AuditActorSystemTask = "system_task" + // AuditActorScheduledJob 表示由 Scheduler 触发的计划任务。 + AuditActorScheduledJob = "scheduled_job" + // AuditActorExternalSystem 表示经验证的外部系统回调。 + AuditActorExternalSystem = "external_system" + // AuditSourceAdminAPI 表示后台管理 API 入口。 + AuditSourceAdminAPI = "admin_api" + // AuditSourcePersonalAPI 表示个人客户 API 入口。 + AuditSourcePersonalAPI = "personal_api" + // AuditSourceOpenAPI 表示代理 OpenAPI 入口。 + AuditSourceOpenAPI = "openapi" + // AuditSourceWorker 表示异步 Worker 入口。 + AuditSourceWorker = "worker" + // AuditSourceScheduler 表示计划任务入口。 + AuditSourceScheduler = "scheduler" + // AuditSourceCallback 表示外部系统回调入口。 + AuditSourceCallback = "callback" + // AuditScopePlatform 表示平台级业务范围。 + AuditScopePlatform = "platform" + // AuditScopePersonalCustomer 表示个人客户本人业务范围。 + AuditScopePersonalCustomer = "personal_customer" +) + +const ( + // AuditResultSuccess 表示业务操作成功。 + AuditResultSuccess = "success" + // AuditResultFailed 表示业务操作执行失败。 + AuditResultFailed = "failed" + // AuditResultDenied 表示业务规则或权限拒绝。 + AuditResultDenied = "denied" + // AuditResultPartial 表示批量操作部分成功。 + AuditResultPartial = "partial" + // AuditResultUnknown 表示业务结果暂时无法确认。 + AuditResultUnknown = "unknown" +) + +const ( + // AuditRiskLow 表示低风险审计动作。 + AuditRiskLow = "low" + // AuditRiskHigh 表示高风险审计动作。 + AuditRiskHigh = "high" + // AuditRiskNormal 表示常规风险审计动作。 + AuditRiskNormal = "normal" + // AuditRiskCritical 表示严重风险审计动作。 + AuditRiskCritical = "critical" + // AuditCategoryConfiguration 表示关键配置类别。 + AuditCategoryConfiguration = "configuration" + // AuditCategoryReliability 表示可靠事件运维类别。 + AuditCategoryReliability = "reliability" + // AuditCategoryAsset 表示资产变更类别。 + AuditCategoryAsset = "asset" + // AuditCategorySecurity 表示安全与敏感读取类别。 + AuditCategorySecurity = "security" + // AuditCategoryIdentity 表示账号与权限身份类别。 + AuditCategoryIdentity = "identity" + // AuditCategoryBusiness 表示通用业务配置类别。 + AuditCategoryBusiness = "business" + // AuditSubjectInternalOnly 表示仅平台内部可见。 + AuditSubjectInternalOnly = "internal_only" + // AuditSubjectResult 表示主体仅可查看安全业务结论。 + AuditSubjectResult = "subject_result" + // AuditSubjectDetail 表示主体可查看注册表允许的结构化业务详情。 + AuditSubjectDetail = "subject_detail" +) + +const ( + // AuditJSONMaxBytes 是单个审计 JSON 字段允许的最大字节数。 + AuditJSONMaxBytes = 16 * 1024 +) diff --git a/pkg/constants/integration_log.go b/pkg/constants/integration_log.go index 7ff660d..962b77c 100644 --- a/pkg/constants/integration_log.go +++ b/pkg/constants/integration_log.go @@ -2,6 +2,28 @@ package constants import "time" +const ( + // IntegrationIDMaxLength 是外部交互稳定ID的数据库长度上限。 + IntegrationIDMaxLength = 64 + // IntegrationTriggerSeriesMaxLength 是技术尝试序列ID的数据库长度上限。 + IntegrationTriggerSeriesMaxLength = 64 + // IntegrationCorrelationIDMaxLength 是跨业务链路关联ID的数据库长度上限。 + IntegrationCorrelationIDMaxLength = 100 + // IntegrationResourceIDMaxLength 是外部交互本地资源ID的数据库长度上限。 + IntegrationResourceIDMaxLength = 128 + // IntegrationResourceKeyMaxLength 是外部交互资源稳定Key的数据库长度上限。 + IntegrationResourceKeyMaxLength = 128 + // IntegrationProviderMessageMaxLength 是外部交互结果摘要的数据库长度上限。 + IntegrationProviderMessageMaxLength = 500 + // IntegrationSafeMessagePrefix 标识调用方已提供的受控可读业务结果摘要。 + IntegrationSafeMessagePrefix = "业务结果摘要:" + + // IntegrationQueryMaxRange 是调查列表允许的最大连续时间范围。 + IntegrationQueryMaxRange = 31 * 24 * time.Hour + // IntegrationPendingStaleAfter 是待处理尝试进入陈旧统计的时长。 + IntegrationPendingStaleAfter = 5 * time.Minute +) + const ( // IntegrationDirectionInbound 表示外部系统调用本系统。 IntegrationDirectionInbound = "inbound" @@ -85,3 +107,86 @@ func IntegrationResultName(result string) string { } return names[result] } + +const ( + // IntegrationResultCategoryProcessing 表示仍在处理。 + IntegrationResultCategoryProcessing = "processing" + // IntegrationResultCategorySucceeded 表示外部请求实际成功。 + IntegrationResultCategorySucceeded = "succeeded" + // IntegrationResultCategoryIndeterminate 表示结果无法确认。 + IntegrationResultCategoryIndeterminate = "indeterminate" + // IntegrationResultCategoryFailed 表示外部请求或载荷明确失败。 + IntegrationResultCategoryFailed = "failed" + // IntegrationResultCategoryNotSent 表示外部请求未发送。 + IntegrationResultCategoryNotSent = "not_sent" +) + +// IntegrationResultCategory 返回外部尝试的稳定派生类别。 +func IntegrationResultCategory(result string) string { + switch result { + case IntegrationResultPending: + return IntegrationResultCategoryProcessing + case IntegrationResultSuccess: + return IntegrationResultCategorySucceeded + case IntegrationResultUnknown: + return IntegrationResultCategoryIndeterminate + case IntegrationResultFailed, IntegrationResultNotFound, IntegrationResultInvalidPayload, IntegrationResultConflict: + return IntegrationResultCategoryFailed + case IntegrationResultIgnored, IntegrationResultMerged, IntegrationResultRateLimited, IntegrationResultCompleted, IntegrationResultCancelled: + return IntegrationResultCategoryNotSent + default: + return "" + } +} + +// IntegrationProviderName 返回外部提供方中文名称。 +func IntegrationProviderName(provider string) string { + names := map[string]string{ + IntegrationProviderCTCC: "中国电信", IntegrationProviderCMCC: "中国移动", + IntegrationProviderCUCC: "中国联通", IntegrationProviderWechatPay: "微信支付", + IntegrationProviderAlipay: "支付宝", IntegrationProviderWeCom: "企业微信", + IntegrationProviderGateway: "Gateway", + } + if name := names[provider]; name != "" { + return name + } + return "未知提供方" +} + +// IntegrationDirectionName 返回外部交互方向中文名称。 +func IntegrationDirectionName(direction string) string { + switch direction { + case IntegrationDirectionInbound: + return "入站" + case IntegrationDirectionOutbound: + return "出站" + default: + return "未知方向" + } +} + +// IntegrationOperationName 返回已注册外部操作中文名称。 +func IntegrationOperationName(operation string) string { + names := map[string]string{ + IntegrationOperationCTCCRealnameCallback: "接收实名结果回调", + IntegrationOperationCUCCRealnameRemovalCallback: "接收解除实名回调", + IntegrationOperationPaymentPreCreate: "支付预下单", IntegrationOperationPaymentQuery: "支付查单", + IntegrationOperationPaymentCallback: "接收支付回调", + IntegrationOperationWeComAccessToken: "获取企业微信访问令牌", + IntegrationOperationWeComVisibleMembers: "查询企业微信可见成员", + IntegrationOperationWeComVisibleDepartments: "查询企业微信可见部门", + IntegrationOperationWeComTemplateDetail: "查询企业微信审批模板", + IntegrationOperationWeComAttachmentUpload: "上传企业微信审批附件", + IntegrationOperationWeComApprovalSubmit: "提交企业微信审批", + IntegrationOperationWeComApprovalCallback: "接收企业微信审批回调", + IntegrationOperationWeComApprovalDetail: "查询企业微信审批详情", + IntegrationOperationWeComApprovalInfo: "查询企业微信审批单号", + IntegrationOperationGatewayRealname: "查询实名状态", IntegrationOperationGatewayTraffic: "查询流量", + IntegrationOperationGatewayNetwork: "查询卡网络状态", IntegrationOperationGatewayDeviceInfo: "查询设备信息", + IntegrationOperationGatewaySpeedTier: "设置卡限速档位", + } + if name := names[operation]; name != "" { + return name + } + return "未知外部操作" +} diff --git a/pkg/logger/middleware.go b/pkg/logger/middleware.go index 5d88609..792c2ff 100644 --- a/pkg/logger/middleware.go +++ b/pkg/logger/middleware.go @@ -7,6 +7,7 @@ import ( "net/url" "time" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/sanitizer" "github.com/bytedance/sonic" @@ -154,8 +155,10 @@ func MiddlewareWithLogger(accessLogger *zap.Logger) fiber.Handler { // 注入请求上下文,供 Service 层审计日志复用 ctx := c.UserContext() + requestID := "" if rid := c.Locals(constants.ContextKeyRequestID); rid != nil { - if requestID, ok := rid.(string); ok && requestID != "" { + if value, ok := rid.(string); ok && value != "" { + requestID = value ctx = context.WithValue(ctx, constants.ContextKeyRequestID, requestID) } } @@ -163,6 +166,11 @@ func MiddlewareWithLogger(accessLogger *zap.Logger) fiber.Handler { ctx = context.WithValue(ctx, constants.ContextKeyUserAgent, c.Get("User-Agent")) ctx = context.WithValue(ctx, constants.ContextKeyRequestPath, c.Path()) ctx = context.WithValue(ctx, constants.ContextKeyRequestMethod, c.Method()) + ctx = auditcontext.With(ctx, auditcontext.Context{ + RequestID: requestID, CorrelationID: requestID, + RequestPath: c.Path(), RequestMethod: c.Method(), + IPAddress: c.IP(), UserAgent: c.Get("User-Agent"), + }) c.SetUserContext(ctx) // 获取请求 body(在 c.Next() 之前读取) @@ -182,7 +190,7 @@ func MiddlewareWithLogger(accessLogger *zap.Logger) fiber.Handler { duration := time.Since(startTime) // 获取请求 ID(由 requestid 中间件设置) - requestID := "" + requestID = "" if rid := c.Locals(constants.ContextKeyRequestID); rid != nil { requestID = rid.(string) } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index e00dcf2..a346052 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -2,7 +2,9 @@ package middleware import ( "context" + "strconv" + "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/logger" @@ -37,6 +39,27 @@ func SetUserContext(ctx context.Context, info *UserContextInfo) context.Context if info.SubordinateShopIDs != nil { ctx = context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, info.SubordinateShopIDs) } + actorKind := constants.AuditActorAccount + actorID := info.UserID + source := constants.AuditSourceAdminAPI + if info.UserType == constants.UserTypePersonalCustomer { + actorKind = constants.AuditActorPersonalCustomer + source = constants.AuditSourcePersonalAPI + if info.CustomerID > 0 { + actorID = info.CustomerID + } + } + var shopID, enterpriseID *uint + if info.ShopID > 0 { + shopID = &info.ShopID + } + if info.EnterpriseID > 0 { + enterpriseID = &info.EnterpriseID + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: actorKind, ActorID: strconv.FormatUint(uint64(actorID), 10), ActorName: info.Username, + ActorShopID: shopID, ActorEnterpriseID: enterpriseID, Source: source, + }) return ctx } diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 1a659ab..b4308a8 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -75,6 +75,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), SystemConfig: admin.NewSystemConfigHandler(nil, nil), + Audit: admin.NewAuditHandler(nil, nil), WeCom: admin.NewWeComHandler(nil, nil), AgentOpenAPI: openapiHandler.NewHandler(nil, nil), } diff --git a/pkg/sanitizer/sanitizer.go b/pkg/sanitizer/sanitizer.go index 1e59671..10de80b 100644 --- a/pkg/sanitizer/sanitizer.go +++ b/pkg/sanitizer/sanitizer.go @@ -20,6 +20,9 @@ var forbiddenFragments = []string{ // IsForbiddenField 判断字段是否禁止进入普通日志、审计或外部交互摘要。 func IsForbiddenField(key string) bool { normalized := strings.ToLower(strings.NewReplacer("-", "_", ".", "_").Replace(key)) + if normalized == "credentials_configured" { + return false + } for _, fragment := range forbiddenFragments { if strings.Contains(normalized, fragment) { return true