diff --git a/docs/agent-specs/.agent b/docs/agent-specs/.agent index e12a2c37..fd08f1d1 100644 --- a/docs/agent-specs/.agent +++ b/docs/agent-specs/.agent @@ -145,3 +145,21 @@ P1R Account Completed Approval 执行版 fresh review 已双 PASS:James execut P1R Account 第一批 operation-level completed approval implementation 当前状态:用户已批准执行版四项前置条件,本轮只把 10 个 Account operation 加入 operation-level completed approval,未把 `account` 加入 domain-level allowlist,未修改 OpenAPI、Account 业务实现或数据库迁移。10 个 completed operation 为 `getCurrentUser`、`getProfile`、`updateProfile`、`adminListAccountUsers`、`adminGetUserEntitlements`、`getAppEntitlements`、`adminGetBalanceSnapshots`、`getAppBalanceSnapshots`、`adminCreateQuotaAdjustment`、`adminListQuotaAdjustments`;`getAppUsage`、`adminListUsageRecords` 和 New-API / FileService / attribution / Market projection / security event 相关 23 个 Account operation 继续 `dedicated/needs_verification`。scanner 仍保持 `APPROVED_COMPLETED_DOMAINS={"ai","knowledge"}`,只在 `APPROVED_COMPLETED_OPERATIONS` 追加上述 10 个 `account:*` key。TDD RED 已验证旧 report 下 `P1rApiCoverageReportTest,P1rAccountRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest,P1rMarketRealApiGateTest` 为 37 tests / 10 failures,失败点为 expected completed=127 but was 117、Account expected 10 completed but was 0;GREEN 后 scanner 输出 summary `233/127/106/0/0/0`,Account 为 `completed=10`、`needs_verification=23`。验证 evidence:`python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` 通过;Account focused verification 为 Member 114/114 pass 与 muse-server 19/19 pass;P1R mixed gates `P1rApiCoverageReportTest,P1rAccountRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest,P1rMarketRealApiGateTest,P1rContentRealApiGateTest,P1rMetaRealApiGateTest` 为 46/46 pass;`P1rAccountEventsPublishFlywayMigrationIT` 使用真实 PostgreSQL `_test` 库 `muse_p1r_account_completed_approval_test` 为 4/4 pass,输出 `flyway_success=true`、`migrations_executed=20`、`successful_migration_count=20`、`target_schema_version=20`、`flyway_latest=20:extend account events publish outbox`;增强证据 `AccountEventPublishOutboxServiceTest,AccountEventPublishWorkerTest` 为 19/19 pass;XML 防空跑覆盖 25 个目标 XML,合计 192 tests,failures/errors/skipped 均为 0。新增 memory:`docs/memorys/2026-06-11-P1RAccount状态推进.md`。当前尚未提交、未 push;下一步必须 fresh implementation spec/correctness review + fresh implementation quality/data-integrity/testing review,双 PASS 前不得提交、push 或宣称 Account 33/33、Market、Content 或总 P1R completed。 P1R Account 第一批 operation-level completed approval fresh implementation review 已双 PASS:Nash spec/correctness review PASS、Copernicus quality/data-integrity/testing review PASS,均无 P0/P1/P2/P3 blocker。Nash 已验证正确 worktree、scanner 未加入 `account` domain allowlist、只追加批准的 10 个 `account:*` operation key、report 为 `233/127/106/0/0/0` 且 Account `10 completed / 23 needs_verification`、usage/New-API/FileService/attribution/Market projection/security event 仍为 `dedicated/needs_verification`、Account/Coverage gate 绑定具体 key、非目标 Market/Content/Meta/AI/Knowledge/Events 口径正确、OpenAPI 与业务实现无 diff、memory/.agent 未过度宣称。Copernicus 已验证 XML 防空跑 25 个目标 XML 合计 192 tests 且 failures/errors/skipped 均为 0,XML 时间集中在本轮验证窗口,Flyway IT 只连接 `_test` 库 `muse_p1r_account_completed_approval_test` 且 URL/user 脱敏、密码来自环境变量、clean/migrate V1-V20 target V20 并验证 V20 outbox 表/索引/约束/trigger,allowed diff 只落在 scanner/report/P1R gates/spec/memory 文档。该双 PASS 只代表本轮 Account 第一批 10 个 operation-level completed approval 具备收口条件;不代表 Account 33/33、Market、Content 或总 P1R completed。提交或 push 仍需用户明确指令。 + +P1R Market Completed Approval 已启动审阅版:`docs/agent-specs/2026-06-11-P1RMarketCompletedApproval审阅版.md`。本审阅版只冻结 Market 32 个 `dedicated/needs_verification` operation 的 completed approval 证据标准和推荐路线,不修改 OpenAPI、scanner、coverage report 或 Market 业务实现,也不推进 Market/Content/Account remaining/总 P1R completed。只读盘点事实:当前 coverage summary 为 `233/127/106/0/0/0`,Market 32 个 operation 全部仍为 `dedicated/needs_verification`;Account 当前为 `completed=10`、`needs_verification=23`。并行子代理只读复核结论:第一候选是 Marketplace 浏览/收藏 6 个 operation,但 `favoriteAsset` / `unfavoriteAsset` 存在实现要求 `X-Command-Id` 而 OpenAPI/report `requiresCommandId=false` 的合同不一致;如不批准 OpenAPI 合同修正,第一批应降级为纯读候选。purchase/install/handoff/governanceImpact 缺目标 owner 消费闭环与 Account 读侧 E2E;publish/review/governance 可作为后续强候选但缺 fresh completed 级验证;appeal 申诉审计、治理 outbox、Account projection 不应混成 completed 闭环。下一步必须对该 Market 审阅版做 fresh spec/scope review + fresh quality/feasibility review;双 PASS 前不得写执行版、实现、提交、push 或修改 scanner/report/gate。 + +P1R Market Completed Approval 审阅版首轮 review:Popper spec/scope review PASS,无阻塞项;Ptolemy quality/feasibility/testing review FAIL,P1 有效:后续执行版原要求只点名 `P1rMarketRealApiGateTest` / `P1rApiCoverageReportTest` 与泛化 mixed gates,不足以覆盖 `P1rEventsRealApiGateTest`、`P1rAiRealApiGateTest`、`P1rKnowledgeRealApiGateTest` 等 legacy gate 中硬编码的 `completed=127`、`needsVerification=106` 或 Market 全 `needs_verification` 断言。审阅版已修订:执行版必须枚举所有读取全局 summary 或 Market 状态的 P1R mixed gate,至少包括 `P1rApiCoverageReportTest`、`P1rMarketRealApiGateTest`、`P1rEventsRealApiGateTest`、`P1rAiRealApiGateTest`、`P1rKnowledgeRealApiGateTest`;需要同步断言的文件必须列入单独用户批准项、allowed diff 和 required XML 防空跑清单;目标值公式化为 `completed=127+N`、`needsVerification=106-N`、Market `completed=N / needs_verification=32-N`,其中 `N` 只能来自用户批准的 Market operation。由于审阅版已在 review 后修订,Popper PASS 失效,下一步必须重新派发 fresh spec/scope review + fresh quality/feasibility review。 + +P1R Market Completed Approval 审阅版第二轮 fresh review 已双 PASS:Godel spec/scope review PASS、Jason quality/feasibility/testing review PASS,均无 P0/P1/P2/P3 阻塞项。Godel 已验证文档仍停留在审阅版边界,未推进 Market 32/32、Market domain、Account remaining、Content 或总 P1R completed;浏览/收藏 6 个 operation 只作为候选;`favoriteAsset` / `unfavoriteAsset` 的 `X-Command-Id` 合同缺口仍是执行前硬门槛;legacy mixed gate 修订覆盖首轮 P1。Jason 已验证 OpenAPI 当前确实缺 favorite/unfavorite `X-Command-Id` header,而 controller/service 会强制校验;scanner 支持识别 required `X-Command-Id` header,后续修 OpenAPI 是可落地路径;`listMarketplaceRecommendations` 确实为 fallback 排序,已要求用户确认;handoff/install/events outbox/Account projection 等暂不推荐切片的缺口与代码事实一致。非阻塞建议:执行版需列明 Account remaining 23 / Content 防误推进的具体 gate 类名,写清第一批 `N` 的分支目标值,补 missing/blank `X-Command-Id` HTTP 失败断言,并把 recommendations fallback 写成明确的用户可批准业务口径。该双 PASS 只代表 Market 审阅版可进入执行版编写;不代表 Market completed,也不允许未获批准前修改 OpenAPI、scanner、report、gate 或业务实现。 + +P1R Market Completed Approval 执行版初稿记录:该初稿曾把 Marketplace 浏览/推荐/收藏候选合并进第一批,但已在首轮 execution quality/feasibility review 后判定证据不足并被下一段修订记录取代。当前有效执行版必须以后续 4-operation 修订版本为准;初稿不能作为实现、审批、提交或推进 Market completed 的依据。 + +P1R Market Completed Approval 执行版首轮 review:Meitner execution spec/scope review PASS,Mill execution quality/feasibility review FAIL。Mill 的 5 个 P1 已只读验证为有效:`listMarketplaceRecommendations` 仍存在 OpenAPI 个性化推荐语义与 fallback 排序实现不闭合;MockMvc + mock service/mapper + Flyway 组合不足以证明 favorite/unfavorite HTTP 到真实 DB 闭环;V8 favorite 表、唯一约束、command 约束和 trigger 在 `P1rMarketFlywayMigrationIT` 中不是 mandatory evidence;XML 防空跑只读旧 surefire XML 存在假绿风险;`listMarketplaceAssets` 缺 pageNo/pageSize 1..100 边界证据且 `PAGE_SIZE_NONE=-1` 可能触发全量查询。执行版已修订:第一批从 6 个 operation 收紧为 4 个 `getMarketplaceAsset`、`listMarketplaceCategories`、`favoriteAsset`、`unfavoriteAsset`;目标 summary 改为 `233/131/102/0/0/0`,Market 改为 `completed=4`、`needsVerification=28`;`listMarketplaceAssets` 与 `listMarketplaceRecommendations` 明确保留 `dedicated/needs_verification`;新增 mandatory `P1rMarketDiscoveryFavoriteCompletedApprovalIT` HTTP + 真实 PostgreSQL `_test` gate,沿用 `p1r.flyway.url/user` system property 与 `P1R_FLYWAY_PASSWORD` 环境变量,禁止通过 JVM property 传密码;Flyway favorite schema/约束/trigger assertion 改为 mandatory 且 XML 最低 tests 提到 4;GREEN 阶段 Maven test 验证命令要求删除目标 XML、记录 exported `RUN_START_EPOCH` 并校验 XML mtime/tests/failures/errors/skipped,TDD RED 只要求确认失败发生在本次运行窗口且失败点来自旧 report mismatch。由于执行版已在 review 后修订,Meitner PASS 不能计入最终 gate;下一步必须重新派发 fresh execution spec/scope review + fresh execution quality/feasibility review。未双 PASS 前不得实现、提交、push 或推进 Market completed。 + +P1R Market Completed Approval 修订执行版 fresh review 当前状态:Parfit execution spec/scope review PASS,无阻塞项;Harvey execution quality/feasibility review FAIL,反馈已按 `superpowers:receiving-code-review` 只读验证并修订。有效修订包括:Market HTTP+DB `_test` 建库命令改为 `PGPASSWORD=... psql -h/-p/-U/-d`,不再把密码嵌入 URI 或 JVM system property;`P1rMarketDiscoveryFavoriteCompletedApprovalIT` 最低 XML tests 从 6 提到 8,mandatory 覆盖 `DELETE /favorite` 缺 `X-Command-Id`、missing/invisible asset 对 favorite/unfavorite 不写 command/favorite fact;rollback 策略按“撤回 OpenAPI commandId header”和“保留 OpenAPI commandId 合同”两条分支分别恢复 `requiresCommandId=false/true`。由于执行版再次修订,Parfit PASS 不能计入最终 gate;下一步必须重新派发 fresh execution spec/scope review + fresh execution quality/feasibility review。未双 PASS 前不得实现、提交、push 或推进 Market completed。 + +P1R Market Completed Approval 修订执行版最终 fresh review 已双 PASS:Banach execution spec/scope review PASS、Avicenna execution quality/feasibility/testing review PASS,均无 P0/P1/P2/P3 blocker。Banach 已验证第一批严格限定 4 个 operation:`getMarketplaceAsset`、`listMarketplaceCategories`、`favoriteAsset`、`unfavoriteAsset`;不做 Market domain-level allowlist;目标值仅为获批后目标 `233/131/102/0/0/0` 与 Market `4 completed / 28 needs_verification`;`listMarketplaceAssets` 与 `listMarketplaceRecommendations` 继续 `needs_verification`;OpenAPI `X-Command-Id` 修正是单独用户批准项;allowed/prohibited diff 与非目标域边界自洽。Avicenna 已验证 Harvey 三项 blocker 已关闭:建库命令使用 `PGPASSWORD` 且 Maven 不通过 URI/JVM property 暴露密码,HTTP+DB `_test` 最低 8 tests 覆盖 detail/categories/favorite/unfavorite happy/error/replay/inactive,Flyway favorite assertion、XML 防空跑、rollback 双分支和目标 summary/Market 4/28 均已落到执行步骤。当前仍未实现、未修改 OpenAPI、scanner、coverage report、Market 业务实现或 gate test;下一步进入用户批准点。未获得用户明确批准四项前,不得实施本执行版、提交、push 或推进 Market completed。 + +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 仍需用户明确指令。 diff --git a/docs/agent-specs/2026-06-11-P1RMarketCompletedApproval审阅版.md b/docs/agent-specs/2026-06-11-P1RMarketCompletedApproval审阅版.md new file mode 100644 index 00000000..237342f3 --- /dev/null +++ b/docs/agent-specs/2026-06-11-P1RMarketCompletedApproval审阅版.md @@ -0,0 +1,311 @@ +# P1R Market Completed Approval 审阅版 + +日期:2026-06-11 + +## 结论 + +不建议把 Market 32 个 operation 一次性整域推进 `completed`。 + +推荐把 Market Completed Approval 拆成 operation-level 或证据切片推进。第一候选是 Marketplace 浏览/收藏切片,但它还不能直接进入实现:`favoriteAsset` / `unfavoriteAsset` 的实现要求 `X-Command-Id`,而当前 OpenAPI 与 coverage report 仍显示这两个 operation `requiresCommandId=false`。这是合同不一致,必须在执行版中单独审批修正或把首批降级为纯读 operation。 + +本审阅版只冻结 Market completed approval 的范围判断、证据缺口、推荐审批粒度和后续执行版要求。不修改 OpenAPI,不修改 scanner,不修改 coverage report,不修改业务实现,也不把 Market 或其它 domain 推进 `completed`。 + +```mermaid +flowchart TB + Start["当前 coverage
Market 32 dedicated / needs_verification"] --> Review["Market completed approval 审阅版
冻结证据标准"] + Review --> Split{"是否整域 32/32 completed?"} + Split -->|否,推荐| Slice["按证据切片 operation-level approval"] + Split -->|是,不推荐| Domain["domain-level approval
需购买/安装/handoff/发布/治理/申诉全闭环"] + Slice --> Candidate["第一候选
Marketplace 浏览/收藏"] + Candidate --> Contract{"favorite/unfavorite 合同一致?"} + Contract -->|否| ReadOnly["降级为纯读候选
或先审批 OpenAPI 合同修正"] + Contract -->|是| Exec["执行版
列出 operation、allowed diff、fresh 验证"] + Exec --> FreshReview["fresh spec/scope review
fresh quality/feasibility review"] + FreshReview --> UserApproval{"用户明确批准
Market 状态推进?"} + UserApproval -->|否| Stay["保持 Market 32 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 +f18116a test(p1r): 收口 Account 第一批 completed approval 门禁 +``` + +当前 coverage summary: + +```text +total=233 +completed=127 +needsVerification=106 +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=0 needsVerification=32 +meta total=16 completed=16 needsVerification=0 +``` + +Market 32 个 operation 当前全部为 `dedicated / needs_verification`: + +| operationId | Method | Path | 当前状态 | +|---|---|---|---| +| `adminListAppeals` | `GET` | `/admin-api/muse/market/appeals` | `dedicated / needs_verification` | +| `adminGetAppeal` | `GET` | `/admin-api/muse/market/appeals/{appealId}` | `dedicated / needs_verification` | +| `adminResolveAppeal` | `POST` | `/admin-api/muse/market/appeals/{appealId}/resolve` | `dedicated / needs_verification` | +| `adminListMarketAssets` | `GET` | `/admin-api/muse/market/assets` | `dedicated / needs_verification` | +| `adminGetMarketAsset` | `GET` | `/admin-api/muse/market/assets/{assetId}` | `dedicated / needs_verification` | +| `adminDelistAsset` | `POST` | `/admin-api/muse/market/assets/{assetId}/delist` | `dedicated / needs_verification` | +| `adminPreviewGovernanceImpact` | `POST` | `/admin-api/muse/market/assets/{assetId}/governance-impact` | `dedicated / needs_verification` | +| `adminRecallAsset` | `POST` | `/admin-api/muse/market/assets/{assetId}/recall` | `dedicated / needs_verification` | +| `adminListPublishRequests` | `GET` | `/admin-api/muse/market/publish-requests` | `dedicated / needs_verification` | +| `adminApprovePublishRequest` | `POST` | `/admin-api/muse/market/publish-requests/{requestId}/approve` | `dedicated / needs_verification` | +| `adminRejectPublishRequest` | `POST` | `/admin-api/muse/market/publish-requests/{requestId}/reject` | `dedicated / needs_verification` | +| `submitAppeal` | `POST` | `/app-api/muse/marketplace/appeals` | `dedicated / needs_verification` | +| `supplementAppeal` | `POST` | `/app-api/muse/marketplace/appeals/{appealId}/supplements` | `dedicated / needs_verification` | +| `withdrawAppeal` | `POST` | `/app-api/muse/marketplace/appeals/{appealId}/withdraw` | `dedicated / needs_verification` | +| `listMarketplaceAssets` | `GET` | `/app-api/muse/marketplace/assets` | `dedicated / needs_verification` | +| `getMarketplaceAsset` | `GET` | `/app-api/muse/marketplace/assets/{assetId}` | `dedicated / needs_verification` | +| `createBindPrecheck` | `POST` | `/app-api/muse/marketplace/assets/{assetId}/bind-precheck` | `dedicated / needs_verification` | +| `unfavoriteAsset` | `DELETE` | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `dedicated / needs_verification` | +| `favoriteAsset` | `POST` | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `dedicated / needs_verification` | +| `getGovernanceImpact` | `GET` | `/app-api/muse/marketplace/assets/{assetId}/governance-impact` | `dedicated / needs_verification` | +| `installMarketplaceAsset` | `POST` | `/app-api/muse/marketplace/assets/{assetId}/install` | `dedicated / needs_verification` | +| `purchaseAsset` | `POST` | `/app-api/muse/marketplace/assets/{assetId}/purchase` | `dedicated / needs_verification` | +| `listMarketplaceCategories` | `GET` | `/app-api/muse/marketplace/categories` | `dedicated / needs_verification` | +| `createMarketplaceHandoff` | `POST` | `/app-api/muse/marketplace/handoffs` | `dedicated / needs_verification` | +| `getHandoffStatus` | `GET` | `/app-api/muse/marketplace/handoffs/{handoffToken}` | `dedicated / needs_verification` | +| `cancelHandoff` | `POST` | `/app-api/muse/marketplace/handoffs/{handoffToken}/cancel` | `dedicated / needs_verification` | +| `listMyPublishRecords` | `GET` | `/app-api/muse/marketplace/my-publish-records` | `dedicated / needs_verification` | +| `savePublishDraft` | `POST` | `/app-api/muse/marketplace/publish-drafts` | `dedicated / needs_verification` | +| `runPublishCheck` | `POST` | `/app-api/muse/marketplace/publish-drafts/{draftId}/checks` | `dedicated / needs_verification` | +| `submitPublishRequest` | `POST` | `/app-api/muse/marketplace/publish-requests` | `dedicated / needs_verification` | +| `withdrawPublishRequest` | `POST` | `/app-api/muse/marketplace/publish-requests/{requestId}/withdraw` | `dedicated / needs_verification` | +| `listMarketplaceRecommendations` | `GET` | `/app-api/muse/marketplace/recommendations` | `dedicated / needs_verification` | + +## 已验证实现证据 + +### Market owner 与入口 + +Market owner 当前位于 `muse-module-market`,负责市场来源侧事实、授权、安装、handoff、发布、治理、申诉、浏览和收藏。 + +现有 dedicated Controller 覆盖 Market 32 个 operation: + +- `AppMuseMarketplaceAssetController` +- `AppMuseMarketLicenseController` +- `AppMuseMarketHandoffController` +- `AppMuseMarketPublishController` +- `AppMuseMarketAppealController` +- `AdminMuseMarketAssetController` +- `AdminMuseMarketReviewController` +- `AdminMuseMarketAppealController` + +`P1rMarketRouteOwnershipTest` 已证明 Market 路由由 dedicated controller 持有,不再走 `MuseContractPersistenceService` 旧合同兜底。 + +### 数据库与迁移证据 + +V15 Market Real API schema 已存在: + +- `muse-cloud/sql/muse/V15__extend_market_real_api_schema.sql` + +历史留痕记录 V15 已在真实 PostgreSQL `_test` 库通过 Flyway: + +```text +flyway_success=true +migrations_executed=15 +successful_migration_count=15 +target_schema_version=15 +flyway_latest=15:extend market real api schema +schema_version=15 +``` + +历史 Market focused tests 记录为 191/191 pass,P1R Market gates 记录为 24/24 pass。但这些证据只证明 P1R-6 已把 Market 32 个 operation 收口到 `dedicated / needs_verification`,不能作为本轮 completed approval 的 fresh runtime 证据。 + +### 子代理只读盘点结论 + +本轮按 Market 业务切片并行只读复核,结论如下: + +- Marketplace 浏览/收藏 6 个 operation 是第一批 operation-level completed 候选,但仍缺合同一致性和真实 HTTP + DB 闭环。 +- purchase / install / handoff / bind-precheck / governanceImpact 7 个 operation 不适合作为第一批;缺目标 owner 消费 handoff token、回写 completed、Account 读侧 E2E 和跨 owner governance 影响闭环。 +- publish / review / governance 13 个 operation 的实现和测试证据较强,可作为后续候选切片;但当前仍缺审阅版/执行版冻结、fresh completed 级验证和 Market 整域覆盖,不应直接推进 completed。 +- appeal / events / projection 6 个申诉相关 operation 不适合作为第一批;申诉事件是 `muse_market_appeal_event` 审计事实,不是统一 Events publish outbox;`restore` 治理 action 当前不进入 MarketEventPublishOutbox;MarketAccountProjectionProvider 不覆盖申诉。 + +## 推荐第一候选 + +推荐第一批候选限定为 Marketplace 浏览/收藏切片: + +```text +listMarketplaceAssets +getMarketplaceAsset +listMarketplaceCategories +listMarketplaceRecommendations +favoriteAsset +unfavoriteAsset +``` + +推荐理由: + +- Controller / Service / Mapper 链路相对短,主要停留在 Market 本域。 +- 浏览读侧依赖资产、版本、收藏状态和可见性,不需要真实支付、安装、handoff 或目标 owner 消费。 +- 收藏写侧已有 command replay、owner 登录校验、可见资产校验、`muse_market_favorite` active/inactive 审计事实。 +- 风险面小于 purchase/install/handoff、publish/review/governance、appeal/projection 切片。 + +但该候选不能直接批准。执行版必须先解决以下硬条件: + +1. `favoriteAsset` / `unfavoriteAsset` 的 `X-Command-Id` 合同不一致:实现和 controller test 要求 header,OpenAPI 与 coverage report 仍显示 `requiresCommandId=false`。 +2. `listMarketplaceRecommendations` 当前是可解释 fallback 排序,不是独立推荐系统读模型;执行版必须明确 MVP completed 口径接受 fallback,或把该 operation 留在 `needs_verification`。 +3. 缺少真实 Spring context + 测试数据库的 HTTP/DB 闭环:上架资产种子、列表/详情/分类/推荐、收藏/取消、DB 状态、回放、冲突和不可见资产拒绝。 +4. 列表/推荐当前存在逐项取 currentVersion / favorite 的 N+1 查询形态;执行版必须给出分页上限、SQL/mapper 验证或明确的 MVP 接受边界。 +5. 当前 `P1rMarketRealApiGateTest` 明确要求 Market 32 个 operation 保持 `dedicated / needs_verification`;未获用户批准前不能修改 gate。 + +如果不允许在 Market completed approval 中修改 OpenAPI 合同,第一批应降级为纯读候选: + +```text +listMarketplaceAssets +getMarketplaceAsset +listMarketplaceCategories +``` + +`listMarketplaceRecommendations` 是否纳入纯读候选,取决于用户是否接受 fallback recommendation 作为当前 completed 口径。 + +## 暂不推荐的切片 + +### purchase / install / handoff / governanceImpact + +以下 operation 暂不推荐第一批 completed: + +- `purchaseAsset` +- `installMarketplaceAsset` +- `createBindPrecheck` +- `createMarketplaceHandoff` +- `getHandoffStatus` +- `cancelHandoff` +- `getGovernanceImpact` + +主要缺口: + +- `purchaseAsset` 只证明本地授权、purchase fact 和 Account projection 写入,不证明真实支付或 Account 读侧 E2E。 +- `installMarketplaceAsset` 明确不写目标 owner canonical facts,`targetFactsWritten=false`。 +- handoff token 只落 hash,缺真实目标 owner 消费 token、创建 owner precheck、回写 owner confirm result 或推进 handoff completed 的公开链路。 +- `MarketTargetOwnerFacade` 当前只生成目标页并做 owner/action 白名单,接口边界明确不消费 token、不写目标 owner facts。 +- `getGovernanceImpact` 当前主要聚合 Market 自有 authorization / installation / handoff / governance facts,不证明 AI / Knowledge / Content 目标 owner 的真实影响闭环。 + +### publish / review / governance + +以下 operation 可作为后续强候选,但不推荐直接作为第一批: + +- `savePublishDraft` +- `runPublishCheck` +- `submitPublishRequest` +- `withdrawPublishRequest` +- `listMyPublishRecords` +- `adminListPublishRequests` +- `adminApprovePublishRequest` +- `adminRejectPublishRequest` +- `adminListMarketAssets` +- `adminGetMarketAsset` +- `adminPreviewGovernanceImpact` +- `adminDelistAsset` +- `adminRecallAsset` + +已有证据显示它们具备草稿、检查、提交、审核、上架、治理 preview、delist/recall 和部分 Events outbox 链路。但它们仍不应直接 completed: + +- 当前没有 Market Completed Approval 执行版冻结这 13 个 operation 的审批范围和 allowed diff。 +- 仍缺 fresh focused tests、P1R mixed gates、真实 `_test`、XML 防空跑和必要的 HTTP/API 或跨 owner runtime 验收。 +- `MarketEventPublishOutboxService` 当前 allowlist 是 `delist / recall`,不能把所有发布/审核/治理操作都等同于 Events 可见闭环。 +- 该切片不能代表 Market 32/32 domain completed。 + +### appeals / restore / projection + +以下 operation 暂不推荐 first batch: + +- `submitAppeal` +- `supplementAppeal` +- `withdrawAppeal` +- `adminListAppeals` +- `adminGetAppeal` +- `adminResolveAppeal` + +主要缺口: + +- `submit/supplement/withdraw/resolve` 写入的是 `muse_market_appeal_event` 审计事实,不是统一 Events publish outbox。 +- `adminResolveAppeal` 的 restore / partially_restored 路径写 `actionType=restore`,当前 outbox service 会跳过。 +- `MarketAccountProjectionProvider` 只支持 `purchase / license / publish`,不覆盖申诉。 +- 因此不能把申诉审计、治理 outbox 和 Account projection 混成一个 completed 闭环。 + +## 非目标 + +- 不修改 7 个 OpenAPI: + - `docs/api-contracts/account/openapi.yaml` + - `docs/api-contracts/ai/openapi.yaml` + - `docs/api-contracts/content/openapi.yaml` + - `docs/api-contracts/events/openapi.yaml` + - `docs/api-contracts/knowledge/openapi.yaml` + - `docs/api-contracts/market/openapi.yaml` + - `docs/api-contracts/meta/openapi.yaml` +- 不修改 `muse-cloud/scripts/p1r-audit-api-coverage.py`。 +- 不修改 `docs/superpowers/reports/p1r-api-coverage.json`。 +- 不修改 `docs/superpowers/reports/p1r-api-coverage.md`。 +- 不修改 Market 业务实现。 +- 不把 Market 32 个 operation 推进 `completed`。 +- 不把 Market 加入 domain-level completed allowlist。 +- 不推进 Account remaining 23 / Content / 总 P1R completed。 +- 不把 P1R-7d Market governance event propagation evidence 当作 Market 整域 completed 证据。 + +## 后续执行版必须包含 + +1. Market 32 operation 的完整清单、当前状态和目标审批状态。 +2. 第一批候选 operation 的逐项 evidence map:Controller、Service、Mapper、权限、版本头、owner/tenant 隔离、幂等、审计、失败路径。 +3. 明确哪些 operation 继续 `needs_verification`,以及原因。 +4. `favoriteAsset` / `unfavoriteAsset` 合同处理方案:要么审批 OpenAPI `X-Command-Id` 修正,要么从第一批移除。 +5. `listMarketplaceRecommendations` fallback completed 口径:要么明确接受 fallback 推荐,要么保持 `needs_verification`。 +6. scanner approval 模型:只允许 operation-level allowlist,不允许 Market domain-level allowlist。 +7. `P1rMarketRealApiGateTest` 从禁止 completed 到审批后 partial completed gate 的修改策略。 +8. `P1rApiCoverageReportTest` summary 期望变更策略,必须只提升用户批准的 Market operation 数。 +9. legacy P1R mixed gate 同步策略:执行版必须枚举所有读取全局 summary 或 Market 状态的 gate,至少包括 `P1rApiCoverageReportTest`、`P1rMarketRealApiGateTest`、`P1rEventsRealApiGateTest`、`P1rAiRealApiGateTest`、`P1rKnowledgeRealApiGateTest`;需要同步断言的文件必须列入单独用户批准项、allowed diff 和 required XML 防空跑清单。 +10. Market partial completed 目标值必须公式化:若用户批准 `N` 个 Market operation,则 summary 只能变为 `completed=127+N`、`needsVerification=106-N`,Market 只能变为 `completed=N / needs_verification=32-N`;`N` 只能来自用户批准的 Market operation,不能由 Market domain-level allowlist 推导。 +11. Market focused tests 的 required class 清单与最低 XML tests 数;执行版必须 fresh rerun,不能只引用历史留痕。 +12. P1R mixed gates:必须覆盖 Market gate、coverage report gate、上述 legacy summary/Market gate,以及用于证明 Account remaining 23 / Content 不被推进的 gate;未列入 allowed diff 的非目标 gate 只允许运行和读取 XML,不能顺手改。 +13. 真实 PostgreSQL `_test`:至少覆盖 V1-V15 clean migrate;如新增 Market completed approval Flyway IT,应验证 V15 Market 表、索引、约束、trigger、favorite unique 和非法 insert 拒绝。 +14. 真实 HTTP + DB 或等价集成验收:覆盖上架资产可见性、publisher-only 非公开可见性、收藏 active/inactive、command replay、missing/invisible asset 拒绝。 +15. protected diff gate:7 个 OpenAPI 在批准前必须保持空 diff;若执行版要修正 market OpenAPI,必须把该文件列入单独用户批准和 allowed diff。 +16. allowed-diff gate:覆盖 unstaged、staged、untracked,并使用 `git -c core.quotePath=false`。 +17. rollback 策略:撤回 scanner allowlist、coverage report、gate test、可选 OpenAPI 修正和新增 memory,恢复目标 operation 为 `dedicated / needs_verification`。 + +## 验收标准 + +本审阅版可以视为完成的条件: + +1. 文件写入 `docs/agent-specs/2026-06-11-P1RMarketCompletedApproval审阅版.md`。 +2. `.agent` 记录 Market completed approval 审阅版状态。 +3. `git diff --check` 通过。 +4. OpenAPI、scanner、coverage report 无 diff。 +5. 文档只定义审阅路线,不进入执行版或实现。 +6. fresh spec/scope review PASS。 +7. fresh quality/feasibility review PASS。 + +## 待确认项 + +1. 是否确认 Market 第一轮不做 domain-level 32/32 completed approval。 +2. 是否确认优先走 operation-level approval,并从 Marketplace 浏览/收藏切片开始。 +3. 是否允许后续执行版把 `docs/api-contracts/market/openapi.yaml` 的 `favoriteAsset` / `unfavoriteAsset` `X-Command-Id` 合同修正列为单独批准项。 +4. 是否接受 `listMarketplaceRecommendations` 当前 fallback recommendation 作为 MVP completed 口径;如果不接受,该 operation 第一轮继续 `needs_verification`。 +5. 是否确认 purchase / install / handoff / governanceImpact / appeals 在没有真实跨 owner 闭环前继续保持 `needs_verification`。 +6. 是否确认审阅版 fresh 双 review PASS 后,再写 Market completed approval 执行版。 diff --git a/docs/agent-specs/2026-06-11-P1RMarketCompletedApproval执行版.md b/docs/agent-specs/2026-06-11-P1RMarketCompletedApproval执行版.md new file mode 100644 index 00000000..eb373803 --- /dev/null +++ b/docs/agent-specs/2026-06-11-P1RMarketCompletedApproval执行版.md @@ -0,0 +1,738 @@ +# P1R Market 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-11 + +## 结论 + +本执行版推荐第一轮只做 Market operation-level completed approval,不做 Market domain-level 32/32 completed。 + +第一轮推荐审批 4 个 Marketplace 详情/分类/收藏 operation: + +```text +market:getMarketplaceAsset +market:listMarketplaceCategories +market:favoriteAsset +market:unfavoriteAsset +``` + +审批后目标 coverage summary 为: + +```text +total=233 +completed=131 +needsVerification=102 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +Market 域目标状态为: + +```text +market total=32 +completed=4 +needsVerification=28 +``` + +本执行版只定义实现前置批准项、允许变更范围、TDD 顺序、验证命令、XML 防空跑和回滚策略。当前不得直接实现,不得修改 OpenAPI、scanner、coverage report、业务实现或 gate test。 + +```mermaid +flowchart TB + Current["当前 Market
32 dedicated / needs_verification"] --> Exec["执行版
锁定第一批 4 个 operation"] + Exec --> Review["fresh execution spec/scope review
fresh execution quality/feasibility review"] + Review --> Approval{"用户明确批准
4 ops / operation-level / OpenAPI commandId / allowed diff"} + Approval -->|否| Stay["保持当前 coverage
233/127/106"] + Approval -->|是| Red["TDD RED
先改 Market/Coverage/legacy gates
旧 report 必须失败"] + Red --> Green["TDD GREEN
Market OpenAPI commandId + scanner operation allowlist + regenerate report"] + Green --> Verify["focused + P1R mixed + Flyway _test
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 +f18116a test(p1r): 收口 Account 第一批 completed approval 门禁 +``` + +当前 coverage summary: + +```text +233 127 106 0 0 0 +``` + +当前 Market 事实: + +- Market 32 个 operation 全部为 `dedicated / needs_verification`。 +- `APPROVED_COMPLETED_DOMAINS` 当前只包含 `ai`、`knowledge`。 +- `APPROVED_COMPLETED_OPERATIONS` 当前只包含 `events:streamEvents`、Meta 16 个 operation、Account 第一批 10 个 operation。 +- `P1rMarketRealApiGateTest` 当前明确禁止 Market operation 标记为 `completed`。 +- `P1rApiCoverageReportTest` 当前断言 summary `completed=127`,并断言 Market 32 个 operation 继续 `needs_verification`。 +- `P1rEventsRealApiGateTest`、`P1rAiRealApiGateTest`、`P1rKnowledgeRealApiGateTest` 仍硬编码当前 summary 或 Market needs_verification 口径;实现前必须把这些 legacy mixed gate 同步列入单独批准项。 +- `favoriteAsset` / `unfavoriteAsset` 的 Java Controller 和 Service 强制校验 `X-Command-Id`,但 `docs/api-contracts/market/openapi.yaml` 当前未声明该 header,coverage report 因此显示 `requiresCommandId=false`。 +- `listMarketplaceRecommendations` 当前是 `fallback_popular` / `fallback_newest` 可解释排序,不是独立个性化推荐系统读模型。 + +## 执行边界 + +### 必须先获用户明确批准 + +实现前必须同时获得以下 4 项批准: + +1. 批准第一轮 Market completed operation 精确清单为本执行版列出的 4 个 operation。 +2. 批准只走 operation-level approval,不把 `market` 加入 domain-level completed allowlist。 +3. 批准只在 `docs/api-contracts/market/openapi.yaml` 的 `favoriteAsset` / `unfavoriteAsset` 增加 required `X-Command-Id` header 合同;不修改其它 OpenAPI。 +4. 批准按本执行版修改 scanner、coverage report、Market/Coverage gate、必要 legacy mixed gate、Market focused tests、新增 Market HTTP+DB `_test` IT、Flyway assertion、memory 和 `.agent`。 + +未获 4 项批准前,不得实施本执行版。 + +如果用户不批准第 3 项,必须把 `favoriteAsset` / `unfavoriteAsset` 移出第一批,并重写执行版为纯读候选。 + +`market:listMarketplaceAssets` 与 `market:listMarketplaceRecommendations` 本轮明确不进入 completed approval;前者需要先补 pageNo/pageSize 1..100 边界证据,后者需要先解决 OpenAPI 个性化推荐描述与当前 fallback 排序实现的合同不一致。 + +### 本轮允许变更 + +获批后只允许修改以下路径: + +- `docs/api-contracts/market/openapi.yaml`,仅限为 `favoriteAsset` / `unfavoriteAsset` 增加 required `X-Command-Id` header。 +- `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/P1rMarketRealApiGateTest.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/P1rMarketDiscoveryFavoriteCompletedApprovalIT.java`,新增 Market 详情/分类/收藏 HTTP + 真实 PostgreSQL `_test` 证据。 +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketFlywayMigrationIT.java`,仅限补充 V6/V8/V15 Market 表、索引、约束、trigger 和 favorite 非法 insert/update assertion。 +- `muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/controller/app/muse/AppMuseMarketplaceAssetControllerTest.java` +- `muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceTest.java` +- `muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketFavoriteServiceTest.java` +- `muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketAssetMapperTest.java` +- `docs/agent-specs/.agent` +- `docs/agent-specs/2026-06-11-P1RMarketCompletedApproval审阅版.md` +- `docs/agent-specs/2026-06-11-P1RMarketCompletedApproval执行版.md` +- `docs/memorys/2026-06-11-P1RMarket状态推进.md` + +如 implementation 发现必须修改 Market 业务实现、其它 OpenAPI、数据库迁移、Account/Content/Meta gate 或其它文件,必须停下说明原因并重新取得用户批准。 + +### 本轮禁止变更 + +- 不修改除 `docs/api-contracts/market/openapi.yaml` 之外的 OpenAPI。 +- 不修改 Market 业务实现来掩盖 coverage 缺口。 +- 不新增或修改 Market domain-level completed allowlist。 +- 不推进 Market purchase / install / handoff / governanceImpact。 +- 不推进 Market publish / review / governance 管理切片。 +- 不推进 Market appeals / restore / projection 切片。 +- 不推进 Account remaining 23、Content 或总 P1R completed。 + +## 第一轮审批清单 + +| operation key | Method | Path | 目标状态 | 完成证据边界 | +|---|---|---|---|---| +| `market:getMarketplaceAsset` | `GET` | `/app-api/muse/marketplace/assets/{assetId}` | `dedicated / completed` | App 资产详情、可见性、missing/invisible asset 拒绝、license/category/governance status DTO | +| `market:listMarketplaceCategories` | `GET` | `/app-api/muse/marketplace/categories` | `dedicated / completed` | 分类聚合、fallback recommendation slot、exposure summary unavailable 明示 | +| `market:favoriteAsset` | `POST` | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `dedicated / completed` | required `X-Command-Id`、登录 owner、可见资产、command replay、favorite active、唯一约束和审计事实 | +| `market:unfavoriteAsset` | `DELETE` | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `dedicated / completed` | required `X-Command-Id`、登录 owner、可见资产、command replay、favorite inactive、不删除审计事实 | + +## 保持 needs_verification 的 Market operation + +以下 28 个 operation 必须保持 `dedicated / needs_verification`: + +```text +market:listMarketplaceAssets +market:listMarketplaceRecommendations +market:adminListAppeals +market:adminGetAppeal +market:adminResolveAppeal +market:adminListMarketAssets +market:adminGetMarketAsset +market:adminDelistAsset +market:adminPreviewGovernanceImpact +market:adminRecallAsset +market:adminListPublishRequests +market:adminApprovePublishRequest +market:adminRejectPublishRequest +market:submitAppeal +market:supplementAppeal +market:withdrawAppeal +market:createBindPrecheck +market:getGovernanceImpact +market:installMarketplaceAsset +market:purchaseAsset +market:createMarketplaceHandoff +market:getHandoffStatus +market:cancelHandoff +market:listMyPublishRecords +market:savePublishDraft +market:runPublishCheck +market:submitPublishRequest +market:withdrawPublishRequest +``` + +保留原因: + +- `listMarketplaceAssets` 当前缺少 pageNo/pageSize 1..100 边界证据;实现层会透传 pageSize,底层 `PAGE_SIZE_NONE=-1` 可能变成全量查询,不能在未获业务实现批准前标为 completed。 +- `listMarketplaceRecommendations` 当前 OpenAPI 描述是个性化推荐,实际实现是 `fallback_popular` / `fallback_newest` 排序;合同与实现语义不闭合,不能在本轮作为 completed。 +- purchase / install / handoff / governanceImpact 缺目标 owner 消费闭环、Account 读侧 E2E 和跨 owner governance 影响闭环。 +- publish / review / governance 证据较强,但仍需单独执行版冻结 13 个 operation 的审批范围、Events outbox 边界和 fresh completed 级验证。 +- appeals / restore / projection 写入的是申诉审计、治理 action 或 Account projection 子集,不能混成统一 completed 闭环。 + +## 证据矩阵 + +| 证据类型 | 必跑或必查内容 | 目的 | +|---|---|---| +| Market focused tests | `AppMuseMarketplaceAssetControllerTest`、`MarketAssetQueryServiceTest`、`MarketFavoriteServiceTest`、`MuseMarketAssetMapperTest` | 证明 4 个 operation 的 HTTP contract、版本头、commandId、owner、可见性、幂等和 favorite active/inactive | +| Market HTTP+DB `_test` | 新增 `P1rMarketDiscoveryFavoriteCompletedApprovalIT` | 证明 4 个 operation 经 HTTP 入口访问真实 PostgreSQL `_test` 数据,覆盖 detail/category/favorite/unfavorite 的持久化和错误路径 | +| P1R Market gates | `P1rMarketRealApiGateTest`、`P1rMarketRouteOwnershipTest`、`P1rMarketMigrationSqlTest` | 证明 only 4 Market operation completed,28 个继续 needs_verification,路由仍 dedicated | +| P1R mixed gates | Coverage、Market、Events、AI、Knowledge、Account、Content、Meta gates | 防止非目标 domain 被连带推进,并同步 legacy summary / Market 状态断言 | +| Flyway `_test` | `P1rMarketFlywayMigrationIT` fresh rerun | 证明 V1-V15 clean migrate、Market V6/V8/V15 表/索引/约束/trigger、favorite unique/command 约束和非法 insert 拒绝 | +| Coverage scanner | `python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` | 证明 scanner/report 与 operation-level allowlist、OpenAPI commandId 口径一致 | +| XML 防空跑 | 逐个读取 surefire XML,检查 tests、failures、errors、skipped | 防止 Maven 未跑目标类或旧 XML 假绿 | +| OpenAPI allowed diff | 只允许 `market/openapi.yaml` 增加 favorite/unfavorite required `X-Command-Id` | 修正合同与实现不一致,不扩大其它合同 | +| Business protected diff | Market 业务实现 staged/unstaged diff 必须为空 | 证明没有通过改实现掩盖 coverage 缺口 | +| Allowed diff | staged、unstaged、untracked 均必须落在允许清单 | 证明没有顺手扩大 scope | + +说明:standalone MockMvc、mock Service、mapper focused tests 只能作为支持证据,不能单独支撑 `favoriteAsset` / `unfavoriteAsset` completed。实现阶段必须新增 Market HTTP + 真实 PostgreSQL `_test` harness;若现有 Spring 测试结构无法稳定做到 WebApplicationContext/MockMvc + real mapper + `_test` DB,必须停下修订执行版,不能用 mock 组合替代。 + +## 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 127 106 0 0 0 +``` + +- [ ] 确认 Market 32 个 operation 仍全部是 `dedicated / needs_verification`: + +```bash +jq -r '.operations[] | select(.domain=="market") | [.operationId,.implementationStatus,.completionStatus,.requiresCommandId] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +- [ ] 确认实施前只有文档 diff,没有 OpenAPI、scanner、coverage report、业务实现 diff: + +```bash +git -c core.quotePath=false status --short --branch +git diff -- docs/api-contracts muse-cloud/scripts/p1r-audit-api-coverage.py docs/superpowers/reports +git diff -- muse-cloud/muse-module-market/muse-module-market-server/src/main/java +``` + +### 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` +- `P1rMarketRealApiGateTest.java` +- `P1rEventsRealApiGateTest.java` +- `P1rAiRealApiGateTest.java` +- `P1rKnowledgeRealApiGateTest.java` + +RED 期望: + +- summary 期望 `completed=131`、`needsVerification=102`,但旧 report 仍是 `127/106`,必须失败。 +- Market 4 个 approved operation 期望 `completed`,但旧 report 仍是 `needs_verification`,必须失败。 +- Market 28 个未批准 operation 继续期望 `needs_verification`,其中必须包含 `listMarketplaceAssets` 与 `listMarketplaceRecommendations`。 +- `favoriteAsset` / `unfavoriteAsset` 期望 `requiresCommandId=true`,但旧 report 仍是 `false`,必须失败。 + +运行: + +```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.P1rMarketRealApiGateTest.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 +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rApiCoverageReportTest,P1rMarketRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +预期:至少一个失败点来自旧 report 的 `127/106`、Market 4 个 operation 尚未 completed,或 favorite/unfavorite `requiresCommandId=false`。若直接通过,必须停下排查假绿。 + +### Task 2:OpenAPI commandId 合同修正 + +只修改 `docs/api-contracts/market/openapi.yaml`: + +- 在 `favoriteAsset` parameters 中追加 required header `X-Command-Id`。 +- 在 `unfavoriteAsset` parameters 中追加 required header `X-Command-Id`。 +- 不修改其它 Market operation。 +- 不修改其它 OpenAPI 文件。 + +推荐写法: + +```yaml + - name: X-Command-Id + in: header + required: true + schema: + type: string + description: 幂等命令 ID,收藏和取消收藏必须传入。 +``` + +修正后用 scanner 验证 report 中: + +```text +market:favoriteAsset requiresCommandId=true +market:unfavoriteAsset requiresCommandId=true +``` + +### Task 3:TDD GREEN,最小 scanner/report 状态推进 + +只修改 `muse-cloud/scripts/p1r-audit-api-coverage.py`: + +- 不修改 `APPROVED_COMPLETED_DOMAINS`。 +- 只向 `APPROVED_COMPLETED_OPERATIONS` 追加 4 个 `market:*` operation key:`market:getMarketplaceAsset`、`market:listMarketplaceCategories`、`market:favoriteAsset`、`market:unfavoriteAsset`。 +- 保持 completed 必须仍是 `dedicated` 的校验。 + +生成 report: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +预期 summary: + +```text +total=233 +completed=131 +needsVerification=102 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +预期 Market: + +```text +completed=4 +needsVerification=28 +favoriteAsset requiresCommandId=true +unfavoriteAsset requiresCommandId=true +``` + +### Task 4:Market focused verification + +运行 Market 本域 focused tests: + +```bash +cd muse-cloud +export RUN_START_EPOCH=$(date +%s) +rm -f \ + muse-module-market/muse-module-market-server/target/surefire-reports/TEST-cn.iocoder.muse.module.market.controller.app.muse.AppMuseMarketplaceAssetControllerTest.xml \ + muse-module-market/muse-module-market-server/target/surefire-reports/TEST-cn.iocoder.muse.module.market.application.muse.MarketAssetQueryServiceTest.xml \ + muse-module-market/muse-module-market-server/target/surefire-reports/TEST-cn.iocoder.muse.module.market.application.muse.MarketFavoriteServiceTest.xml \ + muse-module-market/muse-module-market-server/target/surefire-reports/TEST-cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapperTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketMigrationSqlTest.xml \ + muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketRouteOwnershipTest.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.P1rApiCoverageReportTest.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-module-market/muse-module-market-server,muse-server -am \ + -Dtest=AppMuseMarketplaceAssetControllerTest,MarketAssetQueryServiceTest,MarketFavoriteServiceTest,MuseMarketAssetMapperTest,P1rMarketMigrationSqlTest,P1rMarketRouteOwnershipTest,P1rMarketRealApiGateTest,P1rApiCoverageReportTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +必须读取以下 XML 并检查 `failures=0`、`errors=0`、`skipped=0`: + +| Test class | 最低 tests | +|---|---:| +| `AppMuseMarketplaceAssetControllerTest` | 11 | +| `MarketAssetQueryServiceTest` | 6 | +| `MarketFavoriteServiceTest` | 7 | +| `MuseMarketAssetMapperTest` | 2 | +| `P1rMarketMigrationSqlTest` | 9 | +| `P1rMarketRouteOwnershipTest` | 3 | +| `P1rMarketRealApiGateTest` | 6 | +| `P1rApiCoverageReportTest` | 6 | + +必须补齐或确认以下断言: + +- MockMvc `getMarketplaceAsset` 覆盖 visible asset、missing asset、invisible asset。 +- MockMvc `listMarketplaceCategories` 覆盖分类聚合、fallback slot、exposure summary unavailable。 +- MockMvc 缺少 `X-Command-Id` 的 `favoriteAsset` 返回 `MARKET_COMMAND_ID_REQUIRED`。 +- MockMvc 缺少 `X-Command-Id` 的 `unfavoriteAsset` 返回 `MARKET_COMMAND_ID_REQUIRED`。 +- Service 空白 commandId 的 `favoriteAsset` 返回 `MARKET_COMMAND_ID_REQUIRED`。 +- Service 空白 commandId 的 `unfavoriteAsset` 返回 `MARKET_COMMAND_ID_REQUIRED`。 +- `unfavoriteAsset` 只能把 favorite fact 置为 inactive,不允许 delete。 +- `listMarketplaceAssets` 与 `listMarketplaceRecommendations` 可保留现有回归测试,但不得作为本轮 completed evidence;P1R gate 必须继续断言它们是 `needs_verification`。 + +如测试数量因补断言增加,XML 检查的最低 tests 可高于上表,但不能低于上表。 + +### Task 5:Market HTTP + 真实 PostgreSQL `_test` completed gate + +新增 `P1rMarketDiscoveryFavoriteCompletedApprovalIT`,使用独立 `_test` 数据库和真实 mapper/service 数据,不能使用生产库或非 `_test` 库: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_MARKET_COMPLETED_APPROVAL_TEST_DB=muse_p1r_market_discovery_favorite_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_MARKET_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_MARKET_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.P1rMarketDiscoveryFavoriteCompletedApprovalIT.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rMarketDiscoveryFavoriteCompletedApprovalIT \ + -Dflyway.postgresql.transactional.lock=false \ + -Dp1r.flyway.locations=filesystem:sql/muse \ + -Dp1r.flyway.url="jdbc:postgresql://$MUSE_POSTGRES_HOST:$MUSE_POSTGRES_PORT/$P1R_MARKET_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 必须至少覆盖以下 8 个测试,XML 最低 tests 为 8: + +| Test class | 最低 tests | +|---|---:| +| `P1rMarketDiscoveryFavoriteCompletedApprovalIT` | 8 | + +必须证明: + +- `GET /app-api/muse/marketplace/assets/{assetId}` 对 `_test` 库中的 visible listed asset 返回真实详情、版本、license、category、governance status 和 favorite 状态。 +- `GET /app-api/muse/marketplace/assets/{assetId}` 对 missing 或 invisible asset 返回错误,不泄露非公开资产。 +- `GET /app-api/muse/marketplace/categories` 从真实资产/分类数据生成分类聚合,并明确 exposure summary unavailable / fallback slot。 +- `POST /favorite` 缺少 `X-Command-Id` 返回 `MARKET_COMMAND_ID_REQUIRED`;带 commandId 时写入 active favorite fact 和 command fact。 +- 同一 commandId 重放 `POST /favorite` 不产生重复 favorite fact,返回 replay 语义。 +- `DELETE /favorite` 带 commandId 后把 favorite fact 置为 inactive,不物理删除审计事实;同一 commandId 重放不重复写入。 +- `DELETE /favorite` 缺少 `X-Command-Id` 返回 `MARKET_COMMAND_ID_REQUIRED`,且不写 command fact、不改变 favorite fact。 +- `POST /favorite` 和 `DELETE /favorite` 对 missing 或 invisible asset 返回错误,并断言不写 command fact、不写或不改变 favorite fact。 + +该 IT 必须在测试启动时校验库名以 `_test` 结尾;密码只能从 `P1R_MARKET_COMPLETED_PASSWORD`、`P1R_FLYWAY_PASSWORD` 或 `MUSE_POSTGRES_PASSWORD` 环境变量读取,不能通过 JVM system property 传入;测试必须断言拒绝 `p1r.market.completed.password` 或任何 password system property;日志和断言不得输出明文数据库密码。 + +### Task 6:真实 PostgreSQL `_test` Flyway gate + +fresh rerun `P1rMarketFlywayMigrationIT`。使用独立 `_test` 库,不能使用生产库或非 `_test` 库: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_MARKET_TEST_DB=muse_p1r_market_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_MARKET_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_MARKET_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.P1rMarketFlywayMigrationIT.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rMarketFlywayMigrationIT \ + -Dflyway.postgresql.transactional.lock=false \ + -Dp1r.flyway.locations=filesystem:sql/muse \ + -Dp1r.flyway.url="jdbc:postgresql://$MUSE_POSTGRES_HOST:$MUSE_POSTGRES_PORT/$P1R_MARKET_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 | +|---|---:| +| `P1rMarketFlywayMigrationIT` | 4 | + +必须在该 IT 内新增或补齐以下 assertion,但不得新增迁移或修改业务实现: + +- `muse_market_asset`、`muse_market_asset_version`、`muse_market_favorite` 存在。 +- `uk_muse_market_favorite_user_asset`、`uk_muse_market_favorite_command`、`idx_muse_market_favorite_user` 存在。 +- `trg_muse_market_favorite_updated_at` 存在。 +- 同一 `(tenant_id, user_id, asset_id)` 不能重复 active favorite fact。 +- 同一 `(tenant_id, command_id)` 不能重复 command fact。 +- favorite status 更新为 inactive 后 `updated_at` trigger 可执行。 + +### 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.P1rMarketRealApiGateTest.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.P1rAccountRealApiGateTest.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.P1rMetaRealApiGateTest.xml +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rApiCoverageReportTest,P1rMarketRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest,P1rAccountRealApiGateTest,P1rContentRealApiGateTest,P1rMetaRealApiGateTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +必须读取以下 XML: + +| Test class | 最低 tests | +|---|---:| +| `P1rApiCoverageReportTest` | 6 | +| `P1rMarketRealApiGateTest` | 6 | +| `P1rEventsRealApiGateTest` | 6 | +| `P1rAiRealApiGateTest` | 7 | +| `P1rKnowledgeRealApiGateTest` | 8 | +| `P1rAccountRealApiGateTest` | 4 | +| `P1rContentRealApiGateTest` | 5 | +| `P1rMetaRealApiGateTest` | 4 | + +每个 XML 均必须 `failures=0`、`errors=0`、`skipped=0`。 + +这些 gate 必须共同证明: + +- summary 只从 `127/106` 推进到 `131/102`。 +- Market 只从 `0/32` 推进到 `4/28`。 +- Account 仍保持 `10 completed / 23 needs_verification`。 +- Content 仍保持 `0 completed / 51 needs_verification`。 +- Meta、AI、Knowledge、Events 状态不变。 + +### 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 131 102 0 0 0 +``` + +检查 Market 4/28: + +```bash +python3 - <<'PY' +import json +from collections import Counter +from pathlib import Path +data = json.loads(Path("docs/superpowers/reports/p1r-api-coverage.json").read_text()) +counter = Counter(op["completionStatus"] for op in data["operations"] if op["domain"] == "market") +print(counter) +for op in data["operations"]: + if op["domain"] == "market" and op["operationId"] in {"favoriteAsset", "unfavoriteAsset"}: + print(op["operationId"], op["requiresCommandId"], op["completionStatus"]) +PY +``` + +预期: + +```text +Counter({'needs_verification': 28, 'completed': 4}) +favoriteAsset True completed +unfavoriteAsset True completed +``` + +检查 OpenAPI diff 只限 Market: + +```bash +git diff --quiet -- docs/api-contracts/account/openapi.yaml docs/api-contracts/ai/openapi.yaml docs/api-contracts/content/openapi.yaml docs/api-contracts/events/openapi.yaml docs/api-contracts/knowledge/openapi.yaml docs/api-contracts/meta/openapi.yaml +git diff --cached --quiet -- docs/api-contracts/account/openapi.yaml docs/api-contracts/ai/openapi.yaml docs/api-contracts/content/openapi.yaml docs/api-contracts/events/openapi.yaml docs/api-contracts/knowledge/openapi.yaml docs/api-contracts/meta/openapi.yaml +git diff -- docs/api-contracts/market/openapi.yaml +``` + +检查 Market 业务实现不被修改: + +```bash +git diff --quiet -- muse-cloud/muse-module-market/muse-module-market-server/src/main/java +git diff --cached --quiet -- muse-cloud/muse-module-market/muse-module-market-server/src/main/java +``` + +检查 whitespace: + +```bash +git diff --check +``` + +检查 allowed diff: + +```bash +git -c core.quotePath=false status --short +``` + +输出只能包含本执行版允许路径。若出现非批准 OpenAPI、业务实现、迁移或非批准 gate 文件,必须停止。 + +### Task 9:memory 与 `.agent` + +实现通过后新增: + +- `docs/memorys/2026-06-11-P1RMarket状态推进.md` + +内容必须记录: + +- 批准的 4 个 Market operation。 +- 保持 `needs_verification` 的 28 个 Market operation 及原因,必须点名 `listMarketplaceAssets` 与 `listMarketplaceRecommendations`。 +- OpenAPI commandId 合同修正范围。 +- `listMarketplaceRecommendations` 未进入本轮 completed,原因是个性化推荐 OpenAPI 描述与 fallback 排序实现未闭合。 +- `listMarketplaceAssets` 未进入本轮 completed,原因是 pageNo/pageSize 边界证据未闭合。 +- scanner/report/gate 修改范围。 +- 所有验证命令、结果、XML 计数、Market HTTP+DB `_test` 库名、Flyway `_test` 库名。 +- protected diff 与 allowed diff 结果。 +- 明确说明不代表 Market 32/32 completed,不代表 Account remaining 23 / Content / 总 P1R completed。 + +同时更新 `docs/agent-specs/.agent`,但只能记录事实,不写未验证结论。 + +### Task 10:fresh implementation review + +实现、验证和文档留痕后,必须派发两类 fresh reviewer: + +- spec/correctness reviewer:检查 4 个 operation 是否严格匹配本执行版,28 个 operation 是否仍 needs_verification,Market domain allowlist 是否未新增,OpenAPI commandId 修正是否只限 favorite/unfavorite。 +- quality/data-integrity/testing reviewer:检查 scanner/report/gate、legacy mixed gate 同步、HTTP+DB `_test`、XML 防空跑、Flyway `_test`、allowed/protected diff、rollback 是否可靠。 + +双 PASS 前不得提交、push 或宣称 Market completed approval 已收口。 + +## 回滚策略 + +如实现后需要撤回: + +1. 从 `APPROVED_COMPLETED_OPERATIONS` 移除 4 个 `market:*` key。 +2. 处理 `docs/api-contracts/market/openapi.yaml` 中 favorite/unfavorite 的 required `X-Command-Id` header: + - 如果用户要求完整撤回本轮合同修正,则移除 header。 + - 如果用户单独决定保留实现与 OpenAPI 一致的 commandId 合同,则保留 header,只撤回 completed approval。 +3. 重新运行: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +4. 恢复 gate 期望。完整撤回 OpenAPI header 时: + +```text +summary completed=127 +summary needsVerification=106 +market completed=0 +market needsVerification=32 +favoriteAsset requiresCommandId=false +unfavoriteAsset requiresCommandId=false +``` + +如果保留 OpenAPI commandId 合同,则 gate 必须恢复为: + +```text +summary completed=127 +summary needsVerification=106 +market completed=0 +market needsVerification=32 +favoriteAsset requiresCommandId=true +unfavoriteAsset requiresCommandId=true +``` + +5. 修订本轮 memory 并保留失败原因;除非用户明确要求,不直接删除失败留痕。 +6. 重新运行 Market/P1R focused gates 和 `git diff --check`。 + +## 验收标准 + +执行版本身可以进入实现前批准点的条件: + +1. 本文件已写入 `docs/agent-specs/2026-06-11-P1RMarketCompletedApproval执行版.md`。 +2. `.agent` 记录执行版当前状态。 +3. `git diff --check` 通过。 +4. OpenAPI、scanner、coverage report、业务实现当前无新增 diff;`market/openapi.yaml` 也必须在用户批准前保持无 diff。 +5. fresh execution spec/scope review PASS。 +6. fresh execution quality/feasibility review PASS。 + +实现完成条件必须等用户批准后另行满足,不由本执行版写入自动成立。 diff --git a/docs/api-contracts/market/openapi.yaml b/docs/api-contracts/market/openapi.yaml index 37ef0818..70b0acae 100644 --- a/docs/api-contracts/market/openapi.yaml +++ b/docs/api-contracts/market/openapi.yaml @@ -226,6 +226,12 @@ paths: parameters: - $ref: '../openapi-base.yaml#/components/parameters/XApiVersion' - $ref: '#/components/parameters/assetId' + - name: X-Command-Id + in: header + required: true + schema: + type: string + description: 幂等命令 ID,收藏资产必须传入。 responses: '200': description: 收藏成功 @@ -245,6 +251,12 @@ paths: parameters: - $ref: '../openapi-base.yaml#/components/parameters/XApiVersion' - $ref: '#/components/parameters/assetId' + - name: X-Command-Id + in: header + required: true + schema: + type: string + description: 幂等命令 ID,取消收藏必须传入。 responses: '200': description: 取消收藏成功 diff --git a/docs/memorys/2026-06-11-P1RMarket状态推进.md b/docs/memorys/2026-06-11-P1RMarket状态推进.md new file mode 100644 index 00000000..e18b613e --- /dev/null +++ b/docs/memorys/2026-06-11-P1RMarket状态推进.md @@ -0,0 +1,262 @@ +# P1R Market 状态推进 + +日期:2026-06-11 + +## 结论 + +本轮只推进 Market 第一批 4 个 operation-level completed approval: + +- `market:getMarketplaceAsset` +- `market:listMarketplaceCategories` +- `market:favoriteAsset` +- `market:unfavoriteAsset` + +Market 仍不是整域 completed。`listMarketplaceAssets`、`listMarketplaceRecommendations` 以及 purchase/install/handoff/governance/publish/review/appeal/projection 相关 28 个 Market operation 继续 `dedicated/needs_verification`。 + +本轮未修改 Market 业务实现,未修改数据库迁移,未把 `market` 加入 domain-level completed allowlist。 + +## 工作区与基线 + +工作区: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +基线 HEAD: + +```text +f18116a test(p1r): 收口 Account 第一批 completed approval 门禁 +``` + +实施前 coverage summary: + +```text +233 127 106 0 0 0 +``` + +实施后 coverage summary: + +```text +233 131 102 0 0 0 +``` + +实施后 Market 状态: + +```text +completed=4 +needs_verification=28 +``` + +## 允许变更与实际变更 + +OpenAPI 只修改: + +- `docs/api-contracts/market/openapi.yaml` + +变更内容只是在: + +- `POST /app-api/muse/marketplace/assets/{assetId}/favorite` +- `DELETE /app-api/muse/marketplace/assets/{assetId}/favorite` + +增加 required `X-Command-Id` header。原因是 Java Controller / Service 已强制校验 command id,而原 OpenAPI 没有声明,coverage report 因此误显示 `requiresCommandId=false`。 + +scanner 只修改: + +- `muse-cloud/scripts/p1r-audit-api-coverage.py` + +实际只追加 4 个 operation-level completed key,没有把 `market` 加入 `APPROVED_COMPLETED_DOMAINS`。 + +coverage report 已重新生成: + +- `docs/superpowers/reports/p1r-api-coverage.json` +- `docs/superpowers/reports/p1r-api-coverage.md` + +## TDD 证据 + +RED 阶段先修改 gate 期望值和 Market operation 断言,在旧 scanner/report 下运行 focused P1R gates,预期失败已出现: + +- 失败点包括 expected completed=131 but was 127。 +- 失败点包括 Market expected 4 completed but was 0。 +- 该失败证明 gate 会阻止未获 scanner/report 支撑的 completed 推进。 + +GREEN 阶段完成 OpenAPI commandId 合同修正、scanner operation-level allowlist、report 重新生成和 focused evidence tests 后,目标验证通过。 + +## MockMvc HTTP 入口 + DB 证据 + +新增: + +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketDiscoveryFavoriteCompletedApprovalIT.java` + +验证方式: + +- 使用 Spring Mock web context + `MockMvc`。 +- 使用真实 PostgreSQL `_test` 数据库。 +- 使用 Flyway clean/migrate V1-V15。 +- 密码只从环境变量读取,禁止通过 JVM system property 传入。 + +说明:这里的 HTTP 入口证据是执行版要求的 WebApplicationContext + MockMvc 请求路径证据,不是启动真实 socket 端口的 live servlet container 证据。 + +验证数据库: + +```text +muse_p1r_market_discovery_favorite_completed_approval_test +``` + +通过结果: + +```text +P1rMarketDiscoveryFavoriteCompletedApprovalIT: tests=9, failures=0, errors=0, skipped=0 +``` + +覆盖内容: + +- 资产详情 happy path。 +- missing asset / invisible asset 不泄露详情、不写 command。 +- 分类聚合包含 fallback slot 与 unavailable exposure summary。 +- favorite 缺少 `X-Command-Id` 不写 DB。 +- favorite 写 active favorite 与 command,重复 command 回放不重复写。 +- unfavorite 写 inactive,不删除审计事实,重复 command 回放不重复写。 +- missing/invisible target 对 favorite/unfavorite 不污染 command/favorite fact。 + +## Flyway 证据 + +增强: + +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketFlywayMigrationIT.java` + +验证数据库: + +```text +muse_p1r_market_completed_approval_test +``` + +通过结果: + +```text +P1rMarketFlywayMigrationIT: tests=4, failures=0, errors=0, skipped=0 +``` + +验证内容: + +- V1-V15 clean migrate。 +- `muse_market_favorite` 表存在。 +- favorite user/asset/command 相关索引存在。 +- `uk_muse_market_favorite_user_asset` 唯一约束存在。 +- `trg_muse_market_favorite_updated_at` trigger 存在。 +- 同 tenant/user/asset duplicate insert 被 PostgreSQL 唯一约束拒绝,SQLState 为 `23505`。 +- 同 tenant/command_id duplicate insert 被 PostgreSQL partial unique index 拒绝,SQLState 为 `23505`。 +- favorite status 更新为 `inactive` 时,`trg_muse_market_favorite_updated_at` 会覆盖旧 `update_time`。 + +额外组合验证: + +```text +P1rMarketDiscoveryFavoriteCompletedApprovalIT -> P1rMarketFlywayMigrationIT: tests=13, failures=0, errors=0, skipped=0 +P1rMarketFlywayMigrationIT -> P1rMarketDiscoveryFavoriteCompletedApprovalIT: tests=13, failures=0, errors=0, skipped=0 +``` + +该组合验证使用真实 PostgreSQL `_test` 库: + +```text +muse_p1r_market_combined_completed_approval_test +``` + +组合验证用于证明新增 Discovery IT 与增强 Flyway IT 对 `p1r.flyway.url/user` 的脱敏不会污染同一 Surefire JVM 内其他测试类。曾复现一次失败:Discovery IT 先运行后,Flyway IT 读到脱敏 system property,报缺少原始 `p1r.flyway.url`。修复方式是在 Discovery IT 与 Flyway IT 中都保存原始 `p1r.flyway.url/user`,并在 `@AfterAll` 恢复;随后补充 `-Dsurefire.runOrder=reversealphabetical`,日志确认先跑 Flyway IT、再跑 Discovery IT,合计 13/13 pass。 + +注意:Surefire XML 的 `` 会记录原始 `p1r.flyway.url` host 与 `p1r.flyway.user`,未发现 password 泄露。raw XML 只能作为本地验证产物,不应直接外发;如需外发测试报告,应先清洗 host/user 或改造为不通过 JVM system property 传递 URL/user。 + +review 处理: + +- correctness reviewer 指出的 system property 污染为有效问题,已修复并用双向 combined IT 13/13 pass 关闭。 +- data/testing reviewer 提到“真正 HTTP”若理解为 socket 级 live server则当前证据不足。核对执行版后,本轮要求是 WebApplicationContext/MockMvc + real mapper + `_test` DB,不是 live socket server;本文档已把措辞收窄为 MockMvc HTTP 入口,避免过度声明。 +- data/testing reviewer 提到 favorite `status` 没有 DB-level CHECK、`asset_id/user_id` 没有 FK。该问题属于后续 schema hardening;本轮执行版明确禁止改迁移,只能在现有 V8/V15 约束上补强 unique/index/trigger 断言,不把它扩成新增迁移。 +- 最终 fresh implementation review 已双 PASS:Raman spec/correctness review PASS、Hubble data-integrity/testing review PASS,均无 P0/P1/P2/P3 阻塞项。两名 reviewer 均确认 `market` 未进入 domain-level allowlist、只推进 4 个 `market:*` operation、Market 仍为 4 completed / 28 needs_verification、`listMarketplaceAssets` 与 `listMarketplaceRecommendations` 继续 needs_verification、OpenAPI diff 仅限 favorite/unfavorite required `X-Command-Id`、Market main/java 与 SQL diff 为空、13 个目标 XML 合计 85 tests 且 failures/errors/skipped 均为 0。非阻塞注意事项仍是 raw Surefire XML 暴露 host/user 但不含 password,外发前需清洗。 + +## focused gates + +Market focused verification 通过: + +```text +AppMuseMarketplaceAssetControllerTest: tests=11, failures=0, errors=0, skipped=0 +MarketAssetQueryServiceTest: tests=6, failures=0, errors=0, skipped=0 +MarketFavoriteServiceTest: tests=7, failures=0, errors=0, skipped=0 +MuseMarketAssetMapperTest: tests=2, failures=0, errors=0, skipped=0 +P1rMarketMigrationSqlTest: tests=9, failures=0, errors=0, skipped=0 +P1rMarketRouteOwnershipTest: tests=4, failures=0, errors=0, skipped=0 +P1rMarketRealApiGateTest: tests=6, failures=0, errors=0, skipped=0 +P1rApiCoverageReportTest: tests=6, failures=0, errors=0, skipped=0 +``` + +跨域 P1R focused gates 通过: + +```text +P1rApiCoverageReportTest: tests=6, failures=0, errors=0, skipped=0 +P1rMarketRealApiGateTest: tests=6, failures=0, errors=0, skipped=0 +P1rEventsRealApiGateTest: tests=6, failures=0, errors=0, skipped=0 +P1rAiRealApiGateTest: tests=7, failures=0, errors=0, skipped=0 +P1rKnowledgeRealApiGateTest: tests=8, failures=0, errors=0, skipped=0 +``` + +合计: + +```text +P1R mixed gates: 33/33 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 131 102 0 0 0 +``` + +Market operation 状态确认: + +```text +getMarketplaceAsset completed false +listMarketplaceCategories completed false +favoriteAsset completed true +unfavoriteAsset completed true +listMarketplaceAssets needs_verification false +listMarketplaceRecommendations needs_verification false +``` + +diff check: + +```text +git diff --check: pass +``` + +OpenAPI diff: + +```text +docs/api-contracts/market/openapi.yaml +``` + +仅包含 favorite/unfavorite 两个 operation 的 required `X-Command-Id` header。 + +## 后续边界 + +本轮完成后仍不得宣称: + +- Market 32/32 completed。 +- Content completed。 +- Account remaining 23 completed。 +- 总 P1R completed。 + +后续如果继续 Market completed approval,推荐单独处理: + +1. `listMarketplaceAssets`:先补 pageNo/pageSize 1..100 边界与防全量查询证据。 +2. `listMarketplaceRecommendations`:先解决 OpenAPI 个性化推荐语义与 fallback 排序实现的合同不一致。 +3. publish/review/governance 13 个 operation:另写执行版冻结范围、Events outbox 边界和 fresh runtime evidence。 +4. purchase/install/handoff/governanceImpact:必须先补目标 owner 消费闭环与 Account 读侧 E2E。 +5. appeal/projection:不能把申诉审计、治理 outbox 和 Account projection 混成一个 completed 闭环。 diff --git a/docs/superpowers/reports/p1r-api-coverage.json b/docs/superpowers/reports/p1r-api-coverage.json index 4c6ac0d0..362aa059 100644 --- a/docs/superpowers/reports/p1r-api-coverage.json +++ b/docs/superpowers/reports/p1r-api-coverage.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-25T14:22:23+00:00", + "generatedAt": "2026-05-25T07:06:40+00:00", "sourceContracts": [ "docs/api-contracts/account/openapi.yaml", "docs/api-contracts/ai/openapi.yaml", @@ -11,9 +11,9 @@ ], "summary": { "totalOperations": 233, - "completedOperations": 127, + "completedOperations": 131, "incompleteOperations": 0, - "needsVerificationOperations": 106, + "needsVerificationOperations": 102, "catchAllOperations": 0, "genericPersistenceOperations": 0, "ssePlaceholderOperations": 0, @@ -13968,7 +13968,7 @@ ], "targetStage": "P1R-6 Market Real API", "implementationStatus": "dedicated", - "completionStatus": "needs_verification", + "completionStatus": "completed", "controllerFiles": [ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/controller/app/muse/AppMuseMarketplaceAssetController.java" ], @@ -14006,7 +14006,7 @@ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/MarketTargetOwnerFacade.java", "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/UnavailableMarketTargetOwnerFacade.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": "market", @@ -14069,14 +14069,14 @@ "path": "/app-api/muse/marketplace/assets/{assetId}/favorite", "operationId": "unfavoriteAsset", "isWrite": true, - "requiresCommandId": false, + "requiresCommandId": true, "externalDependencies": [ "Account", "MarketAuthorization" ], "targetStage": "P1R-6 Market Real API", "implementationStatus": "dedicated", - "completionStatus": "needs_verification", + "completionStatus": "completed", "controllerFiles": [ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/controller/app/muse/AppMuseMarketplaceAssetController.java" ], @@ -14114,7 +14114,7 @@ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/MarketTargetOwnerFacade.java", "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/UnavailableMarketTargetOwnerFacade.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": "market", @@ -14123,14 +14123,14 @@ "path": "/app-api/muse/marketplace/assets/{assetId}/favorite", "operationId": "favoriteAsset", "isWrite": true, - "requiresCommandId": false, + "requiresCommandId": true, "externalDependencies": [ "Account", "MarketAuthorization" ], "targetStage": "P1R-6 Market Real API", "implementationStatus": "dedicated", - "completionStatus": "needs_verification", + "completionStatus": "completed", "controllerFiles": [ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/controller/app/muse/AppMuseMarketplaceAssetController.java" ], @@ -14168,7 +14168,7 @@ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/MarketTargetOwnerFacade.java", "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/UnavailableMarketTargetOwnerFacade.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": "market", @@ -14346,7 +14346,7 @@ ], "targetStage": "P1R-6 Market Real API", "implementationStatus": "dedicated", - "completionStatus": "needs_verification", + "completionStatus": "completed", "controllerFiles": [ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/controller/app/muse/AppMuseMarketplaceAssetController.java" ], @@ -14384,7 +14384,7 @@ "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/MarketTargetOwnerFacade.java", "muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/facade/UnavailableMarketTargetOwnerFacade.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": "market", diff --git a/docs/superpowers/reports/p1r-api-coverage.md b/docs/superpowers/reports/p1r-api-coverage.md index 5e478349..0cb74f95 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-25T14:22:23+00:00` +- Generated at: `2026-05-25T07:06:40+00:00` - Total operations: `233` -- Completed: `127` -- Needs verification: `106` +- Completed: `131` +- Needs verification: `102` - Incomplete: `0` - Catch-all: `0` - Generic persistence: `0` @@ -214,14 +214,14 @@ | market | app | POST | `/app-api/muse/marketplace/appeals/{appealId}/supplements` | `supplementAppeal` | dedicated | needs_verification | P1R-6 Market Real API | | market | app | POST | `/app-api/muse/marketplace/appeals/{appealId}/withdraw` | `withdrawAppeal` | dedicated | needs_verification | P1R-6 Market Real API | | market | app | GET | `/app-api/muse/marketplace/assets` | `listMarketplaceAssets` | dedicated | needs_verification | P1R-6 Market Real API | -| market | app | GET | `/app-api/muse/marketplace/assets/{assetId}` | `getMarketplaceAsset` | dedicated | needs_verification | P1R-6 Market Real API | +| market | app | GET | `/app-api/muse/marketplace/assets/{assetId}` | `getMarketplaceAsset` | dedicated | completed | P1R-6 Market Real API | | market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/bind-precheck` | `createBindPrecheck` | dedicated | needs_verification | P1R-6 Market Real API | -| market | app | DELETE | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `unfavoriteAsset` | dedicated | needs_verification | P1R-6 Market Real API | -| market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `favoriteAsset` | dedicated | needs_verification | P1R-6 Market Real API | +| market | app | DELETE | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `unfavoriteAsset` | dedicated | completed | P1R-6 Market Real API | +| market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `favoriteAsset` | dedicated | completed | P1R-6 Market Real API | | market | app | GET | `/app-api/muse/marketplace/assets/{assetId}/governance-impact` | `getGovernanceImpact` | dedicated | needs_verification | P1R-6 Market Real API | | market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/install` | `installMarketplaceAsset` | dedicated | needs_verification | P1R-6 Market Real API | | market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/purchase` | `purchaseAsset` | dedicated | needs_verification | P1R-6 Market Real API | -| market | app | GET | `/app-api/muse/marketplace/categories` | `listMarketplaceCategories` | dedicated | needs_verification | P1R-6 Market Real API | +| market | app | GET | `/app-api/muse/marketplace/categories` | `listMarketplaceCategories` | dedicated | completed | P1R-6 Market Real API | | market | app | POST | `/app-api/muse/marketplace/handoffs` | `createMarketplaceHandoff` | dedicated | needs_verification | P1R-6 Market Real API | | market | app | GET | `/app-api/muse/marketplace/handoffs/{handoffToken}` | `getHandoffStatus` | dedicated | needs_verification | P1R-6 Market Real API | | market | app | POST | `/app-api/muse/marketplace/handoffs/{handoffToken}/cancel` | `cancelHandoff` | dedicated | needs_verification | P1R-6 Market Real API | 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 a019345d..ed25fbc2 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 @@ -78,6 +78,13 @@ class P1rAiRealApiGateTest { entry("adminGetBusinessAuditEvent", "GET /admin-api/muse/audit/business-events/{eventId}") ); + private static final Set APPROVED_MARKET_COMPLETED_OPERATIONS = Set.of( + "getMarketplaceAsset", + "listMarketplaceCategories", + "favoriteAsset", + "unfavoriteAsset" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -120,8 +127,8 @@ class P1rAiRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(127, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10"); + assertEquals(131, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4"); } @Test @@ -150,6 +157,7 @@ class P1rAiRealApiGateTest { assertDomainStatusCount("content", 51); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); + assertMarketStatusCount(4, 28); } private void assertAccountStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { @@ -214,6 +222,33 @@ class P1rAiRealApiGateTest { assertEquals(expectedCount, actualCount, domain + " operation 数量必须保持不变"); } + private void assertMarketStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { + int completed = 0; + int needsVerification = 0; + int total = 0; + for (JsonNode operation : readOperations()) { + if (!"market".equals(operation.path("domain").asText())) { + continue; + } + total++; + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "market/" + operationId + " 必须保持 dedicated"); + if (APPROVED_MARKET_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "market/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "market/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(32, total, "Market operation 数量必须保持 32"); + assertEquals(expectedCompleted, completed, "Market 第一批 completed approval 只能推进 4 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Market 剩余 28 个 operation 必须继续 needs_verification"); + } + private JsonNode readAiOperations() throws IOException { ArrayNode aiOperations = objectMapper.createArrayNode(); for (JsonNode operation : readOperations()) { 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 965fd608..18fc5f16 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 @@ -80,7 +80,11 @@ class P1rApiCoverageReportTest { "account:adminGetBalanceSnapshots", "account:getAppBalanceSnapshots", "account:adminCreateQuotaAdjustment", - "account:adminListQuotaAdjustments" + "account:adminListQuotaAdjustments", + "market:getMarketplaceAsset", + "market:listMarketplaceCategories", + "market:favoriteAsset", + "market:unfavoriteAsset" ); /** 已批准的 MetaSchema 管理接口。 */ @@ -121,6 +125,14 @@ class P1rApiCoverageReportTest { "adminListQuotaAdjustments" ); + /** 已批准的 Market 第一批详情、分类和收藏接口,仍保持 operation-level approval。 */ + private static final Set APPROVED_MARKET_COMPLETED_OPERATIONS = Set.of( + "getMarketplaceAsset", + "listMarketplaceCategories", + "favoriteAsset", + "unfavoriteAsset" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); /** @@ -222,12 +234,12 @@ class P1rApiCoverageReportTest { } /** - * 验证 completed approval 只推进已批准的 Events SSE、Meta 与 Account operation,不连带推进其它 owner domain。 + * 验证 completed approval 只推进已批准的 Events SSE、Meta、Account 与 Market operation,不连带推进其它 owner domain。 * * @throws IOException 读取覆盖报告失败时抛出 */ @Test - void should_only_promote_approved_events_meta_and_account_operations() throws IOException { + void should_only_promote_approved_events_meta_account_and_market_operations() throws IOException { JsonNode operations = readOperations(); int completed = 0; @@ -236,7 +248,7 @@ class P1rApiCoverageReportTest { completed++; } } - assertEquals(127, completed, "Account 第一批 10 个 operation-level approval 后 completed 总数只能从 117 增至 127"); + assertEquals(131, completed, "Market 第一批 4 个 operation-level approval 后 completed 总数只能从 127 增至 131"); assertOperationStatus("events", "streamEvents", "dedicated", "completed"); for (String operationId : APPROVED_META_SCHEMA_COMPLETED_OPERATIONS) { @@ -248,7 +260,14 @@ class P1rApiCoverageReportTest { for (String operationId : APPROVED_ACCOUNT_COMPLETED_OPERATIONS) { assertOperationStatus("account", operationId, "dedicated", "completed"); } - assertDomainStatusCount("market", "dedicated", "needs_verification", 32); + for (String operationId : APPROVED_MARKET_COMPLETED_OPERATIONS) { + assertOperationStatus("market", 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); + assertDomainStatusCount("market", "dedicated", "needs_verification", 28); assertDomainStatusCount("account", "dedicated", "needs_verification", 23); assertDomainStatusCount("content", "dedicated", "needs_verification", 51); } @@ -284,6 +303,23 @@ class P1rApiCoverageReportTest { throw new AssertionError(domain + ":" + operationId + " 必须存在于 P1R coverage report"); } + private void assertOperationStatus(String domain, String operationId, String implementationStatus, + String completionStatus, boolean requiresCommandId) throws IOException { + for (JsonNode operation : readOperations()) { + if (domain.equals(operation.path("domain").asText()) + && operationId.equals(operation.path("operationId").asText())) { + assertEquals(implementationStatus, operation.path("implementationStatus").asText(), + operationId + " implementationStatus 不符合预期"); + assertEquals(completionStatus, operation.path("completionStatus").asText(), + operationId + " completionStatus 不符合预期"); + assertEquals(requiresCommandId, operation.path("requiresCommandId").asBoolean(), + operationId + " requiresCommandId 不符合 OpenAPI 合同"); + return; + } + } + throw new AssertionError(domain + ":" + operationId + " 必须存在于 P1R coverage report"); + } + private void assertDomainStatusCount(String domain, String implementationStatus, String completionStatus, int expected) throws IOException { int count = 0; 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 6bfc4fc7..582ac16a 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 @@ -38,6 +38,13 @@ class P1rEventsRealApiGateTest { "missing" ); + private static final Set APPROVED_MARKET_COMPLETED_OPERATIONS = Set.of( + "getMarketplaceAsset", + "listMarketplaceCategories", + "favoriteAsset", + "unfavoriteAsset" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -81,10 +88,10 @@ class P1rEventsRealApiGateTest { void should_keep_completed_approval_summary_at_approved_operation_boundary() throws IOException { JsonNode summary = readReport().path("summary"); - assertEquals(127, summary.path("completedOperations").asInt(), - "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10"); - assertEquals(106, summary.path("needsVerificationOperations").asInt(), - "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16 和 Account 10"); + 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(0, summary.path("incompleteOperations").asInt(), "Events 退出 placeholder 后不应再留下 incomplete operation"); assertEquals(0, summary.path("genericPersistenceOperations").asInt(), @@ -94,8 +101,10 @@ class P1rEventsRealApiGateTest { } @Test - void should_keep_all_market_operations_dedicated_and_needs_verification() throws IOException { + void should_keep_only_first_batch_market_operations_completed() throws IOException { JsonNode marketOperations = readMarketOperations(); + int completed = 0; + int needsVerification = 0; assertEquals(EXPECTED_MARKET_OPERATION_COUNT, marketOperations.size(), "P1R-7a 不能改变 Market 32 个 operation 的覆盖清单"); @@ -104,9 +113,22 @@ class P1rEventsRealApiGateTest { String operationId = operation.path("operationId").asText(""); assertEquals("dedicated", operation.path("implementationStatus").asText(), "Market/" + operationId + " 必须继续保持 dedicated"); + if (APPROVED_MARKET_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "Market/" + operationId + " 已获本轮批准后必须 completed"); + continue; + } + needsVerification++; assertEquals("needs_verification", operation.path("completionStatus").asText(), - "Market/" + operationId + " 必须继续保持 needs_verification,不能提前 completed"); + "Market/" + operationId + " 未获本轮批准,必须继续保持 needs_verification"); } + assertEquals(4, completed, "Market 第一批 completed approval 只能推进 4 个 operation"); + assertEquals(28, needsVerification, "Market 剩余 28 个 operation 必须继续 needs_verification"); + assertMarketOperationStatus("listMarketplaceAssets", "needs_verification", false); + assertMarketOperationStatus("listMarketplaceRecommendations", "needs_verification", false); + assertMarketOperationStatus("favoriteAsset", "completed", true); + assertMarketOperationStatus("unfavoriteAsset", "completed", true); } @Test @@ -151,6 +173,21 @@ class P1rEventsRealApiGateTest { return readOperationsByDomain("account"); } + private void assertMarketOperationStatus(String operationId, String expectedCompletionStatus, + boolean expectedRequiresCommandId) throws IOException { + for (JsonNode operation : readMarketOperations()) { + if (!operationId.equals(operation.path("operationId").asText())) { + continue; + } + assertEquals(expectedCompletionStatus, operation.path("completionStatus").asText(), + "Market/" + operationId + " completionStatus 不符合本轮审批边界"); + assertEquals(expectedRequiresCommandId, operation.path("requiresCommandId").asBoolean(), + "Market/" + operationId + " requiresCommandId 不符合 OpenAPI 合同"); + return; + } + throw new AssertionError("Market/" + operationId + " 必须存在于 coverage report"); + } + private JsonNode readOperationsByDomain(String domain) throws IOException { ArrayNode matchingOperations = objectMapper.createArrayNode(); for (JsonNode operation : readOperations()) { 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 20109169..dae9e20a 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 @@ -100,6 +100,13 @@ class P1rKnowledgeRealApiGateTest { entry("getLocalKnowledge", "GET /app-api/muse/works/{workId}/local-knowledge") ); + private static final Set APPROVED_MARKET_COMPLETED_OPERATIONS = Set.of( + "getMarketplaceAsset", + "listMarketplaceCategories", + "favoriteAsset", + "unfavoriteAsset" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -159,8 +166,8 @@ class P1rKnowledgeRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(127, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10"); + assertEquals(131, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4"); } @Test @@ -169,6 +176,7 @@ class P1rKnowledgeRealApiGateTest { assertDomainStatusCount("content", 51); assertMetaStatusCount(16, 0); assertAccountStatusCount(10, 23); + assertMarketStatusCount(4, 28); } @Test @@ -236,6 +244,33 @@ class P1rKnowledgeRealApiGateTest { assertEquals(expectedNeedsVerification, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); } + private void assertMarketStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { + int completed = 0; + int needsVerification = 0; + int total = 0; + for (JsonNode operation : readOperations()) { + if (!"market".equals(operation.path("domain").asText())) { + continue; + } + total++; + String operationId = operation.path("operationId").asText(""); + assertEquals("dedicated", operation.path("implementationStatus").asText(), + "market/" + operationId + " 必须保持 dedicated"); + if (APPROVED_MARKET_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + "market/" + operationId + " 已获本轮批准后必须 completed"); + } else { + needsVerification++; + assertEquals("needs_verification", operation.path("completionStatus").asText(), + "market/" + operationId + " 未获本轮批准,必须继续 needs_verification"); + } + } + assertEquals(32, total, "Market operation 数量必须保持 32"); + assertEquals(expectedCompleted, completed, "Market 第一批 completed approval 只能推进 4 个 operation"); + assertEquals(expectedNeedsVerification, needsVerification, "Market 剩余 28 个 operation 必须继续 needs_verification"); + } + private void assertMetaStatusCount(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/P1rMarketDiscoveryFavoriteCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketDiscoveryFavoriteCompletedApprovalIT.java new file mode 100644 index 00000000..7c011a7d --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketDiscoveryFavoriteCompletedApprovalIT.java @@ -0,0 +1,834 @@ +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.common.util.json.JsonUtils; +import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration; +import cn.iocoder.muse.framework.security.core.LoginUser; +import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration; +import cn.iocoder.muse.module.market.application.muse.MarketAssetQueryServiceImpl; +import cn.iocoder.muse.module.market.application.muse.MarketCommandServiceImpl; +import cn.iocoder.muse.module.market.application.muse.MarketFavoriteServiceImpl; +import cn.iocoder.muse.module.market.controller.app.muse.AppMuseMarketplaceAssetController; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +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.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.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_ASSET_NOT_EXISTS; +import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_COMMAND_ID_REQUIRED; +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.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P1R Market 第一批 completed approval:资产发现和收藏的 HTTP + 真实 PostgreSQL 证据。 + * + *

