test(p1r): 收口 Market 事件传播真实链路门禁

This commit is contained in:
zizi 2026-06-07 22:28:54 +08:00
parent b36b153b96
commit e8ed7d7ae0
11 changed files with 3065 additions and 2 deletions

View File

@ -41,3 +41,23 @@ P1R-7c implementation fresh review 派发结果Sagan correctness review PASS
P1R-7c 修复后 fresh review 第一轮Kuhn correctness/spec review PASSHerschel data integrity review PASS均明确 PASS 只代表 `needs_verification` evidence 可收口,不代表 Events/P1R-7/Knowledge/Market completed。Herschel 提出两个 P2 非阻塞建议:其一,`singleLiteralCheck` 只做 substring未来可能误接受 `event_type='notification' OR ...`其二worker 对终态 update 返回 0 缺少 stale claim 诊断日志。已按 `receiving-code-review` 验证为有效且局部可修,并按 TDD 处理:新增 `P1rKnowledgeEventsPublishFlywayMigrationIT.should_rejectBroadSingleLiteralConstraintExpression` 先 RED随后将 `singleLiteralCheck` 改为精确单表达式匹配;新增真实 PostgreSQL 负向 insert 约束探针,验证 event_type、notification_type、publish_status、target_owner_user_id check 真实拒绝非法行;新增 `MuseKnowledgeEventPublishWorkerTest.should_logWhenAcceptedTerminalUpdateIsSkippedByStaleClaim` 先 RED随后 worker 对 `markPublished/markRetryable/markDeadLetter` 返回 0 记录包含 tenantId、targetOwnerUserId、outboxId、sourceEventId、attempt、targetStatus、errorCode 的 warning。RED/GREEN 事实:两个新增测试均先按预期失败;修复后 Flyway 纯函数测试 1/1 passworker stale-claim 日志测试 1/1 pass。一次并行 Maven 验证因两个命令同时触碰 Knowledge `target`,出现源码/target class 瞬时不一致导致 testCompile 找不到已存在类;串行重跑 worker 测试通过,故该失败判定为并发构建污染,不作为代码缺陷。由于 P2 修复包含生产 worker 日志行为变更与 Flyway IT 测试增强Kuhn/Herschel 双 PASS 不能作为最后一笔 review下一步必须重跑 focused/mixed/Flyway/coverage/protected diff并重新派发窄口 fresh implementation review。仍不得修改 OpenAPI/scanner/coverage report不得推进 completed。
P1R-7c P2 增强后最终收口controller 已重跑 full reactor `mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true`62/62 modules success随后串行重跑 Knowledge focused tests 15/15 pass、P1R mixed executable gates 37/37 pass、`P1rKnowledgeEventsPublishFlywayMigrationIT` 4/4 passV18 输出 `flyway_success=true`、`migrations_executed=18`、`target_schema_version=18`、`v18_table=muse_knowledge_event_publish_outbox`coverage scanner 仅在 `/tmp/p1r7c-final-coverage-scan.2HXo1Z` 隔离副本运行 exit 0summary 保持 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0Events `streamEvents=dedicated/needs_verification`Market 32 个 operation 均为 `dedicated/needs_verification`;真实 worktree 受保护 OpenAPI/scanner/coverage report diff 为空。P2 后 fresh review 已双 PASSRawls correctness/spec review PASSBoole data-integrity/testing review PASS两者均明确该结论只代表 Knowledge source owner propagation evidence 收口到 `needs_verification`,不代表 Events/P1R-7/Knowledge/Market completed。下一步可按逻辑分组准备提交提交、push 或任何 completed 推进仍需用户明确批准。
P1R-7c 已按逻辑拆分提交并 push 到 `origin/dev/1.0.0``cef686b feat(p1r): 接入 Knowledge 事件传播真实链路`、`e55618f test(p1r): 收口 Knowledge 事件传播真实链路门禁`。当前分支已与远端对齐到 `e55618f`worktree 干净protected OpenAPI/scanner/coverage report diff 为空。下一主线进入 P1R-7d Market Source Owner Propagation 审阅版,目标只做 Market governance/source status 的用户可见 Events notification 方案审阅,不实现代码、不修改 OpenAPI/scanner/coverage、不推进 completed。只读盘点事实Events OpenAPI 已声明 `notification/governance_action``AdminMarketGovernanceServiceImpl` 在 `delistAsset`/`recallAsset` 成功后写 `muse_market_governance_action``recallAsset` 还写 `muse_market_source_status_event` 且 `needs_recheck` 只进入 actionPolicy/recheckReasons`MarketAccountProjectionOutboxService` 当前只表达 Market -> Member Account 投影同步状态,没有 Events publish claim/retry/dead_letter workerhandoff 涉及 token hash、授权摘要和目标 owner不进入第一切片。P1R-7d 审阅版已新增 `docs/agent-specs/2026-06-07-P1R7dMarketSourceOwnerPropagation审阅版.md`,推荐新增独立 Market Events publish outbox复用 Events 现有 `notification/governance_action`,禁止复用 Account projection outbox禁止发布授权快照、handoff token、管理员内部备注原文、完整 actionSnapshot 或 target owner 私有事实。下一步必须 fresh spec compliance review + fresh quality/feasibility review双 PASS 后才允许写执行版。
P1R-7d 审阅版 fresh review 已双 PASSPtolemy spec compliance PASS、Galileo quality/feasibility PASS均无 P0/P1/P2 blocker。P3 已纳入执行版:`recall` 第一切片只发布一条 `market_governance_action` notification`muse_market_source_status_event` 只作为事实旁证和后续扩展点;`resourceRef.resourceId` 必须是数值型command replay 不补历史 outbox实现必须由 fresh implementer 执行,完成后再做 fresh spec review 与 fresh quality/feasibility review。P1R-7d 执行版已新增 `docs/agent-specs/2026-06-07-P1R7dMarketSourceOwnerPropagation执行版.md`,锁定 V19 `muse_market_event_publish_outbox`、Market server 只依赖 events-api、worker 默认 disabled、claim attempt ownership、stale terminal update 日志、focused/mixed/Flyway `_test`/隔离 coverage scanner/protected diff gate。当前仍没有实现代码改动没有 OpenAPI/scanner/coverage report 改动,不得推进 Events/P1R-7/Market completed。下一步必须 fresh execution spec compliance review + fresh execution quality/feasibility review双 PASS 前不能派发实现或提交。
P1R-7d 执行版首轮 quality/feasibility reviewFaradayFAILP1 blocker 已验证有效worktree 根目录没有 `pom.xml`reactor POM 位于 `muse-cloud/pom.xml`,执行版最终验证命令若在 worktree 根直接运行 Maven/Flyway 会失败。已修订执行版Task 0 明确 Git/protected diff/coverage scanner 在 worktree 根执行Maven/Flyway 必须先 `cd muse-cloud`;最终验证 1-4 步命令均补 `cd muse-cloud`;同时补充 `EventsPublishReqDTO.resourceId` 仍为字符串,`payloadSummary.resourceRef.resourceId` 必须保持 Number/Long 语义并分别测试。旧 spec reviewerAvicenna已关闭首轮 review 不计入 PASS gate。由于执行版已修改下一步必须重新派发 fresh execution spec compliance review + fresh execution quality/feasibility review双 PASS 前不能派发实现、提交或推进 completed。
P1R-7d 执行版第二轮 quality/feasibility reviewHumeFAIL两个 P1 与一个 P2 已验证有效Worker `CommonResult error` 路径虽在失败路径合同中要求 retryable/dead_letter但 Task 6 未要求测试payload 禁发字段列表比 Task 6 负断言更宽,缺 exact allowlist 测试;最终验证命令用连续 `cd muse-cloud` 代码块时可能在同一 shell 下目录漂移。旧 spec reviewerLovelace已关闭第二轮 review 不计入 PASS gate。执行版已修订payload 顶层 exact allowlist 固定为 `type/message/resourceRef/timestamp``resourceRef` exact allowlist 固定为 `resourceType/resourceId`Task 6 补齐 authorization snapshot / handoff token / target owner / admin note / error stack / requestHash 负断言Task 6 补 CommonResult error 非最后一次 -> retryable、最后一次 -> dead_letter并要求 lastError 只保存安全摘要V19 migration 清单和 SQL gate 补 update_time trigger最终验证 1-4 步改成绝对路径 `cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud && ...`。由于执行版再次修改,下一步必须第三轮 fresh execution spec compliance review + fresh execution quality/feasibility review双 PASS 前不能派发实现、提交或推进 completed。
P1R-7d 执行版第三轮 spec compliance reviewCiceroFAILP1 blocker 已验证有效:全局执行版要求每个 owner 子计划必须包含 Maven dependency tree 验证命令,当前 P1R-7d 执行版只包含 dependency test 和 mixed gate缺 dependency tree 原始证据命令。第三轮 feasibility reviewerBacon已关闭不计入 PASS gate。执行版已修订最终验证新增 Dependency tree gate要求 Market server dependency tree 明确包含 `muse-module-events-api`,证明 Market server 不依赖 `muse-module-events-server`、Events server 不反向依赖 AI/Knowledge/Market/Member/Content server命令将原始 tree 输出写入 `/tmp/p1r7d-market-server-dependency-tree.txt` 与 `/tmp/p1r7d-events-server-dependency-tree.txt`,再用 `rg` 正向/反向断言,避免 Maven 失败被管道吞掉;完成条件同步补 `dependency tree` gate。由于执行版再次修改下一步必须第四轮 fresh execution spec compliance review + fresh execution quality/feasibility review双 PASS 前不能派发实现、提交或推进 completed。
P1R-7d 执行版第四轮 spec compliance reviewMendelFAILP1/P2 已验证有效:执行版 mixed gate 未继承审阅版要求的 AI 已有 source owner gates缺 `P1rAiEventsPublishMigrationSqlTest` / `P1rAiEventsPublishDependencyTest` / `P1rAiEventsPublishEndToEndTest`payload invalid fail-closed 缺固定错误码;最终 Protected diff 步骤未内联命令。第四轮 feasibility reviewerHuygens已关闭不计入 PASS gate。执行版已修订mixed gate 列表与最终命令补 AI EventsPublish 三个 gate并保留 Knowledge EventsPublish gatespayload invalid 固定错误码为 `MARKET_EVENTS_PAYLOAD_INVALID`Task 6 要求断言只进入安全日志或安全 lastError 摘要、不创建 outbox、不发布Protected diff 步骤内联受保护 OpenAPI/scanner/coverage report diff 命令。由于执行版再次修改,下一步必须第五轮 fresh execution spec compliance review + fresh execution quality/feasibility review双 PASS 前不能派发实现、提交或推进 completed。
P1R-7d 执行版第五轮 reviewfresh spec compliance reviewerNewtonPASS但 fresh quality/feasibility reviewerPoincareFAILNewton PASS 因后续文档修订失效,不能计入最终 PASS gate。Poincare 的 P1 已验证有效:最终验证命令带 `surefire.failIfNoSpecifiedTests=false`,但缺新增 Market 测试类存在性断言可能漏建或拼错仍空跑通过。P3 同步处理Protected diff 改为失败型 `git diff --quiet -- ...`V19 必须字段补 `creator/updater`,沿用 `TenantBaseDO/BaseDO` 审计列约定。执行版已修订:最终验证 Market focused tests 前增加六个新增测试文件 `test -f` 断言,覆盖 `MarketEventPublishOutboxServiceTest`、`MarketEventPublishWorkerTest`、`P1rMarketEventsPublishMigrationSqlTest`、`P1rMarketEventsPublishDependencyTest`、`P1rMarketEventsPublishEndToEndTest`、`P1rMarketEventsPublishFlywayMigrationIT`Protected diff 改为 `git diff --quiet --`。由于执行版再次修改,下一步必须第六轮 fresh execution spec compliance review + fresh execution quality/feasibility review双 PASS 前不能派发实现、提交或推进 completed。
P1R-7d 执行版第五轮后加固:为彻底关闭新增测试空跑风险,最终验证新增 Surefire XML 计数断言,要求 `MarketEventPublishOutboxServiceTest`、`MarketEventPublishWorkerTest`、`P1rMarketEventsPublishMigrationSqlTest`、`P1rMarketEventsPublishDependencyTest`、`P1rMarketEventsPublishEndToEndTest`、`P1rMarketEventsPublishFlywayMigrationIT` 均生成 XML且 `tests>0`、failures/errors/skipped 均为 0最终验证也内联 `/tmp/p1r7d-coverage-scan.*` 隔离 coverage scanner 命令。由于执行版再次修改,下一步仍必须第六轮 fresh execution spec compliance review + fresh execution quality/feasibility review双 PASS 前不能派发实现、提交或推进 completed。
P1R-7d 执行版第六轮 fresh review 已双 PASSLaplace spec compliance PASS、McClintock quality/feasibility PASS均无 blocker。Laplace 已验证当前执行版覆盖 P1R-7d scope、recall 单 notification、payload allowlist、`MARKET_EVENTS_PAYLOAD_INVALID` fail-closed、worker 状态机、V19 审计列、dependency tree、Surefire XML 计数、隔离 coverage scanner 和 protected diff gateMcClintock 已验证 Market governance action 同事务 outbox、EventsPublishApi/SSE 可见链路、DTO String resourceId 与 payload numeric resourceRef、V19/worker/idempotency/anti-fake-green gate 可落地。该双 PASS 只代表执行版可进入 fresh implementer不代表 P1R-7d 实现完成,也不代表 Events/P1R-7/Market completed。下一步允许派发 fresh implementer 按执行版实现;实现后必须重新进行 fresh spec review + fresh quality/feasibility review双 PASS 前不得提交、push 或推进 completed。
P1R-7d implementation evidence 当前状态Market source owner propagation 主体实现、V19 SQL、focused tests、P1R mixed executable gates、V19 Flyway `_test`、dependency tree gate、full reactor build、隔离 coverage scanner 与 protected diff gate 已落地,修复后 fresh implementation review 已双 PASS但仍只收口到 `needs_verification`,不能推进 Events/P1R-7/Market completed。新增实现包括 Market server 直接依赖 `muse-module-events-api`、`MuseMarketEventPublishOutboxDO/Mapper`、`MarketEventPublishOutboxService/Impl`、`MarketEventPayloads` exact allowlist 与安全 message allowlist、`MarketEventPublishWorker`、`MuseMarketEventsProperties/Configuration` 默认 `muse.market.events.publish-worker.enabled=false`、配置化 `max-attempt` 与 `claim-timeout-seconds`、`V19__extend_market_events_publish_outbox.sql`,以及 `AdminMarketGovernanceServiceImpl.applyGovernanceAction` 在治理动作落库后同事务创建 Market Events publish outbox。Pasteur fresh spec review FAIL 的有效问题已修复V19/DO/Mapper 补齐 `source_command_id`、`actor_user_id`、`preview_id`、`request_hash`,并将 `commandId/outboxId` 幂等 key 改为 `tenantId|actionId|ownerUserId|governance_action`Kant fresh quality review FAIL 的有效问题已修复Flyway IT `baseInsertSql()` 补齐四个 NOT NULL 字段payload validator 收紧为 safe message allowlistTesla fresh spec review FAIL 的有效问题已修复outbox `maxAttempt` 与 worker `claimTimeoutSeconds` 使用配置合同payload timestamp 使用 governance action `createTime`缺失时才回退当前时间。Volta quality/data-integrity review 曾 PASS但 PASS 发生在 Tesla 修复前,已因后续代码和测试变化失效。验证 evidencefull reactor `mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true` 为 62/62 modules successFinished at `2026-06-07T20:36:27+08:00`Market focused tests `AdminMarketGovernanceServiceTest,MarketEventPublishOutboxServiceTest,MarketEventPublishWorkerTest` 为 28/28 passsurefire XML 为 11/6/11一次旧类名 P1R mixed 命令只实际运行 29 tests已丢弃不计入最终证据修正后 P1R mixed executable gates 为 56/56 pass包含 Market 3/2/4、Knowledge、AI 与 Events 既有 gate`P1rMarketEventsPublishFlywayMigrationIT` 为 4/4 pass输出 `flyway_success=true`、`migrations_executed=19`、`target_schema_version=19`、`v19_table=muse_market_event_publish_outbox`,并验证 V19 表、索引、约束、trigger、四个补齐 required columns 与非法 insert 拒绝dependency tree gate 证明 Market server 有 `muse-module-events-api` 且无 `muse-module-events-server`Events server 无 AI/Knowledge/Market/Member/Content servercoverage scanner 仅在 `/tmp/p1r7d-final-coverage-scan.nhBVhM` 隔离副本运行 exit 0summary 保持 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0Events `streamEvents=dedicated/needs_verification`Market 32 个 operation 均为 `dedicated/needs_verification`;真实 worktree 受保护 OpenAPI/scanner/coverage report diff 为空,`git diff --check` exit 0。修复后 fresh spec/correctness reviewPascalPASS无 P0/P1/P2 blocker修复后 fresh quality/data-integrity/testing reviewMillPASS无 P0/P1/P2 blocker。残余非阻塞风险claim SQL 未在领取阶段过滤 `attempt_count < max_attempt` 但 worker 会 claim 后转 dead_letterE2E 仍是 focused harness可后续把 required fields pre-insert 校验前移到 service、收窄 `insertIgnore` conflict target、统一 emittedAt 与 payload timestamp。memory 已更新:`docs/memorys/2026-06-07-P1R7dMarketSourceOwnerPropagation真实链路.md`。下一步可按逻辑分组准备提交但提交、push 或任何 completed 推进仍需用户明确批准;提交前继续确认 protected diff、`git diff --check` 和 `git status --short --branch`。

View File

