test(p1r): prepare S2 market detach gates

This commit is contained in:
zizi 2026-07-07 19:02:23 +08:00
parent 6f2629302f
commit b9663f52d1
10 changed files with 330 additions and 42 deletions

View File

@ -27,6 +27,8 @@
**2.0.0 单人版改造 S1 Dify 基础设施与 live 契约(2026-07-07)**:已在 `mini-infra` 用官方 Dify `1.15.0` compose 独立部署 `muse-dify`,入口 `http://100.64.0.8:18080`,API health 返回 `version=1.15.0`;该栈带自己的 `db/redis/weaviate/nginx/sandbox/plugin_daemon/worker/web` 等容器,不替换 Muse 既有 PG/Redis。Dify 绑定 New-API OpenAI-compatible provider,已接入 `MiniMax-M2.5`、`Qwen/Qwen3-Embedding-8B`、`Qwen/Qwen3-Reranker-8B`,并创建写作 chat app、全书解析 workflow app、工作区级 Datasets API key。S1 真实验收新增 `P1rDifyChatLiveAcceptanceIT` 与 `P1rDifyDatasetsContractLiveIT`,默认 `MUSE_P1R_EXTERNAL_ACCEPTANCE` 未启用时 honest skipped;显式 opt-in 后真实调用 Dify,chat app 返回 `status=success / messageIdPresent=true / summarySha256Prefix=565339bc4d33`,Datasets 完成 create/upload/index/retrieve,保留证据 dataset `cce33d41-c738-42d2-97b6-2d08a69965f2`,`indexingStatus=completed`、`score=0.7442479133605957`。踩坑已沉淀:Dify 官方 `.env` 的 `STORAGE_TYPE=opendal` 在本部署下会导致 worker `File not found`,已切到 `STORAGE_TYPE=local` + `STORAGE_LOCAL_PATH=storage` 并保留远端备份 `.env.s1-storage-before-local`;Dify 1.15 `/retrieve` 的 `retrieval_model` 必填 `search_method` 与 `reranking_enable`。回归证据:targeted live 2/0F/0E/0S、默认跳过 2/0F/0E/2S、`run-p1r-verification.sh local` 61/0F/0E/0S。RAGFlow 实例下线与已暴露 key 轮换仍为手工事项,不阻塞后续 S2。
**2.0.0 单人版改造 S2 门禁与测试树适配(2026-07-07)**:已完成“机制就绪、不拨开关”。`muse-server` 新增 `market-detached-test-tree` profile,显式 `-Dmuse.market.detached.test-tree=true` 时用 compiler `testExcludes` + surefire excludes 剥离 14 个 market/account 混合装配测试及 `P1rMarketRealApiGateTest`;默认本地构建仍编译全量测试树,`market-assembled` 可在 dry-run profile 同启时恢复全量装配口径。`P1rApiCoverageReportTest` 引入仅 market 可用的 `dormant` 口径,dormant 保留总 operation 242、不计入 completed、跳过 completed 证据强制,但仍要求 dedicated 且属于批准域;覆盖 JSON 本步未改。AI/Knowledge/Events 三个 RealApiGate 的汇总断言同步改为 `totalOperations=242`、`completedOperations=242-marketDormant`,避免 S3 dry-run 在非 market gate 假红。Admin e2e 增加 `MUSE_ADMIN_E2E_MARKET=false` 开关,显式关闭时跳过 market/account 治理切片和 market fixture,默认不跳过。验收证据:Admin typecheck 通过;`P1rApiCoverageReportTest` 默认 9/0F/0E/0S;AI/Knowledge/Events RealApiGate 22/0F/0E/0S;`-Pmarket-assembled -DskipTests clean test` 回编译全量 91 个 muse-server 测试源;主工作树 `run-p1r-verification.sh local` 62/0F/0E/0S;临时 worktree 注释 market-server 依赖并把 market 32 条置 dormant 后,带 `MAVEN_ARGS='-Pmarket-detached-test-tree -Dmuse.market.detached.test-tree=true'` 的 local dry-run 56/0F/0E/0S。未改 OpenAPI、DDL、业务实现、覆盖 JSON 或外部凭据;S3 仍需原子拨开关。
**E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business audit;AI owner 不可用时整笔 merge 回滚,避免 Canonical 已写但候选仍 pending。Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive;通知失败不回滚 Canonical 主写,Knowledge confirm 端仍按来源状态 fail-closed。Studio `CandidatePanel` 支持编辑最终正文并按 `accept_as_is`/`modify_then_merge` 提交,IndexedDB 草稿键改为 `workId+blockId+revision` 防跨作品/版本污染。`saveBlock`/`mergeBlockSuggestion` 现在写 `muse_content_block_revision_snapshot`,工作台“历史”Tab 只读展示 Canonical revision 快照;导入可创建真实任务,导出支持范围/格式选择、任务查询与下载凭证消费,知识/记录入口跳转对应工作台。fresh 证据:后端局部单测 `ContentSourceServiceTest` 25/0F/0E、`ContentAppServiceTest` 19/0F/0E、`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E、`AiSuggestionMergeProjectionFacadeTest` 7/0F/0E;契约/覆盖门 `ContractFirstGateTest` 4/0F/0E + `P1rApiCoverageReportTest` 8/0F/0E;real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S;studio `tsc -b --force` 通过、lint 0 errors、Vitest 12/12,追加 `AIPanel.contract.test.tsx` + `sse.test.ts` 22/0F/0E;导出 UI 追加 `useWorks.test.tsx`+`ExportWorkModal.test.tsx` 11/0F/0E、目标 ESLint 0 errors。共享 PG 写入闸门批准后已补跑 MSW-off Playwright 真后端 `accept-suggestion.spec.ts` 2/0F/0E:正路真 New-API 生成 suggestionId=79、authz `rpe-local-*`、Block revision 160→161、AI owner accepted decision archive 非空;负路 stale revision 业务冲突。边界:`muse-studio/src/types/content.ts` 生成类型因 openapi-typescript 版本漂移未同步,hook 内暂维护 `BlockRevision` 最小类型;完整导入向导已在 RC 后补,见下方记录;真后端导出下载 e2e 已在 2026-06-28 补跑通过,FileApi 异常仍按后端合同 fail-closed。
**E2 ai 智能体生命周期与候选处置闭环(2026-06-27)**:已补用户自建 Agent update/archive、初始版本自动创建、版本列表/激活/归档非当前版本、作品槽位 unbind、Studio reject 调后端 AI owner 决策归档。Agent update 会创建下一 active version 并更新 `current_version_id`,archive Agent 同步归档仍 active 的槽位绑定;slot unbind 将绑定行 revision+1 且 `status=archived`,运行时回到默认能力;WorkspacePage “放弃修改”不再只清本地候选,而是真打 `POST /suggestions/{id}/reject` 写 `rejected` 与 decision archive。启动修红:最新单体启动时暴露 `muse.codegen.importEnable` 缺省,已在 `muse-server/src/main/resources/application.yaml` 补 `import-enable:false`,随后 48080 成功启动并 `GET /app-api/muse/agents` smoke 返回 `code=0`。fresh 证据:后端 AI targeted `MuseAgentServiceTest`+`MuseAgentSlotServiceTest`+Controller annotation 63/0F/0E;契约门 `ContractFirstGateTest` 4/0F/0E;studio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0E;MSW-off Playwright 真后端 `agent-create.spec.ts`+`agent-slot-bind.spec.ts`+`accept-suggestion.spec.ts` 5/0F/0E:采纳真生成 suggestionId=80、Block revision 161→162、accepted decision archive 非空;拒绝真生成 suggestionId=81、`status=rejected`、decision archive 非空;Agent 生命周期 DB 核验 v1/v2 current 切换与 v2 archived;Slot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:本轮未跑 studio 全量 e2e。

View File

@ -2,6 +2,9 @@ import { execFileSync } from 'node:child_process';
import { Client } from 'pg';
const isEnvDisabled = (value: string | undefined) =>
value === '0' || value?.toLowerCase() === 'false';
/**
* Playwright globalSetup —— admin 治理 e2e 转真后端的活体前置(可入库,替代一次性种子脚本)。
*
@ -70,8 +73,14 @@ async function globalSetup(): Promise<void> {
`[admin e2e globalSetup] seed system_user_role(super_admin) rowCount=${seed.rowCount}(0=已存在)`,
);
// 复位 market 写命令子域 fixture(详见函数注释)。
await resetMarketFixture(client);
// 复位 market 写命令子域 fixture(详见函数注释)。单人版隔离态 dry-run 可显式关闭该切片。
if (isEnvDisabled(process.env.MUSE_ADMIN_E2E_MARKET)) {
console.log(
'[admin e2e globalSetup] MUSE_ADMIN_E2E_MARKET=false → 跳过 market fixture 复位',
);
} else {
await resetMarketFixture(client);
}
// 复位 jobs/source-event 写命令子域 fixture(详见函数注释)。
await resetJobsFixture(client);

View File

@ -8,6 +8,19 @@ const commonResult = (data: unknown) => ({
msg: '',
});
const isEnvDisabled = (value: string | undefined) =>
value === '0' || value?.toLowerCase() === 'false';
// S2 机制开关:默认保留 market/account 活体覆盖,单人版隔离态 dry-run 可显式关闭该切片。
const isMarketAccountE2eEnabled = () => !isEnvDisabled(process.env.MUSE_ADMIN_E2E_MARKET);
const skipWhenMarketAccountE2eDisabled = () => {
test.skip(
!isMarketAccountE2eEnabled(),
'MUSE_ADMIN_E2E_MARKET=false 跳过 market/account 治理 e2e',
);
};
// market 子域是写命令子域:e2e 真打下架/申诉处理命令后,必须直连真实 PG 核验 muse_market_* 真实行
// (反假绿:不能只看 UI toast,要看 DB listing_status/governance_action 真的变了)。连接凭据从环境变量读
// (MUSE_POSTGRES_*,见 ~/.config/muse-repo/infra.env;绝不入库),与 global-setup 同源。
@ -286,6 +299,8 @@ test.afterEach(async ({ page }) => {
// /account/usage-records 真返空(total=0,不对行断言,仅验 tab 可达)。
// 真分页 wrapper={pageNo,pageSize,total,list},前端 PageResult 只取 list/total,多出字段忽略 → 契约兼容。
test('账号治理页对真后端 account 子域渲染脱敏摘要', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
await page.goto('/muse/account');
// 用户治理 tab:边界文案 + 真用户脱敏摘要 + 真风险标记。
@ -399,6 +414,8 @@ test('任务取消与来源传播重试真打命令并落库', async ({ page })
// 发布审核队列真返 status=pending(后端把 DB 'submitted' 映射成 'pending')的真发布申请。
// 真分页 wrapper={pageNo,pageSize,total,list},前端 PageResult 只取 list/total。
test('市场治理页对真后端 market 子域渲染三大工作台', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
await page.goto('/muse/market');
// 页头与边界文案。
@ -431,6 +448,8 @@ test('市场治理页对真后端 market 子域渲染三大工作台', async ({
// 注意:本用例真实改 muse_slice_live 共享行(资产 1 → delisted);下一轮 globalSetup.resetMarketFixture
// 会把它复位回 listed。同一轮内本用例必须排在"召回"等同样针对资产 1 的写用例之前。
test('市场资产下架真打命令并落库脱敏治理动作', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
const assetId = 1;
// 前置真值核验:资产 1 必须处于 listed(globalSetup 已复位),否则前端"下架"按钮逻辑前提不成立。
expect(await queryAssetListingStatus(assetId)).toBe('listed');
@ -475,6 +494,8 @@ test('市场资产下架真打命令并落库脱敏治理动作', async ({ page
// appeal restore 票据一次性消费路径只有 mock-service 单测、无真 PG IT,且本 e2e 未覆盖 restore 分支
// (FE openAppealAction 默认 resolution=maintained,本用例不切到 restored)。restore 真后端覆盖缺口待补。
test('市场申诉以 maintained 结论真打处理命令并落库', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
const appealId = 1;
// 前置真值核验:申诉 1 必须为开放态 supplementing(globalSetup 已复位),前端"处理"按钮才放行。
const before = await queryAppealFacts(appealId);
@ -610,6 +631,8 @@ test('全局知识页对真后端展示系统世界观库', async ({ page }) =>
// 市场治理巡航:真后端市场治理页边界 + 三个 tab 的 chrome(数据锚点由 market 真连用例另行覆盖,这里只巡航 chrome)。
// market 子域已由 resetMarketFixture 灌真实资产/申诉,故资产名/申诉关联资产也能真断言。
test('市场治理页对真后端巡航三大工作台边界', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
await page.goto('/muse/market');
await expect(page.getByRole('heading', { name: '市场治理' })).toBeVisible();
// 审核队列(默认 active)tab 头文案。

View File

@ -6,4 +6,5 @@
- **边界(不可违反)**:**授权 ≠ 所有权转移**;市场**非**源事实 owner,目标事实回目标 owner;Work 资产仅只读且排除出 install(`INSTALLABLE_ASSET_TYPES=Set.of(agent,knowledge_base)`);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
- **out-of-scope**:完整电商支付结算 DRM(项目非目标);模板化/参考写入/AI 上下文受 Feature Gate 默认关闭。
- **现状**:只读评估 82%;核心不变式已被代码强制。**生产者飞轮已端到端打通并活体证(2026-06-15)**:发布草稿→检查→提交→上架状态可视化→申诉(提交/补充材料/撤回)→申诉态回显(`MarketPublish`+`market-publish.spec.ts` 8/8,见[总账](../../docs/mvp/进度总账.md) §五C)。本会话新增 3 处 app-api 契约:`PublishRecordItem.marketAssetId`(物化资产 id,解申诉 assetId 映射 gap)、`GET /marketplace/appeals`(我的申诉列表)、记录 `appealStatus` 回填(`selectLatestByAssetIdAndUser`)。**2026-06-16 补真实 PG 验收证据**:`P1rMarketPublishProducerCompletedApprovalIT` 覆盖发布生产侧 5 op,真实 PG 3/3 绿;`P1rMarketAdminAppealCompletedApprovalIT` 覆盖管理端申诉 3 op(adminListAppeals/adminGetAppeal/adminResolveAppeal),真实 PG 6/6 绿;`P1rMarketAdminPublishReviewCompletedApprovalIT` 覆盖管理端发布审核 3 op(adminListPublishRequests/adminApprovePublishRequest/adminRejectPublishRequest),真实 PG 6/6 绿;`P1rMarketProducerAppealCompletedApprovalIT` 覆盖生产者申诉写路 3 op(submitAppeal/supplementAppeal/withdrawAppeal,app 入口 + owner 隔离 + 脱敏 + 状态 CAS + 冲突回滚),真实 PG 7/7 绿(独立复跑)。**2026-06-17 续补**:`P1rMarketAdminAssetReadsCompletedApprovalIT` 覆盖管理端资产治理读 2 op(adminListMarketAssets/adminGetMarketAsset:tenant 隔离 + assetType/listingStatus(not_listed↔draft 归一)/publisherId 过滤、详情资产快照 + 授权/安装/绑定/受影响任务计数全 0(关联表空)、缺资产/跨租户→MARKET_ASSET_NOT_EXISTS、API version/RBAC(muse:market:asset:query)fail-closed、纯读 no-write),真实 PG 2/2 绿(独立复跑)。`P1rMarketGovernanceWriteCompletedApprovalIT` 覆盖管理端资产治理写 3 op(adminPreviewGovernanceImpact/adminDelistAsset/adminRecallAsset:影响预览→凭预览下架 listed→delisted 的 expectedStatus CAS→凭预览召回 listed→recalled + 目标 owner 传播 fail-closed 如实记录 recalled/recorded/TARGET_OWNER_UNAVAILABLE;命令幂等、合同枚举 @Pattern→BAD_REQUEST、缺预览/CAS 阻断/缺资产/API version/RBAC fail-closed 0 写),真实 PG 4/4 绿(独立复跑)。`P1rMarketplaceDiscoveryReadsCompletedApprovalIT` 覆盖 marketplace 发现读 3 op(listMarketplaceAssets/listMarketplaceRecommendations/getGovernanceImpact:App 可见性隔离 listed 公开/非公开仅 publisher 自见 + assetType 过滤、fallback 推荐 + 理由、治理影响读 publisher/受影响者门禁 非属主无关联→MARKET_RESOURCE_FORBIDDEN、缺资产/API version fail-closed、纯读 no-write),真实 PG 4/4 绿(独立复跑)。`P1rMarketLicenseInstallCompletedApprovalIT` 覆盖采纳链 2 op(purchaseAsset/installMarketplaceAsset:购买写授权快照+购买事实+命令并跨 BC 同步 purchase/license 两条 Account 投影、安装要求已购授权+仅 agent/kb 可安装+写 installed 记录、命令幂等、未上架/缺资产/授权类型不符/无授权/作品不可安装/API version fail-closed 0 写),真实 PG 3/3 绿(独立复跑);证据已人工批准并翻 completed。`P1rMarketHandoffClusterCompletedApprovalIT` 覆盖 handoff 簇 4 op(createBindPrecheck/createMarketplaceHandoff/getHandoffStatus/cancelHandoff:已购 active 授权快照→来源授权摘要(handoffReady)→一次性 handoff token(明文不入库、只存 sha256 hash,事件快照不含明文)→只读回显 pending→expectedStatus CAS 取消(pending→cancelled)的端到端链;bind/handoff 回放幂等、cancel 终态后再取消被取消性守卫拒绝且不双写;非法 version/缺资产/无授权(授权≠可达)/目标 owner 白名单外(LocalMarketTargetOwnerFacade 本地目标页 fail-closed)/跨属主 token 读取与取消(属主隔离 RESOURCE_FORBIDDEN)/取消 CAS 期望态不匹配(reserveCommand MANDATORY 预占随事务回滚)失败路径 0 写),真实 PG 3/3 绿(独立复跑);至此 market needs_verification 28 op 全补 dedicated 真实 PG 证据,证据已人工批准并翻 completed。
- **2.0.0 S2 摘装配门禁机制(2026-07-07)**:`market-detached-test-tree` 只在显式 `-Dmuse.market.detached.test-tree=true` 时剥离 14 个 market/account 混合装配测试与 `P1rMarketRealApiGateTest`,默认本地构建仍保留全量测试树;`market-assembled` 可恢复全量回编译。覆盖门新增 market-only `dormant` 口径但本步不改 coverage JSON,S3 拨开关时 market 32 条可临时 dormant,summary completed 应为 `242-marketDormant`。dry-run 已在临时 worktree 验证:注释 market-server 依赖并将 market 32 条置 dormant 后,local 门禁 56/0F/0E/0S;主工作树 local 仍为 62/0F/0E/0S。
- **关键风险 / TODO**:① **资产物化只在 admin 审核通过(`markListed`)发生**——submit 阶段 `publish_request.asset_id=draft.id` 占位,故未上架资产无 `muse_market_asset` 行,申诉(`requireAsset`)仅对已物化(曾上架)资产可达;② 申诉证据材料附件上传(`attachmentIds` 当前前端置空);③ 上架后下架/召回的生产者自助入口;④ **market 验收债已清零(2026-06-17)**:批15-19 补 14 op(资产治理读 2 + 治理写 3 + marketplace 发现读 3 + 采纳链 purchase/install 2 + handoff 簇 4)叠加本战役前 14 op(发布生产 5 + 管理端申诉 3 + 发布审核 3 + 生产者申诉 3)= market needs_verification 28 op 全有 dedicated 真实 PG 证据;**台账 completed flip 与覆盖门禁批准集合已按人工批准同步,2026-06-19 已补 `testFiles` 证据锚点门禁**。⑤ **2026-06-25:install 用户可见假绿修复(isAcquired/isInstalled enrich,commit 37e7a8d)**——`MarketAssetQueryServiceImpl` `toCard`/`userActions` 此前把 isAcquired/isInstalled/canInstall 裸硬编 false(install 后端真实已验、但读模型不回填用户真实态),致 studio 安装按钮(`assetType!=='work' && isAcquired && !isInstalled`)真后端永不渲染、批17 IT 把 false 断言为正确(假绿固化)。修=仿 `isFavorite` enrich isAcquired(active 授权)/isInstalled(installation)/isInstallable,**与 install 写端口 `MarketInstallServiceImpl` 严格同源**(`selectActiveByOwnerAndAsset` + `INSTALLABLE_ASSET_TYPES`,避免"显示可装但点了 MARKET_LICENSE_NOT_EXISTS"新假绿);批量加载避放大 isFavorite 既有 N+1;批17 IT 改三态;前端补 onError;UI e2e `market-install.spec` 连跑 2 次。活体真验 purchase→isAcquired=T→install→isInstalled=T(详总账 2026-06-25 / memory muse-market-install-isacquired-enrich)。**教训:读模型 enrich 必须与写端口校验同源,否则显示态与可操作态背离=新假绿。** ⑥ **2026-06-27:E4 agent 物化 + KB 召回触达已补并真验**:Market 召回 `recallAsset` 写 `source_status_event` 后发布 `MarketAssetSourceStatusChangedEvent`,目标 owner 自治消费;agent handoff 只签 market token/assetId,不把发布者 agent id 交给客户端。Studio 真 e2e 覆盖 handoff-agent、market-kb-recall、market-asset-detail、governance-impact、handoff-precheck 6/0F/0E。⑦ **2026-06-27:E6 restore 分支真 PG IT 已补**:`P1rMarketAdminAppealCompletedApprovalIT` 扩到 7/0F/0E/0S,覆盖申诉 `restored` 分支凭治理预览恢复资产、preview consumed、asset relisted、restore governance action、command/event 幂等与快照 `impactPreviewId`。⑧ **2026-06-27 RC 后补治理/撤权申诉独立入口**:Studio 新增 `/market/appeals`,侧边栏“治理”和市场页可直达,消费 `GET /marketplace/appeals`、补充材料、撤回申诉真实 hook;该入口只处理发布者/使用者面对下架/召回/撤权结果后的申诉,不暴露管理员执行撤权/召回命令。验证:`useMarketPublish.test.tsx` 2/0F/0E、`MarketAppealCenter.test.tsx` 2/0F/0E、组合目标 Vitest 4 files / 11 tests passed、`tsc`/目标 ESLint 通过。仍缺:更完整生产者发布深面、申诉附件上传、KB 副本资源回收、下架/撤权更细补偿。

View File

@ -8,4 +8,5 @@
- **现状**:只读评估 68%。**2026-06-15 活体订正**:单体内 `MarketAccountProjectionFacade` 由 market 的 `MarketAccountProjectionProvider` 真实提供,purchases/licenses/publish-records 三端可读投影,不是 `*_UNAVAILABLE` 缺口。**2026-06-16 补真实 PG 验收证据**:`P1rAccountMarketRecordsCompletedApprovalIT` 覆盖 appListPurchases/appListLicenses/appListPublishRecords,真实 PG 3/3 绿;`P1rAccountQuotaRequestCompletedApprovalIT` 覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest(命令幂等+commandId 冲突回滚+owner/tenant 隔离+RBAC+类型白名单),真实 PG 5/5 绿(独立复跑);`P1rAccountUsageObservabilityCompletedApprovalIT` 覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation(用量按 owner 聚合 token/pending/anomaly/byModel+跨用户隔离+period 白名单、外部调用 app owner 守卫/admin 无 owner 限制+RBAC、三 op 纯读 no-write、缺用户/not-found/API version/RBAC fail-closed),真实 PG 4/4 绿(独立复跑);`P1rAccountNewApiBindingClusterCompletedApprovalIT` 覆盖 getAppNewApiBinding/adminListNewApiBindings/appRecheckNewApiBinding(绑定摘要 owner 读 + active→bound 归一、admin 列表以账户用户为分页主表 + EXISTS/NOT EXISTS 状态筛选 + RBAC、两读 no-write、recheck 单体 New-API 不可用正确 fail-closed:命令预占回滚但失败调用 REQUIRES_NEW 独立落库),真实 PG 4/4 绿(独立复跑);`P1rAccountExportDownloadClusterCompletedApprovalIT` 覆盖 appCreateExportTask/appGetExportTask/appDownloadExport(FileService 未接入时非敏感导出落 queued 任务+命令+审计、不伪造 completed/凭证、敏感类型缺 step-up fail-closed、命令幂等;get owner 读 + 跨 owner not found + no-write;download 凭证门禁 + FileService 不可用正确 fail-closed:@Transactional 回滚一次性消费、不写审计),真实 PG 4/4 绿(独立复跑);`P1rAccountAdminReadsCompletedApprovalIT` 覆盖 adminListUsageRecords/adminGetCallAttributionJob(用量以账户用户为分页主表库内聚合 token/pending/failed/attributionStatus、调用归因 job 按 jobId 读 job+items 归一状态+分桶计数、RBAC+API version fail-closed、纯读 no-write),真实 PG 3/3 绿(独立复跑);`P1rAccountAdminWritesCompletedApprovalIT` 覆盖 adminCreateNewApiBinding/adminCreateCallAttributionJob(binding 单体 New-API 不可用正确 fail-closed:命令回滚+失败调用 REQUIRES_NEW 独立落库+binding 不写;attribution job 基于已存在 correlationId 调用建 queued job+命令幂等+callIds 白名单/expectedCallRevision/缺调用记录/RBAC/API version fail-closed),真实 PG 3/3 绿(独立复跑);`P1rAccountSecurityEventAckCompletedApprovalIT` 覆盖 appAcknowledgeSecurityEvent(owner 校验事件归属 + 追加表写处理轨迹 + 基础事件 acknowledged 置真 + 命令幂等 + 审计;非法 action/跨 owner NOT_FOUND/非数字 eventId/缺用户/API version fail-closed 且 0 写),真实 PG 2/2 绿(独立复跑);`P1rAccountAdminPurchaseRecordsCompletedApprovalIT` 覆盖 adminListPurchaseRecords(跨模块 base-package=cn.iocoder.muse.module + 单体内 market 的 `MarketAccountProjectionProvider` 真实判定 purchase 投影可用,读端只读 Account 自有投影表 muse_account_record_projection;无 userId→跨用户全量、userId 过滤→owner 限定 + source_status→admin status 归一 + 资产信息从 title/snapshot 回填、非法 status/API version/RBAC(muse:account:query) fail-closed、纯读 no-write,Account 投影写端口 stub 越界反假绿),真实 PG 2/2 绿(独立复跑);证据已人工批准并翻 completed。
- **E5 member 写侧闭环(2026-06-27)**:AI 生成入口通过 `MuseAccountUsageApi` 预占配额,成功后写 usage record 并累加 quota used,失败终态释放预占;`AccountQuotaRequestWorker` 消费 queued quota request,短事务 claim/terminal、New-API 管理口调用在事务外执行,成功写 completed、失败写 failed + integration unavailable;`AccountAttributionWorker` 消费 queued attribution job,按 usage correlation 写 attribution item 并终态化。`RealNewApiAccountFacade` 已接 `/api/user/search`、`/api/user/`、`/api/user/{id}` 管理口,默认配置 fail-closed,显式 live 验证通过。fresh 证据:targeted 单测 120/0F/0E;account real-PG 16/0F/0E/0S;New-API 管理口 live 1/0F/0E/0S;AI runtime live 3/0F/0E/0S 反查 usage/quota/attribution 全落库。边界:workers 默认 disabled,生产启用需显式配置。
- **2026-06-27 RC 后补安全事件处理入口**:Studio 个人中心安全事件从单一“确认”扩为契约内 `acknowledged/password_changed/session_revoked/false_positive` 四类处理动作,写 `POST /account/security-events/{eventId}/acknowledge` 时带 commandId 和 note,成功后展示后端 `riskSummary/nextSteps` 并刷新列表。验证:Studio `useAccount.test.tsx` 5/0F/0E,组合目标 Vitest 4 files / 11 tests passed,`tsc -b --force` 与目标 ESLint 通过。边界:这不是完整改密/2FA/session service,会话吊销真实联动仍后置;偏好通知仍无 app 写契约,不伪造。
- **2.0.0 S2 market detached 测试树影响(2026-07-07)**:`P1rAccountAdminPurchaseRecordsCompletedApprovalIT` 与 `P1rAccountMarketRecordsCompletedApprovalIT` 依赖 market 单体装配投影,已随 `market-detached-test-tree` 显式 profile 一并剥离;默认构建不跳过,`market-assembled` 可恢复全量回编译。本步未改 Account 业务实现、coverage JSON 或 Account completed 口径,纯 Account gate 仍随 local 门禁运行。
- **关键风险 / TODO**:account needs_verification 验收债**已清零**:末位 adminListPurchaseRecords 经 `P1rAccountAdminPurchaseRecordsCompletedApprovalIT`(跨模块 base-package=cn.iocoder.muse.module + import MarketAccountProjectionProvider)真实 PG 2/2 绿补证;批3(市场记录读)+ 批7-14 account needs_verification op 全部补真实 PG 证据、证据已人工批准并翻 completed。截至 2026-06-17,content/market/account 三域 needs_verification 验收债均已补齐真实 PG 证据,coverage JSON completed flip 与覆盖门禁批准集合已按人工批准同步,2026-06-19 已补 `testFiles` 证据锚点门禁。前端仍有深页未全覆盖真实后端。

View File

@ -174,4 +174,90 @@
</plugins>
</build>
<profiles>
<profile>
<id>market-detached-test-tree</id>
<activation>
<property>
<name>muse.market.detached.test-tree</name>
<value>true</value>
</property>
</activation>
<build>
<plugins>
<!-- 2.0.0 S2 dry-run:显式启用时剥离依赖 market-server 装配态的测试树;S3 才会拨默认开关。 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<testExcludes>
<testExclude>**/P1rMarketAdminAppealCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketAdminAssetReadsCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketAdminPublishReviewCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketDiscoveryFavoriteCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketEventsPublishEndToEndTest.java</testExclude>
<testExclude>**/P1rMarketGovernanceWriteCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketHandoffClusterCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketKbForkMaterializationIT.java</testExclude>
<testExclude>**/P1rMarketLicenseInstallCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketProducerAppealCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketPublishProducerCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketplaceDiscoveryReadsCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rAccountAdminPurchaseRecordsCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rAccountMarketRecordsCompletedApprovalIT.java</testExclude>
<testExclude>**/P1rMarketRealApiGateTest.java</testExclude>
</testExcludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/P1rMarketAdminAppealCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketAdminAssetReadsCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketAdminPublishReviewCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketDiscoveryFavoriteCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketEventsPublishEndToEndTest.java</exclude>
<exclude>**/P1rMarketGovernanceWriteCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketHandoffClusterCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketKbForkMaterializationIT.java</exclude>
<exclude>**/P1rMarketLicenseInstallCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketProducerAppealCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketPublishProducerCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketplaceDiscoveryReadsCompletedApprovalIT.java</exclude>
<exclude>**/P1rAccountAdminPurchaseRecordsCompletedApprovalIT.java</exclude>
<exclude>**/P1rAccountMarketRecordsCompletedApprovalIT.java</exclude>
<exclude>**/P1rMarketRealApiGateTest.java</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>market-assembled</id>
<build>
<plugins>
<!-- 恢复/全量装配口径:即使同时带上 dry-run profile,也以全测试树为准。 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<testExcludes combine.self="override" />
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes combine.self="override" />
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@ -15,6 +15,7 @@ import java.util.Set;
import static java.util.Map.entry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-4 Task 10 AI 真实 API 覆盖门禁。
@ -34,6 +35,8 @@ class P1rAiRealApiGateTest {
"blocked"
);
private static final Set<String> APPROVED_MARKET_COMPLETION_STATUSES = Set.of("completed", "dormant");
private static final Map<String, String> AI_OPERATION_SIGNATURES = Map.ofEntries(
entry("adminListPrompts", "GET /admin-api/muse/ai/prompts"),
entry("adminCreatePromptVersion", "POST /admin-api/muse/ai/prompts/{promptKey}/versions"),
@ -127,8 +130,7 @@ class P1rAiRealApiGateTest {
void should_count_ai_and_knowledge_operations_as_completed() throws IOException {
JsonNode report = readReport();
assertEquals(242, report.path("summary").path("completedOperations").asInt(),
"completedOperations 必须覆盖 AI 48 + Knowledge 60 + Events 1 + Meta 16 + Account 33 + Market 32 + Content 52");
assertApprovedCompletedOperations(report);
}
@Test
@ -253,8 +255,9 @@ class P1rAiRealApiGateTest {
assertEquals(expectedCount, actualCount, domain + " operation 数量必须保持不变");
}
private void assertMarketStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException {
private void assertMarketStatusCount(int expectedApproved, int expectedNeedsVerification) throws IOException {
int completed = 0;
int dormant = 0;
int needsVerification = 0;
int total = 0;
for (JsonNode operation : readOperations()) {
@ -268,17 +271,36 @@ class P1rAiRealApiGateTest {
if ("completed".equals(operation.path("completionStatus").asText())) {
completed++;
}
if ("dormant".equals(operation.path("completionStatus").asText())) {
dormant++;
}
if ("needs_verification".equals(operation.path("completionStatus").asText())) {
needsVerification++;
}
assertEquals("completed", operation.path("completionStatus").asText(),
"market/" + operationId + " 已获用户批准后必须 completed");
String completionStatus = operation.path("completionStatus").asText();
assertTrue(APPROVED_MARKET_COMPLETION_STATUSES.contains(completionStatus),
"market/" + operationId + " 只能保持 completed,或在 S3 detach dry-run 中进入 dormant,实际为 "
+ completionStatus);
}
assertEquals(32, total, "Market operation 数量必须保持 32");
assertEquals(expectedCompleted, completed, "Market 人工批准后 completed 数量不符合预期");
assertEquals(expectedApproved, completed + dormant, "Market completed/dormant 批准态数量不符合预期");
assertEquals(expectedNeedsVerification, needsVerification, "Market 人工批准后 needs_verification 数量不符合预期");
}
private void assertApprovedCompletedOperations(JsonNode report) throws IOException {
int marketDormant = 0;
for (JsonNode operation : readOperations()) {
if ("market".equals(operation.path("domain").asText())
&& "dormant".equals(operation.path("completionStatus").asText())) {
marketDormant++;
}
}
assertEquals(242, report.path("summary").path("totalOperations").asInt(),
"totalOperations 必须保持 242");
assertEquals(242 - marketDormant, report.path("summary").path("completedOperations").asInt(),
"completedOperations 必须等于 242 扣除 S3 detach dry-run 中 dormant 的 Market operation");
}
private JsonNode readAiOperations() throws IOException {
ArrayNode aiOperations = objectMapper.createArrayNode();
for (JsonNode operation : readOperations()) {

View File

@ -47,6 +47,17 @@ class P1rApiCoverageReportTest {
"missing"
);
/** 已完成口径:只统计真正完成且仍处于活跃装配态的 operation。 */
private static final String COMPLETION_COMPLETED = "completed";
/** 2.0.0 单人版 dormant 口径:operation 保留在总量中,但不再计入 completed。 */
private static final String COMPLETION_DORMANT = "dormant";
/** 当前只允许 market 在摘装配期间进入 dormant,避免其它域绕过 completed 审批门。 */
private static final Set<String> APPROVED_DORMANT_DOMAINS = Set.of(
"market"
);
/** 已获用户批准、并有 P1R 外部端到端验收证据支撑的 completed 域。 */
private static final Set<String> APPROVED_COMPLETED_DOMAINS = Set.of(
"ai",
@ -316,7 +327,7 @@ class P1rApiCoverageReportTest {
JsonNode operations = readOperations();
for (JsonNode operation : operations) {
if (!"completed".equals(operation.path("completionStatus").asText())) {
if (!isCompletedOperation(operation)) {
continue;
}
String operationId = operation.path("operationId").asText("<missing-operationId>");
@ -328,6 +339,28 @@ class P1rApiCoverageReportTest {
}
}
/**
* 验证 dormant 只能用于已批准的摘装配域,且不允许借 dormant 绕过 dedicated 实现约束。
*
* @throws IOException 读取覆盖报告失败时抛出
*/
@Test
void should_only_mark_approved_dedicated_operations_as_dormant() throws IOException {
JsonNode operations = readOperations();
for (JsonNode operation : operations) {
if (!isDormantOperation(operation)) {
continue;
}
String operationId = operation.path("operationId").asText("<missing-operationId>");
assertTrue(APPROVED_DORMANT_DOMAINS.contains(operation.path("domain").asText())
&& isApprovedCompletedOperation(operation),
operationId + " 只有已批准摘装配域的 operation 可以标记为 dormant");
assertEquals("dedicated", operation.path("implementationStatus").asText(),
operationId + " 标记 dormant 时必须保留摘装配前的 dedicated 实现口径");
}
}
/**
* 验证 completed approval 只推进已批准的 Events SSE、Meta、Account、Market 与 Content operation,不连带推进其它 owner domain。
*
@ -339,17 +372,16 @@ class P1rApiCoverageReportTest {
int completed = 0;
for (JsonNode operation : operations) {
if ("completed".equals(operation.path("completionStatus").asText())) {
if (isCompletedOperation(operation)) {
completed++;
}
}
// P0 止血(2026-06-13):废弃硬编码 assertEquals(147, ...)。此前每次"收口"需人工把该常量与
// JSON 互相对着拨大,形成自证回路(verification theater)。改为校验"operations 实算的 completed 数
// == summary 自身声明的 completedOperations",防止只改其一造成台账自相矛盾,以实际 operations 为单一可信来源。
// P0 止血(2026-06-13):废弃硬编码 assertEquals(147, ...)。2.0.0 S2 继续把 dormant 从 completed
// 派生口径中排除:summary.totalOperations 仍代表总合同数,summary.completedOperations 只代表非 dormant 的 completed。
int declaredCompleted = objectMapper.readTree(findReportPath().toFile())
.path("summary").path("completedOperations").asInt(-1);
assertEquals(declaredCompleted, completed,
"summary.completedOperations 必须与 operations 实算的 completed 数一致(防手工拨动台账)");
"summary.completedOperations 必须与 operations 中非 dormant 的 completed 数一致");
assertOperationStatus("events", "streamEvents", "dedicated", "completed");
for (String operationId : APPROVED_META_SCHEMA_COMPLETED_OPERATIONS) {
@ -362,15 +394,15 @@ class P1rApiCoverageReportTest {
assertOperationStatus("account", operationId, "dedicated", "completed");
}
for (String operationId : APPROVED_MARKET_COMPLETED_OPERATIONS) {
assertOperationStatus("market", operationId, "dedicated", "completed");
assertOperationStatusIn("market", operationId, "dedicated", Set.of("completed", "dormant"));
}
for (String operationId : APPROVED_CONTENT_COMPLETED_OPERATIONS) {
assertOperationStatus("content", operationId, "dedicated", "completed");
}
assertOperationStatus("market", "listMarketplaceAssets", "dedicated", "completed");
assertOperationStatus("market", "listMarketplaceRecommendations", "dedicated", "completed");
assertOperationStatus("market", "favoriteAsset", "dedicated", "completed", true);
assertOperationStatus("market", "unfavoriteAsset", "dedicated", "completed", true);
assertOperationStatusIn("market", "listMarketplaceAssets", "dedicated", Set.of("completed", "dormant"));
assertOperationStatusIn("market", "listMarketplaceRecommendations", "dedicated", Set.of("completed", "dormant"));
assertOperationStatusIn("market", "favoriteAsset", "dedicated", Set.of("completed", "dormant"), true);
assertOperationStatusIn("market", "unfavoriteAsset", "dedicated", Set.of("completed", "dormant"), true);
assertOperationStatus("account", "appListSecurityEvents", "dedicated", "completed");
assertOperationStatus("account", "appGetSecurityEvent", "dedicated", "completed");
assertOperationStatus("account", "appAcknowledgeSecurityEvent", "dedicated", "completed", true);
@ -385,7 +417,7 @@ class P1rApiCoverageReportTest {
// P1 加固(2026-06-14):废弃硬编码 needs_verification 魔法数(28/21/37,与脊柱规则禁的"可拨动常量"同模式),
// 改为断言"该域 completed 数 == 显式批准集大小"——派生自唯一可信来源 APPROVED_*_COMPLETED_OPERATIONS。
// 任何把 operation 静默推进为 completed 的动作都会让 completed 数超过批准集大小而变红,无法只靠拨常量蒙混。
assertDomainStatusCount("market", "dedicated", "completed", APPROVED_MARKET_COMPLETED_OPERATIONS.size());
assertDomainCompletedOrDormantCount("market", "dedicated", APPROVED_MARKET_COMPLETED_OPERATIONS);
assertDomainStatusCount("account", "dedicated", "completed", APPROVED_ACCOUNT_COMPLETED_OPERATIONS.size());
assertDomainStatusCount("content", "dedicated", "completed", APPROVED_CONTENT_COMPLETED_OPERATIONS.size());
}
@ -415,7 +447,7 @@ class P1rApiCoverageReportTest {
void should_back_completed_operations_with_existing_source_files() throws IOException {
Path repoRoot = findRepoRoot();
for (JsonNode operation : readOperations()) {
if (!"completed".equals(operation.path("completionStatus").asText())) {
if (!isCompletedOperation(operation)) {
continue;
}
String operationId = operation.path("operationId").asText("<missing-operationId>");
@ -444,7 +476,10 @@ class P1rApiCoverageReportTest {
Path repoRoot = findRepoRoot();
for (JsonNode operation : readOperations()) {
String operationId = operation.path("operationId").asText("<missing-operationId>");
if (!"completed".equals(operation.path("completionStatus").asText())) {
if (isDormantOperation(operation)) {
continue;
}
if (!isCompletedOperation(operation)) {
assertEquals(0, operation.path("testFiles").size(),
operationId + " 未完成 operation 不应引用 testFiles 证据");
continue;
@ -499,6 +534,38 @@ class P1rApiCoverageReportTest {
throw new AssertionError(domain + ":" + operationId + " 必须存在于 P1R coverage report");
}
private void assertOperationStatusIn(String domain, String operationId, String implementationStatus,
Set<String> completionStatuses) 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 不符合预期");
assertTrue(completionStatuses.contains(operation.path("completionStatus").asText()),
operationId + " completionStatus 必须属于 " + completionStatuses);
return;
}
}
throw new AssertionError(domain + ":" + operationId + " 必须存在于 P1R coverage report");
}
private void assertOperationStatusIn(String domain, String operationId, String implementationStatus,
Set<String> completionStatuses, 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 不符合预期");
assertTrue(completionStatuses.contains(operation.path("completionStatus").asText()),
operationId + " completionStatus 必须属于 " + completionStatuses);
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;
@ -512,10 +579,42 @@ class P1rApiCoverageReportTest {
assertEquals(expected, count, domain + " completed 数量必须与人工批准集合一致");
}
private void assertDomainCompletedOrDormantCount(String domain, String implementationStatus,
Set<String> approvedOperationIds) throws IOException {
int completed = 0;
int dormant = 0;
for (JsonNode operation : readOperations()) {
if (!domain.equals(operation.path("domain").asText())
|| !implementationStatus.equals(operation.path("implementationStatus").asText())) {
continue;
}
String operationId = operation.path("operationId").asText();
if (isCompletedOperation(operation)) {
completed++;
assertTrue(approvedOperationIds.contains(operationId),
operationId + " 未进入批准集,不能标记 completed");
} else if (isDormantOperation(operation)) {
dormant++;
assertTrue(approvedOperationIds.contains(operationId),
operationId + " 未进入批准集,不能标记 dormant");
}
}
assertEquals(approvedOperationIds.size(), completed + dormant,
domain + " operation 必须处于 completed 或 dormant 之一,且数量必须与人工批准集合一致");
}
private static String operationKey(JsonNode operation) {
return operation.path("domain").asText() + ":" + operation.path("operationId").asText();
}
private static boolean isCompletedOperation(JsonNode operation) {
return COMPLETION_COMPLETED.equals(operation.path("completionStatus").asText());
}
private static boolean isDormantOperation(JsonNode operation) {
return COMPLETION_DORMANT.equals(operation.path("completionStatus").asText());
}
private static boolean isApprovedCompletedOperation(JsonNode operation) {
String domain = operation.path("domain").asText();
String operationId = operation.path("operationId").asText();

View File

@ -12,6 +12,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-7a Task 8 Events 真实 API 覆盖门禁。
@ -38,6 +39,8 @@ class P1rEventsRealApiGateTest {
"missing"
);
private static final Set<String> APPROVED_MARKET_COMPLETION_STATUSES = Set.of("completed", "dormant");
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@ -79,10 +82,10 @@ class P1rEventsRealApiGateTest {
@Test
void should_keep_completed_approval_summary_at_approved_operation_boundary() throws IOException {
JsonNode summary = readReport().path("summary");
JsonNode report = readReport();
JsonNode summary = report.path("summary");
assertEquals(242, summary.path("completedOperations").asInt(),
"completedOperations 必须覆盖 AI 48 + Knowledge 60 + Events 1 + Meta 16 + Account 33 + Market 32 + Content 52");
assertApprovedCompletedOperations(report);
assertEquals(0, summary.path("needsVerificationOperations").asInt(),
"用户批准后 needsVerificationOperations 必须清零");
assertEquals(0, summary.path("incompleteOperations").asInt(),
@ -97,6 +100,7 @@ class P1rEventsRealApiGateTest {
void should_keep_all_market_operations_completed_after_approval() throws IOException {
JsonNode marketOperations = readMarketOperations();
int completed = 0;
int dormant = 0;
int needsVerification = 0;
assertEquals(EXPECTED_MARKET_OPERATION_COUNT, marketOperations.size(),
@ -109,18 +113,23 @@ class P1rEventsRealApiGateTest {
if ("completed".equals(operation.path("completionStatus").asText())) {
completed++;
}
if ("dormant".equals(operation.path("completionStatus").asText())) {
dormant++;
}
if ("needs_verification".equals(operation.path("completionStatus").asText())) {
needsVerification++;
}
assertEquals("completed", operation.path("completionStatus").asText(),
"Market/" + operationId + " 已获用户批准后必须 completed");
String completionStatus = operation.path("completionStatus").asText();
assertTrue(APPROVED_MARKET_COMPLETION_STATUSES.contains(completionStatus),
"Market/" + operationId + " 只能保持 completed,或在 S3 detach dry-run 中进入 dormant,实际为 "
+ completionStatus);
}
assertEquals(32, completed, "Market 人工批准后必须有 32 个 operation completed");
assertEquals(32, completed + dormant, "Market completed/dormant 批准态数量必须合计 32");
assertEquals(0, needsVerification, "Market 人工批准后不能继续保留 needs_verification");
assertMarketOperationStatus("listMarketplaceAssets", "completed", false);
assertMarketOperationStatus("listMarketplaceRecommendations", "completed", false);
assertMarketOperationStatus("favoriteAsset", "completed", true);
assertMarketOperationStatus("unfavoriteAsset", "completed", true);
assertMarketOperationStatus("listMarketplaceAssets", false);
assertMarketOperationStatus("listMarketplaceRecommendations", false);
assertMarketOperationStatus("favoriteAsset", true);
assertMarketOperationStatus("unfavoriteAsset", true);
}
@Test
@ -193,14 +202,15 @@ class P1rEventsRealApiGateTest {
return readOperationsByDomain("content");
}
private void assertMarketOperationStatus(String operationId, String expectedCompletionStatus,
boolean expectedRequiresCommandId) throws IOException {
private void assertMarketOperationStatus(String operationId, 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 不符合本轮审批边界");
String completionStatus = operation.path("completionStatus").asText();
assertTrue(APPROVED_MARKET_COMPLETION_STATUSES.contains(completionStatus),
"Market/" + operationId + " 只能保持 completed,或在 S3 detach dry-run 中进入 dormant,实际为 "
+ completionStatus);
assertEquals(expectedRequiresCommandId, operation.path("requiresCommandId").asBoolean(),
"Market/" + operationId + " requiresCommandId 不符合 OpenAPI 合同");
return;
@ -208,6 +218,20 @@ class P1rEventsRealApiGateTest {
throw new AssertionError("Market/" + operationId + " 必须存在于 coverage report");
}
private void assertApprovedCompletedOperations(JsonNode report) throws IOException {
int marketDormant = 0;
for (JsonNode operation : readOperations()) {
if ("market".equals(operation.path("domain").asText())
&& "dormant".equals(operation.path("completionStatus").asText())) {
marketDormant++;
}
}
assertEquals(242, report.path("summary").path("totalOperations").asInt(),
"totalOperations 必须保持 242");
assertEquals(242 - marketDormant, report.path("summary").path("completedOperations").asInt(),
"completedOperations 必须等于 242 扣除 S3 detach dry-run 中 dormant 的 Market operation");
}
private JsonNode readOperationsByDomain(String domain) throws IOException {
ArrayNode matchingOperations = objectMapper.createArrayNode();
for (JsonNode operation : readOperations()) {

View File

@ -37,6 +37,8 @@ class P1rKnowledgeRealApiGateTest {
"blocked"
);
private static final Set<String> APPROVED_MARKET_COMPLETION_STATUSES = Set.of("completed", "dormant");
/** P1R-5 固定的 Knowledge operation、方法和路径清单。 */
private static final Map<String, String> KNOWLEDGE_OPERATION_SIGNATURES = Map.ofEntries(
entry("listKnowledgeDraftsGovernance", "GET /admin-api/muse/knowledge/drafts"),
@ -160,8 +162,7 @@ class P1rKnowledgeRealApiGateTest {
void should_count_ai_and_knowledge_operations_as_completed() throws IOException {
JsonNode report = readReport();
assertEquals(242, report.path("summary").path("completedOperations").asInt(),
"completedOperations 必须覆盖 AI 48 + Knowledge 60 + Events 1 + Meta 16 + Account 33 + Market 32 + Content 52");
assertApprovedCompletedOperations(report);
}
@Test
@ -264,8 +265,9 @@ class P1rKnowledgeRealApiGateTest {
assertEquals(expectedNeedsVerification, needsVerification, "Account 人工批准后 needs_verification 数量不符合预期");
}
private void assertMarketStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException {
private void assertMarketStatusCount(int expectedApproved, int expectedNeedsVerification) throws IOException {
int completed = 0;
int dormant = 0;
int needsVerification = 0;
int total = 0;
for (JsonNode operation : readOperations()) {
@ -279,17 +281,36 @@ class P1rKnowledgeRealApiGateTest {
if ("completed".equals(operation.path("completionStatus").asText())) {
completed++;
}
if ("dormant".equals(operation.path("completionStatus").asText())) {
dormant++;
}
if ("needs_verification".equals(operation.path("completionStatus").asText())) {
needsVerification++;
}
assertEquals("completed", operation.path("completionStatus").asText(),
"market/" + operationId + " 已获用户批准后必须 completed");
String completionStatus = operation.path("completionStatus").asText();
assertTrue(APPROVED_MARKET_COMPLETION_STATUSES.contains(completionStatus),
"market/" + operationId + " 只能保持 completed,或在 S3 detach dry-run 中进入 dormant,实际为 "
+ completionStatus);
}
assertEquals(32, total, "Market operation 数量必须保持 32");
assertEquals(expectedCompleted, completed, "Market 人工批准后 completed 数量不符合预期");
assertEquals(expectedApproved, completed + dormant, "Market completed/dormant 批准态数量不符合预期");
assertEquals(expectedNeedsVerification, needsVerification, "Market 人工批准后 needs_verification 数量不符合预期");
}
private void assertApprovedCompletedOperations(JsonNode report) throws IOException {
int marketDormant = 0;
for (JsonNode operation : readOperations()) {
if ("market".equals(operation.path("domain").asText())
&& "dormant".equals(operation.path("completionStatus").asText())) {
marketDormant++;
}
}
assertEquals(242, report.path("summary").path("totalOperations").asInt(),
"totalOperations 必须保持 242");
assertEquals(242 - marketDormant, report.path("summary").path("completedOperations").asInt(),
"completedOperations 必须等于 242 扣除 S3 detach dry-run 中 dormant 的 Market operation");
}
private void assertMetaStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException {
int completed = 0;
int needsVerification = 0;