本测试只允许连接显式传入的 PostgreSQL {@code _test} 库,启动最小 Spring MVC 上下文, + * 通过 {@code /app-api/muse/**} 真实前缀进入 Controller,并读取数据库事实验证 Market + * 详情、分类、收藏和命令幂等链路。这里不导入 Market 全包,避免无关 worker 或外部依赖污染证据。

+ */ +@SpringBootTest( + classes = P1rMarketDiscoveryFavoriteCompletedApprovalIT.CompletedApprovalConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rMarketDiscoveryFavoriteCompletedApprovalIT { + + private static final String TARGET_VERSION = "15"; + private static final Long TENANT_ID = 100L; + private static final Long LOGIN_USER_ID = 9001L; + private static final Long PUBLISHER_USER_ID = 2001L; + 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-market-completed-approval-it"); + registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.market"); + registry.add("muse.web.admin-ui.url", () -> "http://localhost"); + registry.add("spring.datasource.url", settings::jdbcUrl); + registry.add("spring.datasource.username", settings::jdbcUser); + registry.add("spring.datasource.password", settings::jdbcPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.main.banner-mode", () -> "off"); + registry.add("spring.main.lazy-initialization", () -> "true"); + registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO"); + } + + @BeforeAll + void migrateMarketSchema() { + 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); + assertTrue(result.migrationsExecuted >= 15, + "Market completed approval 必须在隔离库执行 V1-V15 全量迁移,实际: " + result.migrationsExecuted); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetMarketTables(); + this.seedFacts = seedMarketFacts(); + setRuntimeContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_rejectPasswordSystemProperty() { + System.setProperty("p1r.market.completed.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rMarketDiscoveryFavoriteCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.market.completed.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.market.completed.password"); + } + } + + @Test + void should_getVisibleAssetDetailFromRealPostgresql() throws Exception { + mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", seedFacts.visibleAssetId()) + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.assetId").value(String.valueOf(seedFacts.visibleAssetId()))) + .andExpect(jsonPath("$.data.name").value("P1R Market Listed Agent")) + .andExpect(jsonPath("$.data.version").value("1.0.0")) + .andExpect(jsonPath("$.data.versionId").value(seedFacts.visibleVersionId())) + .andExpect(jsonPath("$.data.licenseInfo.licenseType").value("standard")) + .andExpect(jsonPath("$.data.categoryId").value(10)) + .andExpect(jsonPath("$.data.governanceStatus.listingStatus").value("listed")) + .andExpect(jsonPath("$.data.governanceStatus.sourceStatus").value("available")) + .andExpect(jsonPath("$.data.userActions.canFavorite").value(true)) + .andExpect(jsonPath("$.data.userActions.isFavorite").value(false)); + + assertEquals(0, commandCount(), "纯详情查询不能写 Market command fact"); + assertEquals(0, favoriteCount(seedFacts.visibleAssetId()), "纯详情查询不能写 Market favorite fact"); + } + + @Test + void should_rejectMissingAssetDetailWithoutLeaking() throws Exception { + mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", 999999L) + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode())) + .andExpect(jsonPath("$.msg").exists()); + + assertEquals(0, commandCount(), "missing detail 不能写 Market command fact"); + } + + @Test + void should_rejectInvisibleAssetDetailWithoutLeaking() throws Exception { + mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", seedFacts.invisibleAssetId()) + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode())) + .andExpect(jsonPath("$.msg").exists()); + + assertEquals(0, commandCount(), "不可见资产详情不能写 Market command fact"); + assertEquals(0, favoriteCount(seedFacts.invisibleAssetId()), "不可见资产详情不能写 Market favorite fact"); + } + + @Test + void should_listCategoriesWithFallbackSlotAndUnavailableExposureSummary() throws Exception { + mockMvc.perform(get("/app-api/muse/marketplace/categories") + .header("X-API-Version", "1") + .param("includeExposureSummary", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.categories", hasSize(2))) + .andExpect(jsonPath("$.data.categories[0].categoryId").value(10)) + .andExpect(jsonPath("$.data.categories[0].assetCount").value(1)) + .andExpect(jsonPath("$.data.recommendationSlots[0].slotId").value("fallback_popular")) + .andExpect(jsonPath("$.data.recommendationSlots[0].assetIds", + hasItem(seedFacts.visibleAssetId().intValue()))) + .andExpect(jsonPath("$.data.exposureSummary.totalExposureRange").value("unavailable")) + .andExpect(jsonPath("$.data.exposureSummary.blockedReason").value("exposure_model_not_available")); + + assertEquals(0, commandCount(), "分类查询不能写 Market command fact"); + assertEquals(0, favoriteCount(seedFacts.visibleAssetId()), "分类查询不能写 Market favorite fact"); + } + + @Test + void should_rejectFavoriteMissingCommandIdWithoutDbWrite() throws Exception { + mockMvc.perform(post("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_COMMAND_ID_REQUIRED.getCode())) + .andExpect(jsonPath("$.msg").exists()); + + assertEquals(0, commandCount(), "缺少 X-Command-Id 的收藏请求不能写 command fact"); + assertEquals(0, favoriteCount(seedFacts.visibleAssetId()), "缺少 X-Command-Id 的收藏请求不能写 favorite fact"); + } + + @Test + void should_writeActiveFavoriteAndReplaySameCommand() throws Exception { + String commandId = "p1r-market-favorite-replay"; + + mockMvc.perform(post("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", commandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + mockMvc.perform(post("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", commandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + + assertEquals(1, favoriteCount(seedFacts.visibleAssetId()), "重复收藏命令不能插入重复 favorite fact"); + assertEquals("active", favoriteStatus(seedFacts.visibleAssetId()), "收藏后 favorite fact 必须为 active"); + assertEquals(commandId, favoriteCommandId(seedFacts.visibleAssetId()), "favorite fact 必须保留首次成功 commandId"); + CommandFact command = commandFact(commandId); + assertNotNull(command, "收藏成功必须写 Market command fact"); + assertEquals("favoriteAsset", command.operationId()); + assertEquals("completed", command.status()); + assertEquals(LOGIN_USER_ID, command.actorUserId()); + assertEquals(PUBLISHER_USER_ID, command.ownerUserId()); + assertEquals(seedFacts.visibleAssetId(), command.targetId()); + assertCommandResultSnapshot(command, true); + } + + @Test + void should_unfavoriteToInactiveAndReplayWithoutDeletingFact() throws Exception { + String favoriteCommandId = "p1r-market-unfavorite-seed"; + String unfavoriteCommandId = "p1r-market-unfavorite-replay"; + mockMvc.perform(post("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", favoriteCommandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(delete("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", unfavoriteCommandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + mockMvc.perform(delete("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", unfavoriteCommandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + + assertEquals(1, favoriteCount(seedFacts.visibleAssetId()), "取消收藏不能物理删除 favorite 审计事实"); + assertEquals("inactive", favoriteStatus(seedFacts.visibleAssetId()), "取消收藏后 favorite fact 必须为 inactive"); + assertEquals(unfavoriteCommandId, favoriteCommandId(seedFacts.visibleAssetId()), + "favorite fact 必须保留取消收藏 commandId"); + assertEquals(2, commandCount(), "favorite + unfavorite 各只允许写 1 条 command fact"); + CommandFact command = commandFact(unfavoriteCommandId); + assertNotNull(command, "取消收藏成功必须写 Market command fact"); + assertEquals("unfavoriteAsset", command.operationId()); + assertEquals("completed", command.status()); + assertCommandResultSnapshot(command, false); + } + + @Test + void should_rejectUnfavoriteMissingCommandIdAndMissingOrInvisibleTargetsWithoutDbWrite() throws Exception { + String seedCommandId = "p1r-market-delete-missing-header-seed"; + mockMvc.perform(post("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", seedCommandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + assertEquals("active", favoriteStatus(seedFacts.visibleAssetId()), "前置收藏必须先写入 active favorite fact"); + + mockMvc.perform(delete("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.visibleAssetId()) + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_COMMAND_ID_REQUIRED.getCode())) + .andExpect(jsonPath("$.msg").exists()); + assertEquals("active", favoriteStatus(seedFacts.visibleAssetId()), + "缺少 X-Command-Id 的取消收藏不能改变已有 favorite fact"); + assertEquals(1, commandCount(), "缺少 X-Command-Id 的取消收藏不能新增 command fact"); + + String missingFavoriteCommand = "p1r-market-missing-favorite"; + String invisibleFavoriteCommand = "p1r-market-invisible-favorite"; + String missingUnfavoriteCommand = "p1r-market-missing-unfavorite"; + String invisibleUnfavoriteCommand = "p1r-market-invisible-unfavorite"; + mockMvc.perform(post("/app-api/muse/marketplace/assets/{assetId}/favorite", 999999L) + .header("X-API-Version", "1") + .header("X-Command-Id", missingFavoriteCommand)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode())); + mockMvc.perform(post("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.invisibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", invisibleFavoriteCommand)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode())); + mockMvc.perform(delete("/app-api/muse/marketplace/assets/{assetId}/favorite", 999999L) + .header("X-API-Version", "1") + .header("X-Command-Id", missingUnfavoriteCommand)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode())); + mockMvc.perform(delete("/app-api/muse/marketplace/assets/{assetId}/favorite", seedFacts.invisibleAssetId()) + .header("X-API-Version", "1") + .header("X-Command-Id", invisibleUnfavoriteCommand)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode())); + + assertEquals(0, commandCount(missingFavoriteCommand)); + assertEquals(0, commandCount(invisibleFavoriteCommand)); + assertEquals(0, commandCount(missingUnfavoriteCommand)); + assertEquals(0, commandCount(invisibleUnfavoriteCommand)); + assertEquals(0, favoriteCount(seedFacts.invisibleAssetId()), "不可见资产不能被收藏或取消收藏写入 favorite fact"); + assertEquals("active", favoriteStatus(seedFacts.visibleAssetId()), "错误目标请求不能污染已存在的 visible favorite fact"); + } + + 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 resetMarketTables() throws SQLException { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + // 每个测试重置 Market 目标表,保证 HTTP 请求写出的 command/favorite fact 可被精确计数。 + statement.execute(""" + TRUNCATE TABLE + muse_market_command, + muse_market_favorite, + muse_market_asset_version, + muse_market_asset + RESTART IDENTITY CASCADE + """); + } + } + + private SeedFacts seedMarketFacts() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + Long visibleAssetId = insertAsset(connection, "P1R Market Listed Agent", "listed", + PUBLISHER_USER_ID, "10", 30L, "[\"agent\",\"p1r\"]"); + Long visibleVersionId = insertVersion(connection, visibleAssetId, "1.0.0"); + updateCurrentVersion(connection, visibleAssetId, visibleVersionId); + Long secondVisibleAssetId = insertAsset(connection, "P1R Market Listed Prompt", "listed", + 2002L, "20", 10L, "[\"prompt\"]"); + Long secondVisibleVersionId = insertVersion(connection, secondVisibleAssetId, "1.1.0"); + updateCurrentVersion(connection, secondVisibleAssetId, secondVisibleVersionId); + Long invisibleAssetId = insertAsset(connection, "P1R Market Invisible Draft", "draft", + 3001L, "30", 1L, "[\"draft\"]"); + Long invisibleVersionId = insertVersion(connection, invisibleAssetId, "0.1.0"); + updateCurrentVersion(connection, invisibleAssetId, invisibleVersionId); + return new SeedFacts(visibleAssetId, visibleVersionId, secondVisibleAssetId, invisibleAssetId); + } + } + + private Long insertAsset(Connection connection, String name, String listingStatus, Long publisherId, + String category, Long installCount, String tags) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_market_asset(name, description, asset_type, category, source_id, + publisher_id, listing_status, license_type, status, + rating, install_count, tags, tenant_id) + VALUES (?, ?, 'agent', ?, 7001, ?, ?, 'standard', ?, 4.8, ?, CAST(? AS jsonb), ?) + RETURNING id + """)) { + statement.setString(1, name); + statement.setString(2, name + " description"); + statement.setString(3, category); + statement.setLong(4, publisherId); + statement.setString(5, listingStatus); + statement.setString(6, listingStatus); + statement.setLong(7, installCount); + statement.setString(8, tags); + statement.setLong(9, TENANT_ID); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed Market asset 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + private Long insertVersion(Connection connection, Long assetId, String version) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_market_asset_version(asset_id, version, change_note, version_snapshot, status, tenant_id) + VALUES (?, ?, 'P1R completed approval seed version', CAST(? AS jsonb), 'listed', ?) + RETURNING id + """)) { + statement.setLong(1, assetId); + statement.setString(2, version); + statement.setString(3, "{\"source\":\"p1r-market-completed-approval\"}"); + statement.setLong(4, TENANT_ID); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed Market asset version 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + private void updateCurrentVersion(Connection connection, Long assetId, Long versionId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + UPDATE muse_market_asset + SET current_version_id = ? + WHERE tenant_id = ? + AND id = ? + """)) { + statement.setLong(1, versionId); + statement.setLong(2, TENANT_ID); + statement.setLong(3, assetId); + assertEquals(1, statement.executeUpdate(), "seed Market asset 必须能设置 current_version_id"); + } + } + + private int commandCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_market_command WHERE tenant_id = ?", TENANT_ID); + } + + private int commandCount(String commandId) throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_market_command WHERE tenant_id = ? AND command_id = ?", + TENANT_ID, commandId); + } + + private int favoriteCount(Long assetId) throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_market_favorite WHERE tenant_id = ? AND user_id = ? AND asset_id = ?", + TENANT_ID, LOGIN_USER_ID, assetId); + } + + private String favoriteStatus(Long assetId) throws SQLException { + return queryString(""" + SELECT status + FROM muse_market_favorite + WHERE tenant_id = ? + AND user_id = ? + AND asset_id = ? + """, TENANT_ID, LOGIN_USER_ID, assetId); + } + + private String favoriteCommandId(Long assetId) throws SQLException { + return queryString(""" + SELECT command_id + FROM muse_market_favorite + WHERE tenant_id = ? + AND user_id = ? + AND asset_id = ? + """, TENANT_ID, LOGIN_USER_ID, assetId); + } + + private CommandFact commandFact(String commandId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + SELECT operation_id, status, actor_user_id, owner_user_id, target_id, result_snapshot::text + FROM muse_market_command + 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.getString(2), resultSet.getLong(3), + resultSet.getLong(4), resultSet.getLong(5), resultSet.getString(6)); + } + } + } + + private void assertCommandResultSnapshot(CommandFact command, boolean expectedFavorite) { + Map snapshot = JsonUtils.parseObject(command.resultSnapshot(), Map.class); + assertNotNull(snapshot, "Market command result_snapshot 必须是 JSON 对象"); + assertEquals(expectedFavorite, snapshot.get("isFavorite"), + "Market command result_snapshot 必须记录 isFavorite=" + expectedFavorite); + } + + 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_MARKET_COMPLETED_PASSWORD", + "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_MARKET_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(P1rMarketDiscoveryFavoriteCompletedApprovalIT::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(".market.") + || 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 Market 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 visibleAssetId, + Long visibleVersionId, + Long secondVisibleAssetId, + Long invisibleAssetId) { + } + + private record CommandFact(String operationId, + String status, + Long actorUserId, + Long ownerUserId, + Long targetId, + String resultSnapshot) { + } + + 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({ + AppMuseMarketplaceAssetController.class, + MarketAssetQueryServiceImpl.class, + MarketFavoriteServiceImpl.class, + MarketCommandServiceImpl.class, + SpringUtil.class + }) + static class CompletedApprovalConfiguration { + + @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/P1rMarketFlywayMigrationIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketFlywayMigrationIT.java index 743fdf72..2ed2e638 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketFlywayMigrationIT.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketFlywayMigrationIT.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; @@ -50,6 +51,7 @@ class P1rMarketFlywayMigrationIT { "muse_market_publish_request", "muse_market_appeal", "muse_market_handoff", + "muse_market_favorite", "muse_market_purchase", "muse_market_command", "muse_market_authorization_snapshot", @@ -72,6 +74,8 @@ class P1rMarketFlywayMigrationIT { "uk_muse_market_asset_command", "idx_muse_market_install_user", "uk_muse_market_install_command", + "uk_muse_market_favorite_command", + "idx_muse_market_favorite_user", "uk_muse_market_purchase_command", "idx_muse_market_purchase_user", "idx_muse_market_purchase_asset", @@ -118,6 +122,7 @@ class P1rMarketFlywayMigrationIT { private static final List REQUIRED_CONSTRAINTS = List.of( "uk_muse_market_install", + "uk_muse_market_favorite_user_asset", "uk_muse_market_purchase_command", "uk_muse_market_command", "uk_muse_market_auth_snapshot", @@ -141,6 +146,7 @@ class P1rMarketFlywayMigrationIT { private static final List REQUIRED_V15_TRIGGERS = List.of( "trg_muse_market_command_updated_at", + "trg_muse_market_favorite_updated_at", "trg_muse_market_auth_snapshot_updated_at", "trg_muse_market_auth_summary_updated_at", "trg_muse_market_handoff_event_updated_at", @@ -172,6 +178,16 @@ class P1rMarketFlywayMigrationIT { "access_token", "refresh_token" ); + private static volatile String rememberedFlywayUrl; + private static volatile String rememberedFlywayUser; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @AfterAll + static void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } @Test void should_migrateV1ToV15OnRealPostgresqlAndVerifyMarketSchema() throws Exception { @@ -259,8 +275,76 @@ class P1rMarketFlywayMigrationIT { "合法 _test 库携带 query 时必须保留真实 database name,并整体脱敏 query"); } + @Test + void should_verifyMarketFavoriteSchemaAndRejectDuplicateFacts() throws Exception { + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String requestedLocations = requiredProperty("p1r.flyway.locations"); + redactFlywaySystemProperties(url, user); + assertEquals("filesystem:sql/muse", requestedLocations, + "P1R Market Flyway 收藏表验收要求显式使用 filesystem:sql/muse"); + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + silenceFlywayInfoLogs(); + Flyway flyway = Flyway.configure() + .dataSource(url, user, password) + .locations(resolveMuseSqlLocation(requestedLocations)) + .schemas("public") + .defaultSchema("public") + .target(TARGET_VERSION) + .cleanDisabled(false) + .load(); + cleanSchema(flyway, url, user); + migrateSchema(flyway, url, user); + + try (Connection connection = DriverManager.getConnection(url, user, password)) { + assertColumnsExist(connection, "muse_market_favorite", + "asset_id", "user_id", "status", "command_id", "tenant_id", "update_time"); + assertEquals(List.of("tenant_id", "user_id", "asset_id"), + constraintColumns(connection, "uk_muse_market_favorite_user_asset"), + "收藏事实唯一约束必须按 tenant/user/asset 分层,不能重复插入同一收藏事实"); + assertEquals(List.of("tenant_id", "user_id", "status"), + indexColumns(connection, "idx_muse_market_favorite_user"), + "收藏列表查询索引必须覆盖 tenant/user/status"); + assertPartialCommandIndex(connection, "uk_muse_market_favorite_command"); + assertTrue(exists(connection, + """ + SELECT 1 + FROM information_schema.triggers + WHERE trigger_schema = 'public' + AND trigger_name = ? + """, + "trg_muse_market_favorite_updated_at"), + "收藏状态更新必须有 update_time 触发器"); + + resetFavoriteProbeRows(connection); + insertFavoriteProbe(connection, "p1r-market-favorite-schema-cmd-1"); + SQLException duplicateFact = assertThrows(SQLException.class, + () -> insertFavoriteProbe(connection, "p1r-market-favorite-schema-cmd-2")); + assertEquals("23505", duplicateFact.getSQLState(), + "同一 tenant/user/asset 的重复收藏事实必须由 PostgreSQL 唯一约束拒绝"); + SQLException duplicateCommand = assertThrows(SQLException.class, + () -> insertFavoriteProbe(connection, 990004L, 990005L, "p1r-market-favorite-schema-cmd-1")); + assertEquals("23505", duplicateCommand.getSQLState(), + "同一 tenant/command_id 的重复收藏命令必须由 PostgreSQL partial unique index 拒绝"); + assertFavoriteUpdateTriggerExecutes(connection); + } + } + private static String requiredProperty(String name) { String value = System.getProperty(name); + if ("p1r.flyway.url".equals(name)) { + if (value != null && !value.contains("")) { + rememberedFlywayUrl = value; + } + value = value != null && value.contains("") ? rememberedFlywayUrl : value; + } else if ("p1r.flyway.user".equals(name)) { + if (value != null && !value.contains("")) { + rememberedFlywayUser = value; + } + value = value != null && value.contains("") ? rememberedFlywayUser : value; + } assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name); return value; } @@ -307,10 +391,36 @@ class P1rMarketFlywayMigrationIT { private static void redactFlywaySystemProperties(String url, String user) { // WHY: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; + } + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + 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(); @@ -432,6 +542,13 @@ class P1rMarketFlywayMigrationIT { constraintColumns(connection, "uk_muse_market_install"), "安装幂等唯一约束必须按 tenant/user/asset/version 分层"); + assertColumnsExist(connection, "muse_market_favorite", + "asset_id", "user_id", "status", "command_id", "tenant_id"); + assertEquals(List.of("tenant_id", "user_id", "asset_id"), + constraintColumns(connection, "uk_muse_market_favorite_user_asset"), + "收藏事实唯一约束必须按 tenant/user/asset 分层"); + assertPartialCommandIndex(connection, "uk_muse_market_favorite_command"); + assertColumnsExist(connection, "muse_market_purchase", "asset_id", "asset_version_id", "user_id", "status", "purchase_payload", "command_id"); assertTrue(columnIsNotNullable(connection, "muse_market_purchase", "command_id"), @@ -584,6 +701,52 @@ class P1rMarketFlywayMigrationIT { } } + private static void resetFavoriteProbeRows(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + DELETE FROM muse_market_favorite + WHERE tenant_id = 990001 + """)) { + statement.executeUpdate(); + } + } + + private static void insertFavoriteProbe(Connection connection, String commandId) throws SQLException { + insertFavoriteProbe(connection, 990003L, 990002L, commandId); + } + + private static void insertFavoriteProbe(Connection connection, Long assetId, Long userId, + String commandId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_market_favorite(asset_id, user_id, status, command_id, tenant_id) + VALUES (?, ?, 'active', ?, 990001) + """)) { + statement.setLong(1, assetId); + statement.setLong(2, userId); + statement.setString(3, commandId); + statement.executeUpdate(); + } + } + + private static void assertFavoriteUpdateTriggerExecutes(Connection connection) throws SQLException { + insertFavoriteProbe(connection, 990006L, 990007L, "p1r-market-favorite-schema-cmd-3"); + try (PreparedStatement statement = connection.prepareStatement(""" + UPDATE muse_market_favorite + SET status = 'inactive', + update_time = TIMESTAMP '2000-01-01 00:00:00' + WHERE tenant_id = 990001 + AND user_id = 990007 + AND asset_id = 990006 + RETURNING status, update_time > TIMESTAMP '2000-01-02 00:00:00' + """)) { + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "收藏状态更新必须返回被 trigger 处理后的行"); + assertEquals("inactive", resultSet.getString(1), "收藏状态必须能更新为 inactive"); + assertTrue(resultSet.getBoolean(2), + "收藏状态更新时 trigger 必须覆盖手工写入的旧 update_time"); + } + } + } + private static boolean columnExists(Connection connection, String tableName, String columnName) throws SQLException { try (PreparedStatement statement = connection.prepareStatement(""" SELECT 1 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 a929a4d7..880329e5 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 @@ -19,8 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; /** * P1R-6 Task 11 Market 真实 API 覆盖门禁。 * - *

该测试只证明 Market 32 个 operation 已退出合同兜底并进入 dedicated / needs_verification; - * 它不证明 Market 已经完成跨 owner 端到端验收,也不允许把 Market 提前标记为 completed。

+ *

该测试证明 Market 第一批 4 个 operation 只以 operation-level 方式推进到 completed; + * 它不证明 Market domain-level completed,也不允许把剩余 28 个 operation 提前标记为 completed。

*/ class P1rMarketRealApiGateTest { @@ -71,6 +71,13 @@ class P1rMarketRealApiGateTest { entry("adminResolveAppeal", "POST /admin-api/muse/market/appeals/{appealId}/resolve") ); + private static final Set APPROVED_MARKET_COMPLETED_OPERATIONS = Set.of( + "getMarketplaceAsset", + "listMarketplaceCategories", + "favoriteAsset", + "unfavoriteAsset" + ); + private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -97,22 +104,37 @@ class P1rMarketRealApiGateTest { } @Test - void should_keep_all_market_operations_dedicated_and_needs_verification() throws IOException { + void should_promote_only_first_batch_market_operations_to_completed() throws IOException { + int completed = 0; + int needsVerification = 0; for (JsonNode operation : readMarketOperations()) { String operationId = operation.path("operationId").asText(""); assertEquals("dedicated", operation.path("implementationStatus").asText(), operationId + " 当前 P1R-6 目标必须是 dedicated"); + if (APPROVED_MARKET_COMPLETED_OPERATIONS.contains(operationId)) { + completed++; + assertEquals("completed", operation.path("completionStatus").asText(), + operationId + " 已获用户批准后必须标记 completed"); + continue; + } + needsVerification++; assertEquals("needs_verification", operation.path("completionStatus").asText(), - operationId + " 当前 P1R-6 目标必须保持 needs_verification,不能提前宣称 completed"); + operationId + " 未进入 Market 第一批审批,必须继续保持 needs_verification"); } + assertEquals(4, completed, "Market 第一批 operation-level completed approval 只能推进 4 个 operation"); + assertEquals(28, needsVerification, "Market 剩余 28 个 operation 必须继续 needs_verification"); + assertMarketOperationStatus("listMarketplaceAssets", "needs_verification", false); + assertMarketOperationStatus("listMarketplaceRecommendations", "needs_verification", false); + assertMarketOperationStatus("favoriteAsset", "completed", true); + assertMarketOperationStatus("unfavoriteAsset", "completed", true); } @Test void should_keep_completion_summary_owned_by_approved_scopes_only() throws IOException { JsonNode report = readReport(); - assertEquals(127, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须仍只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10"); + assertEquals(131, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4"); } @Test @@ -206,6 +228,21 @@ class P1rMarketRealApiGateTest { "Meta operation-level approval 后不应再保留 FunctionChain / ProtectionNode needs_verification"); } + private void assertMarketOperationStatus(String operationId, String expectedCompletionStatus, + boolean expectedRequiresCommandId) throws IOException { + for (JsonNode operation : readMarketOperations()) { + if (!operationId.equals(operation.path("operationId").asText())) { + continue; + } + assertEquals(expectedCompletionStatus, operation.path("completionStatus").asText(), + "market/" + operationId + " completionStatus 不符合本轮审批边界"); + assertEquals(expectedRequiresCommandId, operation.path("requiresCommandId").asBoolean(), + "market/" + operationId + " requiresCommandId 不符合 OpenAPI 合同"); + return; + } + throw new AssertionError("market/" + operationId + " 必须存在于 coverage report"); + } + private JsonNode readMarketOperations() throws IOException { ArrayNode marketOperations = objectMapper.createArrayNode(); for (JsonNode operation : readOperations()) { diff --git a/muse-cloud/scripts/p1r-audit-api-coverage.py b/muse-cloud/scripts/p1r-audit-api-coverage.py index f748bda7..24f0b757 100755 --- a/muse-cloud/scripts/p1r-audit-api-coverage.py +++ b/muse-cloud/scripts/p1r-audit-api-coverage.py @@ -74,6 +74,11 @@ APPROVED_COMPLETED_OPERATIONS = { "account:getAppBalanceSnapshots", "account:adminCreateQuotaAdjustment", "account:adminListQuotaAdjustments", + # P1R Market completed approval 第一批仍使用 operation-level 清单,避免整域 Market 被误升 completed。 + "market:getMarketplaceAsset", + "market:listMarketplaceCategories", + "market:favoriteAsset", + "market:unfavoriteAsset", }