@ -0,0 +1,204 @@
# P1R7d Market Source Owner Propagation 审阅版
## 结论
推荐进入 P1R-7d但第一切片只覆盖 Market governance / source status 的用户可见通知,不覆盖 purchase、install、handoff、Account projection 同步或目标 owner 授权消费。
推荐方案:
1. 选择 `AdminMarketGovernanceServiceImpl` 已落库的 `muse_market_governance_action` / `muse_market_source_status_event` 作为 P1R-7d 第一批 source fact。
2. 新增 Market 本域 Events publish outbox / worker调用 `EventsPublishApi`,发布 Events 已声明的 `notification/governance_action`
3. 不复用 `MarketAccountProjectionOutboxService` 作为 Events publish outbox它只表达 Market -> Member Account 投影同步状态。
4. 不修改 OpenAPI、scanner 或 coverage report不推进 Events / P1R-7 / Market completed。
```mermaid
flowchart LR
Admin["Admin governance command<br/>delist / recall"] --> Fact["Market source fact<br/>governance_action / source_status_event"]
Fact --> Outbox["Market events publish outbox<br/>queued / running / retryable / published / dead_letter"]
Outbox --> Worker["Market worker<br/>claim + retry + audit log"]
Worker --> Api["EventsPublishApi<br/>events-api only"]
Api --> Unified[("muse_unified_event<br/>notification / governance_action")]
Unified --> SSE["/app-api/muse/events<br/>publisher visible"]
```
本审阅版不实现代码,只冻结目标、非目标、取舍、影响范围、风险和验收标准。执行版、实现和提交必须在本审阅版 fresh spec review + fresh quality review 双 PASS 后再继续。
## 已验证事实
### 工作区与状态
- 正确 worktree`/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`
- 分支:`dev/1.0.0`
- 当前本地与 `origin/dev/1.0.0` 对齐到 `e55618f test(p1r): 收口 Knowledge 事件传播真实链路门禁`
- 受保护文件 diff 为空:
- `docs/api-contracts/market/openapi.yaml`
- `docs/api-contracts/ai/openapi.yaml`
- `docs/api-contracts/knowledge/openapi.yaml`
- `docs/api-contracts/events/openapi.yaml`
- `muse-cloud/scripts/p1r-audit-api-coverage.py`
- `docs/superpowers/reports/p1r-api-coverage.json`
- `docs/superpowers/reports/p1r-api-coverage.md`
### 全局 P1R-7 边界
- P1R-7 全局审阅版建议阶段:
- P1R-7bAI terminal event。
- P1R-7cKnowledge source status / projection event。
- P1R-7dMarket lifecycle / account projection / governance event。
- P1R-7 completed approval 必须另起任务并由用户单独批准。
- P1R-7 全局执行版要求每个 owner 都必须 fresh implementer + fresh spec review + fresh quality / feasibility review。
- 全局合同要求 source owner 只能依赖 `muse-module-events-api`Events server 不得反向依赖 AI / Knowledge / Market / Member / Content server。
- 当前 coverage summary 仍是 `completedOperations=100``needsVerificationOperations=133``incompleteOperations=0``genericPersistenceOperations=0``ssePlaceholderOperations=0`
- Events `streamEvents` 与 Market 32 operations 均仍为 `dedicated / needs_verification`
### Events 可复用合同
- `EventsPublishReqDTO` 已包含 `commandId``tenantId``ownerUserId``sourceOwner``sourceType``sourceId``sourceRevision``eventType``resourceType``resourceId``payloadSummary``emittedAt`
- `EventsPublishServiceImpl` 先按 `commandId` 回放,再按 source tuple 回放。
- `EventsPublishServiceImpl` 已声明事件类型包含 `notification`
- Events OpenAPI 的 `SSENotificationEvent.data.type` 已包含 `governance_action`
- `UnifiedEventMapper.selectVisibleEventsForOwner` 只返回 `accepted`、同租户、同 owner、未删除、已到 visible time 的事件。
### Market 当前事实模型
- `AdminMarketGovernanceServiceImpl.delistAsset` 在治理命令成功后写入 `muse_market_governance_action`,字段包括 `actionId``previewId``assetId``actorUserId``ownerUserId``actionType``commandId``requestHash``status``revision``actionSnapshot``reason`
- `AdminMarketGovernanceServiceImpl.recallAsset` 在治理命令成功后写入 `muse_market_governance_action`,并额外写入 `muse_market_source_status_event`,其中 `sourceStatus` 固定为合同状态如 `recalled``needs_recheck` 只写入 `actionPolicy/recheckReasons`
- `MuseMarketGovernanceActionMapper` 支持按 tenant + asset 查询最新或列表,但没有 publish claim / retry / dead_letter 语义。
- `MuseMarketSourceStatusEventMapper` 支持按 `sourceEventId` 或 tenant + asset 查询最新,但没有 publish claim / retry / dead_letter 语义。
- `MarketAccountProjectionOutboxServiceImpl` 只有 `recordSynced` / `recordBlocked`,职责是 Market -> Member Account projection 同步状态;当前没有领取 retryable outbox、重试成功、dead_letter 或 replay worker。
- `MarketAccountProjectionProvider` 明确把 Account 查询读模型写入 member 表,失败时记录 Market 侧 blocked outbox 后抛错,防止伪成功。
- `MarketHandoffServiceImpl` 保存 handoff token hash 和生命周期状态,但 token 明文只在创建响应中返回;后续 P1R-7d 第一切片不发布 handoff token 或授权详情。
## 推断
- Market P1R-7d 第一切片应优先选择 governance/source status而不是 purchase/install/handoff。原因是 governance/source status 已有明确 publisher owner、source fact、actionId/sourceEventId 和安全摘要空间,且用户需要实时知道资产被下架或召回。
- 直接复用 `muse_market_account_projection` 作为 Events publish outbox 会混淆职责:它记录 Account 投影同步,不记录 Events publish claim、lease、retry、published event 或 dead_letter。
- `notification/governance_action` 已在 Events 合同内,可避免本阶段修改 OpenAPI。
- recall 比 delist 更适合作为第一条 source status 证据,因为它同时写 governance action 和 source status blocked 事实;但执行版仍可把 delist 作为同一事件族的正向治理通知。
## 假设
- P1R-7d 允许在 Market owner 内新增独立 Events publish outbox 表或等价状态表。
- P1R-7d 允许 `muse-module-market-server` 新增对 `muse-module-events-api` 的直接依赖,但不允许依赖 `muse-module-events-server`
- Market publisher 是第一批通知的 `ownerUserId`admin 操作者只作为 `actorUserId` 写入 Market fact / 安全摘要,不作为 SSE 可见 owner。
- `notification/governance_action` 的 payload 最小字段可以收敛为 `type``message`、可选 `resourceRef``timestamp`,不需要新增 OpenAPI 字段。
## 目标
1. 证明 Market source owner 的真实治理事实可以通过 Market 本域 publish outbox / worker 发布到 Events。
2. 让发布者用户可以通过统一 SSE 看到自己的资产治理通知。
3. 建立 Market 与 Events 的单向依赖证据Market server 依赖 events-api不依赖 events-serverEvents server 不反向依赖 Market server。
4. 为后续 Market source propagation / Account projection / handoff 事件扩展保留清晰边界。
## 非目标
1. 不把 Market 32 operations 从 `needs_verification` 推进到 `completed`
2. 不修改 Market / Events OpenAPI。
3. 不修改 coverage scanner 或 coverage report。
4. 不把 purchase、install、license、handoff token、authorization snapshot 或 Account projection 同步纳入第一切片。
5. 不把 `muse_market_account_projection` 复用为 Events publish outbox。
6. 不发布授权快照、安装详情、handoff token、目标 owner 私有事实、治理 preview 原始 riskSummary、管理员内部备注原文或完整 actionSnapshot。
7. 不让 Events server 查询 Market 表。
## 推荐方案
### 事件选择
第一批只允许:
| Market source fact | 触发入口 | Events eventType | notification type | SSE owner |
|---|---|---|---|---|
| `muse_market_governance_action(actionType=delist)` | `adminDelistAsset` | `notification` | `governance_action` | asset.publisherId |
| `muse_market_governance_action(actionType=recall)` | `adminRecallAsset` | `notification` | `governance_action` | asset.publisherId |
| `muse_market_source_status_event(sourceStatus=recalled)` | `adminRecallAsset` | `notification` | `governance_action` | event.ownerUserId |
不把 `previewGovernanceImpact` 发布到 SSE。preview 只是管理员决策前的影响预览,不是用户必须实时感知的终态事实。
### Payload allowlist
第一批 payload 只允许:
```json
{
"type": "governance_action",
"message": "资产治理状态已更新",
"resourceRef": {
"resourceType": "market_asset",
"resourceId": 1001
},
"timestamp": "2026-06-07T12:00:00"
}
```
执行版可以把 `message` 细化为“资产已下架”或“资产已召回”但不能放入管理员备注原文、治理证据、授权快照、handoff token、target owner 内部细节或错误堆栈。
### Outbox 边界
推荐新增独立 Market Events publish outbox
- source table`muse_market_governance_action` / `muse_market_source_status_event`
- source tuple`sourceOwner=market``sourceType=market_governance_action``market_source_status``sourceId=actionId/sourceEventId``sourceRevision=revision`
- commandId稳定短幂等键建议 `market_evt:<sha256-32>`
- 状态机:`queued/running/retryable/published/dead_letter`
- claim必须原子领取具备 lease / retry / attempt ownership。
- 终态回写:必须防 stale worker 覆盖新 claim 终态。
- worker 默认关闭,配置启用后才 claim。
### 依赖边界
- Market server 可以依赖 `muse-module-events-api`
- Market server 不得依赖 `muse-module-events-server`
- Events server 不得依赖 Market server。
- `muse-server` 作为装配层可以同时包含 Market server 与 Events server。
## 关键取舍
- 选择 governance/source status不选择 Account projection前者是 Market source owner 的用户可见事实;后者是 Account 查询读模型同步状态,复用会制造职责混乱。
- 选择 publisher 可见,不选择 admin 可见SSE app 侧事件面向资源 owner管理员操作已由管理端审计和治理记录承载。
- 使用 `notification/governance_action`,不新增 event typeEvents 合同已经声明该 subtype足够承载第一批摘要。
- 第一切片不发布 handoffhandoff 涉及 token、授权摘要和目标 owner 消费,泄露面和跨 owner 行为更复杂,应后续单独设计。
## 影响范围
- 直接影响:
- Market governance/source status 事实发布链路。
- Market server 依赖 events-api。
- Market 本域 outbox / worker / migration / focused tests。
- Events unified event focused E2E。
- 间接影响:
- P1R-7 completed approval 的证据链更完整。
- 不影响:
- OpenAPI 合同。
- coverage scanner / coverage report。
- Market 32 operations completion status。
- Account projection 查询读模型。
- handoff token 和目标 owner 授权消费。
## 风险与兼容性
- 事件重复风险:同一治理动作 replay 不能创建多条 unified event必须用 commandId 与 source tuple 双重幂等。
- 并发风险worker terminal update 必须绑定本次 claim attempt避免 stale worker 覆盖新 claim。
- 敏感信息风险:`reason``actionSnapshot`、preview `riskSummary` 和 target owner 细节不能进入 payload。
- 职责混淆风险Account projection outbox 只能继续服务 Account 读模型同步,不参与 Events publish。
- 可见性风险ownerUserId 必须是 publisher / source fact owner不能使用 adminUserId否则发布者无法通过 app SSE 看到通知。
- 兼容性:新增 outbox 与 worker 默认关闭,不改变现有 Market API 同步返回语义。
## 验收标准
审阅版通过后,执行版必须包含:
1. Market outbox DO / Mapper / Service / Worker / 配置 / V19 migration 的文件边界。
2. `AdminMarketGovernanceServiceImpl` 创建 governance/source status fact 后创建 outbox 的事务边界。
3. payload allowlist 与敏感字段拒绝策略。
4. worker success / duplicate / retryable / rejected / blocked / dead_letter / stale claim tests。
5. Market governance source fact -> Market outbox -> worker -> EventsPublishApi -> `muse_unified_event` -> SSE visible focused E2E。
6. dependency gateMarket server 有 events-api、无 events-serverEvents server 无 Market server。
7. V19 SQL 静态 gate 与 Flyway `_test`
8. P1R mixed gates 继续确认 Events / AI / Knowledge 已有 source owner gates 不回退Market 仍为 `dedicated / needs_verification`
9. coverage scanner 只在 `/tmp` 隔离副本运行,真实 worktree protected diff 为空。
## 待确认项
1. P1R-7d 第一切片是否确认只做 governance/source status而不做 purchase/install/handoff。
2. `notification/governance_action` 的 message 是否允许按动作区分为“资产已下架 / 资产已召回”,还是统一为“资产治理状态已更新”。
3. 如果 recall 同时产生 governance action 和 source status event执行版是否只发布一条 governance action 通知,还是允许两条不同 sourceType 的通知。

View File

