test(p1r): 收口 Knowledge 事件传播真实链路门禁
This commit is contained in:
parent
cef686b968
commit
e55618f1fb
@ -15,3 +15,29 @@ Task 7 preflight 修订:执行版最终验证命令原先把 `mvn -o clean ins
|
||||
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。
|
||||
|
||||
P1R-7b 已按逻辑拆分提交并 push 到 `origin/dev/1.0.0`:`3db5fbe feat(p1r): 接入 AI 事件传播真实链路`、`230152c test(p1r): 收口 AI 事件传播真实链路门禁`。当前分支已与远端对齐到 `230152c`。下一主线进入 P1R-7c Knowledge Source Owner Propagation 审阅版,目标只做 Knowledge source status / projection summary 的用户可见 Events notification 方案审阅,不实现代码、不修改 OpenAPI/scanner/coverage、不推进 completed。只读盘点事实:Knowledge server 当前无 `muse-module-events-api` 依赖;已有 `muse_knowledge_source_event`、`muse_knowledge_source_binding_projection`、`muse_knowledge_projection_task`;`MuseKnowledgeSourceEventService` 当前在事务内写 source event、同步更新 projection,并将 projection task 明确写为 `completed`,注释说明避免制造无执行者的 queued task;当前没有 Knowledge publish outbox、claim/retry/dead-letter worker 或 `FOR UPDATE SKIP LOCKED` 领取语义。P1R-7c 审阅版已新增 `docs/agent-specs/2026-06-06-P1R7cKnowledgeSourceOwnerPropagation审阅版.md`,推荐新增独立 Knowledge Events publish outbox,使用 Events 现有 `notification` event type,禁止复用 projection task 作为发布队列,禁止发布知识资料原文、RAGFlow raw、GraphRAG trace、授权快照或敏感 reason。下一步必须 fresh spec compliance review + fresh quality/feasibility review;双 PASS 后才允许写执行版。
|
||||
|
||||
P1R-7c 审阅版首轮 review:security reviewer(Leibniz)PASS、scope reviewer(Locke)PASS;quality/feasibility reviewer(Darwin)FAIL、adversarial reviewer(Kierkegaard)FAIL,阻塞项同源:首批 Knowledge 事件与现有 source event 写入模型不闭合。已按 `receiving-code-review` 验证该反馈有效:`MuseKnowledgeSourceEventDO` 没有顶层 `eventType` / `affectedTargets`,`MuseKnowledgeAccessPolicyService`、`MuseKnowledgeBaseService`、`MuseKnowledgeDocumentService` 直接构造 source event 的 summary/status/actionPolicy 形态不统一,其中 `enableGlobalKnowledgeBase` 当前不写 source event。审阅版已修订为只覆盖 `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` canonical source status / projection summary 链路,明确排除 AccessPolicy/Base/Document 直接写 source event 入口到后续标准化切片;notification type 固定使用 Events 已声明的 `source_status_change`;payload 合同改为 DO 顶层 allowlist + canonical `eventSummary` / projection summary allowlist,并默认不写 `resourceRef`。旧 spec reviewer(Carson)因基于过期文档仍在运行,已关闭。下一步需对修订后的审阅版重新派发 fresh spec compliance review + fresh quality/feasibility review;未双 PASS 前不能写执行版、实现代码、提交或推进 completed。
|
||||
|
||||
P1R-7c 审阅版第二轮 review:fresh spec compliance reviewer(Maxwell)FAIL、fresh quality/feasibility reviewer(Bohr)FAIL,阻塞项同源:canonical `triggerGlobalKBSourceEvent` 当前写 `MuseKnowledgeSourceEventDO.ownerUserId=0`,而 Events SSE visible query 按登录用户 `ownerUserId` 过滤;如果直接用 source event 顶层 owner 发布,普通用户不可见。已验证反馈有效:`UnifiedEventMapper.selectVisibleEventsForOwner` 按 tenantId、ownerUserId、accepted、deleted=false、visibleFrom、sequenceNo 过滤;`muse_knowledge_source_binding_projection` 有 `owner_user_id`、`work_id`、`kb_id`、`binding_id`、`last_event_id` 和 owner/source/kb 唯一约束,可作为 affected owner fan-out 来源。审阅版已修订为 P1R-7c 采用 per affected owner notification:source event owner `0` 只保留为 global source fact owner,outbox 创建必须在 `triggerGlobalKBSourceEvent` 同事务内按更新后的 binding projection owner 去重 fan-out,排除 null/0 owner;每个 target owner 独立 outbox/worker/retry/dead_letter;Events commandId/source tuple 必须包含 sourceEventId + targetOwnerUserId,避免 V16 source tuple/command 唯一约束跨 owner 撞车。下一步需第三轮 fresh spec compliance review + fresh quality/feasibility review;未双 PASS 前不能写执行版、实现代码、提交或推进 completed。
|
||||
|
||||
P1R-7c 审阅版第三轮 review 已双 PASS:fresh spec compliance reviewer(Parfit)PASS、fresh quality/feasibility reviewer(Aquinas)PASS,阻塞项无。Parfit 已验证:审阅版只覆盖 Knowledge source owner propagation,不进入 Events/P1R-7/Knowledge/Market completed;第一批只覆盖 `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` canonical 入口并排除 AccessPolicy/Base/Document 直接 source event 写入入口;per affected owner fan-out 已与 Events visible query 和 V16 command/source tuple 唯一约束闭合;OpenAPI/scanner/coverage 未被要求修改。Aquinas 已验证:canonical 入口同事务 source event / projection / projection task 链路与 outbox fan-out 兼容;owner `0` 不直接用于 SSE;`notification/source_status_change` 复用现有合同;Knowledge 仅允许依赖 events-api,不依赖 events-server;projection task 仍不是 publish worker;后续 mapper、migration、worker、E2E、retry/dead_letter、Flyway `_test`、隔离 coverage scanner 都已作为执行版必含 gate。下一步可以编写 `docs/agent-specs/2026-06-06-P1R7cKnowledgeSourceOwnerPropagation执行版.md`;执行版完成后仍需 fresh spec + quality 双 review,之后才可实现。不得提交、push 或推进 completed,除非用户另行明确批准。
|
||||
|
||||
P1R-7c 执行版已新增:`docs/agent-specs/2026-06-06-P1R7cKnowledgeSourceOwnerPropagation执行版.md`。该执行版继承 Parfit/Aquinas 双 PASS 后的审阅方案,锁定 canonical `triggerGlobalKBSourceEvent`、per affected projection owner fan-out、Knowledge 本域 `muse_knowledge_event_publish_outbox`、V18 migration、Knowledge worker 调用 `EventsPublishApi`、`notification/source_status_change` payload allowlist、dependency gate、Flyway `_test`、E2E 和隔离 coverage scanner。执行版同时明确:source event `ownerUserId=0` 只代表 global source fact owner,不能直接用于 SSE 可见 owner;`document_deleted`、AccessPolicy/Base/Document 直接 source event 写入入口、RAGFlow/GraphRAG raw、知识正文或 chunk 不进入 P1R-7c 第一批。当前没有实现代码改动,没有 OpenAPI/scanner/coverage report 改动。下一步必须派发 fresh execution spec compliance review + fresh execution quality/feasibility review;双 PASS 前不能实现代码、提交、push 或推进 Events/P1R-7/Knowledge/Market completed。
|
||||
|
||||
P1R-7c 执行版首轮 quality/feasibility review(Sartre)FAIL,两个 P1 blocker 已按 `superpowers:receiving-code-review` 只读验证为有效:其一,执行版 mixed gate 误包含 P1R-5/V14 专用 `P1rKnowledgeFlywayMigrationIT`,该测试固定 target V14 且需要独立 Flyway 参数,新增 V18 后不应混入 P1R-7c gate;其二,执行版要求 invalid owner 进入 `dead_letter`,但 V18 DDL 同时要求 `target_owner_user_id NOT NULL` 且 `>0`,合同不闭合。执行版已修订:P1R mixed gate 移除旧 `P1rKnowledgeFlywayMigrationIT` 并加 `-am`,V18 真实 Flyway 证据只来自新增 `P1rKnowledgeEventsPublishFlywayMigrationIT`;invalid owner 改为 fan-out 阶段过滤并记录安全日志/审计摘要,不创建 outbox、不发布 owner 0、不要求 dead_letter 行;同时补充 outbox service 写入前必须把空 `sourceRevision` 归一化为 Events 现有 `__none__`。由于执行版已在 review 后变更,下一步必须重新派发 fresh execution spec compliance review + fresh execution quality/feasibility review;首轮 Sartre 不能计入 PASS gate。
|
||||
|
||||
P1R-7c 执行版第二轮 spec compliance review(Chandrasekhar)FAIL,P1 blocker 已只读验证有效:执行版 mixed gate 仍包含 `P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT`,该测试是 P1R-5 Knowledge/RAGFlow opt-in live acceptance,启用时迁移到 V14 并外呼真实 RAGFlow,不能作为 P1R-7c Knowledge Events publish required gate。执行版已修订:P1R mixed gate 移除 `P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT`;旧 `P1rKnowledgeFlywayMigrationIT` 与 `P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT` 均明确不得计入 P1R-7c required PASS gate,后者如需发布前回归只能单列为 P1R-5 optional external regression。由于执行版再次变更,下一步必须重新派发 fresh execution spec compliance review + fresh execution quality/feasibility review;第二轮 Chandrasekhar 不能计入 PASS gate,第二轮 Nash 已关闭为 stale。
|
||||
|
||||
P1R-7c 执行版第三轮 spec compliance review(Curie)PASS,但随后 quality/feasibility review(Hooke)FAIL,Curie PASS 因后续文档修订失效,不能计入最终 PASS gate。Hooke 的三个 P1 已只读验证并修订:第一,执行版写了继续运行 Events/AI gate,但 mixed gate 命令缺 AI gate,已补回 `P1rAiRealApiGateTest` 与 `P1rAiRouteOwnershipTest`,并明确除非触碰 AI outbox/worker 或 Events publish 公共合同,否则不重跑 `P1rAiEventsPublish*` 三个 gate;第二,invalid owner fan-out 审计摘要缺少可实现数据路径,已新增 `selectFanoutOwnerAuditSummaryByLastEventId` mapper 合同,并把 `ownerFanoutSummary` 明确写入 `muse_knowledge_projection_task.result_summary`,包含 total/valid/invalid/distinct/queued 计数;第三,worker 回滚策略要求配置关闭但未定义配置,已新增 `muse.knowledge.events.publish-worker.enabled=false` 默认关闭、scheduled delay 配置键、disabled 不 claim / enabled 才 claim focused test 要求。审阅版也同步修正 invalid owner 旧 dead_letter 口径为“过滤+审计摘要+安全日志,不创建 outbox”。下一步必须重新派发 fresh execution spec compliance review + fresh execution quality/feasibility review;第三轮 Curie/Hooke 不能作为最终双 PASS。
|
||||
|
||||
P1R-7c 执行版第四轮 fresh review 已双 PASS:spec compliance reviewer(Bernoulli)PASS,quality/feasibility reviewer(Hilbert)PASS,阻塞项无。Bernoulli 已验证:执行版继承审阅版核心边界,只覆盖 `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent`,不覆盖 AccessPolicy/Base/Document 直接 source event 入口,不推进 Events/P1R-7/Knowledge/Market completed;旧 P1R-5/V14 Flyway 与 live acceptance gate 均已排除;invalid owner 合同、owner fan-out、Events visible query、V16 command/source tuple、依赖方向与 V18 migration 前提闭合;protected diff 为空。Hilbert 已验证:Knowledge source event/projection 现有字段支撑 fan-out;Events publish 支持 `notification/source_status_change` 和 `__none__`;AI P1R-7b outbox/worker 模式可复用到 Knowledge;当前执行版覆盖 happy/empty/invalid owner/rejected/retryable/dead_letter/worker enabled gate。非阻塞风险:实现时需证明 fan-out mapper 的 tenant 限定,`blocked` 只作为 Events publish 兼容返回分支测试,same source event + owner replay 应覆盖同一 outbox retry 而不是重新触发随机 source event。双 PASS 后可以进入实现,但仍不得修改 OpenAPI/scanner/coverage report,不得推进 completed,不得提交或 push,除非用户另行明确批准。
|
||||
|
||||
P1R-7c implementation evidence 当前状态:Knowledge source owner propagation 主体实现、V18 SQL、server/framework/api gates 与 focused tests 已落地;最初 V18 Flyway `_test` 因 Java 全局 `socksProxyHost/http.proxyHost/https.proxyHost=127.0.0.1:7897` 导致 PostgreSQL JDBC 连接 EOF。已通过 A/B 探针验证同一 JDBC URL 默认代理失败、清空 JVM 代理属性成功;随后使用 `-Djava.net.useSystemProxies=false -DsocksProxyHost= -DsocksProxyPort= -Dhttp.proxyHost= -Dhttp.proxyPort= -Dhttps.proxyHost= -Dhttps.proxyPort=` 重跑 `P1rKnowledgeEventsPublishFlywayMigrationIT` 全量 3 tests PASS。新增实现包括 Knowledge server 直接依赖 `muse-module-events-api`、`MuseKnowledgeEventPublishOutboxDO/Mapper/Service/Worker`、`MuseKnowledgeEventsProperties` 默认 `muse.knowledge.events.publish-worker.enabled=false`、`MuseKnowledgeSourceEventService` 在 canonical source event 事务内创建 per target owner outbox 并把 `ownerFanoutSummary` 写入 projection task summary、`MuseKnowledgeSourceBindingProjectionMapper` fan-out target 与 audit summary 查询、`V18__extend_knowledge_events_publish_outbox.sql`。测试 evidence:Knowledge focused tests `MuseKnowledgeEventPublishOutboxServiceTest,MuseKnowledgeEventPublishWorkerTest,MuseKnowledgeSourceEventServiceTest` 更新为 14/14 pass;P1R mixed executable gates `P1rKnowledgeEventsPublishMigrationSqlTest,P1rKnowledgeEventsPublishDependencyTest,P1rKnowledgeEventsPublishEndToEndTest,P1rApiCoverageReportTest,P1rEventsRealApiGateTest,P1rEventsRouteOwnershipTest,P1rAiRealApiGateTest,P1rAiRouteOwnershipTest` 更新为 37/37 pass;`P1rKnowledgeEventsPublishFlywayMigrationIT` 更新为 3/3 pass,输出 `flyway_success=true`、`migrations_executed=18`、`successful_migration_count=18`、`target_schema_version=18`、`v18_table=muse_knowledge_event_publish_outbox`。full reactor `mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true` 为 62/62 modules success;coverage scanner 在 `/tmp/p1r7c-clean-coverage-scan.fA1ZHI` 隔离副本运行 exit 0;真实 worktree protected OpenAPI/scanner/coverage report diff 为空。仍不得修改 OpenAPI/scanner/coverage report,不得推进 Events/P1R-7/Knowledge/Market completed。
|
||||
|
||||
P1R-7c implementation fresh review 派发结果:Sagan correctness review PASS,Dalton testing evidence review PASS,Halley data integrity review FAIL。Halley 的有效 P1 为 outbox claim 后 `markPublished/markRetryable/markDeadLetter` 只按 id + tenant 回写,缺少“本次 claim 所有权”保护,过期 worker 可能覆盖新 claim 的 published / retryable / dead_letter 终态。已按 TDD 修复:先新增 `MuseKnowledgeEventPublishWorkerTest.should_guardTerminalStatusUpdatesWithClaimAttemptOwnership`,RED 失败信息为 `markPublished 必须只允许当前 running claim 回写终态`;随后把 mapper 三类终态回写补为 `publish_status='running' AND attempt_count=#{claimedAttemptCount}`,worker 调用传入 claim 返回的 `attemptCount`,并在 `P1rKnowledgeEventsPublishEndToEndTest` 增加 stale claim terminal update 被忽略的 focused E2E。修复后验证:`MuseKnowledgeEventPublishWorkerTest` 7/7 pass,Knowledge focused tests 更新为 14/14 pass,P1R mixed executable gates 更新为 37/37 pass。Flyway `_test` blocker 也已通过禁用 JVM 代理参数关闭为 3/3 pass。由于 claim ownership 与 Flyway IT 断言均在 review 后修改,下一步仍需要重新派发 fresh implementation spec review + quality/feasibility review;仍不得推进 completed。
|
||||
|
||||
P1R-7c 修复后 fresh review 第一轮:Kuhn correctness/spec review PASS,Herschel data integrity review PASS;均明确 PASS 只代表 `needs_verification` evidence 可收口,不代表 Events/P1R-7/Knowledge/Market completed。Herschel 提出两个 P2 非阻塞建议:其一,`singleLiteralCheck` 只做 substring,未来可能误接受 `event_type='notification' OR ...`;其二,worker 对终态 update 返回 0 缺少 stale claim 诊断日志。已按 `receiving-code-review` 验证为有效且局部可修,并按 TDD 处理:新增 `P1rKnowledgeEventsPublishFlywayMigrationIT.should_rejectBroadSingleLiteralConstraintExpression` 先 RED,随后将 `singleLiteralCheck` 改为精确单表达式匹配;新增真实 PostgreSQL 负向 insert 约束探针,验证 event_type、notification_type、publish_status、target_owner_user_id check 真实拒绝非法行;新增 `MuseKnowledgeEventPublishWorkerTest.should_logWhenAcceptedTerminalUpdateIsSkippedByStaleClaim` 先 RED,随后 worker 对 `markPublished/markRetryable/markDeadLetter` 返回 0 记录包含 tenantId、targetOwnerUserId、outboxId、sourceEventId、attempt、targetStatus、errorCode 的 warning。RED/GREEN 事实:两个新增测试均先按预期失败;修复后 Flyway 纯函数测试 1/1 pass,worker stale-claim 日志测试 1/1 pass。一次并行 Maven 验证因两个命令同时触碰 Knowledge `target`,出现源码/target class 瞬时不一致导致 testCompile 找不到已存在类;串行重跑 worker 测试通过,故该失败判定为并发构建污染,不作为代码缺陷。由于 P2 修复包含生产 worker 日志行为变更与 Flyway IT 测试增强,Kuhn/Herschel 双 PASS 不能作为最后一笔 review;下一步必须重跑 focused/mixed/Flyway/coverage/protected diff,并重新派发窄口 fresh implementation review。仍不得修改 OpenAPI/scanner/coverage report,不得推进 completed。
|
||||
|
||||
P1R-7c P2 增强后最终收口:controller 已重跑 full reactor `mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true`,62/62 modules success;随后串行重跑 Knowledge focused tests 15/15 pass、P1R mixed executable gates 37/37 pass、`P1rKnowledgeEventsPublishFlywayMigrationIT` 4/4 pass,V18 输出 `flyway_success=true`、`migrations_executed=18`、`target_schema_version=18`、`v18_table=muse_knowledge_event_publish_outbox`;coverage scanner 仅在 `/tmp/p1r7c-final-coverage-scan.2HXo1Z` 隔离副本运行 exit 0,summary 保持 completed=100、needsVerification=133、incomplete=0、genericPersistence=0、ssePlaceholder=0,Events `streamEvents=dedicated/needs_verification`,Market 32 个 operation 均为 `dedicated/needs_verification`;真实 worktree 受保护 OpenAPI/scanner/coverage report diff 为空。P2 后 fresh review 已双 PASS:Rawls correctness/spec review PASS,Boole data-integrity/testing review PASS;两者均明确该结论只代表 Knowledge source owner propagation evidence 收口到 `needs_verification`,不代表 Events/P1R-7/Knowledge/Market completed。下一步可按逻辑分组准备提交;提交、push 或任何 completed 推进仍需用户明确批准。
|
||||
|
||||
@ -0,0 +1,373 @@
|
||||
# P1R7c Knowledge Source Owner Propagation 审阅版
|
||||
|
||||
## 结论
|
||||
|
||||
推荐进入 P1R-7c,但只进入 Knowledge source owner propagation 的第二 owner 切片,不进入 Events / P1R-7 / Knowledge / Market completed approval。
|
||||
|
||||
推荐方案:
|
||||
|
||||
1. P1R-7c 只选择 `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` 这一条 canonical source status / projection summary 链路,使用 Events 现有 `notification` event type。
|
||||
2. 在 Knowledge owner 内新增独立 publish outbox / worker 语义,outbox 按受影响 `muse_knowledge_source_binding_projection.ownerUserId` fan-out,worker 只依赖 `muse-module-events-api` 调用 `EventsPublishApi`。
|
||||
3. 不复用 `muse_knowledge_projection_task` 作为 Events publish 队列;它当前是内部投影任务读模型,且在现有链路里被同步写为 `completed`。
|
||||
4. 本轮不覆盖 `MuseKnowledgeAccessPolicyService`、`MuseKnowledgeBaseService`、`MuseKnowledgeDocumentService` 中直接构造 `MuseKnowledgeSourceEventDO` 的入口;这些入口缺少统一 `eventType` / affected count 合同,必须另起后续切片或先做标准化入口设计。
|
||||
5. `muse_knowledge_source_event.ownerUserId=0` 只代表全局来源事实,不能直接作为 SSE 可见 owner;Events publish 请求的 `ownerUserId` 必须来自受影响 projection owner。
|
||||
6. 不发布知识资料原文、RAGFlow 原始返回、GraphRAG 细节、授权快照或 provider/error raw detail。
|
||||
7. P1R-7c 完成后最多推进 Knowledge propagation evidence 到 `needs_verification` 证据层,不把 Events、P1R-7、Knowledge 或 Market 标为 `completed`。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Source["triggerGlobalKBSourceEvent<br/>canonical source status event"] --> Projection["Knowledge projection update<br/>内部同步投影"]
|
||||
Projection --> Fanout["Affected binding projections<br/>ownerUserId fan-out"]
|
||||
Source --> Fanout
|
||||
Fanout --> Outbox["Knowledge Events publish outbox<br/>per owner queued/running/retryable/dead_letter"]
|
||||
Outbox --> Worker["Knowledge publish worker<br/>claim + retry + dead_letter"]
|
||||
Worker --> Api["EventsPublishApi<br/>muse-module-events-api only"]
|
||||
Api --> Unified[("muse_unified_event<br/>notification / accepted")]
|
||||
Unified --> SSE["/app-api/muse/events<br/>SSE 可见"]
|
||||
```
|
||||
|
||||
本审阅版只做方案审阅,不实现代码,不修改 Java / SQL / OpenAPI / scanner / coverage,不提交,不 push。
|
||||
|
||||
## 已验证事实
|
||||
|
||||
### 工作区与提交状态
|
||||
|
||||
- 正确 worktree:`/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`。
|
||||
- 当前分支:`dev/1.0.0`。
|
||||
- `git push origin dev/1.0.0` 已完成,远端从 `fa29753` 推进到 `230152c`。
|
||||
- 当前最近提交:
|
||||
- `230152c test(p1r): 收口 AI 事件传播真实链路门禁`
|
||||
- `3db5fbe feat(p1r): 接入 AI 事件传播真实链路`
|
||||
- `fa29753 test(p1r): 收口 Events SSE 门禁`
|
||||
|
||||
### Coverage 与阶段边界
|
||||
|
||||
- 当前 coverage summary:
|
||||
- `totalOperations = 233`
|
||||
- `completedOperations = 100`
|
||||
- `needsVerificationOperations = 133`
|
||||
- `incompleteOperations = 0`
|
||||
- `genericPersistenceOperations = 0`
|
||||
- `ssePlaceholderOperations = 0`
|
||||
- Knowledge 59 operations 当前为 `dedicated / completed`。
|
||||
- Events 当前唯一 operation `streamEvents GET /app-api/muse/events` 为 `dedicated / needs_verification`,target stage 仍是 `P1R-7 End-to-End Acceptance`。
|
||||
- Market 32 operations 当前仍为 `dedicated / needs_verification`。
|
||||
- P1R-7 completed approval 必须另起任务,不能由 P1R-7b 或 P1R-7c 自动推出。
|
||||
|
||||
### 全局 P1R7 Source Owner Propagation 约束
|
||||
|
||||
- 全局审阅版把 P1R-7 后续拆分为:
|
||||
- P1R-7b:AI terminal event 第一 owner 切片。
|
||||
- P1R-7c:Knowledge source status / projection event propagation。
|
||||
- P1R-7d:Market lifecycle / account projection / governance event propagation。
|
||||
- P1R-7e:Account(Member) security / entitlement / usage notification propagation。
|
||||
- P1R-7f:Content canonical change / block saved / export task event propagation。
|
||||
- 全局执行版要求每个 owner 保持 source owner 自治:事实归 owner,发布补偿归 owner,统一可见投影归 Events。
|
||||
- 全局执行版要求 source owner server 可以依赖 `muse-module-events-api`,不得依赖 `muse-module-events-server`;Events server 不得反向依赖 source owner server。
|
||||
- 全局执行版要求每个 owner 子计划必须包含 outbox 合同、payload allowlist、dependency gate、migration gate、focused tests 和 fresh spec / quality review gate。
|
||||
|
||||
### Events owner 当前合同
|
||||
|
||||
- `EventsPublishApi` 位于 `muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApi.java`。
|
||||
- `EventsPublishReqDTO` 必填字段包括 `commandId`、`tenantId`、`ownerUserId`、`sourceOwner`、`sourceType`、`sourceId`、`eventType`、`payloadSummary`、`emittedAt`。
|
||||
- `EventsPublishRespDTO` 返回 `eventId`、`sequenceNo`、`publishStatus`、`publishErrorCode`、`duplicate`。
|
||||
- `EventsPublishServiceImpl` 现有 OpenAPI payload schema 校验只接受 `chunk`、`quality_check`、`done`、`error`、`notification`。
|
||||
- `notification` payload 要求 `type` 是已声明类型,且 `message` 是字符串。
|
||||
- Events OpenAPI 与 `EventsPublishServiceImpl` 当前已声明 `notification.type` 包括 `source_status_change`、`knowledge_projection_done`、`governance_action`、`quota_alert`。
|
||||
- Events publish 当前对 payload 含敏感信息、schema mismatch 或未知 event type 的服务路径会写 `rejected` 审计记录;`blocked` 是既有 publish status 与 SSE 过滤兼容口径,执行版如覆盖 blocked 必须引用实际产生路径或作为兼容状态处理。
|
||||
- Events publish 支持 commandId 优先、source tuple 次级幂等;source owner 重放同一 source fact 会返回同一 `eventId/sequenceNo`。
|
||||
|
||||
### Knowledge owner 当前能力
|
||||
|
||||
- Knowledge server 当前 POM 直接依赖:
|
||||
- `muse-module-knowledge-api`
|
||||
- `muse-module-infra-api`
|
||||
- `muse-module-content-server`
|
||||
- web / security / tenant / mybatis / validation / test starter
|
||||
- Knowledge server 当前没有 `muse-module-events-api` 依赖。
|
||||
- `muse_knowledge_source_event` 已存在,字段包括 `source_event_id`、`source_owner`、`source_type`、`source_id`、`source_revision`、`owner_user_id`、`kb_id`、`status`、`action_policy`、`command_id`、`event_summary`、`error_code`、`error_message`、`tenant_id`。
|
||||
- `muse_knowledge_source_event` 约束 status 只允许 `active`、`stale`、`revoked`、`recalled`、`delisted`、`blocked`、`owner_missing`、`unauthorized`。
|
||||
- `muse_knowledge_source_event` 约束 action policy 只允许 `allowed`、`read_only`、`blocked`、`needs_recheck`。
|
||||
- `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` 当前在一个事务中:
|
||||
1. 校验 global KB。
|
||||
2. 预占 Knowledge command。
|
||||
3. 写入 `muse_knowledge_source_event`。
|
||||
4. 同步更新 `muse_knowledge_source_binding_projection`。
|
||||
5. 写入 `muse_knowledge_projection_task`。
|
||||
6. 记录 command completed。
|
||||
- `MuseKnowledgeSourceEventService.createProjectionTask` 当前明确把 projection task 写为 `completed`,代码注释写明 projection 已在当前事务同步更新,避免制造无执行者的 queued task。
|
||||
- `muse_knowledge_projection_task` 有 `status`、`attempt_no`、`retry_count`、`retryable`、`next_retry_at`、`result_summary`、`error_code`、`error_message`、`started_at`、`finished_at` 字段和 retry 索引,但当前没有 claim/retry/dead-letter worker。
|
||||
- `MuseKnowledgeProjectionTaskMapper` 只提供 `selectByTaskId` 和按 status 分页查询,没有 `claimNext...` / `FOR UPDATE SKIP LOCKED` / 状态转换 SQL。
|
||||
- `MuseKnowledgeProjectionTaskService` 当前只是 projection task 查询服务,不是执行 worker。
|
||||
- `MuseKnowledgeSourceEventServiceTest` 已覆盖 source event 写入、binding projection 同步更新、projection task `completed` 记录和非法事件不写事实。
|
||||
- `MuseKnowledgeSourceEventService` 当前把 `eventType` 写入 `eventSummary` / projection summary JSON,把 `affectedTargets` / `affectedProjectionCount` 写入 projection task summary JSON;这些不是 `MuseKnowledgeSourceEventDO` 顶层字段。
|
||||
- `muse_knowledge_source_binding_projection` 当前有 `owner_user_id`、`work_id`、`kb_id`、`binding_id`、`status`、`action_policy`、`last_event_id` 和 `projection_summary` 字段,并有 `(tenant_id, owner_user_id, source_owner, source_type, source_id, kb_id)` 唯一约束。
|
||||
- Events SSE visible query 只返回同租户、同 `ownerUserId`、`publishStatus=accepted`、未删除、已到 `visibleFrom` 且 sequence 大于 cursor 的事件。
|
||||
- `triggerGlobalKBSourceEvent` 当前写入 `MuseKnowledgeSourceEventDO.ownerUserId=0`;该值代表 global KB 来源事实 owner,不代表普通用户 SSE 可见 owner。
|
||||
|
||||
### 其他 Knowledge source event 入口
|
||||
|
||||
- `MuseKnowledgeAccessPolicyService` 会写 `MuseKnowledgeSourceEventDO`,用于 policy publish 后 source event 记录;该入口当前写 `sourceType=access_policy`、`status=active`、`actionPolicy=allowed`,event summary 只有 `policyId` 和 `kbId`,没有标准化 `eventType`。
|
||||
- `MuseKnowledgeBaseService` 会写 `MuseKnowledgeSourceEventDO`,用于 KB 状态、版本和 user KB block 相关 source event;其中 `enableGlobalKnowledgeBase` 的通用 `changeGlobalStatus` 当前只更新 KB 状态并记录 command completed,不写 source event。
|
||||
- `MuseKnowledgeDocumentService` 会写 `MuseKnowledgeSourceEventDO`,用于 document deletion 相关 source event;该入口 event summary 写入 `affectedBindings`,但没有标准化 `eventType`。
|
||||
- 这些入口当前只是写 Knowledge 本域 source event,没有写 Events publish outbox。
|
||||
|
||||
## 推断
|
||||
|
||||
- Knowledge 适合作为 P1R-7c 第二 owner,因为 canonical `triggerGlobalKBSourceEvent` 链路已有 source event、source binding projection、ownerUserId、kbId、sourceRevision、eventSummary 和 command 幂等基础。
|
||||
- Knowledge 不适合直接复用 `muse_knowledge_projection_task` 作为 Events publish queue,因为现有 projection task 是内部投影完成记录,不是可领取的补偿队列。
|
||||
- P1R-7c 要证明 `/app-api/muse/events` 对用户可见,必须把 global source event fan-out 到受影响 projection owner;如果只按 source event 顶层 `ownerUserId=0` 发布,普通登录用户不会看到事件。
|
||||
- P1R-7c 最小用户可见事件应是 `notification`,而不是 `done/error/chunk/quality_check`:
|
||||
- Knowledge source status 变化不是 AI task terminal result。
|
||||
- Knowledge 不能发布知识原文 chunk。
|
||||
- 当前 Events OpenAPI 没有专用 knowledge event type。
|
||||
- 第一轮 P1R-7c 应优先覆盖 `triggerGlobalKBSourceEvent` 已显式接受并规范化的 source status 事件;其他 service 直接构造 source event 的入口需要先补标准化事件合同,不能混入本轮。
|
||||
- 如果 P1R-7c 直接发布 RAGFlow parse / retrieval / GraphRAG 细节,会把外部 provider runtime 状态混进用户可见 Events,不符合全局 payload 合同。
|
||||
|
||||
## 假设
|
||||
|
||||
- P1R-7c 允许在 Knowledge owner 内新增独立 Events publish outbox 表,建议命名为 `muse_knowledge_event_publish_outbox` 或等价本域 publish outbox。
|
||||
- P1R-7c 允许 `muse-module-knowledge-server` 新增 `muse-module-events-api` direct dependency。
|
||||
- P1R-7c 只复用 Events 现有 OpenAPI `notification` schema,不修改 `docs/api-contracts/events/openapi.yaml`。
|
||||
- Knowledge notification 的 `type` 使用 Events 当前已声明的 `source_status_change`。
|
||||
|
||||
## 目标
|
||||
|
||||
- 证明 Knowledge owner 能把 canonical source status / projection summary 安全传播到统一 Events 投影。
|
||||
- 建立链路:`triggerGlobalKBSourceEvent` -> `muse_knowledge_source_event` + affected binding projections -> per-owner Knowledge publish outbox -> Knowledge worker -> `EventsPublishApi` -> `muse_unified_event` -> `/app-api/muse/events` SSE 可见。
|
||||
- 明确 Knowledge 内部 source/projection 事实与用户可见 notification 的边界。
|
||||
- 为后续 P1R-7d Market propagation 提供第二个 owner 的 outbox/worker 复用经验,但不抽取共享库。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不实现代码;本文档只做人类审阅版设计。
|
||||
- 不修改任何 OpenAPI 合同。
|
||||
- 不修改 coverage scanner 或 coverage JSON/Markdown。
|
||||
- 不把 Knowledge 59 operations 从 `completed` 改成其他状态,也不把 Events / P1R-7 / Market 推进到 `completed`。
|
||||
- 不发布 Knowledge 文档原文、chunk 内容、RAGFlow 原始响应、GraphRAG trace、授权快照或 provider 凭据。
|
||||
- 不复用 `muse_knowledge_projection_task` 作为 Events publish worker 队列。
|
||||
- 不把 `MuseKnowledgeSourceEventDO.ownerUserId=0` 直接当作 app SSE 可见 owner。
|
||||
- 不让 Events server 依赖 Knowledge server。
|
||||
- 不让统一 SSE Controller 查询 Knowledge 业务表。
|
||||
- 不把 `MuseKnowledgeAccessPolicyService`、`MuseKnowledgeBaseService`、`MuseKnowledgeDocumentService` 的直接 source event 写入入口纳入 P1R-7c 第一批。
|
||||
- 不同时改造 Market / Account / Content propagation。
|
||||
- 不把 P1R-7c 写成总 P1R-7 End-to-End Acceptance。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
采用“Knowledge source event notification + 本域 publish outbox”的最小方案。
|
||||
|
||||
### 事件选择
|
||||
|
||||
第一批允许进入 Events 的 Knowledge source event 只来自 `triggerGlobalKBSourceEvent` canonical 入口:
|
||||
|
||||
| Knowledge event | 入口 | source status | action policy | Events eventType | notification type | 用户可见 message 原则 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `version_changed` | `triggerGlobalKBSourceEvent` | `stale` | `needs_recheck` | `notification` | `source_status_change` | 知识库版本变化,需要重新检查相关使用方 |
|
||||
| `policy_changed` | `triggerGlobalKBSourceEvent` | `stale` | `needs_recheck` | `notification` | `source_status_change` | 知识库访问策略变化,需要重新确认使用影响 |
|
||||
| `kb_disabled` | `triggerGlobalKBSourceEvent` | `blocked` | `blocked` | `notification` | `source_status_change` | 知识库已禁用,相关能力不可用 |
|
||||
| `kb_enabled` | `triggerGlobalKBSourceEvent` | `active` | `allowed` | `notification` | `source_status_change` | 知识库已恢复可用 |
|
||||
| `status_changed` | `triggerGlobalKBSourceEvent` | 按 action policy 归一化 | 按请求或默认值归一化 | `notification` | `source_status_change` | 知识库状态已变化 |
|
||||
|
||||
不进入第一批:
|
||||
|
||||
- `MuseKnowledgeAccessPolicyService` 直接写入的 access policy source event。
|
||||
- `MuseKnowledgeBaseService` 直接写入的 KB status / version / user KB block source event。
|
||||
- `MuseKnowledgeDocumentService` 直接写入的 document deletion source event。
|
||||
- `document_deleted` 只有在后续切片先补标准化入口后才能纳入;不能把现有 document service 入口直接当作 P1R-7c 已覆盖路径。
|
||||
- RAGFlow parse polling 内部状态。
|
||||
- retrieval / GraphRAG runtime call 细节。
|
||||
- projection rebuild heartbeat。
|
||||
- Knowledge draft conflict / decision 内部状态。
|
||||
- 文档正文或 chunk 内容。
|
||||
|
||||
### Outbox 边界
|
||||
|
||||
新增 Knowledge 本域 publish outbox,而不是扩展 `muse_knowledge_projection_task`。Outbox 粒度是 per affected owner,不是 per source event:
|
||||
|
||||
| 维度 | `muse_knowledge_projection_task` | P1R-7c publish outbox |
|
||||
|---|---|---|
|
||||
| 当前职责 | 内部投影任务读模型 | 对外 Events 发布补偿队列 |
|
||||
| 当前写入 | source event 事务内同步写 `completed` | source event 事务内按合法 affected owner fan-out 写 `queued`;非法 owner 不写 outbox |
|
||||
| 当前 worker | 无 | 必须新增 claim/retry/dead-letter worker |
|
||||
| 可见性 | 管理端任务查询 | SSE 用户可见 notification |
|
||||
| payload | projection result summary | Events OpenAPI allowlist payload |
|
||||
|
||||
### 可见性 fan-out 合同
|
||||
|
||||
P1R-7c 选择“per affected owner notification”,不选择 admin/system-only notification:
|
||||
|
||||
- `MuseKnowledgeSourceEventDO.ownerUserId=0` 只保留为 global source fact owner。
|
||||
- Outbox 创建必须在 `triggerGlobalKBSourceEvent` 同一事务内,基于更新后的 `muse_knowledge_source_binding_projection` 查询受影响 projection。
|
||||
- 受影响 owner 集合来自 projection 的 `ownerUserId`,必须去重,且必须排除 `null` 与 `0`。
|
||||
- 每个受影响 owner 生成一条 outbox;`EventsPublishReqDTO.ownerUserId` 使用该 projection owner。
|
||||
- 空受影响 owner 集合不是发布失败:source event 与 projection task 仍保持成功,publish outbox 不写 `queued`,只在 source event / projection task summary 中保留 `affectedTargets=0` 证据。
|
||||
- 如果 projection 行存在但 `ownerUserId` 缺失或非法,P1R-7c 第一批保持 outbox DDL 的 `target_owner_user_id > 0` 严格约束:该行只记录 fan-out 审计摘要和安全日志,不创建 outbox,不发布给 owner `0`,不要求写入 `dead_letter` outbox 行。
|
||||
- Outbox 不跨 owner 合并;不同 owner 的 publish failure/retry/dead_letter 独立推进。
|
||||
|
||||
P1R-7c 执行版必须补 mapper 能力,能按 `lastEventId` 或本次 source event 关联条件读取受影响 projection owner;不能用全表 scan 或从 request payload 推导 owner。
|
||||
|
||||
### 依赖方向
|
||||
|
||||
P1R-7c 允许:
|
||||
|
||||
- `muse-module-knowledge-server` 直接依赖 `muse-module-events-api`。
|
||||
- `muse-server` 同时装配 Knowledge server 和 Events server。
|
||||
|
||||
P1R-7c 禁止:
|
||||
|
||||
- `muse-module-knowledge-server` 依赖 `muse-module-events-server`。
|
||||
- `muse-module-events-server` 依赖 `muse-module-knowledge-server`。
|
||||
- Events stream controller 查询 Knowledge 表。
|
||||
|
||||
### Payload 合同
|
||||
|
||||
Knowledge outbox service 必须从 `MuseKnowledgeSourceEventDO` 顶层字段和 canonical summary allowlist 重建 payload:
|
||||
|
||||
- 允许进入 internal outbox 的 DO 顶层字段:
|
||||
- `kbId`
|
||||
- `sourceEventId`
|
||||
- `sourceType`
|
||||
- `sourceId`
|
||||
- `sourceRevision`
|
||||
- `status`
|
||||
- `actionPolicy`
|
||||
- `commandId`
|
||||
- `tenantId`
|
||||
- `ownerUserId` 仅作为 source fact owner 记录,不作为 Events publish owner
|
||||
- 允许进入 internal outbox 的 projection fan-out 字段:
|
||||
- `targetOwnerUserId`
|
||||
- `targetWorkId`
|
||||
- `bindingId`
|
||||
- `projectionId`
|
||||
- 允许从 canonical `eventSummary` JSON 读取的字段:
|
||||
- `eventType`
|
||||
- `targetVersion`
|
||||
- `sourceStatus`
|
||||
- `actionPolicy`
|
||||
- 允许从 canonical projection task summary JSON 读取的字段:
|
||||
- `affectedTargets`
|
||||
- `affectedProjectionCount`
|
||||
- 允许进入 Events `notification` payload 的字段按 Events OpenAPI 当前 schema 收敛,至少必须有:
|
||||
- `type`
|
||||
- `message`
|
||||
- 默认不写入 `resourceRef`;如执行版确需使用,只允许写用户已可见资源引用,禁止把内部 provider id、raw source id、授权快照 id 或不可见 KB 内部标识透出到 SSE。
|
||||
- 禁止进入 Events payload:
|
||||
- 文档原文。
|
||||
- chunk 内容。
|
||||
- RAGFlow API key、token、Authorization。
|
||||
- RAGFlow raw request/response。
|
||||
- GraphRAG trace。
|
||||
- authorization snapshot 原文。
|
||||
- admin reason 原文如果包含用户输入或敏感上下文;需要归一化为安全 message。
|
||||
|
||||
### 状态策略
|
||||
|
||||
- outbox 创建时:
|
||||
- payload 可映射到现有 `notification` schema 且 `targetOwnerUserId` 合法 -> `queued`。
|
||||
- payload 缺少必需字段或无法映射 -> `dead_letter`,记录安全错误码。
|
||||
- 没有受影响 owner -> 不创建 `queued` outbox,不视为失败。
|
||||
- worker claim:
|
||||
- 使用 `FOR UPDATE SKIP LOCKED` 或等价原子领取。
|
||||
- claim 阶段递增 attempt count。
|
||||
- claim lease 过期可被重新领取。
|
||||
- publish success:
|
||||
- Events `accepted` -> outbox `published`,回写 `published_event_id` / `published_sequence_no`。
|
||||
- publish rejected / blocked:
|
||||
- outbox `dead_letter`,记录 `publishErrorCode`。
|
||||
- 验证 SSE visible query 不返回 rejected / blocked。
|
||||
- temporary failure:
|
||||
- 未耗尽重试 -> `retryable` + `next_retry_at`。
|
||||
- 耗尽重试 -> `dead_letter`。
|
||||
|
||||
### Events 幂等键
|
||||
|
||||
为避免 per-owner fan-out 被 V16 `muse_unified_event` 的 command/source tuple 唯一约束相互撞车,P1R-7c 执行版必须固定以下规则:
|
||||
|
||||
- outbox `commandId` 必须包含原 Knowledge commandId、`sourceEventId`、`targetOwnerUserId` 和 notification type。
|
||||
- Events `sourceOwner = knowledge`。
|
||||
- Events `sourceType` 必须使用 per-owner fan-out source type,例如 `knowledge_source_status_owner`。
|
||||
- Events `sourceId` 必须包含 `sourceEventId` 与 `targetOwnerUserId`,不能只用 global KB id。
|
||||
- Events `sourceRevision` 使用 source event 的 `sourceRevision`,为空时用 Events 现有 `__none__` 归一化。
|
||||
- Events `eventType = notification`。
|
||||
- 重放同一 source event + 同一 target owner 必须返回同一 `eventId/sequenceNo`;不同 target owner 必须生成不同 unified event。
|
||||
|
||||
## 关键取舍
|
||||
|
||||
- 选择 Knowledge,不选择 Market:全局计划已把 Knowledge 定位为 P1R-7c,且 Knowledge 已有 source event/projection 事实;Market 仍需先拆清 Account projection outbox 与 Events publish outbox 职责。
|
||||
- 选择 `notification`,不新增 event type:当前 OpenAPI 已声明 `notification`,能表达用户可见状态变化;新增事件类型会触发合同变更,不是 P1R-7c 最小路径。
|
||||
- 选择 canonical `triggerGlobalKBSourceEvent`,不直接覆盖所有 source event 写入入口:该入口有显式 `eventType`、归一化 status/action policy、projection update 和 affected count;其他入口当前缺少统一事件合同,贸然纳入会制造伪 E2E。
|
||||
- 选择 per affected owner fan-out,不选择 owner `0` 系统事件:P1R-7c 的证据目标是 app SSE 用户可见,Events visible query 按登录用户过滤;系统 owner `0` 会绕开主目标。
|
||||
- 新增 publish outbox,不复用 projection task:projection task 当前同步完成,没有 claim worker;复用会把内部投影任务和用户可见发布补偿混在一起。
|
||||
- 只发布摘要,不发布资料内容:Knowledge 事件最容易泄露资料正文、检索内容和 RAGFlow 细节,必须用 allowlist 重建 payload。
|
||||
- 不抽共享库:AI 与 Knowledge 可复制全局 outbox 合同,但不在第二 owner 就抽象 shared publisher,避免过早长期抽象。
|
||||
|
||||
## 影响范围
|
||||
|
||||
后续执行版预计涉及:
|
||||
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/pom.xml`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeSourceEventService.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapper.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeProjectionTaskService.java` 只作为读取 affected count 的参考,不改造成 publish worker。
|
||||
- 新增 Knowledge publish outbox service / worker / DO / Mapper。
|
||||
- 新增 V18 或后续 Knowledge publish outbox migration。
|
||||
- 新增 Knowledge outbox service/worker/mapper tests。
|
||||
- 新增 P1R-7c dependency / migration / E2E gate tests。
|
||||
- 更新 `docs/agent-specs/.agent` 与 `docs/memorys/` 留痕。
|
||||
|
||||
本审阅版不做上述实现。
|
||||
|
||||
## 风险与兼容性
|
||||
|
||||
- notification type 风险:执行版必须固定使用已声明的 `source_status_change`,不得为 P1R-7c 偷偷新增 OpenAPI type。
|
||||
- 入口覆盖风险:本轮只覆盖 canonical `triggerGlobalKBSourceEvent`;AccessPolicy/Base/Document 直接 source event 写入入口必须被 dependency/E2E gate 明确排除,后续若纳入必须先做标准化入口合同。
|
||||
- owner 可见性风险:source event owner `0` 不能直接进入 Events publish;执行版必须证明 outbox 按 projection owner fan-out,且 owner `0` 不会被普通用户 SSE 误用。
|
||||
- payload 泄露风险:Knowledge event summary 可能包含 reason、kbName 或外部细节;执行版必须定义 allowlist 和敏感词/结构过滤。
|
||||
- 语义混淆风险:`muse_knowledge_projection_task.status=completed` 是业务任务状态,不等于 coverage completed,也不等于 Events publish 成功。
|
||||
- 幂等风险:Knowledge source event 的 `source_event_id` 当前随机 UUID;Events source tuple 应使用稳定 `source_event_id` 或稳定 source revision,否则 replay 语义可能依赖 commandId 而非 source fact。
|
||||
- 依赖风险:Knowledge 当前依赖 content server;P1R-7c dependency gate 不能误禁既有 Knowledge -> Content 依赖,但必须禁止 Knowledge -> Events server 和 Events server -> Knowledge server。
|
||||
- 完成口径风险:Knowledge operations 已是 `completed`,P1R-7c 不能把这一事实外推成 Events / P1R-7 completed。
|
||||
|
||||
## 验收标准
|
||||
|
||||
审阅版通过标准:
|
||||
|
||||
- 明确 P1R-7c 只做 Knowledge source owner propagation。
|
||||
- 明确 P1R-7c 第一批只覆盖 `triggerGlobalKBSourceEvent` canonical 入口,并排除其他直接 source event 写入入口。
|
||||
- 明确 P1R-7c 使用 affected projection owner fan-out,不把 source event owner `0` 当作 SSE visible owner。
|
||||
- 明确不复用 projection task 作为 Events publish queue。
|
||||
- 明确 notification payload 只使用 OpenAPI 已声明 schema。
|
||||
- 明确不发布知识资料原文、RAGFlow raw、GraphRAG trace 或授权快照。
|
||||
- 明确 Events / P1R-7 / Market / Knowledge completed 边界。
|
||||
|
||||
后续执行版必须包含:
|
||||
|
||||
1. live `git status --short --branch` 和受保护文件 diff 检查。
|
||||
2. Events OpenAPI notification type 枚举复核,并固定使用已声明的 `source_status_change`。
|
||||
3. 第一批入口矩阵:仅 `triggerGlobalKBSourceEvent`,并列出 `version_changed` / `policy_changed` / `kb_disabled` / `kb_enabled` / `status_changed` 的 source status、action policy、summary allowlist 和 notification mapping。
|
||||
4. Knowledge source event -> affected projection owner fan-out -> outbox 同事务写入策略。
|
||||
5. Knowledge outbox migration 设计,唯一键必须支持同一 source event 发布给多个 owner。
|
||||
6. Knowledge outbox mapper/service/worker 设计。
|
||||
7. dependency gate:Knowledge server 有 events-api、无 events-server;Events server 无 Knowledge server。
|
||||
8. E2E gate:`triggerGlobalKBSourceEvent` -> affected owner outbox -> worker -> Events publish -> unified event -> 对应 owner SSE visible。
|
||||
9. owner 可见性 gate:owner `0` 不可作为 app SSE 可见 owner;跨 owner 不可见。
|
||||
10. 排除 gate:AccessPolicy/Base/Document 直接 source event 写入入口不被本轮 E2E 伪覆盖。
|
||||
11. rejected / blocked / duplicate / retry / dead_letter 测试;blocked 若无实际生产路径,只作为兼容状态不可见验证。
|
||||
12. Flyway `_test` 验证。
|
||||
13. 隔离副本 coverage scanner 验证,真实 worktree protected diff 为空。
|
||||
|
||||
## 待确认项
|
||||
|
||||
1. P1R-7c 是否确认只覆盖 `triggerGlobalKBSourceEvent` canonical source status / projection notification,不覆盖 RAGFlow runtime progress。
|
||||
2. P1R-7c 是否确认排除 AccessPolicy/Base/Document 直接 source event 写入入口,后续另起标准化入口切片。
|
||||
3. P1R-7c 是否确认采用 per affected owner fan-out,而不是 owner `0` admin/system-only notification。
|
||||
4. P1R-7c 第一批事件是否按 `version_changed`、`policy_changed`、`kb_disabled`、`kb_enabled`、`status_changed` 收口。
|
||||
5. 是否允许新增 `muse_knowledge_event_publish_outbox` 表作为 V18 或后续 migration。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 对本文档执行 fresh spec compliance review。
|
||||
2. 对本文档执行 fresh quality / feasibility review。
|
||||
3. 双 PASS 后,编写 `docs/agent-specs/2026-06-06-P1R7cKnowledgeSourceOwnerPropagation执行版.md`。
|
||||
4. 执行版再经过 fresh spec / quality 双 review 后,才能进入实现。
|
||||
@ -0,0 +1,591 @@
|
||||
# P1R7c Knowledge Source Owner Propagation 执行版
|
||||
|
||||
## 结论
|
||||
|
||||
P1R-7c 只执行 Knowledge source owner propagation 的第二 owner 切片:把 `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` 产生的 canonical Knowledge source status event,按受影响 projection owner fan-out 到 Knowledge 本域 Events publish outbox,再由 Knowledge worker 调用 `EventsPublishApi` 写入 `muse_unified_event`,最终通过 `/app-api/muse/events` 对对应 owner SSE 可见。
|
||||
|
||||
本执行版不允许把 Events / P1R-7 / Knowledge / Market 推进到 `completed`,不允许修改 OpenAPI、coverage scanner 或 coverage report,不允许把 `MuseKnowledgeSourceEventDO.ownerUserId=0` 直接作为 SSE 可见 owner。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Request["Admin canonical request<br/>triggerGlobalKBSourceEvent"] --> Fact["muse_knowledge_source_event<br/>ownerUserId=0 global source fact"]
|
||||
Fact --> Projection["muse_knowledge_source_binding_projection<br/>lastEventId + status/actionPolicy"]
|
||||
Projection --> Fanout["select fan-out targets<br/>ownerUserId > 0, dedup per owner"]
|
||||
Fanout --> Outbox["muse_knowledge_event_publish_outbox<br/>one row per target owner"]
|
||||
Outbox --> Worker["Knowledge publish worker<br/>claim/retry/dead_letter"]
|
||||
Worker --> Api["EventsPublishApi<br/>events-api only"]
|
||||
Api --> Unified["muse_unified_event<br/>notification/source_status_change"]
|
||||
Unified --> Stream["/app-api/muse/events<br/>owner visible SSE"]
|
||||
```
|
||||
|
||||
## 边界
|
||||
|
||||
### 本轮目标
|
||||
|
||||
- 在 Knowledge owner 内新增 source status notification publish outbox。
|
||||
- 只接入 `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` canonical 入口。
|
||||
- 在 source event 同一事务内按受影响 binding projection owner 建立 per-owner outbox。
|
||||
- 新增 Knowledge publish worker,唯一跨 owner 调用点是 `EventsPublishApi`。
|
||||
- 用 focused tests、P1R gate、Flyway `_test`、dependency gate 和隔离 coverage scanner 证明链路进入 `needs_verification` 证据层。
|
||||
|
||||
### 本轮非目标
|
||||
|
||||
- 不覆盖 `MuseKnowledgeAccessPolicyService`、`MuseKnowledgeBaseService`、`MuseKnowledgeDocumentService` 的直接 source event 写入入口。
|
||||
- 不覆盖 `document_deleted`,直到后续切片先统一 document source event 合同。
|
||||
- 不发布知识库正文、文档 chunk、RAGFlow raw request/response、GraphRAG trace、授权快照、provider 凭据或用户不可见内部资源 id。
|
||||
- 不复用 `muse_knowledge_projection_task` 作为 publish queue。
|
||||
- 不抽取跨 AI / Knowledge 的共享 outbox 框架。
|
||||
- 不修改 `docs/api-contracts/*/openapi.yaml`。
|
||||
- 不修改 `muse-cloud/scripts/p1r-audit-api-coverage.py`。
|
||||
- 不修改 `docs/superpowers/reports/p1r-api-coverage.json` 或 `.md`。
|
||||
|
||||
### 受保护文件
|
||||
|
||||
实现代理不得修改以下文件:
|
||||
|
||||
- `docs/api-contracts/market/openapi.yaml`
|
||||
- `docs/api-contracts/ai/openapi.yaml`
|
||||
- `docs/api-contracts/knowledge/openapi.yaml`
|
||||
- `docs/api-contracts/events/openapi.yaml`
|
||||
- `muse-cloud/scripts/p1r-audit-api-coverage.py`
|
||||
- `docs/superpowers/reports/p1r-api-coverage.json`
|
||||
- `docs/superpowers/reports/p1r-api-coverage.md`
|
||||
|
||||
## 已验证事实
|
||||
|
||||
- 正确 worktree 是 `/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`。
|
||||
- 当前分支是 `dev/1.0.0`,当前远端跟踪是 `origin/dev/1.0.0`。
|
||||
- 当前 HEAD 为 `230152c test(p1r): 收口 AI 事件传播真实链路门禁`。
|
||||
- P1R-7b 已提交并 push:`3db5fbe` 与 `230152c`。
|
||||
- P1R-7b Task 6 fresh review:Huygens spec PASS,Boyle quality PASS。
|
||||
- P1R-7b Task 7 fresh review:Mencius spec PASS,Hume quality PASS。
|
||||
- Turing 返回 `completed:null` 的旧 review 已丢弃并关闭,不计入 review gate。
|
||||
- Reactor build 在 P1R-7b final gate 中为 47/47 modules success。
|
||||
- AI/Events focused tests 在 P1R-7b final gate 中为 87 tests pass。
|
||||
- P1R mixed gates 在 P1R-7b final gate 中为 37 tests pass。
|
||||
- `P1rAiEventsPublishFlywayMigrationIT` 在 `_test` 数据库中 4 tests pass,V17 验证成功。
|
||||
- Dependency gate 已证明 AI server 有 events-api、无 events-server;Events server 无 AI/Knowledge/Market/Member/Content server。
|
||||
- Coverage scanner 只在 `/tmp/p1r7b-final-coverage-scan.AoKcDl` 隔离副本运行,exit 0。
|
||||
- P1R-7b final gate 后真实 worktree 受保护文件 diff 为空。
|
||||
- 当前 coverage 边界为 `completedOperations=100`、`needsVerificationOperations=133`、`incompleteOperations=0`、`genericPersistenceOperations=0`、`ssePlaceholderOperations=0`。
|
||||
- Events `streamEvents` 当前是 `dedicated / needs_verification`。
|
||||
- Market 32 operations 当前是 `dedicated / needs_verification`。
|
||||
- 当前最新 migration 是 `muse-cloud/sql/muse/V17__extend_ai_events_publish_outbox.sql`,P1R-7c 下一 migration 编号应为 `V18`;若实现前 live 文件列表已出现更高版本,必须停止并修订本执行版的 migration 编号。
|
||||
- `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` 当前在同一事务内写 source event、批量更新 binding projection、写 completed projection task、记录 command completed。
|
||||
- `MuseKnowledgeSourceEventService` 当前 `GLOBAL_OWNER_USER_ID = 0L`,source event owner 0 代表 global source fact owner。
|
||||
- `MuseKnowledgeSourceBindingProjectionDO` 当前包含 `ownerUserId`、`workId`、`kbId`、`bindingId`、`projectionId`、`lastEventId`、`status`、`actionPolicy`、`projectionSummary`。
|
||||
- `MuseKnowledgeSourceBindingProjectionMapper.updateStatusByKbId` 当前会设置 `lastEventId`、`status`、`actionPolicy`,并可合并 projection summary JSONB patch。
|
||||
- `UnifiedEventMapper.selectVisibleEventsForOwner` 当前按 `tenantId`、`ownerUserId`、`publishStatus=accepted`、`deleted=false`、`visibleFrom<=now`、`sequenceNo>afterSequenceNo` 查询 SSE 可见事件。
|
||||
- `EventsPublishServiceImpl` 当前已声明 `notification` event type。
|
||||
- `EventsPublishServiceImpl` 当前已声明 `source_status_change` notification type。
|
||||
- `EventsPublishReqDTO` 必填 `commandId`、`tenantId`、`ownerUserId`、`sourceOwner`、`sourceType`、`sourceId`、`eventType`、`payloadSummary`、`emittedAt`。
|
||||
- `EventsPublishServiceImpl` 当前先按 `commandId` 幂等回放,再按 source tuple 幂等回放。
|
||||
- `muse-module-knowledge-server/pom.xml` 当前没有 `muse-module-events-api` direct dependency。
|
||||
- `muse-module-events-server` 当前不依赖 Knowledge server。
|
||||
- P1R-7c 审阅版第三轮 review 已双 PASS:Parfit spec PASS,Aquinas quality PASS。
|
||||
|
||||
## 推断
|
||||
|
||||
- P1R-7c 可以复用 P1R-7b AI outbox 的状态机思路、SQL claim 模式和 dependency gate 风格,但不应抽象共享 outbox 框架,因为 Knowledge fan-out 粒度、payload 合同和 source fact owner 与 AI 不同。
|
||||
- 按 `lastEventId` 查询本次受影响 projection,是最小且可审计的 fan-out 来源;它来自本事务刚刚更新的 projection 行,不需要从 request payload 推导 owner。
|
||||
- 如果直接把 source event owner 0 发布给 Events,普通用户 SSE 查询不会命中,因此 owner fan-out 是 P1R-7c 的必要条件,不是增强项。
|
||||
- Knowledge source status notification 使用 Events 现有 `notification/source_status_change`,可避免 OpenAPI 合同变更。
|
||||
|
||||
## 假设
|
||||
|
||||
- P1R-7c 允许 Knowledge server 新增 `muse-module-events-api` direct dependency。
|
||||
- P1R-7c 允许新增 Knowledge 本域表 `muse_knowledge_event_publish_outbox`。
|
||||
- P1R-7c 实现前 `muse-cloud/sql/muse` 最新 migration 仍是 V17;若不是,必须按 live 编号调整并重新审阅。
|
||||
- P1R-7c 第一批只发布用户可见的状态摘要 message,不发布 `resourceType/resourceId`。
|
||||
|
||||
## 设计合同
|
||||
|
||||
### 事件合同
|
||||
|
||||
第一批只允许以下 canonical event type 进入 publish outbox:
|
||||
|
||||
| Knowledge eventType | source status | action policy | Events eventType | notification type | message |
|
||||
|---|---|---|---|---|---|
|
||||
| `version_changed` | `stale` | `needs_recheck` | `notification` | `source_status_change` | `知识库版本变化,需要重新检查相关使用方` |
|
||||
| `policy_changed` | `stale` | `needs_recheck` | `notification` | `source_status_change` | `知识库访问策略变化,需要重新确认使用影响` |
|
||||
| `kb_disabled` | `blocked` | `blocked` | `notification` | `source_status_change` | `知识库已禁用,相关能力不可用` |
|
||||
| `kb_enabled` | `active` | `allowed` | `notification` | `source_status_change` | `知识库已恢复可用` |
|
||||
| `status_changed` | 按现有方法归一化 | 按现有方法归一化 | `notification` | `source_status_change` | `知识库状态已变化` |
|
||||
|
||||
`document_deleted` 不进入 P1R-7c 第一批。实现必须在 outbox 创建服务中二次校验 event type allowlist,即使上游 `triggerGlobalKBSourceEvent` 当前允许 `document_deleted`,也不能为其创建 queued outbox。
|
||||
|
||||
### 可见性 fan-out 合同
|
||||
|
||||
- Source event 顶层 `ownerUserId=0` 只作为 global source fact owner 进入 internal outbox 审计字段。
|
||||
- Events publish 请求的 `ownerUserId` 必须来自 `MuseKnowledgeSourceBindingProjectionDO.ownerUserId`。
|
||||
- Fan-out target 查询必须限定:
|
||||
- 当前 tenant。
|
||||
- 当前 `kbId`。
|
||||
- 当前 source event `lastEventId`。
|
||||
- `deleted=false`。
|
||||
- `ownerUserId > 0`。
|
||||
- Fan-out 需要按 `ownerUserId` 去重;同一 owner 多条 projection 只生成一条 notification outbox。
|
||||
- Outbox payload 可记录该 owner 的一个 representative `workId/bindingId/projectionId` 和该 owner 的 affected projection count;Events payload 仍只发 `type/message`。
|
||||
- 空 fan-out target 是合法结果:source event、projection update、projection task 保持成功,不写 queued outbox。
|
||||
- 非法 owner 行不得回落到 owner 0,不得发布给 admin/system owner。
|
||||
- P1R-7c 保持 V18 DDL `target_owner_user_id > 0` 严格约束;`ownerUserId` 为 `null` 或 `0` 的 projection 行只记录安全日志和 owner fan-out 审计摘要,不创建 outbox。实现测试必须断言“不发布给 owner 0 且无 queued outbox”,不能要求为非法 owner 写入 dead_letter 行。
|
||||
- Fan-out 审计摘要必须可测试,不能只依赖控制台日志。新增 mapper 能力必须同时返回 valid owner count、invalid owner count、total projection count;摘要落点为 `muse_knowledge_projection_task.result_summary` 的 `ownerFanoutSummary` 字段。
|
||||
|
||||
### 幂等合同
|
||||
|
||||
- 每个 target owner 的 outbox 独立幂等。
|
||||
- `commandId` 格式:`kn_evt:` + SHA-256(`tenantId|sourceCommandId|sourceEventId|targetOwnerUserId|source_status_change`) 前 32 位 hex。
|
||||
- `outboxId` 格式:`kn_out:` + SHA-256(`tenantId|sourceEventId|targetOwnerUserId|source_status_change`) 前 32 位 hex。
|
||||
- Events source tuple:
|
||||
- `sourceOwner = knowledge`
|
||||
- `sourceType = knowledge_source_status_owner`
|
||||
- `sourceId = sourceEventId + ":owner:" + targetOwnerUserId`
|
||||
- `sourceRevision = sourceEvent.sourceRevision`,为空时用 Events 现有 `__none__`
|
||||
- `eventType = notification`
|
||||
- 同一 source event + 同一 owner 重放必须得到同一 Events `eventId/sequenceNo`。
|
||||
- 同一 source event + 不同 owner 必须生成不同 `commandId` 和不同 source tuple,不能被 V16 unique constraint 合并。
|
||||
|
||||
### Payload 合同
|
||||
|
||||
Internal outbox 允许保存以下字段:
|
||||
|
||||
- `sourceEventId`
|
||||
- `sourceFactOwnerUserId`
|
||||
- `targetOwnerUserId`
|
||||
- `kbId`
|
||||
- `sourceType`
|
||||
- `sourceId`
|
||||
- `sourceRevision`
|
||||
- `sourceStatus`
|
||||
- `actionPolicy`
|
||||
- `knowledgeEventType`
|
||||
- `sourceCommandId`
|
||||
- `targetWorkId`
|
||||
- `bindingId`
|
||||
- `projectionId`
|
||||
- `affectedOwnerProjectionCount`
|
||||
|
||||
Events `payloadSummary` 只能发布:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "source_status_change",
|
||||
"message": "知识库状态已变化"
|
||||
}
|
||||
```
|
||||
|
||||
实现不得把以下字段放入 Events payload:
|
||||
|
||||
- `reason`
|
||||
- `kbName`
|
||||
- 文档正文
|
||||
- chunk 内容
|
||||
- RAGFlow request/response
|
||||
- GraphRAG trace
|
||||
- authorization snapshot
|
||||
- source snapshot
|
||||
- provider id
|
||||
- API key、token、Authorization、Bearer、secret、password
|
||||
|
||||
### 状态机合同
|
||||
|
||||
Outbox 本域状态只允许:
|
||||
|
||||
- `queued`
|
||||
- `running`
|
||||
- `retryable`
|
||||
- `published`
|
||||
- `dead_letter`
|
||||
|
||||
状态流转:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> queued
|
||||
[*] --> dead_letter: valid owner but invalid payload
|
||||
queued --> running: claim
|
||||
retryable --> running: retry due
|
||||
running --> running: expired lease reclaim
|
||||
running --> published: Events accepted
|
||||
running --> dead_letter: Events rejected/blocked or non-retryable invalid state
|
||||
running --> retryable: transient failure and attempts remain
|
||||
running --> dead_letter: retry exhausted
|
||||
```
|
||||
|
||||
Worker claim 要求:
|
||||
|
||||
- 使用 PostgreSQL `FOR UPDATE SKIP LOCKED` 或等价 `UPDATE ... RETURNING` 原子领取。
|
||||
- 调度线程无请求租户上下文时必须先忽略租户拦截器领取,再使用 outbox 行内 `tenantId` 恢复租户上下文发布。
|
||||
- Claim 阶段唯一递增 `attemptCount`。
|
||||
- 临时失败只释放 claim、设置 `nextRetryAt` 和安全错误摘要,不二次递增 attempt count。
|
||||
- `dead_letter` 是终态,不自动重放。
|
||||
- `ownerUserId` 为 `null` 或 `0` 的 projection 行不进入状态机;该类行必须在 fan-out 阶段被过滤并记录安全日志,不能通过 outbox DDL 旁路写入 `dead_letter`。
|
||||
|
||||
### 依赖合同
|
||||
|
||||
允许:
|
||||
|
||||
- `muse-module-knowledge-server` 依赖 `muse-module-events-api`。
|
||||
- `muse-server` 同时装配 Knowledge server 与 Events server。
|
||||
|
||||
禁止:
|
||||
|
||||
- `muse-module-knowledge-server` 依赖 `muse-module-events-server`。
|
||||
- `muse-module-events-server` 依赖 `muse-module-knowledge-server`。
|
||||
- Events server 依赖 AI / Knowledge / Market / Member / Content server。
|
||||
- Events stream controller 查询 Knowledge 业务表。
|
||||
|
||||
## 允许修改的路径
|
||||
|
||||
实现阶段允许修改以下路径:
|
||||
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/pom.xml`
|
||||
- `muse-cloud/sql/muse/V18__extend_knowledge_events_publish_outbox.sql`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/dataobject/muse/MuseKnowledgeEventPublishOutboxDO.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeEventPublishOutboxMapper.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxService.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxServiceImpl.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishWorker.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapper.java`
|
||||
- `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeSourceEventService.java`
|
||||
- Focused tests under `muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge`
|
||||
- P1R gate tests under `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api`
|
||||
- `docs/agent-specs/.agent`
|
||||
- `docs/memorys/YYYY-MM-DD-P1R7cKnowledgeSourceOwnerPropagation真实链路.md` only after implementation evidence is available
|
||||
|
||||
## 数据库迁移要求
|
||||
|
||||
### V18 表
|
||||
|
||||
新增 `muse_knowledge_event_publish_outbox`:
|
||||
|
||||
- `id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY`
|
||||
- `tenant_id BIGINT NOT NULL`
|
||||
- `outbox_id VARCHAR(128) NOT NULL`
|
||||
- `source_event_id VARCHAR(128) NOT NULL`
|
||||
- `source_fact_owner_user_id BIGINT NOT NULL`
|
||||
- `target_owner_user_id BIGINT NOT NULL`
|
||||
- `kb_id BIGINT NOT NULL`
|
||||
- `source_type VARCHAR(64) NOT NULL`
|
||||
- `source_id VARCHAR(128) NOT NULL`
|
||||
- `source_revision VARCHAR(80) NOT NULL`
|
||||
- `source_status VARCHAR(32) NOT NULL`
|
||||
- `action_policy VARCHAR(32) NOT NULL`
|
||||
- `knowledge_event_type VARCHAR(64) NOT NULL`
|
||||
- `event_type VARCHAR(32) NOT NULL`
|
||||
- `notification_type VARCHAR(64) NOT NULL`
|
||||
- `target_work_id BIGINT`
|
||||
- `binding_id BIGINT`
|
||||
- `projection_id VARCHAR(128)`
|
||||
- `affected_owner_projection_count INT NOT NULL DEFAULT 0`
|
||||
- `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`
|
||||
|
||||
### V18 约束
|
||||
|
||||
- `uk_muse_knowledge_event_publish_outbox_id UNIQUE (tenant_id, outbox_id)`
|
||||
- `uk_muse_knowledge_event_publish_outbox_command UNIQUE (tenant_id, command_id)`
|
||||
- `uk_muse_knowledge_event_publish_outbox_owner_source UNIQUE (tenant_id, source_event_id, target_owner_user_id, notification_type)`
|
||||
- `chk_muse_knowledge_event_publish_outbox_event_type CHECK (event_type IN ('notification'))`
|
||||
- `chk_muse_knowledge_event_publish_outbox_notification_type CHECK (notification_type IN ('source_status_change'))`
|
||||
- `chk_muse_knowledge_event_publish_outbox_status CHECK (publish_status IN ('queued','running','published','retryable','dead_letter'))`
|
||||
- `chk_muse_knowledge_event_publish_outbox_attempt_count CHECK (attempt_count >= 0)`
|
||||
- `chk_muse_knowledge_event_publish_outbox_max_attempt CHECK (max_attempt > 0)`
|
||||
- `chk_muse_knowledge_event_publish_outbox_target_owner CHECK (target_owner_user_id > 0)`
|
||||
- `chk_muse_knowledge_event_publish_outbox_source_fact_owner CHECK (source_fact_owner_user_id >= 0)`
|
||||
- `chk_muse_knowledge_event_publish_outbox_affected_count CHECK (affected_owner_projection_count >= 0)`
|
||||
|
||||
### V18 索引与触发器
|
||||
|
||||
- `idx_muse_knowledge_event_publish_outbox_claim` on `(publish_status, claim_expires_at, next_retry_at, create_time, id)` where `deleted=false`
|
||||
- `idx_muse_knowledge_event_publish_outbox_owner_status` on `(tenant_id, target_owner_user_id, publish_status, create_time)`
|
||||
- `idx_muse_knowledge_event_publish_outbox_source_event` on `(tenant_id, source_event_id, publish_status)`
|
||||
- `idx_muse_knowledge_source_projection_last_event` on `muse_knowledge_source_binding_projection(tenant_id, kb_id, last_event_id)` where `last_event_id IS NOT NULL`
|
||||
- `trg_muse_knowledge_event_publish_outbox_update_time` before update, execute `update_updated_at_column()`
|
||||
|
||||
V18 不新增数据库外键。source event 与 projection 的引用完整性由 Knowledge 应用层在同一事务内保证,避免跨切片外键阻塞后续演进。
|
||||
|
||||
## 实施任务
|
||||
|
||||
### Task 0:preflight
|
||||
|
||||
- [ ] 进入正确 worktree:`cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`
|
||||
- [ ] 运行 `git status --short --branch`
|
||||
- [ ] 运行 `git log --oneline -5`
|
||||
- [ ] 运行 `git pull --ff-only origin dev/1.0.0`
|
||||
- [ ] 确认受保护文件 diff 为空。
|
||||
- [ ] 确认 `muse-cloud/sql/muse` 最新 migration 仍为 V17;若不是,停止并修订本执行版。
|
||||
|
||||
### Task 1:dependency gate
|
||||
|
||||
- [ ] 在 Knowledge server POM 新增 `muse-module-events-api` direct dependency。
|
||||
- [ ] 不新增 `muse-module-events-server` 依赖。
|
||||
- [ ] 新增 `P1rKnowledgeEventsPublishDependencyTest`:
|
||||
- [ ] 证明 Knowledge server 有 `muse-module-events-api`。
|
||||
- [ ] 证明 Knowledge server 无 `muse-module-events-server`。
|
||||
- [ ] 证明 Events server 无 AI / Knowledge / Market / Member / Content server。
|
||||
- [ ] XML 解析关闭外部实体。
|
||||
|
||||
### Task 2:V18 migration 与 mapper
|
||||
|
||||
- [ ] 新增 `V18__extend_knowledge_events_publish_outbox.sql`。
|
||||
- [ ] 新增 `MuseKnowledgeEventPublishOutboxDO`,继承 `TenantBaseDO`,JSONB 字段使用 Knowledge 现有 `JsonbStringTypeHandler`。
|
||||
- [ ] 新增 `MuseKnowledgeEventPublishOutboxMapper`:
|
||||
- [ ] `insertIgnore`
|
||||
- [ ] `selectByOutboxId`
|
||||
- [ ] `selectByCommandId`
|
||||
- [ ] `selectByOwnerSourceTuple`
|
||||
- [ ] `claimNextPublishOutbox`
|
||||
- [ ] `markPublished`
|
||||
- [ ] `markRetryable`
|
||||
- [ ] `markDeadLetter`
|
||||
- [ ] 在 `MuseKnowledgeSourceBindingProjectionMapper` 新增 `selectFanoutTargetsByLastEventId(Long kbId, String lastEventId)`。
|
||||
- [ ] `selectFanoutTargetsByLastEventId` 必须过滤 `deleted=false`、`ownerUserId>0`,并按 `ownerUserId`、`bindingId`、`projectionId` 稳定排序。
|
||||
- [ ] 在 `MuseKnowledgeSourceBindingProjectionMapper` 新增 `selectFanoutOwnerAuditSummaryByLastEventId(Long kbId, String lastEventId)` 或等价 mapper 方法,返回当前 source event 下 total projection count、valid owner projection count、invalid owner projection count、distinct target owner count。
|
||||
- [ ] `selectFanoutOwnerAuditSummaryByLastEventId` 必须包含 `ownerUserId IS NULL OR ownerUserId <= 0` 的计数,供 service 写入 projection task summary 与测试断言。
|
||||
- [ ] 新增 `P1rKnowledgeEventsPublishMigrationSqlTest` 覆盖表、约束、索引、trigger、敏感字段、重复对象、BaseDO/TenantBaseDO 字段口径。
|
||||
- [ ] 新增 mapper focused tests 覆盖 `insertIgnore` 幂等、claim lease、retryable due、running expired reclaim、mark 状态转换。
|
||||
|
||||
### Task 3:同事务 fan-out outbox 创建
|
||||
|
||||
- [ ] 新增 `MuseKnowledgeEventPublishOutboxService`。
|
||||
- [ ] 新增 `MuseKnowledgeEventPublishOutboxServiceImpl`。
|
||||
- [ ] 在 `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` 写入 source event、更新 projection 后,在同一事务内调用 outbox service 生成 fan-out outbox 与 `ownerFanoutSummary`,再创建 projection task,最后记录 command completed。
|
||||
- [ ] Outbox service 只创建 outbox,不调用 `EventsPublishApi`。
|
||||
- [ ] Outbox service 对 event type 做本地 allowlist,拒绝为 `document_deleted` 创建 queued outbox。
|
||||
- [ ] Outbox service 通过 `selectFanoutTargetsByLastEventId(kbId, sourceEventId)` 获取 targets。
|
||||
- [ ] Outbox service 通过 `selectFanoutOwnerAuditSummaryByLastEventId(kbId, sourceEventId)` 获取 fan-out 审计摘要。
|
||||
- [ ] Outbox service 对 owner 去重,并记录每个 owner 的 affected projection count。
|
||||
- [ ] 空 target 不写 outbox,不抛异常。
|
||||
- [ ] Outbox service 写入 outbox 前必须把空 `sourceRevision` 归一化为 Events 现有 `__none__`,保证 V18 `source_revision NOT NULL` 与 Events source tuple 口径一致。
|
||||
- [ ] `ownerUserId` 为 `null` 或 `0` 的 projection 行只记录安全日志和 owner fan-out 审计摘要,不创建 outbox,不写 owner 0,不写 dead_letter。
|
||||
- [ ] Outbox service 必须返回结构化 `ownerFanoutSummary` 给 `MuseKnowledgeSourceEventService`,不能只写日志;该摘要用于 projection task summary 和 focused test 断言。
|
||||
- [ ] `MuseKnowledgeSourceEventService.createProjectionTask` 的 `projectionTaskSummary` 必须增加 `ownerFanoutSummary`,至少包含 `totalProjectionCount`、`validOwnerProjectionCount`、`invalidOwnerProjectionCount`、`distinctTargetOwnerCount`、`queuedOutboxCount`。
|
||||
- [ ] 重复 source event + owner 使用 `insertIgnore`,重复即按幂等成功处理。
|
||||
- [ ] 所有核心分支添加中文日志,日志不得包含 `reason`、kbName、文档内容、raw provider detail 或敏感 token。
|
||||
- [ ] 补充 `MuseKnowledgeSourceEventServiceTest`,证明 source event / projection / projection task / outbox 同事务创建。
|
||||
- [ ] 补充 service focused tests,证明 invalid owner 不发布给 owner 0、无 queued outbox 且有安全日志/审计摘要,空 target 不写 queued outbox,`document_deleted` 不写 queued outbox。
|
||||
|
||||
### Task 4:Knowledge publish worker
|
||||
|
||||
- [ ] 新增 `MuseKnowledgeEventPublishWorker`。
|
||||
- [ ] 新增 Knowledge events publish worker 配置对象,配置键为 `muse.knowledge.events.publish-worker.enabled`,默认 `false`;未启用时 `dispatchOnce()` 必须直接返回 0 且不得 claim outbox。
|
||||
- [ ] Scheduled 入口使用 `muse.knowledge.events.publish-worker.initial-delay-ms` 与 `muse.knowledge.events.publish-worker.fixed-delay-ms`,默认各为 1000ms。
|
||||
- [ ] Worker 使用 fixed claim lease 和 fixed retry backoff,先沿用 P1R-7b 的 60 秒策略。
|
||||
- [ ] Worker 领取 outbox 后恢复 tenant context。
|
||||
- [ ] Worker 重新校验 payload allowlist,只构建 Events payload `{type,message}`。
|
||||
- [ ] Worker 构建 `EventsPublishReqDTO`:
|
||||
- [ ] `tenantId = outbox.tenantId`
|
||||
- [ ] `ownerUserId = outbox.targetOwnerUserId`
|
||||
- [ ] `sourceOwner = knowledge`
|
||||
- [ ] `sourceType = knowledge_source_status_owner`
|
||||
- [ ] `sourceId = outbox.sourceEventId + ":owner:" + outbox.targetOwnerUserId`
|
||||
- [ ] `sourceRevision = outbox.sourceRevision`
|
||||
- [ ] `eventType = notification`
|
||||
- [ ] `payloadSummary = {"type":"source_status_change","message": mappedMessage}`
|
||||
- [ ] `emittedAt = outbox.createTime`,为空时 fail closed 为 `dead_letter`
|
||||
- [ ] Worker 不设置 `resourceType/resourceId`。
|
||||
- [ ] `EventsPublishApi` 返回 `accepted` 时 mark `published`,保存 `eventId/sequenceNo`。
|
||||
- [ ] `EventsPublishApi` 返回 `rejected` 或 `blocked` 时 mark `dead_letter`。
|
||||
- [ ] `CommonResult` error、runtime exception 按 transient failure 进入 `retryable`,达到 max attempt 进入 `dead_letter`。
|
||||
- [ ] 未知 publish status、payload 解析失败、source tuple 构建失败进入 `dead_letter`。
|
||||
- [ ] 补充 worker focused tests 覆盖 disabled 不 claim、enabled 才 claim、accepted/rejected/blocked/retryable/retry exhausted/duplicate replay。
|
||||
|
||||
### Task 5:P1R E2E 与 Flyway gate
|
||||
|
||||
- [ ] 新增 `P1rKnowledgeEventsPublishEndToEndTest`。
|
||||
- [ ] E2E harness 必须模拟或装配:
|
||||
- [ ] Knowledge source event canonical entry。
|
||||
- [ ] binding projection fan-out 到两个 owner。
|
||||
- [ ] Knowledge worker 调用真实 `EventsPublishServiceImpl` 或等价 in-memory mapper harness。
|
||||
- [ ] `listVisibleEvents` 对 target owner 可见。
|
||||
- [ ] owner 0 不可见。
|
||||
- [ ] 非受影响 owner 不可见。
|
||||
- [ ] 同一 source event + same owner replay 返回同一 event identity。
|
||||
- [ ] 同一 source event + different owners 生成不同 unified event。
|
||||
- [ ] rejected publish 进入 outbox `dead_letter` 且 SSE invisible。
|
||||
- [ ] 新增 `P1rKnowledgeEventsPublishFlywayMigrationIT`:
|
||||
- [ ] 只允许 `_test` 数据库。
|
||||
- [ ] password 只允许从环境变量读取。
|
||||
- [ ] JDBC URL query credentials 必须拒绝。
|
||||
- [ ] target version 为 V18。
|
||||
- [ ] clean 后成功 migration count 为 18。
|
||||
- [ ] 验证 V18 table、indexes、constraints、trigger。
|
||||
- [ ] P1R mixed gate 继续运行 coverage、Events gate 与 AI gate,但不得修改 coverage report 文件。
|
||||
|
||||
### Task 6:最终验证与留痕
|
||||
|
||||
- [ ] 先运行 reactor build,再运行 focused tests 和 P1R gates,避免 `clean` 清理 surefire XML 后丢失 test count 证据。
|
||||
- [ ] coverage scanner 只能在 `/tmp` 隔离副本运行。
|
||||
- [ ] 真实 worktree 受保护文件 diff 必须为空。
|
||||
- [ ] 若双 review 与验证均通过,新增 `docs/memorys/2026-06-06-P1R7cKnowledgeSourceOwnerPropagation真实链路.md`。
|
||||
- [ ] 更新 `docs/agent-specs/.agent`,记录 reviewer ID、PASS/FAIL、命令、测试计数、protected diff、coverage 边界。
|
||||
- [ ] 不提交、不 push,除非用户明确批准。
|
||||
|
||||
## 验证命令
|
||||
|
||||
以下命令由实现代理按阶段运行;执行版编写阶段不运行实现测试。
|
||||
|
||||
### Preflight
|
||||
|
||||
```bash
|
||||
cd /Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0
|
||||
git status --short --branch
|
||||
git log --oneline -5
|
||||
git pull --ff-only origin dev/1.0.0
|
||||
git diff --name-only -- \
|
||||
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
|
||||
```
|
||||
|
||||
### Focused Knowledge tests
|
||||
|
||||
```bash
|
||||
cd muse-cloud
|
||||
mvn -o test \
|
||||
-pl muse-module-knowledge/muse-module-knowledge-server \
|
||||
-Dtest=MuseKnowledgeSourceEventServiceTest,MuseKnowledgeEventPublishOutboxServiceTest,MuseKnowledgeEventPublishOutboxMapperTest,MuseKnowledgeEventPublishWorkerTest
|
||||
```
|
||||
|
||||
### P1R mixed gates
|
||||
|
||||
```bash
|
||||
cd muse-cloud
|
||||
mvn -o test \
|
||||
-pl muse-server -am \
|
||||
-Dtest=P1rKnowledgeEventsPublishMigrationSqlTest,P1rKnowledgeEventsPublishDependencyTest,P1rKnowledgeEventsPublishEndToEndTest,P1rApiCoverageReportTest,P1rEventsRealApiGateTest,P1rEventsRouteOwnershipTest,P1rAiRealApiGateTest,P1rAiRouteOwnershipTest
|
||||
```
|
||||
|
||||
`P1rKnowledgeFlywayMigrationIT` 是 P1R-5/V14 专用真实 Flyway gate,固定 target version V14;P1R-7c 不得把它混入 mixed gate。P1R-7c 的 V18 真实 Flyway 证据只来自本轮新增 `P1rKnowledgeEventsPublishFlywayMigrationIT`。
|
||||
|
||||
`P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT` 是 P1R-5 Knowledge/RAGFlow opt-in live acceptance,启用外部验收时会迁移到 V14 并外呼真实 RAGFlow;P1R-7c 不得把它作为 required PASS gate。如发布前需要额外回归,只能单列为 P1R-5 optional external regression,不计入 P1R-7c execution review 或最终收口门禁。
|
||||
|
||||
P1R-7b AI source-owner 回归在本轮 mixed gate 中保留 AI real API/route ownership gate;不重跑 `P1rAiEventsPublish*` 三个 gate,除非 P1R-7c 实现实际触碰 AI outbox/worker 或 Events publish 公共合同。
|
||||
|
||||
### Reactor build
|
||||
|
||||
```bash
|
||||
cd muse-cloud
|
||||
mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true -pl muse-server -am
|
||||
```
|
||||
|
||||
### Flyway `_test`
|
||||
|
||||
```bash
|
||||
cd muse-cloud
|
||||
P1R_FLYWAY_PASSWORD="$P1R_FLYWAY_PASSWORD" mvn -o test \
|
||||
-pl muse-server \
|
||||
-Dtest=P1rKnowledgeEventsPublishFlywayMigrationIT \
|
||||
-Dp1r.flyway.url="$P1R_FLYWAY_URL" \
|
||||
-Dp1r.flyway.user="$P1R_FLYWAY_USER" \
|
||||
-Dp1r.flyway.locations=filesystem:sql/muse \
|
||||
-Dflyway.postgresql.transactional.lock=false
|
||||
```
|
||||
|
||||
`P1R_FLYWAY_URL` 必须指向真实 PostgreSQL `_test` 数据库,例如 `muse_p1r7c_events_publish_test`。密码不得通过 JVM system property 传入。
|
||||
|
||||
### Dependency tree gate
|
||||
|
||||
```bash
|
||||
cd muse-cloud
|
||||
mvn -o dependency:tree -pl muse-module-knowledge/muse-module-knowledge-server -Dincludes=cn.iocoder.cloud:muse-module-events-api
|
||||
mvn -o dependency:tree -pl muse-module-knowledge/muse-module-knowledge-server -Dincludes=cn.iocoder.cloud:muse-module-events-server
|
||||
mvn -o dependency:tree -pl muse-module-events/muse-module-events-server -Dincludes=cn.iocoder.cloud:muse-module-ai-server,cn.iocoder.cloud:muse-module-knowledge-server,cn.iocoder.cloud:muse-module-market-server,cn.iocoder.cloud:muse-module-member-server,cn.iocoder.cloud:muse-module-content-server
|
||||
```
|
||||
|
||||
### Coverage scanner 隔离 gate
|
||||
|
||||
```bash
|
||||
tmpdir="$(mktemp -d /tmp/p1r7c-final-coverage-scan.XXXXXX)"
|
||||
rsync -a --delete \
|
||||
--exclude .git \
|
||||
--exclude muse-cloud/target \
|
||||
--exclude 'muse-cloud/**/target' \
|
||||
/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0/ "$tmpdir/"
|
||||
cd "$tmpdir"
|
||||
python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check
|
||||
```
|
||||
|
||||
隔离 scanner 结果必须保持:
|
||||
|
||||
- `incompleteOperations = 0`
|
||||
- `genericPersistenceOperations = 0`
|
||||
- `ssePlaceholderOperations = 0`
|
||||
- Events `streamEvents = dedicated / needs_verification`
|
||||
- Market 32 operations 仍为 `dedicated / needs_verification`
|
||||
|
||||
## Fresh review gate
|
||||
|
||||
执行版写完后,必须先派发 fresh review,且只接受本执行版之后新开的 reviewer:
|
||||
|
||||
- fresh spec compliance review:检查是否符合 P1R-7c 审阅版第三轮双 PASS 方案、protected 文件边界、owner fan-out、Events visible query、V18 migration、dependency gate、payload allowlist。
|
||||
- fresh quality/feasibility review:检查实现复杂度、状态机、事务边界、幂等、retry/dead_letter、测试可行性、日志安全、回滚策略。
|
||||
|
||||
任一 reviewer FAIL 时:
|
||||
|
||||
- 不实现代码。
|
||||
- 先按 `superpowers:receiving-code-review` 验证反馈。
|
||||
- 有效反馈修执行版。
|
||||
- 重新派发 fresh review。
|
||||
|
||||
双 PASS 后才允许进入实现。
|
||||
|
||||
## 完成条件
|
||||
|
||||
P1R-7c 实现可进入阶段收口的最低条件:
|
||||
|
||||
- `MuseKnowledgeSourceEventService.triggerGlobalKBSourceEvent` 真实创建 source event、projection update、projection task、per-owner outbox。
|
||||
- Knowledge worker 真实调用 `EventsPublishApi`。
|
||||
- `muse_unified_event` 真实写入 `notification/source_status_change`。
|
||||
- `/app-api/muse/events` 对 target owner 可见,对 owner 0 和非 target owner 不可见。
|
||||
- Duplicate replay 对 same source event + same owner 返回同一 event identity。
|
||||
- Different owners 不被 command/source tuple 合并。
|
||||
- Invalid payload / rejected / blocked / retry exhausted 有 dead_letter 证据。
|
||||
- Focused tests、P1R mixed gates、Flyway `_test`、dependency tree、隔离 coverage scanner 均有命令与计数证据。
|
||||
- 真实 worktree 受保护文件 diff 为空。
|
||||
- `.agent` 与 `docs/memorys` 记录 reviewer、PASS/FAIL、测试计数、coverage 边界和 completed 禁止边界。
|
||||
|
||||
达到以上条件也只代表 Knowledge source owner propagation evidence 可推进到 `needs_verification`,不代表 Events / P1R-7 / Knowledge / Market completed。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
实现提交前回滚:
|
||||
|
||||
- 删除 V18 migration。
|
||||
- 删除新增 Knowledge outbox DO / Mapper / Service / Worker / tests。
|
||||
- 回退 Knowledge POM 的 `events-api` dependency。
|
||||
- 回退 `MuseKnowledgeSourceEventService` 和 `MuseKnowledgeSourceBindingProjectionMapper` 的 P1R-7c 改动。
|
||||
- 保留或更新 `.agent` 说明本轮未进入实现完成。
|
||||
|
||||
实现提交后但未部署回滚:
|
||||
|
||||
- 新提交 revert P1R-7c 代码与 migration。
|
||||
- 不修改 OpenAPI/scanner/coverage report。
|
||||
- 重新运行 dependency gate 与 protected diff。
|
||||
|
||||
已部署后回滚:
|
||||
|
||||
- 先通过 `muse.knowledge.events.publish-worker.enabled=false` 关闭 Knowledge publish worker。
|
||||
- 保留 outbox 表与 unified event 审计记录,不物理删除已发布事件。
|
||||
- 如需补偿,另起 migration 或运维脚本把未发布 outbox 标为 `dead_letter`,脚本必须按 tenant 与 owner 范围限定。
|
||||
- 重新审阅后再恢复 worker。
|
||||
@ -0,0 +1,209 @@
|
||||
# P1R7c Knowledge Source Owner Propagation 真实链路
|
||||
|
||||
日期:2026-06-06
|
||||
|
||||
## 结论
|
||||
|
||||
P1R-7c Knowledge source owner propagation 主体实现、V18 SQL、focused tests、P1R mixed executable gates、V18 Flyway `_test`、reactor build、隔离 coverage scanner 与 P2 增强后 fresh review 已推进到 `needs_verification` 证据层。
|
||||
|
||||
当前仍不能写 `completed`。Rawls correctness/spec review 与 Boole data-integrity/testing review 已双 PASS,但该 PASS 只代表 Knowledge source owner terminal event -> Knowledge outbox -> worker -> EventsPublishApi -> `muse_unified_event` -> SSE 可见链路 evidence 推进到 `needs_verification`,不代表 Events / P1R-7 / Knowledge / Market completed。
|
||||
|
||||
本轮未修改 OpenAPI、coverage scanner 或真实 coverage 报告。
|
||||
|
||||
## 已验证事实
|
||||
|
||||
### 工作区与保护文件
|
||||
|
||||
- 正确 worktree:`/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0`。
|
||||
- 分支:`dev/1.0.0`。
|
||||
- 受保护文件 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`
|
||||
|
||||
### 本轮实现范围
|
||||
|
||||
- Knowledge server 新增直接依赖 `muse-module-events-api`。
|
||||
- 新增 Knowledge 本域 publish outbox:
|
||||
- `MuseKnowledgeEventPublishOutboxDO`
|
||||
- `MuseKnowledgeEventPublishOutboxMapper`
|
||||
- `MuseKnowledgeEventPublishOutboxService`
|
||||
- `MuseKnowledgeEventPublishOutboxServiceImpl`
|
||||
- `MuseKnowledgeEventPublishWorker`
|
||||
- `MuseKnowledgeEventsProperties`
|
||||
- `MuseKnowledgeEventsConfiguration`
|
||||
- `MuseKnowledgeSourceEventService` 在 canonical `triggerGlobalKBSourceEvent` 链路内创建 per target owner outbox,并将 `ownerFanoutSummary` 写入 projection task summary。
|
||||
- `MuseKnowledgeSourceBindingProjectionMapper` 新增按 `kbId + lastEventId` fan-out target 查询与 owner audit summary 查询。
|
||||
- 新增 V18 migration:`muse-cloud/sql/muse/V18__extend_knowledge_events_publish_outbox.sql`。
|
||||
|
||||
### Focused tests
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-module-knowledge/muse-module-knowledge-server -Dtest=MuseKnowledgeEventPublishOutboxServiceTest,MuseKnowledgeEventPublishWorkerTest,MuseKnowledgeSourceEventServiceTest -Dsurefire.failIfNoSpecifiedTests=false
|
||||
```
|
||||
|
||||
结果:exit 0,15 tests pass,failures/errors/skipped 均为 0。
|
||||
|
||||
覆盖点:
|
||||
|
||||
- per distinct target owner 创建 outbox。
|
||||
- 不同 owner 的 `commandId/outboxId` 不相同。
|
||||
- `document_deleted` 不写 outbox。
|
||||
- invalid owner 只进入 audit summary,不进入 outbox。
|
||||
- worker disabled 不 claim。
|
||||
- accepted -> published。
|
||||
- rejected / blocked -> dead_letter。
|
||||
- API error -> retryable。
|
||||
- last attempt exception -> dead_letter。
|
||||
- terminal status update 必须携带本次 claim attempt ownership,避免过期 worker 覆盖新终态。
|
||||
- terminal status update 返回 0 时必须记录 stale claim 诊断日志,包含 tenantId、targetOwnerUserId、outboxId、sourceEventId、attempt、targetStatus、errorCode。
|
||||
- source event 集成写入 `ownerFanoutSummary`。
|
||||
|
||||
### P1R mixed executable gates
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-server -am -Dtest=P1rKnowledgeEventsPublishMigrationSqlTest,P1rKnowledgeEventsPublishDependencyTest,P1rKnowledgeEventsPublishEndToEndTest,P1rApiCoverageReportTest,P1rEventsRealApiGateTest,P1rEventsRouteOwnershipTest,P1rAiRealApiGateTest,P1rAiRouteOwnershipTest -Dsurefire.failIfNoSpecifiedTests=false
|
||||
```
|
||||
|
||||
结果:exit 0,37 tests pass,failures/errors/skipped 均为 0。
|
||||
|
||||
覆盖点:
|
||||
|
||||
- V18 SQL 静态 migration gate。
|
||||
- Knowledge server 依赖 Events API,不依赖 Events server。
|
||||
- Events server 不反向依赖 AI / Knowledge / Market / Member / Content server。
|
||||
- Knowledge outbox -> worker -> EventsPublishServiceImpl -> `muse_unified_event` focused E2E。
|
||||
- target owner 可见,owner 0 / 非 target owner 不可见。
|
||||
- 同一 source event 不同 owner 不被合并。
|
||||
- 同一 source event + owner replay 回放同一 event identity。
|
||||
- stale claim terminal update 因 attempt 不匹配被忽略。
|
||||
- blocked publish 进入 outbox dead_letter 且不可见。
|
||||
|
||||
### Flyway IT 当前状态
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeEventsPublishFlywayMigrationIT.java
|
||||
```
|
||||
|
||||
安全方法执行:
|
||||
|
||||
```bash
|
||||
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-server -am -Dtest='P1rKnowledgeEventsPublishFlywayMigrationIT#should_rejectNonTestDatabaseWhenQueryContainsSlashTestSuffix+should_rejectCredentialQueryParameters' -Dsurefire.failIfNoSpecifiedTests=false
|
||||
```
|
||||
|
||||
结果:exit 0,2 tests pass。
|
||||
|
||||
真实库准备:
|
||||
|
||||
- 测试库 `muse_p1r7c_events_publish_test` 已确认/创建。
|
||||
- `psql` 可连接目标库,输出判断为 `psql_target_db_connect=true`。
|
||||
|
||||
真实 Flyway `_test`:
|
||||
|
||||
```bash
|
||||
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o test -pl muse-server -am -Dtest=P1rKnowledgeEventsPublishFlywayMigrationIT -Dflyway.postgresql.transactional.lock=false -Dp1r.flyway.locations=filesystem:sql/muse -Dp1r.flyway.url=jdbc:postgresql://<host>:5433/muse_p1r7c_events_publish_test -Dp1r.flyway.user=<user> -Djava.net.useSystemProxies=false -DsocksProxyHost= -DsocksProxyPort= -Dhttp.proxyHost= -Dhttp.proxyPort= -Dhttps.proxyHost= -Dhttps.proxyPort= -Dsurefire.failIfNoSpecifiedTests=false
|
||||
```
|
||||
|
||||
结果:exit 0,4 tests pass,failures/errors/skipped 均为 0。P2 修复后新增真实负向 insert 约束探针已纳入全量 Flyway IT。
|
||||
|
||||
关键输出:
|
||||
|
||||
```text
|
||||
flyway_success=true
|
||||
migrations_executed=18
|
||||
successful_migration_count=18
|
||||
target_schema_version=18
|
||||
flyway_latest=18:extend knowledge events publish outbox
|
||||
v18_table=muse_knowledge_event_publish_outbox
|
||||
```
|
||||
|
||||
JDBC EOF 根因:
|
||||
|
||||
```text
|
||||
psql_target_db_connect=true
|
||||
socksProxyHost_initial=127.0.0.1
|
||||
jdbc_default=false ... cause=java.io.EOFException:null
|
||||
socksProxyHost_cleared=null
|
||||
jdbc_no_proxy=true db=muse_p1r7c_events_publish_test
|
||||
```
|
||||
|
||||
结论:V18 SQL 已通过真实 PostgreSQL / Flyway 验收;早前 EOF 是 Java 全局 SOCKS/HTTP/HTTPS 代理属性影响 PostgreSQL JDBC 连接链路,不是 V18 SQL 失败。
|
||||
|
||||
P2 增强:
|
||||
|
||||
- `singleLiteralCheck` 已从 substring 判断改为精确单表达式匹配,避免未来误接受 `event_type='notification' OR ...`。
|
||||
- `P1rKnowledgeEventsPublishFlywayMigrationIT` 新增真实 PostgreSQL 负向 insert 探针,验证 event_type、notification_type、publish_status、target_owner_user_id check 会拒绝非法行。
|
||||
- 纯函数 RED/GREEN:`should_rejectBroadSingleLiteralConstraintExpression` 先失败,修复后 1/1 pass。
|
||||
- Worker RED/GREEN:`should_logWhenAcceptedTerminalUpdateIsSkippedByStaleClaim` 先失败,修复后 1/1 pass。
|
||||
- 一次并行 Maven 验证因两个命令同时触碰 Knowledge `target`,出现 testCompile 找不到已存在类;串行重跑 worker 测试通过,判定为并发构建污染,不作为代码缺陷。
|
||||
|
||||
### Reactor build
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -o clean install -DskipTests -Dspring-boot.repackage.skip=true
|
||||
```
|
||||
|
||||
结果:exit 0,`BUILD SUCCESS`,62/62 modules success。
|
||||
|
||||
### Coverage scanner
|
||||
|
||||
scanner 只在隔离副本运行。
|
||||
|
||||
最终干净隔离副本:
|
||||
|
||||
```text
|
||||
/tmp/p1r7c-final-coverage-scan.2HXo1Z
|
||||
```
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
coverage_exit=0
|
||||
```
|
||||
|
||||
真实 worktree 受保护 coverage JSON/Markdown diff 仍为空。
|
||||
|
||||
## 推断
|
||||
|
||||
基于 focused tests、P1R mixed executable gates、V18 Flyway `_test`、reactor build、隔离 coverage scanner、protected diff 以及 Rawls/Boole fresh review,可以判断 P1R-7c Knowledge source owner propagation 的应用层链路已具备收口到 `needs_verification` 的证据。
|
||||
|
||||
由于本轮证据只覆盖 Knowledge source owner propagation 第一批 canonical source status / projection summary 链路,不能推断 Events / P1R-7 / Knowledge / Market 已完成,也不能推进 completed。
|
||||
|
||||
## 假设
|
||||
|
||||
- `~/.config/muse-repo/infra.env` 是当前项目允许用于本地 P1R `_test` 数据库准备的配置来源。
|
||||
- 后续提交将按实现与测试/文档分组,避免把状态推进、coverage 口径和真实实现混成一笔。
|
||||
|
||||
## 风险与未完成
|
||||
|
||||
- P2 增强后 focused / mixed / Flyway / reactor / coverage / protected diff 已重跑,fresh review 已重新派发并双 PASS:Rawls correctness/spec PASS,Boole data-integrity/testing PASS。
|
||||
- fresh implementation review 历史:Sagan correctness PASS,Dalton testing evidence PASS,Halley data integrity FAIL。
|
||||
- Halley 的 P1 已验证并修复:outbox claim 后终态回写原先缺少本次 claim 所有权保护,过期 worker 可能覆盖新 claim 终态;已先写 RED 测试 `should_guardTerminalStatusUpdatesWithClaimAttemptOwnership`,再把 `markPublished/markRetryable/markDeadLetter` 补为 `publish_status='running' AND attempt_count=#{claimedAttemptCount}`,并由 worker 传入 claim 返回的 `attemptCount`。
|
||||
- 修复后 fresh review 第一轮:Kuhn correctness/spec PASS,Herschel data integrity PASS。Herschel 的两个 P2 已被纳入本轮增强,并已由 Rawls/Boole P2 后 fresh review 复核。
|
||||
- Boole 非阻塞风险:V18 `ON CONFLICT DO NOTHING` 可能静默丢弃非法 duplicate source/owner 行,当前由 focused replay gate 覆盖第一批行为;worker 默认关闭,启用前仍需要运行配置与运维监控确认。
|
||||
- 不得修改 OpenAPI、scanner、coverage report 来绕过缺口。
|
||||
- 不得把 coverage `needs_verification` 改成 `completed`。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 提交前复核 `git status --short --branch` 与 protected diff。
|
||||
2. 按逻辑分组准备提交:实现一组,测试/规格/留痕一组。
|
||||
3. 提交和 push 前仍不得推进 completed,不得修改 OpenAPI、scanner、coverage report。
|
||||
@ -0,0 +1,151 @@
|
||||
package cn.iocoder.muse.module.knowledge.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.events.api.publish.EventsPublishApi;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeEventPublishOutboxDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceBindingProjectionDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceEventDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeEventPublishOutboxMapper;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeSourceBindingProjectionMapper;
|
||||
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.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Knowledge source status Events publish outbox 创建服务测试。
|
||||
*/
|
||||
class MuseKnowledgeEventPublishOutboxServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
private static final Pattern SHORT_COMMAND_ID = Pattern.compile("kn_evt:[0-9a-f]{32}");
|
||||
|
||||
@InjectMocks
|
||||
private MuseKnowledgeEventPublishOutboxServiceImpl outboxService;
|
||||
@Mock
|
||||
private MuseKnowledgeEventPublishOutboxMapper outboxMapper;
|
||||
@Mock
|
||||
private MuseKnowledgeSourceBindingProjectionMapper sourceBindingProjectionMapper;
|
||||
|
||||
@Test
|
||||
void should_createOneQueuedOutboxPerDistinctTargetOwnerWithSafeNotificationPayload() {
|
||||
MuseKnowledgeSourceEventDO event = sourceEvent("version_changed", null);
|
||||
when(sourceBindingProjectionMapper.selectFanoutOwnerAuditSummaryByLastEventId(4001L, event.getSourceEventId()))
|
||||
.thenReturn(new MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary(4L, 3L, 1L, 2L));
|
||||
when(sourceBindingProjectionMapper.selectFanoutTargetsByLastEventId(4001L, event.getSourceEventId()))
|
||||
.thenReturn(List.of(projection("p-1", 2001L, 7001L, 8001L),
|
||||
projection("p-2", 2001L, 7002L, 8002L),
|
||||
projection("p-3", 2002L, 7003L, 8003L)));
|
||||
when(outboxMapper.insertIgnore(any(MuseKnowledgeEventPublishOutboxDO.class))).thenReturn(1);
|
||||
|
||||
MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary summary =
|
||||
outboxService.createForSourceEvent(event);
|
||||
|
||||
assertEquals(4L, summary.totalProjectionCount());
|
||||
assertEquals(3L, summary.validOwnerProjectionCount());
|
||||
assertEquals(1L, summary.invalidOwnerProjectionCount());
|
||||
assertEquals(2L, summary.distinctTargetOwnerCount());
|
||||
assertEquals(2, summary.queuedOutboxCount());
|
||||
List<MuseKnowledgeEventPublishOutboxDO> inserted = captureInsertedOutboxes(2);
|
||||
assertEquals(List.of(2001L, 2002L), inserted.stream()
|
||||
.map(MuseKnowledgeEventPublishOutboxDO::getTargetOwnerUserId)
|
||||
.toList());
|
||||
assertNotEquals(inserted.get(0).getCommandId(), inserted.get(1).getCommandId(),
|
||||
"同一 source event fan-out 给不同 owner 时,commandId 必须带 owner 维度");
|
||||
assertNotEquals(inserted.get(0).getOutboxId(), inserted.get(1).getOutboxId(),
|
||||
"同一 source event fan-out 给不同 owner 时,outboxId 必须带 owner 维度");
|
||||
for (MuseKnowledgeEventPublishOutboxDO outbox : inserted) {
|
||||
assertEquals("queued", outbox.getPublishStatus());
|
||||
assertEquals("notification", outbox.getEventType());
|
||||
assertEquals("source_status_change", outbox.getNotificationType());
|
||||
assertEquals("__none__", outbox.getSourceRevision());
|
||||
assertEquals(0L, outbox.getSourceFactOwnerUserId());
|
||||
assertTrue(SHORT_COMMAND_ID.matcher(outbox.getCommandId()).matches());
|
||||
assertTrue(outbox.getCommandId().length() <= 128);
|
||||
Map<String, Object> payload = JsonUtils.parseObject(outbox.getPayloadSummary(), Map.class);
|
||||
assertEquals(Map.of("type", "source_status_change",
|
||||
"message", "知识库版本变化,需要重新检查相关使用方"), payload);
|
||||
assertFalse(outbox.getPayloadSummary().contains("reason"));
|
||||
assertFalse(outbox.getPayloadSummary().contains("kbName"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_notCreateOutboxForDocumentDeletedOrInvalidOwnerOnlyFanout() {
|
||||
MuseKnowledgeSourceEventDO documentDeleted = sourceEvent("document_deleted", "3");
|
||||
when(sourceBindingProjectionMapper.selectFanoutOwnerAuditSummaryByLastEventId(4001L,
|
||||
documentDeleted.getSourceEventId()))
|
||||
.thenReturn(new MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary(1L, 0L, 1L, 0L));
|
||||
|
||||
MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary summary =
|
||||
outboxService.createForSourceEvent(documentDeleted);
|
||||
|
||||
assertEquals(1L, summary.invalidOwnerProjectionCount());
|
||||
assertEquals(0, summary.queuedOutboxCount());
|
||||
verify(outboxMapper, never()).insertIgnore(any(MuseKnowledgeEventPublishOutboxDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_notDependOnEventsPublishApiInOutboxCreationService() {
|
||||
for (Field field : MuseKnowledgeEventPublishOutboxServiceImpl.class.getDeclaredFields()) {
|
||||
assertFalse(EventsPublishApi.class.equals(field.getType()),
|
||||
"outbox 创建服务只允许写 Knowledge 本域 outbox,不允许持有或调用 EventsPublishApi");
|
||||
}
|
||||
}
|
||||
|
||||
private List<MuseKnowledgeEventPublishOutboxDO> captureInsertedOutboxes(int count) {
|
||||
ArgumentCaptor<MuseKnowledgeEventPublishOutboxDO> captor =
|
||||
ArgumentCaptor.forClass(MuseKnowledgeEventPublishOutboxDO.class);
|
||||
verify(outboxMapper, org.mockito.Mockito.times(count)).insertIgnore(captor.capture());
|
||||
return captor.getAllValues();
|
||||
}
|
||||
|
||||
private static MuseKnowledgeSourceEventDO sourceEvent(String eventType, String sourceRevision) {
|
||||
MuseKnowledgeSourceEventDO event = new MuseKnowledgeSourceEventDO();
|
||||
event.setTenantId(100L);
|
||||
event.setOwnerUserId(0L);
|
||||
event.setKbId(4001L);
|
||||
event.setSourceEventId("source-event-4001-test");
|
||||
event.setSourceOwner("knowledge");
|
||||
event.setSourceType("global_kb");
|
||||
event.setSourceId("4001");
|
||||
event.setSourceRevision(sourceRevision);
|
||||
event.setStatus("stale");
|
||||
event.setActionPolicy("needs_recheck");
|
||||
event.setCommandId("cmd-source-event");
|
||||
event.setEventSummary(JsonUtils.toJsonString(Map.of(
|
||||
"eventType", eventType,
|
||||
"reason", "内部原因不得外发",
|
||||
"kbName", "内部知识库名不得外发",
|
||||
"sourceStatus", "stale",
|
||||
"actionPolicy", "needs_recheck")));
|
||||
return event;
|
||||
}
|
||||
|
||||
private static MuseKnowledgeSourceBindingProjectionDO projection(String projectionId, Long ownerUserId,
|
||||
Long workId, Long bindingId) {
|
||||
MuseKnowledgeSourceBindingProjectionDO projection = new MuseKnowledgeSourceBindingProjectionDO();
|
||||
projection.setProjectionId(projectionId);
|
||||
projection.setOwnerUserId(ownerUserId);
|
||||
projection.setWorkId(workId);
|
||||
projection.setBindingId(bindingId);
|
||||
projection.setKbId(4001L);
|
||||
projection.setLastEventId("source-event-4001-test");
|
||||
return projection;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,265 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
|
||||
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO;
|
||||
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeEventPublishOutboxDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeEventPublishOutboxMapper;
|
||||
import cn.iocoder.muse.module.knowledge.framework.config.MuseKnowledgeEventsProperties;
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Knowledge Events publish worker 测试。
|
||||
*/
|
||||
class MuseKnowledgeEventPublishWorkerTest {
|
||||
|
||||
private final MuseKnowledgeEventPublishOutboxMapper outboxMapper =
|
||||
mock(MuseKnowledgeEventPublishOutboxMapper.class);
|
||||
private final EventsPublishApi eventsPublishApi = mock(EventsPublishApi.class);
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_notClaimOutboxWhenWorkerDisabled() {
|
||||
MuseKnowledgeEventPublishWorker worker = worker(false);
|
||||
|
||||
int dispatched = worker.dispatchOnce();
|
||||
|
||||
assertEquals(0, dispatched);
|
||||
verify(outboxMapper, never()).claimNextPublishOutbox(anyLong());
|
||||
verify(eventsPublishApi, never()).publish(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_publishAcceptedNotificationForTargetOwnerAndMarkPublished() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = runningOutbox();
|
||||
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
|
||||
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
|
||||
respDTO.setPublishStatus("accepted");
|
||||
respDTO.setEventId("evt_knowledge_1");
|
||||
respDTO.setSequenceNo(101L);
|
||||
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
|
||||
when(outboxMapper.markPublished(501L, 100L, 1, "evt_knowledge_1", 101L)).thenReturn(1);
|
||||
|
||||
int dispatched = worker(true).dispatchOnce();
|
||||
|
||||
assertEquals(1, dispatched);
|
||||
EventsPublishReqDTO reqDTO = capturePublishReq();
|
||||
assertEquals("kn_evt:0123456789abcdef0123456789abcdef", reqDTO.getCommandId());
|
||||
assertEquals(100L, reqDTO.getTenantId());
|
||||
assertEquals(2001L, reqDTO.getOwnerUserId());
|
||||
assertEquals("knowledge", reqDTO.getSourceOwner());
|
||||
assertEquals("knowledge_source_status_owner", reqDTO.getSourceType());
|
||||
assertEquals("source-event-4001-test:owner:2001", reqDTO.getSourceId());
|
||||
assertEquals("__none__", reqDTO.getSourceRevision());
|
||||
assertEquals("notification", reqDTO.getEventType());
|
||||
assertNull(reqDTO.getResourceType());
|
||||
assertNull(reqDTO.getResourceId());
|
||||
assertEquals(Map.of("type", "source_status_change",
|
||||
"message", "知识库状态已变化"), reqDTO.getPayloadSummary());
|
||||
verify(outboxMapper).markPublished(501L, 100L, 1, "evt_knowledge_1", 101L);
|
||||
assertNull(TenantContextHolder.getTenantId(), "worker 完成后必须清理租户上下文");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_markRejectedPublishAsDeadLetterAndKeepWorkerResultCount() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = runningOutbox();
|
||||
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
|
||||
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
|
||||
respDTO.setPublishStatus("rejected");
|
||||
respDTO.setPublishErrorCode("payload_schema_mismatch");
|
||||
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
|
||||
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
|
||||
anyString(), anyString())).thenReturn(1);
|
||||
|
||||
int dispatched = worker(true).dispatchOnce();
|
||||
|
||||
assertEquals(1, dispatched);
|
||||
verify(outboxMapper).markDeadLetter(501L, 100L, 1, "payload_schema_mismatch",
|
||||
"Knowledge Events publish rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_markBlockedPublishAsDeadLetterAndKeepWorkerResultCount() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = runningOutbox();
|
||||
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
|
||||
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
|
||||
respDTO.setPublishStatus("blocked");
|
||||
respDTO.setPublishErrorCode("payload_schema_mismatch");
|
||||
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
|
||||
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
|
||||
anyString(), anyString())).thenReturn(1);
|
||||
|
||||
int dispatched = worker(true).dispatchOnce();
|
||||
|
||||
assertEquals(1, dispatched);
|
||||
verify(outboxMapper).markDeadLetter(501L, 100L, 1, "payload_schema_mismatch",
|
||||
"Knowledge Events publish rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_markApiErrorAsRetryableBeforeLastAttempt() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = runningOutbox();
|
||||
outbox.setAttemptCount(2);
|
||||
outbox.setMaxAttempt(5);
|
||||
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
|
||||
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.error(500, "temporary"));
|
||||
when(outboxMapper.markRetryable(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
|
||||
any(LocalDateTime.class), anyString(), anyString())).thenReturn(1);
|
||||
|
||||
int dispatched = worker(true).dispatchOnce();
|
||||
|
||||
assertEquals(0, dispatched);
|
||||
verify(outboxMapper).markRetryable(org.mockito.Mockito.eq(501L), org.mockito.Mockito.eq(100L),
|
||||
org.mockito.Mockito.eq(2),
|
||||
any(LocalDateTime.class), org.mockito.Mockito.eq("KNOWLEDGE_EVENTS_PUBLISH_API_FAILED"),
|
||||
org.mockito.Mockito.eq("Knowledge Events publish failed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_markApiExceptionAsDeadLetterOnLastAttempt() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = runningOutbox();
|
||||
outbox.setAttemptCount(5);
|
||||
outbox.setMaxAttempt(5);
|
||||
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
|
||||
when(eventsPublishApi.publish(any())).thenThrow(new IllegalStateException("boom"));
|
||||
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
|
||||
anyString(), anyString())).thenReturn(1);
|
||||
|
||||
int dispatched = worker(true).dispatchOnce();
|
||||
|
||||
assertEquals(0, dispatched);
|
||||
verify(outboxMapper).markDeadLetter(501L, 100L, 5, "KNOWLEDGE_EVENTS_PUBLISH_RETRY_EXHAUSTED",
|
||||
"Knowledge Events publish retry exhausted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_guardTerminalStatusUpdatesWithClaimAttemptOwnership() throws NoSuchMethodException {
|
||||
assertClaimOwnershipGuard("markPublished", Long.class, Long.class, Integer.class, String.class, Long.class);
|
||||
assertClaimOwnershipGuard("markRetryable", Long.class, Long.class, Integer.class, LocalDateTime.class,
|
||||
String.class, String.class);
|
||||
assertClaimOwnershipGuard("markDeadLetter", Long.class, Long.class, Integer.class, String.class,
|
||||
String.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_logWhenAcceptedTerminalUpdateIsSkippedByStaleClaim() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = runningOutbox();
|
||||
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
|
||||
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
|
||||
respDTO.setPublishStatus("accepted");
|
||||
respDTO.setEventId("evt_stale_claim");
|
||||
respDTO.setSequenceNo(102L);
|
||||
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
|
||||
when(outboxMapper.markPublished(501L, 100L, 1, "evt_stale_claim", 102L)).thenReturn(0);
|
||||
ListAppender<ILoggingEvent> appender = attachWorkerLogAppender();
|
||||
|
||||
try {
|
||||
int dispatched = worker(true).dispatchOnce();
|
||||
|
||||
assertEquals(1, dispatched);
|
||||
assertTrue(appender.list.stream().anyMatch(event -> event.getLevel().equals(Level.WARN)
|
||||
&& event.getFormattedMessage().contains("Knowledge Events publish 终态回写被跳过")
|
||||
&& event.getFormattedMessage().contains("outboxId=501")
|
||||
&& event.getFormattedMessage().contains("attempt=1")
|
||||
&& event.getFormattedMessage().contains("targetStatus=published")),
|
||||
"accepted 发布后若终态回写 0 行,必须记录 stale claim 诊断日志");
|
||||
} finally {
|
||||
detachWorkerLogAppender(appender);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertClaimOwnershipGuard(String methodName, Class<?>... parameterTypes)
|
||||
throws NoSuchMethodException {
|
||||
Method method = MuseKnowledgeEventPublishOutboxMapper.class.getMethod(methodName, parameterTypes);
|
||||
Update update = method.getAnnotation(Update.class);
|
||||
String sql = String.join(" ", update.value()).toLowerCase().replaceAll("\\s+", " ");
|
||||
assertTrue(sql.contains("publish_status = 'running'"),
|
||||
methodName + " 必须只允许当前 running claim 回写终态");
|
||||
assertTrue(sql.contains("attempt_count = #{claimedattemptcount}"),
|
||||
methodName + " 必须用本次 claim attempt_count 防止过期 worker 覆盖新终态");
|
||||
}
|
||||
|
||||
private static ListAppender<ILoggingEvent> attachWorkerLogAppender() {
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(MuseKnowledgeEventPublishWorker.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
return appender;
|
||||
}
|
||||
|
||||
private static void detachWorkerLogAppender(ListAppender<ILoggingEvent> appender) {
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(MuseKnowledgeEventPublishWorker.class);
|
||||
logger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
|
||||
private MuseKnowledgeEventPublishWorker worker(boolean enabled) {
|
||||
MuseKnowledgeEventsProperties properties = new MuseKnowledgeEventsProperties();
|
||||
properties.getPublishWorker().setEnabled(enabled);
|
||||
return new MuseKnowledgeEventPublishWorker(outboxMapper, eventsPublishApi, properties);
|
||||
}
|
||||
|
||||
private EventsPublishReqDTO capturePublishReq() {
|
||||
ArgumentCaptor<EventsPublishReqDTO> captor = ArgumentCaptor.forClass(EventsPublishReqDTO.class);
|
||||
verify(eventsPublishApi).publish(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private static MuseKnowledgeEventPublishOutboxDO runningOutbox() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = new MuseKnowledgeEventPublishOutboxDO();
|
||||
outbox.setId(501L);
|
||||
outbox.setTenantId(100L);
|
||||
outbox.setOutboxId("kn_out:0123456789abcdef0123456789abcdef");
|
||||
outbox.setSourceEventId("source-event-4001-test");
|
||||
outbox.setSourceFactOwnerUserId(0L);
|
||||
outbox.setTargetOwnerUserId(2001L);
|
||||
outbox.setKbId(4001L);
|
||||
outbox.setSourceType("global_kb");
|
||||
outbox.setSourceId("4001");
|
||||
outbox.setSourceRevision("__none__");
|
||||
outbox.setSourceStatus("stale");
|
||||
outbox.setActionPolicy("needs_recheck");
|
||||
outbox.setKnowledgeEventType("status_changed");
|
||||
outbox.setEventType("notification");
|
||||
outbox.setNotificationType("source_status_change");
|
||||
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of("type", "source_status_change",
|
||||
"message", "知识库状态已变化")));
|
||||
outbox.setCommandId("kn_evt:0123456789abcdef0123456789abcdef");
|
||||
outbox.setPublishStatus("running");
|
||||
outbox.setAttemptCount(1);
|
||||
outbox.setMaxAttempt(5);
|
||||
outbox.setCreateTime(LocalDateTime.of(2026, 6, 6, 10, 0));
|
||||
return outbox;
|
||||
}
|
||||
|
||||
}
|
||||
@ -47,6 +47,8 @@ class MuseKnowledgeSourceEventServiceTest extends BaseMockitoUnitTest {
|
||||
private MuseKnowledgeProjectionTaskMapper projectionTaskMapper;
|
||||
@Mock
|
||||
private MuseKnowledgeCommandService commandService;
|
||||
@Mock
|
||||
private MuseKnowledgeEventPublishOutboxService eventPublishOutboxService;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
@ -62,6 +64,8 @@ class MuseKnowledgeSourceEventServiceTest extends BaseMockitoUnitTest {
|
||||
when(sourceBindingProjectionMapper.updateStatusByKbId(any(), any(), any(), any(), any(), any())).thenReturn(3);
|
||||
when(sourceEventMapper.insert(any(MuseKnowledgeSourceEventDO.class))).thenReturn(1);
|
||||
when(projectionTaskMapper.insert(any(MuseKnowledgeProjectionTaskDO.class))).thenReturn(1);
|
||||
when(eventPublishOutboxService.createForSourceEvent(any(MuseKnowledgeSourceEventDO.class)))
|
||||
.thenReturn(new MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary(3L, 3L, 0L, 2L, 2));
|
||||
|
||||
AdminKnowledgeTaskVO.SourceEventReqVO reqVO = new AdminKnowledgeTaskVO.SourceEventReqVO();
|
||||
reqVO.setCommandId("cmd-source-event");
|
||||
@ -93,9 +97,13 @@ class MuseKnowledgeSourceEventServiceTest extends BaseMockitoUnitTest {
|
||||
&& "completed".equals(task.getStatus())
|
||||
&& task.getFinishedAt() != null
|
||||
&& task.getResultSummary().contains("\"affectedTargets\":3")
|
||||
&& task.getResultSummary().contains("\"ownerFanoutSummary\"")
|
||||
&& task.getResultSummary().contains("\"queuedOutboxCount\":2")
|
||||
&& task.getResultSummary().contains("\"distinctTargetOwnerCount\":2")
|
||||
&& task.getResultSummary().contains("\"sourceVersion\":2")
|
||||
&& task.getResultSummary().contains("\"eventType\":\"version_changed\"")
|
||||
&& task.getResultSummary().contains("\"lastEventId\":\"" + respVO.getEventId() + "\"")));
|
||||
verify(eventPublishOutboxService).createForSourceEvent(any(MuseKnowledgeSourceEventDO.class));
|
||||
verify(commandService).recordCompleted(any(MuseKnowledgeCommandService.CommandEnvelope.class), any());
|
||||
}
|
||||
|
||||
@ -163,6 +171,7 @@ class MuseKnowledgeSourceEventServiceTest extends BaseMockitoUnitTest {
|
||||
verify(sourceEventMapper, never()).insert(any(MuseKnowledgeSourceEventDO.class));
|
||||
verify(sourceBindingProjectionMapper, never()).updateStatusByKbId(any(), any(), any(), any());
|
||||
verify(projectionTaskMapper, never()).insert(any(MuseKnowledgeProjectionTaskDO.class));
|
||||
verify(eventPublishOutboxService, never()).createForSourceEvent(any());
|
||||
}
|
||||
|
||||
private static MuseKnowledgeBaseDO globalKb() {
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
package cn.iocoder.muse.server.framework.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* P1R-7c Knowledge source owner event publish 依赖方向门禁。
|
||||
*/
|
||||
class P1rKnowledgeEventsPublishDependencyTest {
|
||||
|
||||
private static final String PROJECT_GROUP_ID = "cn.iocoder.cloud";
|
||||
|
||||
private static final String KNOWLEDGE_SERVER_POM =
|
||||
"muse-cloud/muse-module-knowledge/muse-module-knowledge-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_knowledge_server_depending_on_events_api_only() throws Exception {
|
||||
Set<MavenDependency> dependencies = readDirectDependencies(KNOWLEDGE_SERVER_POM);
|
||||
|
||||
assertTrue(dependencies.contains(projectDependency("muse-module-events-api")),
|
||||
"Knowledge server 必须直接依赖 muse-module-events-api,才能向 Events owner 发布 source owner 事件");
|
||||
assertFalse(dependencies.contains(projectDependency("muse-module-events-server")),
|
||||
"Knowledge 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,368 @@
|
||||
package cn.iocoder.muse.server.framework.api;
|
||||
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
|
||||
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO;
|
||||
import cn.iocoder.muse.module.events.application.publish.EventsPublishServiceImpl;
|
||||
import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO;
|
||||
import cn.iocoder.muse.module.events.dal.mysql.UnifiedEventMapper;
|
||||
import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeEventPublishWorker;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeEventPublishOutboxDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeEventPublishOutboxMapper;
|
||||
import cn.iocoder.muse.module.knowledge.framework.config.MuseKnowledgeEventsProperties;
|
||||
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.assertNotEquals;
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* P1R-7c Knowledge source owner event 通过本域 worker 发布到统一 Events 的 focused E2E 测试。
|
||||
*/
|
||||
class P1rKnowledgeEventsPublishEndToEndTest {
|
||||
|
||||
@Test
|
||||
void should_publishKnowledgeSourceStatusToVisibleEventsForTargetOwnerOnly() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = buildRunningOutbox(501L, 2001L,
|
||||
"kn_evt_owner_2001", "source-event-4001-test");
|
||||
TestHarness harness = buildHarness(List.of(outbox));
|
||||
|
||||
int dispatched = harness.worker().dispatchOnce();
|
||||
|
||||
assertEquals(1, dispatched);
|
||||
assertEquals("published", outbox.getPublishStatus());
|
||||
assertNotNull(outbox.getPublishedEventId());
|
||||
assertNotNull(outbox.getPublishedSequenceNo());
|
||||
|
||||
LocalDateTime visibleNow = LocalDateTime.now().plusSeconds(1);
|
||||
List<UnifiedEventDO> targetOwnerEvents = harness.eventsPublishService()
|
||||
.listVisibleEvents(100L, 2001L, 0L, visibleNow);
|
||||
assertEquals(1, targetOwnerEvents.size());
|
||||
UnifiedEventDO event = targetOwnerEvents.getFirst();
|
||||
assertEquals("accepted", event.getPublishStatus());
|
||||
assertEquals("notification", event.getEventType());
|
||||
assertEquals("knowledge", event.getSourceOwner());
|
||||
assertEquals("knowledge_source_status_owner", event.getSourceType());
|
||||
assertEquals("source-event-4001-test:owner:2001", event.getSourceId());
|
||||
assertEquals("__none__", event.getSourceRevision());
|
||||
assertEquals(outbox.getPublishedEventId(), event.getEventId());
|
||||
assertEquals(outbox.getPublishedSequenceNo(), event.getSequenceNo());
|
||||
assertNull(event.getResourceType());
|
||||
assertNull(event.getResourceId());
|
||||
assertTrue(event.getPayloadSummary().contains("\"type\":\"source_status_change\""));
|
||||
assertTrue(event.getPayloadSummary().contains("\"message\":\"知识库状态已变化\""));
|
||||
assertFalse(event.getPayloadSummary().contains("reason"));
|
||||
assertFalse(event.getPayloadSummary().contains("kbName"));
|
||||
assertTrue(harness.eventsPublishService().listVisibleEvents(100L, 0L, 0L, visibleNow).isEmpty());
|
||||
assertTrue(harness.eventsPublishService().listVisibleEvents(100L, 2002L, 0L, visibleNow).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_publishSameSourceEventAsDistinctVisibleEventsForDifferentTargetOwners() {
|
||||
MuseKnowledgeEventPublishOutboxDO firstOwnerOutbox = buildRunningOutbox(501L, 2001L,
|
||||
"kn_evt_owner_2001", "source-event-shared");
|
||||
MuseKnowledgeEventPublishOutboxDO secondOwnerOutbox = buildRunningOutbox(502L, 2002L,
|
||||
"kn_evt_owner_2002", "source-event-shared");
|
||||
TestHarness harness = buildHarness(List.of(firstOwnerOutbox, secondOwnerOutbox));
|
||||
|
||||
int firstDispatch = harness.worker().dispatchOnce();
|
||||
int secondDispatch = harness.worker().dispatchOnce();
|
||||
|
||||
assertEquals(1, firstDispatch);
|
||||
assertEquals(1, secondDispatch);
|
||||
assertEquals("published", firstOwnerOutbox.getPublishStatus());
|
||||
assertEquals("published", secondOwnerOutbox.getPublishStatus());
|
||||
assertEquals(2, harness.unifiedRows().size());
|
||||
assertNotEquals(firstOwnerOutbox.getPublishedEventId(), secondOwnerOutbox.getPublishedEventId());
|
||||
LocalDateTime visibleNow = LocalDateTime.now().plusSeconds(1);
|
||||
assertEquals("source-event-shared:owner:2001", harness.eventsPublishService()
|
||||
.listVisibleEvents(100L, 2001L, 0L, visibleNow).getFirst().getSourceId());
|
||||
assertEquals("source-event-shared:owner:2002", harness.eventsPublishService()
|
||||
.listVisibleEvents(100L, 2002L, 0L, visibleNow).getFirst().getSourceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_markSecondOutboxPublishedWithSameEventIdentityWhenSameOwnerSourceReplayHappens() {
|
||||
MuseKnowledgeEventPublishOutboxDO firstOutbox = buildRunningOutbox(501L, 2001L,
|
||||
"kn_evt_replay_first", "source-event-replay");
|
||||
MuseKnowledgeEventPublishOutboxDO secondOutbox = buildRunningOutbox(502L, 2001L,
|
||||
"kn_evt_replay_second", "source-event-replay");
|
||||
TestHarness harness = buildHarness(List.of(firstOutbox, secondOutbox));
|
||||
|
||||
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_markBlockedPublishAsDeadLetterAndKeepInvisible() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = buildRunningOutbox(503L, 2001L,
|
||||
"kn_evt_blocked", "source-event-blocked");
|
||||
EventsPublishApi blockedAdapter = req -> {
|
||||
assertEquals("kn_evt_blocked", req.getCommandId());
|
||||
EventsPublishRespDTO resp = new EventsPublishRespDTO();
|
||||
resp.setPublishStatus("blocked");
|
||||
resp.setPublishErrorCode(EventsPublishServiceImpl.ERROR_PAYLOAD_SCHEMA_MISMATCH);
|
||||
return CommonResult.success(resp);
|
||||
};
|
||||
TestHarness harness = buildHarness(List.of(outbox), blockedAdapter);
|
||||
|
||||
int dispatched = harness.worker().dispatchOnce();
|
||||
|
||||
assertEquals(1, dispatched);
|
||||
assertEquals("dead_letter", outbox.getPublishStatus());
|
||||
assertEquals(EventsPublishServiceImpl.ERROR_PAYLOAD_SCHEMA_MISMATCH, outbox.getLastErrorCode());
|
||||
assertTrue(harness.eventsPublishService()
|
||||
.listVisibleEvents(100L, 2001L, 0L, LocalDateTime.now().plusSeconds(1))
|
||||
.isEmpty());
|
||||
assertTrue(harness.unifiedRows().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_ignoreStaleClaimTerminalUpdateWhenAttemptNoLongerMatches() {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = buildRunningOutbox(504L, 2001L,
|
||||
"kn_evt_stale_claim", "source-event-stale-claim");
|
||||
outbox.setAttemptCount(2);
|
||||
TestHarness harness = buildHarness(List.of(outbox));
|
||||
|
||||
outbox.setAttemptCount(3);
|
||||
int staleUpdate = harness.outboxMapper().markPublished(outbox.getId(), outbox.getTenantId(), 2,
|
||||
"evt_stale", 9L);
|
||||
|
||||
assertEquals(0, staleUpdate);
|
||||
assertEquals("running", outbox.getPublishStatus());
|
||||
assertNull(outbox.getPublishedEventId());
|
||||
assertNull(outbox.getPublishedSequenceNo());
|
||||
}
|
||||
|
||||
private static TestHarness buildHarness(List<MuseKnowledgeEventPublishOutboxDO> outboxes) {
|
||||
return buildHarness(outboxes, null);
|
||||
}
|
||||
|
||||
private static TestHarness buildHarness(List<MuseKnowledgeEventPublishOutboxDO> outboxes,
|
||||
EventsPublishApi customEventsPublishApi) {
|
||||
UnifiedEventMapper unifiedEventMapper = mock(UnifiedEventMapper.class);
|
||||
List<UnifiedEventDO> unifiedRows = new ArrayList<>();
|
||||
AtomicLong sequence = new AtomicLong(1L);
|
||||
stubUnifiedEventMapper(unifiedEventMapper, unifiedRows, sequence);
|
||||
EventsPublishServiceImpl eventsPublishService = new EventsPublishServiceImpl(unifiedEventMapper);
|
||||
EventsPublishApi eventsPublishApi = customEventsPublishApi == null
|
||||
? req -> CommonResult.success(eventsPublishService.publish(req))
|
||||
: customEventsPublishApi;
|
||||
|
||||
MuseKnowledgeEventPublishOutboxMapper outboxMapper = mock(MuseKnowledgeEventPublishOutboxMapper.class);
|
||||
Deque<MuseKnowledgeEventPublishOutboxDO> queue = new ArrayDeque<>(outboxes);
|
||||
when(outboxMapper.claimNextPublishOutbox(60L)).thenAnswer(invocation -> queue.pollFirst());
|
||||
when(outboxMapper.markPublished(anyLong(), anyLong(), anyInt(), anyString(), anyLong()))
|
||||
.thenAnswer(invocation -> {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = findOutbox(outboxes, invocation.getArgument(0),
|
||||
invocation.getArgument(1));
|
||||
if (!claimAttemptMatches(outbox, invocation.getArgument(2))) {
|
||||
return 0;
|
||||
}
|
||||
outbox.setPublishStatus("published");
|
||||
outbox.setLastErrorCode(null);
|
||||
outbox.setLastErrorMessage(null);
|
||||
outbox.setPublishedEventId(invocation.getArgument(3));
|
||||
outbox.setPublishedSequenceNo(invocation.getArgument(4));
|
||||
return 1;
|
||||
});
|
||||
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), anyInt(), anyString(), anyString()))
|
||||
.thenAnswer(invocation -> {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = findOutbox(outboxes, invocation.getArgument(0),
|
||||
invocation.getArgument(1));
|
||||
if (!claimAttemptMatches(outbox, invocation.getArgument(2))) {
|
||||
return 0;
|
||||
}
|
||||
outbox.setPublishStatus("dead_letter");
|
||||
outbox.setLastErrorCode(invocation.getArgument(3));
|
||||
outbox.setLastErrorMessage(invocation.getArgument(4));
|
||||
return 1;
|
||||
});
|
||||
|
||||
MuseKnowledgeEventsProperties properties = new MuseKnowledgeEventsProperties();
|
||||
properties.getPublishWorker().setEnabled(true);
|
||||
MuseKnowledgeEventPublishWorker worker = new MuseKnowledgeEventPublishWorker();
|
||||
ReflectionTestUtils.setField(worker, "outboxMapper", outboxMapper);
|
||||
ReflectionTestUtils.setField(worker, "eventsPublishApi", eventsPublishApi);
|
||||
ReflectionTestUtils.setField(worker, "properties", properties);
|
||||
return new TestHarness(worker, outboxMapper, eventsPublishService, unifiedRows);
|
||||
}
|
||||
|
||||
private static void stubUnifiedEventMapper(UnifiedEventMapper unifiedEventMapper,
|
||||
List<UnifiedEventDO> unifiedRows,
|
||||
AtomicLong sequence) {
|
||||
when(unifiedEventMapper.insertIgnore(any(UnifiedEventDO.class))).thenAnswer(invocation -> {
|
||||
UnifiedEventDO event = invocation.getArgument(0);
|
||||
// 这里用内存表模拟 PostgreSQL ON CONFLICT DO NOTHING,锁定 command 和 source tuple 双幂等合同。
|
||||
if (findByCommandId(unifiedRows, event.getTenantId(), event.getCommandId()) != null
|
||||
|| findBySourceTuple(unifiedRows, event.getTenantId(), event.getSourceOwner(),
|
||||
event.getSourceType(), event.getSourceId(), event.getSourceRevision(), event.getEventType()) != null) {
|
||||
return 0;
|
||||
}
|
||||
UnifiedEventDO stored = copyUnifiedEvent(event);
|
||||
long nextSequence = sequence.getAndIncrement();
|
||||
stored.setId(nextSequence);
|
||||
stored.setSequenceNo(nextSequence);
|
||||
unifiedRows.add(stored);
|
||||
return 1;
|
||||
});
|
||||
when(unifiedEventMapper.selectByTenantIdAndCommandId(anyLong(), anyString()))
|
||||
.thenAnswer(invocation -> findByCommandId(unifiedRows, invocation.getArgument(0),
|
||||
invocation.getArgument(1)));
|
||||
when(unifiedEventMapper.selectByTenantIdAndSourceTuple(anyLong(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyString()))
|
||||
.thenAnswer(invocation -> findBySourceTuple(unifiedRows, invocation.getArgument(0),
|
||||
invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3),
|
||||
invocation.getArgument(4), invocation.getArgument(5)));
|
||||
when(unifiedEventMapper.selectVisibleEventsForOwner(anyLong(), anyLong(), anyLong(), any(LocalDateTime.class)))
|
||||
.thenAnswer(invocation -> selectVisibleEventsForOwner(unifiedRows, invocation.getArgument(0),
|
||||
invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3)));
|
||||
}
|
||||
|
||||
private static MuseKnowledgeEventPublishOutboxDO buildRunningOutbox(Long id, Long targetOwnerUserId,
|
||||
String commandId, String sourceEventId) {
|
||||
MuseKnowledgeEventPublishOutboxDO outbox = new MuseKnowledgeEventPublishOutboxDO();
|
||||
outbox.setId(id);
|
||||
outbox.setTenantId(100L);
|
||||
outbox.setOutboxId("kn_out:" + commandId);
|
||||
outbox.setSourceEventId(sourceEventId);
|
||||
outbox.setSourceFactOwnerUserId(0L);
|
||||
outbox.setTargetOwnerUserId(targetOwnerUserId);
|
||||
outbox.setKbId(4001L);
|
||||
outbox.setSourceType("global_kb");
|
||||
outbox.setSourceId("4001");
|
||||
outbox.setSourceRevision("__none__");
|
||||
outbox.setSourceStatus("stale");
|
||||
outbox.setActionPolicy("needs_recheck");
|
||||
outbox.setKnowledgeEventType("status_changed");
|
||||
outbox.setEventType("notification");
|
||||
outbox.setNotificationType("source_status_change");
|
||||
outbox.setTargetWorkId(7001L);
|
||||
outbox.setBindingId(8001L);
|
||||
outbox.setProjectionId("projection-" + targetOwnerUserId);
|
||||
outbox.setAffectedOwnerProjectionCount(1);
|
||||
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of("type", "source_status_change",
|
||||
"message", "知识库状态已变化")));
|
||||
outbox.setCommandId(commandId);
|
||||
outbox.setPublishStatus("running");
|
||||
outbox.setAttemptCount(1);
|
||||
outbox.setMaxAttempt(5);
|
||||
outbox.setCreateTime(LocalDateTime.of(2026, 6, 6, 10, 0));
|
||||
outbox.setDeleted(false);
|
||||
return outbox;
|
||||
}
|
||||
|
||||
private static MuseKnowledgeEventPublishOutboxDO findOutbox(List<MuseKnowledgeEventPublishOutboxDO> outboxes,
|
||||
Long id,
|
||||
Long tenantId) {
|
||||
return outboxes.stream()
|
||||
.filter(row -> Objects.equals(row.getId(), id))
|
||||
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("测试 outbox 未找到"));
|
||||
}
|
||||
|
||||
private static boolean claimAttemptMatches(MuseKnowledgeEventPublishOutboxDO outbox, Integer claimedAttemptCount) {
|
||||
return Objects.equals(outbox.getPublishStatus(), "running")
|
||||
&& Objects.equals(outbox.getAttemptCount(), claimedAttemptCount);
|
||||
}
|
||||
|
||||
private static UnifiedEventDO findByCommandId(List<UnifiedEventDO> unifiedRows, Long tenantId, String commandId) {
|
||||
return unifiedRows.stream()
|
||||
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
|
||||
.filter(row -> Objects.equals(row.getCommandId(), commandId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static UnifiedEventDO findBySourceTuple(List<UnifiedEventDO> unifiedRows, Long tenantId, String sourceOwner,
|
||||
String sourceType, String sourceId, String sourceRevision,
|
||||
String eventType) {
|
||||
return unifiedRows.stream()
|
||||
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
|
||||
.filter(row -> Objects.equals(row.getSourceOwner(), sourceOwner))
|
||||
.filter(row -> Objects.equals(row.getSourceType(), sourceType))
|
||||
.filter(row -> Objects.equals(row.getSourceId(), sourceId))
|
||||
.filter(row -> Objects.equals(row.getSourceRevision(), sourceRevision))
|
||||
.filter(row -> Objects.equals(row.getEventType(), eventType))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static List<UnifiedEventDO> selectVisibleEventsForOwner(List<UnifiedEventDO> unifiedRows, Long tenantId,
|
||||
Long ownerUserId, Long afterSequenceNo,
|
||||
LocalDateTime now) {
|
||||
long cursor = afterSequenceNo == null ? 0L : afterSequenceNo;
|
||||
return unifiedRows.stream()
|
||||
.filter(row -> Objects.equals(row.getTenantId(), tenantId))
|
||||
.filter(row -> Objects.equals(row.getOwnerUserId(), ownerUserId))
|
||||
.filter(row -> Objects.equals(row.getPublishStatus(), "accepted"))
|
||||
.filter(row -> Boolean.FALSE.equals(row.getDeleted()))
|
||||
.filter(row -> row.getVisibleFrom() != null && !row.getVisibleFrom().isAfter(now))
|
||||
.filter(row -> row.getSequenceNo() != null && row.getSequenceNo() > cursor)
|
||||
.sorted(Comparator.comparing(UnifiedEventDO::getSequenceNo))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static UnifiedEventDO copyUnifiedEvent(UnifiedEventDO source) {
|
||||
UnifiedEventDO target = new UnifiedEventDO();
|
||||
target.setTenantId(source.getTenantId());
|
||||
target.setCommandId(source.getCommandId());
|
||||
target.setEventId(source.getEventId());
|
||||
target.setEventType(source.getEventType());
|
||||
target.setOwnerUserId(source.getOwnerUserId());
|
||||
target.setSourceOwner(source.getSourceOwner());
|
||||
target.setSourceType(source.getSourceType());
|
||||
target.setSourceId(source.getSourceId());
|
||||
target.setSourceRevision(source.getSourceRevision());
|
||||
target.setResourceType(source.getResourceType());
|
||||
target.setResourceId(source.getResourceId());
|
||||
target.setPayloadSummary(source.getPayloadSummary());
|
||||
target.setPublishStatus(source.getPublishStatus());
|
||||
target.setPublishErrorCode(source.getPublishErrorCode());
|
||||
target.setVisibleFrom(source.getVisibleFrom());
|
||||
target.setEmittedAt(source.getEmittedAt());
|
||||
target.setDeleted(source.getDeleted());
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* focused E2E 只覆盖 Knowledge worker、Events publish 幂等/拒绝语义和 owner 可见边界;
|
||||
* 不启动 Spring 容器或真实数据库,避免 P1R-7c 扩大到生产链路改造。
|
||||
*/
|
||||
private record TestHarness(MuseKnowledgeEventPublishWorker worker,
|
||||
MuseKnowledgeEventPublishOutboxMapper outboxMapper,
|
||||
EventsPublishServiceImpl eventsPublishService,
|
||||
List<UnifiedEventDO> unifiedRows) {
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,765 @@
|
||||
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-7c Knowledge source owner event publish outbox 真实 PostgreSQL / Flyway 迁移验收。
|
||||
*
|
||||
* <p>本测试会 clean 指定数据库的 public schema,因此只允许连接真实库名以 {@code _test} 结尾的隔离库。
|
||||
* 数据库密码只能从环境变量读取,避免 Surefire XML 或命令历史记录泄露凭据。</p>
|
||||
*/
|
||||
class P1rKnowledgeEventsPublishFlywayMigrationIT {
|
||||
|
||||
/** P1R-7c Knowledge publish outbox V18 是本任务唯一允许验收到达的目标版本。 */
|
||||
private static final String TARGET_VERSION = "18";
|
||||
/** V18 Flyway 文件名会转换出的描述,作为“当前版本确实是 Knowledge publish outbox 迁移”的硬证据。 */
|
||||
private static final String TARGET_DESCRIPTION = "extend knowledge events publish outbox";
|
||||
/** clean 后从 V1 到 V18 应该产生 18 条成功 SQL migration 记录。 */
|
||||
private static final int EXPECTED_SUCCESSFUL_SQL_MIGRATIONS = 18;
|
||||
|
||||
private static final String OUTBOX_TABLE = "muse_knowledge_event_publish_outbox";
|
||||
|
||||
private static final List<String> REQUIRED_INDEXES = List.of(
|
||||
"idx_muse_knowledge_event_publish_outbox_claim",
|
||||
"idx_muse_knowledge_event_publish_outbox_owner_status",
|
||||
"idx_muse_knowledge_event_publish_outbox_source_event",
|
||||
"idx_muse_knowledge_source_projection_last_event"
|
||||
);
|
||||
|
||||
private static final List<String> REQUIRED_CONSTRAINTS = List.of(
|
||||
"uk_muse_knowledge_event_publish_outbox_id",
|
||||
"uk_muse_knowledge_event_publish_outbox_command",
|
||||
"uk_muse_knowledge_event_publish_outbox_owner_source",
|
||||
"chk_muse_knowledge_event_publish_outbox_event_type",
|
||||
"chk_muse_knowledge_event_publish_outbox_notification_type",
|
||||
"chk_muse_knowledge_event_publish_outbox_status",
|
||||
"chk_muse_knowledge_event_publish_outbox_attempt_count",
|
||||
"chk_muse_knowledge_event_publish_outbox_max_attempt",
|
||||
"chk_muse_knowledge_event_publish_outbox_target_owner",
|
||||
"chk_muse_knowledge_event_publish_outbox_source_fact_owner",
|
||||
"chk_muse_knowledge_event_publish_outbox_projection_count"
|
||||
);
|
||||
|
||||
private static final List<String> REQUIRED_TRIGGERS = List.of(
|
||||
"trg_muse_knowledge_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_migrateV1ToV18OnRealPostgresqlAndVerifyKnowledgePublishOutboxSchema() 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-7c Knowledge 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 当前版本必须到 V18");
|
||||
assertEquals(TARGET_DESCRIPTION, current.getDescription(),
|
||||
"真实 Flyway 当前版本必须是 Knowledge publish outbox V18 扩展迁移");
|
||||
assertEquals(EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, result.migrationsExecuted,
|
||||
"clean 后必须只执行 V1-V18 共 18 个迁移,避免遗漏目标版本或误跑后续迁移");
|
||||
int successfulMigrationCount = successfulMigrationCount(url, user, password);
|
||||
assertEquals(EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, successfulMigrationCount,
|
||||
"必须在隔离库中执行 V1-V18 共 18 个成功 SQL 迁移");
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(url, user, password)) {
|
||||
assertRequiredObjects(connection);
|
||||
assertOutboxSchema(connection);
|
||||
assertOutboxCheckConstraintsRejectInvalidRows(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("v18_table=" + OUTBOX_TABLE);
|
||||
System.out.println("v18_indexes=" + String.join(",", REQUIRED_INDEXES));
|
||||
System.out.println("v18_constraints=" + String.join(",", REQUIRED_CONSTRAINTS));
|
||||
System.out.println("v18_triggers=" + String.join(",", REQUIRED_TRIGGERS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectNonTestDatabaseWhenQueryContainsSlashTestSuffix() {
|
||||
String url = "jdbc:postgresql://prod-host/prod?applicationName=/muse_p1r7c_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_p1r7c_events_publish_test?password=plain";
|
||||
|
||||
AssertionError error = assertThrows(AssertionError.class, () -> assertNoCredentialQuery(url));
|
||||
|
||||
assertTrue(error.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"),
|
||||
"JDBC URL query 凭据必须被拒绝: " + error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectBroadSingleLiteralConstraintExpression() {
|
||||
String broadenedEventTypeCheck =
|
||||
"check (((event_type)::text = 'notification'::text) or ((event_type)::text = 'debug'::text))";
|
||||
|
||||
assertFalse(singleLiteralCheck(broadenedEventTypeCheck, "event_type", "notification"),
|
||||
"单值 check 断言不能接受 OR 放宽后的表达式");
|
||||
}
|
||||
|
||||
private static String requiredProperty(String name) {
|
||||
String value = System.getProperty(name);
|
||||
assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String requiredPasswordEnvironment() {
|
||||
String password = firstNonBlankEnvironment("P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
|
||||
assertTrue(password != null, "缺少必需环境变量: P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD");
|
||||
return password;
|
||||
}
|
||||
|
||||
private static String firstNonBlankEnvironment(String... names) {
|
||||
// WHY:数据库密码不能通过 JVM system property 传入,否则 Surefire XML 可能记录属性名和值。
|
||||
for (String name : names) {
|
||||
String value = System.getenv(name);
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String resolveMuseSqlLocation(String requestedLocations) {
|
||||
Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath();
|
||||
String relativeLocation = requestedLocations.substring("filesystem:".length());
|
||||
for (Path cursor = current; cursor != null; cursor = cursor.getParent()) {
|
||||
Path candidate = cursor.resolve(relativeLocation);
|
||||
if (Files.isDirectory(candidate)) {
|
||||
return "filesystem:" + candidate;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current);
|
||||
}
|
||||
|
||||
private static void silenceFlywayInfoLogs() {
|
||||
try {
|
||||
Object flywayLogger = LoggerFactory.getLogger("org.flywaydb");
|
||||
Class<?> levelClass = Class.forName("ch.qos.logback.classic.Level");
|
||||
Object warnLevel = levelClass.getField("WARN").get(null);
|
||||
flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel);
|
||||
} catch (ReflectiveOperationException | LinkageError ignored) {
|
||||
// WHY:日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。
|
||||
}
|
||||
}
|
||||
|
||||
private static void redactFlywaySystemProperties(String url, String user) {
|
||||
// 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),
|
||||
"Knowledge Events publish outbox 表必须存在: " + OUTBOX_TABLE);
|
||||
|
||||
Map<String, Boolean> indexChecks = indexChecks(connection);
|
||||
assertFalse(indexChecks.containsValue(false), "Knowledge publish outbox 关键索引必须全部存在: " + indexChecks);
|
||||
|
||||
Map<String, Boolean> constraintChecks = constraintChecks(connection);
|
||||
assertFalse(constraintChecks.containsValue(false), "Knowledge publish outbox 关键约束必须全部存在: " + constraintChecks);
|
||||
|
||||
Map<String, Boolean> triggerChecks = triggerChecks(connection);
|
||||
assertFalse(triggerChecks.containsValue(false), "Knowledge 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", "outbox_id", "source_event_id", "source_fact_owner_user_id",
|
||||
"target_owner_user_id", "kb_id", "source_type", "source_id", "source_revision",
|
||||
"source_status", "action_policy", "knowledge_event_type", "event_type", "notification_type",
|
||||
"target_work_id", "binding_id", "projection_id", "affected_owner_projection_count",
|
||||
"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("varchar", columnUdtName(connection, OUTBOX_TABLE, "source_event_id"),
|
||||
"source_event_id 必须是 VARCHAR,引用 muse_knowledge_source_event.source_event_id 语义");
|
||||
assertEquals(128, columnLength(connection, OUTBOX_TABLE, "source_event_id"),
|
||||
"source_event_id 必须是 VARCHAR(128)");
|
||||
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "target_owner_user_id"),
|
||||
"target_owner_user_id 必须 NOT NULL");
|
||||
assertEquals(80, columnLength(connection, OUTBOX_TABLE, "source_revision"),
|
||||
"source_revision 必须是 VARCHAR(80)");
|
||||
assertTrue(columnIsNotNullable(connection, OUTBOX_TABLE, "source_revision"),
|
||||
"source_revision 必须 NOT NULL,空 revision 由应用层归一化为 __none__");
|
||||
assertEquals("jsonb", columnUdtName(connection, OUTBOX_TABLE, "payload_summary"),
|
||||
"payload_summary 必须使用 jsonb 保存可公开 notification 摘要");
|
||||
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-7c 最小固定重试上限 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 字段约定保持一致");
|
||||
|
||||
assertEquals(List.of("tenant_id", "outbox_id"),
|
||||
constraintColumns(connection, "uk_muse_knowledge_event_publish_outbox_id"),
|
||||
"outbox_id 唯一约束必须覆盖 tenant_id/outbox_id");
|
||||
assertEquals(List.of("tenant_id", "command_id"),
|
||||
constraintColumns(connection, "uk_muse_knowledge_event_publish_outbox_command"),
|
||||
"命令幂等唯一约束必须覆盖 tenant_id/command_id");
|
||||
assertEquals(List.of("tenant_id", "source_event_id", "target_owner_user_id", "notification_type"),
|
||||
constraintColumns(connection, "uk_muse_knowledge_event_publish_outbox_owner_source"),
|
||||
"owner/source 唯一约束必须覆盖 tenant_id/source_event_id/target_owner_user_id/notification_type");
|
||||
assertEquals(List.of("publish_status", "claim_expires_at", "next_retry_at", "create_time", "id"),
|
||||
indexColumns(connection, "idx_muse_knowledge_event_publish_outbox_claim"),
|
||||
"claim 索引必须按 publish_status/claim_expires_at/next_retry_at/create_time/id 排序");
|
||||
assertTrue(normalizedIndexDefinition(connection, "idx_muse_knowledge_event_publish_outbox_claim").contains("where (deleted = false)")
|
||||
|| normalizedIndexDefinition(connection, "idx_muse_knowledge_event_publish_outbox_claim").contains("where deleted = false"),
|
||||
"claim 索引必须是 deleted=false partial index");
|
||||
assertEquals(List.of("tenant_id", "target_owner_user_id", "publish_status", "create_time"),
|
||||
indexColumns(connection, "idx_muse_knowledge_event_publish_outbox_owner_status"),
|
||||
"owner/status 索引必须覆盖 tenant_id/target_owner_user_id/publish_status/create_time");
|
||||
assertEquals(List.of("tenant_id", "kb_id", "last_event_id"),
|
||||
indexColumns(connection, "idx_muse_knowledge_source_projection_last_event"),
|
||||
"fan-out 辅助索引必须覆盖 tenant_id/kb_id/last_event_id");
|
||||
|
||||
assertTrue(singleLiteralCheck(normalizedConstraintDefinition(connection,
|
||||
"chk_muse_knowledge_event_publish_outbox_event_type"), "event_type", "notification"),
|
||||
"event_type check 必须只允许 notification");
|
||||
assertTrue(singleLiteralCheck(normalizedConstraintDefinition(connection,
|
||||
"chk_muse_knowledge_event_publish_outbox_notification_type"),
|
||||
"notification_type", "source_status_change"),
|
||||
"notification_type check 必须只允许 source_status_change");
|
||||
String publishStatusConstraint =
|
||||
normalizedConstraintDefinition(connection, "chk_muse_knowledge_event_publish_outbox_status");
|
||||
for (String value : Set.of("queued", "running", "published", "retryable", "dead_letter")) {
|
||||
assertTrue(publishStatusConstraint.contains(value),
|
||||
"publish_status check 必须允许 " + value + ": " + publishStatusConstraint);
|
||||
}
|
||||
assertFalse(publishStatusConstraint.contains("accepted"),
|
||||
"accepted/rejected/blocked 是 Events 投影状态,不能混入 Knowledge outbox 本地状态");
|
||||
assertTrue(normalizedConstraintDefinition(connection, "chk_muse_knowledge_event_publish_outbox_target_owner")
|
||||
.contains("target_owner_user_id > 0"),
|
||||
"target owner 必须大于 0");
|
||||
assertTrue(normalizedConstraintDefinition(connection, "chk_muse_knowledge_event_publish_outbox_source_fact_owner")
|
||||
.contains("source_fact_owner_user_id >= 0"),
|
||||
"source fact owner 允许全局 KB owner=0,但不能为负数");
|
||||
}
|
||||
|
||||
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 {
|
||||
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 "NO".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 assertOutboxCheckConstraintsRejectInvalidRows(Connection connection) {
|
||||
assertCheckConstraintRejects(() -> insertOutboxForConstraintProbe(connection, "bad_event_type",
|
||||
"debug", "source_status_change", "queued", 2001L, 0L, 0),
|
||||
"event_type check 必须在真实 PostgreSQL 拒绝非 notification");
|
||||
assertCheckConstraintRejects(() -> insertOutboxForConstraintProbe(connection, "bad_notification_type",
|
||||
"notification", "kb_internal", "queued", 2001L, 0L, 0),
|
||||
"notification_type check 必须在真实 PostgreSQL 拒绝非 source_status_change");
|
||||
assertCheckConstraintRejects(() -> insertOutboxForConstraintProbe(connection, "bad_publish_status",
|
||||
"notification", "source_status_change", "accepted", 2001L, 0L, 0),
|
||||
"publish_status check 必须在真实 PostgreSQL 拒绝 Events 投影状态");
|
||||
assertCheckConstraintRejects(() -> insertOutboxForConstraintProbe(connection, "bad_target_owner",
|
||||
"notification", "source_status_change", "queued", 0L, 0L, 0),
|
||||
"target_owner_user_id check 必须在真实 PostgreSQL 拒绝 owner=0");
|
||||
}
|
||||
|
||||
private static void assertCheckConstraintRejects(SqlRunnable runnable, String message) {
|
||||
SQLException error = assertThrows(SQLException.class, runnable::run, message);
|
||||
assertEquals("23514", error.getSQLState(), message + ",实际 SQLState=" + error.getSQLState());
|
||||
}
|
||||
|
||||
private static void insertOutboxForConstraintProbe(Connection connection, String suffix, String eventType,
|
||||
String notificationType, String publishStatus,
|
||||
Long targetOwnerUserId, Long sourceFactOwnerUserId,
|
||||
Integer projectionCount) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_knowledge_event_publish_outbox(tenant_id, outbox_id, source_event_id,
|
||||
source_fact_owner_user_id, target_owner_user_id,
|
||||
kb_id, source_type, source_id, source_revision,
|
||||
knowledge_event_type, event_type, notification_type,
|
||||
affected_owner_projection_count, payload_summary,
|
||||
command_id, publish_status)
|
||||
VALUES (100, ?, ?, ?, ?, 4001, 'global_kb', '4001', '__none__', 'status_changed',
|
||||
?, ?, ?, '{}'::jsonb, ?, ?)
|
||||
""")) {
|
||||
statement.setString(1, "kn_out_constraint_" + suffix);
|
||||
statement.setString(2, "source-event-constraint-" + suffix);
|
||||
statement.setLong(3, sourceFactOwnerUserId);
|
||||
statement.setLong(4, targetOwnerUserId);
|
||||
statement.setString(5, eventType);
|
||||
statement.setString(6, notificationType);
|
||||
statement.setInt(7, projectionCount);
|
||||
statement.setString(8, "kn_evt_constraint_" + suffix);
|
||||
statement.setString(9, publishStatus);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface SqlRunnable {
|
||||
|
||||
void run() throws SQLException;
|
||||
|
||||
}
|
||||
|
||||
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 Objects.toString(sql, "").toLowerCase(Locale.ROOT).replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
|
||||
private static boolean singleLiteralCheck(String normalizedConstraintDefinition, String columnName, String value) {
|
||||
String expression = normalizedConstraintDefinition
|
||||
.replace("::text", "")
|
||||
.replace("::character varying", "")
|
||||
.replace("(", " ")
|
||||
.replace(")", " ")
|
||||
.replaceAll("\\s+", " ")
|
||||
.trim();
|
||||
String comparison = columnName + " = '" + value + "'";
|
||||
return expression.equals("check " + comparison) || expression.equals(comparison);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,282 @@
|
||||
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-7c Knowledge source owner event publish outbox V18 迁移 SQL 静态门禁。
|
||||
*/
|
||||
class P1rKnowledgeEventsPublishMigrationSqlTest {
|
||||
|
||||
private static final String OUTBOX_TABLE = "muse_knowledge_event_publish_outbox";
|
||||
private static final Path V18_RELATIVE_PATH =
|
||||
Path.of("muse-cloud/sql/muse/V18__extend_knowledge_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_defineKnowledgeSourceOwnerPublishOutboxTableAndColumns() throws IOException {
|
||||
String normalizedSql = normalize(readV18Sql());
|
||||
String tableSql = extractCreateTable(normalizedSql, OUTBOX_TABLE);
|
||||
|
||||
assertTrue(tableSql.contains("id bigint generated always as identity primary key"),
|
||||
"outbox.id 必须使用 PostgreSQL identity 主键");
|
||||
for (String column : Set.of(
|
||||
"tenant_id bigint not null",
|
||||
"outbox_id varchar(128) not null",
|
||||
"source_event_id varchar(128) not null",
|
||||
"source_fact_owner_user_id bigint not null",
|
||||
"target_owner_user_id bigint not null",
|
||||
"kb_id bigint not null",
|
||||
"source_type varchar(64) not null",
|
||||
"source_id varchar(128) not null",
|
||||
"source_revision varchar(80) not null",
|
||||
"source_status varchar(32)",
|
||||
"action_policy varchar(64)",
|
||||
"knowledge_event_type varchar(64) not null",
|
||||
"event_type varchar(32) not null",
|
||||
"notification_type varchar(64) not null",
|
||||
"target_work_id bigint",
|
||||
"binding_id bigint",
|
||||
"projection_id varchar(128)",
|
||||
"affected_owner_projection_count int not null default 0",
|
||||
"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),
|
||||
"Knowledge publish outbox 必须包含字段定义: " + column);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_includeChecksUniqueConstraintsIndexesAndTrigger() throws IOException {
|
||||
String normalizedSql = normalize(readV18Sql());
|
||||
String tableSql = extractCreateTable(normalizedSql, OUTBOX_TABLE);
|
||||
|
||||
assertTrue(tableSql.contains("constraint uk_muse_knowledge_event_publish_outbox_id unique (tenant_id, outbox_id)"),
|
||||
"outbox_id 必须通过 tenant_id/outbox_id 唯一约束保证本域幂等");
|
||||
assertTrue(tableSql.contains("constraint uk_muse_knowledge_event_publish_outbox_command unique (tenant_id, command_id)"),
|
||||
"command_id 必须通过 tenant_id/command_id 唯一约束保证发布命令幂等");
|
||||
assertTrue(tableSql.contains("constraint uk_muse_knowledge_event_publish_outbox_owner_source unique (tenant_id, source_event_id, target_owner_user_id, notification_type)"),
|
||||
"source event + target owner + notification type 必须唯一,避免同一 owner 重复可见事件");
|
||||
|
||||
String eventTypeConstraint = extractConstraint(tableSql,
|
||||
"chk_muse_knowledge_event_publish_outbox_event_type");
|
||||
assertTrue(eventTypeConstraint.contains("event_type = 'notification'"),
|
||||
"Knowledge P1R-7c 只允许发布 notification 类型");
|
||||
|
||||
String notificationTypeConstraint = extractConstraint(tableSql,
|
||||
"chk_muse_knowledge_event_publish_outbox_notification_type");
|
||||
assertTrue(notificationTypeConstraint.contains("notification_type = 'source_status_change'"),
|
||||
"Knowledge P1R-7c 只允许 source_status_change notification");
|
||||
|
||||
String publishStatusConstraint = extractConstraint(tableSql,
|
||||
"chk_muse_knowledge_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 投影状态,不能混入 Knowledge outbox 本地状态");
|
||||
|
||||
assertTrue(extractConstraint(tableSql, "chk_muse_knowledge_event_publish_outbox_attempt_count")
|
||||
.contains("attempt_count >= 0"),
|
||||
"attempt_count 必须有非负约束");
|
||||
assertTrue(extractConstraint(tableSql, "chk_muse_knowledge_event_publish_outbox_max_attempt")
|
||||
.contains("max_attempt > 0"),
|
||||
"max_attempt 必须有正数约束");
|
||||
assertTrue(extractConstraint(tableSql, "chk_muse_knowledge_event_publish_outbox_target_owner")
|
||||
.contains("target_owner_user_id > 0"),
|
||||
"target_owner_user_id 必须 fail-closed,禁止 owner=0 进入 outbox");
|
||||
assertTrue(extractConstraint(tableSql, "chk_muse_knowledge_event_publish_outbox_source_fact_owner")
|
||||
.contains("source_fact_owner_user_id >= 0"),
|
||||
"source_fact_owner_user_id 允许全局 KB owner=0,但不能为负数");
|
||||
assertTrue(extractConstraint(tableSql, "chk_muse_knowledge_event_publish_outbox_projection_count")
|
||||
.contains("affected_owner_projection_count >= 0"),
|
||||
"affected_owner_projection_count 必须有非负约束");
|
||||
|
||||
assertTrue(normalizedSql.contains("create index idx_muse_knowledge_event_publish_outbox_claim")
|
||||
&& normalizedSql.contains("on muse_knowledge_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_knowledge_event_publish_outbox_owner_status")
|
||||
&& normalizedSql.contains("on muse_knowledge_event_publish_outbox(tenant_id, target_owner_user_id, publish_status, create_time)"),
|
||||
"owner/status index 必须覆盖 tenant_id/target_owner_user_id/publish_status/create_time");
|
||||
assertTrue(normalizedSql.contains("create index idx_muse_knowledge_event_publish_outbox_source_event")
|
||||
&& normalizedSql.contains("on muse_knowledge_event_publish_outbox(tenant_id, source_event_id, publish_status)"),
|
||||
"source_event index 必须支持按 source event 排障");
|
||||
assertTrue(normalizedSql.contains("create index idx_muse_knowledge_source_projection_last_event")
|
||||
&& normalizedSql.contains("on muse_knowledge_source_binding_projection(tenant_id, kb_id, last_event_id)")
|
||||
&& normalizedSql.contains("where last_event_id is not null"),
|
||||
"V18 必须为 source event fan-out 查询补 last_event partial index");
|
||||
assertTrue(normalizedSql.contains("create trigger trg_muse_knowledge_event_publish_outbox_update_time")
|
||||
&& normalizedSql.contains("before update on muse_knowledge_event_publish_outbox")
|
||||
&& normalizedSql.contains("execute function update_updated_at_column()"),
|
||||
"outbox 必须复用 update_updated_at_column() 的 update_time 触发器");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_followBaseDoTenantBaseDoAndAvoidSensitiveColumnsForeignKeysAndDuplicates() throws IOException {
|
||||
Path root = findRepositoryRoot();
|
||||
String migrationSql = normalize(readV18Sql());
|
||||
String tableSql = extractCreateTable(migrationSql, OUTBOX_TABLE);
|
||||
String baseDo = Files.readString(root.resolve(
|
||||
"muse-cloud/muse-framework/muse-spring-boot-starter-mybatis/src/main/java/cn/iocoder/muse/framework/mybatis/core/dataobject/BaseDO.java"));
|
||||
String tenantBaseDo = Files.readString(root.resolve(
|
||||
"muse-cloud/muse-framework/muse-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/muse/framework/tenant/core/db/TenantBaseDO.java"));
|
||||
|
||||
assertTrue(baseDo.contains("private LocalDateTime createTime;")
|
||||
&& baseDo.contains("private LocalDateTime updateTime;")
|
||||
&& baseDo.contains("private String creator;")
|
||||
&& baseDo.contains("private String updater;")
|
||||
&& baseDo.contains("private Boolean deleted;"),
|
||||
"BaseDO 必须暴露 createTime/updateTime/creator/updater/deleted");
|
||||
assertTrue(tenantBaseDo.contains("class TenantBaseDO extends BaseDO")
|
||||
&& tenantBaseDo.contains("private Long tenantId;"),
|
||||
"TenantBaseDO 必须继承 BaseDO 并暴露 tenantId");
|
||||
assertFalse(tableSql.contains("created_at"),
|
||||
"禁止使用非项目约定字段 created_at");
|
||||
assertFalse(tableSql.contains("updated_at"),
|
||||
"禁止使用非项目约定字段 updated_at");
|
||||
assertFalse(PLAINTEXT_SECRET_COLUMN_PATTERN.matcher(tableSql).find(),
|
||||
"Knowledge publish outbox 不能新增 token/authorization/secret/password/api_key/bearer 明文字段");
|
||||
assertFalse(tableSql.contains("foreign key") || migrationSql.contains("references muse_knowledge_source_event"),
|
||||
"source_event_id 只表达 muse_knowledge_source_event.source_event_id 引用语义,不能新增数据库外键");
|
||||
|
||||
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bcreate table (muse_[a-z0-9_]+)\\b")),
|
||||
"V18 新增表名不能重复");
|
||||
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bcreate (?:unique )?index (\\w+)\\b")),
|
||||
"V18 新增索引名不能重复");
|
||||
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bcreate trigger (\\w+)\\b")),
|
||||
"V18 新增 trigger 名不能重复");
|
||||
assertNoDuplicateNames(extractNames(migrationSql, Pattern.compile("\\bconstraint (\\w+)\\b")),
|
||||
"V18 新增 constraint 名不能重复");
|
||||
|
||||
String earlierMigrations = readEarlierMigrationsNormalized();
|
||||
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
|
||||
Pattern.compile("\\bcreate table (muse_[a-z0-9_]+)\\b"), "表");
|
||||
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
|
||||
Pattern.compile("\\bcreate (?:unique )?index (\\w+)\\b"), "索引");
|
||||
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
|
||||
Pattern.compile("\\bcreate trigger (\\w+)\\b"), "trigger");
|
||||
assertNewNamesDoNotExistInEarlierMigrations(migrationSql, earlierMigrations,
|
||||
Pattern.compile("\\bconstraint (\\w+)\\b"), "constraint");
|
||||
}
|
||||
|
||||
private static String readV18Sql() throws IOException {
|
||||
Path migrationPath = findRepositoryRoot().resolve(V18_RELATIVE_PATH);
|
||||
assertTrue(Files.exists(migrationPath),
|
||||
"V18 Knowledge Events publish outbox 迁移 SQL 必须存在: " + V18_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(V18_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, "V18 必须创建表: " + tableName);
|
||||
int end = normalizedSql.indexOf(");", start + startToken.length());
|
||||
assertTrue(end >= 0, "V18 表定义必须以 ); 结束: " + 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, "V18 必须创建约束: " + 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),
|
||||
"V18 新增" + objectType + "不能与既有对象重名: " + name);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user