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

This commit is contained in:
zizi 2026-06-06 20:25:04 +08:00
parent 3db5fbedfd
commit 230152c22a
15 changed files with 4948 additions and 0 deletions

17
docs/agent-specs/.agent Normal file
View File

@ -0,0 +1,17 @@
# docs/agent-specs
用途:存放 P1R 等跨模块 agent 规格与执行计划文档。
边界本目录只记录审阅版、执行版、review gate 与交接约束,不承载代码实现,不修改 OpenAPI、scanner 或 coverage 口径。
当前状态P1R-7b 执行版已作为全局方案下的 AI 子计划通过 fresh spec re-reviewErdos和 fresh quality/feasibility reviewCarver。Task 1 只读 preflight 已双 PASSRamanujan / DescartesTask 2 migration/schema 已双 PASSBoyle / LorentzTask 3 AI DO/Mapper/Service 与 terminal fact 同事务 outbox 已双 PASSWegener / BohrTask 4 AI worker / dispatcher 已在修复 spec review FAIL 后双 PASSHume / Linnaeus。Task 4 focused evidence 为 Worker tests=20、Mapper tests=5均 failures=0/errors=0/skipped=0残余非阻塞风险包括 worker 默认开关口径、retryable 状态日志、terminalFact 与 outbox 完整一致性后续加固。Task 5 focused E2E 已由 Task 5a done 可见性和 Task 5b error / duplicate / rejected 证据合并收口,并通过 fresh spec reviewPtolemy与 fresh quality/feasibility reviewCurie双 PASS本地 evidence 为 `P1rAiEventsPublishEndToEndTest` tests=4、failures=0/errors=0/skipped=0覆盖 done owner 可见与跨 owner 不可见、error payload 不泄露敏感 detail、duplicate replay 同 eventId/sequenceNo、rejected -> outbox dead_letter 且 visible query 为空。该证据仍是 focused harness不代表 P1R-7b completed也不代表 Events/Market completed。Task 6 曾因 coverage scanner `--check` 会重写受保护 coverage JSON/Markdown 而 BLOCKED该 diff 已按受保护边界回退,当前 protected diff 为空。执行版修订 spec reviewFeynmanFAILTask 7 仍直跑 scannerMarket 命令只看 completionStatusspec re-reviewBanachFAILTask 6 Market 命令仍只看 completionStatus。已修复为 Task 6/Task 7 都在临时隔离副本运行 scanner当前 worktree protected diff 必须为空,并用 `implementationStatus/completionStatus` 验证 Market 为 `dedicated/needs_verification 32`;修订方案 fresh spec re-reviewKuhnPASS、fresh quality/feasibility reviewMeitnerPASS。Task 6 revised gate 由 fresh implementerLorentzDONE本轮无文件改动evidence 为 dependency test 2/2、P1R focused gates 29/29、Migration SQL 4/4、Flyway `_test` 4/4 均 failures=0/errors=0/skipped=0隔离副本 scanner exit 0summary 为 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0Events `streamEvents=dedicated/needs_verification`Market 32 个 operation 均为 `dedicated/needs_verification`dependency tree 证明 AI server 依赖 events-api、不依赖 events-serverEvents server 不反向依赖 AI/Knowledge/Market/Member/Content server。下一步必须 fresh Task 6 spec review未双 PASS 前不能进入 Task 7。
最新补记Task 6 spec reviewOhmPASS但指出 dependency test 未显式覆盖 `muse-module-content-server` 属于依赖合同完整性风险;按 `superpowers:receiving-code-review` 处理后判定该反馈有效。fresh implementerPeirce已把 `P1rAiEventsPublishDependencyTest` forbidden source owner server 列表补齐为 AI / Knowledge / Market / Member / Content并运行 dependency test 2/2 通过protected diff 为空。controller 随后重跑 P1R focused gates 29/29 通过;再以 `/tmp/p1r7b-coverage-scan.N0QGvX` 临时隔离副本运行 scanner `--check` exit 0summary 仍为 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0Events 仍为 `dedicated/needs_verification`Market 32 个 operation 仍为 `dedicated/needs_verification`,当前真实 worktree 保护文件 diff 为空。只读检索 `docs/memorys` 证实 P1R-7a 留痕已把 source owner propagation 明确移入 P1R-7b且 P1R-7a / Events / Market 均不得写成 completed。执行版文档已同步修正 Task 6 与数据流段的依赖合同旧口径,统一为 Events server 不反向依赖 AI / Knowledge / Market / Member / Content server。由于测试和执行版文档均有变更下一步必须重新派发 fresh Task 6 spec review未双 PASS 前不能进入 Task 7。
Task 6 复审收口fresh spec compliance reviewHuygensPASS阻塞项无其已验证 Content forbidden、执行版旧口径清空、P1R focused gates 29 个 surefire evidence、隔离 scanner evidence、Events/Market 仍为 `dedicated/needs_verification`。fresh quality/feasibility reviewBoylePASS阻塞项无其已验证 dependency test 的 XML 安全解析、仓库根查找、direct dependency 机械断言、Content 补充不会误禁 AI 既有 Content 依赖、`/tmp` 隔离 scanner 方案可行、dependency / coverage / migration gate 证据链相互独立。非阻塞风险dependency test 只覆盖 direct POM dependencies后续 full verification 仍应保留 `dependency:tree`Task 7 或最终 gate 前需 controller 继续在 `/tmp` 隔离副本重跑 scanner 并确认 protected diff 为空。Task 6 当前可视为双 PASS 完成;仍不代表 P1R-7b completed、Events completed 或 Market completed。
Task 7 preflight 修订:执行版最终验证命令原先把 `mvn -o clean install -DskipTests` 放在测试与 scanner 之后,存在清除 `target/surefire-reports` 导致最终无法证明 test count > 0 的顺序风险。已修订为先运行 reactor build再运行 focused tests / P1R gates / Flyway / scanner并要求最终保留 surefire XML 证据。该修订只影响执行版验证顺序不修改实现代码、OpenAPI、scanner 或 coverage 报告。
Task 7 fresh implementer 最终验证:状态 `DONE`,仅代表 P1R-7b AI source owner propagation evidence 推进到 `needs_verification` 证据层,不代表 Events / P1R-7 / Market completed。工作区 `/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`,分支 `dev/1.0.0`。Reactor build 已按修订顺序先运行:`mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true -pl muse-server -am` exit 047/47 reactor modules success。AI/Events focused tests exit 0surefire XML 计数为 `MuseAiEventPublishOutboxMapperTest` 5、`MuseAiEventPublishOutboxServiceTest` 11、`MuseAiEventPublishWorkerTest` 20、`MuseAiRuntimeProjectionServiceTest` 5、`MuseJobServiceTest` 25、`EventsPublishServiceTest` 17、`EventsStreamServiceTest` 9全部 failures=0/errors=0/skipped=0。P1R mixed gates exit 0`P1rAiEventsPublishMigrationSqlTest` 4、`P1rAiEventsPublishDependencyTest` 2、`P1rAiEventsPublishEndToEndTest` 4、`P1rApiCoverageReportTest` 5、`P1rEventsRealApiGateTest` 5、`P1rEventsRouteOwnershipTest` 4、`P1rAiRealApiGateTest` 6、`P1rAiRouteOwnershipTest` 7全部 failures=0/errors=0/skipped=0。Flyway `_test` 使用 `muse_p1r7b_events_publish_test`,命令包含 `flyway.postgresql.transactional.lock=false` 与 `p1r.flyway.locations=filesystem:sql/muse``P1rAiEventsPublishFlywayMigrationIT` tests=4、failures=0/errors=0/skipped=0migrations_executed=17、target_schema_version=17、v17_table=`muse_ai_event_publish_outbox`。dependency tree gateAI server 有 `muse-module-events-api:compile`,显式探针输出 `AI_EVENTS_SERVER_ABSENT`Events server source owner forbidden 探针输出 `EVENTS_FORBIDDEN_SOURCE_OWNER_ABSENT`。coverage scanner 仅在 `/tmp/p1r7b-final-coverage-scan.AoKcDl` 隔离副本运行exit 0summary 为 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0Events `streamEvents=dedicated/needs_verification`Market 32 个 operation 均为 `dedicated/needs_verification`。初始与最终 protected status/diff 均为空。新增 memory`docs/memorys/2026-06-06-P1R7bSourceOwnerPropagation真实链路.md`。下一步必须 fresh Task 7 spec review + fresh Task 7 quality/feasibility review未双 PASS 前不能最终收口、提交、push 或推进 completed。
Task 7 复审收口fresh spec compliance reviewMenciusPASS阻塞项无其已验证 Task 7 最终验证范围、16 个目标测试类 surefire XML、Flyway `_test` 参数、隔离 coverage 副本、protected diff、memory 与 `.agent` 的 needs_verification/completed 边界。fresh quality/feasibility reviewHumePASS阻塞项无其已验证 memory 质量、fact/inference/assumption 分离、`mvn clean install` 前置顺序、surefire XML 留存、Flyway `_test` 证据、隔离 scanner、dependency tree gate 和核心实现/测试覆盖。非阻塞建议dependency tree 原始输出未单独保存为 artifact后续提交/最终交接可保留三条 dependency tree 命令和关键输出;双 PASS 后仍不得自动提交、push 或推进 Events / P1R-7 / Market completed必须等待用户明确批准提交边界与 completed approval。

View File

@ -0,0 +1,309 @@
# P1R7 Source Owner Propagation 全局审阅版
## 结论
推荐把 P1R-7 后续主线从“AI 单 owner 先做”提升为“全局 source owner propagation 分层方案”,但实现仍必须按 owner 分阶段切片推进。
全局推荐方案:
1. Events owner 只负责统一事件投影、幂等、payload 校验、脱敏、可见性过滤和 SSE不反向读取任何 source owner 业务表。
2. 每个 source owner 只负责本域权威事实和本域 publish outboxworker 通过 `muse-module-events-api` 调用 `EventsPublishApi`
3. 不建立一个跨 owner 的中心 source 模块,也不让 Events server 直接拥有 source propagation 业务事实。
4. 当前 AI 第一切片实现草稿不能直接作为全局方案完成证据;它只能作为后续 P1R-7b 的候选实现素材,必须先按本全局方案复审和修订。
P1R-7 后续拆分建议:
| 阶段 | 目标 | 状态口径 |
|---|---|---|
| P1R-7b | 全局 propagation 规则冻结 + AI terminal event 第一 owner 切片 | 只推进 `needs_verification` 证据 |
| P1R-7c | Knowledge source status / projection event propagation | 只推进 `needs_verification` 证据 |
| P1R-7d | Market lifecycle / account projection / governance event propagation | 只推进 `needs_verification` 证据 |
| P1R-7e | Account(Member) security / entitlement / usage notification propagation | 只推进 `needs_verification` 证据 |
| P1R-7f | Content canonical change / block saved / export task event propagation | 只推进 `needs_verification` 证据 |
| P1R-7 completed approval | 对 P1R-7 做单独完成审批 | 必须用户单独批准 |
本审阅版不实现代码、不修改 OpenAPI、不修改 scanner、不修改 coverage JSON/Markdown不把 Events、P1R-7、Market 或任何 owner 标为 `completed`
## 已验证事实
### 工作区与状态
- 正确工作区为 `/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.`
- 当前 HEAD 为 `fa29753 test(p1r): 收口 Events SSE 门禁`
- 当前 worktree 有未提交的 P1R-7b AI outbox 草稿、V17 migration 草稿和 `docs/agent-specs/` 文档草稿;本审阅版不清理、不回退这些文件。
- 受保护文件当前定向 `git status --short` 无输出:
- `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`
### Coverage 与阶段边界
- 当前 coverage summary
- `totalOperations = 233`
- `completedOperations = 100`
- `needsVerificationOperations = 133`
- `incompleteOperations = 0`
- `genericPersistenceOperations = 0`
- `ssePlaceholderOperations = 0`
- Events 当前唯一 operation 为 `streamEvents GET /app-api/muse/events = dedicated / needs_verification`
- Market 当前 32 operations 均为 `dedicated / needs_verification`
- `docs/memorys/2026-06-05-P1R7aEventsSSE真实API收口.md` 明确 P1R-7a 未接入 source owner propagation未运行 source owner publish tests。
### 已有全局设计约束
- `CLAUDE.md` 明确本仓是设计文档 SSOT核心架构决策中 Source 传播采用“事件驱动 + 各模块自治”,统一实时通信为 SSE。
- `design-docs/架构-02-核心数据结构与双轨模型.md` 明确:来源 owner 发出状态变化事件,各消费模块监听事件并自行处理反应逻辑,不需要独立 source 模块。
- `design-docs/后端-03-关键流程实现与接口契约.md` 明确AI 调用、投影、索引、导出、评估、来源传播、New-API 归属和通知走异步任务,异步失败不得回滚已提交 Canonical。
- `docs/superpowers/plans/2026-06-05-P1R-7a-events-sse.md` 明确:跨 owner 发布模式固定为 source owner 本域 outbox + worker 调用 Events APIEvents server 不允许反向依赖 source owner server也不允许统一 SSE Controller 直接扫多域业务表。
### Events owner 当前能力
- `EventsPublishApi.publish(EventsPublishReqDTO)` 已存在于 `muse-module-events-api`
- `EventsPublishReqDTO` 已包含 `commandId``tenantId``ownerUserId``sourceOwner``sourceType``sourceId``sourceRevision``eventType``resourceType``resourceId``payloadSummary``emittedAt`
- `EventsPublishServiceImpl` 已按 commandId 优先、source tuple 次级做幂等回查。
- `EventsPublishServiceImpl` 使用 `insertIgnore`,避免用唯一约束异常作为正常幂等路径。
- `UnifiedEventMapper.selectVisibleEventsForOwner` 只查询 `publish_status = accepted`、同租户、同 ownerUserId、未删除、`sequenceNo` 大于 cursor 的事件。
- `EventsStreamServiceImpl` 将统一投影中的 `done` / `error` / `notification` 等事件转成 SSE。
### Source owner 当前能力差异
- AI 已有 `muse_ai_task_event` terminal fact、`MuseAiRuntimeJobDispatcher``MuseAiJobMapper.claimNextQueuedRuntimeJob()``FOR UPDATE SKIP LOCKED` worker 风格;当前未提交草稿新增了 AI event publish outbox但尚未按全局方案完成复审。
- Knowledge 已有 `muse_knowledge_source_event``muse_knowledge_projection_task`;当前 `MuseKnowledgeSourceEventService` 同步更新 projection 后把 task 记录为 `completed`,注释明确避免制造无执行者的 queued task。
- Market 已有 `MarketAccountProjectionOutboxService`,但接口只有 `recordSynced` / `recordBlocked`,职责是 Market 到 Member Account projection 的同步状态,不是 Events publish worker。
- Member(Account) 已有 `MemberSecurityEventDO``AccountIntegrationCallDO`、调用归因 job 和安全事件脱敏转换;当前 mapper 以查询/归因记录为主,未发现完整 Events publish outbox claim/retry/dead-letter 链路。
- Content 正式设计中已有 `BlockSavedEvent` outbox 方向,但本次只读未验证当前 Content 已有可直接复用到 Events 的 publish worker。
## 推断
- 全局 source propagation 的核心不是新增一个“大 source 服务”,而是冻结所有 owner 都必须遵守的事件发布合同、幂等合同、失败补偿合同和审计合同。
- 直接把当前 AI outbox 草稿继续推进,容易把“第一切片实现细节”误当成全局架构决策;应先用全局审阅版统一约束,再修订 AI 执行版。
- Market 的 projection outbox 名称容易误导;它当前表达的是 Market -> Account 读模型同步状态,不适合直接复用为 Events publish outbox。
- Knowledge 已有 source event但目前 projection task 同步完成,缺少异步 publish worker进入 P1R-7c 前需要先补本域发布补偿语义。
- Account security event 适合做用户通知类 propagation但必须先冻结安全 payload allowlist避免把设备、IP、风控原始详情或内部判断泄露到统一 SSE。
- Content 的 BlockSavedEvent 更接近内部 followup trigger不一定都应该进入用户 SSE需要先区分“内部领域事件”和“用户可见统一事件”。
## 假设
- P1R-7 后续允许在各 source owner 内新增 publish outbox 表或等价本域状态表。
- P1R-7 后续允许在 source owner server 中新增 `muse-module-events-api` 依赖,但不允许依赖 `muse-module-events-server`
- 当前 `EventsPublishReqDTO` envelope 足够承载后续 owner 的用户可见事件;如果后续发现字段不足,应另起 OpenAPI / contract 变更审批,而不是在实现里绕过。
- 当前 P1R-7 完成状态仍需用户单独批准;本方案只定义如何补证据链。
## 全局目标
1. 建立跨 owner 的统一 source propagation 规则,保证每个 owner 都按相同可审核链路发布事件。
2. 保持 source owner 自治:事实归 owner发布补偿归 owner统一可见投影归 Events。
3. 让 `/app-api/muse/events` SSE 成为用户可见事件的统一出口,而不是跨表扫描器。
4. 为 P1R-7 completed approval 准备真实证据成功路径、幂等、失败、重试、dead-letter、权限、审计、数据库落库和 SSE 可见性。
本文只定义“用户可见 Events 发布链路”。它不替代正式 Source / Authorization 内部传播链路;内部来源状态、授权快照、传播到目标 owner 的阻断和禁用仍由正式设计中的 Source Status Event / Source Propagation Job 或等价本域机制承担。
## 非目标
1. 不建立独立 source 模块。
2. 不把 Events server 改造成 source owner 编排器。
3. 不让 Events server 反向依赖 AI / Knowledge / Market / Member / Content server。
4. 不把所有内部领域事件都暴露给 SSE。
5. 不复用职责不匹配的 Market Account projection outbox。
6. 不用修改 OpenAPI、scanner 或 coverage 报告掩盖缺口。
7. 不把当前 P1R-7b AI 草稿视为已完成实现。
8. 不推进 completed 状态。
## 推荐架构
```mermaid
flowchart LR
subgraph Owner["每个 Source Owner"]
Fact["本域权威事实<br/>Canonical 或 Shadow terminal fact"]
Outbox["本域 publish outbox<br/>queued/running/retryable/published/dead_letter"]
Worker["本域 worker<br/>claim + lease + retry"]
Fact --> Outbox --> Worker
end
Worker --> Api["EventsPublishApi<br/>events-api only"]
subgraph Events["Events Owner"]
Api --> Publish["幂等发布服务<br/>commandId + source tuple"]
Publish --> Store[("muse_unified_event<br/>accepted/rejected/blocked")]
Store --> Stream["SSE stream service<br/>tenant + owner + sequence"]
end
Stream --> Client["muse-studio fetch SSE"]
```
关键原则:
1. Source owner 只发布已经落库的、可审计的本域事实,不发布临时内存状态。
2. Source owner outbox 与本域事实绑定,但不能因为 Events 暂时不可用回滚已提交 Canonical。
3. Worker 不拼接自由 payload只从 outbox 固化的 allowlist payload 发布。
4. Events owner 只校验和投影,不反查 source owner 表补上下文。
5. SSE 只读 `muse_unified_event` accepted 事件,不直接查询 AI / Knowledge / Market / Account / Content 表。
## 全局发布合同
每个 owner 的发布请求必须满足:
| 字段 | 全局规则 |
|---|---|
| `commandId` | 稳定、短、幂等;推荐 `<owner>_evt:<sha256-32>`,长度不超过 128 |
| `tenantId` | 必填worker 线程必须恢复租户上下文 |
| `ownerUserId` | 必填;决定 SSE 可见性 |
| `sourceOwner` | 固定 owner 枚举,如 `ai``knowledge``market``account``content` |
| `sourceType` | 固定业务事实类型,不使用表名泄露内部结构 |
| `sourceId` | 本域事实稳定 id 或业务 id |
| `sourceRevision` | 本域事实版本;没有版本时使用明确占位,但 owner 执行版必须解释 |
| `eventType` | 只能使用 Events OpenAPI 已声明类型;新增类型必须另起合同审批 |
| `resourceType/resourceId` | 用户界面定位对象;不得代替 source tuple 幂等 |
| `payloadSummary` | 只包含 OpenAPI schema 允许字段和安全摘要 |
| `emittedAt` | 本域事实发生时间,不是 worker 发布时间 |
payload 全局禁令:
1. 不发布 prompt、provider request/response raw body、完整正文、知识资料原文、授权头、token、apiKey、secret、password。
2. 不发布内部风控原始规则、管理员备注原文、失败堆栈、数据库错误详情。
3. 不发布未在 OpenAPI 中声明的扩展字段。
4. 不把 `rejected``blocked` 事件作为 SSE 可见事件。
## 全局 outbox 状态机
```mermaid
stateDiagram-v2
[*] --> queued: owner fact 已落库并创建 publish intent
queued --> running: worker claim + lease
retryable --> running: retry 到期 claim
running --> running: stale lease reclaim
running --> published: Events accepted 或 duplicate accepted
running --> retryable: temporary failure
running --> dead_letter: payload invalid / rejected / blocked / retry exhausted
retryable --> dead_letter: retry exhausted
published --> [*]
dead_letter --> [*]
```
全局要求:
1. claim 必须是原子 SQL优先使用 `FOR UPDATE SKIP LOCKED` 或等价安全领取机制。
2. claim 必须包含 lease避免 crash-after-claim 后永久 running。
3. attempt 只在 claim 时递增,失败处理不得二次递增。
4. 幂等插入必须优先使用 `ON CONFLICT DO NOTHING` / `insertIgnore` + 回查不把唯一约束异常当正常路径PostgreSQL 同事务异常可能让事务进入 aborted 状态。
5. `dead_letter` 不能自动 replay只有确认是临时故障的记录才能被重置为 `retryable`
6. 所有状态转换必须有中文日志,至少包含 owner、tenantId、ownerUserId、source tuple、outboxId、attempt 和错误摘要。
7. owner 执行版必须显式冻结最小字段词汇:`max_attempt``next_retry_at``claim_expires_at`、固定退避策略、claim 索引和依赖树验证命令。
## 用户可见事件判定准则
只有同时满足以下条件的事件才进入统一 SSE
1. 用户需要实时感知该变化,且变化能影响当前用户下一步操作。
2. 事件 payload 可以被压缩成 OpenAPI 已声明 schema 内的安全摘要。
3. 事件不要求前端读取内部风控、授权、source propagation 或 worker 调度细节。
4. 事件 owner 能提供稳定 source tuple、幂等 commandId 和 ownerUserId。
默认不进入统一 SSE 的事件:
1. 只服务内部 followup trigger 的领域事件,例如 `BlockSavedEvent`
2. projection rebuild、source propagation target、retry job、worker heartbeat 等内部任务状态。
3. 需要完整正文、知识资料、provider raw body、授权详情或安全风控原始证据才能解释的事件。
## 阶段化路线
### P1R-7b全局规则冻结 + AI terminal event 第一切片
目标:
- 修订现有 AI 第一切片执行版,使其服从本全局方案。
- AI terminal `done/error` 通过本域 outbox + worker 发布到 Events。
- 修正当前草稿中可能存在的同事务唯一冲突异常风险,采用 `ON CONFLICT DO NOTHING` / insert-ignore + 回查。
- 证明 AI 只依赖 events-api不依赖 events-server。
不做:
- 不把 AI 以外 owner 混入 P1R-7b 实现。
- 不标 completed。
### P1R-7cKnowledge propagation
目标:
- 选择 Knowledge source status / projection summary 中最小用户可见事件。
- 在 Knowledge owner 内新增或复用 publish outbox 语义,补 claim/retry/dead-letter。
- 区分内部 projection task 和用户可见 Events notification。
不做:
- 不把同步完成的 projection task 伪装成异步 worker。
- 不发布知识资料原文。
### P1R-7dMarket propagation
目标:
- 覆盖 publish review、asset delist/recall、license/install/purchase 对用户可见状态的通知。
- 保留 Market Account projection outbox 的原职责,另建或扩展专门 Events publish outbox。
不做:
- 不把 Market 32 operations 标为 completed。
- 不让 Market 事件接管源作品、源智能体或源知识库事实 owner。
### P1R-7eAccount(Member) propagation
目标:
- 覆盖 security event、entitlement/quota notification、New-API attribution summary 中适合用户可见的事件。
- 复用既有安全事件脱敏转换,但新增统一 Events payload allowlist。
- 如果后续目标偏“用户通知”而不是“source status propagation”Account security event 可以作为 Knowledge 之后的低风险 second slice 备选。
不做:
- 不发布设备风控原始细节、IP 内部判断、接口调用原始日志或成本原始 authority。
### P1R-7fContent propagation
目标:
- 评估 BlockSavedEvent、export task、import task 中哪些应进入用户可见 SSE。
- 明确内部 followup trigger 与用户通知的分界。
不做:
- 不把正文保存的每次内部事件都推给 SSE。
- 不发布正文全文或 block content。
## 完成审批门槛
P1R-7 completed approval 必须另起任务,并至少具备:
1. Events owner 自身 gateroute ownership、OpenAPI schema、payload sanitizer、idempotency、SSE replay、frontend fetch SSE。
2. 至少覆盖 AI、Knowledge、Market、Account(Member)、Content 中被声明为 P1R source owner 的真实发布链路。
3. 每个 owner 都有成功、重复幂等、临时失败重试、retry exhausted、dead-letter、敏感 payload fail-closed、跨 owner 依赖方向测试。
4. PostgreSQL `_test` Flyway / migration 证据覆盖新增 outbox 表和 `muse_unified_event`
5. HTTP / MockMvc / SSE 或等价 focused E2E 证据证明当前用户只能看到同 tenant + ownerUserId 的 accepted 事件。
6. 审计或日志证据证明事件发布链路可追踪、可排查、可回放。
7. 用户明确批准从 `needs_verification` 推进到 `completed`
## 风险
- 抽象过早:如果现在先做共享 publish 库,可能在第二个 owner 前抽错接口;因此先冻结合同和测试模板,等 AI + Knowledge 两个 owner 通过后再抽小型 support module。
- 事务风险:同事务内 outbox 插入如果靠捕获唯一约束异常处理PostgreSQL 可能使事务不可继续;实现必须使用 insert-ignore 或先锁定事实行。
- Payload 风险:不同 owner 的“摘要”含义不同,必须逐 owner allowlist不允许泛化 Map 透传。
- 完成状态风险source propagation evidence 增加不等于 P1R-7 completedcompleted 必须另有审批。
- Dirty baseline 风险:当前 worktree 已有 AI 草稿后续执行前必须决定是修订沿用、拆分提交还是用户批准后重置对应草稿本审阅版不处理。P1R-7b 执行版修订并双 review PASS 前,当前 AI outbox / V17 / docs 草稿不得继续测试、提交、推送或作为完成证据。
## 待确认项
1. 是否确认以本文作为新的 P1R-7 source owner propagation 全局主线,替代此前 AI-only 审阅版作为最高层方案。
2. 是否确认 P1R-7b 仍从 AI terminal event 第一切片落地,但必须先修订执行版以符合本文全局规则。
3. 是否确认 completed approval 另起任务,当前所有阶段只推进 `needs_verification` 证据。
## 下一步
1. 对本文进行 fresh spec compliance review。
2. 对本文进行 fresh quality / feasibility review。
3. 双 PASS 后修订 `docs/agent-specs/2026-06-06-P1R7bSourceOwnerPropagation执行版.md`,把 AI 第一切片执行版降级为全局方案下的 P1R-7b 子计划。
4. P1R-7b 子计划双 review PASS 后,才允许处理当前 AI 草稿实现和后续 worker 任务。