@ -0,0 +1,650 @@
# P1R7d Market Source Owner Propagation 执行版
## 结论
P1R-7d 只执行 Market source owner propagation 的第三 owner 切片:把 Market governance terminal fact 转成 Market 本域 Events publish outbox再由 Market worker 调用 `EventsPublishApi` 写入 `muse_unified_event`,最终通过 `/app-api/muse/events` 对资产发布者 SSE 可见。
本执行版不允许把 Events / P1R-7 / Market 推进到 `completed`,不允许修改 OpenAPI、coverage scanner 或 coverage report不允许复用 Account projection outbox 作为 Events publish outbox。
```mermaid
flowchart LR
Command["adminDelistAsset / adminRecallAsset<br/>expectedStatus + impactPreview"] --> Fact["muse_market_governance_action<br/>applied"]
Command --> SourceStatus["muse_market_source_status_event<br/>recall only, evidence only"]
Fact --> Outbox["muse_market_event_publish_outbox<br/>one row per governance action"]
Outbox --> Worker["Market publish worker<br/>claim/retry/dead_letter"]
Worker --> Api["EventsPublishApi<br/>events-api only"]
Api --> Unified["muse_unified_event<br/>notification/governance_action"]
Unified --> Stream["/app-api/muse/events<br/>publisher visible SSE"]
```
## Review Gate
P1R-7d 审阅版已通过 fresh review
- Ptolemy spec compliance reviewPASS无 P0/P1/P2。
- Galileo quality/feasibility reviewPASS无 P0/P1/P2。
P3 已纳入本执行版:
- `recall` 第一切片只发布一条 `market_governance_action` 通知,`muse_market_source_status_event` 只作为 recall 事实旁证和后续扩展点。
- `resourceRef.resourceId` 必须用数值型,不能把 DTO 的 string `resourceId` 直接塞入 payload。
- command replay 不补历史 outbox历史 governance fact backfill 不是 P1R-7d 第一切片目标。
- 实现必须由 fresh implementer 执行,完成后再做 fresh spec review + fresh quality / feasibility review。
## 边界
### 本轮目标
- 在 Market owner 内新增独立 Events publish outbox。
- 只接入 `AdminMarketGovernanceServiceImpl.delistAsset``AdminMarketGovernanceServiceImpl.recallAsset` 成功路径。
- 只发布 `muse_market_governance_action` 形成的用户可见治理终态通知。
- 通过 Market worker 调用 `EventsPublishApi`
- 用 focused tests、P1R mixed gates、Flyway `_test`、dependency gate 和隔离 coverage scanner 证明链路进入 `needs_verification` 证据层。
### 本轮非目标
- 不发布 `previewGovernanceImpact`
- 不发布 purchase / install / license / handoff / Account projection 同步事件。
- 不发布 `muse_market_source_status_event` 独立 SSErecall 的 source status event 只作为 E2E 事实旁证。
- 不发布 authorization snapshot、handoff token、target owner 私有事实、完整 actionSnapshot、preview riskSummary、管理员内部备注原文或治理证据详情。
- 不复用 `muse_market_account_projection` 作为 Events publish outbox。
- 不抽取 AI / Knowledge / Market 共享 outbox 框架。
- 不为历史 governance action 做 backfill。
- 不修改 `docs/api-contracts/*/openapi.yaml`
- 不修改 `muse-cloud/scripts/p1r-audit-api-coverage.py`
- 不修改 `docs/superpowers/reports/p1r-api-coverage.json``.md`
### 受保护文件
实现代理不得修改以下文件:
- `docs/api-contracts/market/openapi.yaml`
- `docs/api-contracts/ai/openapi.yaml`
- `docs/api-contracts/knowledge/openapi.yaml`
- `docs/api-contracts/events/openapi.yaml`
- `muse-cloud/scripts/p1r-audit-api-coverage.py`
- `docs/superpowers/reports/p1r-api-coverage.json`
- `docs/superpowers/reports/p1r-api-coverage.md`
## 已验证事实
- 正确 worktree 是 `/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`
- 当前分支是 `dev/1.0.0`,远端跟踪是 `origin/dev/1.0.0`
- 当前 HEAD 为 `e55618f test(p1r): 收口 Knowledge 事件传播真实链路门禁`
- P1R-7b 已提交并 push`3db5fbe``230152c`
- P1R-7c 已提交并 push`cef686b``e55618f`
- P1R-7d 审阅版路径为 `docs/agent-specs/2026-06-07-P1R7dMarketSourceOwnerPropagation审阅版.md`
- P1R-7d 审阅版 fresh review 已双 PASSPtolemy spec PASSGalileo quality PASS。
- 当前 coverage 边界仍为 `completedOperations=100``needsVerificationOperations=133``incompleteOperations=0``genericPersistenceOperations=0``ssePlaceholderOperations=0`
- Events `streamEvents` 当前是 `dedicated / needs_verification`
- Market 32 operations 当前是 `dedicated / needs_verification`
- 当前受保护文件 diff 为空。
- 当前最新 migration 是 `muse-cloud/sql/muse/V18__extend_knowledge_events_publish_outbox.sql`P1R-7d 下一 migration 编号应为 `V19`。若实现前 live 文件列表已出现更高版本,必须停止并修订本执行版的 migration 编号。
- `EventsPublishReqDTO` 必填 `commandId``tenantId``ownerUserId``sourceOwner``sourceType``sourceId``eventType``payloadSummary``emittedAt`
- `EventsPublishServiceImpl` 当前先按 `commandId` 回放,再按 source tuple 回放。
- `EventsPublishServiceImpl` 当前已声明 `notification` event type。
- `EventsPublishServiceImpl` 当前已声明 `governance_action` notification type。
- Events OpenAPI `SSENotificationEvent.data.resourceRef.resourceId` 为 int64。
- `AdminMarketGovernanceServiceImpl.delistAsset` 成功后写 `muse_market_governance_action`
- `AdminMarketGovernanceServiceImpl.recallAsset` 成功后写 `muse_market_governance_action`,并额外写 `muse_market_source_status_event`
- `MuseMarketGovernanceActionDO` 包含 `actionId``previewId``assetId``actorUserId``ownerUserId``actionType``commandId``requestHash``status``revision``actionSnapshot``reason`
- `MuseMarketSourceStatusEventDO` 包含 `sourceEventId``sourceOwner``sourceType``sourceId``sourceRevision``ownerUserId``assetId``sourceStatus``actionPolicy``recheckReasons``eventSummary``errorCode``errorMessage`
- `MarketAccountProjectionOutboxServiceImpl` 当前只有 `recordSynced` / `recordBlocked`,无 Events publish claim / retry / dead_letter / published event 语义。
- `muse-module-market-server/pom.xml` 当前已有对 member server 的依赖;实现前必须只新增 events-api direct dependency不得新增 events-server dependency。
## 推断
- P1R-7d 可复用 P1R-7b/P1R-7c 的 outbox 状态机、worker claim、stale claim guard、dependency gate 和 Flyway `_test` 模式。
- Market 第一切片只发 governance action比同时发布 governance action + source status event 更稳用户收到一条明确治理通知source status event 仍保留为 Market 内部 source propagation evidence。
- Account projection outbox 和 Events publish outbox 必须保持两张表 / 两套状态机,否则 Account 读模型同步失败与 SSE 发布失败会互相污染。
## 假设
- P1R-7d 允许 Market server 新增 `muse-module-events-api` direct dependency。
- P1R-7d 允许新增 `muse_market_event_publish_outbox`
- P1R-7d 第一批通知的 SSE owner 是资产发布者 `ownerUserId`,不是 admin actor。
- P1R-7d 第一批使用 Events 现有 `notification/governance_action`,不新增 OpenAPI event type 或 notification type。
## 设计合同
### 事件合同
第一批只允许以下 Market governance action 进入 publish outbox
| Market source fact | actionType | Events eventType | notification type | SSE owner | message |
|---|---|---|---|---|---|
| `muse_market_governance_action` | `delist` | `notification` | `governance_action` | `ownerUserId` | `资产已下架` |
| `muse_market_governance_action` | `recall` | `notification` | `governance_action` | `ownerUserId` | `资产已召回` |
`muse_market_source_status_event(sourceStatus=recalled)` 不单独创建 outbox。实现测试必须证明 recall 成功路径上 source status event 存在,但最终只产生一条 governance action notification。
### 幂等合同
- 每个 governance action 只生成一条 outbox。
- `commandId` 格式:`mk_evt:` + SHA-256(`tenantId|actionId|ownerUserId|governance_action`) 前 32 位 hex。
- `outboxId` 格式:`mk_out:` + SHA-256(`tenantId|actionId|ownerUserId|governance_action`) 前 32 位 hex。
- Events source tuple
- `sourceOwner = market`
- `sourceType = market_governance_action`
- `sourceId = actionId`
- `sourceRevision = revision`,为空时使用 Events 现有 `__none__`
- `eventType = notification`
- 同一 governance action 重放必须得到同一 Events `eventId/sequenceNo`
- 同一 asset 的不同 governance action 必须生成不同 commandId 和不同 source tuple。
- Command replay 策略:`delistAsset` / `recallAsset` 命中 Market command replay 时不补历史 outbox只有首次成功写入 governance action 的事务内创建 outbox。历史 backfill 若需要,必须另起任务。
### Payload 合同
Internal outbox 允许保存以下字段:
- `actionId`
- `assetId`
- `actionType`
- `sourceCommandId`
- `actorUserId`
- `ownerUserId`
- `previewId`
- `requestHash`
- `sourceRevision`
- `message`
- `resourceType`
- `resourceId`
Events `payloadSummary` 只能发布:
```json
{
"type": "governance_action",
"message": "资产已召回",
"resourceRef": {
"resourceType": "market_asset",
"resourceId": 1001
},
"timestamp": "2026-06-07T12:00:00"
}
```
约束:
- `resourceRef.resourceId` 必须是数值型 Long / Integer 语义,不能是字符串。
- `message` 只允许 `资产已下架``资产已召回`
- `timestamp` 使用 governance action 的 `createTime` 或 worker 构造时可回退到 outbox `createTime`
- 不得发布 `reason`、完整 `actionSnapshot`、preview riskSummary、authorization snapshot、handoff token、target owner、admin note、error stack、requestHash。
- `payloadSummary` 必须是 exact allowlist顶层只能包含 `type``message``resourceRef``timestamp``resourceRef` 只能包含 `resourceType``resourceId`
### 状态机合同
Outbox 本域状态只允许:
- `queued`
- `running`
- `retryable`
- `published`
- `dead_letter`
状态流转:
```mermaid
stateDiagram-v2
[*] --> queued
queued --> running: claim
retryable --> running: retry due
running --> running: expired lease reclaim
running --> published: Events accepted
running --> dead_letter: Events rejected/blocked
running --> retryable: temporary failure and attempts remain
running --> dead_letter: retry exhausted
```
Worker claim 要求:
- 使用 PostgreSQL `FOR UPDATE SKIP LOCKED` 或等价 `UPDATE ... RETURNING` 原子领取。
- 调度线程无请求租户上下文时必须先忽略租户拦截器领取,再使用 outbox 行内 `tenantId` 恢复租户上下文发布。
- Claim 阶段唯一递增 `attemptCount`
- 临时失败只释放 claim、设置 `nextRetryAt` 和安全错误摘要,不二次递增 attempt count。
- `markPublished` / `markRetryable` / `markDeadLetter` 必须绑定 `publish_status='running' AND attempt_count=#{claimedAttemptCount}`
- terminal update 返回 0 时必须记录 stale claim 诊断日志,包含 tenantId、ownerUserId、outboxId、actionId、attempt、targetStatus、errorCode。
- Worker 默认关闭:`muse.market.events.publish-worker.enabled=false`
- 调度配置键:
- `muse.market.events.publish-worker.initial-delay-ms`
- `muse.market.events.publish-worker.fixed-delay-ms`
- `muse.market.events.publish-worker.max-attempt`
- `muse.market.events.publish-worker.claim-timeout-seconds`
### 失败路径合同
| 路径 | 结果 |
|---|---|
| 无 governance action | 不创建 outbox |
| actionType 非 `delist/recall` | 不创建 outbox |
| ownerUserId null 或 <= 0 | 不创建 outbox记录安全日志 |
| payload 构造缺 required 字段 | 创建 outbox 前拒绝并记录安全日志,不创建 outbox不发布错误码固定为 `MARKET_EVENTS_PAYLOAD_INVALID` |
| Events accepted | outbox -> published回写 eventId / sequenceNo |
| Events duplicate accepted | outbox -> published回写既有 eventId / sequenceNo |
| Events rejected / blocked | outbox -> dead_letter |
| CommonResult error / exception | attempt 未耗尽 -> retryable耗尽 -> dead_letter |
| command replay | 不补历史 outbox不重复发布 |
## 涉及文件
### 生产代码
- Modify: `muse-cloud/muse-module-market/muse-module-market-server/pom.xml`
- Modify: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/AdminMarketGovernanceServiceImpl.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/MarketEventPublishOutboxService.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/MarketEventPublishOutboxServiceImpl.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/MarketEventPublishWorker.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/dataobject/muse/MuseMarketEventPublishOutboxDO.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketEventPublishOutboxMapper.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/framework/config/MarketEventsConfiguration.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/framework/config/MarketEventsProperties.java`
- Create: `muse-cloud/sql/muse/V19__extend_market_events_publish_outbox.sql`
### 测试代码
- Modify: `muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/AdminMarketGovernanceServiceTest.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketEventPublishOutboxServiceTest.java`
- Create: `muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketEventPublishWorkerTest.java`
- Create: `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishDependencyTest.java`
- Create: `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishEndToEndTest.java`
- Create: `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishFlywayMigrationIT.java`
- Create: `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishMigrationSqlTest.java`
### 文档
- Modify: `docs/agent-specs/.agent`
- Create: `docs/memorys/YYYY-MM-DD-P1R7dMarketSourceOwnerPropagation真实链路.md`
## 实施任务
### Task 0fresh implementer preflight
- 核对 `pwd``git status --short --branch``git log --oneline -5`
- `git pull --ff-only origin dev/1.0.0`
- 核对受保护文件 diff 为空。
- Git / protected diff / coverage scanner 均从 worktree 根目录执行Maven / Flyway 命令必须先 `cd muse-cloud`,因为 reactor POM 位于 `muse-cloud/pom.xml`
- 核对 `muse-cloud/sql/muse` 最新 migration 是否仍为 V18若不是停止并修订本执行版。
- 只读核对 Market server 当前没有 events-api dependency。
- 只读核对 Events server 仍不依赖 Market server。
### Task 1V19 migration 与 SQL gate
新增 `muse_market_event_publish_outbox`
必须字段:
- `id`
- `outbox_id`
- `action_id`
- `asset_id`
- `action_type`
- `source_command_id`
- `owner_user_id`
- `actor_user_id`
- `preview_id`
- `request_hash`
- `source_revision`
- `event_type`
- `notification_type`
- `payload_summary`
- `command_id`
- `publish_status`
- `attempt_count`
- `max_attempt`
- `claimed_at`
- `claim_expires_at`
- `next_retry_at`
- `last_error_code`
- `last_error_message`
- `published_event_id`
- `published_sequence_no`
- `tenant_id`
- `deleted`
- `creator`
- `updater`
- `create_time`
- `update_time`
- `update_time` 自动维护 trigger
必须约束:
- `event_type = 'notification'`
- `notification_type = 'governance_action'`
- `action_type IN ('delist','recall')`
- `publish_status IN ('queued','running','retryable','published','dead_letter')`
- `owner_user_id > 0`
- `attempt_count >= 0`
- `max_attempt > 0`
必须唯一键:
- `(tenant_id, outbox_id)`
- `(tenant_id, command_id)`
- `(tenant_id, action_id, notification_type)`
必须索引:
- claim indextenant + publish_status + next_retry_at + id
- owner status indextenant + owner_user_id + publish_status
- action indextenant + action_id
SQL gate 必须验证:
- 表存在。
- check 约束存在且不是宽松 OR 表达式。
- unique key 和 claim index 存在。
- update_time trigger 存在。
- `resourceRef.resourceId` 不在 DB 约束中表达,留给 payload 单元测试验证。
### Task 2Outbox DO / Mapper
实现 DO 与 Mapper。
Mapper 必须包含:
- `insertIgnore`
- `selectByTenantIdAndOutboxId`
- `selectByTenantIdAndCommandId`
- `selectByTenantIdAndActionIdAndNotificationType`
- `claimNext`
- `markPublished`
- `markRetryable`
- `markDeadLetter`
Mapper 约束:
- `claimNext` 必须只领取 `queued/retryable/running expired`
- `claimNext` 必须设置 `publish_status='running'`、递增 `attempt_count`、设置 `claimed_at/claim_expires_at`
- `markPublished/markRetryable/markDeadLetter` 必须要求 `publish_status='running' AND attempt_count=#{claimedAttemptCount}`
### Task 3Outbox service
实现 `MarketEventPublishOutboxService.createForGovernanceAction(action)`
必须行为:
- 只接受 `delist/recall`
- ownerUserId null 或 <= 0 时跳过并记录安全日志。
- 构造 payload 时 `resourceRef.resourceId` 必须是数值型 action.assetId。
- message 按 actionType 固定:
- `delist` -> `资产已下架`
- `recall` -> `资产已召回`
- 使用 `insertIgnore + 回查` 幂等。
- `sourceRevision` 为空时归一为 `__none__`
- 不读取 `reason` 或完整 `actionSnapshot` 拼 payload。
- command replay 不补历史 outbox。
### Task 4接入 governance service
修改 `AdminMarketGovernanceServiceImpl.applyGovernanceAction`
- 在 governance action insert 成功后创建 outbox。
- 保持同事务:治理事实写入成功且 outbox 创建失败时,整个 governance command 回滚,避免治理事实和 publish intent 不一致。
- `recallAsset` 仍写 source status event但不为 source status event 创建独立 outbox。
- 命中 command replay 时仍直接回放,不补历史 outbox。
### Task 5Worker
新增 Market publish worker。
必须行为:
- 默认 disabled不 claim。
- enabled 时 claim 一条并发布。
- 构造 `EventsPublishReqDTO`
- `sourceOwner=market`
- `sourceType=market_governance_action`
- `sourceId=actionId`
- `sourceRevision=sourceRevision``__none__`
- `eventType=notification`
- `resourceType=market_asset`
- `resourceId=assetId.toString()`
- `payloadSummary` 使用 outbox 中 JSON 解析后的 allowlist map
- `ownerUserId=outbox.ownerUserId`
- `EventsPublishReqDTO.resourceId` 仍按 Events API DTO 使用字符串;`payloadSummary.resourceRef.resourceId` 必须保持 Number / Long 语义,测试必须分别断言两者,不能混写。
- Events accepted -> `published`
- Events rejected/blocked -> `dead_letter`
- CommonResult error / exception -> `retryable` 或 retry exhausted 后 `dead_letter`
- terminal update 0 rows 记录 stale claim 诊断日志。
### Task 6Focused tests
必须覆盖:
- delist action 创建 queued outbox。
- recall action 只创建一条 governance action outboxsource status event 存在但不创建第二条 outbox。
- command replay 不补历史 outbox。
- ownerUserId invalid 不创建 outbox。
- payload 使用 exact allowlist顶层只能有 `type/message/resourceRef/timestamp``resourceRef` 只能有 `resourceType/resourceId`
- payload 不包含 reason / actionSnapshot / preview riskSummary / authorization snapshot / handoff token / target owner / admin note / error stack / requestHash。
- payload invalid fail-closed 使用固定错误码 `MARKET_EVENTS_PAYLOAD_INVALID`,并断言只进入安全日志或安全 lastError 摘要,不创建 outbox、不发布。
- resourceRef.resourceId 是数值型。
- duplicate create 复用既有 outbox。
- worker disabled 不 claim。
- worker accepted -> published。
- worker duplicate accepted -> published。
- worker rejected / blocked -> dead_letter。
- worker CommonResult error -> retryablelast attempt -> dead_letterlastError 只保留安全摘要,不持久化原始错误正文。
- worker exception -> retryablelast attempt -> dead_letter。
- stale claim terminal update 被忽略并记录日志。
### Task 7P1R mixed gates
必须新增:
- `P1rMarketEventsPublishDependencyTest`
- `P1rMarketEventsPublishEndToEndTest`
- `P1rMarketEventsPublishMigrationSqlTest`
- `P1rMarketEventsPublishFlywayMigrationIT`
E2E 必须证明:
- Market governance action -> Market outbox -> worker -> EventsPublishApi -> `muse_unified_event`
- publisher owner 可见。
- admin actor 不因 actorUserId 可见。
- 非 owner 不可见。
- recall 只发布一条 governance action notification。
- blocked/rejected 不进入 visible query。
Mixed gate 命令必须继续包括:
- `P1rApiCoverageReportTest`
- `P1rEventsRealApiGateTest`
- `P1rEventsRouteOwnershipTest`
- `P1rAiRealApiGateTest`
- `P1rAiRouteOwnershipTest`
- `P1rAiEventsPublishMigrationSqlTest`
- `P1rAiEventsPublishDependencyTest`
- `P1rAiEventsPublishEndToEndTest`
- `P1rKnowledgeEventsPublishMigrationSqlTest`
- `P1rKnowledgeEventsPublishDependencyTest`
- `P1rKnowledgeEventsPublishEndToEndTest`
- P1R-7d Market 新增 gate
### Task 8Flyway `_test`
新增 `P1rMarketEventsPublishFlywayMigrationIT`
要求:
- 只允许 `_test` 结尾数据库。
- 禁止 JDBC URL 携带 password / credential query。
- password 只能来自 `P1R_FLYWAY_PASSWORD`
- 推荐复用禁用 JVM 代理参数:
- `-Djava.net.useSystemProxies=false`
- `-DsocksProxyHost=`
- `-DsocksProxyPort=`
- `-Dhttp.proxyHost=`
- `-Dhttp.proxyPort=`
- `-Dhttps.proxyHost=`
- `-Dhttps.proxyPort=`
- 验证 V19 表、约束、索引、trigger。
- 真实 PostgreSQL 负向 insert 探针至少覆盖:
- illegal event_type
- illegal notification_type
- illegal action_type
- illegal publish_status
- owner_user_id <= 0
### Task 9Coverage scanner 与 protected diff
只允许在 `/tmp` 隔离副本运行:
```bash
tmpdir=$(mktemp -d /tmp/p1r7d-coverage-scan.XXXXXX)
rsync -a --delete --exclude .git --exclude muse-cloud/target --exclude 'muse-cloud/**/target' \
/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/ "$tmpdir/"
cd "$tmpdir"
python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check
```
真实 worktree 必须继续确认 protected diff 为空。
### Task 10文档留痕与 review
- 更新 `docs/agent-specs/.agent`
- 新增 `docs/memorys/YYYY-MM-DD-P1R7dMarketSourceOwnerPropagation真实链路.md`
- 实现完成后必须 fresh spec review + fresh quality/feasibility review。
- 双 PASS 也只代表 `needs_verification` evidence 收口,不代表 completed。
## 最终验证命令
实现完成后按顺序运行:
1. Reactor build
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud && \
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true
```
2. Market focused tests
先断言新增测试类存在,避免 `failIfNoSpecifiedTests=false` 掩盖新测试漏建或拼错:
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 && \
test -f muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketEventPublishOutboxServiceTest.java && \
test -f muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketEventPublishWorkerTest.java && \
test -f muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishMigrationSqlTest.java && \
test -f muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishDependencyTest.java && \
test -f muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishEndToEndTest.java && \
test -f muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketEventsPublishFlywayMigrationIT.java
```
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud && \
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-module-market/muse-module-market-server -Dtest=AdminMarketGovernanceServiceTest,MarketEventPublishOutboxServiceTest,MarketEventPublishWorkerTest -Dsurefire.failIfNoSpecifiedTests=false
```
3. P1R mixed gates
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud && \
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-server -am -Dtest=P1rMarketEventsPublishMigrationSqlTest,P1rMarketEventsPublishDependencyTest,P1rMarketEventsPublishEndToEndTest,P1rKnowledgeEventsPublishMigrationSqlTest,P1rKnowledgeEventsPublishDependencyTest,P1rKnowledgeEventsPublishEndToEndTest,P1rAiEventsPublishMigrationSqlTest,P1rAiEventsPublishDependencyTest,P1rAiEventsPublishEndToEndTest,P1rApiCoverageReportTest,P1rEventsRealApiGateTest,P1rEventsRouteOwnershipTest,P1rAiRealApiGateTest,P1rAiRouteOwnershipTest -Dsurefire.failIfNoSpecifiedTests=false
```
4. Dependency tree
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud && \
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \
mvn -o -pl muse-module-market/muse-module-market-server -am dependency:tree \
> /tmp/p1r7d-market-server-dependency-tree.txt && \
rg 'muse-module-events-api' /tmp/p1r7d-market-server-dependency-tree.txt && \
! rg 'muse-module-events-server' /tmp/p1r7d-market-server-dependency-tree.txt
```
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud && \
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \
mvn -o -pl muse-module-events/muse-module-events-server -am dependency:tree \
> /tmp/p1r7d-events-server-dependency-tree.txt && \
! rg 'muse-module-(ai|knowledge|market|member|content)-server' /tmp/p1r7d-events-server-dependency-tree.txt
```
5. Flyway `_test`
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud && \
set -a
source ~/.config/muse-repo/infra.env
set +a
export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD"
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-server -am \
-Dtest=P1rMarketEventsPublishFlywayMigrationIT \
-Dflyway.postgresql.transactional.lock=false \
-Dp1r.flyway.locations=filesystem:sql/muse \
-Dp1r.flyway.url="jdbc:postgresql://$MUSE_POSTGRES_HOST:$MUSE_POSTGRES_PORT/muse_p1r7d_events_publish_test" \
-Dp1r.flyway.user="$MUSE_POSTGRES_USERNAME" \
-Djava.net.useSystemProxies=false \
-DsocksProxyHost= \
-DsocksProxyPort= \
-Dhttp.proxyHost= \
-Dhttp.proxyPort= \
-Dhttps.proxyHost= \
-Dhttps.proxyPort= \
-Dsurefire.failIfNoSpecifiedTests=false
```
6. Surefire XML 计数断言:
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 && \
python3 - <<'PY'
from pathlib import Path
import xml.etree.ElementTree as ET
targets = [
"muse-cloud/muse-module-market/muse-module-market-server/target/surefire-reports/TEST-cn.iocoder.muse.module.market.application.muse.MarketEventPublishOutboxServiceTest.xml",
"muse-cloud/muse-module-market/muse-module-market-server/target/surefire-reports/TEST-cn.iocoder.muse.module.market.application.muse.MarketEventPublishWorkerTest.xml",
"muse-cloud/muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketEventsPublishMigrationSqlTest.xml",
"muse-cloud/muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketEventsPublishDependencyTest.xml",
"muse-cloud/muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketEventsPublishEndToEndTest.xml",
"muse-cloud/muse-server/target/surefire-reports/TEST-cn.iocoder.muse.server.framework.api.P1rMarketEventsPublishFlywayMigrationIT.xml",
]
for target in targets:
path = Path(target)
if not path.exists():
raise SystemExit(f"missing surefire xml: {target}")
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"))
if tests <= 0 or failures or errors or skipped:
raise SystemExit(f"bad surefire result: {target} tests={tests} failures={failures} errors={errors} skipped={skipped}")
PY
```
7. 隔离 coverage scanner
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 && \
tmpdir=$(mktemp -d /tmp/p1r7d-coverage-scan.XXXXXX) && \
rsync -a --delete --exclude .git --exclude muse-cloud/target --exclude 'muse-cloud/**/target' \
/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/ "$tmpdir/" && \
cd "$tmpdir" && \
python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check
```
8. Protected diff
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 && \
git diff --quiet -- docs/api-contracts/market/openapi.yaml docs/api-contracts/ai/openapi.yaml docs/api-contracts/knowledge/openapi.yaml docs/api-contracts/events/openapi.yaml muse-cloud/scripts/p1r-audit-api-coverage.py docs/superpowers/reports/p1r-api-coverage.json docs/superpowers/reports/p1r-api-coverage.md
```
## 完成条件
- P1R-7d Market governance action -> Market outbox -> worker -> EventsPublishApi -> `muse_unified_event` -> SSE visible 证据成立。
- 所有 focused / mixed / dependency tree / Flyway / coverage / protected diff gate 通过。
- fresh implementation spec review + fresh quality/feasibility review 双 PASS。
- 不修改 OpenAPI、scanner、coverage report。
- 不推进 Events / P1R-7 / Market completed。
## 回滚策略
- 配置关闭 `muse.market.events.publish-worker.enabled=false` 后 worker 不 claim。
- 数据层可保留 `muse_market_event_publish_outbox` 作为审计事实,不影响现有 Market API。
- 如需要撤销应用行为,回滚 `AdminMarketGovernanceServiceImpl` 对 outbox service 的调用和 worker bean 配置V19 表可保留到后续清理迁移。

