From 3ced553cb2188f9ca705e54a50e5e2d6caf35c13 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 12 Jun 2026 13:04:42 +0800 Subject: [PATCH] =?UTF-8?q?test(p1r):=20=E6=94=B6=E5=8F=A3=20Content=20?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=89=B9=20completed=20approval=20=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agent-specs/.agent | 20 + ...06-11-P1RContentCompletedApproval审阅版.md | 450 +++++++ ...06-12-P1RContentCompletedApproval执行版.md | 857 ++++++++++++ docs/memorys/2026-06-12-P1RContent状态推进.md | 378 ++++++ .../superpowers/reports/p1r-api-coverage.json | 38 +- docs/superpowers/reports/p1r-api-coverage.md | 22 +- .../api/P1rAccountRealApiGateTest.java | 57 + .../framework/api/P1rAiRealApiGateTest.java | 45 +- .../api/P1rApiCoverageReportTest.java | 40 +- .../P1rContentCoreCompletedApprovalIT.java | 1176 +++++++++++++++++ ...ContentEventsPublishFlywayMigrationIT.java | 41 + .../api/P1rContentRealApiGateTest.java | 57 +- .../api/P1rEventsRealApiGateTest.java | 49 +- .../api/P1rKnowledgeRealApiGateTest.java | 45 +- .../api/P1rMarketRealApiGateTest.java | 45 +- .../framework/api/P1rMetaRealApiGateTest.java | 57 + muse-cloud/scripts/p1r-audit-api-coverage.py | 9 + 17 files changed, 3334 insertions(+), 52 deletions(-) create mode 100644 docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md create mode 100644 docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md create mode 100644 docs/memorys/2026-06-12-P1RContent状态推进.md create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java diff --git a/docs/agent-specs/.agent b/docs/agent-specs/.agent index fd08f1d1..13bf0a3c 100644 --- a/docs/agent-specs/.agent +++ b/docs/agent-specs/.agent @@ -163,3 +163,23 @@ P1R Market Completed Approval 修订执行版最终 fresh review 已双 PASS:B P1R Market 第一批 operation-level completed approval implementation 当前状态:用户已批准执行版四项前置条件,本轮只把 4 个 Market operation 加入 operation-level completed approval,未把 `market` 加入 domain-level allowlist,未修改 Market 业务实现或数据库迁移。4 个 completed operation 为 `getMarketplaceAsset`、`listMarketplaceCategories`、`favoriteAsset`、`unfavoriteAsset`;`listMarketplaceAssets`、`listMarketplaceRecommendations` 以及 purchase/install/handoff/governance/publish/review/appeal/projection 相关 28 个 Market operation 继续 `dedicated/needs_verification`。OpenAPI 仅在 `docs/api-contracts/market/openapi.yaml` 为 `favoriteAsset` / `unfavoriteAsset` 增加 required `X-Command-Id` header,修正已有实现强校验与合同不一致;scanner 仍保持 `APPROVED_COMPLETED_DOMAINS={"ai","knowledge"}`,只追加上述 4 个 `market:*` operation key。TDD RED 已验证旧 report 下 focused P1R gates 因 expected completed=131 but was 127、Market expected 4 completed but was 0 等断言失败;GREEN 后 scanner 输出 summary `233/131/102/0/0/0`,Market 为 `completed=4`、`needs_verification=28`。验证 evidence:`python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` 通过;Market focused verification 为 Market module 26/26 pass 与 muse-server 25/25 pass;新增 `P1rMarketDiscoveryFavoriteCompletedApprovalIT` 使用 MockMvc HTTP 入口与真实 PostgreSQL `_test` 库 `muse_p1r_market_discovery_favorite_completed_approval_test` 为 9/9 pass,覆盖 detail/categories/favorite/unfavorite happy/error/replay/inactive/no-write;增强 `P1rMarketFlywayMigrationIT` 使用真实 PostgreSQL `_test` 库 `muse_p1r_market_completed_approval_test` 为 4/4 pass,输出 V1-V15 clean migrate 并验证 favorite 表、索引、约束、trigger、duplicate tenant/user/asset 拒绝、duplicate tenant/command_id 拒绝和 inactive update trigger 可执行;P1R mixed gates `P1rApiCoverageReportTest,P1rMarketRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest` 为 33/33 pass;XML 防空跑覆盖 13 个目标 XML,全部 failures/errors/skipped=0;`git diff --check` 通过;OpenAPI diff 仅限 Market favorite/unfavorite `X-Command-Id` header。fresh correctness review 指出新增 Discovery IT 脱敏 `p1r.flyway.url/user` 后未恢复会污染同 JVM 后续 Flyway IT;该问题已修复为 Discovery IT 与 Flyway IT 均保存原始 system property 并在 `@AfterAll` 恢复,组合验证使用真实 PostgreSQL `_test` 库 `muse_p1r_market_combined_completed_approval_test`,普通顺序与 `-Dsurefire.runOrder=reversealphabetical` 强制反向顺序均通过,均为 13/13 pass。fresh data/testing review 提到“真实 HTTP”若理解为 socket 级 live server则当前证据不足;按执行版原文,本轮要求是 WebApplicationContext/MockMvc + real mapper + `_test` DB,不是 live socket server,memory 已将措辞收窄为 MockMvc HTTP 入口证据;其提出的 status CHECK / FK 属于新增迁移范围,当前执行版明确禁止改迁移,作为后续 Market schema hardening 风险保留,不作为本轮 approved diff;Dirac 复审提出 raw Surefire XML 会记录原始 host/user 但未泄露 password,当前作为 P3 artifact 边界记录:raw XML 不应直接外发,外发前需清洗 host/user 或后续改造 URL/user 传递方式。新增 memory:`docs/memorys/2026-06-11-P1RMarket状态推进.md`。当前尚未提交、未 push;下一步必须重新派发 fresh implementation spec/correctness review + fresh implementation quality/data-integrity/testing review,双 PASS 前不得提交、push 或宣称 Market 32/32、Content、Account remaining 或总 P1R completed。 P1R Market 第一批 operation-level completed approval fresh implementation review 已双 PASS:Raman spec/correctness review PASS、Hubble data-integrity/testing review PASS,均无 P0/P1/P2/P3 阻塞项。Raman 已验证正确 worktree、scanner 未加入 `market` domain allowlist、只追加 4 个 `market:*` operation key、coverage summary 为 `233/131/102/0/0/0`、Market 为 `4 completed / 28 needs_verification`、`listMarketplaceAssets` 与 `listMarketplaceRecommendations` 继续 `dedicated/needs_verification`、OpenAPI diff 仅限 favorite/unfavorite required `X-Command-Id`、Market main/java 与 SQL diff 为空、文档未宣称 Market 32/32 或总 P1R completed。Hubble 已验证 Discovery IT 使用 WebApplicationContext + MockMvc + real mapper/service + PostgreSQL `_test`,Flyway IT 覆盖 favorite unique/index/trigger/duplicate/no-write 证据,双向组合顺序 13/13 pass 关闭同 JVM system property 污染风险,13 个目标 XML 合计 85 tests 且 failures/errors/skipped 均为 0。非阻塞注意事项:favorite `status` CHECK 与 FK 属于后续 schema hardening;raw Surefire XML 暴露 DB host/user 但未泄露 password,外发前需清洗。该双 PASS 只代表本轮 Market 第一批 4 个 operation-level completed approval 具备收口条件;不代表 Market 32/32、Content、Account remaining 或总 P1R completed。当前尚未提交、未 push;提交或 push 仍需用户明确指令。 + +P1R Content Completed Approval 已按用户确认启动审阅版:`docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md`。本审阅版只冻结 Content 51 个 `dedicated/needs_verification` operation 的 completed approval 证据标准和推荐路线,不修改 OpenAPI、scanner、coverage report 或 Content 业务实现,也不推进 Content/Account remaining/Market remaining/总 P1R completed。只读盘点事实:当前 coverage summary 为 `233/131/102/0/0/0`,Content 51 个 operation 全部仍为 `dedicated/needs_verification`;P1R-7f 已给 `saveBlock -> source attribution active -> Content outbox -> Events -> SSE owner visible` 提供 needs-verification evidence,但仍不等同 `saveBlock` completed。审阅版推荐不要做 Content 51/51 domain-level completed approval;第一批推荐只做 8 个 operation:`listWorks`、`getWork`、`listChapters`、`getChapter`、`listBlocks`、`getBlock`、`saveBlock`、`getBlockSourceAttribution`。只读子代理提出 15 个用户端 CRUD operation 也具备实现候选性,但当前 `createWork` / `updateWork` / `createChapter` 等写命令存在 OpenAPI 或 coverage `requiresCommandId` 口径与服务端 VO 不一致风险,因此审阅版默认不把创建/更新/删除合同修正夹带进第一批。导入/导出/解析/Meta projection/AI planning candidate/style/suggestion 均因 FileService、AI、Knowledge、Meta 等外部 owner closure 缺口暂缓;admin read 和正式 planning section 可作为后续独立切片。下一步必须对该 Content 审阅版做 fresh spec/scope review + fresh quality/feasibility review;双 PASS 前不得写执行版、实现、提交、push 或修改 scanner/report/gate。 + +P1R Content Completed Approval 审阅版首轮 review:Feynman spec/scope review PASS,但 Herschel quality/feasibility review FAIL;Feynman PASS 因后续文档修订失效,不能计入最终 PASS gate。Herschel 的 3 个 P1 和 1 个 P2 已只读验证并修订:第一,`saveBlock` completed approval 依赖 P1R-7f source/event 证据,执行版必须把 `P1rContentEventsPublishMigrationSqlTest`、`P1rContentEventsPublishDependencyTest`、`P1rContentEventsPublishEndToEndTest`、`P1rContentEventsPublishFlywayMigrationIT` 纳入必跑和 XML 防空跑;第二,HTTP/DB IT 必须覆盖 tenant 与 owner 双隔离,至少 seed 两个 tenant,验证同 owner 不同 tenant 不可见、跨 tenant `saveBlock` 不写 block/source/outbox、写入行 `tenant_id` 正确;第三,Content completed IT 与 Content events Flyway IT 必须保存并在 `@AfterAll` 恢复 `p1r.flyway.url/user`,做普通顺序与反序组合验证,raw Surefire XML 只能作为本地证据,外发前清洗 DB host/user;第四,执行版必须明确 Flyway current=V21、成功 SQL migration 数量 21、V21 outbox 表/索引/约束/trigger 与 invalid insert 拒绝,且 V9 planning schema 不属于本第一批业务验收范围。由于审阅版已在 review 后修订,下一步必须重新派发 fresh spec/scope review + fresh quality/feasibility review;双 PASS 前不得写执行版、实现、提交、push 或推进 Content completed。 + +P1R Content Completed Approval 审阅版第二轮 review:Bernoulli spec/scope review PASS,但 Wegener quality/feasibility/testing review FAIL;Bernoulli PASS 因后续文档修订失效,不能计入最终 PASS gate。Wegener 的 2 个 P1 已只读验证并修订:第一,执行版必须把 `P1rAccountRealApiGateTest`、`P1rMetaRealApiGateTest` 纳入必跑、allowed diff 审批项和 XML 防空跑边界,防止 Content 状态推进时漏掉 Account remaining 23 与 Meta completed 防回退断言;第二,8 个候选 operation 的 runtime evidence 必须包含 shadow-path matrix,覆盖列表空页、资源不存在、path mismatch、跨 owner / 跨 tenant 拒绝,以及 `saveBlock` 缺 `commandId`、缺 `expectedRevision`、缺 `sourceSnapshot`、revision conflict 等 no-write 负路径。由于审阅版已在 review 后修订,下一步必须重新派发 fresh spec/scope review + fresh quality/feasibility review;双 PASS 前不得写执行版、实现、提交、push 或推进 Content completed。 + +P1R Content Completed Approval 审阅版第三轮 review:Leibniz quality/feasibility/testing review FAIL,P1 有效并已只读验证:`saveBlock` 与 `getBlockSourceAttribution` 都通过 `requireOwnedBlock(userId, workId, blockId)` 绑定 path workId 与 block.workId,因此执行版必须覆盖“同 owner 但 block 属于另一个 work”的 path mismatch;`saveBlock` 要断言 block/source/outbox/command no-write 或事务回滚后行数不变,`getBlockSourceAttribution` 要断言错误响应且不泄露 attribution。由于审阅版已在 review 后修订,本轮任何已返回或未返回的 PASS 都不能计入最终 PASS gate;下一步必须重新派发 fresh spec/scope review + fresh quality/feasibility review。 + +P1R Content Completed Approval 审阅版第四轮 fresh review 已双 PASS:Fermat spec/scope review PASS、Faraday quality/feasibility/testing review PASS,均无 P0/P1/P2/P3 blocker。Fermat 已验证审阅版仍停留在 review/scope 决策层,第一批 8 个 operation、`N=8` 目标值、非首批 scope 排除、`P1rAccountRealApiGateTest` / `P1rMetaRealApiGateTest` 非目标防回退、P1 修订点和 `.agent` 失效链路均自洽。Faraday 已验证后续执行版硬条件覆盖 MockMvc HTTP 入口、真实 PostgreSQL `_test`、真实 mapper/service、Flyway V1-V21、V21 outbox schema/trigger/invalid insert、fresh XML 防空跑、system property restore 与普通/反序组合验证;8 ops runtime matrix 已覆盖 happy/empty/missing/path mismatch/same-owner wrong workId/cross owner/cross tenant/写命令缺字段/revision conflict/no-write/outbox no-duplicate。该双 PASS 只代表 Content 审阅版可进入执行版编写;不代表 Content completed,也不允许未获批准前修改 scanner/report/OpenAPI/业务实现/gate tests。 + +P1R Content Completed Approval 执行版已新增:`docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md`。执行版只锁定第一批 8 个 operation-level approval:`listWorks`、`getWork`、`listChapters`、`getChapter`、`listBlocks`、`getBlock`、`saveBlock`、`getBlockSourceAttribution`;不把 `content` 加入 domain-level completed allowlist。目标 summary 仅作为获批后执行目标:`233/139/94/0/0/0`,Content 目标为 `completed=8`、`needsVerification=43`。执行版明确要求用户再次批准 4 项后才可实施:8 ops 清单、operation-level approval、allowed diff、6 个 mixed gate 的 summary / Content / 非目标域防回退断言同步。当前只写执行方案和 `.agent`,未修改 OpenAPI、scanner、coverage report、Content 业务实现、SQL migration 或 gate test。下一步必须 fresh execution spec/scope review + fresh execution quality/feasibility review;双 PASS 前不得实现、提交、push 或推进 Content completed。 + +P1R Content Completed Approval 执行版 fresh review 已双 PASS:Rawls execution spec/scope review PASS、Planck execution quality/feasibility/testing review PASS,均无 P0/P1/P2/P3 blocker。Rawls 已验证正确 worktree、HEAD `0032774`、执行版严格锁定第一批 8 个 operation、不做 Content 51/51、不加入 `content` domain-level allowlist、目标值为获批后 `233/139/94` 与 Content `8 completed / 43 needs_verification`、43 个保留 `needs_verification` operation 与 coverage report 机械比对无缺失或额外项、allowed/prohibited diff 自洽且保护面无 diff。Planck 已验证 TDD RED/GREEN、Content HTTP+DB `_test` gate、`saveBlock` 依赖的 source/event/Flyway V21 mandatory gate、system property restore、普通/反序组合验证、XML 防空跑和 diff gate 均可执行;其只读审阅未运行 Maven/Flyway,后续 implementation 仍必须按执行版实际跑完验证。该双 PASS 只代表 Content 执行版可进入用户批准点;不代表 Content completed,也不允许未获批准前修改 scanner/report/OpenAPI/业务实现/SQL migration/gate tests。下一步必须由用户明确批准四项:1) 8 ops 清单;2) operation-level approval 且不加 `content` domain allowlist;3) 执行版 allowed diff;4) 6 个 mixed gate 的 summary / Content / 非目标域防回退断言同步。 + +P1R Content 第一批 operation-level completed approval implementation 当前状态:用户已批准执行版四项前置条件,本轮只把 8 个 Content operation 加入 operation-level completed approval,未把 `content` 加入 domain-level allowlist,未修改 OpenAPI、Content 业务实现或 SQL migration。8 个 completed operation 为 `listWorks`、`getWork`、`listChapters`、`getChapter`、`listBlocks`、`getBlock`、`saveBlock`、`getBlockSourceAttribution`;其余 43 个 Content operation 继续 `dedicated/needs_verification`。scanner 仍保持 `APPROVED_COMPLETED_DOMAINS={"ai","knowledge"}`,只追加 8 个 `content:*` operation key;coverage summary 已推进为 `233/139/94/0/0/0`,Content 为 `8 completed / 43 needs_verification`。TDD RED 已验证旧 report 下 focused P1R gates 因目标 summary 与 Content 8 个 operation 尚未 completed 而失败;GREEN 后 `python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` 通过并重生成 report。新增 `P1rContentCoreCompletedApprovalIT` 使用 MockMvc HTTP 入口、真实 mapper/service 与 PostgreSQL `_test` 库 `muse_p1r_content_core_completed_approval_test`,当前目标为 12/12 pass,覆盖 8 个 operation 的 happy/empty/missing/path mismatch/same-owner wrong work/cross owner/cross tenant、`saveBlock` command/revision/source/outbox/command replay/conflict/no-write、`getBlockSourceAttribution` active/current/empty/no-write;fresh testing review(Hooke)指出 chapter/block missing 负路径缺显式 HTTP 断言,已补 `missing work chapter list`、`missing chapter detail`、`missing chapter block list`、`missing block detail` 和 no-write outbox 断言。修复后已重跑 Core IT:`P1rContentCoreCompletedApprovalIT` tests=12、failures=0、errors=0、skipped=0,XML mtime 在本次运行窗口内。增强 `P1rContentEventsPublishFlywayMigrationIT` 保存并恢复 `p1r.flyway.url/user`,V1-V21 clean migrate 4/4 pass,验证 V21 outbox 表、索引、约束、trigger、`attempt_count<0` 与 `max_attempt<=0` invalid insert 拒绝。Hooke 修复后已重跑组合验证:普通顺序与 `-Dsurefire.runOrder=reversealphabetical` 均通过,实际 Surefire 顺序两次均为 Flyway IT 先、Core IT 后,合计 16/16 pass,证明 Flyway 脱敏/恢复未污染后续 Core IT;该参数未改变实际顺序,留痕不得写成已证明 Core->Flyway 反向顺序。Content focused suite 已 fresh XML 防空跑;P1R mixed gates 已重跑并 XML 防空,49/49 pass。scanner `--check` 已重跑,summary 仍为 `233/139/94/0/0/0`,Content 仍为 `8 completed / 43 needs_verification`,代表性非目标 operation 仍为 `dedicated/needs_verification`。protected OpenAPI diff 为空,`git diff --check` 通过。新增 memory:`docs/memorys/2026-06-12-P1RContent状态推进.md`。当前还需要重新派发 fresh implementation spec/correctness review + quality/data-integrity/testing review;双 PASS 前不得提交、push 或宣称 Content 51/51、Account remaining、Market remaining 或总 P1R completed。 + +P1R Content 第一批 implementation fresh review 第一轮结果:Bohr correctness review FAIL、Euclid testing/data-integrity review FAIL,同源 P1 有效:`P1rContentCoreCompletedApprovalIT` 未覆盖执行版 mandatory 的 `saveBlock` 缺 `expectedRevision` 与缺 `sourceSnapshot` 两个 HTTP+DB no-write 负路径。已按 `superpowers:receiving-code-review` 验证反馈有效:执行版第 513 行明确要求这两项,`BlockSaveReqVO` 对两字段均有 required 语义。已最小修复:在 `should_rejectSaveBlockValidationRevisionConflictAndCommandConflictWithoutDirtyWrite` 中补 `p1r-content-missing-expected-revision` 与 `p1r-content-missing-source-snapshot` 两个请求,并复用 `assertSnapshotUnchanged` 断言 block/source/outbox/command 未写入。修复后已重跑 `P1rContentCoreCompletedApprovalIT` tests=12、failures=0、errors=0、skipped=0;已重跑 Core+Flyway 组合验证,tests=16、failures=0、errors=0、skipped=0,两个目标 XML 均为新产物。当前仍需重新派发 fresh implementation spec/correctness review + quality/data-integrity/testing review;双 PASS 前不得提交、push 或宣称 Content 51/51、Account remaining、Market remaining 或总 P1R completed。 + +P1R Content 第一批 operation-level completed approval fresh implementation re-review 已双 PASS:Mendel correctness review PASS,无 findings、无 residual risks;Anscombe testing/data-integrity review PASS,无 findings、无 testing gaps。Mendel 已验证 Bohr/Euclid P1 已关闭,`P1rContentCoreCompletedApprovalIT` 覆盖缺 `expectedRevision` 与缺 `sourceSnapshot` 两个 `saveBlock` HTTP 负路径并复用 no-write 断言,coverage summary 为 `233/139/94/0/0/0`,Content 精确为 `8 completed / 43 needs_verification`,scanner 未加入 `content` domain allowlist,只追加 8 个 `content:*` operation key,OpenAPI、Content main/java 与 SQL migration diff 均为空,Core 12/12、Core+Flyway 16/16、P1R mixed gates 49/49 通过。Anscombe 已验证 testing/data-integrity 口径,无新增 blocker;唯一残余风险是 raw Surefire XML 仍可能包含本地 DB host/user,但未发现 password 泄露,外发前需清洗。该双 PASS 只代表 Content 第一批 8 个 operation-level completed approval 具备提交条件;不代表 Content 51/51、Account remaining、Market remaining 或总 P1R completed。提交或 push 仍需用户明确指令。 diff --git a/docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md b/docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md new file mode 100644 index 00000000..7e16ba23 --- /dev/null +++ b/docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md @@ -0,0 +1,450 @@ +# P1R Content Completed Approval 审阅版 + +日期:2026-06-11 + +## 结论 + +不建议把 Content 51 个 operation 一次性整域推进 `completed`。 + +推荐把 Content Completed Approval 拆成 operation-level 或证据切片推进。第一候选是用户端核心作品/章节/Block 读链路,加上已经在 P1R-7f 形成 source owner propagation evidence 的 `saveBlock` 与 `getBlockSourceAttribution`。第一批建议限定为 8 个 operation: + +```text +listWorks +getWork +listChapters +getChapter +listBlocks +getBlock +saveBlock +getBlockSourceAttribution +``` + +本审阅版只冻结 Content completed approval 的范围判断、证据缺口、推荐审批粒度和后续执行版要求。不修改 OpenAPI,不修改 scanner,不修改 coverage report,不修改业务实现,也不把 Content 或其它 domain 推进 `completed`。 + +```mermaid +flowchart TB + Start["当前 coverage
Content 51 dedicated / needs_verification"] --> Review["Content completed approval 审阅版
冻结证据标准"] + Review --> Split{"是否整域 51/51 completed?"} + Split -->|否,推荐| OpLevel["按证据切片 operation-level approval"] + Split -->|是,不推荐| Domain["domain-level approval
需 CRUD / source / admin / import / export / parse / planning / meta 全闭环"] + OpLevel --> First["第一候选 8 ops
work/chapter/block read + saveBlock + attribution read"] + First --> Contract{"是否触碰创建/更新/删除合同?"} + Contract -->|否| Exec["执行版
只写 operation allowlist + real HTTP/DB gate"] + Contract -->|是| ContractFix["先单独审批 OpenAPI/report commandId 合同修正"] + Exec --> FreshReview["fresh spec/scope review
fresh quality/feasibility review"] + FreshReview --> UserApproval{"用户明确批准
Content 状态推进?"} + UserApproval -->|否| Stay["保持 Content 51 needs_verification"] + UserApproval -->|是| Change["最小修改 scanner/report/gates
运行 focused + P1R + _test + XML 防空跑"] +``` + +## 当前事实状态 + +工作区: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +当前分支与远端同步,HEAD 为: + +```text +0032774 test(p1r): 收口 Market 第一批 completed approval 门禁 +``` + +当前 coverage summary: + +```text +total=233 +completed=131 +needsVerification=102 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +当前按 domain 聚合: + +```text +account total=33 completed=10 needsVerification=23 +ai total=41 completed=41 needsVerification=0 +content total=51 completed=0 needsVerification=51 +events total=1 completed=1 needsVerification=0 +knowledge total=59 completed=59 needsVerification=0 +market total=32 completed=4 needsVerification=28 +meta total=16 completed=16 needsVerification=0 +``` + +Content 51 个 operation 当前全部为 `dedicated / needs_verification`: + +| operationId | Method | Path | 当前状态 | +|---|---|---|---| +| `adminListExportTasks` | `GET` | `/admin-api/muse/content/export-tasks` | `dedicated / needs_verification` | +| `adminListImportTasks` | `GET` | `/admin-api/muse/content/import-tasks` | `dedicated / needs_verification` | +| `adminListWorks` | `GET` | `/admin-api/muse/content/works` | `dedicated / needs_verification` | +| `adminGetWork` | `GET` | `/admin-api/muse/content/works/{workId}` | `dedicated / needs_verification` | +| `adminListChapters` | `GET` | `/admin-api/muse/content/works/{workId}/chapters` | `dedicated / needs_verification` | +| `adminRiskAction` | `POST` | `/admin-api/muse/content/works/{workId}/risk-actions` | `dedicated / needs_verification` | +| `confirmChapterParseResult` | `POST` | `/app-api/muse/chapter-parse-results/{resultId}/confirm` | `dedicated / needs_verification` | +| `rejectChapterParseResult` | `POST` | `/app-api/muse/chapter-parse-results/{resultId}/reject` | `dedicated / needs_verification` | +| `downloadExportPackage` | `GET` | `/app-api/muse/downloads/{credentialId}` | `dedicated / needs_verification` | +| `getExportTask` | `GET` | `/app-api/muse/export-tasks/{taskId}` | `dedicated / needs_verification` | +| `getImportTask` | `GET` | `/app-api/muse/import-tasks/{taskId}` | `dedicated / needs_verification` | +| `getParseJob` | `GET` | `/app-api/muse/parse-jobs/{jobId}` | `dedicated / needs_verification` | +| `listParseJobChapters` | `GET` | `/app-api/muse/parse-jobs/{jobId}/chapters` | `dedicated / needs_verification` | +| `batchConfirmChapters` | `POST` | `/app-api/muse/parse-jobs/{jobId}/chapters/batch-confirm` | `dedicated / needs_verification` | +| `retryParseJob` | `POST` | `/app-api/muse/parse-jobs/{jobId}/retry` | `dedicated / needs_verification` | +| `listWorks` | `GET` | `/app-api/muse/works` | `dedicated / needs_verification` | +| `createWork` | `POST` | `/app-api/muse/works` | `dedicated / needs_verification` | +| `deleteWork` | `DELETE` | `/app-api/muse/works/{workId}` | `dedicated / needs_verification` | +| `getWork` | `GET` | `/app-api/muse/works/{workId}` | `dedicated / needs_verification` | +| `updateWork` | `PUT` | `/app-api/muse/works/{workId}` | `dedicated / needs_verification` | +| `deleteBlock` | `DELETE` | `/app-api/muse/works/{workId}/blocks/{blockId}` | `dedicated / needs_verification` | +| `getBlock` | `GET` | `/app-api/muse/works/{workId}/blocks/{blockId}` | `dedicated / needs_verification` | +| `saveBlock` | `PUT` | `/app-api/muse/works/{workId}/blocks/{blockId}` | `dedicated / needs_verification` | +| `mergeBlocks` | `POST` | `/app-api/muse/works/{workId}/blocks/{blockId}/merge` | `dedicated / needs_verification` | +| `getBlockSourceAttribution` | `GET` | `/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution` | `dedicated / needs_verification` | +| `splitBlock` | `POST` | `/app-api/muse/works/{workId}/blocks/{blockId}/split` | `dedicated / needs_verification` | +| `mergeBlockSuggestion` | `POST` | `/app-api/muse/works/{workId}/blocks/{blockId}/suggestion-merges` | `dedicated / needs_verification` | +| `listChapters` | `GET` | `/app-api/muse/works/{workId}/chapters` | `dedicated / needs_verification` | +| `createChapter` | `POST` | `/app-api/muse/works/{workId}/chapters` | `dedicated / needs_verification` | +| `deleteChapter` | `DELETE` | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `dedicated / needs_verification` | +| `getChapter` | `GET` | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `dedicated / needs_verification` | +| `updateChapter` | `PUT` | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `dedicated / needs_verification` | +| `listBlocks` | `GET` | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `dedicated / needs_verification` | +| `createBlock` | `POST` | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `dedicated / needs_verification` | +| `reorderChapters` | `PUT` | `/app-api/muse/works/{workId}/chapters/{chapterId}/reorder` | `dedicated / needs_verification` | +| `validateDynamicFields` | `POST` | `/app-api/muse/works/{workId}/dynamic-fields/validate` | `dedicated / needs_verification` | +| `exportWork` | `POST` | `/app-api/muse/works/{workId}/export` | `dedicated / needs_verification` | +| `createExportTask` | `POST` | `/app-api/muse/works/{workId}/export-tasks` | `dedicated / needs_verification` | +| `createImportTask` | `POST` | `/app-api/muse/works/{workId}/import-tasks` | `dedicated / needs_verification` | +| `listMetaProjections` | `GET` | `/app-api/muse/works/{workId}/meta-projections` | `dedicated / needs_verification` | +| `getMetaProjection` | `GET` | `/app-api/muse/works/{workId}/meta-projections/{projectionKey}` | `dedicated / needs_verification` | +| `createParseJob` | `POST` | `/app-api/muse/works/{workId}/parse-jobs` | `dedicated / needs_verification` | +| `getPlanning` | `GET` | `/app-api/muse/works/{workId}/planning` | `dedicated / needs_verification` | +| `listPlanningCandidates` | `GET` | `/app-api/muse/works/{workId}/planning/candidates` | `dedicated / needs_verification` | +| `createPlanningCandidate` | `POST` | `/app-api/muse/works/{workId}/planning/candidates` | `dedicated / needs_verification` | +| `getPlanningCandidate` | `GET` | `/app-api/muse/works/{workId}/planning/candidates/{candidateId}` | `dedicated / needs_verification` | +| `confirmPlanningCandidate` | `POST` | `/app-api/muse/works/{workId}/planning/candidates/{candidateId}/confirm` | `dedicated / needs_verification` | +| `discardPlanningCandidate` | `POST` | `/app-api/muse/works/{workId}/planning/candidates/{candidateId}/discard` | `dedicated / needs_verification` | +| `createStyleCheck` | `POST` | `/app-api/muse/works/{workId}/planning/style-checks` | `dedicated / needs_verification` | +| `getStyleCheckResult` | `GET` | `/app-api/muse/works/{workId}/planning/style-checks/{jobId}` | `dedicated / needs_verification` | +| `savePlanningItem` | `PUT` | `/app-api/muse/works/{workId}/planning/{sectionKey}` | `dedicated / needs_verification` | + +## 已验证实现证据 + +### 子代理只读盘点结论 + +本轮并行只读复核得到两类结论: + +- 核心 Content explorer 支持先做 owned work / chapter / block / source 小批量,并从实现证据角度把 `createWork`、`updateWork`、`deleteWork`、`createChapter`、`updateChapter`、`deleteChapter`、`createBlock` 等也列为强候选。 +- 外部依赖 explorer 明确导入、导出、解析确认、Meta projection、AI planning candidate / style check / suggestion merge 都不应进入第一批;admin read、正式 planning section 可作为后续早批候选,但应独立切片。 + +本审阅版没有采纳 15-operation 首批方案。原因不是这些实现一定不可用,而是当前只读盘点已发现若干创建/更新/删除写命令存在 OpenAPI / coverage `requiresCommandId` 口径与服务端 VO 不一致的风险。第一批若纳入这些 operation,就会把合同修正夹带进 completed approval。更稳的路线是先审批 8 个不需要修改 OpenAPI 的核心读 + `saveBlock` + source attribution 闭环,把写命令合同修正放到后续单独审阅。 + +### Content owner 与入口 + +Content owner 位于 `muse-module-content/muse-module-content-server`。 + +现有 dedicated Controller 覆盖 Content 51 个 operation: + +- `AppContentController` +- `AppContentStructureController` +- `AppContentSourceController` +- `AppContentExportController` +- `AppContentImportParseController` +- `AppContentPlanningController` +- `AppContentMetaProjectionController` +- `AdminContentController` + +`P1rContentRealApiGateTest` 当前明确要求 Content 51 个 operation 全部保持 `dedicated / needs_verification`。这证明 P1R-1 已退出 catch-all / generic persistence / SSE placeholder,不证明任何 Content operation 已达到 completed 口径。 + +### Content 本域核心服务 + +用户端核心作品/章节/Block 链路由 `ContentAppServiceImpl` 承载: + +- `listWorks` / `getWork`:读取 owner 下作品。 +- `listChapters` / `getChapter`:先校验 work owner,再读取章节和 Block 摘要。 +- `listBlocks` / `getBlock`:先校验 work / chapter / block 归属,再读取 Block。 +- `saveBlock`:校验 commandId、owner、expectedRevision,更新 Block,写 `muse_content_block_source_attribution(source_status=active)`,创建 Content event publish outbox,并记录审计。 + +来源归因读链路由 `ContentSourceServiceImpl.getBlockSourceAttribution` 承载,基于当前 Block revision 查询 `muse_content_block_source_attribution` 并返回来源摘要。 + +### P1R-7f saveBlock 证据 + +P1R-7f 已形成以下 `needs_verification` evidence: + +```text +ContentAppServiceImpl.saveBlock +-> muse_content_block_source_attribution(source_status=active) +-> muse_content_event_publish_outbox +-> ContentEventPublishWorker +-> EventsPublishApi +-> muse_unified_event +-> /app-api/muse/events 对 owner 可见 +``` + +该证据覆盖 saveBlock 首次成功路径、command replay 不重复创建 outbox、revision conflict 不更新 Block / 不写 source attribution / 不创建 outbox、payload allowlist、worker retry/dead_letter、V21 Flyway `_test` 和 dependency tree gate。 + +但它仍只是 source owner propagation evidence,不等同于 `saveBlock` API operation completed。Content completed approval 仍必须补 fresh HTTP/API + 真实 PostgreSQL `_test` + scanner/report/gate 同步证据。 + +## 推荐第一候选 + +推荐第一批候选限定为 8 个 operation: + +| operationId | 推荐原因 | 必补 completed-grade 证据 | +|---|---|---| +| `listWorks` | 本域读侧,依赖 owner 过滤和分页,不需要外部 owner | MockMvc HTTP + 真实 DB seed,验证 owner 可见/不可见、分页和响应 DTO | +| `getWork` | 本域读侧,owner guard 明确 | HTTP + DB 验证存在、跨 owner forbidden/not found、DTO 字段 | +| `listChapters` | 本域章节读侧,先校验 work owner | HTTP + DB 验证章节排序、blockCount/wordCount、跨 owner 拒绝 | +| `getChapter` | 本域章节详情,返回 blocks | HTTP + DB 验证章节详情、Block 列表、章节不属 work 拒绝 | +| `listBlocks` | 本域 Block 列表,先校验 chapter 从属 | HTTP + DB 验证列表、排序、跨 work/chapter 拒绝 | +| `getBlock` | 本域 Block 详情,owner/work 归属明确 | HTTP + DB 验证 DTO、跨 owner 拒绝 | +| `saveBlock` | 本域核心写路径,已有 P1R-7f source/event evidence | HTTP + DB 验证 commandId、revision CAS、source attribution、outbox、replay、conflict no-write | +| `getBlockSourceAttribution` | 与 saveBlock 形成读后验证闭环 | HTTP + DB 验证当前 revision 来源、空来源、跨 owner 拒绝 | + +推荐理由: + +- 范围只覆盖 Content 本域核心 owned work / chapter / block,不进入 AI / Knowledge / Meta / FileService 等外部 owner。 +- 读链路可以用测试种子建立作品、章节、Block,避免把 `createWork` / `createChapter` / `createBlock` 的合同缺口带入第一批。 +- `saveBlock` 是 Content 当前最强证据链路,已有 source attribution 和 Events outbox 真实链路基础,但仍需补 API operation completed 级别的 fresh HTTP/DB gate。 +- `getBlockSourceAttribution` 与 `saveBlock` 组成可验证闭环:写入当前 revision 来源事实,再读取同一 revision 的来源摘要。 + +替代方案是扩大到 15 个用户端 work / chapter / block CRUD operation。该方案只有在用户明确批准“先修 Content 写命令 OpenAPI / coverage commandId 合同,再做 completed approval”时才应进入执行版;默认不采用。 + +如果执行版按 8 个 operation 推进,获批后的目标值只能是: + +```text +total=233 +completed=139 +needsVerification=94 +content completed=8 +content needsVerification=43 +``` + +该目标值只是后续获批后的执行目标,不是本审阅版当前状态。 + +## 暂不推荐第一批的切片 + +### create/update/delete 与结构编辑写命令 + +以下 operation 暂不纳入第一批: + +- `createWork` +- `updateWork` +- `deleteWork` +- `createChapter` +- `updateChapter` +- `deleteChapter` +- `createBlock` +- `reorderChapters` +- `splitBlock` +- `mergeBlocks` +- `deleteBlock` + +主要原因: + +- 只读盘点已发现部分写接口的 OpenAPI / coverage `requiresCommandId` 口径与服务端 VO 不一致。例如 `WorkCreateReqVO`、`WorkUpdateReqVO`、`ChapterCreateReqVO` 服务端要求 `commandId`,但当前 coverage 对 `createWork`、`updateWork`、`createChapter` 仍显示 `requiresCommandId=false`,OpenAPI 对 `createWork` / `updateWork` / `createChapter` 的 request body 也没有把 commandId 和 expectedRevision 等写命令字段完整列入 required。 +- `deleteWork` 当前 OpenAPI 路径缺写命令 request body,而服务端 controller 使用 `RevisionCommandReqVO`。 +- 结构编辑写命令会影响 work/chapter/block revision、排序、sourceSnapshot、审计和幂等 replay,适合在合同修正审阅后单独切片。 + +执行版不得为了把这些 operation completed 而顺手改 OpenAPI 或 scanner。若用户希望第二批处理写命令,必须先做 Content 写命令合同一致性审阅版。 + +### AI suggestion 合并 + +`mergeBlockSuggestion` 暂不推荐第一批。 + +它依赖 `ContentAiSuggestionFacade` 查询 AI owner 投影,当前默认 `UnavailableContentAiSuggestionFacade` 明确返回 unavailable。服务端要求 suggestion status、revision、quality result、source status、authorization snapshot 和 license restriction 全部可用,不能在 AI owner 未闭合时伪造成功。 + +### 导入 / 解析 / Knowledge 草稿 + +以下 operation 暂不推荐第一批: + +- `createImportTask` +- `getImportTask` +- `createParseJob` +- `getParseJob` +- `retryParseJob` +- `listParseJobChapters` +- `confirmChapterParseResult` +- `rejectChapterParseResult` +- `batchConfirmChapters` +- `adminListImportTasks` + +主要缺口: + +- `ContentFileFacade` 默认 unavailable,导入文件扫描、存储引用和失败阶段归 FileService owner。 +- `ContentParseJobFacade` 默认 unavailable,Parse Job、章节解析结果和重试事实归 AI owner。 +- `ContentKnowledgeDraftFacade` 默认 unavailable,确认解析结果后创建知识草稿归 Knowledge owner。 +- 确认/批量确认链路需要 Knowledge draft 创建与 AI parse decision record 双 owner 成功,不能只凭 Content 编排服务 completed。 + +### 导出 / 下载 + +以下 operation 暂不推荐第一批: + +- `exportWork` +- `createExportTask` +- `getExportTask` +- `downloadExportPackage` +- `adminListExportTasks` + +主要缺口: + +- `ContentFileFacade.createExportPackage` 和 `downloadExportPackage` 默认 unavailable。 +- 导出包、下载凭证、packageRef、字节内容和过期校验归 FileService owner,Content 当前只能 fail-closed。 +- `getExportTask` / admin export read 可在后续通过 DB seed 单独审批,但不应与创建/下载闭环混为一批。 + +### Meta projection 与动态字段 + +以下 operation 暂不推荐第一批: + +- `listMetaProjections` +- `getMetaProjection` +- `validateDynamicFields` + +主要缺口: + +- `ContentMetaFacade` 默认 unavailable。 +- MetaSchema 解释、字段校验和写入路由建议归 Meta owner,Content 服务明确不复制 Meta 规则、不写 Content 事实表。 + +### Planning candidate / style check + +以下 operation 暂不推荐第一批: + +- `createPlanningCandidate` +- `listPlanningCandidates` +- `getPlanningCandidate` +- `confirmPlanningCandidate` +- `discardPlanningCandidate` +- `createStyleCheck` +- `getStyleCheckResult` + +主要缺口: + +- Candidate 生成、候选投影、丢弃决策和 style check 运行事实归 AI owner。 +- `ContentPlanningCandidateFacade` 与 `ContentStyleCheckFacade` 默认 unavailable。 +- `confirmPlanningCandidate` 会把 AI candidate 写入正式 planning,必须验证 candidate source、quality result、authorization snapshot 和 revision,不适合混入核心 Block 第一批。 + +### Admin governance + +以下 operation 可作为后续候选,但不推荐第一批: + +- `adminListWorks` +- `adminGetWork` +- `adminListChapters` +- `adminRiskAction` + +理由: + +- admin read 是本域读侧,但包含 RBAC、治理摘要、risk flags、word count 汇总和 admin DTO,不应与用户端核心编辑首批混在一起。 +- `adminRiskAction` 是治理写路径,涉及 `expectedVersion`、行锁、targetScope、targetIds 归属、幂等 envelope 和治理动作状态,适合单独做 Admin governance completed approval。 + +### Planning 正式 section + +以下 operation 可作为后续本域候选: + +- `getPlanning` +- `savePlanningItem` + +理由: + +- 它们主要依赖 Content 本域 `muse_content_planning_section`,不是外部 AI candidate runtime。 +- 但 planning section 有 schemaVersion、projectionVersion、sourceSnapshot、upsert CAS 与 command replay 语义,建议作为第二批本域 planning 切片,不混入 Block 第一批。 + +## 后续执行版硬条件 + +如用户批准进入执行版,执行版必须先通过 fresh spec/scope review 与 fresh quality/feasibility review,且至少满足以下硬条件。 + +### 允许的最小 diff + +默认允许: + +- 新增/修改 Content completed approval 执行版文档。 +- 更新 `docs/agent-specs/.agent` 的阶段状态。 +- 新增 Content 第一批 completed approval 的真实 `_test` gate。 +- 在 scanner 中只追加用户批准的 `content:` operation-level allowlist,不加入 `content` domain-level allowlist。 +- 重新生成 coverage report。 +- 同步 P1R gate tests 中的 summary / Content 状态断言,以及 Account / Market / Meta 等非目标域防回退断言。 +- 增强既有 Content events publish gate 仅限测试证据安全边界,例如 `P1rContentEventsPublishFlywayMigrationIT` 的 `p1r.flyway.url/user` 保存恢复与组合顺序验证;不得改变业务断言口径来掩盖回退。 +- 新增 `docs/memorys/YYYY-MM-DD-P1RContent状态推进.md`。 + +默认禁止: + +- 修改 Content OpenAPI。 +- 修改 Content 业务实现来掩盖 coverage 缺口。 +- 修改 SQL migration。 +- 把 `content` 加入 domain-level completed allowlist。 +- 把 create/update/delete/import/export/parse/planning/meta/admin operation 顺手推进 completed。 + +如果执行中发现必须修改 OpenAPI、业务实现或 SQL,必须停下说明原因并取得用户明确批准。 + +### 必须同步的 gate 文件 + +执行版必须枚举所有读取全局 summary、Content 状态,或作为 `saveBlock` source/event 前置证据的 P1R gate。当前至少包括: + +- `P1rApiCoverageReportTest` +- `P1rContentRealApiGateTest` +- `P1rEventsRealApiGateTest` +- `P1rAiRealApiGateTest` +- `P1rKnowledgeRealApiGateTest` +- `P1rMarketRealApiGateTest` +- `P1rAccountRealApiGateTest` +- `P1rMetaRealApiGateTest` +- `P1rContentEventsPublishMigrationSqlTest` +- `P1rContentEventsPublishDependencyTest` +- `P1rContentEventsPublishEndToEndTest` +- `P1rContentEventsPublishFlywayMigrationIT` + +目标值必须公式化为: + +```text +completed = 131 + N +needsVerification = 102 - N +Content completed = N +Content needsVerification = 51 - N +``` + +其中 `N` 只能来自用户明确批准的 Content operation。按本审阅版推荐第一批,`N=8`。 + +### 必须新增或增强的 runtime evidence + +执行版必须包含类似 `P1rContentCoreCompletedApprovalIT` 的真实运行证据: + +- 使用 WebApplicationContext / MockMvc 走 HTTP controller 入口。 +- 使用真实 PostgreSQL `_test` 数据库,执行 Flyway V1-V21。 +- 明确断言 Flyway current=V21、成功 SQL migration 数量为 21、V21 Content event outbox 表/索引/约束/trigger 存在,并验证 invalid outbox insert 被拒绝。 +- V9 planning schema 不属于本第一批 8 个 operation 的业务验收范围;执行版不得为了本批去扩 V9 planning 业务 schema 断言。 +- 使用真实 mapper / service 写读 `muse_content_work`、`muse_content_chapter`、`muse_content_block`、`muse_content_block_source_attribution` 和 `muse_content_event_publish_outbox`。 +- 覆盖 tenant 与 owner 双隔离:至少 seed 两个 tenant,验证同 owner 不同 tenant 不可见、跨 tenant `saveBlock` 不写 block/source/outbox,并验证本批写入行的 `tenant_id` 正确。 +- 覆盖 `saveBlock` 首次成功、command replay、revision conflict、source attribution、outbox no-duplicate / conflict no-write。 +- 覆盖 `getBlockSourceAttribution` 当前 revision 读模型。 +- 为 8 个 operation 建立 shadow-path matrix,不能只测 happy path: + - `listWorks`:有数据分页、空作品页、跨 tenant 同 owner 不可见。 + - `getWork`:存在、ID 不存在、跨 owner / 跨 tenant 拒绝。 + - `listChapters`:有章节、空章节列表、work 不存在或跨 owner / 跨 tenant 拒绝。 + - `getChapter`:存在并返回 blocks、chapter 不存在、chapter 不属于 path work、跨 owner / 跨 tenant 拒绝。 + - `listBlocks`:有 Block、空 Block 列表、chapter 不属于 path work、跨 owner / 跨 tenant 拒绝。 + - `getBlock`:存在、block 不存在、block 不属于 path work、跨 owner / 跨 tenant 拒绝。 + - `saveBlock`:happy、command replay、revision conflict、缺 `commandId`、缺 `expectedRevision`、缺 `sourceSnapshot`、block 属于同 owner 的另一个 work、跨 owner / 跨 tenant 拒绝;所有失败路径均断言 block/source/outbox/command no-write 或事务回滚后的行数不变。 + - `getBlockSourceAttribution`:当前 revision 有来源、当前 revision 空来源、block 不存在、block 属于同 owner 的另一个 work、跨 owner / 跨 tenant 拒绝,且 path mismatch 不泄露 attribution。 + - 每个路径必须断言 HTTP 状态、CommonResult 体、关键 DTO 字段和 DB 行数。 +- 把 `P1rContentEventsPublishMigrationSqlTest`、`P1rContentEventsPublishDependencyTest`、`P1rContentEventsPublishEndToEndTest`、`P1rContentEventsPublishFlywayMigrationIT` 纳入必跑和 XML 防空跑,作为 `saveBlock` completed approval 的 source/event 前置证据。 +- Content completed IT 与 Content events Flyway IT 如果读取并脱敏 `p1r.flyway.url/user`,必须保存原始 system property 并在 `@AfterAll` 恢复;执行版必须包含普通顺序与 `-Dsurefire.runOrder=reversealphabetical` 或等价反序组合验证,防止同一 Surefire JVM 内污染后续 Flyway IT。 +- 读取并校验 fresh Surefire XML,要求 tests 数量大于 0,failures/errors/skipped 均为 0,且 XML mtime 在本轮运行窗口内。 +- raw Surefire XML 只作为本地验证产物;若需要外发测试报告,必须先清洗 DB host/user,且密码只能来自环境变量,不能通过 JVM system property 或 JDBC URL query 传入。 + +### 必须保留的非目标断言 + +执行版和实现阶段必须继续证明: + +- Account remaining 23 个 operation 仍保持 `dedicated / needs_verification`。 +- Market remaining 28 个 operation 仍保持 `dedicated / needs_verification`。 +- Content 未获批准的 43 个 operation 仍保持 `dedicated / needs_verification`。 +- AI / Knowledge / Events / Meta 已有 completed 口径不回退。 +- OpenAPI protected diff 为空,除非用户单独批准合同修正。 + +## 待确认项 + +1. 是否接受 Content 第一批只审批上述 8 个 operation,不做 Content 51/51。 +2. 是否接受第一批不修改 OpenAPI;创建/更新/删除写命令的 commandId 合同缺口放到后续单独审阅。 +3. 是否接受后续执行版目标值按 `N=8` 计算为 `completed=139`、`needsVerification=94`。 +4. 是否允许执行版把 `P1rApiCoverageReportTest`、`P1rContentRealApiGateTest`、`P1rEventsRealApiGateTest`、`P1rAiRealApiGateTest`、`P1rKnowledgeRealApiGateTest`、`P1rMarketRealApiGateTest`、`P1rAccountRealApiGateTest`、`P1rMetaRealApiGateTest` 的 summary / Content / 非目标域状态防回退断言,以及 `P1rContentEventsPublish*` gate 的必跑与测试证据安全边界修正,列为后续获批 implementation 的 allowed diff。 diff --git a/docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md b/docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md new file mode 100644 index 00000000..8a21ee56 --- /dev/null +++ b/docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md @@ -0,0 +1,857 @@ +# P1R Content Completed Approval 执行版 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task after the explicit approval gate. Steps use checkbox syntax for tracking. + +日期:2026-06-12 + +## 结论 + +本执行版推荐第一轮只做 Content operation-level completed approval,不做 Content domain-level 51/51 completed。 + +第一轮精确审批 8 个用户端核心作品 / 章节 / Block / 来源归因 operation: + +```text +content:listWorks +content:getWork +content:listChapters +content:getChapter +content:listBlocks +content:getBlock +content:saveBlock +content:getBlockSourceAttribution +``` + +审批后目标 coverage summary 为: + +```text +total=233 +completed=139 +needsVerification=94 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +Content 域目标状态为: + +```text +content total=51 +completed=8 +needsVerification=43 +``` + +本执行版只定义实现前置批准项、允许变更范围、TDD 顺序、验证命令、XML 防空跑和回滚策略。当前不得直接实现,不得修改 OpenAPI、scanner、coverage report、业务实现或 gate test。 + +```mermaid +flowchart TB + Current["当前 Content
51 dedicated / needs_verification"] --> Exec["执行版
锁定第一批 8 个 operation"] + Exec --> Review["fresh execution spec/scope review
fresh execution quality/feasibility review"] + Review --> Approval{"用户明确批准
8 ops / operation-level / allowed diff / legacy gates"} + Approval -->|否| Stay["保持当前 coverage
233/131/102"] + Approval -->|是| Red["TDD RED
先改 Content/Coverage/legacy gates
旧 report 必须失败"] + Red --> Green["TDD GREEN
scanner operation allowlist + regenerate report"] + Green --> Runtime["Content HTTP+DB _test
V21 Flyway + source/event gates"] + Runtime --> Verify["focused + P1R mixed + XML 防空跑
allowed/protected diff"] + Verify --> FreshReview["fresh implementation review"] +``` + +## 当前事实 + +正确 worktree: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +当前 HEAD: + +```text +0032774 test(p1r): 收口 Market 第一批 completed approval 门禁 +``` + +当前 coverage summary: + +```text +233 131 102 0 0 0 +``` + +当前 Content 事实: + +- Content 51 个 operation 全部为 `dedicated / needs_verification`。 +- `APPROVED_COMPLETED_DOMAINS` 当前只包含 `ai`、`knowledge`。 +- `APPROVED_COMPLETED_OPERATIONS` 当前包含 `events:streamEvents`、Meta 16 个 operation、Account 第一批 10 个 operation、Market 第一批 4 个 operation。 +- `P1rContentRealApiGateTest` 当前明确禁止 Content operation 标记为 `completed`。 +- `P1rApiCoverageReportTest` 当前断言 summary `completed=131`,并断言 Content 51 个 operation 继续 `needs_verification`。 +- `P1rEventsRealApiGateTest`、`P1rAiRealApiGateTest`、`P1rKnowledgeRealApiGateTest`、`P1rMarketRealApiGateTest`、`P1rAccountRealApiGateTest`、`P1rMetaRealApiGateTest` 读取全局 summary 或非目标域状态;实现前必须把这些 mixed gate 同步列入单独批准项。 +- P1R-7f 已证明 `saveBlock -> source attribution active -> Content outbox -> Events -> SSE owner visible` 的 `needs_verification` evidence,但该 evidence 不等同于 `saveBlock` API operation completed。 + +## 执行边界 + +### 必须先获用户明确批准 + +实现前必须同时获得以下 4 项批准: + +1. 批准第一轮 Content completed operation 精确清单为本执行版列出的 8 个 operation。 +2. 批准只走 operation-level approval,不把 `content` 加入 domain-level completed allowlist。 +3. 批准按本执行版修改 scanner、coverage report、Content/Coverage gate、必要 legacy mixed gate、Content HTTP+DB `_test` IT、ContentEventsPublish gate 安全边界、memory 和 `.agent`。 +4. 批准同步 6 个 mixed gate 文件的 summary / Content / 非目标域状态防回退断言: + - `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` + +未获 4 项批准前,不得实施本执行版。 + +### 本轮允许变更 + +获批后只允许修改以下路径: + +- `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/P1rContentCoreCompletedApprovalIT.java`,新增 Content 8 个 operation 的 MockMvc HTTP + 真实 PostgreSQL `_test` 证据。 +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java`,仅限保存/恢复 `p1r.flyway.url/user` system property 与组合顺序验证所需的安全边界增强;不得弱化 V21 schema 断言。 +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentAppServiceTest.java` +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentSourceServiceTest.java` +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentEventPublishOutboxServiceTest.java` +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentEventPublishWorkerTest.java` +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/controller/app/AppContentControllerTest.java` +- `muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/controller/app/AppContentSourceControllerTest.java` +- `docs/agent-specs/.agent` +- `docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md` +- `docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md` +- `docs/memorys/2026-06-12-P1RContent状态推进.md` + +如 implementation 发现必须修改 Content 业务实现、OpenAPI、SQL migration、AI/Knowledge/Meta/FileService owner 实现、非批准 gate 或其它文件,必须停下说明原因并重新取得用户批准。 + +### 本轮禁止变更 + +- 不修改 7 个 OpenAPI。 +- 不修改 Content 业务实现来掩盖 coverage 缺口。 +- 不新增或修改 SQL migration。 +- 不新增或修改 Content domain-level completed allowlist。 +- 不推进 create/update/delete/structure edit operation。 +- 不推进 import/export/parse/FileService operation。 +- 不推进 Meta projection / dynamic fields operation。 +- 不推进 AI planning candidate / style check / suggestion merge operation。 +- 不推进 admin governance / admin read operation。 +- 不推进 planning section operation。 +- 不推进 Account remaining 23、Market remaining 28 或总 P1R completed。 + +## 第一轮审批清单 + +| operation key | Method | Path | 目标状态 | 完成证据边界 | +|---|---|---|---|---| +| `content:listWorks` | `GET` | `/app-api/muse/works` | `dedicated / completed` | App 当前 owner,真实 DB 分页,有数据页、空页、同 owner 跨 tenant 不可见 | +| `content:getWork` | `GET` | `/app-api/muse/works/{workId}` | `dedicated / completed` | App 当前 owner,存在、missing、跨 owner、跨 tenant,DTO 字段完整 | +| `content:listChapters` | `GET` | `/app-api/muse/works/{workId}/chapters` | `dedicated / completed` | work owner guard,章节排序,有章节、空章节、work missing、跨 owner/tenant | +| `content:getChapter` | `GET` | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `dedicated / completed` | chapter 属于 path work,详情含 blocks,missing、wrong work、跨 owner/tenant | +| `content:listBlocks` | `GET` | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `dedicated / completed` | chapter 属于 path work,Block 排序,有数据、空列表、wrong work/chapter、跨 owner/tenant | +| `content:getBlock` | `GET` | `/app-api/muse/works/{workId}/blocks/{blockId}` | `dedicated / completed` | block 属于 path work,存在、missing、wrong work、跨 owner/tenant | +| `content:saveBlock` | `PUT` | `/app-api/muse/works/{workId}/blocks/{blockId}` | `dedicated / completed` | commandId、expectedRevision、sourceSnapshot、revision CAS、source attribution、Content outbox、replay、conflict/no-write、wrong work、跨 owner/tenant | +| `content:getBlockSourceAttribution` | `GET` | `/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution` | `dedicated / completed` | 当前 Block revision 来源读模型,有来源、空来源、missing、wrong work、跨 owner/tenant,不泄露 attribution | + +说明:本轮使用 MockMvc HTTP 入口 + real mapper/service + PostgreSQL `_test` 作为执行版要求的 HTTP 入口证据,不声明 socket 级 live servlet container 证据。 + +## 保持 needs_verification 的 Content operation + +以下 43 个 operation 必须保持 `dedicated / needs_verification`: + +```text +content:adminListExportTasks +content:adminListImportTasks +content:adminListWorks +content:adminGetWork +content:adminListChapters +content:adminRiskAction +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:getPlanning +content:listPlanningCandidates +content:createPlanningCandidate +content:getPlanningCandidate +content:confirmPlanningCandidate +content:discardPlanningCandidate +content:createStyleCheck +content:getStyleCheckResult +content:savePlanningItem +``` + +保留原因: + +- 写命令存在 OpenAPI / coverage `requiresCommandId` 与服务端 VO 口径不一致风险,不能夹带合同修正。 +- 导入、导出、下载、解析确认依赖 FileService、AI、Knowledge owner 闭环。 +- Meta projection、dynamic fields 依赖 Meta owner 解释与校验。 +- AI planning candidate、style check、suggestion merge 依赖 AI owner runtime / projection / quality facts。 +- admin read / governance 需要单独覆盖 RBAC、治理摘要、risk flags 和 admin DTO。 +- formal planning section 虽属 Content 本域,但有 schemaVersion、projectionVersion、upsert CAS、command replay,应作为后续独立切片。 + +## 证据矩阵 + +| 证据类型 | 必跑或必查内容 | 目的 | +|---|---|---| +| Content focused tests | `ContentAppServiceTest`、`ContentSourceServiceTest`、`ContentEventPublishOutboxServiceTest`、`ContentEventPublishWorkerTest`、`AppContentControllerTest`、`AppContentSourceControllerTest` | 支撑本域读写、source attribution、outbox、controller contract 与错误码 | +| Content HTTP+DB `_test` | 新增 `P1rContentCoreCompletedApprovalIT` | 证明 8 个 operation 经 MockMvc HTTP 入口访问真实 PostgreSQL `_test` 数据,覆盖 shadow-path matrix | +| Content source/event gates | `P1rContentEventsPublishMigrationSqlTest`、`P1rContentEventsPublishDependencyTest`、`P1rContentEventsPublishEndToEndTest`、`P1rContentEventsPublishFlywayMigrationIT` | 证明 `saveBlock` 的 source attribution / Content outbox / Events 前置证据仍成立 | +| P1R Content gates | `P1rContentRealApiGateTest`、`P1rApiCoverageReportTest` | 证明 only 8 Content operation completed,43 个继续 needs_verification | +| P1R mixed gates | Coverage、Content、Events、AI、Knowledge、Market、Account、Meta gates | 防止非目标 domain 被连带推进,并同步 legacy summary / Content 状态断言 | +| Flyway `_test` | `P1rContentEventsPublishFlywayMigrationIT` fresh rerun | 证明 V1-V21 clean migrate、V21 outbox 表/索引/约束/trigger、invalid insert 拒绝 | +| Coverage scanner | `python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` | 证明 scanner/report 与 operation-level allowlist 口径一致 | +| XML 防空跑 | 逐个读取 Surefire XML,检查 tests、mtime、failures、errors、skipped | 防止 Maven 未跑目标类或旧 XML 假绿 | +| Protected diff | 7 个 OpenAPI、Content 业务实现、SQL migration 必须无 diff | 证明没有通过改合同、实现或迁移掩盖缺口 | +| Allowed diff | unstaged、staged、untracked 均必须落在允许清单 | 证明没有顺手扩大 scope | + +## TDD 执行步骤 + +### Task 0:实现前现场确认 + +- [ ] 确认 worktree: + +```bash +pwd +git -c core.quotePath=false status --short --branch +git log --oneline -5 +jq -r '.summary | [.totalOperations,.completedOperations,.needsVerificationOperations,.incompleteOperations,.genericPersistenceOperations,.ssePlaceholderOperations] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +预期: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +233 131 102 0 0 0 +``` + +- [ ] 确认 Content 51 个 operation 仍全部是 `dedicated / needs_verification`: + +```bash +jq -r '.operations[] | select(.domain=="content") | [.operationId,.implementationStatus,.completionStatus,.requiresCommandId] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +- [ ] 确认实施前只有文档 diff,没有 OpenAPI、scanner、coverage report、业务实现、SQL migration、P1R gate test diff: + +```bash +git -c core.quotePath=false status --short --branch +git -c core.quotePath=false diff -- docs/api-contracts +git -c core.quotePath=false diff -- muse-cloud/scripts/p1r-audit-api-coverage.py docs/superpowers/reports +git -c core.quotePath=false diff -- muse-cloud/muse-module-content/muse-module-content-server/src/main/java muse-cloud/sql/muse +git -c core.quotePath=false diff -- muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api +``` + +### XML 防空跑通用规则 + +除 TDD RED 失败验证外,所有 Maven test 验证命令都必须执行以下规则,不能只读取历史 Surefire XML: + +- 命令前记录 `export RUN_START_EPOCH=$(date +%s)`。 +- 命令前删除本任务要求检查的 `target/surefire-reports/TEST-*.xml`。 +- 命令后逐个确认 XML 存在、`mtime >= RUN_START_EPOCH`、`tests` 不低于本执行版最低值、`failures=0`、`errors=0`、`skipped=0`。 +- 任一 XML 缺失、mtime 早于运行开始时间、tests 低于最低值或存在失败/跳过,都必须视为验证失败。 + +TDD RED 失败验证必须同样删除目标 XML 并记录 `RUN_START_EPOCH`,但 RED 的预期是 Maven 失败;此时只校验失败发生在本次运行窗口内,并人工确认失败点来自旧 report mismatch,不要求 `failures=0`。 + +推荐校验脚本: + +```bash +python3 - <<'PY' +import os +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +run_start = int(os.environ["RUN_START_EPOCH"]) +checks = { + "muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rApiCoverageReportTest.xml": 6, +} +failed = False +for path_text, min_tests in checks.items(): + path = Path(path_text) + if not path.exists(): + print(f"MISSING_XML {path}") + failed = True + continue + if int(path.stat().st_mtime) < run_start: + print(f"STALE_XML {path}") + failed = True + continue + root = ET.parse(path).getroot() + tests = int(root.attrib.get("tests", "0")) + failures = int(root.attrib.get("failures", "0")) + errors = int(root.attrib.get("errors", "0")) + skipped = int(root.attrib.get("skipped", "0")) + print(path, tests, failures, errors, skipped) + if tests < min_tests or failures or errors or skipped: + failed = True +if failed: + sys.exit(1) +PY +``` + +执行具体任务时必须把 `checks` 替换成该任务表格内列出的 XML 路径和最低 tests。 + +### Task 1:TDD RED,先改 gate 期望 + +获批后先修改: + +- `P1rApiCoverageReportTest.java` +- `P1rContentRealApiGateTest.java` +- 必要的 6 个 mixed gate: + - `P1rEventsRealApiGateTest.java` + - `P1rAiRealApiGateTest.java` + - `P1rKnowledgeRealApiGateTest.java` + - `P1rMarketRealApiGateTest.java` + - `P1rAccountRealApiGateTest.java` + - `P1rMetaRealApiGateTest.java` + +RED 期望: + +- summary 期望 `completed=139`、`needsVerification=94`,但旧 report 仍是 `131/102`,必须失败。 +- Content 8 个 approved operation 期望 `completed`,但旧 report 仍是 `needs_verification`,必须失败。 +- Content 43 个未批准 operation 继续期望 `needs_verification`,其中必须包含 `createWork`、`updateWork`、`createChapter`、`mergeBlockSuggestion`、`exportWork`、`getPlanning`、`adminRiskAction`。 +- Account 仍为 `10 completed / 23 needs_verification`,Market 仍为 `4 completed / 28 needs_verification`,Meta 仍为 `16 completed / 0 needs_verification`,AI/Knowledge/Events 不回退。 + +运行: + +```bash +cd muse-cloud +export RUN_START_EPOCH=$(date +%s) +rm -f \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rApiCoverageReportTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rEventsRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rAiRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rKnowledgeRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rAccountRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMetaRealApiGateTest.xml +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 +``` + +预期:至少一个失败点来自旧 report 的 `131/102` 或 Content 8 个 operation 尚未 completed。若直接通过,必须停下排查假绿。 + +### Task 2:TDD GREEN,最小 scanner/report 状态推进 + +只修改 `muse-cloud/scripts/p1r-audit-api-coverage.py`: + +- 不修改 `APPROVED_COMPLETED_DOMAINS`。 +- 只向 `APPROVED_COMPLETED_OPERATIONS` 追加 8 个 `content:*` operation key。 +- 保持 completed 必须仍是 `dedicated` 的校验。 + +生成 report: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +预期 summary: + +```text +total=233 +completed=139 +needsVerification=94 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +预期 Content: + +```text +completed=8 +needsVerification=43 +``` + +预期 scanner 仍不包含 `content` domain-level allowlist: + +```bash +python3 - <<'PY' +from pathlib import Path +source = Path("muse-cloud/scripts/p1r-audit-api-coverage.py").read_text() +assert 'APPROVED_COMPLETED_DOMAINS = {"ai", "knowledge"}' in source +for key in [ + "content:listWorks", "content:getWork", "content:listChapters", "content:getChapter", + "content:listBlocks", "content:getBlock", "content:saveBlock", "content:getBlockSourceAttribution", +]: + assert key in source +print("content_operation_level_allowlist=PASS") +PY +``` + +### Task 3:Content focused verification + +运行 Content 本域 focused tests: + +```bash +cd muse-cloud +export RUN_START_EPOCH=$(date +%s) +rm -f \ + muse-module-content/muse-module-content-server/target/surefire-reports/TEST-cn.iocoder.muse.module.content.application.ContentAppServiceTest.xml \ + muse-module-content/muse-module-content-server/target/surefire-reports/TEST-cn.iocoder.muse.module.content.application.ContentSourceServiceTest.xml \ + muse-module-content/muse-module-content-server/target/surefire-reports/TEST-cn.iocoder.muse.module.content.application.ContentEventPublishOutboxServiceTest.xml \ + muse-module-content/muse-module-content-server/target/surefire-reports/TEST-cn.iocoder.muse.module.content.application.ContentEventPublishWorkerTest.xml \ + muse-module-content/muse-module-content-server/target/surefire-reports/TEST-cn.iocoder.muse.module.content.controller.app.AppContentControllerTest.xml \ + muse-module-content/muse-module-content-server/target/surefire-reports/TEST-cn.iocoder.muse.module.content.controller.app.AppContentSourceControllerTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentEventsPublishMigrationSqlTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentEventsPublishDependencyTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentEventsPublishEndToEndTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rApiCoverageReportTest.xml +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=ContentAppServiceTest,ContentSourceServiceTest,ContentEventPublishOutboxServiceTest,ContentEventPublishWorkerTest,AppContentControllerTest,AppContentSourceControllerTest,P1rContentEventsPublishMigrationSqlTest,P1rContentEventsPublishDependencyTest,P1rContentEventsPublishEndToEndTest,P1rContentRealApiGateTest,P1rApiCoverageReportTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +必须读取以下 XML 并检查 `mtime >= RUN_START_EPOCH`、`failures=0`、`errors=0`、`skipped=0`: + +| Test class | 最低 tests | +|---|---:| +| `ContentAppServiceTest` | 13 | +| `ContentSourceServiceTest` | 23 | +| `ContentEventPublishOutboxServiceTest` | 6 | +| `ContentEventPublishWorkerTest` | 10 | +| `AppContentControllerTest` | 5 | +| `AppContentSourceControllerTest` | 5 | +| `P1rContentEventsPublishMigrationSqlTest` | 3 | +| `P1rContentEventsPublishDependencyTest` | 2 | +| `P1rContentEventsPublishEndToEndTest` | 5 | +| `P1rContentRealApiGateTest` | 5 | +| `P1rApiCoverageReportTest` | 6 | + +如实现修改测试数量,XML 检查的最低 tests 可高于上表,但不能低于上表。 + +### Task 4:Content HTTP + 真实 PostgreSQL `_test` completed gate + +新增 `P1rContentCoreCompletedApprovalIT`,使用独立 `_test` 数据库和真实 mapper/service 数据,不能使用生产库或非 `_test` 库: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_COMPLETED_APPROVAL_TEST_DB=muse_p1r_content_core_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_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_COMPLETED_APPROVAL_TEST_DB" + +cd muse-cloud +export RUN_START_EPOCH=$(date +%s) +rm -f muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentCoreCompletedApprovalIT.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rContentCoreCompletedApprovalIT \ + -Dflyway.postgresql.transactional.lock=false \ + -Dp1r.flyway.locations=filesystem:sql/muse \ + -Dp1r.flyway.url="jdbc:postgresql://$MUSE_POSTGRES_HOST:$MUSE_POSTGRES_PORT/$P1R_CONTENT_COMPLETED_APPROVAL_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 +``` + +该 IT 必须至少覆盖以下 12 个测试,XML 最低 tests 为 12: + +| Test class | 最低 tests | +|---|---:| +| `P1rContentCoreCompletedApprovalIT` | 12 | + +必须证明: + +- `GET /app-api/muse/works` 覆盖有数据分页、空作品页、同 owner 不同 tenant 不可见。 +- `GET /app-api/muse/works/{workId}` 覆盖存在、ID 不存在、跨 owner、跨 tenant。 +- `GET /app-api/muse/works/{workId}/chapters` 覆盖有章节、空章节、work missing、跨 owner、跨 tenant。 +- `GET /app-api/muse/works/{workId}/chapters/{chapterId}` 覆盖存在并返回 blocks、chapter missing、chapter 属于同 owner 另一个 work、跨 owner、跨 tenant。 +- `GET /app-api/muse/works/{workId}/chapters/{chapterId}/blocks` 覆盖有 Block、空列表、chapter 属于同 owner 另一个 work、跨 owner、跨 tenant。 +- `GET /app-api/muse/works/{workId}/blocks/{blockId}` 覆盖存在、block missing、block 属于同 owner 另一个 work、跨 owner、跨 tenant。 +- `PUT /app-api/muse/works/{workId}/blocks/{blockId}` 覆盖 happy、command replay、revision conflict、缺 `commandId`、缺 `expectedRevision`、缺 `sourceSnapshot`、block 属于同 owner 另一个 work、跨 owner、跨 tenant。 +- `GET /app-api/muse/works/{workId}/blocks/{blockId}/source-attribution` 覆盖当前 revision 有来源、当前 revision 空来源、block missing、block 属于同 owner 另一个 work、跨 owner、跨 tenant,且 path mismatch 不泄露 attribution。 +- 每个路径必须断言 HTTP 状态、CommonResult 体、关键 DTO 字段和 DB 行数。 +- 所有失败路径必须断言 block/source/outbox/command no-write 或事务回滚后的行数不变。 +- 成功 `saveBlock` 必须断言 `muse_content_block_source_attribution(source_status=active)`、`muse_content_event_publish_outbox`、`tenant_id`、owner、blockRevision、sourceStatus、notificationType 和 commandId/outbox 唯一事实。 +- 该 IT 必须在测试启动时校验库名以 `_test` 结尾;密码只能从 `P1R_CONTENT_COMPLETED_PASSWORD`、`P1R_FLYWAY_PASSWORD` 或 `MUSE_POSTGRES_PASSWORD` 环境变量读取,不能通过 JVM system property 传入;日志和断言不得输出明文数据库密码。 +- 该 IT 如果读取并脱敏 `p1r.flyway.url/user`,必须保存原始 system property 并在 `@AfterAll` 恢复。 + +### Task 5:真实 PostgreSQL `_test` Flyway gate + +fresh rerun 并增强 `P1rContentEventsPublishFlywayMigrationIT`。使用独立 `_test` 库,不能使用生产库或非 `_test` 库: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_EVENTS_TEST_DB=muse_p1r_content_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_EVENTS_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_EVENTS_TEST_DB" + +cd muse-cloud +export RUN_START_EPOCH=$(date +%s) +rm -f muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentEventsPublishFlywayMigrationIT.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=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_EVENTS_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 +``` + +必须检查 XML: + +| Test class | 最低 tests | +|---|---:| +| `P1rContentEventsPublishFlywayMigrationIT` | 4 | + +必须保持或补齐以下 assertion: + +- V1-V21 clean migrate。 +- `target_schema_version=21`。 +- `successful_migration_count=21`。 +- V21 `muse_content_event_publish_outbox` 表、索引、唯一约束、CHECK 约束、trigger 存在。 +- invalid outbox insert 被 PostgreSQL 拒绝,至少覆盖非法 `event_type`、非法 `notification_type`、`source_status=deleted`、非法 `publish_status`、非法 owner/block/resource/attempt。 +- 保存原始 `p1r.flyway.url/user`,脱敏后在 `@AfterAll` 恢复,避免同一 Surefire JVM 中污染其它 Flyway IT。 + +### Task 6:Content completed IT 与 Content events Flyway IT 组合顺序验证 + +为证明 system property 脱敏不会污染同 JVM 后续测试,必须用同一个 `_test` 库做普通顺序与反序组合验证。 + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_CONTENT_COMBINED_TEST_DB=muse_p1r_content_combined_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_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_COMBINED_TEST_DB" + +cd muse-cloud +export RUN_START_EPOCH=$(date +%s) +rm -f \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentCoreCompletedApprovalIT.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentEventsPublishFlywayMigrationIT.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=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_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 + +export RUN_START_EPOCH=$(date +%s) +rm -f \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentCoreCompletedApprovalIT.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentEventsPublishFlywayMigrationIT.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rContentCoreCompletedApprovalIT,P1rContentEventsPublishFlywayMigrationIT \ + -Dsurefire.runOrder=reversealphabetical \ + -Dflyway.postgresql.transactional.lock=false \ + -Dp1r.flyway.locations=filesystem:sql/muse \ + -Dp1r.flyway.url="jdbc:postgresql://$MUSE_POSTGRES_HOST:$MUSE_POSTGRES_PORT/$P1R_CONTENT_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 +``` + +两次组合验证均必须满足: + +| Test class | 最低 tests | +|---|---:| +| `P1rContentCoreCompletedApprovalIT` | 12 | +| `P1rContentEventsPublishFlywayMigrationIT` | 4 | + +### Task 7:P1R mixed gate + +运行 mixed gates: + +```bash +cd muse-cloud +export RUN_START_EPOCH=$(date +%s) +rm -f \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rApiCoverageReportTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rContentRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rEventsRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rAiRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rKnowledgeRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rAccountRealApiGateTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMetaRealApiGateTest.xml +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 +``` + +必须读取以下 XML: + +| Test class | 最低 tests | +|---|---:| +| `P1rApiCoverageReportTest` | 6 | +| `P1rContentRealApiGateTest` | 5 | +| `P1rEventsRealApiGateTest` | 6 | +| `P1rAiRealApiGateTest` | 7 | +| `P1rKnowledgeRealApiGateTest` | 8 | +| `P1rMarketRealApiGateTest` | 6 | +| `P1rAccountRealApiGateTest` | 4 | +| `P1rMetaRealApiGateTest` | 4 | + +每个 XML 均必须 `mtime >= RUN_START_EPOCH`、`failures=0`、`errors=0`、`skipped=0`。 + +这些 gate 必须共同证明: + +- summary 只从 `131/102` 推进到 `139/94`。 +- Content 只从 `0/51` 推进到 `8/43`。 +- Account 仍保持 `10 completed / 23 needs_verification`。 +- Market 仍保持 `4 completed / 28 needs_verification`。 +- Meta 仍保持 `16 completed / 0 needs_verification`。 +- AI 41、Knowledge 59、Events 1 仍为 completed,不回退。 + +### Task 8:报告与 diff gate + +检查 summary: + +```bash +jq -r '.summary | [.totalOperations,.completedOperations,.needsVerificationOperations,.incompleteOperations,.genericPersistenceOperations,.ssePlaceholderOperations] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +预期: + +```text +233 139 94 0 0 0 +``` + +检查 Content 8/43: + +```bash +python3 - <<'PY' +import json +from collections import Counter +from pathlib import Path + +approved = { + "listWorks", "getWork", "listChapters", "getChapter", + "listBlocks", "getBlock", "saveBlock", "getBlockSourceAttribution", +} +data = json.loads(Path("docs/superpowers/reports/p1r-api-coverage.json").read_text()) +content_ops = [op for op in data["operations"] if op["domain"] == "content"] +counter = Counter(op["completionStatus"] for op in content_ops) +print(counter) +assert counter == Counter({"needs_verification": 43, "completed": 8}) +for op in content_ops: + expected = "completed" if op["operationId"] in approved else "needs_verification" + assert op["implementationStatus"] == "dedicated", op["operationId"] + assert op["completionStatus"] == expected, op["operationId"] +print("content_8_43=PASS") +PY +``` + +检查 protected diff: + +```bash +git -c core.quotePath=false diff --name-only -- docs/api-contracts +git -c core.quotePath=false diff --cached --name-only -- docs/api-contracts +git -c core.quotePath=false diff --name-only -- muse-cloud/muse-module-content/muse-module-content-server/src/main/java muse-cloud/sql/muse +git -c core.quotePath=false diff --cached --name-only -- muse-cloud/muse-module-content/muse-module-content-server/src/main/java muse-cloud/sql/muse +``` + +输出必须为空。 + +检查 whitespace: + +```bash +git diff --check +``` + +检查 allowed diff,必须覆盖 unstaged / staged / untracked: + +```bash +python3 - <<'PY' +import subprocess +import sys + +allowed = { + "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/P1rContentCoreCompletedApprovalIT.java", + "muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java", + "muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentAppServiceTest.java", + "muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentSourceServiceTest.java", + "muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentEventPublishOutboxServiceTest.java", + "muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentEventPublishWorkerTest.java", + "muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/controller/app/AppContentControllerTest.java", + "muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/controller/app/AppContentSourceControllerTest.java", + "docs/agent-specs/.agent", + "docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md", + "docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md", + "docs/memorys/2026-06-12-P1RContent状态推进.md", +} + +commands = [ + ["git", "-c", "core.quotePath=false", "diff", "--name-only"], + ["git", "-c", "core.quotePath=false", "diff", "--cached", "--name-only"], + ["git", "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], +] +actual = set() +for command in commands: + output = subprocess.check_output(command, text=True) + actual.update(line for line in output.splitlines() if line) +unexpected = sorted(actual - allowed) +if unexpected: + print("UNEXPECTED_DIFF") + print("\n".join(unexpected)) + sys.exit(1) +print("allowed_diff_gate=PASS") +PY +``` + +输出只能包含本执行版允许路径。若出现 OpenAPI、业务实现、SQL migration 或非批准 gate 文件,必须停止。 + +### Task 9:memory 与 `.agent` + +实现通过后新增: + +- `docs/memorys/2026-06-12-P1RContent状态推进.md` + +内容必须记录: + +- 批准的 8 个 Content operation。 +- 保持 `needs_verification` 的 43 个 Content operation 及原因。 +- scanner/report/gate 修改范围。 +- Content HTTP+DB `_test` 的数据库名、验证命令、XML 计数和 shadow-path matrix 覆盖点。 +- ContentEventsPublish gate、V21 Flyway `_test`、组合顺序验证的命令和结果。 +- focused tests、P1R mixed gates、XML 防空跑、protected diff 和 allowed diff 结果。 +- 明确说明不代表 Content 51/51 completed,不代表 Account remaining / Market remaining / 总 P1R completed。 + +同时更新 `docs/agent-specs/.agent`,但只能记录事实,不写未验证结论。 + +### Task 10:fresh implementation review + +实现、验证和文档留痕后,必须派发两类 fresh reviewer: + +- spec/correctness reviewer:检查 8 个 operation 是否严格匹配本执行版,43 个 operation 是否仍 needs_verification,Content domain allowlist 是否未新增。 +- quality/data-integrity/testing reviewer:检查 scanner/report/gate、legacy mixed gate 同步、HTTP+DB `_test`、XML 防空跑、V21 Flyway `_test`、system property restore、allowed/protected diff、rollback 是否可靠。 + +双 PASS 前不得提交、push 或宣称 Content completed approval 已收口。 + +## 回滚策略 + +如实现后需要撤回: + +1. 从 `APPROVED_COMPLETED_OPERATIONS` 移除 8 个 `content:*` key。 +2. 重新运行: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +3. 恢复 gate 期望: + +```text +summary completed=131 +summary needsVerification=102 +content completed=0 +content needsVerification=51 +``` + +4. 删除或修订本轮 memory,保留失败原因。 +5. 重新运行 Content/P1R focused gates、`git diff --check`、protected diff 和 allowed diff。 + +## 验收标准 + +执行版本身可以进入实现前批准点的条件: + +1. 本文件已写入 `docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md`。 +2. `.agent` 记录执行版当前状态。 +3. `git diff --check` 通过。 +4. OpenAPI、scanner、coverage report、业务实现、SQL migration、P1R gate test 当前无新增 diff。 +5. fresh execution spec/scope review PASS。 +6. fresh execution quality/feasibility review PASS。 + +实现完成条件必须等用户批准后另行满足,不由本执行版写入自动成立。 diff --git a/docs/memorys/2026-06-12-P1RContent状态推进.md b/docs/memorys/2026-06-12-P1RContent状态推进.md new file mode 100644 index 00000000..14daef48 --- /dev/null +++ b/docs/memorys/2026-06-12-P1RContent状态推进.md @@ -0,0 +1,378 @@ +# P1R Content 状态推进 + +日期:2026-06-12 + +## 结论 + +本轮只推进 Content 第一批 8 个 operation-level completed approval: + +- `content:listWorks` +- `content:getWork` +- `content:listChapters` +- `content:getChapter` +- `content:listBlocks` +- `content:getBlock` +- `content:saveBlock` +- `content:getBlockSourceAttribution` + +Content 仍不是整域 completed。其余 43 个 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 +0032774 test(p1r): 收口 Market 第一批 completed approval 门禁 +``` + +实施前 coverage summary: + +```text +233 131 102 0 0 0 +``` + +实施后 coverage summary: + +```text +233 139 94 0 0 0 +``` + +实施后 Content 状态: + +```text +completed=8 +needs_verification=43 +``` + +## 继续 needs_verification 的 Content operation + +```text +content:adminListExportTasks +content:adminListImportTasks +content:adminListWorks +content:adminGetWork +content:adminListChapters +content:adminRiskAction +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:getPlanning +content:listPlanningCandidates +content:createPlanningCandidate +content:getPlanningCandidate +content:confirmPlanningCandidate +content:discardPlanningCandidate +content:createStyleCheck +content:getStyleCheckResult +content:savePlanningItem +``` + +主要原因: + +- 创建、更新、删除、重排、拆分、合并等写命令仍存在 OpenAPI / coverage `requiresCommandId` 与服务端 VO 或实现口径不一致风险,本轮不夹带合同修正。 +- 导入、导出、解析、下载仍缺 FileService、任务产物和真实下载字节闭环 evidence。 +- Meta projection、AI planning candidate、style check、suggestion 等能力仍依赖 Meta / AI / Knowledge 外部 owner closure 和运行时证据。 +- admin read / risk action 需要单独 admin guard、审计和跨 owner 边界 evidence。 +- planning section 虽属 Content 本域,但有 schemaVersion、projectionVersion、CAS、command replay 等独立合同,后续应单独切片。 + +## 修改范围 + +本轮修改: + +- `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/P1rContentEventsPublishFlywayMigrationIT.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java` +- `docs/agent-specs/.agent` +- `docs/agent-specs/2026-06-11-P1RContentCompletedApproval审阅版.md` +- `docs/agent-specs/2026-06-12-P1RContentCompletedApproval执行版.md` +- `docs/memorys/2026-06-12-P1RContent状态推进.md` + +未修改: + +- 7 个 OpenAPI。 +- Content 业务实现。 +- SQL migration。 +- Content domain-level completed allowlist。 + +## TDD evidence + +RED 阶段先修改 P1R gate 期望值和 Content 8 个 operation 断言,在旧 scanner/report 下运行 focused P1R gates,预期失败已出现: + +- 失败点来自旧 report 仍为 `completed=131 / needsVerification=102`。 +- 失败点来自 Content 8 个 operation 仍为 `needs_verification`。 +- 该失败证明 gate 会阻止未获 scanner/report 支撑的 completed 推进。 + +GREEN 阶段完成 scanner operation-level allowlist、report 重新生成、Content HTTP+DB `_test` evidence 和 focused gates 后,目标验证通过。 + +## MockMvc HTTP 入口 + DB 证据 + +新增: + +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java` + +验证方式: + +- 使用 Spring Mock web context + `MockMvc`。 +- 使用真实 mapper / service。 +- 使用真实 PostgreSQL `_test` 数据库。 +- 使用 Flyway clean/migrate V1-V21。 +- 密码只从环境变量读取,禁止通过 JVM system property 传入。 + +说明:这里的 HTTP 入口证据是执行版要求的 WebApplicationContext + MockMvc 请求路径证据,不是启动真实 socket 端口的 live servlet container 证据。 + +验证数据库: + +```text +muse_p1r_content_core_completed_approval_test +``` + +通过结果: + +```text +P1rContentCoreCompletedApprovalIT: tests=12, failures=0, errors=0, skipped=0 +``` + +review 修复: + +- fresh testing review(Hooke)指出 chapter/block missing 负路径缺显式 HTTP 断言。 +- 已补 `missing work chapter list`、`missing chapter detail`、`missing chapter block list`、`missing block detail`。 +- 已补上述查询负路径的 Content outbox no-write 断言。 +- 修复后已重跑 `P1rContentCoreCompletedApprovalIT`,结果仍为 tests=12、failures=0、errors=0、skipped=0,XML mtime 在本次运行窗口内。 +- fresh implementation review 第一轮中,Bohr correctness review 与 Euclid testing/data-integrity review 均 FAIL,指出 `saveBlock` 缺 `expectedRevision`、缺 `sourceSnapshot` 两个执行版 mandatory 负路径。 +- 已补 `p1r-content-missing-expected-revision` 与 `p1r-content-missing-source-snapshot` 两个 HTTP+DB no-write 断言,并复用 `assertSnapshotUnchanged` 覆盖 block/source/outbox/command 不写入。 +- 修复后已重跑 `P1rContentCoreCompletedApprovalIT`,结果仍为 tests=12、failures=0、errors=0、skipped=0,XML mtime 在本次运行窗口内。 + +覆盖内容: + +- `listWorks` 分页、状态过滤、owner 隔离、tenant 隔离、空页。 +- `getWork` happy、missing、cross owner、cross tenant。 +- `listChapters` 的 happy、empty、missing work、cross owner、cross tenant。 +- `getChapter` 的 happy、missing chapter、path mismatch、cross owner、cross tenant。 +- `listBlocks` 的 happy、empty、missing chapter、path mismatch、cross owner、cross tenant。 +- `getBlock` 的 happy、missing block、path mismatch、cross owner、cross tenant。 +- `saveBlock` 写入 block revision、source attribution、Content outbox、command log。 +- `saveBlock` command replay 不重复写。 +- `saveBlock` 缺 `commandId`、缺 `expectedRevision`、缺 `sourceSnapshot`、revision conflict、command conflict、path mismatch、cross owner、cross tenant 均断言 no-write 或事务回滚后行数不变。 +- `getBlockSourceAttribution` active/current、empty、path mismatch、cross owner、cross tenant,错误路径不写 DB。 + +## Flyway V21 证据 + +增强: + +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java` + +验证数据库: + +```text +muse_p1r_content_core_completed_approval_test +``` + +通过结果: + +```text +P1rContentEventsPublishFlywayMigrationIT: tests=4, failures=0, errors=0, skipped=0 +``` + +关键输出: + +```text +flyway_success=true +flyway_url=jdbc:postgresql:///muse_p1r_content_core_completed_approval_test +flyway_locations_effective=filesystem:/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud/sql/muse +migrations_executed=21 +successful_migration_count=21 +target_schema_version=21 +flyway_latest=21:extend content events publish outbox +v21_table=muse_content_event_publish_outbox +v21_triggers=trg_muse_content_event_publish_outbox_update_time +``` + +验证内容: + +- V1-V21 clean migrate。 +- V21 Content event publish outbox 表存在。 +- claim、owner/status、block/revision 相关索引存在。 +- event type、notification type、source status、publish status、owner、block、block revision、resource ref、attempt count、max attempt 等约束存在。 +- update time trigger 存在。 +- `attempt_count=-1` 被 PostgreSQL check 拒绝。 +- `max_attempt=0` 被 PostgreSQL check 拒绝。 + +## system property 组合验证 + +`P1rContentCoreCompletedApprovalIT` 与 `P1rContentEventsPublishFlywayMigrationIT` 都会保存并在 `@AfterAll` 恢复 `p1r.flyway.url/user`,避免脱敏后的 system property 污染同一 Surefire JVM 后续测试。 + +组合验证使用真实 PostgreSQL `_test` 库: + +```text +muse_p1r_content_core_completed_approval_test +``` + +通过结果: + +```text +P1rContentCoreCompletedApprovalIT,P1rContentEventsPublishFlywayMigrationIT: tests=16, failures=0, errors=0, skipped=0 +P1rContentCoreCompletedApprovalIT,P1rContentEventsPublishFlywayMigrationIT with -Dsurefire.runOrder=reversealphabetical: tests=16, failures=0, errors=0, skipped=0 +``` + +Hooke 修复后已重跑上述两组组合验证,结果仍为 16/16 pass,两个目标 XML 均为本次运行窗口内的新产物。 + +Bohr / Euclid P1 修复后已重跑普通组合验证,结果仍为 16/16 pass,两个目标 XML 均为本次运行窗口内的新产物。 + +注意:两次组合验证的实际 Surefire 执行顺序均为 `P1rContentEventsPublishFlywayMigrationIT` 先、`P1rContentCoreCompletedApprovalIT` 后。`-Dsurefire.runOrder=reversealphabetical` 未改变实际顺序,因此本轮只能证明 Flyway IT 脱敏/恢复不会污染后续 Core IT,不能写成已证明 Core IT -> Flyway IT 反向顺序。 + +## focused gates + +Content focused verification 通过,并已做 XML 防空跑: + +```text +ContentAppServiceTest: tests=13, failures=0, errors=0, skipped=0 +ContentSourceServiceTest: tests=23, failures=0, errors=0, skipped=0 +ContentEventPublishOutboxServiceTest: tests=6, failures=0, errors=0, skipped=0 +ContentEventPublishWorkerTest: tests=10, failures=0, errors=0, skipped=0 +AppContentControllerTest: tests=32, failures=0, errors=0, skipped=0 +AppContentSourceControllerTest: tests=5, failures=0, errors=0, skipped=0 +P1rContentEventsPublishMigrationSqlTest: tests=3, failures=0, errors=0, skipped=0 +P1rContentEventsPublishDependencyTest: tests=2, failures=0, errors=0, skipped=0 +P1rContentEventsPublishEndToEndTest: tests=5, failures=0, errors=0, skipped=0 +P1rContentRealApiGateTest: tests=5, failures=0, errors=0, skipped=0 +P1rApiCoverageReportTest: tests=6, failures=0, errors=0, skipped=0 +``` + +P1R mixed gates 通过,并已做 XML 防空跑: + +```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 +``` + +合计: + +```text +P1R mixed gates: 49/49 pass +``` + +## scanner 与 diff 证据 + +scanner: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +结果: + +```text +Generated docs/superpowers/reports/p1r-api-coverage.json +Generated docs/superpowers/reports/p1r-api-coverage.md +233 139 94 0 0 0 +``` + +Content operation 状态确认: + +```text +completed=8 +needs_verification=43 +``` + +completed 清单: + +```text +listWorks +getWork +listChapters +getChapter +listBlocks +getBlock +saveBlock +getBlockSourceAttribution +``` + +代表性非目标 operation 仍为 `needs_verification`: + +```text +adminRiskAction +createWork +updateWork +mergeBlockSuggestion +createChapter +exportWork +getPlanning +``` + +diff check: + +```text +git diff --check: pass +``` + +protected OpenAPI diff: + +```text +empty +``` + +allowed diff 检查结果: + +```text +仅包含执行版允许的 docs/agent-specs、docs/memorys、coverage report、scanner、P1R gate test、Content Core IT 与 Content Flyway IT 文件。 +``` + +## 当前边界 + +- fresh implementation re-review 已双 PASS:Mendel correctness review PASS,确认 Bohr/Euclid P1 已关闭,8 个 Content completed operation、scanner operation-level allowlist、coverage summary、protected diff 与 XML 证据均自洽;Anscombe testing/data-integrity review PASS,无 findings、无 testing gaps,残余风险仅为 raw Surefire XML 可能包含本地 DB host/user,未发现 password 泄露。 +- 本轮只代表 Content 第一批 8 个 operation-level completed approval。 +- 不代表 Content 51/51 completed。 +- 不代表 Account remaining completed。 +- 不代表 Market remaining completed。 +- 不代表总 P1R completed。 +- raw Surefire XML 可能包含 DB host/user,未发现 password 泄露;外发前需清洗。 +- 当前已具备提交条件;提交或 push 仍需用户明确指令。 diff --git a/docs/superpowers/reports/p1r-api-coverage.json b/docs/superpowers/reports/p1r-api-coverage.json index 362aa059..292cdeb8 100644 --- a/docs/superpowers/reports/p1r-api-coverage.json +++ b/docs/superpowers/reports/p1r-api-coverage.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-25T07:06:40+00:00", + "generatedAt": "2026-05-25T15:49:45+00:00", "sourceContracts": [ "docs/api-contracts/account/openapi.yaml", "docs/api-contracts/ai/openapi.yaml", @@ -11,9 +11,9 @@ ], "summary": { "totalOperations": 233, - "completedOperations": 131, + "completedOperations": 139, "incompleteOperations": 0, - "needsVerificationOperations": 102, + "needsVerificationOperations": 94, "catchAllOperations": 0, "genericPersistenceOperations": 0, "ssePlaceholderOperations": 0, @@ -8308,7 +8308,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/app/AppContentController.java" ], @@ -8345,7 +8345,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", @@ -8458,7 +8458,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/app/AppContentController.java" ], @@ -8495,7 +8495,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", @@ -8608,7 +8608,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/app/AppContentController.java" ], @@ -8645,7 +8645,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", @@ -8658,7 +8658,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/app/AppContentController.java" ], @@ -8695,7 +8695,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", @@ -8758,7 +8758,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/app/AppContentSourceController.java" ], @@ -8795,7 +8795,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", @@ -8911,7 +8911,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/app/AppContentController.java" ], @@ -8948,7 +8948,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", @@ -9061,7 +9061,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/app/AppContentController.java" ], @@ -9098,7 +9098,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", @@ -9161,7 +9161,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/app/AppContentController.java" ], @@ -9198,7 +9198,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 0cb74f95..0bf4d4b0 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-25T07:06:40+00:00` +- Generated at: `2026-05-25T15:49:45+00:00` - Total operations: `233` -- Completed: `131` -- Needs verification: `102` +- Completed: `139` +- Needs verification: `94` - Incomplete: `0` - Catch-all: `0` - Generic persistence: `0` @@ -103,24 +103,24 @@ | content | app | GET | `/app-api/muse/parse-jobs/{jobId}/chapters` | `listParseJobChapters` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/parse-jobs/{jobId}/chapters/batch-confirm` | `batchConfirmChapters` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/parse-jobs/{jobId}/retry` | `retryParseJob` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | GET | `/app-api/muse/works` | `listWorks` | dedicated | needs_verification | P1R-1 Content Real API | +| content | app | GET | `/app-api/muse/works` | `listWorks` | dedicated | completed | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/works` | `createWork` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | DELETE | `/app-api/muse/works/{workId}` | `deleteWork` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | GET | `/app-api/muse/works/{workId}` | `getWork` | dedicated | needs_verification | P1R-1 Content Real API | +| content | app | GET | `/app-api/muse/works/{workId}` | `getWork` | dedicated | completed | P1R-1 Content Real API | | content | app | PUT | `/app-api/muse/works/{workId}` | `updateWork` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | DELETE | `/app-api/muse/works/{workId}/blocks/{blockId}` | `deleteBlock` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | GET | `/app-api/muse/works/{workId}/blocks/{blockId}` | `getBlock` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | PUT | `/app-api/muse/works/{workId}/blocks/{blockId}` | `saveBlock` | dedicated | needs_verification | P1R-1 Content Real API | +| content | app | GET | `/app-api/muse/works/{workId}/blocks/{blockId}` | `getBlock` | dedicated | completed | P1R-1 Content Real API | +| content | app | PUT | `/app-api/muse/works/{workId}/blocks/{blockId}` | `saveBlock` | dedicated | completed | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/works/{workId}/blocks/{blockId}/merge` | `mergeBlocks` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | GET | `/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution` | `getBlockSourceAttribution` | dedicated | needs_verification | P1R-1 Content Real API | +| content | app | GET | `/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution` | `getBlockSourceAttribution` | dedicated | completed | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/works/{workId}/blocks/{blockId}/split` | `splitBlock` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/works/{workId}/blocks/{blockId}/suggestion-merges` | `mergeBlockSuggestion` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | GET | `/app-api/muse/works/{workId}/chapters` | `listChapters` | dedicated | needs_verification | P1R-1 Content Real API | +| content | app | GET | `/app-api/muse/works/{workId}/chapters` | `listChapters` | dedicated | completed | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/works/{workId}/chapters` | `createChapter` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | DELETE | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `deleteChapter` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | GET | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `getChapter` | dedicated | needs_verification | P1R-1 Content Real API | +| content | app | GET | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `getChapter` | dedicated | completed | P1R-1 Content Real API | | content | app | PUT | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `updateChapter` | dedicated | needs_verification | P1R-1 Content Real API | -| content | app | GET | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `listBlocks` | dedicated | needs_verification | P1R-1 Content Real API | +| content | app | GET | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `listBlocks` | dedicated | completed | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `createBlock` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | PUT | `/app-api/muse/works/{workId}/chapters/{chapterId}/reorder` | `reorderChapters` | dedicated | needs_verification | P1R-1 Content Real API | | content | app | POST | `/app-api/muse/works/{workId}/dynamic-fields/validate` | `validateDynamicFields` | 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 73e8c390..ccc45321 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,6 +54,18 @@ class P1rAccountRealApiGateTest { "adminListQuotaAdjustments" ); + /** Content completed approval 第一批只允许推进的 8 个 operation。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + /** P1R-3 计划第 1 节冻结的 Account operation、方法和路径清单。 */ private static final Map P1R3_ACCOUNT_OPERATION_SIGNATURES = Map.ofEntries( entry("adminListAccountUsers", "GET /admin-api/muse/account/users"), @@ -175,11 +187,46 @@ class P1rAccountRealApiGateTest { assertEquals(23, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); } + /** + * 验证 Content 只推进第一批 8 个 operation,防止 Account gate 漏掉 Content 未批准项回退检查。 + * + * @throws IOException 读取覆盖报告失败时抛出 + */ + @Test + void should_keep_content_partial_approval_at_8_completed_operations() throws IOException { + JsonNode contentOperations = readContentOperations(); + int completed = 0; + int needsVerification = 0; + + assertEquals(51, contentOperations.size(), "Content operation 数量必须保持 51"); + for (JsonNode operation : contentOperations) { + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "Content/" + operationId + " 必须继续保持 dedicated"); + if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "Content/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(8, completed, "Content 第一批 completed approval 只能推进 8 个 operation"); + assertEquals(43, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification"); + } + private JsonNode readAccountOperations() throws IOException { JsonNode operations = objectMapper.readTree(findReportPath().toFile()).path("operations"); return filterAccountOperations(operations); } + private JsonNode readContentOperations() throws IOException { + JsonNode operations = objectMapper.readTree(findReportPath().toFile()).path("operations"); + return filterContentOperations(operations); + } + private JsonNode filterAccountOperations(JsonNode operations) { ArrayNode accountOperations = objectMapper.createArrayNode(); for (JsonNode operation : operations) { @@ -191,6 +238,16 @@ class P1rAccountRealApiGateTest { return accountOperations; } + private JsonNode filterContentOperations(JsonNode operations) { + ArrayNode contentOperations = objectMapper.createArrayNode(); + for (JsonNode operation : operations) { + if ("content".equals(operation.path("domain").asText())) { + contentOperations.add(operation); + } + } + return contentOperations; + } + private static Map toOperationSignatures(JsonNode operations) { Map operationSignatures = new LinkedHashMap<>(); for (JsonNode operation : operations) { 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 ed25fbc2..c76c4f42 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,6 +85,18 @@ class P1rAiRealApiGateTest { "unfavoriteAsset" ); + /** Content completed approval 第一批只允许推进的 8 个 operation。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -127,8 +139,8 @@ class P1rAiRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(131, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4"); + assertEquals(139, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 8"); } @Test @@ -154,12 +166,39 @@ class P1rAiRealApiGateTest { @Test void should_keep_content_meta_account_dedicated_needs_verification() throws IOException { - assertDomainStatusCount("content", 51); + assertContentStatusCount(8, 43); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); assertMarketStatusCount(4, 28); } + private void assertContentStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { + int completed = 0; + int needsVerification = 0; + int total = 0; + for (JsonNode operation : readOperations()) { + if (!"content".equals(operation.path("domain").asText())) { + continue; + } + total++; + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "content/" + operationId + " 必须保持 dedicated"); + if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "content/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(51, total, "Content operation 数量必须保持 51"); + assertEquals(expectedCompleted, completed, "Content 第一批 completed approval 只能推进 8 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification"); + } + private void assertAccountStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { int completed = 0; int needsVerification = 0; 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 18fc5f16..23fa8fe7 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 @@ -84,7 +84,15 @@ class P1rApiCoverageReportTest { "market:getMarketplaceAsset", "market:listMarketplaceCategories", "market:favoriteAsset", - "market:unfavoriteAsset" + "market:unfavoriteAsset", + "content:listWorks", + "content:getWork", + "content:listChapters", + "content:getChapter", + "content:listBlocks", + "content:getBlock", + "content:saveBlock", + "content:getBlockSourceAttribution" ); /** 已批准的 MetaSchema 管理接口。 */ @@ -133,6 +141,18 @@ class P1rApiCoverageReportTest { "unfavoriteAsset" ); + /** 已批准的 Content 第一批用户端核心作品、章节、Block 与来源归因接口,仍保持 operation-level approval。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); /** @@ -234,12 +254,12 @@ class P1rApiCoverageReportTest { } /** - * 验证 completed approval 只推进已批准的 Events SSE、Meta、Account 与 Market operation,不连带推进其它 owner domain。 + * 验证 completed approval 只推进已批准的 Events SSE、Meta、Account、Market 与 Content operation,不连带推进其它 owner domain。 * * @throws IOException 读取覆盖报告失败时抛出 */ @Test - void should_only_promote_approved_events_meta_account_and_market_operations() throws IOException { + void should_only_promote_approved_events_meta_account_market_and_content_operations() throws IOException { JsonNode operations = readOperations(); int completed = 0; @@ -248,7 +268,7 @@ class P1rApiCoverageReportTest { completed++; } } - assertEquals(131, completed, "Market 第一批 4 个 operation-level approval 后 completed 总数只能从 127 增至 131"); + assertEquals(139, completed, "Content 第一批 8 个 operation-level approval 后 completed 总数只能从 131 增至 139"); assertOperationStatus("events", "streamEvents", "dedicated", "completed"); for (String operationId : APPROVED_META_SCHEMA_COMPLETED_OPERATIONS) { @@ -263,13 +283,23 @@ class P1rApiCoverageReportTest { for (String operationId : APPROVED_MARKET_COMPLETED_OPERATIONS) { assertOperationStatus("market", operationId, "dedicated", "completed"); } + for (String operationId : APPROVED_CONTENT_COMPLETED_OPERATIONS) { + assertOperationStatus("content", operationId, "dedicated", "completed"); + } assertOperationStatus("market", "listMarketplaceAssets", "dedicated", "needs_verification"); assertOperationStatus("market", "listMarketplaceRecommendations", "dedicated", "needs_verification"); assertOperationStatus("market", "favoriteAsset", "dedicated", "completed", true); assertOperationStatus("market", "unfavoriteAsset", "dedicated", "completed", true); + assertOperationStatus("content", "createWork", "dedicated", "needs_verification"); + assertOperationStatus("content", "updateWork", "dedicated", "needs_verification"); + assertOperationStatus("content", "createChapter", "dedicated", "needs_verification"); + assertOperationStatus("content", "mergeBlockSuggestion", "dedicated", "needs_verification"); + assertOperationStatus("content", "exportWork", "dedicated", "needs_verification"); + assertOperationStatus("content", "getPlanning", "dedicated", "needs_verification"); + assertOperationStatus("content", "adminRiskAction", "dedicated", "needs_verification"); assertDomainStatusCount("market", "dedicated", "needs_verification", 28); assertDomainStatusCount("account", "dedicated", "needs_verification", 23); - assertDomainStatusCount("content", "dedicated", "needs_verification", 51); + assertDomainStatusCount("content", "dedicated", "needs_verification", 43); } /** diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java new file mode 100644 index 00000000..b2146685 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java @@ -0,0 +1,1176 @@ +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.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.ContentAppServiceImpl; +import cn.iocoder.muse.module.content.application.ContentAuditServiceImpl; +import cn.iocoder.muse.module.content.application.ContentCommandServiceImpl; +import cn.iocoder.muse.module.content.application.ContentEventPublishOutboxServiceImpl; +import cn.iocoder.muse.module.content.application.ContentSourceServiceImpl; +import cn.iocoder.muse.module.content.application.facade.ContentAiSuggestionFacade; +import cn.iocoder.muse.module.content.controller.app.AppContentController; +import cn.iocoder.muse.module.content.controller.app.AppContentSourceController; +import cn.iocoder.muse.module.content.framework.config.MuseContentEventsProperties; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; +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.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.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import javax.sql.DataSource; +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.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_COMMAND_ID_CONFLICT; +import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_FORBIDDEN; +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.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +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.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P1R Content 第一批 completed approval:8 个核心读写 operation 的 HTTP + 真实 PostgreSQL 证据。 + * + *