View File

@ -0,0 +1,353 @@
# P1R7 Source Owner Propagation 全局执行版
## 结论
本执行版只定义 P1R-7 Source Owner Propagation 的全局落地顺序、子阶段拆分、review gate、验证门槛和当前 AI 草稿处理规则;不实现代码,不提交,不推送,不推进 completed。
总执行主线:
```mermaid
flowchart TB
GlobalReview["全局审阅版<br/>已双 review PASS"] --> GlobalPlan["全局执行版<br/>本文件,待双 review"]
GlobalPlan --> Freeze["冻结全局合同<br/>用户可见 Events 不替代内部 Source/Authorization"]
Freeze --> P7bPlan["P1R-7b 子计划<br/>AI terminal event"]
P7bPlan --> P7bImpl["P1R-7b 实现<br/>AI outbox + worker + Events evidence"]
P7bImpl --> NextDecision{"下一 owner 选择"}
NextDecision --> P7c["P1R-7c Knowledge<br/>source status / projection notification"]
NextDecision --> P7e["P1R-7e Account<br/>security / quota notification 备选"]
P7c --> P7d["P1R-7d Market<br/>lifecycle / governance notification"]
P7e --> P7d
P7d --> P7f["P1R-7f Content<br/>用户可见 content/task notification"]
P7f --> Approval["P1R-7 completed approval<br/>用户单独批准"]
```
硬边界:
1. 当前 AI outbox / V17 / docs 草稿在 P1R-7b 子计划修订并双 review PASS 前不得继续测试、提交、推送或作为完成证据。
2. 全局方案只定义“用户可见 Events 发布链路”,不替代正式 Source / Authorization 内部传播链路。
3. 每个 owner 的实现都必须 fresh implementer + fresh spec review + fresh quality / feasibility review。
4. completed approval 必须另起任务,不能由任何 dedicated gate 或 owner evidence 自动推出。
## 范围
本执行版负责:
- 固化 P1R-7 全局 source owner propagation 合同。
- 定义后续 P1R-7b/c/d/e/f 子计划顺序和拆分条件。
- 定义每个子计划必须继承的通用验证门槛。
- 定义当前 dirty AI 草稿如何进入 P1R-7b 子计划复审。
本执行版不负责:
- 不改业务代码、迁移、OpenAPI、scanner、coverage JSON/Markdown。
- 不运行 Maven / pnpm / Flyway / coverage scanner full verification。
- 不清理或回退当前 dirty/untracked baseline。
- 不把 `streamEvents`、P1R-7、Market 或任何 owner 标为 `completed`
## 已验证事实
### 工作区与 review gate
1. 正确工作区为 `/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`
2. 当前分支为 `dev/1.0.0`,远端 `origin/dev/1.0.0` 已 ff-only 对齐。
3. 当前 HEAD 为 `fa29753 test(p1r): 收口 Events SSE 门禁`
4. 当前 `git status --short --branch` 显示未提交 AI P1R-7b 草稿、V17 migration 草稿和 `docs/agent-specs/` 文档草稿。
5. 受保护文件定向 `git status --short` 无输出。
6. `docs/agent-specs/2026-06-06-P1R7SourceOwnerPropagation全局审阅版.md` 已通过 fresh spec review
- Kant / `019e9987-ca59-7370-bc49-9938eb93c70f` / PASS。
7. 同一全局审阅版已通过 fresh quality / feasibility review
- Hubble / `019e998b-2d82-7200-9289-c6c8dd37f744` / PASS。
### Coverage 与状态边界
1. 当前 coverage summary
- `totalOperations = 233`
- `completedOperations = 100`
- `needsVerificationOperations = 133`
- `incompleteOperations = 0`
- `genericPersistenceOperations = 0`
- `ssePlaceholderOperations = 0`
2. Events 当前唯一 operation 为 `streamEvents GET /app-api/muse/events = dedicated / needs_verification`
3. Market 当前 32 operations 均为 `dedicated / needs_verification`
4. P1R-7a 收口文档明确P1R-7a 未接入 source owner propagation未运行 source owner publish tests。
### 全局设计事实
1. `CLAUDE.md` 将 Source 传播定为“事件驱动 + 各模块自治”。
2. `design-docs/架构-02-核心数据结构与双轨模型.md` 明确不需要独立 source 模块。
3. `design-docs/后端-03-关键流程实现与接口契约.md` 明确异步失败不得回滚已提交 Canonical。
4. P1R-7a 执行版已固定 source owner 本域 outbox + worker 调用 Events APIEvents server 不反向依赖 source owner server。
5. Events owner 当前已有 `EventsPublishApi``EventsPublishReqDTO`、commandId/source tuple 幂等、payload sanitizer、accepted-only 可见查询和 SSE stream。
## 推断
1. 全局执行顺序应先冻结通用合同,再修订 P1R-7b AI 子计划;否则当前 AI 草稿可能继续携带单点设计里的事务和 dirty baseline 风险。
2. AI 仍是第一实现切片,因为它已有 terminal fact、ownerUserId、runtime job 和 focused test 基础。
3. Knowledge 适合作为第二个 source status propagation owner但如果后续目标更偏用户通知而非来源状态传播Account security event 可以作为更低风险 second slice 备选。
4. Market 应排在 AI/Knowledge 之后,因为 Market Account projection outbox 与 Events publish outbox 职责不同,直接复用风险较高。
5. Content 需要先界定内部领域事件与用户可见事件,否则容易把 BlockSavedEvent 等内部 trigger 过度推送到 SSE。
## 假设
1. 后续 owner 允许在本域新增 publish outbox 或等价状态表。
2. 后续 owner server 允许新增 `muse-module-events-api` 依赖。
3. 现有 Events OpenAPI 事件类型足够覆盖第一轮用户可见摘要;如果不够,应另起合同变更审批。
4. 当前 AI 草稿可以作为 P1R-7b 子计划的候选素材,但必须经过执行版修订、双 review 和本地验证后才能继续。
## 全局合同
### 依赖合同
1. Source owner server 可以依赖 `muse-module-events-api`
2. Source owner server 不得依赖 `muse-module-events-server`
3. `muse-module-events-server` 不得依赖 AI / Knowledge / Market / Member / Content server。
4. `muse-server` 可以装配 Events server 与各 source owner server。
5. 每个 owner 子计划必须包含 Maven dependency tree 验证命令。
### 发布 envelope 合同
每个 owner 子计划必须固定:
| 字段 | 要求 |
|---|---|
| `commandId` | 稳定短幂等键,长度 `<= 128`,推荐 `<owner>_evt:<sha256-32>` |
| `tenantId` | 必填worker 必须恢复租户上下文 |
| `ownerUserId` | 必填,决定 SSE 可见性 |
| `sourceOwner` | 固定 owner 枚举 |
| `sourceType` | 固定业务事实类型 |
| `sourceId` | 本域事实稳定 id 或业务 id |
| `sourceRevision` | 本域事实版本;缺失时必须在子计划解释占位语义 |
| `eventType` | 只能使用 Events OpenAPI 已声明类型 |
| `resourceType/resourceId` | UI 定位对象,不参与替代 source tuple 幂等 |
| `payloadSummary` | OpenAPI allowlist 安全摘要 |
| `emittedAt` | 本域事实发生时间 |
### 用户可见事件判定
每个候选事件进入 SSE 前必须满足:
1. 用户需要实时感知。
2. 用户可据此采取动作或理解当前工作状态。
3. payload 能压缩为 OpenAPI 已声明 schema 的安全摘要。
4. 不需要暴露内部 source propagation、授权快照、worker、风控或 provider raw 细节。
5. 能提供稳定 source tuple、commandId、ownerUserId 和 emittedAt。
默认不进入 SSE
1. 内部 followup trigger。
2. projection rebuild / worker heartbeat / retry job 状态。
3. 需要完整正文、知识资料、provider raw、授权详情或安全原始证据才能解释的事件。
### Outbox 合同
每个 owner 子计划必须包含:
1. `queued/running/retryable/published/dead_letter` 或等价状态。
2. `attempt_count` 或等价 claim 次数字段。
3. `max_attempt` 配置。
4. `next_retry_at`
5. `claimed_at`
6. `claim_expires_at`
7. 固定退避策略。
8. claim 索引。
9. `FOR UPDATE SKIP LOCKED` 或等价原子领取机制。
10. `ON CONFLICT DO NOTHING` / `insertIgnore` + 回查,禁止把唯一约束异常作为正常幂等路径。
11. `published_event_id` / `published_sequence_no` 或等价回写字段。
12. `last_error_code` / `last_error_message` 安全错误摘要。
13. 中文日志,包含 owner、tenantId、ownerUserId、source tuple、outboxId、attempt、状态转换和错误摘要。
### Payload 合同
每个 owner 子计划必须列出:
1. 允许的 `eventType`
2. 每个 `eventType` 的 payload allowlist。
3. 需要丢弃或归一化的敏感字段。
4. payload invalid 时的 fail-closed 状态和错误码。
5. rejected/blocked 后不得进入 SSE 可见查询的验证方式。
## 子阶段执行顺序
### Task 0冻结总执行版
目标:
- 本文件完成 fresh spec review + fresh quality / feasibility review 双 PASS。
- 双 PASS 前不修订 P1R-7b 子计划,不继续 AI 草稿测试或实现。
验收:
- Reviewer 明确 PASS。
- 如有 FAIL先修订本文件并重审。
- protected files 仍无改动。
### Task 1P1R-7b 子计划修订
目标:
- 修订 `docs/agent-specs/2026-06-06-P1R7bSourceOwnerPropagation执行版.md`
- 将它从“AI 单点执行版”改为“全局执行版下的 AI 第一切片子计划”。
- 明确当前 AI 草稿的处理策略:修订沿用、拆分提交或用户批准后清理。
必须补充:
1. 继承本执行版的依赖合同、envelope 合同、outbox 合同和 payload 合同。
2. 明确 AI 只发布用户可见 terminal `done/error` 摘要。
3. 明确 AI outbox 创建不得依赖捕获 `DuplicateKeyException` 作为正常幂等路径。
4. 明确 P1R-7b 只处理 AI不处理 Knowledge / Market / Account / Content。
5. 明确 P1R-7b 不推进 completed。
Review gate
- Fresh spec compliance review。
- Fresh quality / feasibility review。
- 双 PASS 前不得继续当前 AI 草稿实现。
### Task 2P1R-7b AI 实现收口
目标:
- 在 P1R-7b 子计划双 PASS 后,处理当前 AI 草稿或由 fresh implementer 重做。
- 实现 AI terminal event -> AI publish outbox -> AI worker -> EventsPublishApi -> `muse_unified_event` -> SSE 可见证据。
必须验证:
1. AI focused tests。
2. Outbox mapper/service/worker tests。
3. Events publish accepted/rejected/blocked/duplicate tests。
4. SSE visible query tests。
5. Dependency treeAI 只依赖 events-api不依赖 events-serverEvents server 不依赖 source owner server。
6. Migration SQL test 和 Flyway `_test`
7. protected files 无改动。
Review gate
- 每个实现 Task fresh implementer。
- 每个实现 Task fresh spec review。
- 每个实现 Task fresh quality / feasibility review。
### Task 3第二 owner 选择决策
目标:
- 在 AI 第一切片通过后,只读评估第二 owner 是 Knowledge source status 还是 Account security notification。
选择规则:
1. 如果目标是 source status propagation 证据优先,选择 Knowledge。
2. 如果目标是用户可见通知链路低风险扩展优先,选择 Account security event。
3. 不因 Market 有 outbox 命名而优先选择 Market。
4. Content 进入前必须先冻结内部事件与用户可见事件判定。
5. 如果 Account security event 先于 Market 落地,阶段编号仍保留 P1R-7e不重命名 P1R-7d避免阶段编号反向改写历史计划。
输出:
- `docs/agent-specs/YYYY-MM-DD-P1R7c...审阅版.md`
- fresh spec review + fresh quality review。
### Task 4P1R-7c / P1R-7e 子计划与实现
目标:
- 按 Task 3 决策进入 Knowledge 或 Account。
- 每个 owner 都必须先产出单独审阅版,再产出单独执行版;审阅版和执行版各自 fresh spec review + fresh quality / feasibility review 双 PASS 后,才允许进入该 owner 实现。
必须继承:
- 本执行版的全局合同。
- P1R-7b 的复用经验,但不得复制 AI payload / 状态机细节到不匹配 owner。
### Task 5Market 与 Content 后续切片
目标:
- Market 先拆清 Account projection outbox 与 Events publish outbox 职责。
- Content 先拆清内部 followup trigger 与用户可见 Events notification。
进入条件:
- 至少已有两个 owner 的 Events publish 链路通过验证,或者用户明确要求优先 Market / Content。
### Task 6P1R-7 completed approval 预检
目标:
- 汇总 Events owner + 各 source owner evidence。
- 只读判断是否具备 completed approval 申请条件。
必须包含:
1. coverage 当前状态。
2. 所有 owner 的测试证据。
3. HTTP / SSE / DB / Flyway / dependency / protected file evidence。
4. 未覆盖 owner 和未覆盖失败路径清单。
边界:
- 该 task 只做预检,不自动标 completed。
- completed approval 必须用户单独批准。
## 验证命令模板
每个 owner 子计划必须按实际模块填充以下模板:
```bash
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0
git status --short --branch
git status --short -- 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
```
```bash
cd muse-cloud
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" mvn -o -pl <owner-module> -am -Dtest=<focused-tests> -Dsurefire.failIfNoSpecifiedTests=false test
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" mvn -o -pl muse-server -am -Dtest=<p1r-gate-tests> -Dsurefire.failIfNoSpecifiedTests=false test
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" mvn -o -pl <owner-module> -am dependency:tree
```
Flyway `_test` 必须使用独立测试库,且必须显式清空 JVM 代理参数或证明代理不会影响内网 PostgreSQL 连接。
## 当前 dirty baseline 处理规则
当前未提交 AI P1R-7b 草稿包括:
- AI module POM、runtime projection service、job service 与相关测试改动。
- AI publish outbox service / mapper / DO 新文件。
- `muse-cloud/sql/muse/V17__extend_ai_events_publish_outbox.sql`
- P1R-7b migration SQL/Flyway test 草稿。
- `docs/agent-specs/` 文档草稿。
处理规则:
1. 本执行版双 PASS 前,不继续测试或实现这些草稿。
2. P1R-7b 子计划修订时必须显式决定这些草稿是“修订沿用”还是“废弃重做”。
3. 如果选择修订沿用,必须补证据证明草稿已符合本执行版全局合同。
4. 如果选择废弃重做,必须先征得用户明确批准后才能清理相关 dirty/untracked 文件。
5. 任何情况下不得回退用户或其他代理的无关改动。
## 完成条件
本总执行版完成条件:
1. 本文件存在且内容覆盖全局合同、子阶段顺序、验证模板、dirty baseline 处理规则。
2. Fresh spec compliance review PASS。
3. Fresh quality / feasibility review PASS。
4. protected files 无改动。
5. 输出下一步:修订 P1R-7b 子计划,而不是直接继续 AI 实现。
6. 本轮 reviewer 只读审查文档,不运行 full verificationreview PASS 不能解释为实现验证 PASS。
P1R-7 全链路完成条件不在本文件完成范围内;它必须在 P1R-7 completed approval 任务中单独判断。
## 回滚策略
本执行版只新增文档;若 review FAIL
1. 只修订本文件和必要的 `.agent` 说明。
2. 不回退代码草稿。
3. 不修改 protected files。
4. 不运行 full verification。
若后续实现阶段发现全局合同错误:
1. 停止对应 owner 实现。
2. 回到全局审阅版 / 执行版修订。
3. 重新 fresh spec review + fresh quality review。