View File

@ -0,0 +1,289 @@
# P1R7d Market Source Owner Propagation 真实链路
日期2026-06-07
## 结论
P1R-7d Market source owner propagation 主体实现、V19 SQL、focused tests、P1R mixed executable gates、V19 Flyway `_test`、dependency tree gate、reactor build、隔离 coverage scanner 与 protected diff gate 已形成进入修复后 fresh implementation review 的证据。
修复后 fresh implementation review 已双 PASSPascal spec/correctness review PASSMill quality/data-integrity/testing review PASS均无 P0/P1/P2 blocker。
当前仍不能写 `completed`。本轮证据只代表 Market governance terminal fact -> Market publish outbox -> Market worker -> EventsPublishApi -> `muse_unified_event` -> `/app-api/muse/events` 对资产发布者可见的链路推进到 `needs_verification` 证据层,不代表 Events / P1R-7 / Market completed。
本轮未修改 OpenAPI、coverage scanner 或真实 coverage 报告。
## 已验证事实
### 工作区与保护文件
- 正确 worktree`/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`
- 分支:`dev/1.0.0`
- `git pull --ff-only origin dev/1.0.0` 返回 `Already up to date.`
- 受保护文件最终 `git diff --quiet -- ...` 返回 exit 0
- `docs/api-contracts/market/openapi.yaml`
- `docs/api-contracts/ai/openapi.yaml`
- `docs/api-contracts/knowledge/openapi.yaml`
- `docs/api-contracts/events/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 server 新增直接依赖 `muse-module-events-api`
- 新增 Market 本域 publish outbox
- `MuseMarketEventPublishOutboxDO`
- `MuseMarketEventPublishOutboxMapper`
- `MarketEventPublishOutboxService`
- `MarketEventPublishOutboxServiceImpl`
- `MarketEventPayloads`
- `MarketEventPublishWorker`
- `MuseMarketEventsProperties`
- `MuseMarketEventsConfiguration`
- `AdminMarketGovernanceServiceImpl.applyGovernanceAction` 在治理动作 `muse_market_governance_action` 落库后,同事务创建 Market Events publish outbox。
- command replay 不补历史 outbox。
- 新增 V19 migration`muse-cloud/sql/muse/V19__extend_market_events_publish_outbox.sql`
### Review 反馈与修复
- Pasteur fresh implementation spec review 返回 FAIL两个有效问题已修复
- V19 schema、DO、Mapper 补齐 `source_command_id``actor_user_id``preview_id``request_hash`
- `commandId/outboxId` 幂等 key 改为 `tenantId|actionId|ownerUserId|governance_action` 的 SHA-256 前 32 位。
- Kant fresh quality/data-integrity review 返回 FAIL两个有效问题已修复
- Flyway IT `baseInsertSql()` 补齐四个 NOT NULL 字段,避免负向 insert 假绿。
- payload validator 从“任意字符串 message”收紧为安全消息 allowlist。
- Tesla fresh implementation spec review 返回 FAIL两个有效问题已修复
- `muse.market.events.publish-worker.max-attempt``claim-timeout-seconds` 从执行版配置合同落到 `MuseMarketEventsProperties`outbox service 写入配置化 `maxAttempt`worker claim 使用配置化超时时间。
- payload `timestamp` 从 governance action `createTime` 派生,缺失时才回退当前时间,避免事件时间不可审计。
- Volta fresh quality/data-integrity review 曾返回 PASS但该 PASS 发生在 Tesla 修复前;由于后续修改了 outbox service、worker、properties 和测试Volta PASS 已失效,不能计入最终 PASS gate。
- 旧 Pasteur/Kant/Tesla/Volta 均不能计入最终 PASS gate。
- 修复后 fresh implementation spec/correctness reviewPascalPASS无 P0/P1/P2 blocker残余非阻塞风险claim SQL 不在领取阶段过滤 `attempt_count < max_attempt`,但 worker 会在 claim 后判断耗尽并转 dead_letterE2E 仍是 focused harness不是真实 HTTP/SSE + 真实 DB 全链路dependency 单测主要读 direct POM后续最终 gate 应保留 dependency tree 原始输出。
- 修复后 fresh implementation quality/data-integrity/testing reviewMillPASS无 P0/P1/P2 blocker残余非阻塞风险可后续把 `sourceCommandId / actorUserId / previewId / requestHash` 的 pre-insert 校验前移到 service可后续把 `insertIgnore` 收窄到指定 conflict target 或在 insert=0 且 action/command 均查不到时记录 error若未来审计要求精确同源时间可统一 `EventsPublishReqDTO.emittedAt` 与 payload `timestamp`
### 实现边界
- 第一切片只发布 `delist` / `recall``notification/governance_action`
- `recall` 第一切片只发布一条 `market_governance_action` notification`muse_market_source_status_event` 只作为事实旁证和后续扩展点。
- SSE 可见 owner 是资产发布者 `ownerUserId`,不是 admin actor也不是 target owner。
- payload exact allowlist
- 顶层只允许 `type/message/resourceRef/timestamp`
- `resourceRef` 只允许 `resourceType/resourceId`
- `message` 只允许 `资产已下架` / `资产已召回`
- `payloadSummary.resourceRef.resourceId` 保持 Number/Long 语义。
- `EventsPublishReqDTO.resourceId` 仍按 Events DTO 合同使用 String。
- 禁止发布授权快照、handoff token、target owner、admin note、error stack、完整 actionSnapshot。
- `request_hash` 只作为 outbox 幂等/审计字段保存,不进入 Events payload。
- worker 默认配置关闭:`muse.market.events.publish-worker.enabled=false`
- 终态回写使用 `publish_status='running' AND attempt_count=#{claimedAttemptCount}` 防止过期 claim 覆盖新终态。
### Reactor build
`muse-cloud` 执行:
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true
```
结果exit 0`BUILD SUCCESS`62/62 modules successFinished at `2026-06-07T20:36:27+08:00`
### Market focused tests
执行:
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-module-market/muse-module-market-server -am -Dtest=AdminMarketGovernanceServiceTest,MarketEventPublishOutboxServiceTest,MarketEventPublishWorkerTest -Dsurefire.failIfNoSpecifiedTests=false
```
结果exit 028 tests passfailures/errors/skipped 均为 0。
surefire XML 证明以下目标类实际运行且 test count > 0
| 测试类 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `AdminMarketGovernanceServiceTest` | 11 | 0 | 0 | 0 |
| `MarketEventPublishOutboxServiceTest` | 6 | 0 | 0 | 0 |
| `MarketEventPublishWorkerTest` | 11 | 0 | 0 | 0 |
覆盖点:
- `delist` / `recall` 成功治理动作会调用 Market Events outbox service。
- command replay 不补历史 outbox。
- invalid enum / invalid preview / CAS blocker 不创建 outbox。
- outbox service 创建安全 payload`resourceRef.resourceId` 是数值型。
- duplicate command/action notification 返回既有 outbox不重复创建。
- unsupported action / invalid owner 不创建 outbox。
- payload invalid fail-closed抛出固定错误码 `MARKET_EVENTS_PAYLOAD_INVALID`,且不 insert outbox。
- unsafe message 被 fail-closed且不调用 EventsPublishApi。
- outbox service 把配置化 `maxAttempt` 写入 outbox默认值为 5。
- payload timestamp 使用 governance action `createTime`,缺失时才回退当前时间。
- Market outbox service 不依赖 `EventsPublishApi`
- worker disabled 不 claim。
- worker claim 使用配置化 `claimTimeoutSeconds`;默认值为 60。
- accepted -> published。
- duplicate accepted 回放同一 event identity。
- rejected / blocked -> dead_letter。
- `CommonResult` error 非最后一次 -> retryable最后一次 -> dead_letter。
- API exception 非最后一次 -> retryable最后一次 -> dead_letter。
- invalid payload 在 worker 侧 fail-closed dead_letter。
- terminal status update 必须携带 claim attempt ownership。
- terminal update 返回 0 时记录 stale claim warning。
### P1R mixed executable gates
一次旧类名命令只实际运行 29 个测试;该结果已丢弃,不计入最终 evidence。修正为真实存在的 14 个测试类后重新执行:
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-server -am -Dtest=P1rMarketEventsPublishMigrationSqlTest,P1rMarketEventsPublishDependencyTest,P1rMarketEventsPublishEndToEndTest,P1rKnowledgeEventsPublishMigrationSqlTest,P1rKnowledgeEventsPublishDependencyTest,P1rKnowledgeEventsPublishEndToEndTest,P1rAiEventsPublishMigrationSqlTest,P1rAiEventsPublishDependencyTest,P1rAiEventsPublishEndToEndTest,P1rApiCoverageReportTest,P1rEventsRealApiGateTest,P1rEventsRouteOwnershipTest,P1rAiRealApiGateTest,P1rAiRouteOwnershipTest -Dsurefire.failIfNoSpecifiedTests=false
```
结果exit 056 tests passfailures/errors/skipped 均为 0。
surefire XML 证明以下目标类实际运行且 test count > 0
| 测试类 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `P1rMarketEventsPublishMigrationSqlTest` | 3 | 0 | 0 | 0 |
| `P1rMarketEventsPublishDependencyTest` | 2 | 0 | 0 | 0 |
| `P1rMarketEventsPublishEndToEndTest` | 4 | 0 | 0 | 0 |
| `P1rKnowledgeEventsPublishMigrationSqlTest` | 3 | 0 | 0 | 0 |
| `P1rKnowledgeEventsPublishDependencyTest` | 2 | 0 | 0 | 0 |
| `P1rKnowledgeEventsPublishEndToEndTest` | 5 | 0 | 0 | 0 |
| `P1rAiEventsPublishMigrationSqlTest` | 4 | 0 | 0 | 0 |
| `P1rAiEventsPublishDependencyTest` | 2 | 0 | 0 | 0 |
| `P1rAiEventsPublishEndToEndTest` | 4 | 0 | 0 | 0 |
| `P1rApiCoverageReportTest` | 5 | 0 | 0 | 0 |
| `P1rEventsRealApiGateTest` | 5 | 0 | 0 | 0 |
| `P1rEventsRouteOwnershipTest` | 4 | 0 | 0 | 0 |
| `P1rAiRealApiGateTest` | 6 | 0 | 0 | 0 |
| `P1rAiRouteOwnershipTest` | 7 | 0 | 0 | 0 |
新增 Market gate 覆盖:
- V19 SQL 静态 migration gate。
- Market server 依赖 Events API不依赖 Events server。
- Events server 不反向依赖 AI / Knowledge / Market / Member / Content server。
- Market outbox -> worker -> EventsPublishServiceImpl -> `muse_unified_event` focused E2E。
- asset publisher owner 可见admin actor 不可见,非 owner 不可见。
- `recall` 只产生一条 `governance_action` notification。
- blocked publish 进入 outbox dead_letter 且 SSE 不可见。
- stale claim terminal update 因 attempt 不匹配被忽略。
- AI / Knowledge / Events 既有 P1R gates 不回退。
### Flyway `_test`
执行命令包含:
```bash
-Dtest=P1rMarketEventsPublishFlywayMigrationIT
-Dflyway.postgresql.transactional.lock=false
-Dp1r.flyway.locations=filesystem:sql/muse
-Dp1r.flyway.url=jdbc:postgresql://<host>:<port>/muse_p1r7d_events_publish_test
-Djava.net.useSystemProxies=false
-DsocksProxyHost=
-Dhttp.proxyHost=
-Dhttps.proxyHost=
```
结果exit 0`P1rMarketEventsPublishFlywayMigrationIT` tests=4、failures=0、errors=0、skipped=0。
关键输出:
```text
flyway_success=true
flyway_url=jdbc:postgresql://<host>/muse_p1r7d_events_publish_test
flyway_locations_requested=filesystem:sql/muse
flyway_locations_effective=filesystem:/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/muse-cloud/sql/muse
migrations_executed=19
successful_migration_count=19
target_schema_version=19
flyway_latest=19:extend market events publish outbox
schema_version=19
v19_table=muse_market_event_publish_outbox
v19_indexes=idx_muse_market_event_publish_outbox_claim,idx_muse_market_event_publish_outbox_owner_status,idx_muse_market_event_publish_outbox_action
v19_constraints=uk_muse_market_event_publish_outbox_id,uk_muse_market_event_publish_outbox_command,uk_muse_market_event_publish_outbox_action_notification,chk_muse_market_event_publish_outbox_event_type,chk_muse_market_event_publish_outbox_notification_type,chk_muse_market_event_publish_outbox_action_type,chk_muse_market_event_publish_outbox_status,chk_muse_market_event_publish_outbox_owner,chk_muse_market_event_publish_outbox_attempt_count,chk_muse_market_event_publish_outbox_max_attempt
v19_triggers=trg_muse_market_event_publish_outbox_update_time
```
Flyway IT 覆盖:
- 只允许 `_test` 数据库。
- JDBC URL 不允许携带 credential query parameters。
- V19 table / indexes / constraints / trigger 存在。
- V19 required columns 包含 `source_command_id``actor_user_id``preview_id``request_hash`,且为 NOT NULL。
- 非法 `event_type` / `notification_type` / `action_type` / `publish_status` / `owner_user_id <= 0` 的真实 insert 被 PostgreSQL 拒绝。
### Dependency tree gate
执行:
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o dependency:tree -pl muse-module-market/muse-module-market-server -Dincludes=cn.iocoder.cloud:muse-module-events-api,cn.iocoder.cloud:muse-module-events-server -DoutputType=text
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o dependency:tree -pl muse-module-events/muse-module-events-server -Dincludes=cn.iocoder.cloud:muse-module-ai-server,cn.iocoder.cloud:muse-module-knowledge-server,cn.iocoder.cloud:muse-module-market-server,cn.iocoder.cloud:muse-module-member-server,cn.iocoder.cloud:muse-module-content-server -DoutputType=text
```
结果:
- Market server dependency tree 明确包含 `cn.iocoder.cloud:muse-module-events-api:jar:2026.04-SNAPSHOT:compile`
- Market server dependency tree 未命中 `muse-module-events-server`
- Events server dependency tree 对 AI / Knowledge / Market / Member / Content server 过滤无输出。
### Coverage scanner
scanner 只在隔离副本运行,未在真实 worktree 写 coverage 报告。
隔离副本:
```text
/tmp/p1r7d-final-coverage-scan.nhBVhM
```
执行:
```bash
python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check
```
结果exit 0并只在隔离副本中生成 coverage JSON/Markdown。
隔离副本 summary
```text
completedOperations=100
needsVerificationOperations=133
incompleteOperations=0
genericPersistenceOperations=0
ssePlaceholderOperations=0
events.streamEvents=dedicated/needs_verification
marketOperations=32
marketStatuses=dedicated/needs_verification
```
真实 worktree 受保护 coverage JSON/Markdown diff 仍为空。
### 静态检查
执行:
```bash
git diff --check
```
结果exit 0无 whitespace error。
## 推断
基于 focused tests、P1R mixed executable gates、V19 Flyway `_test`、dependency tree、reactor build、隔离 coverage scanner、protected diff 和 `git diff --check` 的组合证据,可以判断 P1R-7d Market source owner propagation 应用层链路已具备进入 fresh implementation review 的 `needs_verification` 证据。
由于 Pascal/Mill 已 fresh 双 PASS可以推断 P1R-7d Market source owner propagation implementation evidence 已收口到 `needs_verification`。该推断不代表 Events / P1R-7 / Market completed也不代表可以自动提交或 push。
## 假设
- 当前 `_test` PostgreSQL 环境和 `~/.config/muse-repo/infra.env` 仍代表本地 Flyway 验证环境。
- Events 既有 `EventsPublishApi` 与 SSE visible query 合同不在 P1R-7d 中修改。
- P1R-7d 不要求补 Market OpenAPI coverage 状态到 `completed`
## 下一步
1. 按逻辑分组准备提交,但提交和 push 仍需用户明确批准。
2. 提交前再次确认 protected diff、`git diff --check``git status --short --branch`
3. 双 PASS 后仍只允许收口到 `needs_verification`,不得自动推进 completed。

