test(p1r): 收口 AI 事件传播真实链路门禁
This commit is contained in:
parent
3db5fbedfd
commit
230152c22a
17
docs/agent-specs/.agent
Normal file
17
docs/agent-specs/.agent
Normal file
@ -0,0 +1,17 @@
|
||||
# docs/agent-specs
|
||||
|
||||
用途:存放 P1R 等跨模块 agent 规格与执行计划文档。
|
||||
|
||||
边界:本目录只记录审阅版、执行版、review gate 与交接约束,不承载代码实现,不修改 OpenAPI、scanner 或 coverage 口径。
|
||||
|
||||
当前状态:P1R-7b 执行版已作为全局方案下的 AI 子计划通过 fresh spec re-review(Erdos)和 fresh quality/feasibility review(Carver)。Task 1 只读 preflight 已双 PASS(Ramanujan / Descartes),Task 2 migration/schema 已双 PASS(Boyle / Lorentz),Task 3 AI DO/Mapper/Service 与 terminal fact 同事务 outbox 已双 PASS(Wegener / Bohr),Task 4 AI worker / dispatcher 已在修复 spec review FAIL 后双 PASS(Hume / 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 review(Ptolemy)与 fresh quality/feasibility review(Curie)双 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 review(Feynman)FAIL:Task 7 仍直跑 scanner,Market 命令只看 completionStatus;spec re-review(Banach)FAIL:Task 6 Market 命令仍只看 completionStatus。已修复为 Task 6/Task 7 都在临时隔离副本运行 scanner,当前 worktree protected diff 必须为空,并用 `implementationStatus/completionStatus` 验证 Market 为 `dedicated/needs_verification 32`;修订方案 fresh spec re-review(Kuhn)PASS、fresh quality/feasibility review(Meitner)PASS。Task 6 revised gate 由 fresh implementer(Lorentz)DONE,本轮无文件改动;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 0,summary 为 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0;Events `streamEvents=dedicated/needs_verification`,Market 32 个 operation 均为 `dedicated/needs_verification`;dependency tree 证明 AI server 依赖 events-api、不依赖 events-server,Events server 不反向依赖 AI/Knowledge/Market/Member/Content server。下一步必须 fresh Task 6 spec review,未双 PASS 前不能进入 Task 7。
|
||||
|
||||
最新补记:Task 6 spec review(Ohm)PASS,但指出 dependency test 未显式覆盖 `muse-module-content-server` 属于依赖合同完整性风险;按 `superpowers:receiving-code-review` 处理后判定该反馈有效。fresh implementer(Peirce)已把 `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 0,summary 仍为 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0,Events 仍为 `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 review(Huygens)PASS,阻塞项无;其已验证 Content forbidden、执行版旧口径清空、P1R focused gates 29 个 surefire evidence、隔离 scanner evidence、Events/Market 仍为 `dedicated/needs_verification`。fresh quality/feasibility review(Boyle)PASS,阻塞项无;其已验证 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 0,47/47 reactor modules success。AI/Events focused tests exit 0,surefire 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=0,migrations_executed=17、target_schema_version=17、v17_table=`muse_ai_event_publish_outbox`。dependency tree gate:AI 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 0,summary 为 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0;Events `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 review(Mencius)PASS,阻塞项无;其已验证 Task 7 最终验证范围、16 个目标测试类 surefire XML、Flyway `_test` 参数、隔离 coverage 副本、protected diff、memory 与 `.agent` 的 needs_verification/completed 边界。fresh quality/feasibility review(Hume)PASS,阻塞项无;其已验证 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。
|
||||
309
docs/agent-specs/2026-06-06-P1R7SourceOwnerPropagation全局审阅版.md
Normal file
309
docs/agent-specs/2026-06-06-P1R7SourceOwnerPropagation全局审阅版.md
Normal 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 outbox;worker 通过 `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 API;Events 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-7c:Knowledge propagation
|
||||
|
||||
目标:
|
||||
|
||||
- 选择 Knowledge source status / projection summary 中最小用户可见事件。
|
||||
- 在 Knowledge owner 内新增或复用 publish outbox 语义,补 claim/retry/dead-letter。
|
||||
- 区分内部 projection task 和用户可见 Events notification。
|
||||
|
||||
不做:
|
||||
|
||||
- 不把同步完成的 projection task 伪装成异步 worker。
|
||||
- 不发布知识资料原文。
|
||||
|
||||
### P1R-7d:Market propagation
|
||||
|
||||
目标:
|
||||
|
||||
- 覆盖 publish review、asset delist/recall、license/install/purchase 对用户可见状态的通知。
|
||||
- 保留 Market Account projection outbox 的原职责,另建或扩展专门 Events publish outbox。
|
||||
|
||||
不做:
|
||||
|
||||
- 不把 Market 32 operations 标为 completed。
|
||||
- 不让 Market 事件接管源作品、源智能体或源知识库事实 owner。
|
||||
|
||||
### P1R-7e:Account(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-7f:Content propagation
|
||||
|
||||
目标:
|
||||
|
||||
- 评估 BlockSavedEvent、export task、import task 中哪些应进入用户可见 SSE。
|
||||
- 明确内部 followup trigger 与用户通知的分界。
|
||||
|
||||
不做:
|
||||
|
||||
- 不把正文保存的每次内部事件都推给 SSE。
|
||||
- 不发布正文全文或 block content。
|
||||
|
||||
## 完成审批门槛
|
||||
|
||||
P1R-7 completed approval 必须另起任务,并至少具备:
|
||||
|
||||
1. Events owner 自身 gate:route 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 completed;completed 必须另有审批。
|
||||
- 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 任务。
|
||||
353
docs/agent-specs/2026-06-06-P1R7SourceOwnerPropagation全局执行版.md
Normal file
353
docs/agent-specs/2026-06-06-P1R7SourceOwnerPropagation全局执行版.md
Normal 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 API;Events 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 1:P1R-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 2:P1R-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 tree:AI 只依赖 events-api,不依赖 events-server;Events 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 4:P1R-7c / P1R-7e 子计划与实现
|
||||
|
||||
目标:
|
||||
|
||||
- 按 Task 3 决策进入 Knowledge 或 Account。
|
||||
- 每个 owner 都必须先产出单独审阅版,再产出单独执行版;审阅版和执行版各自 fresh spec review + fresh quality / feasibility review 双 PASS 后,才允许进入该 owner 实现。
|
||||
|
||||
必须继承:
|
||||
|
||||
- 本执行版的全局合同。
|
||||
- P1R-7b 的复用经验,但不得复制 AI payload / 状态机细节到不匹配 owner。
|
||||
|
||||
### Task 5:Market 与 Content 后续切片
|
||||
|
||||
目标:
|
||||
|
||||
- Market 先拆清 Account projection outbox 与 Events publish outbox 职责。
|
||||
- Content 先拆清内部 followup trigger 与用户可见 Events notification。
|
||||
|
||||
进入条件:
|
||||
|
||||
- 至少已有两个 owner 的 Events publish 链路通过验证,或者用户明确要求优先 Market / Content。
|
||||
|
||||
### Task 6:P1R-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 verification;review 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。
|
||||
148
docs/agent-specs/2026-06-06-P1R7bSourceOwnerPropagation审阅版.md
Normal file
148
docs/agent-specs/2026-06-06-P1R7bSourceOwnerPropagation审阅版.md
Normal 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 owner:AI。
|
||||
- Source fact:AI task terminal event,优先选择已可公开、已持久化、已有 ownerUserId 和 sequence 的 done/error 事实。
|
||||
- Publish mode:AI 本域 outbox/job 异步调用 `EventsPublishApi`,不在业务事务内阻塞等待 Events 成功。
|
||||
- Idempotency:使用 AI 本域 command/source tuple 映射 Events `commandId` 和 `(sourceOwner, sourceType, sourceId, sourceRevision, eventType)`。
|
||||
- Failure:Events 临时失败时 AI 业务事实仍保留,outbox 保持可重试;达到上限后进入阻塞或 dead-letter 等价状态。
|
||||
- SSE evidence:只证明 accepted 事件进入 `muse_unified_event` 后能被当前用户 SSE 可见查询读到。
|
||||
|
||||
后续切片:
|
||||
|
||||
- P1R-7c:Knowledge source/projection event publish outbox。
|
||||
- P1R-7d:Market governance / Account projection 相关事件,需先拆清 Account 投影 outbox 与 Events publish outbox 职责。
|
||||
- P1R-7e:Member(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,不选择 Market:Market outbox 名称容易误导,但当前没有 claim/retry/dead-letter/replay worker;AI 的 task terminal event 更接近可公开事件源。
|
||||
- 做一个 owner,不做四个 owner:source owner propagation 的核心风险在失败补偿和幂等;先用单 owner 打穿链路,比并行堆多个半成品更可审核。
|
||||
- 新增本域 publish outbox,不把 runtime job 当 Events publish 队列:AI runtime job 职责是执行 New-API task;混用会污染任务语义和重试策略。
|
||||
- 保持 Events API 单向依赖:source owner 依赖 events-api;Events server 不读取 source owner 表,不反向依赖 source owner server。
|
||||
- 只推进证据,不推进 completed:P1R-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 server,AI 只依赖 `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` 残留。
|
||||
1141
docs/agent-specs/2026-06-06-P1R7bSourceOwnerPropagation执行版.md
Normal file
1141
docs/agent-specs/2026-06-06-P1R7bSourceOwnerPropagation执行版.md
Normal file
File diff suppressed because it is too large
Load Diff
218
docs/memorys/2026-06-06-P1R7bSourceOwnerPropagation真实链路.md
Normal file
218
docs/memorys/2026-06-06-P1R7bSourceOwnerPropagation真实链路.md
Normal 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 success,Finished 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。
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 worker、Events publish 幂等/拒绝语义和可见查询边界;
|
||||
* 不启动 Spring 容器或真实数据库,避免 Task 5b 扩大到生产链路改造。
|
||||
*/
|
||||
private record TestHarness(MuseAiEventPublishWorker worker, EventsPublishServiceImpl eventsPublishService,
|
||||
List<UnifiedEventDO> unifiedRows) {
|
||||
}
|
||||
|
||||
}
|
||||
@ -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) {
|
||||
// WHY:Surefire 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) {
|
||||
// WHY:Flyway 连接异常默认会打印原始 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) {
|
||||
// WHY:URL 可能会被 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) {
|
||||
// WHY:Flyway 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+", " ");
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user