From 304f6656602e64a0c4c1194b333b797e151ca215 Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 13 Jun 2026 20:56:07 +0800 Subject: [PATCH] =?UTF-8?q?test(p1r):=20=E6=94=B6=E5=8F=A3=20Content=20Adm?= =?UTF-8?q?in=20RiskAction=20completed=20approval=20=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agent-specs/.agent | 10 + ...tAdminRiskActionCompletedApproval审阅版.md | 417 +++++ ...tAdminRiskActionCompletedApproval执行版.md | 615 ++++++++ ...06-13-P1RContentAdminRiskAction状态推进.md | 261 ++++ .../superpowers/reports/p1r-api-coverage.json | 10 +- docs/superpowers/reports/p1r-api-coverage.md | 8 +- .../api/P1rAccountRealApiGateTest.java | 13 +- .../framework/api/P1rAiRealApiGateTest.java | 15 +- .../api/P1rApiCoverageReportTest.java | 14 +- ...entAdminRiskActionCompletedApprovalIT.java | 1337 +++++++++++++++++ .../api/P1rContentRealApiGateTest.java | 13 +- .../api/P1rEventsRealApiGateTest.java | 19 +- .../api/P1rKnowledgeRealApiGateTest.java | 15 +- .../api/P1rMarketRealApiGateTest.java | 15 +- .../framework/api/P1rMetaRealApiGateTest.java | 13 +- muse-cloud/scripts/p1r-audit-api-coverage.py | 1 + 16 files changed, 2713 insertions(+), 63 deletions(-) create mode 100644 docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval审阅版.md create mode 100644 docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval执行版.md create mode 100644 docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java diff --git a/docs/agent-specs/.agent b/docs/agent-specs/.agent index d68f2b2d..68c078cb 100644 --- a/docs/agent-specs/.agent +++ b/docs/agent-specs/.agent @@ -221,3 +221,13 @@ P1R Content Admin Read Completed Approval implementation 当前状态:用户 P1R Content Admin Read Completed Approval implementation fresh review 进展:Lorentz correctness review PASS,无 findings;Hegel testing/data-integrity review FAIL,2 个有效问题已修复。P1:`should_listWorksWithFiltersGovernanceTenantIsolationAndNoBodyLeak` 先执行两次 admin read 后再调用测试自身 `deleteGovernanceActions()`,最后只按治理行数 0 做 no-write,可能遮蔽 read 路径新增/删除治理事实。P2:旧 `assertNoAdminReadWrites` 只比较 work/chapter/block/governance/command/outbox 行数,不能发现 admin read 意外 UPDATE 既有字段、revision、status、reason 或 update_time。修复方式:在 `P1rContentAdminReadCompletedApprovalIT` 中新增 `ContentFactSnapshot`,用 `row_to_json(t)::text` 对 `muse_content_work`、`muse_content_chapter`、`muse_content_block`、`muse_content_governance_action`、`muse_content_command_log`、`muse_content_event_publish_outbox` 做请求前后全表快照比对;每个 admin read happy/error/RBAC/API version 请求都包进 `assertNoContentMutationDuring`;riskFlag 空结果在测试自身删除治理事实后重新采样快照,不把测试 DML 混入 no-write 窗口。修复后已 fresh 重跑 `P1rContentAdminReadCompletedApprovalIT`,`5/5 pass`;Content combined DB gate `P1rContentAdminReadCompletedApprovalIT,P1rContentPlanningCompletedApprovalIT,P1rContentCoreCompletedApprovalIT,P1rContentEventsPublishFlywayMigrationIT` fresh 重跑 `32/32 pass`。当前需要重新派发 fresh implementation correctness/spec re-review + testing/data-integrity re-review;双 PASS 前不得提交、push 或宣称本轮收口。 P1R Content Admin Read Completed Approval implementation fresh re-review 已双 PASS:Peirce correctness/spec re-review PASS、Carver testing/data-integrity re-review PASS,均无 P0/P1/P2/P3 findings。Peirce 已验证正确 worktree、OpenAPI/Content main/java/SQL migration protected diff 为空、scanner 仅追加 3 个 Content admin read operation、coverage 为 `233/144/89/0/0/0`、Content 为 `13 completed / 38 needs_verification`、`adminRiskAction` 仍为 `dedicated/needs_verification/requiresCommandId=true`、mixed gate 防回退断言同步、Hegel P1/P2 已通过全表快照 no-write 关闭,且 `.agent`/memory 没有宣称 Content 51/51 或总 P1R completed。Carver 已验证 `ContentFactSnapshot` 用 `row_to_json(t)::text` 覆盖 work/chapter/block/governance_action/command_log/event_publish_outbox 的 INSERT/DELETE/UPDATE,所有 admin read happy/error、API version deny、RBAC deny 请求都包进 `assertNoContentMutationDuring`,riskFlag 空结果在测试自身 DML 后重新建立 no-write baseline;修复后主证据 `P1rContentAdminReadCompletedApprovalIT 5/5`、Content combined `32/32`、P1R mixed gates `49/49`、scanner `233/144/89` 均为 fresh 产物。Carver P3 residual 为 focused `ContentAdminServiceTest/AdminContentControllerTest` XML 早于 no-write 修复,但这两个源码本轮未改;该 residual 已通过重新运行 focused Content tests 关闭,fresh 结果为 `ContentAdminServiceTest 13/13`、`AdminContentControllerTest 8/8`,合计 `21/21 pass`。该双 PASS 只代表本轮 Content Admin Read 3 个 operation-level completed approval 具备收口候选条件;不代表 Content 51/51、Account remaining、Market remaining 或总 P1R completed。提交或 push 仍需用户明确指令。 + +P1R Content Admin RiskAction Completed Approval 已启动审阅版:`docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval审阅版.md`。本审阅版只推荐下一批 operation-level approval 为 `content:adminRiskAction` 一个治理写命令;不把 `content` 加入 domain-level completed allowlist,不推进 Content 51/51,不修改 OpenAPI、scanner、coverage report、Content 业务实现、SQL migration 或 gate test。只读盘点事实:当前 coverage 为 `233/144/89/0/0/0`,Content 为 `13 completed / 38 needs_verification`,`adminRiskAction` 为 `dedicated/needs_verification/requiresCommandId=true`,对应 `POST /admin-api/muse/content/works/{workId}/risk-actions`;OpenAPI `RiskActionRequest` 已要求 `commandId`、`action`、`reason`、`expectedVersion`,Controller 权限点为 `muse:content:risk-action`,Service 已有事务、command reserve、`selectByIdForUpdate`、expectedVersion 校验、targetScope/targetIds 归属校验、governance action insert 和 command/audit succeeded envelope。推荐目标值仅作为获批后执行目标:`233/145/88/0/0/0`,Content 目标为 `14 completed / 37 needs_verification`。下一步必须对该审阅版做 fresh spec/scope review + fresh quality/feasibility/testing review;双 PASS 前不得写执行版、修改 scanner/report/gate、提交、push 或推进 Content completed。 + +P1R Content Admin RiskAction Completed Approval 审阅版 fresh review 已双 PASS:Rawls spec/scope review PASS,无 P0/P1/P2/P3 findings,确认审阅版只覆盖 `content:adminRiskAction` 一个 operation-level approval,不推进 Content domain-level completed,不修改 OpenAPI/scanner/report/业务实现,目标 `233/145/88` 与 Content `14/37` 可由当前 `233/144/89` 与 Content `13/38` 加 1 个 operation 机械推导,非目标 37 个 Content operation 与 coverage 差集一致。Kierkegaard quality/feasibility/testing review PASS,无阻塞项,确认 MockMvc HTTP + real PostgreSQL `_test`、method security RBAC、tenant interceptor、command replay/conflict、expectedVersion/row lock、targetScope/targetIds 归属、governance fact、command/audit snapshot、no-write/rollback、Flyway/schema、XML 防空跑和 mixed gate 同步都可落地;非阻断注意为 OpenAPI 已声明 `expectedVersion` required,但生成 DTO 新鲜度不能单独作为合同证据,后续应以 OpenAPI + 服务端 VO validation + HTTP/DB 证据闭合。执行版已新增:`docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval执行版.md`。执行版只锁定 `content:adminRiskAction`,不把 `content` 加入 domain allowlist,不推进 Content 51/51,不修改 OpenAPI、Content main/java 或 SQL migration;目标 summary 仅作为获批后执行目标:`233/145/88/0/0/0`,Content 目标为 `14 completed / 37 needs_verification`。下一步必须 fresh execution spec/scope review + fresh execution quality/testing review;双 PASS 前不得实现、提交、push 或推进 Content completed。 + +P1R Content Admin RiskAction Completed Approval 执行版 fresh review 进展:Chandrasekhar execution spec/scope review PASS,无 findings,确认执行版严格限定 `content:adminRiskAction` 一个 operation-level completed approval、目标 `233/145/88` 与 Content `14/37` 可机械推导、allowed/prohibited diff 与 mixed gate 同步范围覆盖当前硬编码点。Aquinas execution quality/feasibility/testing review FAIL,3 个问题已按 `superpowers:receiving-code-review` 验证为有效并修订:P1 harness 缺必需 admin web/security bean 细节,已补 `@Bean("ss")` test security service、`ApiErrorLogCommonApi` stub、`MuseWebAutoConfiguration`、真实 `ContentCommandServiceImpl`、真实 `ContentAuditServiceImpl`、真实 `ContentAdminServiceImpl`、所有 Content mapper / facade stub 和 tenant interceptor 要求;P1 command/audit gate 漏 command envelope 行级事实,已补 `muse_content_command_log` 的 tenant_id、command_id、command_type、owner_user_id、target_type、target_id、request_hash,以及 `muse_content_governance_action` 的 tenant/operator/command/audit/action/target/expectedVersion/status 断言;P2 `_test` 数据库准备步骤不完整,已补 standalone 与 combined `_test` 建库命令、password env export 和非 `_test` 拒绝说明。由于执行版已在 review 后修订,Chandrasekhar PASS 需重新确认或至少由下一轮 spec/scope reviewer 基于最新文件复核;Aquinas FAIL 不能计入最终 gate。下一步必须重新派发 fresh execution quality/feasibility/testing review,必要时同步 fresh spec/scope re-review;双 PASS 前不得实现、提交、push 或推进 Content completed。 + +P1R Content Admin RiskAction Completed Approval 执行版 fresh re-review 已双 PASS:Peirce execution spec/scope re-review PASS,确认最新执行版仍只覆盖 `content:adminRiskAction` 一个 operation-level completed approval,不推进 Content domain-level completed,不夹带 admin import/export task、Content CRUD 写命令、OpenAPI、Content main/java 或 SQL migration,目标 `233/145/88` 与 Content `14/37` 可由当前 `233/144/89` 与 Content `13/38` 加 1 个 operation 机械推导;补充的 harness bean set、command/governance 行级断言和 standalone/combined `_test` 建库命令都仍落在测试与门禁范围内。Arendt execution quality/feasibility/testing re-review PASS,确认上一轮 3 项 FAIL 已关闭:执行版已补 exact admin harness bean set、command/audit row + snapshot gate、governance action row fact、standalone / combined `_test` DB 准备、Flyway system property restore、XML 防空跑和 V1-V21 schema gate;未发现会导致 fake pass 或启动失败的 P0/P1/P2/P3 blocker。该双 PASS 只代表执行版可进入用户批准点,不代表 `content:adminRiskAction` completed;下一步必须由用户明确批准四项执行边界后才允许 implementation,并且 implementation 后仍需 fresh implementation 双 review。 + +P1R Content Admin RiskAction Completed Approval implementation 当前状态:用户已批准执行版四项前置条件,本轮只把 `content:adminRiskAction` 一个治理写命令加入 operation-level completed approval,未把 `content` 加入 domain-level allowlist,未修改 OpenAPI、Content 业务实现或 SQL migration。scanner 仍保持 `APPROVED_COMPLETED_DOMAINS={"ai","knowledge"}`,只追加 `content:adminRiskAction`;coverage summary 已推进为 `233/145/88/0/0/0`,Content 为 `14 completed / 37 needs_verification`。admin import/export task、Content CRUD 写命令、AI planning candidate、style check、Meta projection、FileService、import/export/parse operation 仍保持 `needs_verification`。TDD RED 已验证旧 report 下 focused P1R gates 因目标 summary 仍为 144、`adminRiskAction` 仍为 `needs_verification` 而失败;GREEN 后 `python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` 通过并重生成 report。新增 `P1rContentAdminRiskActionCompletedApprovalIT` 使用 MockMvc HTTP 入口、真实 `AdminContentController` / `ContentAdminServiceImpl` / `ContentCommandServiceImpl` / `ContentAuditServiceImpl` / mapper、method security + test-local `ss` bean 与 PostgreSQL `_test` 库,单类验证 `8/8 pass`,覆盖安全配置拒绝、V1-V21 schema、`target_ids` JSONB、duplicate command SQLState 23505、`FOR UPDATE` 行锁合同、API version、RBAC、Bean Validation no-write、work/chapter/block happy path、status、replay、command conflict、revision conflict、unsupported action/scope、invalid targetIds、cross-work/cross-tenant target、missing/cross tenant work、command/governance 行级事实、command/audit snapshot 和全表 no-write / rollback。Content focused `ContentAdminServiceTest,AdminContentControllerTest` fresh 运行 `21/21 pass`;Content combined DB gate `P1rContentAdminRiskActionCompletedApprovalIT,P1rContentAdminReadCompletedApprovalIT,P1rContentPlanningCompletedApprovalIT,P1rContentCoreCompletedApprovalIT,P1rContentEventsPublishFlywayMigrationIT` 使用 `_test` 库 `muse_p1r_content_admin_risk_action_combined_test` fresh 运行 `40/40 pass`;P1R mixed gates fresh 运行 `49/49 pass`;XML 防空跑覆盖 15 个目标类全部 tests>0 且 failures/errors/skipped=0;`git diff --check` 通过;protected OpenAPI diff、Content main/java diff、SQL migration diff 均为空。fresh implementation review 已双 PASS:Gauss scope review PASS、Lagrange data-integrity/testing review PASS,均无 findings。新增 memory:`docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md`。当前具备提交候选条件,但仍不代表 Content 51/51、Account remaining、Market remaining 或总 P1R completed。 diff --git a/docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval审阅版.md b/docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval审阅版.md new file mode 100644 index 00000000..7171313d --- /dev/null +++ b/docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval审阅版.md @@ -0,0 +1,417 @@ +# P1R Content Admin RiskAction Completed Approval 审阅版 + +日期:2026-06-13 + +## 结论 + +推荐 Content 下一批只推进 1 个管理后台治理写命令的 operation-level completed approval: + +```text +content:adminRiskAction +``` + +不推荐把 Content 剩余 38 个 operation 一次性推进 completed,也不推荐把 `content` 加入 domain-level completed allowlist。 + +`adminRiskAction` 与刚收口的 Admin Read 3 个只读 operation 相邻,但它不是读模型。它是治理写路径,必须证明 `commandId`、`expectedVersion`、行锁、目标归属、治理事实写入、command/audit envelope、幂等 replay、冲突拒绝、RBAC、API version、tenant 隔离和事务回滚全部闭合后,才允许进入 completed approval。 + +本审阅版只冻结候选范围、证据标准、风险边界和后续执行版要求。不修改 OpenAPI,不修改 scanner,不修改 coverage report,不修改业务实现,不推进任何 operation completed。 + +```mermaid +flowchart TB + Current["当前 Content
13 completed / 38 needs_verification"] --> Candidate["下一包候选
adminRiskAction"] + Candidate --> Kind{"路径类型"} + Kind -->|"POST 写命令"| Evidence["必须补 completed-grade write evidence"] + Evidence --> E1["RBAC + API version"] + Evidence --> E2["commandId + replay + conflict"] + Evidence --> E3["expectedVersion + row lock"] + Evidence --> E4["targetScope / targetIds 归属"] + Evidence --> E5["governance fact + audit snapshot"] + Evidence --> E6["错误路径 no-write / rollback"] + Evidence --> Review["fresh spec/scope review
fresh quality/testing review"] + Review --> Exec["双 PASS 后写执行版"] + Exec --> Approval{"用户明确批准后才实现"} + Approval -->|"否"| Stay["保持 233/144/89"] + Approval -->|"是"| Target["目标 233/145/88
Content 14 completed / 37 needs_verification"] +``` + +## 当前事实状态 + +工作区: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +当前分支状态: + +```text +dev/1.0.0...origin/dev/1.0.0 +``` + +当前 HEAD: + +```text +b0b4b2c test(p1r): 收口 Content Admin Read completed approval 门禁 +``` + +当前 coverage summary: + +```text +total=233 +completed=144 +needsVerification=89 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +当前 domain 状态: + +```text +account completed=10 needsVerification=23 +content completed=13 needsVerification=38 +market completed=4 needsVerification=28 +meta completed=16 needsVerification=0 +``` + +目标 operation 当前状态: + +```text +adminRiskAction dedicated needs_verification requiresCommandId=true POST /admin-api/muse/content/works/{workId}/risk-actions +``` + +本审阅版推荐的下一批目标值只是后续获批后的执行目标: + +```text +total=233 +completed=145 +needsVerification=88 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 + +content total=51 +completed=14 +needsVerification=37 +``` + +## 推荐候选 + +| operation key | Method | Path | 推荐原因 | 必补 completed-grade evidence | +|---|---|---|---|---| +| `content:adminRiskAction` | `POST` | `/admin-api/muse/content/works/{workId}/risk-actions` | admin 治理写命令,当前实现已有 command reserve、expectedVersion、行锁、治理事实和 audit envelope,但仍缺 HTTP + 真实 DB completed approval 证据 | MockMvc HTTP + 真实 PostgreSQL `_test`,覆盖 RBAC、API version、commandId、expectedVersion、replay、command conflict、revision conflict、targetScope / targetIds 归属、治理事实、audit snapshot、tenant 隔离和 no-write / rollback | + +推荐理由: + +- 当前 coverage report 已识别 `adminRiskAction.requiresCommandId=true`,说明它天然属于写命令切片,不能和 admin read 混在一起。 +- Controller 使用 `@PreAuthorize("@ss.hasPermission('muse:content:risk-action')")`,权限点与 Admin Read 的 `muse:content:query` 不同,必须单独证明。 +- Service 使用 `@Transactional(rollbackFor = Exception.class)`,写路径应以事务内证据证明成功写入和失败回滚。 +- Service 先 `reserveCommand`,再 `selectByIdForUpdate` 锁定作品行并校验 `expectedVersion`,适合单独验证幂等、冲突和并发边界。 +- OpenAPI 已声明 `RiskActionRequest.required=[commandId, action, reason, expectedVersion]`,当前不需要为了本切片修改合同。 + +## 不纳入本批的 operation + +以下 37 个 Content operation 必须继续 `dedicated / needs_verification`: + +```text +content:adminListExportTasks +content:adminListImportTasks +content:confirmChapterParseResult +content:rejectChapterParseResult +content:downloadExportPackage +content:getExportTask +content:getImportTask +content:getParseJob +content:listParseJobChapters +content:batchConfirmChapters +content:retryParseJob +content:createWork +content:deleteWork +content:updateWork +content:deleteBlock +content:mergeBlocks +content:splitBlock +content:mergeBlockSuggestion +content:createChapter +content:deleteChapter +content:updateChapter +content:createBlock +content:reorderChapters +content:validateDynamicFields +content:exportWork +content:createExportTask +content:createImportTask +content:listMetaProjections +content:getMetaProjection +content:createParseJob +content:listPlanningCandidates +content:createPlanningCandidate +content:getPlanningCandidate +content:confirmPlanningCandidate +content:discardPlanningCandidate +content:createStyleCheck +content:getStyleCheckResult +``` + +排除原因: + +- `adminListImportTasks` / `adminListExportTasks` 是任务读模型,证据应覆盖导入导出任务状态、产物和 work 关联,不应夹入治理写命令。 +- 创建、更新、删除、重排、拆分、合并等 Content 写命令有各自的 revision、source snapshot、outbox 和结构一致性边界,应独立切片。 +- 导入、导出、解析、下载仍缺 FileService、任务产物和真实下载字节闭环 evidence。 +- Meta projection、AI planning candidate、style check、suggestion 等能力依赖 Meta / AI / Knowledge 外部 owner closure 和运行时证据。 + +## 已有实现证据 + +### OpenAPI 合同 + +OpenAPI 当前 operation: + +```text +POST /admin-api/muse/content/works/{workId}/risk-actions +operationId: adminRiskAction +security: adminBearerAuth +``` + +请求体 `RiskActionRequest`: + +```text +required: commandId, action, reason, expectedVersion +action enum: warn, restrict, block_content, force_archive, notify_owner +targetScope enum: work, chapter, block +targetIds: chapter 或 block 粒度必填 +expectedVersion: 作品当前版本号,用于乐观锁 +duration: restrict 动作限制时长 +``` + +响应体 `RiskActionResult`: + +```text +actionId +status: executed | pending_notification +affectedScope +auditLogId +``` + +本切片不需要修改 `docs/api-contracts/content/openapi.yaml`。 + +### Controller 入口 + +`AdminContentController.adminRiskAction`: + +```text +POST /muse/content/works/{workId}/risk-actions +@PreAuthorize("@ss.hasPermission('muse:content:risk-action')") +requireApiVersion() +contentAdminService.adminRiskAction(getLoginUserId(), workId, reqVO) +``` + +说明: + +- Controller-local mapping 是 `/muse/content/...`;对外 OpenAPI 路径仍以 `/admin-api/muse/...` 为准。 +- 执行版必须用当前项目既有 admin MockMvc / Web 测试上下文证明最终 admin API 路径可访问。 +- 权限证据必须来自启用 method security 的 HTTP 请求,不能只靠反射检查注解。 + +### Service 写链路 + +`ContentAdminServiceImpl.adminRiskAction` 当前链路: + +1. 校验 `expectedVersion` 非空。 +2. 用请求体和 `workId` 构造 scoped request hash。 +3. `ContentCommandService.reserveCommand(commandId, "adminRiskAction", operatorUserId, "work", workId, requestHash)`。 +4. 如果 command 已存在且 hash / target 匹配,返回 `resultSnapshot` replay。 +5. `WorkMapper.selectByIdForUpdate(workId, tenantId)` 锁定作品行。 +6. 校验 `work.revision == expectedVersion`。 +7. 校验 action、targetScope、targetIds。 +8. 对 chapter / block target 沿 `targetIds -> chapter/block -> work` 校验从属关系。 +9. 插入 `muse_content_governance_action`。 +10. 构造 `RiskActionResultRespVO`。 +11. `ContentCommandAuditService.recordSucceededWithAudit(...)` 记录 command/audit/result snapshot。 + +关键语义: + +- `notify_owner` 结果状态为 `pending_notification`。 +- 其它当前支持 action 结果状态为 `executed`。 +- `affectedScope` 对 work 粒度为 `work:{workId}`;chapter/block 粒度为 `{targetScope}:{targetIds}`。 +- 成功审计 target 仍绑定业务目标 work,不能改写成治理动作 id,否则 command replay 会被判为跨目标冲突。 + +### Schema 证据来源 + +V9 当前定义: + +```text +muse_content_governance_action +tenant_id +work_id +operator_user_id +action +reason +target_scope default work +target_ids jsonb +expected_version not null +status +audit_log_id +command_id not null +uk_muse_content_governance_action_command unique (tenant_id, command_id) +idx_muse_content_governance_action_work +trg_muse_content_governance_action_updated_at +``` + +执行版不需要新增 migration,但必须 fresh 证明 `_test` 库从 V1 到当前版本 clean migrate。当前 Content 已有 V21 event publish outbox,因此本切片的真实 schema gate 应至少证明 V1-V21 clean migrate 成功,并断言治理动作表、唯一约束、work 索引、JSONB `target_ids`、`tenant_id` / `expected_version` / `command_id` 关键列、updated_at trigger 真实存在。 + +## 当前测试证据与缺口 + +已有 focused tests 可作为辅助证据: + +- `AdminContentControllerTest.should_requireRbacAnnotationsForAdminContentApis` +- `AdminContentControllerTest.should_rejectRiskActionMissingReason` +- `AdminContentControllerTest.should_rejectRiskActionMissingExpectedVersionAtValidationLayer` +- `AdminContentControllerTest.should_createRiskActionWithCommonResult` +- `ContentAdminServiceTest.should_rejectRiskAction_when_expectedVersionMismatch` +- `ContentAdminServiceTest.should_rejectRiskAction_when_workMissing` +- `ContentAdminServiceTest.should_createRiskActionWithAudit` +- `ContentAdminServiceTest.should_rejectRiskAction_when_actionUnsupported` +- `ContentAdminServiceTest.should_rejectRiskAction_when_targetScopeUnsupported` +- `ContentAdminServiceTest.should_rejectRiskAction_when_chapterTargetIdsBelongToOtherWork` +- `ContentAdminServiceTest.should_rejectRiskAction_when_blockTargetIdsBelongToOtherWork` +- `ContentAdminServiceTest.should_replayRiskActionCommand` + +这些测试不能直接等同 completed。缺口包括: + +- 没有 HTTP + 真实 PostgreSQL `_test` 的端到端证据。 +- 缺 method security 真实拒绝路径证据。 +- 缺真实 tenant interceptor 下的 cross tenant 不可见 / no-write 证据。 +- 缺 command table、governance action、Content outbox、work/chapter/block 的全表快照或关键字段 no-write / rollback 证据。 +- 缺 V1-V21 clean migrate 后 `muse_content_governance_action` / `muse_content_command_log` schema、唯一键、JSONB、trigger、tenant 关键列证据与 XML 防空跑。 +- 缺 command replay / command conflict 的真实 DB 证据,尤其同 commandId 不同 action、reason、targetScope、targetIds、workId、expectedVersion。 +- 缺并发/行锁语义的可接受代理证据,例如 `FOR UPDATE` SQL 与真实事务 revision stale / conflict 场景。 +- 缺 P1R scanner/report/gate 在获批后从 `233/144/89` 推进到 `233/145/88` 的 RED/GREEN 证据。 + +## 必补证据标准 + +后续执行版必须至少覆盖以下矩阵。 + +### HTTP / 权限 / 版本 + +- 有 `X-API-Version` 的成功请求。 +- 缺或不支持 `X-API-Version` 的统一错误响应。 +- 有 `muse:content:risk-action` 权限时允许进入 service。 +- 缺 `muse:content:risk-action` 权限时 403,且不写 command、governance action、outbox 或业务表。 +- 登录 operator user id 必须进入 `operator_user_id` 和 command/audit 事实。 +- command/audit snapshot 必须显式断言 `operationId=adminRiskAction`、`side=admin`、`targetType=work`、`targetId=workId`、`status=succeeded`、`reason`、`requestSummary.action`、`requestSummary.targetScope`、`requestSummary.targetIds`、`requestSummary.expectedVersion` 和 `result.actionId/status/affectedScope/auditLogId`。 + +### 成功写路径 + +- work 粒度 happy path:写入 1 条 governance action,写入 1 条 command/audit envelope,返回 actionId/status/affectedScope/auditLogId。 +- chapter 粒度 happy path:targetIds 必须属于 URL work,返回 chapter affected scope。 +- block 粒度 happy path:targetIds 必须沿 block -> chapter -> work 归属到 URL work。 +- `notify_owner` 返回 `pending_notification`。 +- `block_content` / `warn` / `restrict` / `force_archive` 返回 `executed`。 +- 结果 snapshot 可被 replay 解析,重复同 commandId / 同请求返回同一结果,不重复插入治理事实。 + +### command / revision / target 负路径 + +- 缺 `commandId` validation 失败,不写任何事实。 +- 缺 `expectedVersion` validation 失败,不写任何事实。 +- `expectedVersion` 与 work revision 不一致,返回 revision conflict,不写治理事实和 succeeded audit。 +- 同 commandId 不同 action、reason、targetScope、targetIds、workId 或 expectedVersion 返回 command conflict,不写第二条治理事实。 +- 同 commandId 不同 workId 返回 command conflict 或目标冲突,不泄露目标数据。 +- unsupported action 返回统一错误并 no-write。 +- unsupported targetScope 返回统一错误并 no-write。 +- chapter targetIds 为空、含 null、missing、属于其它 work、属于其它 tenant,均拒绝并 no-write。 +- block targetIds 为空、含 null、missing、属于其它 work、章节链路不匹配、属于其它 tenant,均拒绝并 no-write。 +- missing work / cross tenant work 返回不可见或 not found 语义,不写任何事实。 + +### no-write / rollback + +错误路径必须对以下表做全表快照或关键字段快照,不只比较行数: + +```text +muse_content_work +muse_content_chapter +muse_content_block +muse_content_governance_action +muse_content_command_log +muse_content_event_publish_outbox +``` + +说明: + +- 成功写路径允许 governance action 与 command/audit 变化。 +- 错误路径必须证明上述表没有 INSERT / DELETE / UPDATE 污染。 +- 如果测试自身需要 seed 或清理,必须在 no-write baseline 采样之前完成。 + +### Flyway / schema + +执行版必须 fresh 证明: + +- `_test` 库只使用测试库名,禁止连接非 `_test`。 +- 密码只从环境变量读取,禁止通过 JVM system property 或 JDBC query 传入。 +- V1-V21 clean migrate 成功。 +- `muse_content_governance_action` 表、关键列、JSONB `target_ids`、unique command 约束、work 索引和 updated_at trigger 存在。 +- duplicate `(tenant_id, command_id)` 被数据库唯一约束拒绝。 + +## 风险和取舍 + +1. `adminRiskAction` 不是 Admin Read 的延伸证据。 + + Admin Read 已证明治理事实可读;它不能证明治理事实写入、command replay、expectedVersion、行锁和 audit snapshot。 + +2. `selectByIdForUpdate` 是本切片的核心边界。 + + 执行版必须证明 expectedVersion 校验发生在同一事务的锁定作品行之后;否则 completed 证据不足以支撑并发写路径。 + +3. `targetIds` 归属不能只查目标 id 存在。 + + chapter 必须属于 URL work;block 必须同时满足 block.workId 与 block.chapterId 对应 chapter.workId 都属于 URL work。 + +4. command replay 不能只证明不报错。 + + 必须证明 replay 返回原 `resultSnapshot`,且没有重复插入治理事实、command/audit 或 outbox。 + +5. 不应新增 Content audit 表。 + + 当前 Content audit snapshot 保存在 `muse_content_command_log.result_snapshot` / command audit envelope 中。执行版不得为了测试方便新增表或修改 schema。 + +6. 不把生成 DTO 当成唯一合同证据。 + + OpenAPI 已声明 `expectedVersion` required,服务端 `RiskActionReqVO` 也有 `@NotNull` validation。后续执行版如果引用生成物或客户端 DTO,必须说明其新鲜度和同步关系;completed 证据应以 OpenAPI、服务端 VO validation、HTTP 请求和真实 DB 结果闭合。 + +## 后续执行版要求 + +后续执行版必须包含: + +1. 精确目标 operation 清单:只包括 `content:adminRiskAction`。 +2. operation-level approval:不把 `content` 加入 domain allowlist。 +3. 实施前 RED:旧 report 下 P1R coverage gate 必须因 `adminRiskAction` 尚未 completed 而失败。 +4. HTTP + real PostgreSQL `_test` gate:新增独立 `P1rContentAdminRiskActionCompletedApprovalIT` 或等价命名。 +5. Web/MyBatis/DataSource 测试上下文:必须启用真实 tenant interceptor、真实 mapper、真实 service、真实 controller 和 method security。 +6. RBAC / API version / CommonResult 错误响应证据。 +7. happy / replay / command conflict / revision conflict / validation / missing / cross tenant / target mismatch / no-write matrix。 +8. focused tests:运行 `ContentAdminServiceTest`、`AdminContentControllerTest` 并做 XML 防空跑。 +9. P1R mixed gates:同步所有读取全局 summary、Content 状态或非目标 domain 防回退的 gate。 +10. scanner/report 更新策略:只在用户批准后追加 1 个 `content:adminRiskAction` operation-level allowlist。 +11. protected diff:OpenAPI、Content main/java、SQL migration 默认不得修改。 +12. `.agent` 和 `docs/memorys` 留痕。 +13. rollback:撤回 scanner operation key、coverage report、P1R gate 目标值和新增 `_test`,恢复 `adminRiskAction` 为 `needs_verification`。 + +## 审批前置条件 + +进入执行版前需要 fresh review 双 PASS: + +- fresh spec/scope review:确认只覆盖 `content:adminRiskAction`,不夹带 admin import/export task、Content CRUD 写命令、Content domain-level completed 或其它 domain。 +- fresh quality/feasibility/testing review:确认 RBAC、tenant 拦截、command replay、expectedVersion、targetScope / targetIds、no-write / rollback、schema gate、XML 防空跑和 mixed gate 同步可落地。 + +执行版双 PASS 后,仍必须由用户明确批准以下事项后才允许实现: + +1. 只审批 `content:adminRiskAction`。 +2. 继续 operation-level approval,不把 `content` 加入 domain allowlist。 +3. 允许按执行版修改 scanner、coverage report、P1R gate tests、新增 HTTP+DB `_test` 和 memory。 +4. 允许同步 P1R mixed gate 的 summary / Content / 非目标域防回退断言。 + +## 本审阅版验收标准 + +- 候选 operation 和目标值可由当前 report 机械推导。 +- 非目标 37 个 Content operation 明确保持 `needs_verification`。 +- `adminRiskAction` 与 Admin Read、admin import/export task、Content CRUD 写命令的边界清晰。 +- evidence 标准包含 RBAC、tenant 隔离、commandId、expectedVersion、行锁、target 归属、governance action、audit snapshot、replay、conflict、no-write / rollback 和 XML 防空跑。 +- 未修改 OpenAPI、scanner、coverage report、业务实现、SQL migration 或 P1R gate。 diff --git a/docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval执行版.md b/docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval执行版.md new file mode 100644 index 00000000..b7ff4a04 --- /dev/null +++ b/docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval执行版.md @@ -0,0 +1,615 @@ +# P1R Content Admin RiskAction Completed Approval 执行版 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task only after fresh execution review double PASS and explicit user approval. Steps use checkbox syntax for tracking. + +日期:2026-06-13 + +## 结论 + +本执行版只锁定 Content 管理后台治理写命令 1 个 operation-level completed approval: + +```text +content:adminRiskAction +``` + +本轮不把 `content` 加入 domain-level completed allowlist,不推进 Content 51/51 completed,不修改 OpenAPI,不修改 Content 业务实现,不修改 SQL migration。 + +获批后目标 coverage summary 为: + +```text +total=233 +completed=145 +needsVerification=88 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +Content 域目标状态为: + +```text +content total=51 +completed=14 +needsVerification=37 +``` + +当前阶段只允许写执行方案并进入 fresh execution review。执行版 review 双 PASS 后,仍必须由用户再次明确批准审批清单、operation-level 边界、allowed diff 和 mixed gate 同步范围,才允许实施。 + +```mermaid +flowchart TB + Current["当前 Content
13 completed / 38 needs_verification"] --> Exec["执行版
锁定 adminRiskAction 1 op"] + Exec --> Review["fresh execution spec/scope review
fresh execution quality/testing review"] + Review --> Approval{"用户明确批准
1 op / operation-level / allowed diff / mixed gates"} + Approval -->|否| Stay["保持当前 coverage
233/144/89"] + Approval -->|是| Red["TDD RED
先改 gate 期望
旧 report 必须失败"] + Red --> Runtime["HTTP + real DB _test
RBAC + tenant + command + audit + rollback"] + Runtime --> Green["scanner operation allowlist
regenerate report"] + Green --> Verify["focused tests + P1R mixed gates
XML 防空跑 + protected diff"] + Verify --> FreshReview["fresh implementation review"] +``` + +## 当前事实 + +正确 worktree: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +当前 HEAD: + +```text +b0b4b2c test(p1r): 收口 Content Admin Read completed approval 门禁 +``` + +当前分支: + +```text +dev/1.0.0...origin/dev/1.0.0 +``` + +当前 coverage summary: + +```text +233 144 89 0 0 0 +``` + +当前 domain 状态: + +```text +account completed=10 needsVerification=23 +content completed=13 needsVerification=38 +market completed=4 needsVerification=28 +meta completed=16 needsVerification=0 +``` + +当前目标 operation 状态: + +```text +adminRiskAction dedicated needs_verification true POST /admin-api/muse/content/works/{workId}/risk-actions +``` + +已验证的只读实现事实: + +- OpenAPI `RiskActionRequest.required` 已包含 `commandId`、`action`、`reason`、`expectedVersion`。 +- `AdminContentController.adminRiskAction` 使用 `@PreAuthorize("@ss.hasPermission('muse:content:risk-action')")`,并调用 `requireApiVersion()`。 +- Controller-local mapping 为 `/muse/content/works/{workId}/risk-actions`;对外 OpenAPI 路径为 `/admin-api/muse/content/works/{workId}/risk-actions`。 +- `ContentAdminServiceImpl.adminRiskAction` 使用 `@Transactional(rollbackFor = Exception.class)`。 +- Service 先通过 `ContentCommandService.reserveCommand(...)` 处理 commandId 和 replay,再通过 `WorkMapper.selectByIdForUpdate(workId, tenantId)` 锁定作品行。 +- Service 在锁定作品行后校验 `expectedVersion`、action、targetScope、targetIds,并校验 chapter / block target 归属 URL work。 +- 成功路径插入 `muse_content_governance_action`,并通过 `ContentCommandAuditService.recordSucceededWithAudit(...)` 写 command/audit/result snapshot。 +- 当前实现不写 `muse_content_event_publish_outbox`;本切片不证明 Events propagation。 +- 现有 `AdminContentControllerTest` / `ContentAdminServiceTest` 只能作为 focused 辅助证据,不能替代 completed-grade HTTP + real PostgreSQL `_test`。 + +## 执行边界 + +### 必须先获用户明确批准 + +实现前必须同时获得以下 4 项批准: + +1. 批准本轮只审批 `content:adminRiskAction` 一个 operation。 +2. 批准继续使用 operation-level approval,不把 `content` 加入 domain-level completed allowlist。 +3. 批准按本执行版修改 scanner、coverage report、P1R gates、新增 Admin RiskAction HTTP+DB `_test`、memory 和 `.agent`。 +4. 批准同步 mixed gate 的 summary / Content / 非目标域防回退断言,目标为 `233/145/88/0/0/0` 与 Content `14 completed / 37 needs_verification`。 + +未获上述批准前,不得实施本执行版。 + +### 本轮允许变更 + +获批后只允许修改以下路径: + +- `muse-cloud/scripts/p1r-audit-api-coverage.py` +- `docs/superpowers/reports/p1r-api-coverage.json` +- `docs/superpowers/reports/p1r-api-coverage.md` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java` +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentAdminServiceTest.java`,仅限补充本执行版缺失的 focused behavior 证据。 +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/controller/admin/AdminContentControllerTest.java`,仅限补充本执行版缺失的 controller contract 证据。 +- `docs/agent-specs/.agent` +- `docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval审阅版.md` +- `docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval执行版.md` +- `docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md` + +如 implementation 发现必须修改 Content 业务实现、OpenAPI、SQL migration、非批准 gate 或其它文件,必须停下说明原因并重新取得用户批准。 + +### 本轮禁止变更 + +- 不修改 7 个 OpenAPI。 +- 不修改 Content main/java 业务实现来掩盖 coverage 缺口。 +- 不新增或修改 SQL migration。 +- 不把 `content` 加入 domain-level completed allowlist。 +- 不推进 Content 51/51 completed。 +- 不推进 Account remaining 23、Market remaining 28、Content remaining 37 或总 P1R completed。 +- 不推进 admin import/export task、Content CRUD 写命令、AI planning candidate、style check、Meta projection、dynamic fields、FileService、import/export/parse operation。 +- 不把 Admin Read 的 riskFlag 聚合证据当成 `adminRiskAction` completed 证据。 +- 不把 dedicated gate PASS、review PASS、文档结论或已有 Mockito 单测直接等同 completed。 + +## 审批清单 + +| operation key | Method | Path | 目标状态 | 完成证据边界 | +|---|---|---|---|---| +| `content:adminRiskAction` | `POST` | `/admin-api/muse/content/works/{workId}/risk-actions` | `dedicated / completed` | MockMvc HTTP + 真实 PostgreSQL `_test`,覆盖 API version、method security RBAC、commandId、replay、command conflict、expectedVersion、row lock、targetScope / targetIds 归属、governance action、command/audit snapshot、tenant 隔离、schema gate、no-write / rollback | + +## 保持 needs_verification 的 Content operation + +以下 37 个 operation 必须保持 `dedicated / needs_verification`: + +```text +content:adminListExportTasks +content:adminListImportTasks +content:confirmChapterParseResult +content:rejectChapterParseResult +content:downloadExportPackage +content:getExportTask +content:getImportTask +content:getParseJob +content:listParseJobChapters +content:batchConfirmChapters +content:retryParseJob +content:createWork +content:deleteWork +content:updateWork +content:deleteBlock +content:mergeBlocks +content:splitBlock +content:mergeBlockSuggestion +content:createChapter +content:deleteChapter +content:updateChapter +content:createBlock +content:reorderChapters +content:validateDynamicFields +content:exportWork +content:createExportTask +content:createImportTask +content:listMetaProjections +content:getMetaProjection +content:createParseJob +content:listPlanningCandidates +content:createPlanningCandidate +content:getPlanningCandidate +content:confirmPlanningCandidate +content:discardPlanningCandidate +content:createStyleCheck +content:getStyleCheckResult +``` + +## 证据分层 + +| Evidence | 文件 / 测试 | 目的 | +|---|---|---| +| Operation inventory / coverage gate | `P1rApiCoverageReportTest`、`P1rContentRealApiGateTest` 和 mixed gates | 证明只推进获批 1 个 operation,非目标 domain 不回退 | +| Admin RiskAction HTTP+DB `_test` | 新增 `P1rContentAdminRiskActionCompletedApprovalIT` | 证明 `adminRiskAction` 经 MockMvc HTTP 入口访问真实 service / mapper / PostgreSQL `_test` 数据 | +| Tenant / schema gate | `P1rContentAdminRiskActionCompletedApprovalIT` 内验证 V1-V21 clean migrate 与 governance action 依赖 schema | 证明 Work / Chapter / Block / GovernanceAction / CommandLog / Outbox schema 和 tenant interceptor 真实可用 | +| Method security gate | `P1rContentAdminRiskActionCompletedApprovalIT` 内启用 method security 与 `ss` bean | 证明 admin RBAC 缺权限拒绝不是注解反射假证据 | +| Command / audit gate | `P1rContentAdminRiskActionCompletedApprovalIT` 内读取 `muse_content_command_log.result_snapshot` | 证明 operationId、side、target、requestSummary、result snapshot 和 replay 语义真实落库 | +| Focused unit/controller tests | `ContentAdminServiceTest`、`AdminContentControllerTest` | 支撑 service/controller contract,不替代 HTTP+DB `_test` | + +## TDD / 实施步骤 + +### Task 1:Preflight 与 RED gate + +- [ ] 确认当前 worktree: + +```bash +git -c core.quotePath=false status --short --branch +``` + +期望: + +```text +## dev/1.0.0...origin/dev/1.0.0 +``` + +允许存在本执行版和审阅版文档 diff;不得存在 OpenAPI、业务实现、SQL、scanner、coverage report 或 gate test 的未授权 diff。 + +- [ ] 确认当前 coverage: + +```bash +jq -r '.summary | [.totalOperations,.completedOperations,.needsVerificationOperations,.incompleteOperations,.genericPersistenceOperations,.ssePlaceholderOperations] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +jq -r '.operations[] | select(.domain=="content" and .operationId=="adminRiskAction") | [.operationId,.implementationStatus,.completionStatus,(.requiresCommandId|tostring),.method,.path] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +期望: + +```text +233 144 89 0 0 0 +adminRiskAction dedicated needs_verification true POST /admin-api/muse/content/works/{workId}/risk-actions +``` + +- [ ] TDD RED:先修改 P1R gate 期望值和 `adminRiskAction` completed 断言,但不修改 scanner/report。 + +RED 目标: + +- `P1rApiCoverageReportTest` 期望 summary `completed=145 / needsVerification=88`。 +- `P1rContentRealApiGateTest` 期望 Content `14 completed / 37 needs_verification`,`adminRiskAction` completed。 +- 7 个 mixed gate 中所有硬编码 Content `13/38` 或 summary `144/89` 的断言同步为 `14/37` 或 `145/88`。 + +RED 命令: + +```bash +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rApiCoverageReportTest,P1rContentRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest,P1rMarketRealApiGateTest,P1rAccountRealApiGateTest,P1rMetaRealApiGateTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +期望:BUILD FAILURE。失败点必须来自旧 report 仍是 `233/144/89` 或 `adminRiskAction` 仍为 `needs_verification`;不得来自编译错误或无关测试失败。 + +### Task 2:新增 Admin RiskAction HTTP+DB `_test` + +- [ ] 新增: + +```text +muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java +``` + +测试上下文要求: + +- 使用 `@SpringBootTest(webEnvironment = MOCK)` + `WebApplicationContext` + `MockMvcBuilders.webAppContextSetup(...)`。 +- 真实 `/admin-api/muse/**` 前缀进入 `AdminContentController`。 +- 导入 `MuseWebAutoConfiguration`,并提供 test-local `ApiErrorLogCommonApi` stub,返回 `CommonResult.success(true)`,满足 `GlobalExceptionHandler` 依赖。 +- 启用 `@EnableMethodSecurity(securedEnabled = true)`,并提供 `@Bean("ss")` 的 test-local `SecurityFrameworkService`,权限点必须是 `muse:content:risk-action`。 +- 使用真实 `ContentAdminServiceImpl`、`ContentCommandServiceImpl`、`ContentAuditServiceImpl`、真实 mapper 和真实 PostgreSQL `_test` 数据库。 +- 注册真实 MyBatis tenant interceptor:`TenantLineInnerInterceptor` + `TenantDatabaseInterceptor(new TenantProperties())`。 +- 所有 `ContentAdminServiceImpl` 的 `@Resource` 依赖必须可启动;不得只注册 Work/Chapter/Block/GovernanceAction mapper 后让上下文靠 mock 补洞。 +- Configuration 必须导入所有 ContentAdminServiceImpl 字段依赖需要的 mapper / service / facade stub;如果某个 facade 不属于本切片,应提供 fail-fast stub,并证明本切片未调用它。 +- `TARGET_VERSION="21"`,执行 V1-V21 clean migrate。 +- 数据库密码只能来自环境变量,禁止 JVM system property 或 JDBC query 参数传入。 +- 保存并在 `@AfterAll` 恢复 `p1r.flyway.url` / `p1r.flyway.user` system property,避免污染同一 Surefire JVM 后续 Flyway 测试。 + +建议测试库: + +```text +muse_p1r_content_admin_risk_action_completed_approval_test +``` + +建议环境变量优先级: + +```text +P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_PASSWORD +P1R_CONTENT_COMPLETED_PASSWORD +P1R_FLYWAY_PASSWORD +MUSE_POSTGRES_PASSWORD +``` + +standalone 建库与运行前置: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_APPROVAL_TEST_DB=muse_p1r_content_admin_risk_action_completed_approval_test + +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -tc "SELECT 1 FROM pg_database WHERE datname = '$P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_APPROVAL_TEST_DB'" | grep -q 1 || \ +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -c "CREATE DATABASE $P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_APPROVAL_TEST_DB" +``` + +执行版 implementation 必须显式拒绝非 `_test` 数据库名;如果建库变量不是 `_test` 后缀,测试必须在连接前失败。 + +- [ ] 新增基础 seed 与快照工具。 + +seed 要求: + +- 当前 tenant 的 work,revision 固定为 `7`。 +- 当前 tenant 下属于该 work 的 chapter / block。 +- 当前 tenant 下另一个 work、另一个 chapter / block,用于 cross-work target 拒绝。 +- 另一个 tenant 的 work / chapter / block,用于 tenant 隔离。 +- 当前 login user 为 admin user,tenant 与 visitTenant 都设置为当前 tenant。 + +错误路径 no-write 快照必须覆盖: + +```text +muse_content_work +muse_content_chapter +muse_content_block +muse_content_governance_action +muse_content_command_log +muse_content_event_publish_outbox +``` + +快照必须用 `row_to_json(t)::text` 或等价关键字段快照,不能只比对行数。 + +- [ ] mandatory test matrix。 + +最低测试类行为: + +1. 拒绝 unsafe DB 配置:非 `_test` 数据库、JVM password system property、JDBC credential query。 +2. 验证 schema:V1-V21 clean migrate;`muse_content_governance_action` 表、关键列、JSONB `target_ids`、unique command 约束、work 索引、updated_at trigger 存在;duplicate `(tenant_id, command_id)` 返回 SQLState `23505`。 +3. API version / RBAC:缺或不支持 `X-API-Version` no-write;缺 `muse:content:risk-action` 权限返回 403 CommonResult 且 no-write;成功请求必须证明 `ss.hasPermission("muse:content:risk-action")` 被调用。 +4. work scope happy path:成功写 1 条 governance action 和 1 条 command/audit envelope,返回 `actionId/status/affectedScope/auditLogId`;`affectedScope=work:{workId}`;`outbox` 不写。 +5. chapter / block scope happy path:targetIds 属于 URL work 才能成功;chapter 与 block affectedScope 正确。 +6. action status:`notify_owner` 返回 `pending_notification`;`warn` / `restrict` / `block_content` / `force_archive` 返回 `executed`。 +7. command replay:同 commandId 同请求返回同一 `resultSnapshot`,不重复插入 governance action、command/audit 或 outbox。 +8. command conflict:同 commandId 不同 action、reason、targetScope、targetIds、workId 或 expectedVersion 返回 command conflict,且 no-write。 +9. revision conflict:`expectedVersion` 与 work revision 不一致返回 `CONTENT_REVISION_CONFLICT`,且 no-write。 +10. validation / unsupported:缺 commandId、缺 expectedVersion、unsupported action、unsupported targetScope 均拒绝并 no-write。 +11. targetIds invalid:chapter / block targetIds 为空、含 null、missing、cross-work、cross-tenant、block -> chapter -> work 链路不匹配,均拒绝并 no-write。 +12. missing / cross tenant work:返回 not found 或不可见语义,不泄露其它 tenant 数据,且 no-write。 +13. row-lock proxy:至少用 `WorkMapper.selectByIdForUpdate` 源码 / mapper SQL 断言包含 `FOR UPDATE`,并用真实 DB stale expectedVersion 场景证明锁后 revision 校验会拒绝并 rollback。若要做真实并发事务测试,必须保证可重复、无 sleep 竞态和可诊断日志。 +14. command/audit row fact:断言 `muse_content_command_log` 行级字段 `tenant_id`、`command_id`、`command_type=adminRiskAction`、`owner_user_id=admin user`、`target_type=work`、`target_id=workId`、`request_hash` 非空且 replay 时稳定。 +15. governance action row fact:断言 `muse_content_governance_action` 行级字段 `tenant_id`、`work_id`、`operator_user_id=admin user`、`command_id`、`audit_log_id`、`action`、`reason`、`target_scope`、`target_ids`、`expected_version`、`status`。 +16. command/audit snapshot:解析 `muse_content_command_log.result_snapshot`,断言 `operationId=adminRiskAction`、`side=admin`、`targetType=work`、`targetId=workId`、`status=succeeded`、`reason`、`requestSummary.action`、`requestSummary.targetScope`、`requestSummary.targetIds`、`requestSummary.expectedVersion`、`result.actionId/status/affectedScope/auditLogId`。 + +### Task 3:Focused tests + +- [ ] 运行 Content focused tests: + +```bash +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-module-content/muse-module-content-server,muse-server -am \ + -Dtest=ContentAdminServiceTest,AdminContentControllerTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +期望: + +```text +ContentAdminServiceTest: pass +AdminContentControllerTest: pass +BUILD SUCCESS +``` + +如执行版实现发现 focused tests 缺少审阅版硬门槛,可只在允许范围内补充测试,不得改 main/java。 + +### Task 4:GREEN scanner / report + +- [ ] scanner 只追加 operation-level key: + +```text +content:adminRiskAction +``` + +禁止修改: + +```text +APPROVED_COMPLETED_DOMAINS +``` + +- [ ] 重新生成 coverage report: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +期望 summary: + +```text +233 145 88 0 0 0 +``` + +期望 Content: + +```text +completed=14 +needsVerification=37 +``` + +期望 operation: + +```text +adminRiskAction dedicated completed true +``` + +仍必须抽样断言这些非目标 operation 保持 `needs_verification`: + +```text +adminListImportTasks +adminListExportTasks +createWork +updateWork +createChapter +mergeBlockSuggestion +exportWork +listPlanningCandidates +createStyleCheck +getStyleCheckResult +``` + +### Task 5:P1R mixed gates + +- [ ] 运行 P1R mixed gates: + +```bash +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rApiCoverageReportTest,P1rContentRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest,P1rMarketRealApiGateTest,P1rAccountRealApiGateTest,P1rMetaRealApiGateTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +期望: + +- `P1rApiCoverageReportTest` summary 为 `233/145/88/0/0/0`。 +- `P1rContentRealApiGateTest` Content 为 `14/37`,`adminRiskAction` completed。 +- `P1rEventsRealApiGateTest`、`P1rAiRealApiGateTest`、`P1rKnowledgeRealApiGateTest`、`P1rMarketRealApiGateTest`、`P1rAccountRealApiGateTest`、`P1rMetaRealApiGateTest` 内所有 Content partial approval 防回退断言同步为 `14/37`。 +- AI 41、Knowledge 59、Events 1、Meta 16、Account 10、Market 4 状态不回退。 + +### Task 6:Content combined DB gate + +- [ ] 使用独立 `_test` 库组合运行 Content completed approval DB gates: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_ADMIN_RISK_ACTION_COMBINED_TEST_DB=muse_p1r_content_admin_risk_action_combined_test + +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -tc "SELECT 1 FROM pg_database WHERE datname = '$P1R_CONTENT_ADMIN_RISK_ACTION_COMBINED_TEST_DB'" | grep -q 1 || \ +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -c "CREATE DATABASE $P1R_CONTENT_ADMIN_RISK_ACTION_COMBINED_TEST_DB" + +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rContentAdminRiskActionCompletedApprovalIT,P1rContentAdminReadCompletedApprovalIT,P1rContentPlanningCompletedApprovalIT,P1rContentCoreCompletedApprovalIT,P1rContentEventsPublishFlywayMigrationIT \ + -Dflyway.postgresql.transactional.lock=false \ + -Dp1r.flyway.locations=filesystem:sql/muse \ + -Dp1r.flyway.url="jdbc:postgresql://$MUSE_POSTGRES_HOST:$MUSE_POSTGRES_PORT/$P1R_CONTENT_ADMIN_RISK_ACTION_COMBINED_TEST_DB" \ + -Dp1r.flyway.user="$MUSE_POSTGRES_USERNAME" \ + -Djava.net.useSystemProxies=false \ + -DsocksProxyHost= -DsocksProxyPort= \ + -Dhttp.proxyHost= -Dhttp.proxyPort= \ + -Dhttps.proxyHost= -Dhttps.proxyPort= \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +要求: + +- 所有类 tests > 0。 +- failures=0、errors=0、skipped=0。 +- 证明新增 Admin RiskAction IT 不污染同 JVM 的 Flyway system properties。 +- 如 first run 暴露 password env 缺失或 system property 污染,必须修复后重跑,并记录失败根因与修复。 + +### Task 7:XML 防空跑 + +- [ ] 检查以下 XML 必须存在、mtime 在本轮验证窗口内、tests > 0、failures/errors/skipped 均为 0: + +```text +P1rContentAdminRiskActionCompletedApprovalIT +P1rContentAdminReadCompletedApprovalIT +P1rContentPlanningCompletedApprovalIT +P1rContentCoreCompletedApprovalIT +P1rContentEventsPublishFlywayMigrationIT +ContentAdminServiceTest +AdminContentControllerTest +P1rApiCoverageReportTest +P1rContentRealApiGateTest +P1rEventsRealApiGateTest +P1rAiRealApiGateTest +P1rKnowledgeRealApiGateTest +P1rMarketRealApiGateTest +P1rAccountRealApiGateTest +P1rMetaRealApiGateTest +``` + +防空跑脚本可用 shell / awk / xmllint / existing local pattern,但不得把 XML 旧产物当 fresh evidence。 + +### Task 8:Protected diff / final checks + +- [ ] 运行: + +```bash +git diff --check +``` + +- [ ] 确认 protected diff 为空: + +```bash +git diff --name-only -- docs/api-contracts muse-cloud/muse-module-content/muse-module-content-server/src/main/java muse-cloud/sql/muse +``` + +期望:无输出。 + +- [ ] 确认 scanner/report 目标: + +```bash +jq -r '.summary | [.totalOperations,.completedOperations,.needsVerificationOperations,.incompleteOperations,.genericPersistenceOperations,.ssePlaceholderOperations] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +jq -r '.operations[] | select(.domain=="content" and (.operationId=="adminRiskAction" or .operationId=="adminListExportTasks" or .operationId=="adminListImportTasks" or .operationId=="createWork" or .operationId=="exportWork" or .operationId=="createStyleCheck")) | [.operationId,.implementationStatus,.completionStatus,(.requiresCommandId|tostring)] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +期望: + +```text +233 145 88 0 0 0 +adminRiskAction dedicated completed true +adminListExportTasks dedicated needs_verification false +adminListImportTasks dedicated needs_verification false +createWork dedicated needs_verification false +exportWork dedicated needs_verification false +createStyleCheck dedicated needs_verification true +``` + +## Review gate + +本执行版写完后必须先进行 fresh execution review: + +1. fresh execution spec/scope review: + - 确认只覆盖 `content:adminRiskAction`。 + - 确认不夹带 admin import/export task、Content CRUD 写命令、Content domain-level completed 或其它 domain。 + - 确认目标值 `233/145/88` 与 Content `14/37` 能从当前 `233/144/89` 与 Content `13/38` 加 1 个 operation 机械推导。 + - 确认 allowed / prohibited diff 自洽。 + +2. fresh execution quality/feasibility/testing review: + - 确认 HTTP+DB `_test` harness 可复用现有 Admin Read / Planning 模式落地。 + - 确认 method security、tenant interceptor、command replay/conflict、expectedVersion/row lock、targetScope / targetIds、governance action、command/audit snapshot、no-write / rollback、schema gate、XML 防空跑和 mixed gate 同步都可验证。 + - 确认不需要改 OpenAPI、Content main/java 或 SQL migration。 + +双 PASS 后,仍不代表 `adminRiskAction` completed。必须由用户明确批准执行边界后才允许进入 implementation。 + +## Rollback + +如果实现后需要回滚本切片: + +1. 从 `APPROVED_COMPLETED_OPERATIONS` 删除 `content:adminRiskAction`。 +2. 重新运行 scanner,恢复 report summary 到 `233/144/89/0/0/0`。 +3. 恢复 `P1rApiCoverageReportTest`、`P1rContentRealApiGateTest` 和 mixed gates 的 summary / Content count / `adminRiskAction` 断言。 +4. 删除 `P1rContentAdminRiskActionCompletedApprovalIT`。 +5. 删除或修订对应 memory / `.agent` 状态推进记录。 +6. 保留 OpenAPI、Content main/java、SQL migration 无 diff。 + +## 完成条件 + +只有同时满足以下条件,才允许汇报本切片具备提交候选条件: + +- 用户已批准执行版四项前置条件。 +- RED gate 先失败且失败点正确。 +- `P1rContentAdminRiskActionCompletedApprovalIT` fresh pass,且 XML 防空跑通过。 +- Content focused tests fresh pass。 +- scanner `--check` fresh pass,summary 为 `233/145/88/0/0/0`。 +- P1R mixed gates fresh pass。 +- Content combined DB gate fresh pass。 +- protected OpenAPI / Content main/java / SQL diff 为空。 +- `git diff --check` 通过。 +- `.agent` 和 `docs/memorys` 留痕明确说明 Content 仍不是 51/51 completed。 +- fresh implementation spec/correctness review + fresh quality/data-integrity/testing review 双 PASS。 diff --git a/docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md b/docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md new file mode 100644 index 00000000..28b268ad --- /dev/null +++ b/docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md @@ -0,0 +1,261 @@ +# P1R Content Admin RiskAction 状态推进 + +日期:2026-06-13 + +## 结论 + +本轮只推进 Content 管理端治理写命令 1 个 operation-level completed approval: + +- `content:adminRiskAction` + +Content 仍不是整域 completed。其余 37 个 Content operation 继续 `dedicated/needs_verification`。 + +本轮未修改 OpenAPI,未修改 Content 业务实现,未修改 SQL migration,未把 `content` 加入 domain-level completed allowlist。 + +## 工作区与基线 + +工作区: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +基线 HEAD: + +```text +b0b4b2c test(p1r): 收口 Content Admin Read completed approval 门禁 +``` + +实施前 coverage summary: + +```text +233 144 89 0 0 0 +``` + +实施后 coverage summary: + +```text +233 145 88 0 0 0 +``` + +实施后 Content 状态: + +```text +completed=14 +needs_verification=37 +``` + +目标 operation 状态: + +```text +adminRiskAction dedicated completed true +``` + +代表性非目标 operation 仍保持: + +```text +adminListExportTasks dedicated needs_verification false +adminListImportTasks dedicated needs_verification false +createWork dedicated needs_verification false +exportWork dedicated needs_verification false +createStyleCheck dedicated needs_verification true +getStyleCheckResult dedicated needs_verification false +``` + +## 修改范围 + +本轮修改: + +- `muse-cloud/scripts/p1r-audit-api-coverage.py` +- `docs/superpowers/reports/p1r-api-coverage.json` +- `docs/superpowers/reports/p1r-api-coverage.md` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java` +- `docs/agent-specs/.agent` +- `docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval审阅版.md` +- `docs/agent-specs/2026-06-13-P1RContentAdminRiskActionCompletedApproval执行版.md` +- `docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md` + +未修改: + +- 7 个 OpenAPI。 +- Content 业务实现。 +- SQL migration。 +- Content domain-level completed allowlist。 + +## TDD evidence + +RED 阶段先修改 P1R gate 期望值和 `adminRiskAction` completed 断言,在旧 scanner/report 下运行 focused P1R gates,预期失败已出现: + +```text +Tests run: 49, Failures: 12, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +失败点来自旧 report 仍为 `completed=144 / needsVerification=89`,以及 `adminRiskAction` 仍为 `needs_verification`;没有编译错误或无关测试失败。 + +GREEN 阶段只把 `content:adminRiskAction` 加入 scanner operation-level allowlist,重新生成 report,并完成 HTTP+DB `_test` evidence 和 focused gates。 + +## MockMvc HTTP 入口 + DB 证据 + +新增: + +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java` + +验证方式: + +- 使用 Spring Mock web context + `MockMvc`。 +- 通过 `/admin-api/muse/content/works/{workId}/risk-actions` HTTP 入口进入 `AdminContentController`。 +- 使用真实 `ContentAdminServiceImpl`、`ContentCommandServiceImpl`、`ContentAuditServiceImpl`、mapper 和 PostgreSQL `_test` 数据库。 +- 启用 method security,并用 test-local `ss` bean 验证 `muse:content:risk-action` 权限门禁。 +- 注册真实 tenant SQL interceptor。 +- Flyway clean/migrate V1-V21。 +- 密码只从环境变量读取,拒绝 JVM password system property 和 JDBC credential query。 +- `@AfterAll` 恢复 `p1r.flyway.url` / `p1r.flyway.user`,避免污染同一 Surefire JVM 后续 Flyway 测试。 + +说明:这里的 HTTP 入口证据是执行版要求的 WebApplicationContext + MockMvc 请求路径证据,不是启动真实 socket 端口的 live servlet container 证据。 + +单类验证数据库: + +```text +muse_p1r_content_admin_risk_action_completed_approval_test +``` + +通过结果: + +```text +P1rContentAdminRiskActionCompletedApprovalIT: tests=8, failures=0, errors=0, skipped=0 +``` + +覆盖内容: + +- 拒绝非 `_test` 数据库、JVM password system property 和 JDBC credential query。 +- V1-V21 schema gate,覆盖 `muse_content_governance_action` 的 JSONB `target_ids`、unique command 约束、work 索引和 updated_at trigger。 +- duplicate `(tenant_id, command_id)` 返回 SQLState `23505`。 +- `WorkMapper.selectByIdForUpdate` 源码断言包含 `FOR UPDATE`。 +- API version、RBAC 和 Bean Validation 拒绝路径均 no-write。 +- work scope happy path 写 governance action、command/audit envelope,不写 Content Events outbox。 +- chapter / block scope happy path 只允许 target 属于 URL work。 +- `notify_owner` 返回 `pending_notification`,其它支持 action 返回 `executed`。 +- command replay 返回首次结果,不重复写 governance action、command/audit 或 outbox。 +- command conflict、revision conflict、unsupported action、unsupported targetScope、空 targetIds、null targetIds、missing/cross-work/cross-tenant target、block->chapter->work 链路不匹配和 cross-tenant work 都整体回滚。 +- command row 断言 `tenant_id`、`command_id`、`command_type=adminRiskAction`、`owner_user_id`、`target_type=work`、`target_id`、`request_hash`。 +- governance action row 断言 `tenant_id`、`work_id`、`operator_user_id`、`command_id`、`audit_log_id`、`action`、`reason`、`target_scope`、`target_ids`、`expected_version`、`status`。 +- command/audit snapshot 解析断言 `operationId=adminRiskAction`、`side=admin`、`targetType=work`、`targetId`、`status=succeeded`、`reason`、`requestSummary` 和 `result`。 +- no-write / rollback 使用 `row_to_json(t)::text` 对 `muse_content_work`、`muse_content_chapter`、`muse_content_block`、`muse_content_governance_action`、`muse_content_command_log`、`muse_content_event_publish_outbox` 做全表快照比对。 + +## 组合验证 + +组合验证使用真实 PostgreSQL `_test` 库: + +```text +muse_p1r_content_admin_risk_action_combined_test +``` + +通过结果: + +```text +P1rContentAdminRiskActionCompletedApprovalIT: tests=8, failures=0, errors=0, skipped=0 +P1rContentAdminReadCompletedApprovalIT: tests=5, failures=0, errors=0, skipped=0 +P1rContentPlanningCompletedApprovalIT: tests=11, failures=0, errors=0, skipped=0 +P1rContentCoreCompletedApprovalIT: tests=12, failures=0, errors=0, skipped=0 +P1rContentEventsPublishFlywayMigrationIT: tests=4, failures=0, errors=0, skipped=0 +combined: tests=40, failures=0, errors=0, skipped=0 +``` + +## focused Content tests + +执行版 focused tests 已单独运行,并确认 XML 为本轮 fresh 产物: + +```text +ContentAdminServiceTest: tests=13, failures=0, errors=0, skipped=0 +AdminContentControllerTest: tests=8, failures=0, errors=0, skipped=0 +focused total: tests=21, failures=0, errors=0, skipped=0 +``` + +## P1R mixed gates + +P1R mixed gates 已 fresh 运行: + +```text +P1rApiCoverageReportTest: tests=6, failures=0, errors=0, skipped=0 +P1rContentRealApiGateTest: tests=5, failures=0, errors=0, skipped=0 +P1rEventsRealApiGateTest: tests=7, failures=0, errors=0, skipped=0 +P1rAiRealApiGateTest: tests=7, failures=0, errors=0, skipped=0 +P1rKnowledgeRealApiGateTest: tests=8, failures=0, errors=0, skipped=0 +P1rMarketRealApiGateTest: tests=6, failures=0, errors=0, skipped=0 +P1rAccountRealApiGateTest: tests=5, failures=0, errors=0, skipped=0 +P1rMetaRealApiGateTest: tests=5, failures=0, errors=0, skipped=0 +mixed total: tests=49, failures=0, errors=0, skipped=0 +``` + +## scanner / report + +执行命令: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +通过后确认: + +```text +233 145 88 0 0 0 +51 14 37 0 0 0 +adminRiskAction dedicated completed true +``` + +## protected diff + +以下保护面 diff 为空: + +```text +docs/api-contracts +muse-cloud/muse-module-content/muse-module-content-server/src/main/java +muse-cloud/sql/muse +``` + +`git diff --check` 无输出。 + +## fresh implementation review + +Lagrange data-integrity/testing review: + +```text +PASS +findings=none +``` + +Lagrange 已只读验证: + +- `P1rContentAdminRiskActionCompletedApprovalIT` 使用 MockMvc HTTP + WebApplicationContext + 真实 PostgreSQL `_test`。 +- 真实 controller/service/mapper、method security、tenant interceptor、Flyway V1-V21、command/governance 行级事实、command/audit snapshot、no-write / rollback 和 XML evidence 均闭合。 +- coverage 为 `233/145/88/0/0/0`,Content 为 `14 completed / 37 needs_verification`,非目标 Content operation 仍保持 `needs_verification`。 +- protected diff 与 `git diff --check` 均无问题。 + +Gauss scope review: + +```text +PASS +findings=none +``` + +Gauss 已只读验证: + +- diff 只在 scanner/report、P1R gate tests、新 IT、`docs/agent-specs`、`docs/memorys` 范围内。 +- protected diff 为空。 +- scanner 只追加 `content:adminRiskAction` operation-level key,`APPROVED_COMPLETED_DOMAINS` 仍为 `{"ai", "knowledge"}`。 +- summary 为 `233/145/88/0/0/0`,Content 为 `14 completed / 37 needs_verification`。 +- `adminRiskAction` 为 `dedicated/completed/requiresCommandId=true`,抽样非目标 Content operation 仍为 `dedicated/needs_verification`。 +- 文档没有宣称 Content 51/51、Account remaining、Market remaining 或总 P1R completed。 + +## 后续路线 + +`content:adminRiskAction` 之后,Content 仍剩余 37 个 `dedicated/needs_verification` operation。后续应继续按 operation-level approval 切片推进,优先只选择证据闭合、外部依赖少、可用 HTTP+DB `_test` 证明的 operation;不要把 Content 51/51、Account remaining、Market remaining 或总 P1R completed 混入本轮结论。 diff --git a/docs/superpowers/reports/p1r-api-coverage.json b/docs/superpowers/reports/p1r-api-coverage.json index 795d2297..f6165b02 100644 --- a/docs/superpowers/reports/p1r-api-coverage.json +++ b/docs/superpowers/reports/p1r-api-coverage.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-25T09:36:21+00:00", + "generatedAt": "2026-05-25T09:52:35+00:00", "sourceContracts": [ "docs/api-contracts/account/openapi.yaml", "docs/api-contracts/ai/openapi.yaml", @@ -11,9 +11,9 @@ ], "summary": { "totalOperations": 233, - "completedOperations": 144, + "completedOperations": 145, "incompleteOperations": 0, - "needsVerificationOperations": 89, + "needsVerificationOperations": 88, "catchAllOperations": 0, "genericPersistenceOperations": 0, "ssePlaceholderOperations": 0, @@ -7786,7 +7786,7 @@ "externalDependencies": [], "targetStage": "P1R-1 Content Real API", "implementationStatus": "dedicated", - "completionStatus": "needs_verification", + "completionStatus": "completed", "controllerFiles": [ "muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/admin/AdminContentController.java" ], @@ -7823,7 +7823,7 @@ "muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/facade/ContentPlanningCandidateFacade.java", "muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/facade/ContentStyleCheckFacade.java" ], - "notes": "Dedicated entry exists; later P1R stage must verify DTO, state machine, persistence, and real external closure." + "notes": "Dedicated entry exists; user-approved P1R acceptance evidence allows this operation to advance to completed coverage." }, { "domain": "content", diff --git a/docs/superpowers/reports/p1r-api-coverage.md b/docs/superpowers/reports/p1r-api-coverage.md index e1645105..efda4b4a 100644 --- a/docs/superpowers/reports/p1r-api-coverage.md +++ b/docs/superpowers/reports/p1r-api-coverage.md @@ -1,9 +1,9 @@ # P1R API Coverage Report -- Generated at: `2026-05-25T09:36:21+00:00` +- Generated at: `2026-05-25T09:52:35+00:00` - Total operations: `233` -- Completed: `144` -- Needs verification: `89` +- Completed: `145` +- Needs verification: `88` - Incomplete: `0` - Catch-all: `0` - Generic persistence: `0` @@ -93,7 +93,7 @@ | content | admin | GET | `/admin-api/muse/content/works` | `adminListWorks` | dedicated | completed | P1R-1 Content Real API | | content | admin | GET | `/admin-api/muse/content/works/{workId}` | `adminGetWork` | dedicated | completed | P1R-1 Content Real API | | content | admin | GET | `/admin-api/muse/content/works/{workId}/chapters` | `adminListChapters` | dedicated | completed | P1R-1 Content Real API | -| content | admin | POST | `/admin-api/muse/content/works/{workId}/risk-actions` | `adminRiskAction` | dedicated | needs_verification | P1R-1 Content Real API | +| content | admin | POST | `/admin-api/muse/content/works/{workId}/risk-actions` | `adminRiskAction` | dedicated | completed | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/chapter-parse-results/{resultId}/confirm` | `confirmChapterParseResult` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/chapter-parse-results/{resultId}/reject` | `rejectChapterParseResult` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | GET | `/app-api/muse/downloads/{credentialId}` | `downloadExportPackage` | dedicated | needs_verification | P1R-1 Content Real API | diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java index c28068bf..f70ec23e 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java @@ -54,7 +54,7 @@ class P1rAccountRealApiGateTest { "adminListQuotaAdjustments" ); - /** Content completed approval 当前只允许推进 13 个 operation。 */ + /** Content completed approval 当前只允许推进 14 个 operation。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -68,7 +68,8 @@ class P1rAccountRealApiGateTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); /** P1R-3 计划第 1 节冻结的 Account operation、方法和路径清单。 */ @@ -193,12 +194,12 @@ class P1rAccountRealApiGateTest { } /** - * 验证 Content 只推进当前已批准的 13 个 operation,防止 Account gate 漏掉 Content 未批准项回退检查。 + * 验证 Content 只推进当前已批准的 14 个 operation,防止 Account gate 漏掉 Content 未批准项回退检查。 * * @throws IOException 读取覆盖报告失败时抛出 */ @Test - void should_keep_content_partial_approval_at_13_completed_operations() throws IOException { + void should_keep_content_partial_approval_at_14_completed_operations() throws IOException { JsonNode contentOperations = readContentOperations(); int completed = 0; int needsVerification = 0; @@ -218,8 +219,8 @@ class P1rAccountRealApiGateTest { "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); } } - assertEquals(13, completed, "Content Admin Read completed approval 后只能推进 13 个 operation"); - assertEquals(38, needsVerification, "Content 剩余 38 个 operation 必须继续 needs_verification"); + assertEquals(14, completed, "Content Admin RiskAction completed approval 后只能推进 14 个 operation"); + assertEquals(37, needsVerification, "Content 剩余 37 个 operation 必须继续 needs_verification"); } private JsonNode readAccountOperations() throws IOException { diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java index 7eb36e6d..0258ae48 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java @@ -85,7 +85,7 @@ class P1rAiRealApiGateTest { "unfavoriteAsset" ); - /** Content completed approval 当前只允许推进 13 个 operation。 */ + /** Content completed approval 当前只允许推进 14 个 operation。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -99,7 +99,8 @@ class P1rAiRealApiGateTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -144,8 +145,8 @@ class P1rAiRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(144, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 13"); + assertEquals(145, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); } @Test @@ -171,7 +172,7 @@ class P1rAiRealApiGateTest { @Test void should_keep_content_meta_account_dedicated_statuses() throws IOException { - assertContentStatusCount(13, 38); + assertContentStatusCount(14, 37); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); assertMarketStatusCount(4, 28); @@ -200,8 +201,8 @@ class P1rAiRealApiGateTest { } } assertEquals(51, total, "Content operation 数量必须保持 51"); - assertEquals(expectedCompleted, completed, "Content Admin Read completed approval 后只能推进 13 个 operation"); - assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 38 个 operation 必须继续 needs_verification"); + assertEquals(expectedCompleted, completed, "Content Admin RiskAction completed approval 后只能推进 14 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 37 个 operation 必须继续 needs_verification"); } private void assertAccountStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java index 08673b3b..9e5ddca9 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java @@ -97,7 +97,8 @@ class P1rApiCoverageReportTest { "content:savePlanningItem", "content:adminListWorks", "content:adminGetWork", - "content:adminListChapters" + "content:adminListChapters", + "content:adminRiskAction" ); /** 已批准的 MetaSchema 管理接口。 */ @@ -146,7 +147,7 @@ class P1rApiCoverageReportTest { "unfavoriteAsset" ); - /** 已批准的 Content 用户端核心、Planning 与 Admin Read 接口,仍保持 operation-level approval。 */ + /** 已批准的 Content 用户端核心、Planning、Admin Read 与 Admin RiskAction 接口,仍保持 operation-level approval。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -160,7 +161,8 @@ class P1rApiCoverageReportTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -278,7 +280,7 @@ class P1rApiCoverageReportTest { completed++; } } - assertEquals(144, completed, "Content Admin Read 3 个 operation-level approval 后 completed 总数只能从 141 增至 144"); + assertEquals(145, completed, "Content Admin RiskAction operation-level approval 后 completed 总数只能从 144 增至 145"); assertOperationStatus("events", "streamEvents", "dedicated", "completed"); for (String operationId : APPROVED_META_SCHEMA_COMPLETED_OPERATIONS) { @@ -307,10 +309,10 @@ class P1rApiCoverageReportTest { assertOperationStatus("content", "exportWork", "dedicated", "needs_verification"); assertOperationStatus("content", "listPlanningCandidates", "dedicated", "needs_verification"); assertOperationStatus("content", "createStyleCheck", "dedicated", "needs_verification"); - assertOperationStatus("content", "adminRiskAction", "dedicated", "needs_verification", true); + assertOperationStatus("content", "adminRiskAction", "dedicated", "completed", true); assertDomainStatusCount("market", "dedicated", "needs_verification", 28); assertDomainStatusCount("account", "dedicated", "needs_verification", 23); - assertDomainStatusCount("content", "dedicated", "needs_verification", 38); + assertDomainStatusCount("content", "dedicated", "needs_verification", 37); } /** diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java new file mode 100644 index 00000000..ab2f0480 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentAdminRiskActionCompletedApprovalIT.java @@ -0,0 +1,1337 @@ +package cn.iocoder.muse.server.framework.api; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.muse.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import cn.iocoder.muse.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO; +import cn.iocoder.muse.framework.common.enums.UserTypeEnum; +import cn.iocoder.muse.framework.common.pojo.CommonResult; +import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.muse.framework.security.core.LoginUser; +import cn.iocoder.muse.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.muse.framework.tenant.config.TenantProperties; +import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.muse.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration; +import cn.iocoder.muse.module.content.application.ContentAdminServiceImpl; +import cn.iocoder.muse.module.content.application.ContentAuditServiceImpl; +import cn.iocoder.muse.module.content.application.ContentCommandServiceImpl; +import cn.iocoder.muse.module.content.controller.admin.AdminContentController; +import cn.iocoder.muse.module.content.dal.mysql.WorkMapper; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; +import org.apache.ibatis.annotations.Select; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import javax.sql.DataSource; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_API_VERSION_UNSUPPORTED; +import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_COMMAND_ID_CONFLICT; +import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_INVALID_STATUS_TRANSITION; +import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_NOT_FOUND; +import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_REVISION_CONFLICT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P1R Content Admin RiskAction completed approval:管理端治理写命令的 HTTP + 真实 PostgreSQL 证据。 + * + *