View File

@ -74,6 +74,8 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
private MuseMarketSourceStatusEventMapper sourceStatusEventMapper;
@Mock
private MarketCommandService commandService;
@Mock
private MarketEventPublishOutboxService eventPublishOutboxService;
@AfterEach
void clearTenantContext() {
@ -239,6 +241,16 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
&& "applied".equals(action.getStatus())
&& "preview-1".equals(action.getPreviewId())
&& action.getActionSnapshot().contains("\"expectedStatus\":\"listed\"")));
verify(eventPublishOutboxService).createForGovernanceAction(
org.mockito.ArgumentMatchers.<MuseMarketGovernanceActionDO>argThat(action ->
"delist".equals(action.getActionType())
&& action.getActionId().startsWith("gov-action-")
&& Long.valueOf(9001L).equals(action.getOwnerUserId())
&& Long.valueOf(7001L).equals(action.getActorUserId())
&& "preview-1".equals(action.getPreviewId())
&& "hash-delist".equals(action.getRequestHash())
&& "cmd-delist".equals(action.getCommandId())
&& Long.valueOf(1001L).equals(action.getAssetId())));
verify(commandService).recordCompleted(any(), argThat(snapshot -> snapshot.contains("\"listingStatus\":\"delisted\"")));
}
@ -282,12 +294,46 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
"source_status_propagation".equals(impact.getImpactType())
&& "blocked".equals(impact.getStatus())
&& impact.getImpactSnapshot().contains("\"reason\":\"target_owner_unavailable\"")));
verify(eventPublishOutboxService).createForGovernanceAction(
org.mockito.ArgumentMatchers.<MuseMarketGovernanceActionDO>argThat(action ->
"recall".equals(action.getActionType())
&& Long.valueOf(9001L).equals(action.getOwnerUserId())
&& Long.valueOf(7001L).equals(action.getActorUserId())
&& "preview-2".equals(action.getPreviewId())
&& "hash-recall".equals(action.getRequestHash())
&& "cmd-recall".equals(action.getCommandId())
&& Long.valueOf(1001L).equals(action.getAssetId())));
verify(commandService).recordCompleted(any(), argThat(snapshot ->
snapshot.contains("\"listingStatus\":\"recalled\"")
&& snapshot.contains("\"generationPolicy\":\"stop_generation\"")
&& snapshot.contains("\"recheckReasons\":[\"needs_recheck\"")));
}
@Test
void should_replayGovernanceCommandWithoutBackfillingOutbox() {
TenantContextHolder.setTenantId(100L);
MuseMarketAssetDO asset = asset(1001L, "agent", "listed");
when(assetMapper.selectAdminById(100L, 1001L)).thenReturn(asset);
when(governancePreviewMapper.selectReadyByPreviewId(100L, "preview-1"))
.thenReturn(preview("preview-1", 1001L, 9001L, "delist", "ready", "stop_generation"));
when(commandService.buildRequestHash(any())).thenReturn("hash-delist");
when(commandService.reserveCommand(any(MarketCommandService.CommandEnvelope.class)))
.thenReturn(command("completed", "{\"assetId\":\"1001\",\"listingStatus\":\"delisted\","
+ "\"actionPolicy\":{\"acquirePolicy\":\"allowed\",\"installPolicy\":\"allowed\","
+ "\"bindPolicy\":\"allowed\",\"generationPolicy\":\"stop_generation\","
+ "\"recheckReasons\":[\"admin_delist\",\"stop_generation\"]},"
+ "\"actionId\":\"gov-action-replay\"}"));
AdminMarketGovernanceService.GovernanceActionResult result = service.delistAsset(7001L, "1", 1001L,
new AdminMarketGovernanceService.DelistAssetCommand("cmd-delist", "listed",
"preview-1", "侵权下架", "stop_generation"));
assertEquals("delisted", result.listingStatus());
verify(assetMapper, never()).updateGovernanceStatusByExpectedStatus(any(), any());
verify(governanceActionMapper, never()).insert(org.mockito.ArgumentMatchers.<MuseMarketGovernanceActionDO>any());
verify(eventPublishOutboxService, never()).createForGovernanceAction(any());
}
@Test
void should_rejectInvalidPreviewActionTypeBeforePersistence() {
TenantContextHolder.setTenantId(100L);
@ -312,7 +358,7 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
assertEquals(MARKET_GOVERNANCE_REQUEST_INVALID.getCode(), exception.getCode());
verifyNoInteractions(assetMapper, governancePreviewMapper, governanceActionMapper,
governanceImpactMapper, commandService);
governanceImpactMapper, eventPublishOutboxService, commandService);
}
@Test
@ -326,7 +372,7 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
assertEquals(MARKET_GOVERNANCE_REQUEST_INVALID.getCode(), exception.getCode());
verifyNoInteractions(assetMapper, governancePreviewMapper, governanceActionMapper,
governanceImpactMapper, sourceStatusEventMapper, commandService);
governanceImpactMapper, sourceStatusEventMapper, eventPublishOutboxService, commandService);
}
@Test

View File

@ -0,0 +1,204 @@
package cn.iocoder.muse.module.market.application.muse;
import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketEventPublishOutboxDO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketGovernanceActionDO;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketEventPublishOutboxMapper;
import cn.iocoder.muse.module.market.framework.config.MuseMarketEventsProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Market governance action Events publish outbox 创建服务测试
*/
class MarketEventPublishOutboxServiceTest extends BaseMockitoUnitTest {
private static final Pattern SHORT_COMMAND_ID = Pattern.compile("mk_evt:[0-9a-f]{32}");
private MarketEventPublishOutboxServiceImpl outboxService;
@Mock
private MuseMarketEventPublishOutboxMapper outboxMapper;
@BeforeEach
void setUp() {
outboxService = outboxServiceWithMaxAttempt(5);
}
@Test
void should_createQueuedOutboxWithExactAllowlistAndNumericResourceRef() {
MuseMarketGovernanceActionDO action = action("delist", 9001L, 1);
when(outboxMapper.insertIgnore(any(MuseMarketEventPublishOutboxDO.class))).thenReturn(1);
when(outboxMapper.selectByTenantIdAndOutboxId(any(), any())).thenAnswer(invocation -> {
MuseMarketEventPublishOutboxDO inserted = captureInsertedOutbox();
inserted.setId(501L);
return inserted;
});
MuseMarketEventPublishOutboxDO result = outboxService.createForGovernanceAction(action);
MuseMarketEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals(501L, result.getId());
assertEquals(100L, inserted.getTenantId());
assertEquals("mk_out:6586ca5961e39ebdad9410418184b444", inserted.getOutboxId());
assertEquals("mk_evt:6586ca5961e39ebdad9410418184b444", inserted.getCommandId());
assertEquals("cmd-governance", inserted.getSourceCommandId());
assertEquals("gov-action-1", inserted.getActionId());
assertEquals("delist", inserted.getActionType());
assertEquals(1001L, inserted.getAssetId());
assertEquals(9001L, inserted.getOwnerUserId());
assertEquals(7001L, inserted.getActorUserId());
assertEquals("preview-1", inserted.getPreviewId());
assertEquals("request-hash-secret", inserted.getRequestHash());
assertEquals("1", inserted.getSourceRevision());
assertEquals("notification", inserted.getEventType());
assertEquals("governance_action", inserted.getNotificationType());
assertEquals("market_asset", inserted.getResourceType());
assertEquals("1001", inserted.getResourceId());
assertEquals("queued", inserted.getPublishStatus());
assertEquals(0, inserted.getAttemptCount());
assertEquals(5, inserted.getMaxAttempt());
assertTrue(SHORT_COMMAND_ID.matcher(inserted.getCommandId()).matches());
assertTrue(inserted.getCommandId().contains("6586ca5961e39ebdad9410418184b444"),
"commandId 必须由 tenantId|actionId|ownerUserId|governance_action 生成");
assertTrue(inserted.getOutboxId().contains("6586ca5961e39ebdad9410418184b444"),
"outboxId 必须由 tenantId|actionId|ownerUserId|governance_action 生成");
Map<String, Object> payload = JsonUtils.parseObject(inserted.getPayloadSummary(), Map.class);
assertEquals(Set.of("type", "message", "resourceRef", "timestamp"), payload.keySet());
assertEquals("governance_action", payload.get("type"));
assertEquals("资产已下架", payload.get("message"));
assertEquals("2026-06-07T12:00", payload.get("timestamp"));
Map<?, ?> resourceRef = assertInstanceOf(Map.class, payload.get("resourceRef"));
assertEquals(Set.of("resourceType", "resourceId"), resourceRef.keySet());
assertEquals("market_asset", resourceRef.get("resourceType"));
assertInstanceOf(Number.class, resourceRef.get("resourceId"));
assertEquals(1001L, ((Number) resourceRef.get("resourceId")).longValue());
assertNoForbiddenPayloadFields(inserted.getPayloadSummary());
}
@Test
void should_writeConfiguredMaxAttemptIntoOutbox() {
outboxService = outboxServiceWithMaxAttempt(9);
MuseMarketGovernanceActionDO action = action("delist", 9001L, 1);
when(outboxMapper.insertIgnore(any(MuseMarketEventPublishOutboxDO.class))).thenReturn(1);
when(outboxMapper.selectByTenantIdAndOutboxId(any(), any())).thenAnswer(invocation -> {
MuseMarketEventPublishOutboxDO inserted = captureInsertedOutbox();
inserted.setId(502L);
return inserted;
});
outboxService.createForGovernanceAction(action);
MuseMarketEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals(9, inserted.getMaxAttempt(), "outbox maxAttempt 必须来自 muse.market.events.publish-worker.max-attempt");
}
@Test
void should_reuseExistingOutboxOnDuplicateCreate() {
MuseMarketGovernanceActionDO action = action("recall", 9001L, null);
MuseMarketEventPublishOutboxDO existing = new MuseMarketEventPublishOutboxDO();
existing.setId(601L);
existing.setActionId("gov-action-1");
existing.setNotificationType("governance_action");
when(outboxMapper.insertIgnore(any(MuseMarketEventPublishOutboxDO.class))).thenReturn(0);
when(outboxMapper.selectByTenantIdAndActionIdAndNotificationType(100L, "gov-action-1",
"governance_action")).thenReturn(existing);
MuseMarketEventPublishOutboxDO result = outboxService.createForGovernanceAction(action);
assertEquals(601L, result.getId());
MuseMarketEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals("__none__", inserted.getSourceRevision());
Map<String, Object> payload = JsonUtils.parseObject(inserted.getPayloadSummary(), Map.class);
assertEquals("资产已召回", payload.get("message"));
}
@Test
void should_notCreateOutboxForUnsupportedActionOrInvalidOwner() {
assertNull(outboxService.createForGovernanceAction(action("review_rejection", 9001L, 1)));
assertNull(outboxService.createForGovernanceAction(action("delist", 0L, 1)));
verify(outboxMapper, never()).insertIgnore(any(MuseMarketEventPublishOutboxDO.class));
}
@Test
void should_failClosedBeforeInsertWhenPayloadInvalid() {
MuseMarketGovernanceActionDO action = action("delist", 9001L, 1);
action.setAssetId(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> outboxService.createForGovernanceAction(action));
assertEquals(1_044_000_030, exception.getCode());
assertEquals("MARKET_EVENTS_PAYLOAD_INVALID", exception.getMessage());
verify(outboxMapper, never()).insertIgnore(any(MuseMarketEventPublishOutboxDO.class));
}
@Test
void should_notDependOnEventsPublishApiInOutboxCreationService() {
for (Field field : MarketEventPublishOutboxServiceImpl.class.getDeclaredFields()) {
assertFalse(EventsPublishApi.class.equals(field.getType()),
"outbox 创建服务只允许写 Market 本域 outbox不允许持有或调用 EventsPublishApi");
}
}
private MuseMarketEventPublishOutboxDO captureInsertedOutbox() {
ArgumentCaptor<MuseMarketEventPublishOutboxDO> captor =
ArgumentCaptor.forClass(MuseMarketEventPublishOutboxDO.class);
verify(outboxMapper).insertIgnore(captor.capture());
return captor.getValue();
}
private MarketEventPublishOutboxServiceImpl outboxServiceWithMaxAttempt(int maxAttempt) {
MuseMarketEventsProperties properties = new MuseMarketEventsProperties();
properties.getPublishWorker().setMaxAttempt(maxAttempt);
return new MarketEventPublishOutboxServiceImpl(outboxMapper, properties);
}
private static void assertNoForbiddenPayloadFields(String payloadJson) {
for (String forbidden : Set.of("reason", "actionSnapshot", "riskSummary", "authorization",
"handoff", "token", "targetOwner", "adminNote", "errorStack", "requestHash")) {
assertFalse(payloadJson.contains(forbidden), "payload 不得包含敏感字段:" + forbidden);
}
}
private static MuseMarketGovernanceActionDO action(String actionType, Long ownerUserId, Integer revision) {
MuseMarketGovernanceActionDO action = new MuseMarketGovernanceActionDO();
action.setTenantId(100L);
action.setActionId("gov-action-1");
action.setCommandId("cmd-governance");
action.setActionType(actionType);
action.setAssetId(1001L);
action.setOwnerUserId(ownerUserId);
action.setActorUserId(7001L);
action.setPreviewId("preview-1");
action.setReason("内部治理原因不得外发");
action.setRequestHash("request-hash-secret");
action.setActionSnapshot("{\"reason\":\"should-not-leak\",\"handoffToken\":\"secret\"}");
action.setRevision(revision);
action.setCreateTime(LocalDateTime.of(2026, 6, 7, 12, 0));
return action;
}
}

View File