本测试只允许连接显式传入的 PostgreSQL {@code _test} 库,启动最小 Spring MVC 上下文, + * 通过 {@code /app-api/muse/**} 真实前缀进入 Controller,并读取数据库事实验证 owner guard、 + * tenant 隔离、saveBlock 事务写入、command 幂等和 Content Events outbox。本测试不导入 Content 全包, + * 避免未审阅的导入导出、规划、worker 等能力污染这批 operation 的 evidence。

+ */ +@SpringBootTest( + classes = P1rContentCoreCompletedApprovalIT.CompletedApprovalConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rContentCoreCompletedApprovalIT { + + 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 LOGIN_USER_ID = 9001L; + private static final Long OTHER_USER_ID = 9002L; + private static final String API_VERSION = "1"; + 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 CompletedApprovalSettings cachedSettings; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @Autowired + private DataSource dataSource; + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + private SeedFacts seedFacts; + + @DynamicPropertySource + static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) { + CompletedApprovalSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-content-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"); + registry.add("muse.content.events.publish-worker.enabled", () -> "false"); + registry.add("muse.content.events.publish-worker.max-attempt", () -> "5"); + } + + @BeforeAll + void migrateContentSchema() { + CompletedApprovalSettings 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 completed approval 必须在隔离库执行 V1-V21 全量迁移,实际: " + result.migrationsExecuted); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetContentTables(); + this.seedFacts = seedContentFacts(); + setRuntimeContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_rejectPasswordSystemProperty() { + System.setProperty("p1r.content.completed.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rContentCoreCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.content.completed.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.content.completed.password"); + } + } + + @Test + void should_listWorksWithPaginationStatusOwnerAndTenantIsolationFromRealPostgresql() throws Exception { + mockMvc.perform(get("/app-api/muse/works") + .header("X-API-Version", API_VERSION) + .param("pageNo", "1") + .param("pageSize", "10") + .param("status", "draft")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(2)) + .andExpect(jsonPath("$.data.list[*].id", hasItem(seedFacts.workId().intValue()))) + .andExpect(jsonPath("$.data.list[*].id", hasItem(seedFacts.emptyWorkId().intValue()))) + .andExpect(jsonPath("$.data.list[*].id").value(hasSize(2))) + .andExpect(jsonPath("$.data.list[0].description").doesNotExist()); + + mockMvc.perform(get("/app-api/muse/works") + .header("X-API-Version", API_VERSION) + .param("pageNo", "3") + .param("pageSize", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(3)) + .andExpect(jsonPath("$.data.list").isEmpty()); + + assertEquals(0, commandCount(), "listWorks 是纯查询,不能写 Content command fact"); + assertEquals(0, outboxCount(), "listWorks 是纯查询,不能写 Content Events outbox"); + } + + @Test + void should_getWorkAndRejectMissingCrossOwnerAndCrossTenantWithoutWrite() throws Exception { + mockMvc.perform(get("/app-api/muse/works/{workId}", seedFacts.workId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(seedFacts.workId().intValue())) + .andExpect(jsonPath("$.data.title").value("P1R Content Work")) + .andExpect(jsonPath("$.data.revision").value(1)); + + assertContentError(get("/app-api/muse/works/{workId}", 999999L), CONTENT_NOT_FOUND.getCode(), "missing work"); + assertContentError(get("/app-api/muse/works/{workId}", seedFacts.otherOwnerWorkId()), + CONTENT_FORBIDDEN.getCode(), "cross-owner work"); + assertContentError(get("/app-api/muse/works/{workId}", seedFacts.otherTenantWorkId()), + CONTENT_NOT_FOUND.getCode(), "cross-tenant work"); + assertEquals(0, commandCount(), "getWork 错误路径不能写 command fact"); + assertEquals(0, outboxCount(), "getWork 错误路径不能写 outbox"); + } + + @Test + void should_listChaptersAndRejectOwnerTenantLeaks() throws Exception { + mockMvc.perform(get("/app-api/muse/works/{workId}/chapters", seedFacts.workId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].id").value(seedFacts.chapterId().intValue())) + .andExpect(jsonPath("$.data[0].blockCount").value(2)) + .andExpect(jsonPath("$.data[1].id").value(seedFacts.emptyChapterId().intValue())) + .andExpect(jsonPath("$.data[1].blockCount").value(0)); + + assertContentError(get("/app-api/muse/works/{workId}/chapters", seedFacts.otherOwnerWorkId()), + CONTENT_FORBIDDEN.getCode(), "cross-owner chapter list"); + assertContentError(get("/app-api/muse/works/{workId}/chapters", seedFacts.otherTenantWorkId()), + CONTENT_NOT_FOUND.getCode(), "cross-tenant chapter list"); + assertContentError(get("/app-api/muse/works/{workId}/chapters", 999999L), + CONTENT_NOT_FOUND.getCode(), "missing work chapter list"); + assertEquals(0, commandCount(), "listChapters 是纯查询,不能写 command fact"); + assertEquals(0, outboxCount(), "listChapters 是纯查询,不能写 outbox"); + } + + @Test + void should_getChapterWithBlocksAndRejectPathMismatchOwnerTenantLeaks() throws Exception { + mockMvc.perform(get("/app-api/muse/works/{workId}/chapters/{chapterId}", + seedFacts.workId(), seedFacts.chapterId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(seedFacts.chapterId().intValue())) + .andExpect(jsonPath("$.data.blocks", hasSize(2))) + .andExpect(jsonPath("$.data.blocks[0].id").value(seedFacts.blockId().intValue())); + + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}", + seedFacts.mismatchWorkId(), seedFacts.chapterId()), + CONTENT_NOT_FOUND.getCode(), "chapter path mismatch"); + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}", + seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerChapterId()), + CONTENT_FORBIDDEN.getCode(), "cross-owner chapter detail"); + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}", + seedFacts.otherTenantWorkId(), seedFacts.otherTenantChapterId()), + CONTENT_NOT_FOUND.getCode(), "cross-tenant chapter detail"); + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}", + seedFacts.workId(), 999999L), + CONTENT_NOT_FOUND.getCode(), "missing chapter detail"); + assertEquals(0, commandCount(), "getChapter 错误路径不能写 command fact"); + assertEquals(0, outboxCount(), "getChapter 错误路径不能写 outbox"); + } + + @Test + void should_listBlocksAndRejectPathMismatchOwnerTenantLeaks() throws Exception { + mockMvc.perform(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", + seedFacts.workId(), seedFacts.chapterId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].id").value(seedFacts.blockId().intValue())) + .andExpect(jsonPath("$.data[1].id").value(seedFacts.emptySourceBlockId().intValue())); + + mockMvc.perform(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", + seedFacts.workId(), seedFacts.emptyChapterId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isEmpty()); + + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", + seedFacts.mismatchWorkId(), seedFacts.chapterId()), + CONTENT_NOT_FOUND.getCode(), "block list path mismatch"); + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", + seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerChapterId()), + CONTENT_FORBIDDEN.getCode(), "cross-owner block list"); + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", + seedFacts.otherTenantWorkId(), seedFacts.otherTenantChapterId()), + CONTENT_NOT_FOUND.getCode(), "cross-tenant block list"); + assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", + seedFacts.workId(), 999999L), + CONTENT_NOT_FOUND.getCode(), "missing chapter block list"); + assertEquals(0, commandCount(), "listBlocks 是纯查询,不能写 command fact"); + assertEquals(0, outboxCount(), "listBlocks 是纯查询,不能写 outbox"); + } + + @Test + void should_getBlockAndRejectPathMismatchOwnerTenantLeaks() throws Exception { + mockMvc.perform(get("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(seedFacts.blockId().intValue())) + .andExpect(jsonPath("$.data.content").value("original block")) + .andExpect(jsonPath("$.data.revision").value(1)); + + assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}", + seedFacts.mismatchWorkId(), seedFacts.blockId()), + CONTENT_NOT_FOUND.getCode(), "block path mismatch"); + assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}", + seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerBlockId()), + CONTENT_FORBIDDEN.getCode(), "cross-owner block detail"); + assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}", + seedFacts.otherTenantWorkId(), seedFacts.otherTenantBlockId()), + CONTENT_NOT_FOUND.getCode(), "cross-tenant block detail"); + assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}", + seedFacts.workId(), 999999L), + CONTENT_NOT_FOUND.getCode(), "missing block detail"); + assertEquals(0, commandCount(), "getBlock 错误路径不能写 command fact"); + assertEquals(0, outboxCount(), "getBlock 错误路径不能写 outbox"); + } + + @Test + void should_saveBlockWriteSourceAttributionOutboxCommandAndTenantOwnerFacts() throws Exception { + String commandId = "p1r-content-save-block"; + String content = "updated canonical content"; + + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(saveBlockBody(commandId, 1, content, "ai_suggestion", "501", 7, "91001"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.revision").value(2)); + + assertEquals(2, blockRevision(seedFacts.blockId()), "saveBlock 必须推进 block revision"); + assertEquals(content, blockContent(seedFacts.blockId()), "saveBlock 必须写入 Canonical Block 正文"); + assertEquals(content.length(), blockWordCount(seedFacts.blockId()), "word_count 必须来自保存后的正文"); + assertEquals(1, sourceAttributionCount(seedFacts.blockId(), 2), + "saveBlock 必须为新 revision 写一条 source attribution"); + assertEquals(1, outboxCount(), "active source attribution 必须写入 Content Events outbox"); + OutboxFact outbox = outboxFact(seedFacts.blockId(), 2L); + assertNotNull(outbox, "saveBlock 必须能读取到 outbox fact"); + assertEquals(LOGIN_USER_ID, outbox.ownerUserId()); + assertEquals(commandId, outbox.sourceCommandId()); + assertEquals("notification", outbox.eventType()); + assertEquals("source_status_change", outbox.notificationType()); + assertEquals("content_block", outbox.resourceRefType()); + assertEquals("queued", outbox.publishStatus()); + assertEquals(0, outbox.attemptCount()); + assertEquals(5, outbox.maxAttempt()); + assertTrue(outbox.payloadSummary().contains("source_status_change"), + "outbox payload_summary 只能包含 notification 摘要"); + assertFalse(outbox.payloadSummary().contains(content), + "outbox payload_summary 禁止外发正文内容"); + + CommandFact command = commandFact(commandId); + assertNotNull(command, "saveBlock 成功必须写 command fact"); + assertEquals("save_block", command.commandType()); + assertEquals(LOGIN_USER_ID, command.ownerUserId()); + assertEquals("block", command.targetType()); + assertEquals(seedFacts.blockId(), command.targetId()); + assertEquals("2", command.resultRevision(), + "command result_snapshot 必须保存首次成功 revision"); + } + + @Test + void should_replaySaveBlockWithoutDuplicateSourceOutboxOrRevisionChange() throws Exception { + String commandId = "p1r-content-save-replay"; + String content = "first replay content"; + String requestBody = saveBlockBody(commandId, 1, content, "user_original", "manual-1", 1, "91002"); + + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.revision").value(2)); + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.revision").value(2)); + + assertEquals(2, blockRevision(seedFacts.blockId()), "重复相同 commandId 必须回放首次结果,不能继续推进 revision"); + assertEquals(1, sourceAttributionCount(seedFacts.blockId(), 2), + "重复相同 commandId 不能重复插入 source attribution"); + assertEquals(1, outboxCount(), "重复相同 commandId 不能重复插入 outbox"); + assertEquals(1, commandCount(commandId), "重复相同 commandId 只能保留一条 command fact"); + } + + @Test + void should_rejectSaveBlockValidationRevisionConflictAndCommandConflictWithoutDirtyWrite() throws Exception { + Snapshot before = snapshot(seedFacts.blockId()); + + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "expectedRevision": 1, + "content": "missing command", + "sourceSnapshot": {"sourceType":"user_original"} + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.msg").exists()); + assertSnapshotUnchanged(before, "缺少 commandId 时不能写入任何 Content fact"); + + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "commandId": "p1r-content-missing-expected-revision", + "content": "missing expected revision", + "sourceSnapshot": {"sourceType":"user_original"} + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.msg").exists()); + assertSnapshotUnchanged(before, "缺少 expectedRevision 时不能写入任何 Content fact"); + + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "commandId": "p1r-content-missing-source-snapshot", + "expectedRevision": 1, + "content": "missing source snapshot" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.msg").exists()); + assertSnapshotUnchanged(before, "缺少 sourceSnapshot 时不能写入任何 Content fact"); + + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(saveBlockBody("p1r-content-conflict-revision", 99, + "wrong revision", "user_original", "manual-2", 1, "91003"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_REVISION_CONFLICT.getCode())) + .andExpect(jsonPath("$.msg", containsString("版本"))); + assertSnapshotUnchanged(before, "revision conflict 必须整体回滚已预占 commandId"); + assertEquals(0, commandCount("p1r-content-conflict-revision"), + "revision conflict 事务回滚后不能残留 reserved command"); + + String commandId = "p1r-content-command-conflict"; + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(saveBlockBody(commandId, 1, "first command body", "user_original", + "manual-3", 1, "91004"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + Snapshot afterSuccess = snapshot(seedFacts.blockId()); + mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(saveBlockBody(commandId, 2, "different body", "user_original", + "manual-4", 1, "91005"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_COMMAND_ID_CONFLICT.getCode())); + assertSnapshotUnchanged(afterSuccess, "复用 commandId 到不同请求不能追加写 source/outbox/revision"); + } + + @Test + void should_rejectSaveBlockPathMismatchCrossOwnerAndCrossTenantWithoutDirtyWrite() throws Exception { + Snapshot before = snapshot(seedFacts.blockId()); + assertContentError(put("/app-api/muse/works/{workId}/blocks/{blockId}", + seedFacts.mismatchWorkId(), seedFacts.blockId()) + .contentType(MediaType.APPLICATION_JSON) + .content(saveBlockBody("p1r-content-mismatch", 1, "mismatch", + "user_original", "manual-5", 1, "91006")), + CONTENT_NOT_FOUND.getCode(), "saveBlock path mismatch"); + assertSnapshotUnchanged(before, "path mismatch 事务必须整体回滚 command/source/outbox"); + + assertContentError(put("/app-api/muse/works/{workId}/blocks/{blockId}", + seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerBlockId()) + .contentType(MediaType.APPLICATION_JSON) + .content(saveBlockBody("p1r-content-cross-owner", 1, "cross owner", + "user_original", "manual-6", 1, "91007")), + CONTENT_FORBIDDEN.getCode(), "saveBlock cross owner"); + assertEquals(0, commandCount("p1r-content-cross-owner"), + "cross-owner saveBlock 不能残留 command fact"); + + assertContentError(put("/app-api/muse/works/{workId}/blocks/{blockId}", + seedFacts.otherTenantWorkId(), seedFacts.otherTenantBlockId()) + .contentType(MediaType.APPLICATION_JSON) + .content(saveBlockBody("p1r-content-cross-tenant", 1, "cross tenant", + "user_original", "manual-7", 1, "91008")), + CONTENT_NOT_FOUND.getCode(), "saveBlock cross tenant"); + assertEquals(0, commandCount("p1r-content-cross-tenant"), + "cross-tenant saveBlock 不能残留 command fact"); + } + + @Test + void should_getBlockSourceAttributionForActiveAndEmptyCurrentRevisionAndRejectLeaks() throws Exception { + mockMvc.perform(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution", + seedFacts.workId(), seedFacts.blockId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.blockId").value(seedFacts.blockId().intValue())) + .andExpect(jsonPath("$.data.revision").value(1)) + .andExpect(jsonPath("$.data.sources", hasSize(1))) + .andExpect(jsonPath("$.data.sources[0].sourceType").value("ai_suggestion")) + .andExpect(jsonPath("$.data.sources[0].sourceId").value("501")) + .andExpect(jsonPath("$.data.sources[0].authorizationSnapshotId").value("91001")) + .andExpect(jsonPath("$.data.sources[0].sourceVersion").value(7)) + .andExpect(jsonPath("$.data.sources[0].licenseRestrictions[0]").value("no_export")); + + mockMvc.perform(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution", + seedFacts.workId(), seedFacts.emptySourceBlockId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.blockId").value(seedFacts.emptySourceBlockId().intValue())) + .andExpect(jsonPath("$.data.sources").isEmpty()) + .andExpect(jsonPath("$.data.lineage").doesNotExist()); + + assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution", + seedFacts.mismatchWorkId(), seedFacts.blockId()), + CONTENT_NOT_FOUND.getCode(), "source attribution path mismatch"); + assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution", + seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerBlockId()), + CONTENT_FORBIDDEN.getCode(), "source attribution cross owner"); + assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution", + seedFacts.otherTenantWorkId(), seedFacts.otherTenantBlockId()), + CONTENT_NOT_FOUND.getCode(), "source attribution cross tenant"); + assertEquals(0, commandCount(), "getBlockSourceAttribution 是纯查询,不能写 command fact"); + assertEquals(0, outboxCount(), "getBlockSourceAttribution 是纯查询,不能写 outbox"); + } + + private void assertContentError(org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request, + int expectedCode, String scenario) throws Exception { + mockMvc.perform(request.header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(expectedCode)) + .andExpect(jsonPath("$.msg").exists()); + assertTrue(commandCount() >= 0, "scenario 必须执行到 DB 断言路径: " + scenario); + } + + private void setRuntimeContext() { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(LOGIN_USER_ID); + loginUser.setUserType(UserTypeEnum.MEMBER.getValue()); + loginUser.setTenantId(TENANT_ID); + loginUser.setVisitTenantId(TENANT_ID); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + private void resetContentTables() throws SQLException { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + // 每个测试重置 Content 目标表,保证 HTTP 请求写出的 command/source/outbox fact 可被精确计数。 + statement.execute(""" + TRUNCATE TABLE + muse_content_event_publish_outbox, + muse_content_command_log, + muse_content_block_source_attribution, + 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, LOGIN_USER_ID, "P1R Content Work", "draft", 12, 2); + Long chapterId = insertChapter(connection, TENANT_ID, workId, "Chapter A", 1); + Long blockId = insertBlock(connection, TENANT_ID, workId, chapterId, "Block A", "original block", 1); + Long emptySourceBlockId = insertBlock(connection, TENANT_ID, workId, chapterId, "Block Empty Source", "empty source", 2); + Long emptyChapterId = insertChapter(connection, TENANT_ID, workId, "Chapter Empty", 2); + Long emptyWorkId = insertWork(connection, TENANT_ID, LOGIN_USER_ID, "P1R Empty Work", "draft", 0, 0); + Long mismatchWorkId = insertWork(connection, TENANT_ID, LOGIN_USER_ID, "P1R Mismatch Work", "published", 0, 0); + Long otherOwnerWorkId = insertWork(connection, TENANT_ID, OTHER_USER_ID, "P1R Other Owner Work", "draft", 5, 1); + Long otherOwnerChapterId = insertChapter(connection, TENANT_ID, otherOwnerWorkId, "Other Owner Chapter", 1); + Long otherOwnerBlockId = insertBlock(connection, TENANT_ID, otherOwnerWorkId, otherOwnerChapterId, + "Other Owner Block", "other owner block", 1); + Long otherTenantWorkId = insertWork(connection, OTHER_TENANT_ID, LOGIN_USER_ID, "P1R Other Tenant Work", "draft", 5, 1); + Long otherTenantChapterId = insertChapter(connection, OTHER_TENANT_ID, otherTenantWorkId, "Other Tenant Chapter", 1); + Long otherTenantBlockId = insertBlock(connection, OTHER_TENANT_ID, otherTenantWorkId, otherTenantChapterId, + "Other Tenant Block", "other tenant block", 1); + insertSourceAttribution(connection, TENANT_ID, workId, blockId, 1); + return new SeedFacts(workId, chapterId, blockId, emptySourceBlockId, emptyChapterId, emptyWorkId, + mismatchWorkId, otherOwnerWorkId, otherOwnerChapterId, otherOwnerBlockId, otherTenantWorkId, + otherTenantChapterId, otherTenantBlockId); + } + } + + private Long insertWork(Connection connection, Long tenantId, Long ownerUserId, String title, + String status, int wordCount, int chapterCount) 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', ?, ?, ?, 1, ?) + 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.setLong(7, 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 insertSourceAttribution(Connection connection, Long tenantId, Long workId, Long blockId, + int revision) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_block_source_attribution(work_id, block_id, revision, source_type, + source_object_id, source_version, + authorization_snapshot_id, source_status, + license_restriction_snapshot, tenant_id) + VALUES (?, ?, ?, 'ai_suggestion', '501', 7, 91001, 'active', '[\"no_export\"]'::jsonb, ?) + """)) { + statement.setLong(1, workId); + statement.setLong(2, blockId); + statement.setInt(3, revision); + statement.setLong(4, tenantId); + assertEquals(1, statement.executeUpdate(), "seed Content source attribution 必须插入一行"); + } + } + + private String saveBlockBody(String commandId, int expectedRevision, String content, + String sourceType, String sourceId, int sourceVersion, + String authorizationSnapshotId) { + return """ + { + "commandId": "%s", + "expectedRevision": %d, + "content": "%s", + "sourceSnapshot": { + "sourceType": "%s", + "sourceId": "%s", + "sourceVersion": %d, + "authorizationSnapshotId": "%s" + }, + "auditReason": "p1r-content-completed-approval" + } + """.formatted(commandId, expectedRevision, content, sourceType, sourceId, sourceVersion, + authorizationSnapshotId); + } + + private Snapshot snapshot(Long blockId) throws SQLException { + return new Snapshot(blockRevision(blockId), blockContent(blockId), sourceAttributionCount(blockId), + outboxCount(), commandCount()); + } + + private void assertSnapshotUnchanged(Snapshot expected, String message) throws SQLException { + Snapshot actual = snapshot(seedFacts.blockId()); + assertEquals(expected, actual, message); + } + + private Integer blockRevision(Long blockId) throws SQLException { + return queryInt("SELECT revision FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId); + } + + private Integer blockWordCount(Long blockId) throws SQLException { + return queryInt("SELECT word_count FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId); + } + + private String blockContent(Long blockId) throws SQLException { + return queryString("SELECT content_text FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId); + } + + private int sourceAttributionCount(Long blockId) throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_block_source_attribution WHERE tenant_id = ? AND block_id = ?", + TENANT_ID, blockId); + } + + private int sourceAttributionCount(Long blockId, int revision) throws SQLException { + return queryInt(""" + SELECT COUNT(*) + FROM muse_content_block_source_attribution + WHERE tenant_id = ? + AND block_id = ? + AND revision = ? + """, TENANT_ID, blockId, revision); + } + + private int outboxCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_content_event_publish_outbox WHERE tenant_id = ?", TENANT_ID); + } + + 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 CommandFact commandFact(String commandId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + SELECT command_type, owner_user_id, target_type, target_id, + result_snapshot #>> '{result,revision}', 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.getString(1), resultSet.getLong(2), resultSet.getString(3), + resultSet.getLong(4), resultSet.getString(5), resultSet.getString(6)); + } + } + } + + private OutboxFact outboxFact(Long blockId, Long blockRevision) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + SELECT owner_user_id, source_command_id, event_type, notification_type, resource_ref_type, + publish_status, attempt_count, max_attempt, payload_summary::text + FROM muse_content_event_publish_outbox + WHERE tenant_id = ? + AND block_id = ? + AND block_revision = ? + """)) { + statement.setLong(1, TENANT_ID); + statement.setLong(2, blockId); + statement.setLong(3, blockRevision); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return new OutboxFact(resultSet.getLong(1), resultSet.getString(2), resultSet.getString(3), + resultSet.getString(4), resultSet.getString(5), resultSet.getString(6), + resultSet.getInt(7), resultSet.getInt(8), resultSet.getString(9)); + } + } + } + + 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 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 static CompletedApprovalSettings settings() { + if (cachedSettings == null) { + cachedSettings = CompletedApprovalSettings.fromPropertiesAndEnvironment(); + } + return cachedSettings; + } + + private static void cleanSchema(Flyway flyway, CompletedApprovalSettings settings) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, CompletedApprovalSettings settings) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, CompletedApprovalSettings 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_COMPLETED_PASSWORD", + "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: 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(P1rContentCoreCompletedApprovalIT::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 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 emptySourceBlockId, + Long emptyChapterId, + Long emptyWorkId, + Long mismatchWorkId, + Long otherOwnerWorkId, + Long otherOwnerChapterId, + Long otherOwnerBlockId, + Long otherTenantWorkId, + Long otherTenantChapterId, + Long otherTenantBlockId) { + } + + private record Snapshot(Integer revision, + String content, + int sourceAttributionCount, + int outboxCount, + int commandCount) { + } + + private record CommandFact(String commandType, + Long ownerUserId, + String targetType, + Long targetId, + String resultRevision, + String resultSnapshot) { + } + + private record OutboxFact(Long ownerUserId, + String sourceCommandId, + String eventType, + String notificationType, + String resourceRefType, + String publishStatus, + int attemptCount, + int maxAttempt, + String payloadSummary) { + } + + private record CompletedApprovalSettings(String jdbcUrl, + String jdbcUser, + String jdbcPassword, + String flywayLocations) { + + static CompletedApprovalSettings 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 CompletedApprovalSettings(url, user, password, locations); + } + } + + @SpringBootConfiguration + @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({ + AppContentController.class, + AppContentSourceController.class, + ContentAppServiceImpl.class, + ContentSourceServiceImpl.class, + ContentCommandServiceImpl.class, + ContentAuditServiceImpl.class, + ContentEventPublishOutboxServiceImpl.class, + SpringUtil.class + }) + static class CompletedApprovalConfiguration { + + @Bean + MuseContentEventsProperties museContentEventsProperties() { + return new MuseContentEventsProperties(); + } + + @Bean + ContentAiSuggestionFacade contentAiSuggestionFacade() { + return new ContentAiSuggestionFacade() { + }; + } + + @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/P1rContentEventsPublishFlywayMigrationIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java index 97c21dd2..001dd5a7 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java @@ -3,6 +3,7 @@ package cn.iocoder.muse.server.framework.api; import org.flywaydb.core.Flyway; import org.flywaydb.core.api.MigrationInfo; import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; @@ -67,6 +68,14 @@ class P1rContentEventsPublishFlywayMigrationIT { "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token" ); + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @AfterAll + static void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } @Test void should_migrateV1ToV21OnRealPostgresqlAndVerifyContentPublishOutboxSchema() throws Exception { @@ -205,10 +214,38 @@ class P1rContentEventsPublishFlywayMigrationIT { private static void redactFlywaySystemProperties(String url, String user) { // 原因:Surefire XML 会记录 JVM system property;读取后立即脱敏,避免报告文件残留真实连接信息。 + captureOriginalFlywaySystemProperties(); System.setProperty("p1r.flyway.url", maskedUrl(url)); System.setProperty("p1r.flyway.user", maskUser(user)); } + 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 cleanSchema(Flyway flyway, String url, String user) { try { flyway.clean(); @@ -488,6 +525,10 @@ class P1rContentEventsPublishFlywayMigrationIT { "block_revision<=0 必须被 V21 check 拒绝"); assertInsertRejected(connection, rowWith("resource_ref_id", 0L), "resource_ref_id<=0 必须被 V21 check 拒绝"); + assertInsertRejected(connection, rowWith("attempt_count", -1), + "attempt_count<0 必须被 V21 check 拒绝"); + assertInsertRejected(connection, rowWith("max_attempt", 0), + "max_attempt<=0 必须被 V21 check 拒绝"); connection.setAutoCommit(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 7e904bae..c6867c98 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,6 +93,29 @@ class P1rContentRealApiGateTest { "savePlanningItem" ); + /** 已批准的 Content 第一批用户端核心作品、章节、Block 与来源归因接口,仍保持 operation-level approval。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + + /** 必须继续保持 needs_verification 的代表性未批准 Content operation,防止 CRUD、导出、规划和 admin 误推进。 */ + private static final Set REPRESENTATIVE_UNAPPROVED_CONTENT_OPERATIONS = Set.of( + "createWork", + "updateWork", + "createChapter", + "mergeBlockSuggestion", + "exportWork", + "getPlanning", + "adminRiskAction" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); /** @@ -173,20 +196,34 @@ class P1rContentRealApiGateTest { } /** - * 验证当前 P1R-1 收口口径:Content 全部进入 dedicated,但仍保持 needs_verification。 + * 验证当前 completed approval 只推进第一批 8 个 Content operation,其余 43 个仍保持 needs_verification。 * * @throws IOException 读取覆盖报告失败时抛出 */ @Test - void should_keep_current_content_operations_dedicated_and_needs_verification() throws IOException { + void should_promote_only_first_batch_content_operations_to_completed() throws IOException { JsonNode contentOperations = readContentOperations(); + int completed = 0; + int needsVerification = 0; for (JsonNode operation : contentOperations) { String operationId = operation.path("operationId").asText(""); assertEquals("dedicated", operation.path("implementationStatus").asText(), operationId + " 当前 P1R-1 目标必须是 dedicated"); - assertEquals("needs_verification", operation.path("completionStatus").asText(), - operationId + " 当前 P1R-1 目标必须是 needs_verification,不能提前宣称 completed"); + if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + operationId + " 已获 Content 第一批批准后必须标记 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + operationId + " 未获 Content 第一批批准,必须继续保持 needs_verification"); + } + } + assertEquals(8, completed, "Content 第一批 operation-level completed approval 只能推进 8 个 operation"); + assertEquals(43, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification"); + for (String operationId : REPRESENTATIVE_UNAPPROVED_CONTENT_OPERATIONS) { + assertContentOperationStatus(operationId, "needs_verification"); } } @@ -213,6 +250,18 @@ class P1rContentRealApiGateTest { return operationIds; } + private void assertContentOperationStatus(String operationId, String expectedCompletionStatus) throws IOException { + for (JsonNode operation : readContentOperations()) { + if (!operationId.equals(operation.path("operationId").asText())) { + continue; + } + assertEquals(expectedCompletionStatus, operation.path("completionStatus").asText(), + "content/" + operationId + " completionStatus 不符合本轮审批边界"); + return; + } + throw new AssertionError("content/" + operationId + " 必须存在于 coverage report"); + } + /** * 从当前 Maven 执行目录逐级向上查找仓库根目录,避免 surefire 在不同模块目录执行时路径失效。 */ 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 582ac16a..8a3c5834 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,6 +45,18 @@ class P1rEventsRealApiGateTest { "unfavoriteAsset" ); + /** Content completed approval 第一批只允许推进的 8 个 operation。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -88,10 +100,10 @@ class P1rEventsRealApiGateTest { void should_keep_completed_approval_summary_at_approved_operation_boundary() throws IOException { JsonNode summary = readReport().path("summary"); - assertEquals(131, summary.path("completedOperations").asInt(), - "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4"); - assertEquals(102, summary.path("needsVerificationOperations").asInt(), - "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 10 和 Market 4"); + assertEquals(139, summary.path("completedOperations").asInt(), + "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 8"); + assertEquals(94, summary.path("needsVerificationOperations").asInt(), + "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 10、Market 4 和 Content 8"); assertEquals(0, summary.path("incompleteOperations").asInt(), "Events 退出 placeholder 后不应再留下 incomplete operation"); assertEquals(0, summary.path("genericPersistenceOperations").asInt(), @@ -154,6 +166,31 @@ class P1rEventsRealApiGateTest { assertEquals(23, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); } + @Test + void should_keep_content_partial_approval_at_8_completed_operations() throws IOException { + JsonNode contentOperations = readContentOperations(); + int completed = 0; + int needsVerification = 0; + + assertEquals(51, contentOperations.size(), "Content operation 数量必须保持 51"); + for (JsonNode operation : contentOperations) { + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "Content/" + operationId + " 必须继续保持 dedicated"); + if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "Content/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(8, completed, "Content 第一批 completed approval 只能推进 8 个 operation"); + assertEquals(43, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification"); + } + private JsonNode readSingleEventsOperation() throws IOException { JsonNode eventsOperations = readEventsOperations(); assertEquals(EXPECTED_EVENTS_OPERATION_COUNT, eventsOperations.size(), @@ -173,6 +210,10 @@ class P1rEventsRealApiGateTest { return readOperationsByDomain("account"); } + private JsonNode readContentOperations() throws IOException { + return readOperationsByDomain("content"); + } + private void assertMarketOperationStatus(String operationId, String expectedCompletionStatus, boolean expectedRequiresCommandId) throws IOException { for (JsonNode operation : readMarketOperations()) { 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 dae9e20a..f438951c 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,6 +107,18 @@ class P1rKnowledgeRealApiGateTest { "unfavoriteAsset" ); + /** Content completed approval 第一批只允许推进的 8 个 operation。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -166,19 +178,46 @@ class P1rKnowledgeRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(131, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4"); + assertEquals(139, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 8"); } @Test void should_keep_existing_dedicated_domains_unchanged() throws IOException { assertDomainStatusCount("ai", 41, "completed"); - assertDomainStatusCount("content", 51); + assertContentStatusCount(8, 43); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); assertMarketStatusCount(4, 28); } + private void assertContentStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { + int completed = 0; + int needsVerification = 0; + int total = 0; + for (JsonNode operation : readOperations()) { + if (!"content".equals(operation.path("domain").asText())) { + continue; + } + total++; + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "content/" + operationId + " 必须保持 dedicated"); + if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "content/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(51, total, "Content operation 数量必须保持 51"); + assertEquals(expectedCompleted, completed, "Content 第一批 completed approval 只能推进 8 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification"); + } + @Test void should_promote_p1r7_events_stream_without_reintroducing_sse_placeholder() throws IOException { int ssePlaceholderCount = 0; 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 880329e5..019e3725 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,6 +78,18 @@ class P1rMarketRealApiGateTest { "unfavoriteAsset" ); + /** Content completed approval 第一批只允许推进的 8 个 operation。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -133,19 +145,46 @@ class P1rMarketRealApiGateTest { void should_keep_completion_summary_owned_by_approved_scopes_only() throws IOException { JsonNode report = readReport(); - assertEquals(131, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4"); + assertEquals(139, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 8"); } @Test void should_keep_existing_domain_statuses_unchanged() throws IOException { assertDomainStatusCount("ai", 41, "completed"); assertDomainStatusCount("knowledge", 59, "completed"); - assertDomainStatusCount("content", 51, "needs_verification"); + assertContentStatusCount(8, 43); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); } + private void assertContentStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { + int completed = 0; + int needsVerification = 0; + int total = 0; + for (JsonNode operation : readOperations()) { + if (!"content".equals(operation.path("domain").asText())) { + continue; + } + total++; + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "content/" + operationId + " 必须保持 dedicated"); + if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "content/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(51, total, "Content operation 数量必须保持 51"); + assertEquals(expectedCompleted, completed, "Content 第一批 completed approval 只能推进 8 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification"); + } + @Test void should_promote_p1r7_events_stream_without_reintroducing_sse_placeholder() throws IOException { int ssePlaceholderCount = 0; 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 f30966c0..e1d77bf2 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,6 +76,18 @@ class P1rMetaRealApiGateTest { "activateFunctionChainVersion" ); + /** Content completed approval 第一批只允许推进的 8 个 operation。 */ + private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of( + "listWorks", + "getWork", + "listChapters", + "getChapter", + "listBlocks", + "getBlock", + "saveBlock", + "getBlockSourceAttribution" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); /** @@ -155,11 +167,46 @@ class P1rMetaRealApiGateTest { assertEquals(0, needsVerification, "Meta operation-level approval 后不应再保留 needs_verification operation"); } + /** + * 验证 Content 只推进第一批 8 个 operation,防止 Meta gate 漏掉 Content 未批准项回退检查。 + * + * @throws IOException 读取覆盖报告失败时抛出 + */ + @Test + void should_keep_content_partial_approval_at_8_completed_operations() throws IOException { + JsonNode contentOperations = readContentOperations(); + int completed = 0; + int needsVerification = 0; + + assertEquals(51, contentOperations.size(), "Content operation 数量必须保持 51"); + for (JsonNode operation : contentOperations) { + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "Content/" + operationId + " 必须继续保持 dedicated"); + if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "Content/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(8, completed, "Content 第一批 completed approval 只能推进 8 个 operation"); + assertEquals(43, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification"); + } + private JsonNode readMetaOperations() throws IOException { JsonNode operations = objectMapper.readTree(findReportPath().toFile()).path("operations"); return filterMetaOperations(operations); } + private JsonNode readContentOperations() throws IOException { + JsonNode operations = objectMapper.readTree(findReportPath().toFile()).path("operations"); + return filterContentOperations(operations); + } + private JsonNode filterMetaOperations(JsonNode operations) { ArrayNode metaOperations = objectMapper.createArrayNode(); for (JsonNode operation : operations) { @@ -170,6 +217,16 @@ class P1rMetaRealApiGateTest { return metaOperations; } + private JsonNode filterContentOperations(JsonNode operations) { + ArrayNode contentOperations = objectMapper.createArrayNode(); + for (JsonNode operation : operations) { + if ("content".equals(operation.path("domain").asText())) { + contentOperations.add(operation); + } + } + return contentOperations; + } + private static Map toOperationSignatures(JsonNode operations) { Map operationSignatures = new LinkedHashMap<>(); for (JsonNode operation : operations) { diff --git a/muse-cloud/scripts/p1r-audit-api-coverage.py b/muse-cloud/scripts/p1r-audit-api-coverage.py index 24f0b757..6aeb5033 100755 --- a/muse-cloud/scripts/p1r-audit-api-coverage.py +++ b/muse-cloud/scripts/p1r-audit-api-coverage.py @@ -79,6 +79,15 @@ APPROVED_COMPLETED_OPERATIONS = { "market:listMarketplaceCategories", "market:favoriteAsset", "market:unfavoriteAsset", + # P1R Content completed approval 第一批仍使用 operation-level 清单,避免整域 Content 被误升 completed。 + "content:listWorks", + "content:getWork", + "content:listChapters", + "content:getChapter", + "content:listBlocks", + "content:getBlock", + "content:saveBlock", + "content:getBlockSourceAttribution", }