本测试只连接显式传入的 PostgreSQL {@code _test} 库,通过真实 {@code /admin-api/muse/**} + * 前缀进入 Controller,并启用 Spring method security。它只证明 {@code adminRiskAction} 一个 + * operation 的 RBAC、API version、command 幂等、expectedVersion、行锁、target 归属、 + * governance fact、command/audit snapshot、tenant 隔离和事务回滚,不推进其它 Content 写命令。

+ */ +@SpringBootTest( + classes = P1rContentAdminRiskActionCompletedApprovalIT.AdminRiskActionCompletedApprovalConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rContentAdminRiskActionCompletedApprovalIT { + + private static final String TARGET_VERSION = "21"; + private static final Long TENANT_ID = 100L; + private static final Long OTHER_TENANT_ID = 200L; + private static final Long ADMIN_USER_ID = 9001L; + private static final Long OTHER_OWNER_ID = 9002L; + private static final String API_VERSION = "1"; + private static final String RISK_ACTION_PERMISSION = "muse:content:risk-action"; + private static final Set CREDENTIAL_QUERY_KEYS = Set.of( + "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", + "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"); + + private static volatile AdminRiskActionCompletedApprovalSettings cachedSettings; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @Autowired + private DataSource dataSource; + @Autowired + private WebApplicationContext webApplicationContext; + @Autowired + private TestSecurityFrameworkService securityFrameworkService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private MockMvc mockMvc; + private SeedFacts seedFacts; + + @DynamicPropertySource + static void registerAdminRiskActionCompletedApprovalProperties(DynamicPropertyRegistry registry) { + AdminRiskActionCompletedApprovalSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-content-admin-risk-action-completed-approval-it"); + registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.content"); + registry.add("muse.web.admin-ui.url", () -> "http://localhost"); + registry.add("spring.datasource.url", settings::jdbcUrl); + registry.add("spring.datasource.username", settings::jdbcUser); + registry.add("spring.datasource.password", settings::jdbcPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.main.banner-mode", () -> "off"); + registry.add("spring.main.lazy-initialization", () -> "true"); + registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO"); + } + + @BeforeAll + void migrateContentSchema() { + AdminRiskActionCompletedApprovalSettings settings = settings(); + silenceFlywayInfoLogs(); + Flyway flyway = Flyway.configure() + .dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword()) + .locations(resolveMuseSqlLocation(settings.flywayLocations())) + .schemas("public") + .defaultSchema("public") + .target(TARGET_VERSION) + .cleanDisabled(false) + .load(); + cleanSchema(flyway, settings); + MigrateResult result = migrateSchema(flyway, settings); + assertEquals(21, result.migrationsExecuted, + "Content Admin RiskAction completed approval 必须在隔离库执行 V1-V21 全量迁移,实际: " + + result.migrationsExecuted); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetContentTables(); + this.seedFacts = seedContentFacts(); + setRuntimeContext(); + securityFrameworkService.allowPermissions(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_rejectUnsafeDatabaseConfigurationInputs() { + System.setProperty("p1r.content.admin-risk-action.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rContentAdminRiskActionCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.content.admin-risk-action.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.content.admin-risk-action.password"); + } + + AssertionError credentialQueryError = assertThrows(AssertionError.class, + () -> assertNoCredentialQuery("jdbc:postgresql://localhost:5432/muse_test?password=secret")); + assertTrue(credentialQueryError.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"), + "拒绝 JDBC credential query 时必须说明连接串只能通过环境变量传密码"); + + AssertionError databaseNameError = assertThrows(AssertionError.class, + () -> assertTestDatabaseUrl("jdbc:postgresql://localhost:5432/muse_prod")); + assertTrue(databaseNameError.getMessage().contains("_test"), + "拒绝非 _test 数据库时必须指出隔离库后缀要求"); + } + + @Test + void should_verifyGovernanceSchemaCommandConstraintAndRowLockContract() throws Exception { + assertTrue(columnExists("muse_content_governance_action", "target_ids"), + "governance action 必须保留 target_ids 字段"); + assertEquals("jsonb", columnDataType("muse_content_governance_action", "target_ids"), + "target_ids 必须是 PostgreSQL JSONB,不能退化为字符串列"); + assertEquals("NO", columnNullable("muse_content_governance_action", "target_ids"), + "target_ids 必须 NOT NULL,避免治理目标丢失"); + assertTrue(columnDefault("muse_content_governance_action", "target_ids").contains("'[]'::jsonb"), + "target_ids 必须有 []::jsonb 默认值"); + assertTrue(columnExists("muse_content_governance_action", "expected_version"), + "governance action 必须记录 expected_version"); + assertEquals("NO", columnNullable("muse_content_governance_action", "expected_version"), + "expected_version 必须 NOT NULL"); + assertTrue(constraintExists("uk_muse_content_governance_action_command"), + "governance action 必须用 tenant_id + command_id 保证治理动作唯一"); + assertTrue(indexExists("idx_muse_content_governance_action_work"), + "governance action 必须保留 tenant_id + work_id 查询索引"); + assertTrue(triggerExists("muse_content_governance_action", "trg_muse_content_governance_action_updated_at"), + "governance action 必须保留 update_time trigger"); + assertTrue(indexExists("idx_muse_content_work_owner"), + "work 表必须保留 tenant_id + owner_user_id 查询索引"); + assertTrue(indexExists("idx_muse_content_work_status"), + "work 表必须保留 tenant_id + status 查询索引"); + assertTrue(tableExists("muse_content_event_publish_outbox"), + "V21 Content event outbox schema 必须已迁移,证明本切片不依赖占位表"); + + insertGovernanceActionRaw(TENANT_ID, seedFacts.workId(), "schema-duplicate-command"); + SQLException duplicateError = assertThrows(SQLException.class, + () -> insertGovernanceActionRaw(TENANT_ID, seedFacts.workId(), "schema-duplicate-command")); + assertEquals("23505", duplicateError.getSQLState(), + "重复 tenant_id + command_id 必须由 PostgreSQL 唯一约束拒绝"); + + Method method = WorkMapper.class.getMethod("selectByIdForUpdate", Long.class, Long.class); + Select select = method.getAnnotation(Select.class); + assertNotNull(select, "WorkMapper.selectByIdForUpdate 必须显式声明锁定 SQL"); + assertTrue(Arrays.stream(select.value()) + .anyMatch(sql -> sql.toUpperCase(Locale.ROOT).contains("FOR UPDATE")), + "adminRiskAction 的 expectedVersion 校验必须使用 SELECT ... FOR UPDATE 行锁边界"); + } + + @Test + void should_enforceApiVersionMethodSecurityAndBeanValidationWithoutWrite() throws Exception { + assertNoContentMutationDuring("API version 拒绝不能写任何 Content fact", + () -> mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-bad-version", "warn", "bad version", null, null, 7)) + .header("X-API-Version", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + securityFrameworkService.denyPermissions(); + assertNoContentMutationDuring("RBAC 拒绝不能写任何 Content fact", + () -> mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-rbac", "warn", "rbac", null, null, 7)) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(FORBIDDEN.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + assertEquals(RISK_ACTION_PERMISSION, securityFrameworkService.lastPermission(), + "method security 必须真实检查 muse:content:risk-action 权限点"); + securityFrameworkService.allowPermissions(); + + assertNoContentMutationDuring("缺少 commandId 的 Bean Validation 拒绝不能写任何 Content fact", + () -> mockMvc.perform(riskActionPost(seedFacts.workId(), """ + {"action":"warn","reason":"missing command","expectedVersion":7} + """) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.msg").exists())); + + assertNoContentMutationDuring("缺少 expectedVersion 的 Bean Validation 拒绝不能写任何 Content fact", + () -> mockMvc.perform(riskActionPost(seedFacts.workId(), """ + {"commandId":"p1r-risk-missing-version","action":"warn","reason":"missing version"} + """) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.msg").exists())); + } + + @Test + void should_createWorkScopeRiskActionWithGovernanceCommandAuditAndNoOutbox() throws Exception { + String commandId = "p1r-risk-work"; + MvcResult result = mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody(commandId, "warn", "work warning", null, List.of(), 7)) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("executed")) + .andExpect(jsonPath("$.data.affectedScope").value("work:" + seedFacts.workId())) + .andExpect(jsonPath("$.data.auditLogId").value(commandId)) + .andReturn(); + + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + assertEquals(1, governanceCount(commandId), "成功治理动作只能写一条 governance fact"); + assertEquals(1, commandCount(commandId), "成功治理动作只能写一条 command/audit envelope"); + assertEquals(0, outboxCount(), "adminRiskAction 当前不写 Content Events outbox"); + + GovernanceActionFact action = governanceActionFact(commandId); + assertGovernanceActionFact(action, commandId, seedFacts.workId(), "warn", "work warning", + "work", "[]", 7, "executed"); + CommandFact command = commandFact(commandId); + assertCommandAuditSnapshot(command, commandId, seedFacts.workId(), "warn", "work warning", "work", "[]", + 7, data, "work scope 必须写完整 command/audit snapshot"); + assertEquals(action.auditLogId(), data.path("auditLogId").asText(), + "response.auditLogId 必须与 governance action audit_log_id 对齐"); + } + + @Test + void should_createChapterAndBlockScopeRiskActionsOnlyWhenTargetsBelongToPathWork() throws Exception { + String chapterCommandId = "p1r-risk-chapter"; + mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody(chapterCommandId, "restrict", "chapter restriction", + "chapter", List.of(seedFacts.chapterId()), 7)) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("executed")) + .andExpect(jsonPath("$.data.affectedScope").value("chapter:[" + seedFacts.chapterId() + "]")); + assertGovernanceActionFact(governanceActionFact(chapterCommandId), chapterCommandId, seedFacts.workId(), + "restrict", "chapter restriction", "chapter", "[" + seedFacts.chapterId() + "]", 7, "executed"); + + String blockCommandId = "p1r-risk-block"; + mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody(blockCommandId, "block_content", "block restriction", + "block", List.of(seedFacts.blockId()), 7)) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("executed")) + .andExpect(jsonPath("$.data.affectedScope").value("block:[" + seedFacts.blockId() + "]")); + assertGovernanceActionFact(governanceActionFact(blockCommandId), blockCommandId, seedFacts.workId(), + "block_content", "block restriction", "block", "[" + seedFacts.blockId() + "]", 7, "executed"); + + assertEquals(2, governanceCount(), "chapter 与 block happy path 必须各写一条治理事实"); + assertEquals(2, commandCount(), "chapter 与 block happy path 必须各写一条 command/audit envelope"); + assertEquals(0, outboxCount(), "adminRiskAction chapter/block scope 也不能写 Content Events outbox"); + } + + @Test + void should_returnPendingNotificationForNotifyOwnerAndExecutedForOtherSupportedActions() throws Exception { + mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-notify", "notify_owner", "notify owner", null, null, 7)) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("pending_notification")); + + for (String action : List.of("warn", "restrict", "block_content", "force_archive")) { + String commandId = "p1r-risk-status-" + action; + mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody(commandId, action, "status " + action, null, null, 7)) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("executed")); + } + + assertEquals(5, governanceCount(), "5 个受支持 action 必须各写一条 governance action"); + assertEquals(5, commandCount(), "5 个受支持 action 必须各写一条 command/audit envelope"); + } + + @Test + void should_replaySameCommandWithoutDuplicatingGovernanceCommandOrOutboxFacts() throws Exception { + String commandId = "p1r-risk-replay"; + String requestBody = riskActionBody(commandId, "warn", "replay same command", null, null, 7); + MvcResult first = mockMvc.perform(riskActionPost(seedFacts.workId(), requestBody) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn(); + String firstActionId = objectMapper.readTree(first.getResponse().getContentAsString()) + .path("data").path("actionId").asText(); + String firstRequestHash = commandFact(commandId).requestHash(); + + MvcResult replay = mockMvc.perform(riskActionPost(seedFacts.workId(), requestBody) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn(); + String replayActionId = objectMapper.readTree(replay.getResponse().getContentAsString()) + .path("data").path("actionId").asText(); + + assertEquals(firstActionId, replayActionId, "同 commandId 同请求必须回放首次 actionId"); + assertEquals(1, governanceCount(commandId), "同 commandId 同请求不能重复插入 governance action"); + assertEquals(1, commandCount(commandId), "同 commandId 同请求只能保留一条 command fact"); + assertEquals(firstRequestHash, commandFact(commandId).requestHash(), "replay 前后 request_hash 必须稳定"); + assertEquals(0, outboxCount(), "command replay 不能写 Content Events outbox"); + } + + @Test + void should_rejectCommandConflictRevisionConflictUnsupportedInputsAndTargetLeaksWithRollback() throws Exception { + String commandId = "p1r-risk-conflict"; + mockMvc.perform(riskActionPost(seedFacts.workId(), + riskActionBody(commandId, "warn", "initial command", null, null, 7)) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + ContentFactSnapshot afterSuccess = contentFactSnapshot(); + + assertContentError(riskActionPost(seedFacts.workId(), + riskActionBody(commandId, "restrict", "different command", null, null, 7)), + CONTENT_COMMAND_ID_CONFLICT.getCode(), "同 commandId 不同 action 必须拒绝"); + assertEquals(afterSuccess, contentFactSnapshot(), "command conflict 不能追加或修改任何 Content fact"); + + assertRejectedWithoutWrite("revision conflict 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-revision-conflict", "warn", "stale version", null, null, 6)), + CONTENT_REVISION_CONFLICT.getCode(), "p1r-risk-revision-conflict"); + + assertRejectedWithoutWrite("unsupported action 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-unsupported-action", "mute", "bad action", null, null, 7)), + CONTENT_INVALID_STATUS_TRANSITION.getCode(), "p1r-risk-unsupported-action"); + + assertRejectedWithoutWrite("unsupported targetScope 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-unsupported-scope", "warn", "bad scope", + "section", List.of(seedFacts.chapterId()), 7)), + CONTENT_INVALID_STATUS_TRANSITION.getCode(), "p1r-risk-unsupported-scope"); + + assertRejectedWithoutWrite("chapter targetIds 为空必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-empty-targets", "warn", "empty targets", + "chapter", List.of(), 7)), + CONTENT_INVALID_STATUS_TRANSITION.getCode(), "p1r-risk-empty-targets"); + + assertRejectedWithoutWrite("targetIds 含 null 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), """ + { + "commandId": "p1r-risk-null-target", + "action": "warn", + "reason": "null target", + "targetScope": "chapter", + "targetIds": [null], + "expectedVersion": 7 + } + """), + CONTENT_INVALID_STATUS_TRANSITION.getCode(), "p1r-risk-null-target"); + + assertRejectedWithoutWrite("missing chapter target 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-missing-chapter", "warn", "missing chapter", + "chapter", List.of(999999L), 7)), + CONTENT_NOT_FOUND.getCode(), "p1r-risk-missing-chapter"); + + assertRejectedWithoutWrite("cross-work chapter target 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-cross-work-chapter", "warn", "cross work chapter", + "chapter", List.of(seedFacts.otherWorkChapterId()), 7)), + CONTENT_NOT_FOUND.getCode(), "p1r-risk-cross-work-chapter"); + + assertRejectedWithoutWrite("cross-tenant chapter target 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-cross-tenant-chapter", "warn", "cross tenant chapter", + "chapter", List.of(seedFacts.otherTenantChapterId()), 7)), + CONTENT_NOT_FOUND.getCode(), "p1r-risk-cross-tenant-chapter"); + + assertRejectedWithoutWrite("block -> chapter -> work 链路不匹配必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-mismatch-block", "warn", "mismatch block", + "block", List.of(seedFacts.mismatchedBlockId()), 7)), + CONTENT_NOT_FOUND.getCode(), "p1r-risk-mismatch-block"); + + assertRejectedWithoutWrite("cross-work block target 必须整体回滚已预占 commandId", + riskActionPost(seedFacts.workId(), + riskActionBody("p1r-risk-cross-work-block", "warn", "cross work block", + "block", List.of(seedFacts.otherWorkBlockId()), 7)), + CONTENT_NOT_FOUND.getCode(), "p1r-risk-cross-work-block"); + + assertRejectedWithoutWrite("cross-tenant work 必须按当前租户不可见并整体回滚 command", + riskActionPost(seedFacts.otherTenantWorkId(), + riskActionBody("p1r-risk-cross-tenant-work", "warn", "cross tenant work", null, null, 7)), + CONTENT_NOT_FOUND.getCode(), "p1r-risk-cross-tenant-work"); + } + + private void assertRejectedWithoutWrite(String message, MockHttpServletRequestBuilder request, + int expectedCode, String commandId) throws Exception { + ContentFactSnapshot before = contentFactSnapshot(); + assertContentError(request, expectedCode, message); + assertEquals(before, contentFactSnapshot(), message); + assertEquals(0, commandCountAcrossTenants(commandId), message + ",失败事务不能残留任何租户的 command fact"); + assertEquals(0, outboxCountAcrossTenants(), message + ",失败事务不能写任何租户的 outbox"); + } + + private void assertContentError(MockHttpServletRequestBuilder request, int expectedCode, String scenario) + throws Exception { + MvcResult result = mockMvc.perform(request.header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(expectedCode)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andReturn(); + String body = result.getResponse().getContentAsString(); + assertFalse(body.contains("P1R Other Tenant Risk Work"), scenario + " 不能泄露跨租户作品标题"); + } + + private void assertGovernanceActionFact(GovernanceActionFact action, String commandId, Long workId, + String expectedAction, String expectedReason, String targetScope, + String targetIds, int expectedVersion, String status) { + assertNotNull(action, commandId + " 必须写入 governance action"); + assertEquals(TENANT_ID, action.tenantId(), commandId + " tenant_id 必须来自当前租户"); + assertEquals(workId, action.workId(), commandId + " work_id 必须绑定 URL work"); + assertEquals(ADMIN_USER_ID, action.operatorUserId(), commandId + " operator_user_id 必须记录 admin user"); + assertEquals(commandId, action.commandId(), commandId + " command_id 必须落入治理事实"); + assertEquals(commandId, action.auditLogId(), commandId + " audit_log_id 必须与 commandId 对齐"); + assertEquals(expectedAction, action.action(), commandId + " action 必须保留请求值"); + assertEquals(expectedReason, action.reason(), commandId + " reason 必须保留请求值"); + assertEquals(targetScope, action.targetScope(), commandId + " target_scope 必须保留归一化范围"); + assertEquals(targetIds, action.targetIds(), commandId + " target_ids 必须保留 JSONB 目标集合"); + assertEquals(expectedVersion, action.expectedVersion(), commandId + " expected_version 必须保留 CAS 输入"); + assertEquals(status, action.status(), commandId + " status 必须保留治理动作执行状态"); + } + + private void assertCommandAuditSnapshot(CommandFact command, String commandId, Long workId, + String expectedAction, String expectedReason, + String targetScope, String targetIds, + int expectedVersion, JsonNode responseData, String message) + throws Exception { + assertNotNull(command, message); + assertEquals(TENANT_ID, command.tenantId(), message + ",tenant_id 必须来自当前租户"); + assertEquals(commandId, command.commandId(), message + ",command_id 必须匹配请求"); + assertEquals("adminRiskAction", command.commandType(), message + ",command_type 必须匹配 operationId"); + assertEquals(ADMIN_USER_ID, command.ownerUserId(), message + ",owner_user_id 必须记录 admin user"); + assertEquals("work", command.targetType(), message + ",target_type 必须绑定业务目标 work"); + assertEquals(workId, command.targetId(), message + ",target_id 必须绑定 URL work"); + assertNotNull(command.requestHash(), message + ",request_hash 不能为空"); + assertEquals(64, command.requestHash().length(), message + ",request_hash 必须是 SHA-256 十六进制摘要"); + + JsonNode snapshot = objectMapper.readTree(command.resultSnapshot()); + assertEquals(responseData.path("actionId").asText(), snapshot.path("result").path("actionId").asText(), + message + ",result.actionId 必须可回放"); + assertEquals(responseData.path("status").asText(), snapshot.path("result").path("status").asText(), + message + ",result.status 必须可回放"); + assertEquals(responseData.path("affectedScope").asText(), snapshot.path("result").path("affectedScope").asText(), + message + ",result.affectedScope 必须可回放"); + assertEquals(responseData.path("auditLogId").asText(), snapshot.path("result").path("auditLogId").asText(), + message + ",result.auditLogId 必须可回放"); + JsonNode audit = snapshot.path("audit"); + assertEquals("admin", audit.path("side").asText(), message + ",audit.side 必须记录 admin"); + assertEquals("adminRiskAction", audit.path("operationId").asText(), + message + ",audit.operationId 必须匹配 OpenAPI operationId"); + assertEquals("risk_action_created", audit.path("resultSummary").asText(), + message + ",audit.resultSummary 必须记录治理动作已创建"); + assertEquals(expectedReason, audit.path("reason").asText(), + message + ",audit.reason 必须保留治理原因"); + assertEquals("succeeded", audit.path("status").asText(), message + ",audit.status 必须记录成功状态"); + + JsonNode requestSummary = objectMapper.readTree(audit.path("requestSummary").asText()); + assertEquals(expectedAction, requestSummary.path("action").asText(), + message + ",requestSummary.action 必须保留请求 action"); + assertEquals(targetScope, requestSummary.path("targetScope").asText(), + message + ",requestSummary.targetScope 必须保留归一化范围"); + assertEquals(targetIds, requestSummary.path("targetIds").toString(), + message + ",requestSummary.targetIds 必须保留目标集合"); + assertEquals(expectedVersion, requestSummary.path("expectedVersion").asInt(), + message + ",requestSummary.expectedVersion 必须保留 CAS 输入"); + } + + private void resetContentTables() throws SQLException { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + // 每个测试重置 adminRiskAction 触达的 Content fact,确保 no-write 断言覆盖回滚与租户隔离。 + statement.execute(""" + TRUNCATE TABLE + muse_content_event_publish_outbox, + muse_content_command_log, + muse_content_governance_action, + muse_content_block, + muse_content_chapter, + muse_content_work + RESTART IDENTITY CASCADE + """); + } + } + + private SeedFacts seedContentFacts() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + Long workId = insertWork(connection, TENANT_ID, OTHER_OWNER_ID, "P1R Risk Work", "draft", 9, 1, 7); + Long chapterId = insertChapter(connection, TENANT_ID, workId, "Risk Chapter", 1); + Long blockId = insertBlock(connection, TENANT_ID, workId, chapterId, "Risk Block", "risk body", 1); + + Long otherWorkId = insertWork(connection, TENANT_ID, OTHER_OWNER_ID, "P1R Other Risk Work", "draft", 3, 1, 7); + Long otherWorkChapterId = insertChapter(connection, TENANT_ID, otherWorkId, "Other Work Chapter", 1); + Long otherWorkBlockId = insertBlock(connection, TENANT_ID, otherWorkId, otherWorkChapterId, + "Other Work Block", "other body", 1); + Long mismatchedBlockId = insertBlock(connection, TENANT_ID, workId, otherWorkChapterId, + "Mismatched Block", "mismatched chapter", 2); + + Long otherTenantWorkId = insertWork(connection, OTHER_TENANT_ID, ADMIN_USER_ID, + "P1R Other Tenant Risk Work", "draft", 4, 1, 7); + Long otherTenantChapterId = insertChapter(connection, OTHER_TENANT_ID, otherTenantWorkId, + "Other Tenant Risk Chapter", 1); + Long otherTenantBlockId = insertBlock(connection, OTHER_TENANT_ID, otherTenantWorkId, + otherTenantChapterId, "Other Tenant Risk Block", "other tenant body", 1); + + return new SeedFacts(workId, chapterId, blockId, otherWorkId, otherWorkChapterId, otherWorkBlockId, + mismatchedBlockId, otherTenantWorkId, otherTenantChapterId, otherTenantBlockId); + } + } + + private Long insertWork(Connection connection, Long tenantId, Long ownerUserId, String title, + String status, int wordCount, int chapterCount, int revision) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_work(owner_user_id, title, description, genre, status, word_count, + chapter_count, revision, tenant_id) + VALUES (?, ?, ?, 'fiction', ?, ?, ?, ?, ?) + RETURNING id + """)) { + statement.setLong(1, ownerUserId); + statement.setString(2, title); + statement.setString(3, title + " description"); + statement.setString(4, status); + statement.setInt(5, wordCount); + statement.setInt(6, chapterCount); + statement.setInt(7, revision); + statement.setLong(8, tenantId); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed Content work 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + private Long insertChapter(Connection connection, Long tenantId, Long workId, String title, int orderNo) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_chapter(work_id, title, order_no, status, revision, tenant_id) + VALUES (?, ?, ?, 'draft', 1, ?) + RETURNING id + """)) { + statement.setLong(1, workId); + statement.setString(2, title); + statement.setInt(3, orderNo); + statement.setLong(4, tenantId); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed Content chapter 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + private Long insertBlock(Connection connection, Long tenantId, Long workId, Long chapterId, String title, + String content, int orderNo) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_block(work_id, chapter_id, order_no, block_type, title, content_text, + revision, word_count, tenant_id) + VALUES (?, ?, ?, 'scene', ?, ?, 1, ?, ?) + RETURNING id + """)) { + statement.setLong(1, workId); + statement.setLong(2, chapterId); + statement.setInt(3, orderNo); + statement.setString(4, title); + statement.setString(5, content); + statement.setInt(6, content.length()); + statement.setLong(7, tenantId); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed Content block 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + private void insertGovernanceActionRaw(Long tenantId, Long workId, String commandId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_governance_action(work_id, operator_user_id, action, reason, + target_scope, target_ids, expected_version, + status, audit_log_id, command_id, tenant_id) + VALUES (?, ?, 'warn', 'schema duplicate', 'work', '[]'::jsonb, 7, + 'executed', ?, ?, ?) + """)) { + statement.setLong(1, workId); + statement.setLong(2, ADMIN_USER_ID); + statement.setString(3, commandId); + statement.setString(4, commandId); + statement.setLong(5, tenantId); + assertEquals(1, statement.executeUpdate(), "schema gate governance action 必须插入一行"); + } + } + + private String riskActionBody(String commandId, String action, String reason, String targetScope, + List targetIds, Integer expectedVersion) { + return """ + { + "commandId": %s, + "action": %s, + "reason": %s, + "targetScope": %s, + "targetIds": %s, + "expectedVersion": %s + } + """.formatted(jsonString(commandId), jsonString(action), jsonString(reason), + jsonString(targetScope), jsonLongArray(targetIds), expectedVersion == null ? "null" : expectedVersion); + } + + private MockHttpServletRequestBuilder riskActionPost(Long workId, String body) { + return post("/admin-api/muse/content/works/{workId}/risk-actions", workId) + .contentType(MediaType.APPLICATION_JSON) + .content(body); + } + + private ContentFactSnapshot contentFactSnapshot() throws SQLException { + return new ContentFactSnapshot( + tableSnapshot("muse_content_work", "tenant_id, id"), + tableSnapshot("muse_content_chapter", "tenant_id, id"), + tableSnapshot("muse_content_block", "tenant_id, id"), + tableSnapshot("muse_content_governance_action", "tenant_id, id"), + tableSnapshot("muse_content_command_log", "tenant_id, id"), + tableSnapshot("muse_content_event_publish_outbox", "tenant_id, id") + ); + } + + private List tableSnapshot(String tableName, String orderBy) throws SQLException { + String sql = """ + SELECT row_to_json(t)::text + FROM %s t + ORDER BY %s + """.formatted(tableName, orderBy); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + List rows = new ArrayList<>(); + while (resultSet.next()) { + rows.add(resultSet.getString(1)); + } + return rows; + } + } + + private void assertNoContentMutationDuring(String message, CheckedOperation operation) throws Exception { + ContentFactSnapshot before = contentFactSnapshot(); + operation.run(); + assertEquals(before, contentFactSnapshot(), message + ":不能新增、删除或更新任何 Content fact"); + } + + private GovernanceActionFact governanceActionFact(String commandId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + SELECT tenant_id, work_id, operator_user_id, command_id, audit_log_id, action, reason, + target_scope, target_ids::text, expected_version, status + FROM muse_content_governance_action + WHERE tenant_id = ? + AND command_id = ? + """)) { + statement.setLong(1, TENANT_ID); + statement.setString(2, commandId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return new GovernanceActionFact(resultSet.getLong(1), resultSet.getLong(2), resultSet.getLong(3), + resultSet.getString(4), resultSet.getString(5), resultSet.getString(6), + resultSet.getString(7), resultSet.getString(8), resultSet.getString(9), + resultSet.getInt(10), resultSet.getString(11)); + } + } + } + + private CommandFact commandFact(String commandId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + SELECT tenant_id, command_id, command_type, owner_user_id, target_type, target_id, + request_hash, result_snapshot::text + FROM muse_content_command_log + WHERE tenant_id = ? + AND command_id = ? + """)) { + statement.setLong(1, TENANT_ID); + statement.setString(2, commandId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return new CommandFact(resultSet.getLong(1), resultSet.getString(2), resultSet.getString(3), + resultSet.getLong(4), resultSet.getString(5), resultSet.getLong(6), + resultSet.getString(7), resultSet.getString(8)); + } + } + } + + private int governanceCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_governance_action WHERE tenant_id = ?", TENANT_ID); + } + + private int governanceCount(String commandId) throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_governance_action WHERE tenant_id = ? AND command_id = ?", + TENANT_ID, commandId); + } + + private int commandCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_command_log WHERE tenant_id = ?", TENANT_ID); + } + + private int commandCount(String commandId) throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_command_log WHERE tenant_id = ? AND command_id = ?", + TENANT_ID, commandId); + } + + private int commandCountAcrossTenants(String commandId) throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_command_log WHERE command_id = ?", commandId); + } + + private int outboxCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_event_publish_outbox WHERE tenant_id = ?", TENANT_ID); + } + + private int outboxCountAcrossTenants() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_event_publish_outbox"); + } + + private boolean tableExists(String tableName) throws SQLException { + return queryInt(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ? + """, tableName) == 1; + } + + private boolean columnExists(String tableName, String columnName) throws SQLException { + return queryInt(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """, tableName, columnName) == 1; + } + + private boolean constraintExists(String constraintName) throws SQLException { + return queryInt(""" + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE table_schema = 'public' + AND constraint_name = ? + """, constraintName) == 1; + } + + private boolean indexExists(String indexName) throws SQLException { + return queryInt(""" + SELECT COUNT(*) + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = ? + """, indexName) == 1; + } + + private boolean triggerExists(String tableName, String triggerName) throws SQLException { + return queryInt(""" + SELECT COUNT(*) + FROM pg_trigger trigger + JOIN pg_class relation ON relation.oid = trigger.tgrelid + JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'public' + AND relation.relname = ? + AND trigger.tgname = ? + AND NOT trigger.tgisinternal + """, tableName, triggerName) == 1; + } + + private String columnDataType(String tableName, String columnName) throws SQLException { + return queryString(""" + SELECT data_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """, tableName, columnName); + } + + private String columnNullable(String tableName, String columnName) throws SQLException { + return queryString(""" + SELECT is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """, tableName, columnName); + } + + private String columnDefault(String tableName, String columnName) throws SQLException { + return queryString(""" + SELECT column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """, tableName, columnName); + } + + private int queryInt(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "计数查询必须返回一行"); + return resultSet.getInt(1); + } + } + } + + private String queryString(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "事实查询必须返回一行"); + return resultSet.getString(1); + } + } + } + + private void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int i = 0; i < args.length; i++) { + Object value = args[i]; + if (value == null) { + statement.setObject(i + 1, null); + } else if (value instanceof Long longValue) { + statement.setLong(i + 1, longValue); + } else if (value instanceof Integer intValue) { + statement.setInt(i + 1, intValue); + } else { + statement.setString(i + 1, String.valueOf(value)); + } + } + } + + private void setRuntimeContext() { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(ADMIN_USER_ID); + loginUser.setUserType(UserTypeEnum.ADMIN.getValue()); + loginUser.setTenantId(TENANT_ID); + loginUser.setVisitTenantId(TENANT_ID); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + private static String jsonString(String value) { + return value == null ? "null" : "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private static String jsonLongArray(List values) { + if (values == null) { + return "null"; + } + return values.toString(); + } + + private static AdminRiskActionCompletedApprovalSettings settings() { + if (cachedSettings == null) { + cachedSettings = AdminRiskActionCompletedApprovalSettings.fromPropertiesAndEnvironment(); + } + return cachedSettings; + } + + private static void cleanSchema(Flyway flyway, AdminRiskActionCompletedApprovalSettings settings) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, AdminRiskActionCompletedApprovalSettings settings) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, + AdminRiskActionCompletedApprovalSettings settings, + RuntimeException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace("for user '" + settings.jdbcUser() + "'", "for user ''"); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name); + return value; + } + + private static String requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_PASSWORD", + "P1R_CONTENT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_CONTENT_ADMIN_RISK_ACTION_COMPLETED_PASSWORD、" + + "P1R_CONTENT_COMPLETED_PASSWORD、P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); + return password; + } + + private static String firstNonBlankEnvironment(String... names) { + // 数据库密码只能来自环境变量,避免 Surefire XML 或 JVM 参数泄露。 + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static void assertNoPasswordSystemProperties() { + Properties properties = System.getProperties(); + List passwordProperties = properties.stringPropertyNames().stream() + .filter(P1rContentAdminRiskActionCompletedApprovalIT::isForbiddenPasswordSystemProperty) + .sorted() + .toList(); + assertTrue(passwordProperties.isEmpty(), + "数据库密码不能通过 JVM system property 传入: " + passwordProperties); + } + + private static boolean isForbiddenPasswordSystemProperty(String name) { + String normalized = name.toLowerCase(Locale.ROOT); + return normalized.contains("password") + && (normalized.startsWith("p1r.") + || normalized.startsWith("p1r_") + || normalized.contains(".flyway.") + || normalized.contains(".content.") + || normalized.contains(".datasource.")); + } + + private static void assertNoCredentialQuery(String url) { + int queryStart = url.indexOf('?'); + if (queryStart < 0) { + return; + } + String query = url.substring(queryStart + 1); + for (String parameter : query.split("&")) { + String key = parameter; + int equalsStart = key.indexOf('='); + if (equalsStart >= 0) { + key = key.substring(0, equalsStart); + } + assertFalse(isCredentialQueryKey(key), + "p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入"); + } + } + + private static boolean isCredentialQueryKey(String rawKey) { + String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_'); + return CREDENTIAL_QUERY_KEYS.contains(key) + || key.endsWith("_token") + || key.endsWith("_secret") + || key.endsWith("_password"); + } + + private static void assertTestDatabaseUrl(String url) { + String databaseName = jdbcDatabaseName(url); + assertTrue(databaseName.endsWith("_test"), + "p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url)); + } + + private static String resolveMuseSqlLocation(String requestedLocations) { + assertEquals("filesystem:sql/muse", requestedLocations, + "P1R Content Admin RiskAction completed approval IT 要求显式使用 filesystem:sql/muse"); + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + String relativeLocation = requestedLocations.substring("filesystem:".length()); + for (Path cursor = current; cursor != null; cursor = cursor.getParent()) { + Path candidate = cursor.resolve(relativeLocation); + if (Files.isDirectory(candidate)) { + return "filesystem:" + candidate; + } + } + throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current); + } + + private static String jdbcDatabaseName(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + return urlWithoutQuery.substring(databaseStart + 1); + } + + private static String jdbcUrlWithoutQuery(String url) { + int queryStart = url.indexOf('?'); + return queryStart < 0 ? url : url.substring(0, queryStart); + } + + private static String maskedUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + if (databaseStart < 0) { + return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url); + } + String prefix = urlWithoutQuery.substring(0, databaseStart + 1); + String database = urlWithoutQuery.substring(databaseStart + 1); + return maskJdbcHost(prefix) + database + maskedQuerySuffix(url); + } + + private static String maskedQuerySuffix(String url) { + return url.indexOf('?') < 0 ? "" : "?"; + } + + private static String maskJdbcHost(String urlPart) { + return urlPart.replaceAll("//([^:/?#]+)", "//"); + } + + private static void redactFlywaySystemProperties(String url, String user) { + captureOriginalFlywaySystemProperties(); + System.setProperty("p1r.flyway.url", maskedUrl(url)); + System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : ""); + } + + private static void captureOriginalFlywaySystemProperties() { + if (originalFlywayPropertiesCaptured) { + return; + } + // 该 IT 会为日志脱敏 p1r.flyway.*;先保存原值,避免同一 Surefire JVM 中污染后续 Flyway IT。 + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + // 恢复用户传入的原始连接属性,让组合运行的 Flyway 验收测试继续读取真实 _test 库地址。 + restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty); + restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty); + } + + private static void restoreSystemProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + return; + } + System.setProperty(name, value); + } + + private static void silenceFlywayInfoLogs() { + try { + Object flywayLogger = LoggerFactory.getLogger("org.flywaydb"); + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object warnLevel = levelClass.getField("WARN").get(null); + flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel); + } catch (ReflectiveOperationException | LinkageError ignored) { + // 日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。 + } + } + + private record SeedFacts(Long workId, + Long chapterId, + Long blockId, + Long otherWorkId, + Long otherWorkChapterId, + Long otherWorkBlockId, + Long mismatchedBlockId, + Long otherTenantWorkId, + Long otherTenantChapterId, + Long otherTenantBlockId) { + } + + private record ContentFactSnapshot(List works, + List chapters, + List blocks, + List governanceActions, + List commands, + List outboxEvents) { + } + + private record GovernanceActionFact(Long tenantId, + Long workId, + Long operatorUserId, + String commandId, + String auditLogId, + String action, + String reason, + String targetScope, + String targetIds, + Integer expectedVersion, + String status) { + } + + private record CommandFact(Long tenantId, + String commandId, + String commandType, + Long ownerUserId, + String targetType, + Long targetId, + String requestHash, + String resultSnapshot) { + } + + @FunctionalInterface + private interface CheckedOperation { + + void run() throws Exception; + } + + private record AdminRiskActionCompletedApprovalSettings(String jdbcUrl, + String jdbcUser, + String jdbcPassword, + String flywayLocations) { + + static AdminRiskActionCompletedApprovalSettings fromPropertiesAndEnvironment() { + assertNoPasswordSystemProperties(); + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String locations = requiredProperty("p1r.flyway.locations"); + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + return new AdminRiskActionCompletedApprovalSettings(url, user, password, locations); + } + } + + static class TestSecurityFrameworkService implements SecurityFrameworkService { + + private final AtomicBoolean permissionAllowed = new AtomicBoolean(true); + private final AtomicReference lastPermission = new AtomicReference<>(); + + void allowPermissions() { + permissionAllowed.set(true); + lastPermission.set(null); + } + + void denyPermissions() { + permissionAllowed.set(false); + lastPermission.set(null); + } + + String lastPermission() { + return lastPermission.get(); + } + + @Override + public boolean hasPermission(String permission) { + lastPermission.set(permission); + return permissionAllowed.get(); + } + + @Override + public boolean hasAnyPermissions(String... permissions) { + lastPermission.set(permissions == null || permissions.length == 0 ? null : permissions[0]); + return permissionAllowed.get(); + } + + @Override + public boolean hasRole(String role) { + return false; + } + + @Override + public boolean hasAnyRoles(String... roles) { + return false; + } + + @Override + public boolean hasScope(String scope) { + return false; + } + + @Override + public boolean hasAnyScopes(String... scope) { + return false; + } + } + + @SpringBootConfiguration + @EnableMethodSecurity(securedEnabled = true) + @ImportAutoConfiguration({ + JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + TransactionAutoConfiguration.class, + RestTemplateAutoConfiguration.class, + WebMvcAutoConfiguration.class, + MuseDataSourceAutoConfiguration.class, + MuseMybatisAutoConfiguration.class, + MybatisPlusAutoConfiguration.class, + MybatisPlusJoinAutoConfiguration.class, + MuseWebAutoConfiguration.class + }) + @Import({ + AdminContentController.class, + ContentAdminServiceImpl.class, + ContentCommandServiceImpl.class, + ContentAuditServiceImpl.class, + SpringUtil.class + }) + static class AdminRiskActionCompletedApprovalConfiguration { + + @Bean("ss") + TestSecurityFrameworkService securityFrameworkService() { + return new TestSecurityFrameworkService(); + } + + @Bean + TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + @Bean + ApiErrorLogCommonApi apiErrorLogCommonApi() { + return new ApiErrorLogCommonApi() { + @Override + public CommonResult createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) { + return CommonResult.success(true); + } + }; + } + } +} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java index 562f1809..b6460e1f 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java @@ -93,7 +93,7 @@ class P1rContentRealApiGateTest { "savePlanningItem" ); - /** 已批准的 Content 用户端核心、Planning 与 Admin Read 接口,仍保持 operation-level approval。 */ + /** 已批准的 Content 用户端核心、Planning、Admin Read 与 Admin RiskAction 接口,仍保持 operation-level approval。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -107,7 +107,8 @@ class P1rContentRealApiGateTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); /** 必须继续保持 needs_verification 的代表性未批准 Content operation,防止 CRUD、导出、AI planning/style 和 admin 写入误推进。 */ @@ -121,7 +122,7 @@ class P1rContentRealApiGateTest { "exportWork", "listPlanningCandidates", "createStyleCheck", - "adminRiskAction" + "getStyleCheckResult" ); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -204,7 +205,7 @@ class P1rContentRealApiGateTest { } /** - * 验证当前 completed approval 只推进已批准的 13 个 Content operation,其余 38 个仍保持 needs_verification。 + * 验证当前 completed approval 只推进已批准的 14 个 Content operation,其余 37 个仍保持 needs_verification。 * * @throws IOException 读取覆盖报告失败时抛出 */ @@ -228,8 +229,8 @@ class P1rContentRealApiGateTest { operationId + " 未获 Content operation-level 批准,必须继续保持 needs_verification"); } } - assertEquals(13, completed, "Content Admin Read operation-level completed approval 后只能推进 13 个 operation"); - assertEquals(38, needsVerification, "Content 剩余 38 个 operation 必须继续 needs_verification"); + assertEquals(14, completed, "Content Admin RiskAction operation-level completed approval 后只能推进 14 个 operation"); + assertEquals(37, needsVerification, "Content 剩余 37 个 operation 必须继续 needs_verification"); for (String operationId : REPRESENTATIVE_UNAPPROVED_CONTENT_OPERATIONS) { assertContentOperationStatus(operationId, "needs_verification"); } diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java index e230cdde..430d1625 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java @@ -45,7 +45,7 @@ class P1rEventsRealApiGateTest { "unfavoriteAsset" ); - /** Content completed approval 当前只允许推进 13 个 operation。 */ + /** Content completed approval 当前只允许推进 14 个 operation。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -59,7 +59,8 @@ class P1rEventsRealApiGateTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -105,10 +106,10 @@ class P1rEventsRealApiGateTest { void should_keep_completed_approval_summary_at_approved_operation_boundary() throws IOException { JsonNode summary = readReport().path("summary"); - assertEquals(144, summary.path("completedOperations").asInt(), - "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 13"); - assertEquals(89, summary.path("needsVerificationOperations").asInt(), - "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 10、Market 4 和 Content 13"); + assertEquals(145, summary.path("completedOperations").asInt(), + "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); + assertEquals(88, summary.path("needsVerificationOperations").asInt(), + "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 10、Market 4 和 Content 14"); assertEquals(0, summary.path("incompleteOperations").asInt(), "Events 退出 placeholder 后不应再留下 incomplete operation"); assertEquals(0, summary.path("genericPersistenceOperations").asInt(), @@ -172,7 +173,7 @@ class P1rEventsRealApiGateTest { } @Test - void should_keep_content_partial_approval_at_10_completed_operations() throws IOException { + void should_keep_content_partial_approval_at_14_completed_operations() throws IOException { JsonNode contentOperations = readContentOperations(); int completed = 0; int needsVerification = 0; @@ -192,8 +193,8 @@ class P1rEventsRealApiGateTest { "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); } } - assertEquals(13, completed, "Content Admin Read completed approval 后只能推进 13 个 operation"); - assertEquals(38, needsVerification, "Content 剩余 38 个 operation 必须继续 needs_verification"); + assertEquals(14, completed, "Content Admin RiskAction completed approval 后只能推进 14 个 operation"); + assertEquals(37, needsVerification, "Content 剩余 37 个 operation 必须继续 needs_verification"); } private JsonNode readSingleEventsOperation() throws IOException { diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java index 301b04c3..cd9eaded 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java @@ -107,7 +107,7 @@ class P1rKnowledgeRealApiGateTest { "unfavoriteAsset" ); - /** Content completed approval 当前只允许推进 13 个 operation。 */ + /** Content completed approval 当前只允许推进 14 个 operation。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -121,7 +121,8 @@ class P1rKnowledgeRealApiGateTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -183,14 +184,14 @@ class P1rKnowledgeRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(144, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 13"); + assertEquals(145, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); } @Test void should_keep_existing_dedicated_domains_unchanged() throws IOException { assertDomainStatusCount("ai", 41, "completed"); - assertContentStatusCount(13, 38); + assertContentStatusCount(14, 37); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); assertMarketStatusCount(4, 28); @@ -219,8 +220,8 @@ class P1rKnowledgeRealApiGateTest { } } assertEquals(51, total, "Content operation 数量必须保持 51"); - assertEquals(expectedCompleted, completed, "Content Admin Read completed approval 后只能推进 13 个 operation"); - assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 38 个 operation 必须继续 needs_verification"); + assertEquals(expectedCompleted, completed, "Content Admin RiskAction completed approval 后只能推进 14 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 37 个 operation 必须继续 needs_verification"); } @Test diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java index ccc3f1b5..4b5d5f0b 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java @@ -78,7 +78,7 @@ class P1rMarketRealApiGateTest { "unfavoriteAsset" ); - /** Content completed approval 当前只允许推进 13 个 operation。 */ + /** Content completed approval 当前只允许推进 14 个 operation。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -92,7 +92,8 @@ class P1rMarketRealApiGateTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -150,15 +151,15 @@ class P1rMarketRealApiGateTest { void should_keep_completion_summary_owned_by_approved_scopes_only() throws IOException { JsonNode report = readReport(); - assertEquals(144, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 13"); + assertEquals(145, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); } @Test void should_keep_existing_domain_statuses_unchanged() throws IOException { assertDomainStatusCount("ai", 41, "completed"); assertDomainStatusCount("knowledge", 59, "completed"); - assertContentStatusCount(13, 38); + assertContentStatusCount(14, 37); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); } @@ -186,8 +187,8 @@ class P1rMarketRealApiGateTest { } } assertEquals(51, total, "Content operation 数量必须保持 51"); - assertEquals(expectedCompleted, completed, "Content Admin Read completed approval 后只能推进 13 个 operation"); - assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 38 个 operation 必须继续 needs_verification"); + assertEquals(expectedCompleted, completed, "Content Admin RiskAction completed approval 后只能推进 14 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 37 个 operation 必须继续 needs_verification"); } @Test diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java index bd4af782..4349e5d9 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java @@ -76,7 +76,7 @@ class P1rMetaRealApiGateTest { "activateFunctionChainVersion" ); - /** Content completed approval 当前只允许推进 13 个 operation。 */ + /** Content completed approval 当前只允许推进 14 个 operation。 */ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( "listWorks", "getWork", @@ -90,7 +90,8 @@ class P1rMetaRealApiGateTest { "savePlanningItem", "adminListWorks", "adminGetWork", - "adminListChapters" + "adminListChapters", + "adminRiskAction" ); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -173,12 +174,12 @@ class P1rMetaRealApiGateTest { } /** - * 验证 Content 只推进当前已批准的 13 个 operation,防止 Meta gate 漏掉 Content 未批准项回退检查。 + * 验证 Content 只推进当前已批准的 14 个 operation,防止 Meta gate 漏掉 Content 未批准项回退检查。 * * @throws IOException 读取覆盖报告失败时抛出 */ @Test - void should_keep_content_partial_approval_at_13_completed_operations() throws IOException { + void should_keep_content_partial_approval_at_14_completed_operations() throws IOException { JsonNode contentOperations = readContentOperations(); int completed = 0; int needsVerification = 0; @@ -198,8 +199,8 @@ class P1rMetaRealApiGateTest { "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); } } - assertEquals(13, completed, "Content Admin Read completed approval 后只能推进 13 个 operation"); - assertEquals(38, needsVerification, "Content 剩余 38 个 operation 必须继续 needs_verification"); + assertEquals(14, completed, "Content Admin RiskAction completed approval 后只能推进 14 个 operation"); + assertEquals(37, needsVerification, "Content 剩余 37 个 operation 必须继续 needs_verification"); } private JsonNode readMetaOperations() throws IOException { diff --git a/muse-cloud/scripts/p1r-audit-api-coverage.py b/muse-cloud/scripts/p1r-audit-api-coverage.py index 948812ac..0fc7aee9 100755 --- a/muse-cloud/scripts/p1r-audit-api-coverage.py +++ b/muse-cloud/scripts/p1r-audit-api-coverage.py @@ -93,6 +93,7 @@ APPROVED_COMPLETED_OPERATIONS = { "content:adminListWorks", "content:adminGetWork", "content:adminListChapters", + "content:adminRiskAction", }