@ -0,0 +1,371 @@
package cn.iocoder.muse.module.market.application.muse;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO;
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketEventPublishOutboxDO;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketEventPublishOutboxMapper;
import cn.iocoder.muse.module.market.framework.config.MuseMarketEventsProperties;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Market Events publish worker 测试
*/
class MarketEventPublishWorkerTest {
private final MuseMarketEventPublishOutboxMapper outboxMapper =
mock(MuseMarketEventPublishOutboxMapper.class);
private final EventsPublishApi eventsPublishApi = mock(EventsPublishApi.class);
@AfterEach
void clearTenantContext() {
TenantContextHolder.clear();
}
@Test
void should_notClaimOutboxWhenWorkerDisabled() {
MarketEventPublishWorker worker = worker(false);
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper, never()).claimNextPublishOutbox(anyLong());
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_publishAcceptedNotificationForPublisherOwnerAndMarkPublished() {
MuseMarketEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_market_1", 301L);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_market_1", 301L)).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(1, dispatched);
EventsPublishReqDTO reqDTO = capturePublishReq();
assertEquals("mk_evt:0123456789abcdef0123456789abcdef", reqDTO.getCommandId());
assertEquals(100L, reqDTO.getTenantId());
assertEquals(9001L, reqDTO.getOwnerUserId());
assertEquals("market", reqDTO.getSourceOwner());
assertEquals("market_governance_action", reqDTO.getSourceType());
assertEquals("gov-action-1", reqDTO.getSourceId());
assertEquals("__none__", reqDTO.getSourceRevision());
assertEquals("notification", reqDTO.getEventType());
assertEquals("market_asset", reqDTO.getResourceType());
assertEquals("1001", reqDTO.getResourceId());
assertPayload(reqDTO.getPayloadSummary());
verify(outboxMapper).markPublished(501L, 100L, 1, "evt_market_1", 301L);
assertNull(TenantContextHolder.getTenantId(), "worker 完成后必须清理租户上下文");
}
@Test
void should_claimWithConfiguredTimeoutSeconds() {
MuseMarketEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(180L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_market_claim_timeout", 304L);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_market_claim_timeout", 304L)).thenReturn(1);
MuseMarketEventsProperties properties = new MuseMarketEventsProperties();
properties.getPublishWorker().setEnabled(true);
properties.getPublishWorker().setClaimTimeoutSeconds(180L);
int dispatched = new MarketEventPublishWorker(outboxMapper, eventsPublishApi, properties).dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).claimNextPublishOutbox(180L);
}
@Test
void should_markDuplicateAcceptedNotificationAsPublished() {
MuseMarketEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_market_dup", 302L);
respDTO.setDuplicate(true);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_market_dup", 302L)).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).markPublished(501L, 100L, 1, "evt_market_dup", 302L);
}
@Test
void should_markRejectedAndBlockedPublishAsDeadLetter() {
assertDeadLetterForTerminalStatus("rejected");
assertDeadLetterForTerminalStatus("blocked");
}
@Test
void should_markCommonResultErrorAsRetryableBeforeLastAttemptAndDeadLetterOnLastAttempt() {
MuseMarketEventPublishOutboxDO retryable = runningOutbox();
retryable.setAttemptCount(2);
retryable.setMaxAttempt(5);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(retryable);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.error(500, "temporary secret stack"));
when(outboxMapper.markRetryable(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
any(LocalDateTime.class), anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true).dispatchOnce());
verify(outboxMapper).markRetryable(org.mockito.Mockito.eq(501L), org.mockito.Mockito.eq(100L),
org.mockito.Mockito.eq(2), any(LocalDateTime.class),
org.mockito.Mockito.eq("MARKET_EVENTS_PUBLISH_API_FAILED"),
org.mockito.Mockito.eq("Market Events publish failed"));
MuseMarketEventPublishOutboxMapper lastAttemptMapper = mock(MuseMarketEventPublishOutboxMapper.class);
EventsPublishApi lastAttemptApi = mock(EventsPublishApi.class);
MuseMarketEventPublishOutboxDO lastAttempt = runningOutbox();
lastAttempt.setAttemptCount(5);
lastAttempt.setMaxAttempt(5);
when(lastAttemptMapper.claimNextPublishOutbox(60L)).thenReturn(lastAttempt);
when(lastAttemptApi.publish(any())).thenReturn(CommonResult.error(500, "temporary secret stack"));
when(lastAttemptMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true, lastAttemptMapper, lastAttemptApi).dispatchOnce());
verify(lastAttemptMapper).markDeadLetter(501L, 100L, 5,
"MARKET_EVENTS_PUBLISH_RETRY_EXHAUSTED", "Market Events publish retry exhausted");
}
@Test
void should_markExceptionAsRetryableBeforeLastAttemptAndDeadLetterOnLastAttempt() {
MuseMarketEventPublishOutboxDO retryable = runningOutbox();
retryable.setAttemptCount(2);
retryable.setMaxAttempt(5);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(retryable);
when(eventsPublishApi.publish(any())).thenThrow(new IllegalStateException("boom secret stack"));
when(outboxMapper.markRetryable(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
any(LocalDateTime.class), anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true).dispatchOnce());
verify(outboxMapper).markRetryable(org.mockito.Mockito.eq(501L), org.mockito.Mockito.eq(100L),
org.mockito.Mockito.eq(2), any(LocalDateTime.class),
org.mockito.Mockito.eq("MARKET_EVENTS_PUBLISH_TEMPORARY_UNAVAILABLE"),
org.mockito.Mockito.eq("Market Events publish temporary unavailable"));
MuseMarketEventPublishOutboxMapper lastAttemptMapper = mock(MuseMarketEventPublishOutboxMapper.class);
EventsPublishApi lastAttemptApi = mock(EventsPublishApi.class);
MuseMarketEventPublishOutboxDO lastAttempt = runningOutbox();
lastAttempt.setAttemptCount(5);
lastAttempt.setMaxAttempt(5);
when(lastAttemptMapper.claimNextPublishOutbox(60L)).thenReturn(lastAttempt);
when(lastAttemptApi.publish(any())).thenThrow(new IllegalStateException("boom secret stack"));
when(lastAttemptMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true, lastAttemptMapper, lastAttemptApi).dispatchOnce());
verify(lastAttemptMapper).markDeadLetter(501L, 100L, 5,
"MARKET_EVENTS_PUBLISH_RETRY_EXHAUSTED", "Market Events publish retry exhausted");
}
@Test
void should_failClosedForInvalidPayloadAndNotPublish() {
MuseMarketEventPublishOutboxDO outbox = runningOutbox();
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"type", "governance_action",
"message", "资产已下架",
"resourceRef", Map.of("resourceType", "market_asset", "resourceId", "1001"),
"timestamp", "2026-06-07T10:00:00")));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(501L, 100L, 1,
"MARKET_EVENTS_PAYLOAD_INVALID", "Market Events publish payload invalid");
}
@Test
void should_failClosedForUnsafeMessageAndNotPublish() {
MuseMarketEventPublishOutboxDO outbox = runningOutbox();
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"type", "governance_action",
"message", "内部治理原因不得外发",
"resourceRef", Map.of("resourceType", "market_asset", "resourceId", 1001L),
"timestamp", "2026-06-07T10:00:00")));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(501L, 100L, 1,
"MARKET_EVENTS_PAYLOAD_INVALID", "Market Events publish payload invalid");
}
@Test
void should_guardTerminalStatusUpdatesWithClaimAttemptOwnership() throws NoSuchMethodException {
assertClaimOwnershipGuard("markPublished", Long.class, Long.class, Integer.class, String.class, Long.class);
assertClaimOwnershipGuard("markRetryable", Long.class, Long.class, Integer.class, LocalDateTime.class,
String.class, String.class);
assertClaimOwnershipGuard("markDeadLetter", Long.class, Long.class, Integer.class, String.class,
String.class);
}
@Test
void should_logWhenAcceptedTerminalUpdateIsSkippedByStaleClaim() {
MuseMarketEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_stale_claim", 303L);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_stale_claim", 303L)).thenReturn(0);
ListAppender<ILoggingEvent> appender = attachWorkerLogAppender();
try {
int dispatched = worker(true).dispatchOnce();
assertEquals(1, dispatched);
assertTrue(appender.list.stream().anyMatch(event -> event.getLevel().equals(Level.WARN)
&& event.getFormattedMessage().contains("Market Events publish 终态回写被跳过")
&& event.getFormattedMessage().contains("outboxId=501")
&& event.getFormattedMessage().contains("attempt=1")
&& event.getFormattedMessage().contains("targetStatus=published")),
"accepted 发布后若终态回写 0 行,必须记录 stale claim 诊断日志");
} finally {
detachWorkerLogAppender(appender);
}
}
private void assertDeadLetterForTerminalStatus(String status) {
MuseMarketEventPublishOutboxMapper mapper = mock(MuseMarketEventPublishOutboxMapper.class);
EventsPublishApi api = mock(EventsPublishApi.class);
MuseMarketEventPublishOutboxDO outbox = runningOutbox();
when(mapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
respDTO.setPublishStatus(status);
respDTO.setPublishErrorCode("payload_schema_mismatch");
when(api.publish(any())).thenReturn(CommonResult.success(respDTO));
when(mapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
int dispatched = worker(true, mapper, api).dispatchOnce();
assertEquals(1, dispatched);
verify(mapper).markDeadLetter(501L, 100L, 1, "payload_schema_mismatch",
"Market Events publish rejected");
}
private static void assertClaimOwnershipGuard(String methodName, Class<?>... parameterTypes)
throws NoSuchMethodException {
Method method = MuseMarketEventPublishOutboxMapper.class.getMethod(methodName, parameterTypes);
Update update = method.getAnnotation(Update.class);
String sql = String.join(" ", update.value()).toLowerCase().replaceAll("\\s+", " ");
assertTrue(sql.contains("publish_status = 'running'"),
methodName + " 必须只允许当前 running claim 回写终态");
assertTrue(sql.contains("attempt_count = #{claimedattemptcount}"),
methodName + " 必须用本次 claim attempt_count 防止过期 worker 覆盖新终态");
}
private static ListAppender<ILoggingEvent> attachWorkerLogAppender() {
Logger logger = (Logger) LoggerFactory.getLogger(MarketEventPublishWorker.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
return appender;
}
private static void detachWorkerLogAppender(ListAppender<ILoggingEvent> appender) {
Logger logger = (Logger) LoggerFactory.getLogger(MarketEventPublishWorker.class);
logger.detachAppender(appender);
appender.stop();
}
private MarketEventPublishWorker worker(boolean enabled) {
return worker(enabled, outboxMapper, eventsPublishApi);
}
private MarketEventPublishWorker worker(boolean enabled, MuseMarketEventPublishOutboxMapper mapper,
EventsPublishApi api) {
MuseMarketEventsProperties properties = new MuseMarketEventsProperties();
properties.getPublishWorker().setEnabled(enabled);
return new MarketEventPublishWorker(mapper, api, properties);
}
private EventsPublishReqDTO capturePublishReq() {
ArgumentCaptor<EventsPublishReqDTO> captor = ArgumentCaptor.forClass(EventsPublishReqDTO.class);
verify(eventsPublishApi).publish(captor.capture());
return captor.getValue();
}
private static EventsPublishRespDTO accepted(String eventId, Long sequenceNo) {
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
respDTO.setPublishStatus("accepted");
respDTO.setEventId(eventId);
respDTO.setSequenceNo(sequenceNo);
return respDTO;
}
private static void assertPayload(Map<String, Object> payload) {
assertEquals(Set.of("type", "message", "resourceRef", "timestamp"), payload.keySet());
assertEquals("governance_action", payload.get("type"));
assertEquals("资产已下架", payload.get("message"));
Map<?, ?> resourceRef = assertInstanceOf(Map.class, payload.get("resourceRef"));
assertEquals(Set.of("resourceType", "resourceId"), resourceRef.keySet());
assertEquals("market_asset", resourceRef.get("resourceType"));
assertInstanceOf(Number.class, resourceRef.get("resourceId"));
assertEquals(1001L, ((Number) resourceRef.get("resourceId")).longValue());
}
private static MuseMarketEventPublishOutboxDO runningOutbox() {
MuseMarketEventPublishOutboxDO outbox = new MuseMarketEventPublishOutboxDO();
outbox.setId(501L);
outbox.setTenantId(100L);
outbox.setOutboxId("mk_out:0123456789abcdef0123456789abcdef");
outbox.setCommandId("mk_evt:0123456789abcdef0123456789abcdef");
outbox.setActionId("gov-action-1");
outbox.setActionType("delist");
outbox.setAssetId(1001L);
outbox.setOwnerUserId(9001L);
outbox.setSourceRevision("__none__");
outbox.setEventType("notification");
outbox.setNotificationType("governance_action");
outbox.setResourceType("market_asset");
outbox.setResourceId("1001");
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"type", "governance_action",
"message", "资产已下架",
"resourceRef", Map.of("resourceType", "market_asset", "resourceId", 1001L),
"timestamp", "2026-06-07T10:00:00")));
outbox.setPublishStatus("running");
outbox.setAttemptCount(1);
outbox.setMaxAttempt(5);
outbox.setCreateTime(LocalDateTime.of(2026, 6, 7, 10, 0));
return outbox;
}
}

View File

@ -0,0 +1,119 @@
package cn.iocoder.muse.server.framework.api;
import org.junit.jupiter.api.Test;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-7d Market governance action event publish 依赖方向门禁
*/
class P1rMarketEventsPublishDependencyTest {
private static final String PROJECT_GROUP_ID = "cn.iocoder.cloud";
private static final String MARKET_SERVER_POM =
"muse-cloud/muse-module-market/muse-module-market-server/pom.xml";
private static final String EVENTS_SERVER_POM =
"muse-cloud/muse-module-events/muse-module-events-server/pom.xml";
@Test
void should_keep_market_server_depending_on_events_api_only() throws Exception {
Set<MavenDependency> dependencies = readDirectDependencies(MARKET_SERVER_POM);
assertTrue(dependencies.contains(projectDependency("muse-module-events-api")),
"Market server 必须直接依赖 muse-module-events-api才能向 Events owner 发布治理通知");
assertFalse(dependencies.contains(projectDependency("muse-module-events-server")),
"Market server 不能依赖 muse-module-events-server避免跨 owner 反向耦合服务实现");
}
@Test
void should_not_let_events_server_depend_on_source_owner_servers() throws Exception {
Set<MavenDependency> dependencies = readDirectDependencies(EVENTS_SERVER_POM);
for (String forbiddenArtifactId : List.of(
"muse-module-ai-server",
"muse-module-knowledge-server",
"muse-module-market-server",
"muse-module-member-server",
"muse-module-content-server")) {
assertFalse(dependencies.contains(projectDependency(forbiddenArtifactId)),
"Events server 不能反向依赖 source owner server: " + forbiddenArtifactId);
}
}
private static Set<MavenDependency> readDirectDependencies(String pomRelativePath)
throws IOException, ParserConfigurationException, SAXException {
Path pomPath = findRepositoryRoot().resolve(pomRelativePath);
assertTrue(Files.exists(pomPath), "待检查的 Maven POM 必须存在: " + pomRelativePath);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
// WHY测试只读取受信任的本仓 POM但仍关闭外部实体避免 XML 解析意外访问本地或网络资源
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
Document document = factory.newDocumentBuilder().parse(pomPath.toFile());
Set<MavenDependency> dependencies = new LinkedHashSet<>();
for (Element dependenciesElement : childElements(document.getDocumentElement(), "dependencies")) {
for (Element dependencyElement : childElements(dependenciesElement, "dependency")) {
dependencies.add(new MavenDependency(
childText(dependencyElement, "groupId"),
childText(dependencyElement, "artifactId")));
}
}
return dependencies;
}
private static MavenDependency projectDependency(String artifactId) {
return new MavenDependency(PROJECT_GROUP_ID, artifactId);
}
private static List<Element> childElements(Element parent, String localName) {
return java.util.stream.IntStream.range(0, parent.getChildNodes().getLength())
.mapToObj(parent.getChildNodes()::item)
.filter(Element.class::isInstance)
.map(Element.class::cast)
.filter(element -> localName.equals(element.getLocalName()))
.toList();
}
private static String childText(Element parent, String localName) {
return childElements(parent, localName).stream()
.findFirst()
.map(Element::getTextContent)
.map(String::trim)
.orElse("");
}
/**
* 从当前 Maven 执行目录逐级向上查找仓库根目录避免 surefire 在不同模块目录执行时路径失效
*/
private static Path findRepositoryRoot() {
Path current = Path.of("").toAbsolutePath();
for (Path candidate = current; candidate != null; candidate = candidate.getParent()) {
if (Files.exists(candidate.resolve("muse-cloud/muse-server/pom.xml"))) {
return candidate;
}
}
return current;
}
private record MavenDependency(String groupId, String artifactId) {
}
}

View File