View File

@ -0,0 +1,148 @@
# P1R7b Source Owner Propagation 审阅版
## 结论
推荐进入 P1R-7b但只进入 source owner propagation 的第一切片,不进入 Events / P1R-7 / Market completed approval。
推荐拆分为:
1. P1R-7b只选择 AI task terminal event 作为第一切片,证明 AI owner 本域事件通过最小 publish outbox / worker 调用 `EventsPublishApi`,落入 `muse_unified_event`,并可被 `/app-api/muse/events` SSE 读到。
2. P1R-7c 或后续:再评估 Knowledge、Market、Member(Account) 的 source owner propagation不在 P1R-7b 同时改造多个 owner。
P1R-7b 最多补齐 source owner propagation 的 `needs_verification` 证据;不得自动把 Events、总 P1R-7 或 Market 32 operations 标为 `completed`
## 已验证事实
- 正确工作区已核验为 `/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0``git log -1 --oneline``fa29753 test(p1r): 收口 Events SSE 门禁`
- P1R-7a 推送后正确 worktree 曾核验为干净;本文档写入后当前 live `git status --short` 显示 `?? docs/agent-specs/`。后续执行版和实现前必须重新记录 live dirty baseline。受保护文件无改动
- `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`
- coverage 当前为 `completed=100``needsVerification=133``incomplete=0``genericPersistence=0``ssePlaceholder=0`
- Events 当前唯一 operation`streamEvents GET /app-api/muse/events = dedicated / needs_verification`
- Market 当前 32 operations 均仍为 `dedicated / needs_verification`
- P1R-7a 收口文档明确P1R-7a 未接入 source owner propagation未运行 source owner publish tests。
- `muse-module-events-api` 已存在 `EventsPublishApi.publish(EventsPublishReqDTO)`Events server 已有 `muse_unified_event` 投影、幂等回查、payload 脱敏、`accepted/rejected/blocked` 状态和可见事件查询。
- 当前 AI 有 `muse_ai_task_event`、task event replay mapper、`MuseAiRuntimeJobDispatcher``MuseAiJobMapper.claimNextQueuedRuntimeJob()`;但 AI runtime job 只领取 `ai_task_runtime`AI source event retry 只提交 `source_event_retry` job未验证存在 Events publish worker。
- 当前 Knowledge 有 `muse_knowledge_source_event``muse_knowledge_projection_task``MuseKnowledgeSourceEventService` 同步更新 projection 后把 task 直接写为 `completed`,注释说明避免制造无执行者的 queued task。
- 当前 Market 有 `MarketAccountProjectionOutboxService`,但接口只有 `recordSynced` / `recordBlocked`,用于 Market 写 member Account projection 的同步状态;未发现 claim/retry/dead-letter/replay worker。
- 当前 Member(Account) 有 `AccountIntegrationCallDO``MemberSecurityEventDO` 和安全事件脱敏转换mapper 只支持查询,不支持 publish outbox claim 或补偿状态转换。
## 推断
- AI 是 P1R-7b 第一切片的最小风险 owner它已有 owner-visible task terminal event、runtime job、sequence/replay 和 focused test 基础,新增 Events publish outbox/worker 的范围最小。
- Market 虽然有 outbox 命名和状态字段,但当前职责是 Account 投影同步记录,不是通用 Events publish 补偿队列;直接复用会混淆职责。
- Knowledge 和 Member(Account) 要进入统一事件传播,都需要先补本域 publish outbox/worker 语义;放入 P1R-7b 会扩大 blast radius。
- 如果 P1R-7b 同时拉 AI、Knowledge、Market、Member(Account),大概率会变成跨 owner 队列重构,不符合“真实最小”的阶段目标。
## 假设
- P1R-7b 允许在 AI owner 内新增最小 Events publish outbox/worker 结构,但仍不得修改 OpenAPI、scanner 或 coverage 口径。
- P1R-7b 的 source owner publish tests 可以优先使用 focused unit / integration tests 证明发布、失败、重试、幂等和 SSE 可见性,不要求一次性覆盖所有业务 owner。
- 当前代码结构中的 Events API 依赖方向会保持source owner 可以依赖 `muse-module-events-api`,但 Events server 不反向依赖 AI / Knowledge / Market / Member server。
## 目标
- 证明至少一个真实 source owner 能把本域事实传播到统一 Events 投影。
- 第一切片只覆盖 AI task terminal event 的 `done` / `error` 或等价最小可公开事件。
- 建立 source owner 本域 outbox/job 到 `EventsPublishApi` 的可追踪链路。
- 产出 publish tests 和最小 E2E evidence证明 source owner -> Events -> `muse_unified_event` -> SSE stream 的链路可验证。
- 保持 `streamEvents` 和相关 owner 状态在 `needs_verification` 证据推进层,不做 completed approval。
## 非目标
- 不实现代码;本文档只做人类审阅版设计。
- 不修改任何 OpenAPI 合同。
- 不修改 coverage scanner 或通过 scanner 改口径掩盖缺口。
- 不修改 coverage JSON / Markdown不标 `completed`
- 不把 P1R-7b 扩大成总 P1R-7 End-to-End Acceptance。
- 不把 Market 32 operations 从 `needs_verification` 推进到 `completed`
- 不同时改造 AI、Knowledge、Market、Member(Account) 四个 owner。
- 不清理、不回退、不修复错误 checkout `/Users/qingse/Sync/local-git/oh-my-muse` 的任何残留。
## 推荐方案
采用“AI first slice + 后续 owner 分批”的真实最小方案。
```mermaid
flowchart LR
Owner["AI source owner<br/>task terminal fact"] --> Outbox["AI local publish outbox/job<br/>queued/running/failed/dead_letter"]
Outbox --> Worker["AI publish worker<br/>retry + idempotency"]
Worker --> Api["EventsPublishApi<br/>events-api only"]
Api --> Store[("muse_unified_event<br/>accepted/rejected/blocked")]
Store --> Query["Events visible query<br/>tenant + ownerUserId + sequence"]
Query --> SSE["/app-api/muse/events<br/>SSE stream"]
```
推荐 P1R-7b 第一切片边界:
- Source ownerAI。
- Source factAI task terminal event优先选择已可公开、已持久化、已有 ownerUserId 和 sequence 的 done/error 事实。
- Publish modeAI 本域 outbox/job 异步调用 `EventsPublishApi`,不在业务事务内阻塞等待 Events 成功。
- Idempotency使用 AI 本域 command/source tuple 映射 Events `commandId``(sourceOwner, sourceType, sourceId, sourceRevision, eventType)`
- FailureEvents 临时失败时 AI 业务事实仍保留outbox 保持可重试;达到上限后进入阻塞或 dead-letter 等价状态。
- SSE evidence只证明 accepted 事件进入 `muse_unified_event` 后能被当前用户 SSE 可见查询读到。
后续切片:
- P1R-7cKnowledge source/projection event publish outbox。
- P1R-7dMarket governance / Account projection 相关事件,需先拆清 Account 投影 outbox 与 Events publish outbox 职责。
- P1R-7eMember(Account) 安全/额度通知,需先冻结脱敏 payload contract。
## 候选 source owner 分级
| 分级 | Owner | 结论 | 主要证据 |
|---|---|---|---|
| 第一切片 | AI | 推荐 P1R-7b 先做 | 已有 task event、runtime job、ownerUserId、sequence/replay 基础;缺口集中在 Events publish outbox/worker |
| 后续高优先 | Knowledge | 放入 P1R-7c | 有 source event/projection 事实,但 projection task 当前同步完成,不是可执行补偿队列 |
| 后续中优先 | Market | 放入后续 | 有 Account projection outbox 状态记录,但职责是 Market -> Member Account 投影同步,不是 Events publish worker |
| 后续中优先 | Member(Account) | 放入后续 | 有安全事件和脱敏转换,但缺少 publish outbox/job/retry/dead-letter 入口 |
## 关键取舍
- 选择 AI不选择 MarketMarket outbox 名称容易误导,但当前没有 claim/retry/dead-letter/replay workerAI 的 task terminal event 更接近可公开事件源。
- 做一个 owner不做四个 ownersource owner propagation 的核心风险在失败补偿和幂等;先用单 owner 打穿链路,比并行堆多个半成品更可审核。
- 新增本域 publish outbox不把 runtime job 当 Events publish 队列AI runtime job 职责是执行 New-API task混用会污染任务语义和重试策略。
- 保持 Events API 单向依赖source owner 依赖 events-apiEvents server 不读取 source owner 表,不反向依赖 source owner server。
- 只推进证据,不推进 completedP1R-7b 是 source propagation evidence 阶段,不是审批阶段。
## 影响范围
- 直接影响AI owner 的 task terminal event 发布路径、Events publish API 调用、`muse_unified_event` 投影可见性、SSE focused evidence。
- 间接影响P1R-7 completed approval 的后续证据链更完整,但不会自动改变 completed 状态。
- 不影响OpenAPI 合同、scanner 规则、Market coverage 状态、错误 checkout `/Users/qingse/Sync/local-git/oh-my-muse`
## 风险与兼容性
- 幂等风险:同一 AI task terminal event 重试不能生成多条 unified event必须用 command/source tuple 双重幂等验证。
- 事务风险AI 业务事实不能因为 Events 暂时不可用而回滚publish outbox 必须承载失败补偿。
- 敏感信息风险AI payload 只能发布 OpenAPI 允许的 done/error 摘要,不能透传 provider raw body、prompt、token、授权头或私有上下文。
- 顺序风险AI task sequence 与 Events 全局 sequence 是不同序列;文档和测试必须明确映射,不得混用 cursor。
- 兼容性风险:新增 AI -> events-api 依赖可以接受;不得新增 AI -> events-server 依赖,也不得新增 Events server -> AI server 依赖。
- 状态风险P1R-7b evidence 可能让 Events 更接近 completion但没有用户明确 approval 前不得改 coverage completed。
## 验收标准
- 有只读 preflight 证明 worktree、HEAD、coverage 和 protected files 状态。
- AI 第一切片 source fact、outbox/job、publish worker、EventsPublishApi、`muse_unified_event` 和 SSE 可见查询形成闭环证据。
- Publish tests 至少覆盖成功发布、重复发布幂等、EventsPublishApi 临时失败后 outbox 可重试、非法/敏感 payload 不进入可见 SSE、dead-letter 或等价阻塞状态。
- E2E evidence 至少证明 AI terminal fact 发布后,`muse_unified_event` 有 accepted 记录,并能被 `/app-api/muse/events` 当前用户 SSE 读到。
- 依赖方向测试证明 Events server 不依赖 AI / Knowledge / Market / Member serverAI 只依赖 `muse-module-events-api`
- coverage 仍不标 completed若需要 completed approval必须另起审批任务。
- 受保护文件保持无改动。
## 待确认项
1. P1R-7b 第一切片是否确认只选 AI task terminal event而不是 Market / Knowledge / Member。
2. AI terminal event 的最小事件类型是否限定为 `done` / `error`,还是允许补一个 `notification` 摘要。
3. P1R-7b 是否允许新增 AI 本域 publish outbox/job 表或等价状态字段;若不允许,只能做更弱的同步 publish 证明,不建议。
4. P1R-7b 的 E2E evidence 是否需要真实 PostgreSQL `_test` + MockMvc/SSE还是 focused integration test 足够进入下一轮 review。
## 下一步
1. 人类确认本文推荐方案和待确认项。
2. 确认后再写执行版计划,路径建议为 `docs/agent-specs/2026-06-06-P1R7bSourceOwnerPropagation执行版.md`
3. 执行版计划需先过 fresh spec review + fresh quality review双 PASS 前不实现代码、不跑 full verification。
4. 实现阶段只在 `/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0` 进行,不处理 `/Users/qingse/Sync/local-git/oh-my-muse` 残留。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,218 @@
# P1R7b Source Owner Propagation 真实链路
日期2026-06-06
## 结论
P1R-7b Task 7 fresh implementer 最终验证、阶段留痕与交接已执行,状态为 `DONE`
本轮只把 AI source owner terminal event -> AI publish outbox -> AI worker -> EventsPublishApi -> `muse_unified_event` -> SSE 可见链路的 evidence 推进到 `needs_verification` 证据层;不代表 Events、P1R-7 或 Market completed approval。
本轮不提交、不 push、不清理或回退 dirty baseline除本文档与 `docs/agent-specs/.agent` 外,不新增实现改动。
## 已验证事实
### 工作区与保护文件
- 正确 worktree`/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`
- 分支:`dev/1.0.0``git status --short --branch` 显示 `## dev/1.0.0...origin/dev/1.0.0`
- 初始与最终受保护文件定向 status/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`
### Reactor build
`muse-cloud` 执行:
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true -pl muse-server -am
```
结果exit 0`BUILD SUCCESS`47/47 reactor modules successFinished at `2026-06-06T15:42:09+08:00`
### AI / Events focused tests
执行:
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" mvn -pl muse-module-ai/muse-module-ai-server,muse-module-events/muse-module-events-server -am -Dtest=MuseAiEventPublishOutboxMapperTest,MuseAiEventPublishOutboxServiceTest,MuseAiEventPublishWorkerTest,MuseAiRuntimeProjectionServiceTest,MuseJobServiceTest,EventsPublishServiceTest,EventsStreamServiceTest -Dsurefire.failIfNoSpecifiedTests=false test
```
结果exit 0。surefire XML 证明以下目标类实际运行且 test count > 0
| 测试类 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `MuseAiEventPublishOutboxMapperTest` | 5 | 0 | 0 | 0 |
| `MuseAiEventPublishOutboxServiceTest` | 11 | 0 | 0 | 0 |
| `MuseAiEventPublishWorkerTest` | 20 | 0 | 0 | 0 |
| `MuseAiRuntimeProjectionServiceTest` | 5 | 0 | 0 | 0 |
| `MuseJobServiceTest` | 25 | 0 | 0 | 0 |
| `EventsPublishServiceTest` | 17 | 0 | 0 | 0 |
| `EventsStreamServiceTest` | 9 | 0 | 0 | 0 |
AI/Events focused tests 合计 87 个failures/errors/skipped 均为 0。
### P1R mixed gates
执行:
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" mvn -pl muse-server -am -Dtest=P1rAiEventsPublishMigrationSqlTest,P1rAiEventsPublishDependencyTest,P1rAiEventsPublishEndToEndTest,P1rApiCoverageReportTest,P1rEventsRealApiGateTest,P1rEventsRouteOwnershipTest,P1rAiRealApiGateTest,P1rAiRouteOwnershipTest -Dsurefire.failIfNoSpecifiedTests=false test
```
结果exit 0。surefire XML 证明以下目标类实际运行:
| 测试类 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `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 |
P1R mixed gates 合计 37 个failures/errors/skipped 均为 0。
### Flyway `_test`
使用 `~/.config/muse-repo/infra.env`,测试库名为 `muse_p1r7b_events_publish_test`,符合必须以 `_test` 结尾的保护规则。
执行命令包含:
```bash
-Dtest=P1rAiEventsPublishFlywayMigrationIT
-Dflyway.postgresql.transactional.lock=false
-Dp1r.flyway.locations=filesystem:sql/muse
-Dp1r.flyway.url=jdbc:postgresql://<host>:5433/muse_p1r7b_events_publish_test
```
结果exit 0`BUILD SUCCESS`surefire XML 证明 `P1rAiEventsPublishFlywayMigrationIT` tests=4、failures=0、errors=0、skipped=0。
关键输出:
```text
flyway_success=true
flyway_url=jdbc:postgresql://<host>:5433/muse_p1r7b_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=17
successful_migration_count=17
target_schema_version=17
flyway_latest=17:extend ai events publish outbox
v17_table=muse_ai_event_publish_outbox
v17_indexes=idx_muse_ai_event_publish_outbox_claim,idx_muse_ai_event_publish_outbox_owner_status
v17_constraints=uk_muse_ai_event_publish_outbox_command,uk_muse_ai_event_publish_outbox_source,chk_muse_ai_event_publish_outbox_event_type,chk_muse_ai_event_publish_outbox_status,chk_muse_ai_event_publish_outbox_attempt_count,chk_muse_ai_event_publish_outbox_max_attempt
v17_triggers=trg_muse_ai_event_publish_outbox_update_time
```
### Dependency tree gate
执行 dependency tree 证明:
- AI server 有 `cn.iocoder.cloud:muse-module-events-api:jar:2026.04-SNAPSHOT:compile`
- AI server 禁止依赖 `muse-module-events-server` 的显式探针输出 `AI_EVENTS_SERVER_ABSENT`
- Events server 禁止依赖 AI / Knowledge / Market / Member / Content server 的显式探针输出 `EVENTS_FORBIDDEN_SOURCE_OWNER_ABSENT`
### Coverage scanner
scanner 只在隔离副本 `/tmp/p1r7b-final-coverage-scan.AoKcDl` 运行,未在真实 worktree 运行会写报告的 scanner。
执行:
```bash
python3 scripts/p1r-audit-api-coverage.py --check
```
结果exit 0并只在隔离副本中生成 coverage JSON/Markdown。
隔离副本 summary
```json
{
"totalOperations": 233,
"completedOperations": 100,
"incompleteOperations": 0,
"needsVerificationOperations": 133,
"catchAllOperations": 0,
"genericPersistenceOperations": 0,
"ssePlaceholderOperations": 0,
"missingOperations": 0,
"blockedOperations": 0
}
```
Events 状态:
```text
events streamEvents GET /app-api/muse/events dedicated needs_verification P1R-7 End-to-End Acceptance
```
Market 状态:
```text
dedicated/needs_verification 32
```
回到真实 worktree 后,受保护文件 diff/status 仍为空。
### Dirty baseline 区分
P1R-7b 实现/测试 dirty baseline 包括:
- `muse-cloud/muse-module-ai/muse-module-ai-server/pom.xml`
- `MuseAiRuntimeProjectionService.java`
- `MuseJobServiceImpl.java`
- `MuseAiProperties.java`
- `MuseAiRuntimeProjectionServiceTest.java`
- `MuseJobServiceTest.java`
- `MuseAiEventPublishOutboxService.java`
- `MuseAiEventPublishOutboxServiceImpl.java`
- `MuseAiEventPublishWorker.java`
- `MuseAiEventPublishOutboxDO.java`
- `MuseAiEventPublishOutboxMapper.java`
- `MuseAiEventPublishOutboxServiceTest.java`
- `MuseAiEventPublishWorkerTest.java`
- `MuseAiEventPublishOutboxMapperTest.java`
- `P1rAiEventsPublishDependencyTest.java`
- `P1rAiEventsPublishEndToEndTest.java`
- `P1rAiEventsPublishFlywayMigrationIT.java`
- `P1rAiEventsPublishMigrationSqlTest.java`
- `muse-cloud/sql/muse/V17__extend_ai_events_publish_outbox.sql`
文档 dirty baseline / 本轮允许文档范围:
- `docs/agent-specs/` 目录当前仍为 untracked 文档目录;本轮追加更新 `docs/agent-specs/.agent`
- 本轮新增本文档:`docs/memorys/2026-06-06-P1R7bSourceOwnerPropagation真实链路.md`
## 推断
基于 reactor build、focused tests、P1R mixed gates、Flyway `_test`、dependency tree、隔离 coverage scanner 和受保护文件 diff 的组合证据,可以判断 P1R-7b AI source owner propagation 真实链路已具备进入 fresh review 的 needs_verification 证据。
由于本轮没有进行 fresh spec review + fresh quality review 双 PASS也没有用户 completed approval不能推断 Events、P1R-7 或 Market 已 completed。
## 假设
- `~/.config/muse-repo/infra.env` 仍是项目允许用于 P1R `_test` Flyway 验收的本地/内网测试配置。
- 当前 P1R-7b 实现 dirty baseline 来自前置 fresh implementers本轮 Task 7 不清理、不回退、不重新归因这些实现文件。
## 风险与未完成
- Maven 多次出现 nexus metadata handshake / connect timeout warning但相关 build/test 命令最终 exit 0该 warning 未阻塞本轮验证。
- 当前 evidence 仍是 Task 7 implementer evidence后续必须 fresh spec review + fresh quality review。未双 PASS 前不能最终收口、提交、push 或推进 completed。
- Events 当前仍为 `dedicated / needs_verification`
- Market 32 operations 仍为 `dedicated / needs_verification`
- P1R-7 completed approval 必须另起单独审批,不能由本轮 Task 7 自动推出。
## 下一步
1. 派发 fresh Task 7 spec review核对本文档、`.agent`、验证命令、surefire XML、dependency tree、coverage scanner 隔离路径和 protected diff 证据。
2. 派发 fresh Task 7 quality / feasibility review核对真实链路、失败路径、日志审计、依赖方向、测试覆盖和交接风险。
3. 双 PASS 前不得最终收口、提交、push 或把 Events / P1R-7 / Market 标为 completed。

View File

@ -0,0 +1,287 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiEventPublishOutboxDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiTaskEventDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiEventPublishOutboxMapper;
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* AI Events publish outbox 创建服务测试
*/
class MuseAiEventPublishOutboxServiceTest extends BaseMockitoUnitTest {
private static final Pattern SHORT_COMMAND_ID = Pattern.compile("ai_evt:[0-9a-f]{32}");
@InjectMocks
private MuseAiEventPublishOutboxServiceImpl outboxService;
@Mock
private MuseAiEventPublishOutboxMapper outboxMapper;
@Mock
private EventsPublishApi eventsPublishApi;
@Test
void should_createQueuedOutboxForDoneTerminalEventWithAllowlistPayloadAndShortCommandId() {
MuseAiTaskEventDO event = terminalEvent("3001", "done", "completed",
JsonUtils.toJsonString(Map.of(
"suggestionId", 2001L,
"summary", "AI task completed",
"prompt", "用户原始 prompt",
"providerRawBody", "secret provider body",
"token", "secret-token",
"authorization", "Bearer secret-auth",
"privateContext", "private source context",
"fullOutput", "full model output")),
null, null);
when(outboxMapper.insertIgnore(any(MuseAiEventPublishOutboxDO.class))).thenReturn(1);
outboxService.createForTerminalEvent(event);
MuseAiEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals("queued", inserted.getPublishStatus());
assertEquals(0, inserted.getAttemptCount());
assertEquals(5, inserted.getMaxAttempt());
assertEquals("done", inserted.getEventType());
assertEquals("3001", inserted.getTaskId());
assertEquals(5001L, inserted.getSourceEventId());
assertEquals("2", inserted.getSourceRevision());
assertTrue(SHORT_COMMAND_ID.matcher(inserted.getCommandId()).matches());
assertTrue(inserted.getCommandId().length() <= 128);
assertFalse(inserted.getCommandId().contains("100|3001|2|done"),
"command_id 默认实现不能使用 tenant/task/sourceRevision/eventType 长拼接格式");
Map<String, Object> payload = parseMap(inserted.getPayloadSummary());
assertEquals(3001, ((Number) payload.get("taskId")).intValue());
assertEquals(2001, ((Number) payload.get("suggestionId")).intValue());
assertEquals("AI task completed", payload.get("summary"));
assertEquals(3, payload.size(), "done payload 只能包含 Events OpenAPI allowlist 字段");
assertNoSensitivePayload(inserted.getPayloadSummary());
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_notInsertOrThrowWhenSourceEventIdIsMissing() {
MuseAiTaskEventDO event = terminalEvent("3001", "done", "completed",
JsonUtils.toJsonString(Map.of("suggestionId", 2001L)), null, null);
event.setId(null);
assertDoesNotThrow(() -> outboxService.createForTerminalEvent(event));
verify(outboxMapper, never()).insertIgnore(any(MuseAiEventPublishOutboxDO.class));
verify(outboxMapper, never()).insert(any(MuseAiEventPublishOutboxDO.class));
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_useInsertIgnoreAndLookupDuplicateInsteadOfCatchingDuplicateKeyException() {
MuseAiTaskEventDO event = terminalEvent("3001", "done", "completed",
JsonUtils.toJsonString(Map.of("suggestionId", 2001L)), null, null);
MuseAiEventPublishOutboxDO existing = new MuseAiEventPublishOutboxDO();
when(outboxMapper.selectBySourceTuple(100L, "3001", "2", "done"))
.thenReturn(null, existing);
when(outboxMapper.insertIgnore(any(MuseAiEventPublishOutboxDO.class))).thenReturn(0);
assertDoesNotThrow(() -> outboxService.createForTerminalEvent(event));
verify(outboxMapper).insertIgnore(any(MuseAiEventPublishOutboxDO.class));
verify(outboxMapper, never()).insert(any(MuseAiEventPublishOutboxDO.class));
verify(outboxMapper, org.mockito.Mockito.times(2)).selectBySourceTuple(100L, "3001", "2", "done");
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_notUseDuplicateKeyExceptionAsNormalIdempotentBranch() throws Exception {
Path source = Path.of("src/main/java/cn/iocoder/muse/module/ai/application/muse/"
+ "MuseAiEventPublishOutboxServiceImpl.java");
String code = Files.readString(source);
assertFalse(code.contains("DuplicateKeyException"),
"Task 3 幂等必须走 insertIgnore 返回行数 + 回查,不能捕获 DuplicateKeyException 作为正常路径");
}
@Test
void should_dropDoneSummaryWhenTextContainsHighConfidenceCredentialsOrJsonBody() {
for (String unsafeSummary : unsafeTexts()) {
MuseAiTaskEventDO event = terminalEvent("3001", "done", "completed",
JsonUtils.toJsonString(Map.of("suggestionId", 2001L, "summary", unsafeSummary)),
null, null);
when(outboxMapper.insertIgnore(any(MuseAiEventPublishOutboxDO.class))).thenReturn(1);
outboxService.createForTerminalEvent(event);
MuseAiEventPublishOutboxDO inserted = captureInsertedOutbox();
Map<String, Object> payload = parseMap(inserted.getPayloadSummary());
assertFalse(payload.containsKey("summary"), "done summary 包含高置信凭据或 JSON 正文时不得进入 payload: " + unsafeSummary);
org.mockito.Mockito.clearInvocations(outboxMapper, eventsPublishApi);
}
}
@Test
void should_replaceUnsafeErrorMessageAndDropUnsafeDetail() {
for (String unsafeText : unsafeTexts()) {
MuseAiTaskEventDO event = terminalEvent("3001", "error", "failed",
JsonUtils.toJsonString(Map.of("detail", unsafeText, "retryable", false)),
"AI_PROVIDER_ERROR", unsafeText);
when(outboxMapper.insertIgnore(any(MuseAiEventPublishOutboxDO.class))).thenReturn(1);
outboxService.createForTerminalEvent(event);
MuseAiEventPublishOutboxDO inserted = captureInsertedOutbox();
Map<String, Object> payload = parseMap(inserted.getPayloadSummary());
assertEquals("AI task failed", payload.get("message"),
"error message 包含高置信凭据或 JSON 正文时必须替换成安全默认值: " + unsafeText);
assertFalse(payload.containsKey("detail"),
"error detail 包含高置信凭据或 JSON 正文时不得进入 payload: " + unsafeText);
org.mockito.Mockito.clearInvocations(outboxMapper, eventsPublishApi);
}
}
@Test
void should_keepDoneMissingSuggestionIdAsDeadLetterAndNotCreatePublishReadyQueuedRecord() {
MuseAiTaskEventDO event = terminalEvent("3001", "done", "completed",
JsonUtils.toJsonString(Map.of("summary", "AI task completed")),
null, null);
when(outboxMapper.insertIgnore(any(MuseAiEventPublishOutboxDO.class))).thenReturn(1);
outboxService.createForTerminalEvent(event);
MuseAiEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals("dead_letter", inserted.getPublishStatus());
assertEquals("AI_EVENTS_DONE_PAYLOAD_INCOMPLETE", inserted.getLastErrorCode());
assertNull(inserted.getNextRetryAt());
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_failClosedForNonNumericTaskIdAndNotCreatePublishReadyQueuedRecord() {
MuseAiTaskEventDO event = terminalEvent("task-abc", "error", "failed",
JsonUtils.toJsonString(Map.of("retryable", false)),
"AI_NEW_API_UNAVAILABLE", "AI task failed");
when(outboxMapper.insertIgnore(any(MuseAiEventPublishOutboxDO.class))).thenReturn(1);
outboxService.createForTerminalEvent(event);
MuseAiEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals("dead_letter", inserted.getPublishStatus());
assertEquals("AI_EVENTS_TASK_ID_NOT_NUMERIC", inserted.getLastErrorCode());
assertEquals("{}", inserted.getPayloadSummary());
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_createCancellationAsErrorPayloadWithoutUserReasonOrCancelledEventType() {
MuseAiTaskEventDO event = terminalEvent("3001", "error", "cancelled",
JsonUtils.toJsonString(Map.of("retryable", false, "reason", "用户取消原因")),
"AI_TASK_CANCELLED", "AI task cancelled");
when(outboxMapper.insertIgnore(any(MuseAiEventPublishOutboxDO.class))).thenReturn(1);
outboxService.createForTerminalEvent(event);
MuseAiEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals("queued", inserted.getPublishStatus());
assertEquals("error", inserted.getEventType(), "OpenAPI 没有 cancelled event type取消只能发布 error");
Map<String, Object> payload = parseMap(inserted.getPayloadSummary());
assertEquals(Map.of("code", "AI_TASK_CANCELLED", "message", "AI task cancelled", "retryable", false),
payload);
assertFalse(inserted.getPayloadSummary().contains("用户取消原因"),
"统一 Events payload 不得携带用户提交的取消原因");
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_ignoreNonTerminalEventAndReuseExistingOutboxForDuplicateSourceTuple() {
MuseAiTaskEventDO progress = terminalEvent("3001", "progress", "running", "{}", null, null);
MuseAiTaskEventDO done = terminalEvent("3001", "done", "completed",
JsonUtils.toJsonString(Map.of("suggestionId", 2001L)), null, null);
when(outboxMapper.selectBySourceTuple(100L, "3001", "2", "done"))
.thenReturn(new MuseAiEventPublishOutboxDO());
outboxService.createForTerminalEvent(progress);
outboxService.createForTerminalEvent(done);
verify(outboxMapper, never()).insertIgnore(any(MuseAiEventPublishOutboxDO.class));
verify(outboxMapper, never()).insert(any(MuseAiEventPublishOutboxDO.class));
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_notDependOnEventsPublishApiInTask3OutboxService() {
for (Field field : MuseAiEventPublishOutboxServiceImpl.class.getDeclaredFields()) {
assertFalse(EventsPublishApi.class.equals(field.getType()),
"Task 3 只创建 AI 本域 outbox不允许持有或调用 EventsPublishApi");
}
}
private MuseAiEventPublishOutboxDO captureInsertedOutbox() {
ArgumentCaptor<MuseAiEventPublishOutboxDO> captor = ArgumentCaptor.forClass(MuseAiEventPublishOutboxDO.class);
verify(outboxMapper).insertIgnore(captor.capture());
return captor.getValue();
}
private static MuseAiTaskEventDO terminalEvent(String taskId, String eventType, String taskStatus,
String payloadSummary, String errorCode, String errorMessage) {
MuseAiTaskEventDO event = new MuseAiTaskEventDO();
event.setId(5001L);
event.setTenantId(100L);
event.setOwnerUserId(1001L);
event.setTaskId(taskId);
event.setSequenceNo(2L);
event.setEventType(eventType);
event.setTaskStatus(taskStatus);
event.setPayloadSummary(payloadSummary);
event.setErrorCode(errorCode);
event.setErrorMessage(errorMessage);
event.setEmittedAt(LocalDateTime.of(2026, 6, 6, 10, 0));
return event;
}
private static Map<String, Object> parseMap(String json) {
return JsonUtils.parseObject(json, new TypeReference<Map<String, Object>>() {
});
}
private static String[] unsafeTexts() {
return new String[]{
"Provider returned invalid API key sk-testsecret1234567890",
"provider returned auth token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"password=correct-horse-battery-staple",
"Bearer abcdefghijklmnopqrstuvwxyz123456",
"authorization: Bearer abcdefghijklmnopqrstuvwxyz123456",
"token=secret-token-value",
"secret=my-secret-value",
"{\"provider\":\"raw\",\"body\":\"secret\"}"
};
}
private static void assertNoSensitivePayload(String json) {
for (String forbidden : new String[]{"prompt", "providerRawBody", "token", "auth", "private", "full output",
"secret-token", "secret-auth", "private source context", "full model output",
"用户原始 prompt", "secret provider body"}) {
assertFalse(json.contains(forbidden), "outbox payload 不得包含敏感字段或原文: " + forbidden);
}
}
}

View File

@ -0,0 +1,435 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiEventPublishOutboxDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiTaskEventDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiEventPublishOutboxMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiTaskEventMapper;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* AI Events publish worker 测试
*/
class MuseAiEventPublishWorkerTest extends BaseMockitoUnitTest {
@Mock
private MuseAiEventPublishOutboxMapper outboxMapper;
@Mock
private MuseAiTaskEventMapper taskEventMapper;
@Mock
private EventsPublishApi eventsPublishApi;
@AfterEach
void clearTenantContext() {
TenantContextHolder.clear();
}
@Test
void should_publishAcceptedOutboxAndMarkPublishedWithStableEventsEnvelope() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("done", 1, 5);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(resp("accepted", false)));
int dispatched = worker.dispatchOnce();
assertEquals(1, dispatched);
EventsPublishReqDTO reqDTO = capturePublishReq();
assertEquals("ai_evt:abcdef0123456789abcdef0123456789", reqDTO.getCommandId());
assertEquals(100L, reqDTO.getTenantId());
assertEquals(1001L, reqDTO.getOwnerUserId());
assertEquals("ai", reqDTO.getSourceOwner());
assertEquals("ai_task_event", reqDTO.getSourceType());
assertEquals("3001", reqDTO.getSourceId());
assertEquals("2", reqDTO.getSourceRevision());
assertEquals("done", reqDTO.getEventType());
assertEquals("ai_task", reqDTO.getResourceType());
assertEquals("3001", reqDTO.getResourceId());
assertEquals(Map.of("taskId", 3001L, "suggestionId", 2001L), reqDTO.getPayloadSummary());
assertEquals(LocalDateTime.of(2026, 6, 6, 10, 0), reqDTO.getEmittedAt());
verify(outboxMapper).markPublished(7001L, 100L, "evt_100", 9001L);
assertNull(TenantContextHolder.getTenantId(), "worker 完成后必须清理领取行租户上下文");
}
@Test
void should_treatAcceptedDuplicateAsPublishedAndReuseReturnedEventIdentity() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("done", 2, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(resp("accepted", true)));
int dispatched = worker.dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).markPublished(7001L, 100L, "evt_100", 9001L);
}
@Test
void should_allowLastAttemptAcceptedToMarkPublished() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("done", 5, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(resp("accepted", false)));
int dispatched = worker.dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).markPublished(7001L, 100L, "evt_100", 9001L);
}
@Test
void should_markRejectedResponseAsDeadLetter() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("error", 1, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("error"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(resp("rejected", false,
"payload_schema_mismatch")));
int dispatched = worker.dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).markDeadLetter(7001L, 100L, "payload_schema_mismatch",
"AI Events publish rejected");
}
@Test
void should_markBlockedResponseAsDeadLetter() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("done", 1, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(resp("blocked", false,
"payload_contains_secret")));
int dispatched = worker.dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).markDeadLetter(7001L, 100L, "payload_contains_secret",
"AI Events publish rejected");
}
@Test
void should_failClosedForUnknownPublishStatusWithoutMarkingPublished() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("done", 1, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(resp("mystery", false)));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_UNKNOWN_STATUS",
"AI Events publish unknown status");
}
@Test
void should_markCommonResultFailureAsRetryableWithoutIncrementingAttemptAgain() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("done", 2, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.error(503,
"temporary failure with token sk-test-should-not-be-persisted"));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper).markRetryable(eq(7001L), eq(100L), argThat(nextRetryAt -> nextRetryAt != null),
eq("AI_EVENTS_PUBLISH_API_FAILED"), eq("AI Events publish failed"));
}
@Test
void should_markCommonResultFailureOnLastAttemptAsRetryExhaustedDeadLetter() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("done", 5, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.error(503,
"temporary failure with token sk-test-should-not-be-persisted"));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_RETRY_EXHAUSTED",
"AI Events publish retry exhausted");
}
@Test
void should_markPublishExceptionAsRetryableWithoutPersistingRawExceptionMessage() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("error", 3, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("error"));
doThrow(new IllegalStateException("raw bearer token should never be stored"))
.when(eventsPublishApi).publish(any());
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper).markRetryable(eq(7001L), eq(100L), argThat(nextRetryAt -> nextRetryAt != null),
eq("AI_EVENTS_PUBLISH_TEMPORARY_UNAVAILABLE"),
eq("AI Events publish temporary unavailable"));
}
@Test
void should_markPublishExceptionOnLastAttemptAsRetryExhaustedDeadLetter() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("error", 5, 5));
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("error"));
doThrow(new IllegalStateException("raw bearer token should never be stored"))
.when(eventsPublishApi).publish(any());
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_RETRY_EXHAUSTED",
"AI Events publish retry exhausted");
}
@Test
void should_markRetryExhaustedAsDeadLetterWithoutCallingEventsAgain() {
MuseAiEventPublishWorker worker = worker(true);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(runningOutbox("done", 6, 5));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_RETRY_EXHAUSTED",
"AI Events publish retry exhausted");
}
@Test
void should_keepPayloadInvalidErrorWhenPayloadSummaryCannotBeParsed() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("done", 1, 5);
outbox.setPayloadSummary("{invalid-json");
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper, times(1)).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_PAYLOAD_INVALID",
"AI Events publish payload invalid");
verify(outboxMapper, never()).markDeadLetter(7001L, 100L, "AI_EVENTS_TERMINAL_FACT_MISSING",
"AI Events publish terminal fact missing");
}
@Test
void should_keepPayloadInvalidErrorBeforeTerminalFactMissingWhenPayloadCannotBeParsed() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("done", 1, 5);
outbox.setPayloadSummary("{invalid-json");
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_PAYLOAD_INVALID",
"AI Events publish payload invalid");
verify(outboxMapper, never()).markDeadLetter(7001L, 100L, "AI_EVENTS_TERMINAL_FACT_MISSING",
"AI Events publish terminal fact missing");
}
@Test
void should_rejectDonePayloadWithExtraFieldBeforeCallingEvents() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("done", 1, 5);
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"taskId", 3001L, "suggestionId", 2001L, "prompt", "secret prompt")));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_PAYLOAD_INVALID",
"AI Events publish payload invalid");
}
@Test
void should_rejectDonePayloadMissingSuggestionIdWithSpecificErrorBeforeCallingEvents() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("done", 1, 5);
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of("taskId", 3001L)));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_DONE_PAYLOAD_INCOMPLETE",
"AI done payload missing suggestionId");
}
@Test
void should_rejectNonNumericTaskIdBeforeCallingEvents() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("done", 1, 5);
outbox.setTaskId("task-abc");
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of("taskId", "task-abc", "suggestionId", 2001L)));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("done"));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_TASK_ID_NOT_NUMERIC",
"AI taskId must be numeric before Events publish");
}
@Test
void should_rejectErrorPayloadWithExtraFieldBeforeCallingEvents() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("error", 1, 5);
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"code", "AI_TASK_FAILED", "message", "AI task failed", "retryable", false,
"reason", "operator supplied reason")));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("error"));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_PAYLOAD_INVALID",
"AI Events publish payload invalid");
}
@Test
void should_rejectCancellationPayloadWithDetailBeforeCallingEvents() {
MuseAiEventPublishWorker worker = worker(true);
MuseAiEventPublishOutboxDO outbox = runningOutbox("error", 1, 5);
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"code", "AI_TASK_CANCELLED", "message", "AI task cancelled", "retryable", false,
"detail", "manual cancel reason")));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(taskEventMapper.selectById(5001L)).thenReturn(terminalFact("error", "cancelled"));
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(7001L, 100L, "AI_EVENTS_PUBLISH_PAYLOAD_INVALID",
"AI Events publish payload invalid");
}
@Test
void should_notClaimWhenWorkerDisabled() {
MuseAiEventPublishWorker worker = worker(false);
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper, never()).claimNextPublishOutbox(anyLong());
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_scheduledEntryOnlyDelegateToDispatchOnceWhenEnabled() {
MuseAiEventPublishWorker worker = spy(worker(true));
doReturn(1).when(worker).dispatchOnce();
worker.dispatchScheduled();
verify(worker).dispatchOnce();
}
private MuseAiEventPublishWorker worker(boolean enabled) {
MuseAiProperties properties = new MuseAiProperties();
MuseAiProperties.Events events = new MuseAiProperties.Events();
MuseAiProperties.Events.PublishWorker publishWorker = new MuseAiProperties.Events.PublishWorker();
publishWorker.setEnabled(enabled);
events.setPublishWorker(publishWorker);
properties.setEvents(events);
return new MuseAiEventPublishWorker(outboxMapper, taskEventMapper, eventsPublishApi, properties);
}
private EventsPublishReqDTO capturePublishReq() {
ArgumentCaptor<EventsPublishReqDTO> captor = ArgumentCaptor.forClass(EventsPublishReqDTO.class);
verify(eventsPublishApi).publish(captor.capture());
return captor.getValue();
}
private static MuseAiEventPublishOutboxDO runningOutbox(String eventType, int attemptCount, int maxAttempt) {
MuseAiEventPublishOutboxDO outbox = new MuseAiEventPublishOutboxDO();
outbox.setId(7001L);
outbox.setTenantId(100L);
outbox.setOwnerUserId(1001L);
outbox.setTaskId("3001");
outbox.setSourceEventId(5001L);
outbox.setSourceRevision("2");
outbox.setEventType(eventType);
outbox.setPayloadSummary("done".equals(eventType)
? JsonUtils.toJsonString(Map.of("taskId", 3001L, "suggestionId", 2001L))
: JsonUtils.toJsonString(Map.of("code", "AI_TASK_FAILED", "message", "AI task failed",
"retryable", false)));
outbox.setCommandId("ai_evt:abcdef0123456789abcdef0123456789");
outbox.setPublishStatus("running");
outbox.setAttemptCount(attemptCount);
outbox.setMaxAttempt(maxAttempt);
return outbox;
}
private static MuseAiTaskEventDO terminalFact(String eventType) {
return terminalFact(eventType, "error".equals(eventType) ? "failed" : "completed");
}
private static MuseAiTaskEventDO terminalFact(String eventType, String taskStatus) {
MuseAiTaskEventDO event = new MuseAiTaskEventDO();
event.setId(5001L);
event.setTenantId(100L);
event.setOwnerUserId(1001L);
event.setTaskId("3001");
event.setSequenceNo(2L);
event.setEventType(eventType);
event.setTaskStatus(taskStatus);
event.setEmittedAt(LocalDateTime.of(2026, 6, 6, 10, 0));
return event;
}
private static EventsPublishRespDTO resp(String publishStatus, boolean duplicate) {
return resp(publishStatus, duplicate, null);
}
private static EventsPublishRespDTO resp(String publishStatus, boolean duplicate, String publishErrorCode) {
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
respDTO.setEventId("evt_100");
respDTO.setSequenceNo(9001L);
respDTO.setPublishStatus(publishStatus);
respDTO.setDuplicate(duplicate);
respDTO.setPublishErrorCode(publishErrorCode);
return respDTO;
}
}

View File

@ -6,10 +6,12 @@ import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiGenerationDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiJobDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiTaskEventDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiGenerationMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiJobMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiSuggestionMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiTaskEventMapper;
import org.mockito.ArgumentCaptor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@ -18,6 +20,7 @@ import org.mockito.Mock;
import java.time.Instant;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ -38,6 +41,8 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
private MuseAiSuggestionMapper suggestionMapper;
@Mock
private MuseAiTaskEventMapper taskEventMapper;
@Mock
private MuseAiEventPublishOutboxService eventPublishOutboxService;
@AfterEach
void clearTenantContext() {
@ -75,6 +80,35 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
verify(taskEventMapper, never()).insert(any(cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiTaskEventDO.class));
}
@Test
void should_createOutboxInSameTransactionWhenWritingTerminalDoneEvent() {
TenantContextHolder.setTenantId(100L);
MuseAiGenerationDO task = task("running");
MuseAiJobDO job = job("running");
when(jobMapper.selectByIdForUpdate(100L, 4001L)).thenReturn(job("running"));
projectionService.applyRuntimeResponse(task, job, runtimeSuccess(), command());
ArgumentCaptor<MuseAiTaskEventDO> eventCaptor = ArgumentCaptor.forClass(MuseAiTaskEventDO.class);
verify(taskEventMapper).insert(eventCaptor.capture());
verify(eventPublishOutboxService).createForTerminalEvent(eventCaptor.getValue());
assertEquals("done", eventCaptor.getValue().getEventType());
assertEquals("completed", eventCaptor.getValue().getTaskStatus());
}
@Test
void should_notCreateOutboxWhenRetryableFailureDoesNotWriteTerminalEvent() {
TenantContextHolder.setTenantId(100L);
MuseAiGenerationDO task = task("running");
MuseAiJobDO job = job("running");
when(jobMapper.selectByIdForUpdate(100L, 4001L)).thenReturn(job("running"));
projectionService.applyRuntimeResponse(task, job, runtimeFailure(true), command());
verify(taskEventMapper, never()).insert(any(MuseAiTaskEventDO.class));
verify(eventPublishOutboxService, never()).createForTerminalEvent(any(MuseAiTaskEventDO.class));
}
private static MuseAiGenerationDO task(String status) {
MuseAiGenerationDO task = new MuseAiGenerationDO();
task.setId(3001L);

View File

@ -76,6 +76,8 @@ class MuseJobServiceTest extends BaseMockitoUnitTest {
private MuseAiAuditService auditService;
@Mock
private MuseAiRuntimePayloadStore runtimePayloadStore;
@Mock
private MuseAiEventPublishOutboxService eventPublishOutboxService;
@AfterEach
void clearTenantContext() {
@ -250,6 +252,32 @@ class MuseJobServiceTest extends BaseMockitoUnitTest {
verify(runtimePayloadStore).remove(200L, 4001L);
}
@Test
void should_createOutboxWhenAppendingCancellationErrorEventWithoutLeakingReason() {
TenantContextHolder.setTenantId(100L);
MuseAiJobDO job = job(4001L, "running", 1001L);
job.setTaskId("3001");
MuseAiGenerationDO task = task(3001L, "running", 1001L);
when(jobMapper.selectByIdForUpdate(100L, 4001L)).thenReturn(job);
when(generationMapper.selectByTaskId(3001L)).thenReturn(task);
when(commandService.buildRequestHash("cmd-user-cancel")).thenReturn("hash-user-cancel");
when(commandService.reserveCommand(any())).thenReturn(null);
ArgumentCaptor<MuseAiTaskEventDO> eventCaptor = ArgumentCaptor.forClass(MuseAiTaskEventDO.class);
jobService.cancelUserJob(1001L, 4001L, "cmd-user-cancel");
verify(taskEventMapper).insert(eventCaptor.capture());
verify(eventPublishOutboxService).createForTerminalEvent(eventCaptor.getValue());
MuseAiTaskEventDO event = eventCaptor.getValue();
assertEquals("error", event.getEventType());
assertEquals("cancelled", event.getTaskStatus());
assertEquals("AI_TASK_CANCELLED", event.getErrorCode());
assertEquals("AI task cancelled", event.getErrorMessage());
assertEquals(Map.of("retryable", false), JsonUtils.parseObject(event.getPayloadSummary(), Map.class),
"取消事件进入统一 Events 前只能保留 retryable=false不能带用户取消原因");
assertFalse(event.getPayloadSummary().contains("cmd-user-cancel"));
}
@Test
void should_removeRuntimePayloadWithCurrentTenantWhenCancellingQueuedJobWithoutLockedTenant() {
TenantContextHolder.setTenantId(100L);

View File

@ -0,0 +1,532 @@
package cn.iocoder.muse.module.ai.dal.mysql.muse;
import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiEventPublishOutboxDO;
import cn.iocoder.muse.module.ai.dal.type.JsonbStringTypeHandler;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.apache.ibatis.type.JdbcType;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import javax.sql.DataSource;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
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.sql.Statement;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import static 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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* AI Events publish outbox DO / Mapper 合同测试
*/
class MuseAiEventPublishOutboxMapperTest {
private static final String TABLE_NAME = "muse_ai_event_publish_outbox";
private static final Set<String> CREATED_SCHEMAS = new LinkedHashSet<>();
@BeforeAll
static void initMybatisPlusTableInfo() {
// Mapper 默认方法会解析 Lambda 字段名轻量测试必须先初始化 MyBatis-Plus 表元数据缓存
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
MuseAiEventPublishOutboxDO.class);
}
@AfterAll
static void dropCreatedSchemas() throws Exception {
if (CREATED_SCHEMAS.isEmpty()) {
return;
}
RealPostgresConfig config = realPostgresConfig();
try (Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password());
Statement statement = connection.createStatement()) {
for (String schema : CREATED_SCHEMAS) {
statement.execute("DROP SCHEMA IF EXISTS " + schema + " CASCADE");
}
}
}
@Test
void should_mapAllMigrationFieldsToTenantBaseDo() throws Exception {
assertTrue(TenantBaseDO.class.isAssignableFrom(MuseAiEventPublishOutboxDO.class),
"outbox DO 必须继承 TenantBaseDO继承 tenant/create/update/deleted 项目约定字段");
TableName tableName = MuseAiEventPublishOutboxDO.class.getAnnotation(TableName.class);
assertNotNull(tableName);
assertEquals("muse_ai_event_publish_outbox", tableName.value());
assertTrue(tableName.autoResultMap(), "JSONB 字段需要 autoResultMap 才能应用 typeHandler");
Set<String> fields = new LinkedHashSet<>();
for (Field field : MuseAiEventPublishOutboxDO.class.getDeclaredFields()) {
fields.add(field.getName());
}
assertEquals(new LinkedHashSet<>(List.of(
"id", "ownerUserId", "taskId", "sourceEventId", "sourceRevision", "eventType",
"payloadSummary", "commandId", "publishStatus", "attemptCount", "maxAttempt",
"claimedAt", "claimExpiresAt", "nextRetryAt", "lastErrorCode", "lastErrorMessage",
"publishedEventId", "publishedSequenceNo")), fields,
"DO 字段必须覆盖 V17 migration 的业务字段tenant/create/update/deleted 由 TenantBaseDO 提供");
assertEquals(Long.class, MuseAiEventPublishOutboxDO.class.getDeclaredField("sourceEventId").getType(),
"source_event_id 对齐 migration BIGINT NOT NULL不能用 String 模糊化 source event 主键");
assertEquals(Integer.class, MuseAiEventPublishOutboxDO.class.getDeclaredField("maxAttempt").getType(),
"max_attempt 是 Task 2 新增字段DO 必须显式映射,支撑后续 retry exhausted 语义");
assertEquals(LocalDateTime.class, MuseAiEventPublishOutboxDO.class.getDeclaredField("claimExpiresAt").getType());
TableField payloadField = MuseAiEventPublishOutboxDO.class.getDeclaredField("payloadSummary")
.getAnnotation(TableField.class);
assertNotNull(payloadField, "payload_summary 是 JSONB必须声明 typeHandler");
assertTrue(payloadField.typeHandler().getName().endsWith("JsonbStringTypeHandler"));
}
@Test
void should_provideInsertIgnoreUsingPostgresOnConflictDoNothing() throws Exception {
Method insertIgnore = MuseAiEventPublishOutboxMapper.class.getDeclaredMethod("insertIgnore",
MuseAiEventPublishOutboxDO.class);
Insert insert = insertIgnore.getAnnotation(Insert.class);
assertNotNull(insert, "outbox mapper 必须提供 insertIgnore SQL不能依赖 DuplicateKeyException 作为正常幂等路径");
String sql = String.join(" ", insert.value()).replaceAll("\\s+", " ").toLowerCase(Locale.ROOT).trim();
assertTrue(sql.contains("insert into muse_ai_event_publish_outbox"),
"insertIgnore 必须写入 AI 本域 outbox 表");
assertTrue(sql.contains("payload_summary") && sql.contains("max_attempt"),
"insertIgnore 必须覆盖 payload_summary 和 Task 2 新增 max_attempt 字段");
assertTrue(sql.contains("on conflict do nothing"),
"outbox 有 command/source 双唯一约束SQL 必须使用无目标 ON CONFLICT DO NOTHING 覆盖两类重复");
}
@Test
void should_lookupDuplicateByCommandIdAndSourceTuple() {
MuseAiEventPublishOutboxMapper mapper = mock(MuseAiEventPublishOutboxMapper.class, CALLS_REAL_METHODS);
doReturn(null).when(mapper).selectOne(org.mockito.ArgumentMatchers.<Wrapper<MuseAiEventPublishOutboxDO>>any());
mapper.selectByCommandId(100L, "ai_evt:0123456789abcdef0123456789abcdef");
mapper.selectBySourceTuple(100L, "3001", "2", "done");
ArgumentCaptor<Wrapper<MuseAiEventPublishOutboxDO>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper, org.mockito.Mockito.times(2)).selectOne(captor.capture());
String commandSql = normalizeSql(captor.getAllValues().get(0));
String sourceSql = normalizeSql(captor.getAllValues().get(1));
assertTrue(commandSql.contains("tenant_id") && commandSql.contains("command_id"));
assertTrue(sourceSql.contains("tenant_id") && sourceSql.contains("task_id")
&& sourceSql.contains("source_revision") && sourceSql.contains("event_type"),
"source tuple 查重必须收敛到 tenant/task/sourceRevision/eventType");
}
@Test
void should_claimOnePublishOutboxAtomicallyWithLeaseAndRetryStateMachine() throws Exception {
Method claimMethod = MuseAiEventPublishOutboxMapper.class.getMethod("claimNextPublishOutbox", long.class);
Select select = claimMethod.getAnnotation(Select.class);
assertNotNull(select, "Task 4 worker 必须提供原子 claim SQL而不是先 select 再 update");
String sql = String.join(" ", select.value()).replaceAll("\\s+", " ").toLowerCase(Locale.ROOT).trim();
assertTrue(sql.startsWith("update muse_ai_event_publish_outbox"),
"claim 必须以单条 UPDATE 开始,避免多 worker 竞态领取同一 outbox");
assertTrue(sql.contains("where id = ( select id from muse_ai_event_publish_outbox"),
"claim 必须在 UPDATE WHERE id = (SELECT ...) 内选择单行");
assertTrue(sql.contains("for update skip locked") && sql.contains("returning *"),
"claim 必须使用 FOR UPDATE SKIP LOCKED 并 RETURNING * 返回领取后的 tenant/attempt 状态");
assertTrue(sql.contains("publish_status = 'running'"),
"claim 必须把 publish_status 写为 running");
assertTrue(sql.contains("claimed_at = current_timestamp"),
"claim 必须写 claimed_at便于审计 worker 领取时间");
assertTrue(sql.contains("claim_expires_at = current_timestamp"),
"claim 必须写 claim_expires_at便于 stale running 重新领取");
assertTrue(sql.contains("attempt_count = attempt_count + 1"),
"claim 阶段只能在这里递增 attempt_count失败处理不得二次递增");
Options options = claimMethod.getAnnotation(Options.class);
assertNotNull(options, "UPDATE ... RETURNING 用 @Select 承载时必须显式关闭查询缓存");
assertEquals(Options.FlushCachePolicy.TRUE, options.flushCache(),
"claim 是有副作用的写操作,必须在每次执行前 flush SqlSession 本地缓存");
assertFalse(options.useCache(), "claim 不得进入二级缓存,避免重复领取返回旧 outbox");
assertTrue(sql.contains("publish_status = 'queued'"),
"claim 必须领取 queued outbox");
assertTrue(sql.contains("publish_status = 'retryable'")
&& sql.contains("next_retry_at <= current_timestamp"),
"claim 必须领取到期 retryable outbox");
assertTrue(sql.contains("publish_status = 'running'")
&& sql.contains("claim_expires_at <= current_timestamp"),
"claim 必须允许 stale running 重新领取");
assertTrue(!sql.contains("publish_status = 'published'") && !sql.contains("publish_status = 'dead_letter'"),
"claim 不得领取 published 或 dead_letter");
}
@Test
void should_executeClaimAndExplicitStateTransitionsOnRealPostgresql() throws Exception {
RealPostgresConfig config = realPostgresConfig();
ensureDatabaseReachable(config);
String schema = createIsolatedSchema(config);
SqlSessionFactory sqlSessionFactory = sqlSessionFactory(config.withSchema(schema));
try (SqlSession sqlSession = sqlSessionFactory.openSession(true);
Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password())) {
MuseAiEventPublishOutboxMapper mapper = sqlSession.getMapper(MuseAiEventPublishOutboxMapper.class);
// 真实 PostgreSQL 测试必须使用数据库时钟claim SQL CURRENT_TIMESTAMP 判定 retry/stale
// 如果用本机时间构造 next_retry_at/claim_expires_at远程 DB 时钟偏差会制造假阴性
LocalDateTime now = databaseNow(connection);
long queuedId = insertOutbox(connection, schema, 100L, "queued", 0, null, null,
null, "queued-cmd", now.minusMinutes(10));
long retryableDueId = insertOutbox(connection, schema, 100L, "retryable", 2, null, null,
now.minusSeconds(30), "retryable-due-cmd", now.minusMinutes(9));
long staleRunningId = insertOutbox(connection, schema, 100L, "running", 3, now.minusMinutes(2),
now.minusSeconds(30), null, "stale-running-cmd", now.minusMinutes(8));
long activeRunningId = insertOutbox(connection, schema, 100L, "running", 4, now.minusSeconds(10),
now.plusMinutes(10), null, "active-running-cmd", now.minusMinutes(7));
long publishedId = insertOutbox(connection, schema, 100L, "published", 1, null, null,
null, "published-cmd", now.minusMinutes(6));
long deadLetterId = insertOutbox(connection, schema, 100L, "dead_letter", 5, null, null,
null, "dead-letter-cmd", now.minusMinutes(5));
MuseAiEventPublishOutboxDO claimedQueued = mapper.claimNextPublishOutbox(60L);
assertClaimed(claimedQueued, queuedId, 1);
MuseAiEventPublishOutboxDO claimedRetryable = mapper.claimNextPublishOutbox(60L);
assertClaimed(claimedRetryable, retryableDueId, 3);
MuseAiEventPublishOutboxDO claimedStaleRunning = mapper.claimNextPublishOutbox(60L);
assertClaimed(claimedStaleRunning, staleRunningId, 4);
assertNull(mapper.claimNextPublishOutbox(60L),
"未到期 running、published、dead_letter 不得被领取");
assertEquals("running", selectString(connection, schema, activeRunningId, "publish_status"));
assertEquals("published", selectString(connection, schema, publishedId, "publish_status"));
assertEquals("dead_letter", selectString(connection, schema, deadLetterId, "publish_status"));
seedDirtyState(connection, schema, queuedId);
int publishedRows = invokeStateTransition(mapper, "markPublished",
new Class<?>[]{Long.class, Long.class, String.class, Long.class},
queuedId, 100L, "evt_real_100", 9001L);
assertEquals(1, publishedRows);
Map<String, Object> published = selectRow(connection, schema, queuedId);
assertEquals("published", published.get("publish_status"));
assertNull(published.get("claimed_at"));
assertNull(published.get("claim_expires_at"));
assertNull(published.get("next_retry_at"));
assertNull(published.get("last_error_code"));
assertNull(published.get("last_error_message"));
assertEquals("evt_real_100", published.get("published_event_id"));
assertEquals(9001L, ((Number) published.get("published_sequence_no")).longValue());
seedDirtyState(connection, schema, retryableDueId);
LocalDateTime nextRetryAt = LocalDateTime.now().plusMinutes(3).withNano(0);
int retryableRows = invokeStateTransition(mapper, "markRetryable",
new Class<?>[]{Long.class, Long.class, LocalDateTime.class, String.class, String.class},
retryableDueId, 100L, nextRetryAt, "AI_EVENTS_PUBLISH_API_FAILED", "AI Events publish failed");
assertEquals(1, retryableRows);
Map<String, Object> retryable = selectRow(connection, schema, retryableDueId);
assertEquals("retryable", retryable.get("publish_status"));
assertNull(retryable.get("claimed_at"));
assertNull(retryable.get("claim_expires_at"));
assertEquals(3, ((Number) retryable.get("attempt_count")).intValue(),
"失败处理不得二次递增 attempt_count");
assertEquals("AI_EVENTS_PUBLISH_API_FAILED", retryable.get("last_error_code"));
assertEquals("AI Events publish failed", retryable.get("last_error_message"));
assertNotNull(retryable.get("next_retry_at"));
seedDirtyState(connection, schema, staleRunningId);
int deadLetterRows = invokeStateTransition(mapper, "markDeadLetter",
new Class<?>[]{Long.class, Long.class, String.class, String.class},
staleRunningId, 100L, "AI_EVENTS_PUBLISH_RETRY_EXHAUSTED",
"AI Events publish retry exhausted");
assertEquals(1, deadLetterRows);
Map<String, Object> deadLetter = selectRow(connection, schema, staleRunningId);
assertEquals("dead_letter", deadLetter.get("publish_status"));
assertNull(deadLetter.get("claimed_at"));
assertNull(deadLetter.get("claim_expires_at"));
assertNull(deadLetter.get("next_retry_at"));
assertEquals("AI_EVENTS_PUBLISH_RETRY_EXHAUSTED", deadLetter.get("last_error_code"));
assertEquals("AI Events publish retry exhausted", deadLetter.get("last_error_message"));
int wrongTenantRows = invokeStateTransition(mapper, "markDeadLetter",
new Class<?>[]{Long.class, Long.class, String.class, String.class},
activeRunningId, 999L, "AI_EVENTS_PUBLISH_RETRY_EXHAUSTED",
"AI Events publish retry exhausted");
assertEquals(0, wrongTenantRows, "状态转换必须按 id + tenant_id 更新,租户不匹配时不得跨租户改行");
assertEquals("running", selectString(connection, schema, activeRunningId, "publish_status"));
}
}
private static String normalizeSql(Wrapper<MuseAiEventPublishOutboxDO> wrapper) {
return wrapper.getSqlSegment().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT).trim();
}
private static void assertClaimed(MuseAiEventPublishOutboxDO outbox, long expectedId, int expectedAttemptCount) {
assertNotNull(outbox);
assertEquals(expectedId, outbox.getId());
assertEquals("running", outbox.getPublishStatus());
assertEquals(expectedAttemptCount, outbox.getAttemptCount());
assertNotNull(outbox.getClaimedAt());
assertNotNull(outbox.getClaimExpiresAt());
}
private static int invokeStateTransition(MuseAiEventPublishOutboxMapper mapper, String methodName,
Class<?>[] parameterTypes, Object... args) throws Exception {
Method method = MuseAiEventPublishOutboxMapper.class.getMethod(methodName, parameterTypes);
return ((Number) method.invoke(mapper, args)).intValue();
}
private static SqlSessionFactory sqlSessionFactory(RealPostgresConfig config) {
DataSource dataSource = new UnpooledDataSource("org.postgresql.Driver", config.url(), config.user(),
config.password());
Configuration configuration = new Configuration(new Environment("task4-real-postgres",
new JdbcTransactionFactory(), dataSource));
configuration.setMapUnderscoreToCamelCase(true);
configuration.getTypeHandlerRegistry().register(String.class, JdbcType.OTHER, JsonbStringTypeHandler.class);
configuration.addMapper(MuseAiEventPublishOutboxMapper.class);
return new SqlSessionFactoryBuilder().build(configuration);
}
private static RealPostgresConfig realPostgresConfig() throws Exception {
Properties env = readInfraEnv();
String host = firstNonBlank(System.getenv("MUSE_POSTGRES_HOST"), env.getProperty("MUSE_POSTGRES_HOST"));
String port = firstNonBlank(System.getenv("MUSE_POSTGRES_PORT"), env.getProperty("MUSE_POSTGRES_PORT"));
String database = firstNonBlank(System.getenv("P1R_MAPPER_DATABASE"),
env.getProperty("MUSE_POSTGRES_DATABASE"));
String user = firstNonBlank(System.getenv("P1R_FLYWAY_USER"), System.getenv("MUSE_POSTGRES_USERNAME"),
env.getProperty("MUSE_POSTGRES_USERNAME"));
String password = firstNonBlank(System.getenv("P1R_FLYWAY_PASSWORD"), System.getenv("MUSE_POSTGRES_PASSWORD"),
env.getProperty("MUSE_POSTGRES_PASSWORD"));
assertTrue(host != null && port != null && database != null && user != null && password != null,
"真实 PostgreSQL focused test 缺少连接配置;需要 ~/.config/muse-repo/infra.env 或对应环境变量");
String url = "jdbc:postgresql://" + host + ":" + port + "/" + database
+ "?sslmode=disable&connectTimeout=5&socketTimeout=5";
assertTrue(!Set.of("postgres", "template0", "template1").contains(databaseName(url)),
"真实 PostgreSQL focused test 不能在系统库中创建隔离 schema: " + maskedUrl(url));
return new RealPostgresConfig(url, user, password);
}
private static Properties readInfraEnv() throws Exception {
Properties properties = new Properties();
Path infraEnv = Path.of(System.getProperty("user.home"), ".config", "muse-repo", "infra.env");
if (!Files.isRegularFile(infraEnv)) {
return properties;
}
for (String line : Files.readAllLines(infraEnv)) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#") || !trimmed.contains("=")) {
continue;
}
int split = trimmed.indexOf('=');
properties.setProperty(trimmed.substring(0, split), trimmed.substring(split + 1));
}
return properties;
}
private static void ensureDatabaseReachable(RealPostgresConfig config) throws SQLException {
try (Connection ignored = DriverManager.getConnection(config.url(), config.user(), config.password())) {
// 真实 DB focused test 只验证连接可达后续所有 DDL 都限定在唯一 schema避免清理业务库对象
} catch (SQLException ex) {
String causeType = ex.getCause() == null ? "none" : ex.getCause().getClass().getSimpleName();
fail("真实 PostgreSQL/MyBatis focused test 无法通过 pgjdbc 连接测试库url=" + maskedUrl(config.url())
+ ", errorType=" + ex.getClass().getSimpleName() + ", causeType=" + causeType);
}
}
private static LocalDateTime databaseNow(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("SELECT CURRENT_TIMESTAMP::timestamp")) {
assertTrue(resultSet.next());
return resultSet.getObject(1, LocalDateTime.class);
}
}
private static String createIsolatedSchema(RealPostgresConfig config) throws SQLException {
String schema = "p1r7b_task4_" + Long.toUnsignedString(System.nanoTime(), 36);
assertTrue(schema.matches("[a-z0-9_]+"));
try (Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password());
Statement statement = connection.createStatement()) {
statement.execute("CREATE SCHEMA " + schema);
statement.execute("""
CREATE TABLE %s.%s (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
owner_user_id BIGINT NOT NULL,
task_id VARCHAR(128) NOT NULL,
source_event_id BIGINT NOT NULL,
source_revision VARCHAR(80) NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
command_id VARCHAR(128) NOT NULL,
publish_status VARCHAR(32) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
max_attempt INT NOT NULL DEFAULT 5,
claimed_at TIMESTAMP,
claim_expires_at TIMESTAMP,
next_retry_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 '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
)
""".formatted(schema, TABLE_NAME));
statement.execute("CREATE INDEX idx_" + schema + "_claim ON " + schema + "." + TABLE_NAME
+ "(publish_status, claim_expires_at, next_retry_at, create_time, id) WHERE deleted = FALSE");
CREATED_SCHEMAS.add(schema);
}
return schema;
}
private static long insertOutbox(Connection connection, String schema, Long tenantId, String publishStatus,
int attemptCount, LocalDateTime claimedAt, LocalDateTime claimExpiresAt,
LocalDateTime nextRetryAt, String commandId, LocalDateTime createTime)
throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO %s.%s(tenant_id, owner_user_id, task_id, source_event_id, source_revision, event_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,
create_time, update_time, deleted)
VALUES (?, 1001, ?, 5001, '2', 'done', ?::jsonb, ?, ?, ?, 5, ?, ?, ?, ?, ?,
?, ?, FALSE)
RETURNING id
""".formatted(schema, TABLE_NAME))) {
statement.setLong(1, tenantId);
statement.setString(2, "task-" + commandId);
statement.setString(3, "{}");
statement.setString(4, commandId);
statement.setString(5, publishStatus);
statement.setInt(6, attemptCount);
setNullableTimestamp(statement, 7, claimedAt);
setNullableTimestamp(statement, 8, claimExpiresAt);
setNullableTimestamp(statement, 9, nextRetryAt);
statement.setString(10, "OLD_ERROR");
statement.setString(11, "old error message");
statement.setObject(12, createTime);
statement.setObject(13, createTime);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next());
return resultSet.getLong(1);
}
}
}
private static void seedDirtyState(Connection connection, String schema, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
UPDATE %s.%s
SET publish_status = 'running',
claimed_at = CURRENT_TIMESTAMP,
claim_expires_at = CURRENT_TIMESTAMP + INTERVAL '60 seconds',
next_retry_at = CURRENT_TIMESTAMP + INTERVAL '60 seconds',
last_error_code = 'OLD_ERROR',
last_error_message = 'old error message',
published_event_id = NULL,
published_sequence_no = NULL
WHERE id = ?
""".formatted(schema, TABLE_NAME))) {
statement.setLong(1, id);
assertEquals(1, statement.executeUpdate());
}
}
private static String selectString(Connection connection, String schema, long id, String column)
throws SQLException {
return Objects.toString(selectRow(connection, schema, id).get(column), null);
}
private static Map<String, Object> selectRow(Connection connection, String schema, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT publish_status, attempt_count, claimed_at, claim_expires_at, next_retry_at,
last_error_code, last_error_message, published_event_id, published_sequence_no
FROM %s.%s
WHERE id = ?
""".formatted(schema, TABLE_NAME))) {
statement.setLong(1, id);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next());
Map<String, Object> row = new HashMap<>();
for (int i = 1; i <= resultSet.getMetaData().getColumnCount(); i++) {
row.put(resultSet.getMetaData().getColumnName(i), resultSet.getObject(i));
}
return row;
}
}
}
private static void setNullableTimestamp(PreparedStatement statement, int index, LocalDateTime value)
throws SQLException {
if (value == null) {
statement.setObject(index, null);
} else {
statement.setObject(index, value);
}
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private static String databaseName(String url) {
String withoutQuery = url.split("\\?", 2)[0];
return withoutQuery.substring(withoutQuery.lastIndexOf('/') + 1);
}
private static String quoteIdentifier(String identifier) {
assertFalse(identifier.contains("\""));
return "\"" + identifier + "\"";
}
private static String maskedUrl(String url) {
String withoutQuery = url.split("\\?", 2)[0];
int schemeEnd = withoutQuery.indexOf("//");
int databaseStart = withoutQuery.lastIndexOf('/');
if (schemeEnd < 0 || databaseStart < 0 || databaseStart <= schemeEnd + 2) {
return "jdbc:postgresql://<host>/<database>";
}
return withoutQuery.substring(0, schemeEnd + 2) + "<host>" + withoutQuery.substring(databaseStart);
}
private record RealPostgresConfig(String url, String user, String password) {
RealPostgresConfig withSchema(String schema) {
String separator = url.contains("?") ? "&" : "?";
return new RealPostgresConfig(url + separator + "currentSchema=" + schema, user, password);
}
}
}

View File

@ -0,0 +1,120 @@
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-7b Task 6 AI terminal event publish 依赖方向门禁
*
* <p>该测试直接解析当前 worktree Maven POM证明依赖方向来自 live dirty baseline
* 不依赖人工记录或覆盖报告状态</p>
*/
class P1rAiEventsPublishDependencyTest {
private static final String PROJECT_GROUP_ID = "cn.iocoder.cloud";
private static final String AI_SERVER_POM = "muse-cloud/muse-module-ai/muse-module-ai-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_ai_server_depending_on_events_api_only() throws Exception {
Set<MavenDependency> dependencies = readDirectDependencies(AI_SERVER_POM);
assertTrue(dependencies.contains(projectDependency("muse-module-events-api")),
"AI server 必须直接依赖 muse-module-events-api才能向 Events owner 发布终态事件");
assertFalse(dependencies.contains(projectDependency("muse-module-events-server")),
"AI 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,386 @@
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.ai.application.muse.MuseAiEventPublishWorker;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiEventPublishOutboxDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiTaskEventDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiEventPublishOutboxMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiTaskEventMapper;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
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 org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* P1R-7b AI terminal event 通过本域 worker 发布到统一 Events focused E2E 测试
*/
class P1rAiEventsPublishEndToEndTest {
@Test
void should_publishAiDoneOutboxToVisibleEventsForCurrentOwner() {
LocalDateTime emittedAt = LocalDateTime.now();
MuseAiEventPublishOutboxDO outbox = buildRunningDoneOutbox();
TestHarness harness = buildHarness(List.of(outbox),
Map.of(outbox.getSourceEventId(), buildTerminalFact(emittedAt)));
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> currentOwnerEvents = harness.eventsPublishService()
.listVisibleEvents(100L, 1001L, 0L, visibleNow);
assertEquals(1, currentOwnerEvents.size());
UnifiedEventDO event = currentOwnerEvents.get(0);
assertEquals("accepted", event.getPublishStatus());
assertEquals("done", event.getEventType());
assertEquals(outbox.getPublishedEventId(), event.getEventId());
assertEquals(outbox.getPublishedSequenceNo(), event.getSequenceNo());
assertTrue(event.getPayloadSummary().contains("\"taskId\":7001"));
assertTrue(event.getPayloadSummary().contains("\"suggestionId\":9001"));
assertTrue(harness.eventsPublishService().listVisibleEvents(100L, 9999L, 0L, visibleNow).isEmpty());
}
@Test
void should_publishAiErrorWithoutSensitiveDetailToVisibleEvents() {
LocalDateTime emittedAt = LocalDateTime.now();
MuseAiEventPublishOutboxDO outbox = buildRunningErrorOutbox();
TestHarness harness = buildHarness(List.of(outbox),
Map.of(outbox.getSourceEventId(), buildTerminalFact(outbox.getSourceEventId(), outbox.getTaskId(),
21L, "error", "failed", emittedAt)));
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> currentOwnerEvents = harness.eventsPublishService()
.listVisibleEvents(100L, 1001L, 0L, visibleNow);
assertEquals(1, currentOwnerEvents.size());
UnifiedEventDO event = currentOwnerEvents.get(0);
assertEquals("accepted", event.getPublishStatus());
assertEquals("error", event.getEventType());
assertEquals(outbox.getPublishedEventId(), event.getEventId());
assertEquals(outbox.getPublishedSequenceNo(), event.getSequenceNo());
assertTrue(event.getPayloadSummary().contains("\"code\":\"AI_TASK_FAILED\""));
assertTrue(event.getPayloadSummary().contains("\"message\":\"AI task failed\""));
assertTrue(event.getPayloadSummary().contains("\"retryable\":false"));
assertFalse(event.getPayloadSummary().contains("token"));
assertFalse(event.getPayloadSummary().contains("Bearer"));
assertFalse(event.getPayloadSummary().contains("secret"));
assertFalse(event.getPayloadSummary().contains("provider raw"));
}
@Test
void should_markSecondOutboxPublishedWithSameEventIdentityWhenEventsTreatsDuplicateAsReplay() {
LocalDateTime emittedAt = LocalDateTime.now();
MuseAiEventPublishOutboxDO firstOutbox = buildRunningDoneOutbox(501L, 601L, "7001", "11",
"ai_evt_duplicate_replay");
MuseAiEventPublishOutboxDO secondOutbox = buildRunningDoneOutbox(502L, 602L, "7001", "12",
"ai_evt_duplicate_replay");
TestHarness harness = buildHarness(List.of(firstOutbox, secondOutbox), Map.of(
firstOutbox.getSourceEventId(), buildTerminalFact(firstOutbox.getSourceEventId(),
firstOutbox.getTaskId(), 11L, "done", "done", emittedAt),
secondOutbox.getSourceEventId(), buildTerminalFact(secondOutbox.getSourceEventId(),
secondOutbox.getTaskId(), 12L, "done", "done", emittedAt.plusNanos(1))));
int firstDispatch = harness.worker().dispatchOnce();
int secondDispatch = harness.worker().dispatchOnce();
assertEquals(1, firstDispatch);
assertEquals(1, secondDispatch);
assertEquals("published", firstOutbox.getPublishStatus());
assertEquals("published", secondOutbox.getPublishStatus());
assertEquals(firstOutbox.getPublishedEventId(), secondOutbox.getPublishedEventId());
assertEquals(firstOutbox.getPublishedSequenceNo(), secondOutbox.getPublishedSequenceNo());
assertEquals(1, harness.unifiedRows().size());
}
@Test
void should_markRejectedPublishAsDeadLetterAndKeepInvisible() {
MuseAiEventPublishOutboxDO outbox = buildRunningDoneOutbox(503L, 603L, "7003", "13",
"ai_evt_rejected_case");
EventsPublishApi rejectedAdapter = req -> {
assertEquals("ai_evt_rejected_case", req.getCommandId());
EventsPublishRespDTO resp = new EventsPublishRespDTO();
resp.setPublishStatus("rejected");
resp.setPublishErrorCode(EventsPublishServiceImpl.ERROR_PAYLOAD_SCHEMA_MISMATCH);
return CommonResult.success(resp);
};
TestHarness harness = buildHarness(List.of(outbox),
Map.of(outbox.getSourceEventId(), buildTerminalFact(outbox.getSourceEventId(), outbox.getTaskId(),
13L, "done", "done", LocalDateTime.now())), rejectedAdapter);
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, 1001L, 0L, LocalDateTime.now().plusSeconds(1))
.isEmpty());
assertTrue(harness.unifiedRows().isEmpty());
}
private static TestHarness buildHarness(List<MuseAiEventPublishOutboxDO> outboxes,
Map<Long, MuseAiTaskEventDO> terminalFacts) {
return buildHarness(outboxes, terminalFacts, null);
}
private static TestHarness buildHarness(List<MuseAiEventPublishOutboxDO> outboxes,
Map<Long, MuseAiTaskEventDO> terminalFacts,
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;
MuseAiEventPublishOutboxMapper outboxMapper = mock(MuseAiEventPublishOutboxMapper.class);
Deque<MuseAiEventPublishOutboxDO> queue = new ArrayDeque<>(outboxes);
when(outboxMapper.claimNextPublishOutbox(60L)).thenAnswer(invocation -> queue.pollFirst());
when(outboxMapper.markPublished(anyLong(), anyLong(), anyString(), anyLong()))
.thenAnswer(invocation -> {
MuseAiEventPublishOutboxDO outbox = findOutbox(outboxes, invocation.getArgument(0),
invocation.getArgument(1));
outbox.setPublishStatus("published");
outbox.setLastErrorCode(null);
outbox.setLastErrorMessage(null);
outbox.setPublishedEventId(invocation.getArgument(2));
outbox.setPublishedSequenceNo(invocation.getArgument(3));
return 1;
});
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
MuseAiEventPublishOutboxDO outbox = findOutbox(outboxes, invocation.getArgument(0),
invocation.getArgument(1));
outbox.setPublishStatus("dead_letter");
outbox.setLastErrorCode(invocation.getArgument(2));
outbox.setLastErrorMessage(invocation.getArgument(3));
return 1;
});
MuseAiTaskEventMapper taskEventMapper = mock(MuseAiTaskEventMapper.class);
when(taskEventMapper.selectById(any())).thenAnswer(invocation -> {
Object id = invocation.getArgument(0);
return id instanceof Number number ? terminalFacts.get(number.longValue()) : null;
});
MuseAiProperties properties = new MuseAiProperties();
properties.getEvents().getPublishWorker().setEnabled(true);
MuseAiEventPublishWorker worker = new MuseAiEventPublishWorker();
ReflectionTestUtils.setField(worker, "outboxMapper", outboxMapper);
ReflectionTestUtils.setField(worker, "taskEventMapper", taskEventMapper);
ReflectionTestUtils.setField(worker, "eventsPublishApi", eventsPublishApi);
ReflectionTestUtils.setField(worker, "properties", properties);
return new TestHarness(worker, 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 MuseAiEventPublishOutboxDO buildRunningDoneOutbox() {
return buildRunningDoneOutbox(501L, 601L, "7001", "11", "ai_evt_done_7001_11");
}
private static MuseAiEventPublishOutboxDO buildRunningDoneOutbox(Long id, Long sourceEventId, String taskId,
String sourceRevision, String commandId) {
MuseAiEventPublishOutboxDO outbox = new MuseAiEventPublishOutboxDO();
outbox.setId(id);
outbox.setTenantId(100L);
outbox.setOwnerUserId(1001L);
outbox.setTaskId(taskId);
outbox.setSourceEventId(sourceEventId);
outbox.setSourceRevision(sourceRevision);
outbox.setEventType("done");
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"taskId", Long.valueOf(taskId),
"suggestionId", 9001L,
"summary", "outline ready")));
outbox.setCommandId(commandId);
outbox.setPublishStatus("running");
outbox.setAttemptCount(1);
outbox.setMaxAttempt(5);
outbox.setDeleted(false);
return outbox;
}
private static MuseAiEventPublishOutboxDO buildRunningErrorOutbox() {
MuseAiEventPublishOutboxDO outbox = new MuseAiEventPublishOutboxDO();
outbox.setId(504L);
outbox.setTenantId(100L);
outbox.setOwnerUserId(1001L);
outbox.setTaskId("7004");
outbox.setSourceEventId(604L);
outbox.setSourceRevision("21");
outbox.setEventType("error");
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"code", "AI_TASK_FAILED",
"message", "AI task failed",
"retryable", false)));
outbox.setCommandId("ai_evt_error_7004_21");
outbox.setPublishStatus("running");
outbox.setAttemptCount(1);
outbox.setMaxAttempt(5);
outbox.setDeleted(false);
return outbox;
}
private static MuseAiTaskEventDO buildTerminalFact(LocalDateTime emittedAt) {
return buildTerminalFact(601L, "7001", 11L, "done", "done", emittedAt);
}
private static MuseAiTaskEventDO buildTerminalFact(Long id, String taskId, Long sequenceNo, String eventType,
String taskStatus, LocalDateTime emittedAt) {
MuseAiTaskEventDO fact = new MuseAiTaskEventDO();
fact.setId(id);
fact.setTenantId(100L);
fact.setTaskId(taskId);
fact.setSequenceNo(sequenceNo);
fact.setEventType(eventType);
fact.setTaskStatus(taskStatus);
fact.setOwnerUserId(1001L);
fact.setEmittedAt(emittedAt);
fact.setDeleted(false);
return fact;
}
private static MuseAiEventPublishOutboxDO findOutbox(List<MuseAiEventPublishOutboxDO> 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 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 只覆盖 AI workerEvents publish 幂等/拒绝语义和可见查询边界
* 不启动 Spring 容器或真实数据库避免 Task 5b 扩大到生产链路改造
*/
private record TestHarness(MuseAiEventPublishWorker worker, EventsPublishServiceImpl eventsPublishService,
List<UnifiedEventDO> unifiedRows) {
}
}

View File

@ -0,0 +1,680 @@
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.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
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-7b AI terminal event publish outbox 真实 PostgreSQL / Flyway 迁移验收
*
* <p>本测试会 clean 指定数据库的 public schema因此只允许连接真实库名以 {@code _test} 结尾的隔离库
* 数据库密码只能从环境变量读取避免 Surefire XML 或命令历史记录泄露凭据</p>
*/
class P1rAiEventsPublishFlywayMigrationIT {
/** P1R-7b AI publish outbox V17 是本任务唯一允许验收到达的目标版本。 */
private static final String TARGET_VERSION = "17";
/** V17 Flyway 文件名会转换出的描述,作为“当前版本确实是 AI publish outbox 迁移”的硬证据。 */
private static final String TARGET_DESCRIPTION = "extend ai events publish outbox";
/** clean 后从 V1 到 V17 应该产生 17 条成功 SQL migration 记录。 */
private static final int EXPECTED_SUCCESSFUL_SQL_MIGRATIONS = 17;
private static final String OUTBOX_TABLE = "muse_ai_event_publish_outbox";
private static final List<String> REQUIRED_INDEXES = List.of(
"idx_muse_ai_event_publish_outbox_claim",
"idx_muse_ai_event_publish_outbox_owner_status"
);
private static final List<String> REQUIRED_CONSTRAINTS = List.of(
"uk_muse_ai_event_publish_outbox_command",
"uk_muse_ai_event_publish_outbox_source",
"chk_muse_ai_event_publish_outbox_event_type",
"chk_muse_ai_event_publish_outbox_status",
"chk_muse_ai_event_publish_outbox_attempt_count",
"chk_muse_ai_event_publish_outbox_max_attempt"
);
private static final List<String> REQUIRED_TRIGGERS = List.of(
"trg_muse_ai_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_migrateV1ToV17OnRealPostgresqlAndVerifyAiPublishOutboxSchema() 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-7b AI 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 当前版本必须到 V17");
assertEquals(TARGET_DESCRIPTION, current.getDescription(),
"真实 Flyway 当前版本必须是 AI publish outbox V17 扩展迁移");
assertEquals(EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, result.migrationsExecuted,
"clean 后必须只执行 V1-V17 共 17 个迁移,避免遗漏目标版本或误跑后续迁移");
int successfulMigrationCount = successfulMigrationCount(url, user, password);
assertEquals(EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, successfulMigrationCount,
"必须在隔离库中执行 V1-V17 共 17 个成功 SQL 迁移");
try (Connection connection = DriverManager.getConnection(url, user, password)) {
assertRequiredObjects(connection);
assertOutboxSchema(connection);
}
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("v17_table=" + OUTBOX_TABLE);
System.out.println("v17_indexes=" + String.join(",", REQUIRED_INDEXES));
System.out.println("v17_constraints=" + String.join(",", REQUIRED_CONSTRAINTS));
System.out.println("v17_triggers=" + String.join(",", REQUIRED_TRIGGERS));
}
@Test
void should_rejectNonTestDatabaseWhenQueryContainsSlashTestSuffix() {
String url = "jdbc:postgresql://prod-host/prod?applicationName=/muse_p1r7b_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_p1r7b_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_allowTestDatabaseWithQueryAndMaskRealDatabaseName() {
String url = "jdbc:postgresql://prod-host:5432/muse_p1r7b_events_publish_test?applicationName=/muse-local&sslmode=disable";
assertTestDatabaseUrl(url);
assertEquals("jdbc:postgresql://<host>:5432/muse_p1r7b_events_publish_test?<query-redacted>", maskedUrl(url),
"合法 _test 库携带 query 时必须保留真实 database name并整体脱敏 query");
}
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) {
// WHYFlyway 连接异常默认会打印原始 JDBC URL这里统一替换成 masked URL避免测试日志暴露环境细节
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),
"AI Events publish outbox 表必须存在: " + OUTBOX_TABLE);
Map<String, Boolean> indexChecks = indexChecks(connection);
assertFalse(indexChecks.containsValue(false), "AI publish outbox 关键索引必须全部存在: " + indexChecks);
Map<String, Boolean> constraintChecks = constraintChecks(connection);
assertFalse(constraintChecks.containsValue(false), "AI publish outbox 关键约束必须全部存在: " + constraintChecks);
Map<String, Boolean> triggerChecks = triggerChecks(connection);
assertFalse(triggerChecks.containsValue(false), "AI publish outbox 更新时间触发器必须全部存在: " + triggerChecks);
}
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", "owner_user_id", "task_id", "source_event_id", "source_revision",
"event_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", "creator", "create_time", "updater",
"update_time", "deleted");
assertEquals("ALWAYS", columnIdentityGeneration(connection, OUTBOX_TABLE, "id"),
"id 必须使用 GENERATED ALWAYS AS IDENTITY");
assertEquals("int8", columnUdtName(connection, OUTBOX_TABLE, "source_event_id"),
"source_event_id 必须是 BIGINT引用 muse_ai_task_event.id 语义");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "source_event_id"),
"source_event_id 必须 NOT NULL");
assertEquals(128, columnLength(connection, OUTBOX_TABLE, "command_id"),
"command_id 必须是 VARCHAR(128)");
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "command_id"),
"command_id 必须 NOT NULL");
assertEquals("jsonb", columnUdtName(connection, OUTBOX_TABLE, "payload_summary"),
"payload_summary 必须使用 jsonb 保存可公开事件摘要");
assertEquals("'{}'::jsonb", normalizeSql(columnDefault(connection, OUTBOX_TABLE, "payload_summary")),
"payload_summary 默认值必须是空 JSONB 对象");
assertEquals("0", normalizeSql(columnDefault(connection, OUTBOX_TABLE, "attempt_count")),
"attempt_count 默认值必须是 0");
assertEquals("5", normalizeSql(columnDefault(connection, OUTBOX_TABLE, "max_attempt")),
"max_attempt 默认值必须是 P1R-7b 最小固定重试上限 5");
assertEquals("false", normalizeSql(columnDefault(connection, OUTBOX_TABLE, "deleted")),
"deleted 默认值必须是 false");
assertEquals("", columnDefaultLiteral(connection, OUTBOX_TABLE, "creator"),
"creator 默认值必须与 BaseDO 字段约定保持一致");
assertEquals("", columnDefaultLiteral(connection, OUTBOX_TABLE, "updater"),
"updater 默认值必须与 BaseDO 字段约定保持一致");
assertTrue(columnIsNullable(connection, OUTBOX_TABLE, "claimed_at"),
"claimed_at 必须允许为空queued 状态尚未 claim");
assertTrue(columnIsNullable(connection, OUTBOX_TABLE, "claim_expires_at"),
"claim_expires_at 必须允许为空queued 状态尚未 lease");
assertTrue(columnIsNullable(connection, OUTBOX_TABLE, "next_retry_at"),
"next_retry_at 必须允许为空,非 retryable 状态不需要重试时间");
assertEquals(List.of("tenant_id", "command_id"),
constraintColumns(connection, "uk_muse_ai_event_publish_outbox_command"),
"命令幂等唯一约束必须覆盖 tenant_id/command_id");
assertEquals(List.of("tenant_id", "task_id", "source_revision", "event_type"),
constraintColumns(connection, "uk_muse_ai_event_publish_outbox_source"),
"source tuple 唯一约束必须覆盖 tenant_id/task_id/source_revision/event_type");
assertEquals(List.of("publish_status", "claim_expires_at", "next_retry_at", "create_time", "id"),
indexColumns(connection, "idx_muse_ai_event_publish_outbox_claim"),
"claim 索引必须按 publish_status/claim_expires_at/next_retry_at/create_time/id 排序");
assertTrue(normalizedIndexDefinition(connection, "idx_muse_ai_event_publish_outbox_claim").contains("where (deleted = false)")
|| normalizedIndexDefinition(connection, "idx_muse_ai_event_publish_outbox_claim").contains("where deleted = false"),
"claim 索引必须是 deleted=false partial index");
assertEquals(List.of("tenant_id", "owner_user_id", "publish_status", "create_time"),
indexColumns(connection, "idx_muse_ai_event_publish_outbox_owner_status"),
"owner/status 索引必须覆盖 tenant_id/owner_user_id/publish_status/create_time");
String eventTypeConstraint = normalizedConstraintDefinition(connection, "chk_muse_ai_event_publish_outbox_event_type");
for (String value : Set.of("done", "error")) {
assertTrue(eventTypeConstraint.contains(value),
"event_type check 必须允许 " + value + ": " + eventTypeConstraint);
}
assertFalse(eventTypeConstraint.contains("cancelled"),
"Task 2 不能新增 cancelled event_type: " + eventTypeConstraint);
String publishStatusConstraint = normalizedConstraintDefinition(connection, "chk_muse_ai_event_publish_outbox_status");
for (String value : Set.of("queued", "running", "published", "retryable", "dead_letter")) {
assertTrue(publishStatusConstraint.contains(value),
"publish_status check 必须允许 " + value + ": " + publishStatusConstraint);
}
assertTrue(normalizedConstraintDefinition(connection, "chk_muse_ai_event_publish_outbox_attempt_count")
.contains("attempt_count >= 0"),
"attempt_count 必须有非负约束");
assertTrue(normalizedConstraintDefinition(connection, "chk_muse_ai_event_publish_outbox_max_attempt")
.contains("max_attempt > 0"),
"max_attempt 必须有正数约束,支撑 retry 耗尽后进入 dead_letter");
}
private static void assertColumnsExist(Connection connection, String tableName, String... columnNames) throws SQLException {
for (String columnName : columnNames) {
assertTrue(columnExists(connection, tableName, columnName),
tableName + " 必须包含字段: " + columnName);
}
}
private static boolean columnExists(Connection connection, String tableName, String columnName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""")) {
statement.setString(1, tableName);
statement.setString(2, columnName);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next();
}
}
}
private static boolean columnIsNotNullable(Connection connection, String tableName, String columnName) throws SQLException {
return !columnIsNullable(connection, tableName, columnName);
}
private static boolean columnIsNullable(Connection connection, String tableName, String columnName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""")) {
statement.setString(1, tableName);
statement.setString(2, columnName);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName);
return "YES".equals(resultSet.getString(1));
}
}
}
private static String columnUdtName(Connection connection, String tableName, String columnName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT udt_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""")) {
statement.setString(1, tableName);
statement.setString(2, columnName);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName);
return resultSet.getString(1);
}
}
}
private static Integer columnLength(Connection connection, String tableName, String columnName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""")) {
statement.setString(1, tableName);
statement.setString(2, columnName);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName);
return resultSet.getInt(1);
}
}
}
private static String columnDefault(Connection connection, String tableName, String columnName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""")) {
statement.setString(1, tableName);
statement.setString(2, columnName);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName);
return Objects.toString(resultSet.getString(1), "");
}
}
}
private static String columnIdentityGeneration(Connection connection, String tableName, String columnName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT identity_generation
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ?
AND column_name = ?
""")) {
statement.setString(1, tableName);
statement.setString(2, columnName);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName);
return resultSet.getString(1);
}
}
}
private static String columnDefaultLiteral(Connection connection, String tableName, String columnName) throws SQLException {
String value = normalizeSql(columnDefault(connection, tableName, columnName));
int quotedValueStart = value.indexOf('\'');
int quotedValueEnd = value.indexOf('\'', quotedValueStart + 1);
assertTrue(quotedValueStart >= 0 && quotedValueEnd > quotedValueStart,
"字段默认值必须是字符串字面量: " + tableName + "." + columnName + "=" + value);
return value.substring(quotedValueStart + 1, quotedValueEnd);
}
private static String normalizedConstraintDefinition(Connection connection, String constraintName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
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 = ?
""")) {
statement.setString(1, constraintName);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), "缺少约束定义: " + constraintName);
return normalizeSql(resultSet.getString(1));
}
}
}
private static String normalizedIndexDefinition(Connection connection, String indexName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = ?
""")) {
statement.setString(1, indexName);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), "缺少索引定义: " + indexName);
return normalizeSql(resultSet.getString(1));
}
}
}
private static List<String> indexColumns(Connection connection, String indexName) throws SQLException {
String sql = """
SELECT a.attname
FROM pg_class i
JOIN pg_namespace ni ON ni.oid = i.relnamespace
JOIN pg_index ix ON ix.indexrelid = i.oid
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_namespace nt ON nt.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = ANY(ix.indkey)
WHERE ni.nspname = 'public'
AND nt.nspname = 'public'
AND i.relname = ?
ORDER BY array_position(ix.indkey, a.attnum)
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, indexName);
try (ResultSet resultSet = statement.executeQuery()) {
List<String> columns = new ArrayList<>();
while (resultSet.next()) {
columns.add(resultSet.getString(1));
}
return columns;
}
}
}
private static List<String> constraintColumns(Connection connection, String constraintName) throws SQLException {
String sql = """
SELECT a.attname
FROM pg_constraint c
JOIN pg_namespace nc ON nc.oid = c.connamespace
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace nt ON nt.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE nc.nspname = 'public'
AND nt.nspname = 'public'
AND c.conname = ?
ORDER BY array_position(c.conkey, a.attnum)
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, constraintName);
try (ResultSet resultSet = statement.executeQuery()) {
List<String> columns = new ArrayList<>();
while (resultSet.next()) {
columns.add(resultSet.getString(1));
}
return columns;
}
}
}
private static void assertNoCredentialQuery(String url) {
int queryStart = url.indexOf('?');
if (queryStart < 0) {
return;
}
String query = url.substring(queryStart + 1);
for (String parameter : query.split("&")) {
String key = parameter;
int equalsStart = key.indexOf('=');
if (equalsStart >= 0) {
key = key.substring(0, equalsStart);
}
assertFalse(isCredentialQueryKey(key),
"p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入");
}
}
private static boolean isCredentialQueryKey(String rawKey) {
// WHYURL 可能会被 Surefire 或日志记录凭据只能走单独参数或环境变量不能藏在 JDBC query
String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_');
return CREDENTIAL_QUERY_KEYS.contains(key)
|| key.endsWith("_token")
|| key.endsWith("_secret")
|| key.endsWith("_password");
}
private static void assertTestDatabaseUrl(String url) {
// WHYFlyway clean 会删除 public schema 内全部对象只能允许真实 database name _test 结尾
// query 由调用方控制不能参与库名判断否则 applicationName=/xxx_test 这类参数会绕过安全闸
String databaseName = jdbcDatabaseName(url);
assertTrue(databaseName.endsWith("_test"),
"p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url));
}
private static String jdbcDatabaseName(String url) {
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
int databaseStart = urlWithoutQuery.lastIndexOf('/');
assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1,
"p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url));
return urlWithoutQuery.substring(databaseStart + 1);
}
private static String jdbcUrlWithoutQuery(String url) {
int queryStart = url.indexOf('?');
return queryStart < 0 ? url : url.substring(0, queryStart);
}
private static String maskedUrl(String url) {
// WHY脱敏输出既要保留真实 database name 便于排查又不能让 query 中的 slash 干扰库名解析
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
int databaseStart = urlWithoutQuery.lastIndexOf('/');
if (databaseStart < 0) {
return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url);
}
String prefix = urlWithoutQuery.substring(0, databaseStart + 1);
String database = urlWithoutQuery.substring(databaseStart + 1);
return maskJdbcHost(prefix) + database + maskedQuerySuffix(url);
}
private static String maskedQuerySuffix(String url) {
return url.indexOf('?') < 0 ? "" : "?<query-redacted>";
}
private static String maskJdbcHost(String urlPart) {
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
}
private static String maskUser(String user) {
return user == null || user.isBlank() ? "<user-redacted>" : "<user-redacted>";
}
private static String normalizeSql(String sql) {
return sql.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
}

View File

@ -0,0 +1,260 @@
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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-7b AI terminal event publish outbox V17 迁移 SQL 静态门禁
*/
class P1rAiEventsPublishMigrationSqlTest {
private static final String OUTBOX_TABLE = "muse_ai_event_publish_outbox";
private static final Path V17_RELATIVE_PATH = Path.of("muse-cloud/sql/muse/V17__extend_ai_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)\\w*\\s+"
+ "(?:varchar|text|jsonb|char|bytea)");
@Test
void should_defineAiTerminalEventPublishOutboxTableAndColumns() throws IOException {
String normalizedSql = normalize(readV17Sql());
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",
"owner_user_id bigint not null",
"task_id varchar(128) not null",
"source_event_id bigint not null",
"source_revision varchar(80) not null",
"event_type varchar(32) not null",
"payload_summary jsonb not null default '{}'::jsonb",
"command_id varchar(128) not null",
"publish_status varchar(32) not null",
"attempt_count int not null default 0",
"max_attempt int not null default 5",
"claimed_at timestamp",
"claim_expires_at timestamp",
"next_retry_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 ''",
"create_time timestamp not null default current_timestamp",
"updater varchar(64) not null default ''",
"update_time timestamp not null default current_timestamp",
"deleted boolean not null default false")) {
assertTrue(tableSql.contains(column),
"AI publish outbox 必须包含字段定义: " + column);
}
}
@Test
void should_includeChecksUniqueConstraintsIndexesAndTrigger() throws IOException {
String normalizedSql = normalize(readV17Sql());
String tableSql = extractCreateTable(normalizedSql, OUTBOX_TABLE);
assertTrue(tableSql.contains("constraint uk_muse_ai_event_publish_outbox_command unique (tenant_id, command_id)"),
"command_id 必须通过 tenant_id/command_id 唯一约束保证发布命令幂等");
assertTrue(tableSql.contains("constraint uk_muse_ai_event_publish_outbox_source unique (tenant_id, task_id, source_revision, event_type)"),
"source tuple 必须通过 tenant_id/task_id/source_revision/event_type 唯一约束保证来源幂等");
String eventTypeConstraint = extractConstraint(tableSql, "chk_muse_ai_event_publish_outbox_event_type");
for (String value : Set.of("done", "error")) {
assertTrue(eventTypeConstraint.contains("'" + value + "'"),
"event_type check 必须允许: " + value);
}
assertFalse(eventTypeConstraint.contains("'cancelled'"),
"Task 2 schema 只允许 done/error不能新增 cancelled event_type");
String publishStatusConstraint = extractConstraint(tableSql, "chk_muse_ai_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 投影状态,不能混入 AI outbox 本地状态");
String attemptConstraint = extractConstraint(tableSql, "chk_muse_ai_event_publish_outbox_attempt_count");
assertTrue(attemptConstraint.contains("attempt_count >= 0"),
"attempt_count 必须有非负约束,避免 claim 次数语义被破坏");
String maxAttemptConstraint = extractConstraint(tableSql, "chk_muse_ai_event_publish_outbox_max_attempt");
assertTrue(maxAttemptConstraint.contains("max_attempt > 0"),
"max_attempt 必须有正数约束,支撑 retry 耗尽后进入 dead_letter");
assertTrue(normalizedSql.contains("create index idx_muse_ai_event_publish_outbox_claim")
&& normalizedSql.contains("on muse_ai_event_publish_outbox(publish_status, claim_expires_at, next_retry_at, create_time, id)")
&& normalizedSql.contains("where deleted = false"),
"claim partial index 必须覆盖 publish_status/claim_expires_at/next_retry_at/create_time/id 并过滤 deleted=false");
assertTrue(normalizedSql.contains("create index idx_muse_ai_event_publish_outbox_owner_status")
&& normalizedSql.contains("on muse_ai_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 trigger trg_muse_ai_event_publish_outbox_update_time")
&& normalizedSql.contains("before update on muse_ai_event_publish_outbox")
&& normalizedSql.contains("execute function update_updated_at_column()"),
"outbox 必须复用 update_updated_at_column() 的 update_time 触发器");
}
@Test
void should_followBaseDoTenantBaseDoAndUpdateTimeConventions() throws IOException {
Path root = findRepositoryRoot();
String migrationSql = normalize(readV17Sql());
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"));
String v1Sql = normalize(Files.readString(root.resolve("muse-cloud/sql/muse/V1__init_content_schema.sql")));
assertTrue(baseDo.contains("private LocalDateTime createTime;"),
"BaseDO 必须暴露 createTime对应 DDL create_time");
assertTrue(baseDo.contains("private LocalDateTime updateTime;"),
"BaseDO 必须暴露 updateTime对应 DDL update_time");
assertTrue(baseDo.contains("private String creator;") && baseDo.contains("private String updater;")
&& baseDo.contains("private Boolean deleted;"),
"BaseDO 必须暴露 creator/updater/deleted对应 DDL 审计与逻辑删除字段");
assertTrue(tenantBaseDo.contains("class TenantBaseDO extends BaseDO")
&& tenantBaseDo.contains("private Long tenantId;"),
"TenantBaseDO 必须继承 BaseDO 并暴露 tenantId对应 DDL tenant_id");
assertTrue(v1Sql.contains("new.update_time = current_timestamp"),
"update_updated_at_column() 必须更新 update_time");
assertTrue(extractCreateTable(migrationSql, OUTBOX_TABLE).contains("create_time timestamp not null default current_timestamp")
&& extractCreateTable(migrationSql, OUTBOX_TABLE).contains("update_time timestamp not null default current_timestamp"),
"outbox 必须使用 create_time/update_time 字段名对齐 BaseDO");
}
@Test
void should_rejectNonProjectTimestampNamesSensitiveColumnsForeignKeysAndDuplicateObjects() throws IOException {
String normalizedSql = normalize(readV17Sql());
String tableSql = extractCreateTable(normalizedSql, OUTBOX_TABLE);
assertFalse(tableSql.contains("created_at"),
"Task 2 禁止使用非项目约定字段 created_at");
assertFalse(tableSql.contains("updated_at"),
"Task 2 禁止使用非项目约定字段 updated_at");
assertFalse(PLAINTEXT_SECRET_COLUMN_PATTERN.matcher(tableSql).find(),
"AI publish outbox 不能新增 token/authorization/secret/password/api_key/bearer 明文字段");
assertFalse(tableSql.contains("foreign key") || normalizedSql.contains("references muse_ai_task_event"),
"source_event_id 只表达 muse_ai_task_event.id 引用语义,引用完整性由应用层保证,不能新增数据库外键");
assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bcreate table (muse_[a-z0-9_]+)\\b")),
"V17 新增表名不能重复");
assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bcreate (?:unique )?index (\\w+)\\b")),
"V17 新增索引名不能重复");
assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bcreate trigger (\\w+)\\b")),
"V17 新增 trigger 名不能重复");
assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bconstraint (\\w+)\\b")),
"V17 新增 constraint 名不能重复");
String earlierMigrations = readEarlierMigrationsNormalized();
assertNewNamesDoNotExistInEarlierMigrations(normalizedSql, earlierMigrations,
Pattern.compile("\\bcreate table (muse_[a-z0-9_]+)\\b"), "");
assertNewNamesDoNotExistInEarlierMigrations(normalizedSql, earlierMigrations,
Pattern.compile("\\bcreate (?:unique )?index (\\w+)\\b"), "索引");
assertNewNamesDoNotExistInEarlierMigrations(normalizedSql, earlierMigrations,
Pattern.compile("\\bcreate trigger (\\w+)\\b"), "trigger");
assertNewNamesDoNotExistInEarlierMigrations(normalizedSql, earlierMigrations,
Pattern.compile("\\bconstraint (\\w+)\\b"), "constraint");
}
private static String readV17Sql() throws IOException {
Path migrationPath = findRepositoryRoot().resolve(V17_RELATIVE_PATH);
assertTrue(Files.exists(migrationPath), "V17 AI Events publish outbox 迁移 SQL 必须存在: " + V17_RELATIVE_PATH);
return Files.readString(migrationPath);
}
/**
* 从当前 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/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(V17_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, "V17 必须创建表: " + tableName);
int end = normalizedSql.indexOf(");", start + startToken.length());
assertTrue(end >= 0, "V17 表定义必须以 ); 结束: " + 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, "V17 必须创建约束: " + 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 normalizedSql, Pattern pattern) {
Matcher matcher = pattern.matcher(normalizedSql);
return matcher.results()
.map(result -> result.group(1))
.toList();
}
private static void assertNoDuplicateNames(List<String> names, String message) {
Set<String> uniqueNames = new HashSet<>(names);
assertEquals(uniqueNames.size(), names.size(), message + ": " + names);
}
private static void assertNewNamesDoNotExistInEarlierMigrations(
String normalizedSql, String earlierMigrations, Pattern pattern, String objectType) {
for (String name : extractNames(normalizedSql, pattern)) {
assertFalse(earlierMigrations.contains(name),
"V17 新增" + objectType + "不能与既有对象重名: " + name);
}
}
}