@ -0,0 +1,335 @@
package cn.iocoder.muse.server.framework.api;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO;
import cn.iocoder.muse.module.events.application.publish.EventsPublishServiceImpl;
import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO;
import cn.iocoder.muse.module.events.dal.mysql.UnifiedEventMapper;
import cn.iocoder.muse.module.market.application.muse.MarketEventPublishWorker;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketEventPublishOutboxDO;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketEventPublishOutboxMapper;
import cn.iocoder.muse.module.market.framework.config.MuseMarketEventsProperties;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* P1R-7d Market governance action 通过本域 worker 发布到统一 Events focused E2E 测试
*/
class P1rMarketEventsPublishEndToEndTest {
@Test
void should_publishMarketGovernanceActionToVisibleEventsForPublisherOwnerOnly() {
MuseMarketEventPublishOutboxDO outbox = buildRunningOutbox(501L, "mk_evt_delist", "gov-action-delist",
"delist", "资产已下架");
TestHarness harness = buildHarness(List.of(outbox));
int dispatched = harness.worker().dispatchOnce();
assertEquals(1, dispatched);
assertEquals("published", outbox.getPublishStatus());
assertNotNull(outbox.getPublishedEventId());
assertNotNull(outbox.getPublishedSequenceNo());
LocalDateTime visibleNow = LocalDateTime.now().plusSeconds(1);
List<UnifiedEventDO> publisherOwnerEvents = harness.eventsPublishService()
.listVisibleEvents(100L, 9001L, 0L, visibleNow);
assertEquals(1, publisherOwnerEvents.size());
UnifiedEventDO event = publisherOwnerEvents.getFirst();
assertEquals("accepted", event.getPublishStatus());
assertEquals("notification", event.getEventType());
assertEquals("market", event.getSourceOwner());
assertEquals("market_governance_action", event.getSourceType());
assertEquals("gov-action-delist", event.getSourceId());
assertEquals("__none__", event.getSourceRevision());
assertEquals("market_asset", event.getResourceType());
assertEquals("1001", event.getResourceId());
assertEquals(outbox.getPublishedEventId(), event.getEventId());
assertEquals(outbox.getPublishedSequenceNo(), event.getSequenceNo());
assertTrue(event.getPayloadSummary().contains("\"type\":\"governance_action\""));
assertTrue(event.getPayloadSummary().contains("\"message\":\"资产已下架\""));
assertTrue(event.getPayloadSummary().contains("\"resourceId\":1001"));
assertFalse(event.getPayloadSummary().contains("reason"));
assertFalse(event.getPayloadSummary().contains("actionSnapshot"));
assertFalse(event.getPayloadSummary().contains("handoff"));
assertTrue(harness.eventsPublishService().listVisibleEvents(100L, 7001L, 0L, visibleNow).isEmpty(),
"admin actor 不能因为 actorUserId 可见");
assertTrue(harness.eventsPublishService().listVisibleEvents(100L, 9002L, 0L, visibleNow).isEmpty(),
"非资产发布者 owner 不可见");
}
@Test
void should_publishRecallAsOneGovernanceActionNotificationOnly() {
MuseMarketEventPublishOutboxDO recallOutbox = buildRunningOutbox(502L, "mk_evt_recall", "gov-action-recall",
"recall", "资产已召回");
TestHarness harness = buildHarness(List.of(recallOutbox));
int dispatched = harness.worker().dispatchOnce();
assertEquals(1, dispatched);
assertEquals("published", recallOutbox.getPublishStatus());
List<UnifiedEventDO> rows = harness.unifiedRows();
assertEquals(1, rows.size(), "recall 第一切片只能发布一条 governance_action notification");
assertEquals("gov-action-recall", rows.getFirst().getSourceId());
assertTrue(rows.getFirst().getPayloadSummary().contains("\"message\":\"资产已召回\""));
assertFalse(rows.getFirst().getPayloadSummary().contains("source_status"));
}
@Test
void should_markBlockedPublishAsDeadLetterAndKeepInvisible() {
MuseMarketEventPublishOutboxDO outbox = buildRunningOutbox(503L, "mk_evt_blocked", "gov-action-blocked",
"delist", "资产已下架");
EventsPublishApi blockedAdapter = req -> {
assertEquals("mk_evt_blocked", req.getCommandId());
EventsPublishRespDTO resp = new EventsPublishRespDTO();
resp.setPublishStatus("blocked");
resp.setPublishErrorCode(EventsPublishServiceImpl.ERROR_PAYLOAD_SCHEMA_MISMATCH);
return CommonResult.success(resp);
};
TestHarness harness = buildHarness(List.of(outbox), blockedAdapter);
int dispatched = harness.worker().dispatchOnce();
assertEquals(1, dispatched);
assertEquals("dead_letter", outbox.getPublishStatus());
assertEquals(EventsPublishServiceImpl.ERROR_PAYLOAD_SCHEMA_MISMATCH, outbox.getLastErrorCode());
assertTrue(harness.eventsPublishService()
.listVisibleEvents(100L, 9001L, 0L, LocalDateTime.now().plusSeconds(1))
.isEmpty());
assertTrue(harness.unifiedRows().isEmpty());
}
@Test
void should_ignoreStaleClaimTerminalUpdateWhenAttemptNoLongerMatches() {
MuseMarketEventPublishOutboxDO outbox = buildRunningOutbox(504L, "mk_evt_stale", "gov-action-stale",
"delist", "资产已下架");
outbox.setAttemptCount(2);
TestHarness harness = buildHarness(List.of(outbox));
outbox.setAttemptCount(3);
int staleUpdate = harness.outboxMapper().markPublished(outbox.getId(), outbox.getTenantId(), 2,
"evt_stale", 9L);
assertEquals(0, staleUpdate);
assertEquals("running", outbox.getPublishStatus());
assertEquals(null, outbox.getPublishedEventId());
assertEquals(null, outbox.getPublishedSequenceNo());
}
private static TestHarness buildHarness(List<MuseMarketEventPublishOutboxDO> outboxes) {
return buildHarness(outboxes, null);
}
private static TestHarness buildHarness(List<MuseMarketEventPublishOutboxDO> outboxes,
EventsPublishApi customEventsPublishApi) {
UnifiedEventMapper unifiedEventMapper = mock(UnifiedEventMapper.class);
List<UnifiedEventDO> unifiedRows = new ArrayList<>();
AtomicLong sequence = new AtomicLong(1L);
stubUnifiedEventMapper(unifiedEventMapper, unifiedRows, sequence);
EventsPublishServiceImpl eventsPublishService = new EventsPublishServiceImpl(unifiedEventMapper);
EventsPublishApi eventsPublishApi = customEventsPublishApi == null
? req -> CommonResult.success(eventsPublishService.publish(req))
: customEventsPublishApi;
MuseMarketEventPublishOutboxMapper outboxMapper = mock(MuseMarketEventPublishOutboxMapper.class);
Deque<MuseMarketEventPublishOutboxDO> queue = new ArrayDeque<>(outboxes);
when(outboxMapper.claimNextPublishOutbox(60L)).thenAnswer(invocation -> queue.pollFirst());
when(outboxMapper.markPublished(anyLong(), anyLong(), anyInt(), anyString(), anyLong()))
.thenAnswer(invocation -> {
MuseMarketEventPublishOutboxDO outbox = findOutbox(outboxes, invocation.getArgument(0),
invocation.getArgument(1));
if (!claimAttemptMatches(outbox, invocation.getArgument(2))) {
return 0;
}
outbox.setPublishStatus("published");
outbox.setLastErrorCode(null);
outbox.setLastErrorMessage(null);
outbox.setPublishedEventId(invocation.getArgument(3));
outbox.setPublishedSequenceNo(invocation.getArgument(4));
return 1;
});
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), anyInt(), anyString(), anyString()))
.thenAnswer(invocation -> {
MuseMarketEventPublishOutboxDO outbox = findOutbox(outboxes, invocation.getArgument(0),
invocation.getArgument(1));
if (!claimAttemptMatches(outbox, invocation.getArgument(2))) {
return 0;
}
outbox.setPublishStatus("dead_letter");
outbox.setLastErrorCode(invocation.getArgument(3));
outbox.setLastErrorMessage(invocation.getArgument(4));
return 1;
});
MuseMarketEventsProperties properties = new MuseMarketEventsProperties();
properties.getPublishWorker().setEnabled(true);
MarketEventPublishWorker worker = new MarketEventPublishWorker(outboxMapper, eventsPublishApi, properties);
return new TestHarness(worker, outboxMapper, eventsPublishService, unifiedRows);
}
private static void stubUnifiedEventMapper(UnifiedEventMapper unifiedEventMapper,
List<UnifiedEventDO> unifiedRows,
AtomicLong sequence) {
when(unifiedEventMapper.insertIgnore(any(UnifiedEventDO.class))).thenAnswer(invocation -> {
UnifiedEventDO event = invocation.getArgument(0);
// 这里用内存表模拟 PostgreSQL ON CONFLICT DO NOTHING锁定 command source tuple 双幂等合同
if (findByCommandId(unifiedRows, event.getTenantId(), event.getCommandId()) != null
|| findBySourceTuple(unifiedRows, event.getTenantId(), event.getSourceOwner(),
event.getSourceType(), event.getSourceId(), event.getSourceRevision(), event.getEventType()) != null) {
return 0;
}
UnifiedEventDO stored = copyUnifiedEvent(event);
long nextSequence = sequence.getAndIncrement();
stored.setId(nextSequence);
stored.setSequenceNo(nextSequence);
unifiedRows.add(stored);
return 1;
});
when(unifiedEventMapper.selectByTenantIdAndCommandId(anyLong(), anyString()))
.thenAnswer(invocation -> findByCommandId(unifiedRows, invocation.getArgument(0),
invocation.getArgument(1)));
when(unifiedEventMapper.selectByTenantIdAndSourceTuple(anyLong(), anyString(), anyString(), anyString(),
anyString(), anyString()))
.thenAnswer(invocation -> findBySourceTuple(unifiedRows, invocation.getArgument(0),
invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3),
invocation.getArgument(4), invocation.getArgument(5)));
when(unifiedEventMapper.selectVisibleEventsForOwner(anyLong(), anyLong(), anyLong(), any(LocalDateTime.class)))
.thenAnswer(invocation -> selectVisibleEventsForOwner(unifiedRows, invocation.getArgument(0),
invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3)));
}
private static MuseMarketEventPublishOutboxDO buildRunningOutbox(Long id, String commandId, String actionId,
String actionType, String message) {
MuseMarketEventPublishOutboxDO outbox = new MuseMarketEventPublishOutboxDO();
outbox.setId(id);
outbox.setTenantId(100L);
outbox.setOutboxId("mk_out:" + commandId);
outbox.setCommandId(commandId);
outbox.setActionId(actionId);
outbox.setActionType(actionType);
outbox.setAssetId(1001L);
outbox.setOwnerUserId(9001L);
outbox.setSourceRevision("__none__");
outbox.setEventType("notification");
outbox.setNotificationType("governance_action");
outbox.setResourceType("market_asset");
outbox.setResourceId("1001");
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"type", "governance_action",
"message", message,
"resourceRef", Map.of("resourceType", "market_asset", "resourceId", 1001L),
"timestamp", "2026-06-07T10:00:00")));
outbox.setPublishStatus("running");
outbox.setAttemptCount(1);
outbox.setMaxAttempt(5);
outbox.setCreateTime(LocalDateTime.of(2026, 6, 7, 10, 0));
outbox.setDeleted(false);
return outbox;
}
private static MuseMarketEventPublishOutboxDO findOutbox(List<MuseMarketEventPublishOutboxDO> outboxes,
Long id,
Long tenantId) {
return outboxes.stream()
.filter(row -> Objects.equals(row.getId(), id))
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("测试 outbox 未找到"));
}
private static boolean claimAttemptMatches(MuseMarketEventPublishOutboxDO outbox, Integer claimedAttemptCount) {
return Objects.equals(outbox.getPublishStatus(), "running")
&& Objects.equals(outbox.getAttemptCount(), claimedAttemptCount);
}
private static UnifiedEventDO findByCommandId(List<UnifiedEventDO> unifiedRows, Long tenantId, String commandId) {
return unifiedRows.stream()
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
.filter(row -> Objects.equals(row.getCommandId(), commandId))
.findFirst()
.orElse(null);
}
private static UnifiedEventDO findBySourceTuple(List<UnifiedEventDO> unifiedRows, Long tenantId, String sourceOwner,
String sourceType, String sourceId, String sourceRevision,
String eventType) {
return unifiedRows.stream()
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
.filter(row -> Objects.equals(row.getSourceOwner(), sourceOwner))
.filter(row -> Objects.equals(row.getSourceType(), sourceType))
.filter(row -> Objects.equals(row.getSourceId(), sourceId))
.filter(row -> Objects.equals(row.getSourceRevision(), sourceRevision))
.filter(row -> Objects.equals(row.getEventType(), eventType))
.findFirst()
.orElse(null);
}
private static List<UnifiedEventDO> selectVisibleEventsForOwner(List<UnifiedEventDO> unifiedRows, Long tenantId,
Long ownerUserId, Long afterSequenceNo,
LocalDateTime now) {
long cursor = afterSequenceNo == null ? 0L : afterSequenceNo;
return unifiedRows.stream()
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
.filter(row -> Objects.equals(row.getOwnerUserId(), ownerUserId))
.filter(row -> Objects.equals(row.getPublishStatus(), "accepted"))
.filter(row -> Boolean.FALSE.equals(row.getDeleted()))
.filter(row -> row.getVisibleFrom() != null && !row.getVisibleFrom().isAfter(now))
.filter(row -> row.getSequenceNo() != null && row.getSequenceNo() > cursor)
.sorted(Comparator.comparing(UnifiedEventDO::getSequenceNo))
.toList();
}
private static UnifiedEventDO copyUnifiedEvent(UnifiedEventDO source) {
UnifiedEventDO target = new UnifiedEventDO();
target.setTenantId(source.getTenantId());
target.setCommandId(source.getCommandId());
target.setEventId(source.getEventId());
target.setEventType(source.getEventType());
target.setOwnerUserId(source.getOwnerUserId());
target.setSourceOwner(source.getSourceOwner());
target.setSourceType(source.getSourceType());
target.setSourceId(source.getSourceId());
target.setSourceRevision(source.getSourceRevision());
target.setResourceType(source.getResourceType());
target.setResourceId(source.getResourceId());
target.setPayloadSummary(source.getPayloadSummary());
target.setPublishStatus(source.getPublishStatus());
target.setPublishErrorCode(source.getPublishErrorCode());
target.setVisibleFrom(source.getVisibleFrom());
target.setEmittedAt(source.getEmittedAt());
target.setDeleted(source.getDeleted());
return target;
}
/**
* focused E2E 只覆盖 Market workerEvents publish 幂等/拒绝语义和 owner 可见边界
* 不启动 Spring 容器或真实数据库避免 P1R-7d 扩大到生产链路改造
*/
private record TestHarness(MarketEventPublishWorker worker,
MuseMarketEventPublishOutboxMapper outboxMapper,
EventsPublishServiceImpl eventsPublishService,
List<UnifiedEventDO> unifiedRows) {
}
}

View File

@ -0,0 +1,558 @@
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.Test;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-7d Market governance action event publish outbox 真实 PostgreSQL / Flyway 迁移验收
*
* <p>本测试会 clean 指定数据库的 public schema因此只允许连接真实库名以 {@code _test} 结尾的隔离库
* 数据库密码只能从环境变量读取避免 Surefire XML 或命令历史记录泄露凭据</p>
*/
class P1rMarketEventsPublishFlywayMigrationIT {
private static final String TARGET_VERSION = "19";
private static final String TARGET_DESCRIPTION = "extend market events publish outbox";
private static final int EXPECTED_SUCCESSFUL_SQL_MIGRATIONS = 19;
private static final String OUTBOX_TABLE = "muse_market_event_publish_outbox";
private static final List<String> REQUIRED_INDEXES = List.of(
"idx_muse_market_event_publish_outbox_claim",
"idx_muse_market_event_publish_outbox_owner_status",
"idx_muse_market_event_publish_outbox_action"
);
private static final List<String> REQUIRED_CONSTRAINTS = List.of(
"uk_muse_market_event_publish_outbox_id",
"uk_muse_market_event_publish_outbox_command",
"uk_muse_market_event_publish_outbox_action_notification",
"chk_muse_market_event_publish_outbox_event_type",
"chk_muse_market_event_publish_outbox_notification_type",
"chk_muse_market_event_publish_outbox_action_type",
"chk_muse_market_event_publish_outbox_status",
"chk_muse_market_event_publish_outbox_owner",
"chk_muse_market_event_publish_outbox_attempt_count",
"chk_muse_market_event_publish_outbox_max_attempt"
);
private static final List<String> REQUIRED_TRIGGERS = List.of(
"trg_muse_market_event_publish_outbox_update_time"
);
private static final Set<String> CREDENTIAL_QUERY_KEYS = Set.of(
"user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
"token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"
);
@Test
void should_migrateV1ToV19OnRealPostgresqlAndVerifyMarketPublishOutboxSchema() 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-7d Market publish Flyway IT 要求显式使用 filesystem:sql/muse");
String effectiveLocations = resolveMuseSqlLocation(requestedLocations);
silenceFlywayInfoLogs();
assertNoCredentialQuery(url);
assertTestDatabaseUrl(url);
Flyway flyway = Flyway.configure()
.dataSource(url, user, password)
.locations(effectiveLocations)
.schemas("public")
.defaultSchema("public")
.target(TARGET_VERSION)
.cleanDisabled(false)
.load();
cleanSchema(flyway, url, user);
MigrateResult result = migrateSchema(flyway, url, user);
MigrationInfo current = flyway.info().current();
assertNotNull(current, "必须存在当前 Flyway 版本");
assertEquals(TARGET_VERSION, Objects.requireNonNull(current.getVersion(), "当前版本必须包含版本号").getVersion(),
"真实 Flyway 当前版本必须到 V19");
assertEquals(TARGET_DESCRIPTION, current.getDescription(),
"真实 Flyway 当前版本必须是 Market publish outbox V19 扩展迁移");
assertEquals(EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, result.migrationsExecuted,
"clean 后必须只执行 V1-V19 共 19 个迁移,避免遗漏目标版本或误跑后续迁移");
int successfulMigrationCount = successfulMigrationCount(url, user, password);
assertEquals(EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, successfulMigrationCount,
"必须在隔离库中执行 V1-V19 共 19 个成功 SQL 迁移");
try (Connection connection = DriverManager.getConnection(url, user, password)) {
assertRequiredObjects(connection);
assertOutboxSchema(connection);
assertOutboxCheckConstraintsRejectInvalidRows(url, user, password);
}
System.out.println("flyway_success=true");
System.out.println("flyway_url=" + maskedUrl(url));
System.out.println("flyway_locations_requested=" + requestedLocations);
System.out.println("flyway_locations_effective=" + effectiveLocations);
System.out.println("migrations_executed=" + result.migrationsExecuted);
System.out.println("successful_migration_count=" + successfulMigrationCount);
System.out.println("target_schema_version=" + TARGET_VERSION);
System.out.println("flyway_latest=" + current.getVersion() + ":" + current.getDescription());
System.out.println("schema_version=" + current.getVersion());
System.out.println("v19_table=" + OUTBOX_TABLE);
System.out.println("v19_indexes=" + String.join(",", REQUIRED_INDEXES));
System.out.println("v19_constraints=" + String.join(",", REQUIRED_CONSTRAINTS));
System.out.println("v19_triggers=" + String.join(",", REQUIRED_TRIGGERS));
}
@Test
void should_rejectNonTestDatabaseWhenQueryContainsSlashTestSuffix() {
String url = "jdbc:postgresql://prod-host/prod?applicationName=/muse_p1r7d_events_publish_test";
AssertionError error = assertThrows(AssertionError.class, () -> assertTestDatabaseUrl(url));
assertTrue(error.getMessage().contains("jdbc:postgresql://<host>/prod?<query-redacted>"),
"拒绝信息必须只展示真实 database name并脱敏 query: " + error.getMessage());
}
@Test
void should_rejectCredentialQueryParameters() {
String url = "jdbc:postgresql://prod-host:5432/muse_p1r7d_events_publish_test?password=plain";
AssertionError error = assertThrows(AssertionError.class, () -> assertNoCredentialQuery(url));
assertTrue(error.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"),
"JDBC URL query 凭据必须被拒绝: " + error.getMessage());
}
@Test
void should_rejectBroadSingleLiteralConstraintExpression() {
String broadenedEventTypeCheck =
"check (((event_type)::text = 'notification'::text) or ((event_type)::text = 'debug'::text))";
assertFalse(singleLiteralCheck(broadenedEventTypeCheck, "event_type", "notification"),
"单值 check 断言不能接受 OR 放宽后的表达式");
}
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_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
assertTrue(password != null, "缺少必需环境变量: P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD");
return password;
}
private static String firstNonBlankEnvironment(String... names) {
// WHY数据库密码不能通过 JVM system property 传入否则 Surefire XML 可能记录属性名和值
for (String name : names) {
String value = System.getenv(name);
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private static String resolveMuseSqlLocation(String requestedLocations) {
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 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) {
// WHY日志实现不是 logback 时不影响迁移验收测试自身仍只输出脱敏 URL
}
}
private static void redactFlywaySystemProperties(String url, String user) {
// WHYSurefire XML 会记录 JVM system property读取后立即脱敏避免报告文件残留真实连接信息
System.setProperty("p1r.flyway.url", maskedUrl(url));
System.setProperty("p1r.flyway.user", maskUser(user));
}
private static void cleanSchema(Flyway flyway, String url, String user) {
try {
flyway.clean();
} catch (RuntimeException exception) {
throw sanitizedFlywayFailure("Flyway clean 失败", url, user, exception);
}
}
private static MigrateResult migrateSchema(Flyway flyway, String url, String user) {
try {
return flyway.migrate();
} catch (RuntimeException exception) {
throw sanitizedFlywayFailure("Flyway migrate 失败", url, user, exception);
}
}
private static AssertionError sanitizedFlywayFailure(String action, String url, String user,
RuntimeException exception) {
String sanitizedMessage = Objects.toString(exception.getMessage(), "")
.replace(url, maskedUrl(url))
.replace("for user '" + user + "'", "for user '<user-redacted>'");
return new AssertionError(action + ": " + sanitizedMessage);
}
private static int successfulMigrationCount(String url, String user, String password) throws SQLException {
try (Connection connection = DriverManager.getConnection(url, user, password);
PreparedStatement statement = connection.prepareStatement(
"SELECT COUNT(*) FROM flyway_schema_history WHERE success = TRUE AND type = 'SQL'");
ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), "必须能读取 flyway_schema_history");
return resultSet.getInt(1);
}
}
private static void assertRequiredObjects(Connection connection) throws SQLException {
assertTrue(tableExists(connection, OUTBOX_TABLE),
"Market Events publish outbox 表必须存在: " + OUTBOX_TABLE);
assertFalse(indexChecks(connection).containsValue(false),
"Market publish outbox 关键索引必须全部存在: " + indexChecks(connection));
assertFalse(constraintChecks(connection).containsValue(false),
"Market publish outbox 关键约束必须全部存在: " + constraintChecks(connection));
assertFalse(triggerChecks(connection).containsValue(false),
"Market publish outbox 更新时间触发器必须全部存在: " + triggerChecks(connection));
}
private static boolean tableExists(Connection connection, String tableName) throws SQLException {
return exists(connection,
"SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?",
tableName);
}
private static Map<String, Boolean> indexChecks(Connection connection) throws SQLException {
Map<String, Boolean> checks = new LinkedHashMap<>();
for (String indexName : REQUIRED_INDEXES) {
checks.put(indexName, exists(connection,
"SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = ?",
indexName));
}
return checks;
}
private static Map<String, Boolean> constraintChecks(Connection connection) throws SQLException {
Map<String, Boolean> checks = new LinkedHashMap<>();
for (String constraintName : REQUIRED_CONSTRAINTS) {
checks.put(constraintName, exists(connection,
"""
SELECT 1
FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = 'public'
AND c.conname = ?
""",
constraintName));
}
return checks;
}
private static Map<String, Boolean> triggerChecks(Connection connection) throws SQLException {
Map<String, Boolean> checks = new LinkedHashMap<>();
for (String triggerName : REQUIRED_TRIGGERS) {
checks.put(triggerName, exists(connection,
"""
SELECT 1
FROM information_schema.triggers
WHERE trigger_schema = 'public'
AND trigger_name = ?
""",
triggerName));
}
return checks;
}
private static boolean exists(Connection connection, String sql, String value) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, value);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next();
}
}
}
private static void assertOutboxSchema(Connection connection) throws SQLException {
assertColumnsExist(connection, OUTBOX_TABLE,
"id", "tenant_id", "outbox_id", "command_id", "source_command_id", "action_id", "action_type",
"asset_id", "owner_user_id", "actor_user_id", "preview_id", "request_hash", "source_revision",
"event_type", "notification_type", "resource_type",
"resource_id", "payload_summary", "publish_status", "attempt_count", "max_attempt",
"next_retry_at", "claimed_at", "claim_expires_at", "last_error_code", "last_error_message",
"published_event_id", "published_sequence_no", "creator", "updater", "create_time",
"update_time", "deleted");
assertEquals("ALWAYS", columnIdentityGeneration(connection, OUTBOX_TABLE, "id"),
"id 必须使用 GENERATED ALWAYS AS IDENTITY");
assertEquals("jsonb", columnUdtName(connection, OUTBOX_TABLE, "payload_summary"),
"payload_summary 必须使用 jsonb 保存可公开 notification 摘要");
assertEquals("'{}'::jsonb", normalizeSql(columnDefault(connection, OUTBOX_TABLE, "payload_summary")),
"payload_summary 默认值必须是空 JSONB 对象");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "owner_user_id"),
"owner_user_id 必须 NOT NULL");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "actor_user_id"),
"actor_user_id 必须 NOT NULL");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "source_command_id"),
"source_command_id 必须 NOT NULL");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "preview_id"),
"preview_id 必须 NOT NULL");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "request_hash"),
"request_hash 必须 NOT NULL");
assertEquals(80, columnLength(connection, OUTBOX_TABLE, "source_revision"),
"source_revision 必须是 VARCHAR(80)");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "source_revision"),
"source_revision 必须 NOT NULL空 revision 由应用层归一化为 __none__");
assertTrue(singleLiteralCheck(constraintDefinition(connection,
"chk_muse_market_event_publish_outbox_event_type"), "event_type", "notification"),
"event_type check 必须是单值 notification不能被 OR 放宽");
assertTrue(singleLiteralCheck(constraintDefinition(connection,
"chk_muse_market_event_publish_outbox_notification_type"), "notification_type",
"governance_action"),
"notification_type check 必须是单值 governance_action不能被 OR 放宽");
}
private static void assertColumnsExist(Connection connection, String tableName, String... columns)
throws SQLException {
for (String column : columns) {
assertTrue(exists(connection,
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""",
tableName, column), "缺少字段: " + column);
}
}
private static boolean exists(Connection connection, String sql, String first, String second) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, first);
statement.setString(2, second);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next();
}
}
}
private static String columnIdentityGeneration(Connection connection, String tableName, String column)
throws SQLException {
return queryString(connection,
"""
SELECT identity_generation
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""",
tableName, column);
}
private static String columnUdtName(Connection connection, String tableName, String column) throws SQLException {
return queryString(connection,
"""
SELECT udt_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""",
tableName, column);
}
private static Integer columnLength(Connection connection, String tableName, String column) throws SQLException {
String value = queryString(connection,
"""
SELECT character_maximum_length::text
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""",
tableName, column);
return value == null ? null : Integer.valueOf(value);
}
private static String columnDefault(Connection connection, String tableName, String column) throws SQLException {
return queryString(connection,
"""
SELECT column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""",
tableName, column);
}
private static boolean columnIsNotNullable(Connection connection, String tableName, String column)
throws SQLException {
return "NO".equals(queryString(connection,
"""
SELECT is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""",
tableName, column));
}
private static String constraintDefinition(Connection connection, String constraintName) throws SQLException {
return queryString(connection,
"""
SELECT pg_get_constraintdef(c.oid)
FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = 'public'
AND c.conname = ?
""",
constraintName);
}
private static String queryString(Connection connection, String sql, String... values) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int i = 0; i < values.length; i++) {
statement.setString(i + 1, values[i]);
}
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), "查询必须返回一行: " + sql);
return resultSet.getString(1);
}
}
}
private static void assertOutboxCheckConstraintsRejectInvalidRows(String url, String user, String password)
throws SQLException {
assertInsertRejected(url, user, password, baseInsertSql().replace("'notification'", "'debug'"),
"illegal event_type");
assertInsertRejected(url, user, password,
baseInsertSql().replace("'governance_action'", "'source_status_change'"),
"illegal notification_type");
assertInsertRejected(url, user, password, baseInsertSql().replace("'delist'", "'review_rejection'"),
"illegal action_type");
assertInsertRejected(url, user, password, baseInsertSql().replace("'queued'", "'accepted'"),
"illegal publish_status");
assertInsertRejected(url, user, password, baseInsertSql().replace("9001", "0"),
"owner_user_id <= 0");
}
private static String baseInsertSql() {
return """
INSERT INTO muse_market_event_publish_outbox(
tenant_id, outbox_id, command_id, source_command_id, action_id, action_type, asset_id,
owner_user_id, actor_user_id, preview_id, request_hash, source_revision, event_type,
notification_type, resource_type, resource_id, payload_summary,
publish_status, attempt_count, max_attempt)
VALUES (100, 'mk_out_test', 'mk_evt_test', 'cmd-governance-test', 'gov-action-test',
'delist', 1001, 9001, 7001, 'preview-test', 'request-hash-test', '__none__',
'notification', 'governance_action', 'market_asset', '1001',
'{"type":"governance_action","message":"资产已下架","resourceRef":{"resourceType":"market_asset","resourceId":1001},"timestamp":"2026-06-07T10:00:00"}'::jsonb,
'queued', 0, 5)
""";
}
private static void assertInsertRejected(String url, String user, String password, String sql, String scenario)
throws SQLException {
try (Connection connection = DriverManager.getConnection(url, user, password)) {
connection.setAutoCommit(false);
assertInsertRejected(connection, sql, scenario);
}
}
private static void assertInsertRejected(Connection connection, String sql, String scenario) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
try {
statement.executeUpdate();
throw new AssertionError("约束必须拒绝非法 outbox row: " + scenario);
} catch (SQLException expected) {
connection.rollback();
}
}
}
private static void assertNoCredentialQuery(String url) {
int queryIndex = url.indexOf('?');
if (queryIndex < 0) {
return;
}
String query = url.substring(queryIndex + 1);
for (String pair : query.split("&")) {
String key = pair.split("=", 2)[0].toLowerCase();
assertFalse(CREDENTIAL_QUERY_KEYS.contains(key),
"p1r.flyway.url 不能携带凭据 query 参数: " + key);
}
}
private static void assertTestDatabaseUrl(String url) {
String databaseName = databaseName(url);
assertTrue(databaseName.endsWith("_test"),
"Flyway IT 只能连接 _test 隔离库,当前 URL=" + maskedUrl(url));
}
private static String databaseName(String url) {
String withoutQuery = url.split("\\?", 2)[0];
int slash = withoutQuery.lastIndexOf('/');
return slash >= 0 ? withoutQuery.substring(slash + 1) : withoutQuery;
}
private static boolean singleLiteralCheck(String definition, String column, String literal) {
String normalized = normalizeSql(definition);
return normalized.contains(column + ")::text = '" + literal + "'::text")
&& !normalized.contains(" or ")
&& !normalized.contains(" any ");
}
private static String normalizeSql(String sql) {
return sql == null ? null : sql.toLowerCase().replaceAll("\\s+", " ").trim();
}
private static String maskedUrl(String url) {
String withoutQuery = url.split("\\?", 2)[0];
String database = databaseName(url);
int scheme = withoutQuery.indexOf("://");
String prefix = scheme >= 0 ? withoutQuery.substring(0, scheme + 3) : "jdbc:postgresql://";
return prefix + "<host>/" + database + (url.contains("?") ? "?<query-redacted>" : "");
}
private static String maskUser(String user) {
return user == null || user.isBlank() ? "<user-redacted>" : "<user-redacted>";
}
}

View File

@ -0,0 +1,267 @@
package cn.iocoder.muse.server.framework.api;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-7d Market governance action event publish outbox V19 迁移 SQL 静态门禁
*/
class P1rMarketEventsPublishMigrationSqlTest {
private static final String OUTBOX_TABLE = "muse_market_event_publish_outbox";
private static final Path V19_RELATIVE_PATH =
Path.of("muse-cloud/sql/muse/V19__extend_market_events_publish_outbox.sql");
private static final Pattern PLAINTEXT_SECRET_COLUMN_PATTERN = Pattern.compile(
"(?i)\\b\\w*(?:token|authorization|secret|password|api_key|apikey|bearer|reason|snapshot)\\w*\\s+"
+ "(?:varchar|text|jsonb|char|bytea)");
@Test
void should_defineMarketGovernancePublishOutboxTableAndColumns() throws IOException {
String normalizedSql = normalize(readV19Sql());
String tableSql = extractCreateTable(normalizedSql, OUTBOX_TABLE);
assertTrue(tableSql.contains("id bigint generated always as identity primary key"),
"outbox.id 必须使用 PostgreSQL identity 主键");
for (String column : Set.of(
"tenant_id bigint not null",
"outbox_id varchar(128) not null",
"command_id varchar(128) not null",
"source_command_id varchar(128) not null",
"action_id varchar(128) not null",
"action_type varchar(32) not null",
"asset_id bigint not null",
"owner_user_id bigint not null",
"actor_user_id bigint not null",
"preview_id varchar(128) not null",
"request_hash varchar(128) not null",
"source_revision varchar(80) not null",
"event_type varchar(32) not null",
"notification_type varchar(64) not null",
"resource_type varchar(64) not null",
"resource_id varchar(128) not null",
"payload_summary jsonb not null default '{}'::jsonb",
"publish_status varchar(32) not null",
"attempt_count int not null default 0",
"max_attempt int not null default 5",
"next_retry_at timestamp",
"claimed_at timestamp",
"claim_expires_at timestamp",
"last_error_code varchar(64)",
"last_error_message text",
"published_event_id varchar(128)",
"published_sequence_no bigint",
"creator varchar(64) not null default ''",
"updater varchar(64) not null default ''",
"create_time timestamp not null default current_timestamp",
"update_time timestamp not null default current_timestamp",
"deleted boolean not null default false")) {
assertTrue(tableSql.contains(column),
"Market publish outbox 必须包含字段定义: " + column);
}
}
@Test
void should_includeChecksUniqueConstraintsIndexesAndTrigger() throws IOException {
String normalizedSql = normalize(readV19Sql());
String tableSql = extractCreateTable(normalizedSql, OUTBOX_TABLE);
assertTrue(tableSql.contains("constraint uk_muse_market_event_publish_outbox_id unique (tenant_id, outbox_id)"),
"outbox_id 必须通过 tenant_id/outbox_id 唯一约束保证本域幂等");
assertTrue(tableSql.contains("constraint uk_muse_market_event_publish_outbox_command unique (tenant_id, command_id)"),
"command_id 必须通过 tenant_id/command_id 唯一约束保证发布命令幂等");
assertTrue(tableSql.contains("constraint uk_muse_market_event_publish_outbox_action_notification unique (tenant_id, action_id, notification_type)"),
"governance action + notification type 必须唯一,避免 recall 写第二条 source status outbox");
assertTrue(extractConstraint(tableSql, "chk_muse_market_event_publish_outbox_event_type")
.contains("event_type = 'notification'"),
"Market P1R-7d 只允许发布 notification 类型");
assertTrue(extractConstraint(tableSql, "chk_muse_market_event_publish_outbox_notification_type")
.contains("notification_type = 'governance_action'"),
"Market P1R-7d 只允许 governance_action notification");
String actionTypeConstraint = extractConstraint(tableSql, "chk_muse_market_event_publish_outbox_action_type");
assertTrue(actionTypeConstraint.contains("'delist'") && actionTypeConstraint.contains("'recall'"),
"action_type check 必须只允许 delist/recall");
String publishStatusConstraint = extractConstraint(tableSql,
"chk_muse_market_event_publish_outbox_status");
for (String value : Set.of("queued", "running", "published", "retryable", "dead_letter")) {
assertTrue(publishStatusConstraint.contains("'" + value + "'"),
"publish_status check 必须允许: " + value);
}
assertFalse(publishStatusConstraint.contains("'accepted'"),
"accepted/rejected/blocked 是 Events 投影状态,不能混入 Market outbox 本地状态");
assertTrue(extractConstraint(tableSql, "chk_muse_market_event_publish_outbox_owner")
.contains("owner_user_id > 0"),
"owner_user_id 必须 fail-closed禁止 owner=0 进入 outbox");
assertTrue(extractConstraint(tableSql, "chk_muse_market_event_publish_outbox_attempt_count")
.contains("attempt_count >= 0"),
"attempt_count 必须有非负约束");
assertTrue(extractConstraint(tableSql, "chk_muse_market_event_publish_outbox_max_attempt")
.contains("max_attempt > 0"),
"max_attempt 必须有正数约束");
assertTrue(normalizedSql.contains("create index idx_muse_market_event_publish_outbox_claim")
&& normalizedSql.contains("on muse_market_event_publish_outbox(tenant_id, publish_status, next_retry_at, id)")
&& normalizedSql.contains("where deleted = false"),
"claim partial index 必须覆盖 tenant_id/publish_status/next_retry_at/id 并过滤 deleted=false");
assertTrue(normalizedSql.contains("create index idx_muse_market_event_publish_outbox_owner_status")
&& normalizedSql.contains("on muse_market_event_publish_outbox(tenant_id, owner_user_id, publish_status, create_time)"),
"owner/status index 必须覆盖 tenant_id/owner_user_id/publish_status/create_time");
assertTrue(normalizedSql.contains("create index idx_muse_market_event_publish_outbox_action")
&& normalizedSql.contains("on muse_market_event_publish_outbox(tenant_id, action_id)"),
"action index 必须支持按 governance action 排障");
assertTrue(normalizedSql.contains("create trigger trg_muse_market_event_publish_outbox_update_time")
&& normalizedSql.contains("before update on muse_market_event_publish_outbox")
&& normalizedSql.contains("execute function update_updated_at_column()"),
"outbox 必须复用 update_updated_at_column() 的 update_time 触发器");
}
@Test
void should_followBaseDoTenantBaseDoAndAvoidSensitiveColumnsForeignKeysAndDuplicates() throws IOException {
Path root = findRepositoryRoot();
String migrationSql = normalize(readV19Sql());
String tableSql = extractCreateTable(migrationSql, OUTBOX_TABLE);
String baseDo = Files.readString(root.resolve(
"muse-cloud/muse-framework/muse-spring-boot-starter-mybatis/src/main/java/cn/iocoder/muse/framework/mybatis/core/dataobject/BaseDO.java"));
String tenantBaseDo = Files.readString(root.resolve(
"muse-cloud/muse-framework/muse-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/muse/framework/tenant/core/db/TenantBaseDO.java"));
assertTrue(baseDo.contains("private LocalDateTime createTime;")
&& baseDo.contains("private LocalDateTime updateTime;")
&& baseDo.contains("private String creator;")
&& baseDo.contains("private String updater;")
&& baseDo.contains("private Boolean deleted;"),
"BaseDO 必须暴露 createTime/updateTime/creator/updater/deleted");
assertTrue(tenantBaseDo.contains("class TenantBaseDO extends BaseDO")
&& tenantBaseDo.contains("private Long tenantId;"),
"TenantBaseDO 必须继承 BaseDO 并暴露 tenantId");
assertFalse(tableSql.contains("created_at"), "禁止使用非项目约定字段 created_at");
assertFalse(tableSql.contains("updated_at"), "禁止使用非项目约定字段 updated_at");
assertFalse(PLAINTEXT_SECRET_COLUMN_PATTERN.matcher(tableSql).find(),
"Market publish outbox 不能新增 reason/snapshot/token/authorization/secret/password 明文字段");
assertFalse(tableSql.contains("foreign key") || migrationSql.contains("references muse_market_governance_action"),
"action_id 只表达 muse_market_governance_action.action_id 引用语义,不能新增数据库外键");
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bcreate table (muse_[a-z0-9_]+)\\b")),
"V19 新增表名不能重复");
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bcreate (?:unique )?index (\\w+)\\b")),
"V19 新增索引名不能重复");
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bcreate trigger (\\w+)\\b")),
"V19 新增 trigger 名不能重复");
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bconstraint (\\w+)\\b")),
"V19 新增 constraint 名不能重复");
String earlierMigrations = readEarlierMigrationsNormalized();
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
Pattern.compile("\\bcreate table (muse_[a-z0-9_]+)\\b"), "");
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
Pattern.compile("\\bcreate (?:unique )?index (\\w+)\\b"), "索引");
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
Pattern.compile("\\bcreate trigger (\\w+)\\b"), "trigger");
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
Pattern.compile("\\bconstraint (\\w+)\\b"), "constraint");
}
private static String readV19Sql() throws IOException {
Path migrationPath = findRepositoryRoot().resolve(V19_RELATIVE_PATH);
assertTrue(Files.exists(migrationPath),
"V19 Market Events publish outbox 迁移 SQL 必须存在: " + V19_RELATIVE_PATH);
return Files.readString(migrationPath);
}
private static Path findRepositoryRoot() {
Path current = Path.of("").toAbsolutePath();
for (Path candidate = current; candidate != null; candidate = candidate.getParent()) {
if (Files.exists(candidate.resolve("muse-cloud/sql/muse/V1__init_content_schema.sql"))) {
return candidate;
}
}
return current;
}
private static String readEarlierMigrationsNormalized() throws IOException {
Path migrationsDir = findRepositoryRoot().resolve("muse-cloud/sql/muse");
try (var stream = Files.list(migrationsDir)) {
return stream
.filter(path -> path.getFileName().toString().matches("V\\d+__.*\\.sql"))
.filter(path -> !path.getFileName().toString().equals(V19_RELATIVE_PATH.getFileName().toString()))
.sorted()
.map(path -> {
try {
return normalize(Files.readString(path));
} catch (IOException exception) {
throw new IllegalStateException("无法读取 migration: " + path, exception);
}
})
.collect(Collectors.joining(" "));
} catch (IllegalStateException exception) {
if (exception.getCause() instanceof IOException ioException) {
throw ioException;
}
throw exception;
}
}
private static String normalize(String sql) {
return sql.toLowerCase(Locale.ROOT)
.replaceAll("--.*", " ")
.replaceAll("\\s+", " ");
}
private static String extractCreateTable(String normalizedSql, String tableName) {
String startToken = "create table " + tableName + " ";
int start = normalizedSql.indexOf(startToken);
assertTrue(start >= 0, "V19 必须创建表: " + tableName);
int end = normalizedSql.indexOf(");", start + startToken.length());
assertTrue(end >= 0, "V19 表定义必须以 ); 结束: " + tableName);
return normalizedSql.substring(start, end + 2);
}
private static String extractConstraint(String tableSql, String constraintName) {
String startToken = "constraint " + constraintName;
int start = tableSql.indexOf(startToken);
assertTrue(start >= 0, "V19 必须创建约束: " + constraintName);
int next = tableSql.indexOf("constraint ", start + startToken.length());
return next >= 0 ? tableSql.substring(start, next) : tableSql.substring(start);
}
private static List<String> extractNames(String sql, Pattern pattern) {
Matcher matcher = pattern.matcher(sql);
List<String> names = new java.util.ArrayList<>();
while (matcher.find()) {
names.add(matcher.group(1));
}
return names;
}
private static void assertNoDuplicateNames(List<String> names, String message) {
Set<String> seen = new HashSet<>();
for (String name : names) {
assertTrue(seen.add(name), message + ": " + name);
}
}
private static void assertNewNamesDoNotExistInEarlierMigrations(String currentSql, String earlierSql,
Pattern pattern, String objectType) {
for (String name : extractNames(currentSql, pattern)) {
assertFalse(earlierSql.contains(name),
"V19 新增" + objectType + "名不能已经存在于早期 migration: " + name);
}
}
}