From e8f616d5a5692b60340bee92594140cc95857f02 Mon Sep 17 00:00:00 2001 From: lili Date: Sun, 14 Jun 2026 09:58:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(p1-ai):=20AI=20=E7=94=9F=E6=88=90=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=A1=A5=E9=BD=90=E3=80=8C=E5=AE=A1=E3=80=8D=E5=AD=97?= =?UTF-8?q?=E6=AE=B5+active+=E6=8E=88=E6=9D=83=E5=BF=AB=E7=85=A7,=E6=89=93?= =?UTF-8?q?=E9=80=9A=E7=94=9F=E6=88=90=E2=86=92=E9=87=87=E7=BA=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除「经真实 AI 生成流产出的候选永远无法被采纳入正文」缺口(chip task_092cfc32): - 新增 MuseAiCandidateReviewService:对生成正文做真实(轻量)审——输出合规扫描(空/凭据痕迹) + 静态检查(空白/超长)+ 许可限制快照透传;三项全过才给可追溯审结果 ID(绑定 generation+内容指纹), 任一不过则不给 → 调用方不写审字段 → Content 合并门禁如实拒绝(反假绿)。 - MuseAiRuntimeProjectionService.createRuntimeSuggestion: · 顶层 content 写入候选 contentSnapshot(脱敏 summary,供 mergeBlockSuggestion facade 可用性检查; 完整 provider 原文按数据主权不持久化,采纳走 merge_after_edit 由用户回传所审正文) · diff_summary 写齐三「审」字段(审查通过时) · source_status 'verified'→'active'(原值既非来源状态机取值,又会被合并门禁拒绝) · 数值授权快照:envelope id 为数值时落 authorization_snapshot_id(沿用 MuseSuggestionServiceImpl 的 envelope=authz 既有约定);非数值受 BIGINT 列限制,待授权快照建模收口(遗留) 验证(真实 PG,16/16 绿): - MuseAiCandidateReviewServiceTest 5/0(真实审逻辑:通过/空/凭据泄露/许可透传/空值) - MuseAiRuntimeProjectionServiceTest 5/0(回归:补 @Mock+stub 防 NPE) - P1rContentMergeGeneratedSuggestionIT 1/0(机械门禁:真实 createShadowSuggestionCandidate 产候选 → active/审/authz/content 由真实代码产出 → mergeBlockSuggestion 写入 Canonical) - P1rContentMergeSuggestionIT 5/0(回归,未受影响) 至此 MVP #1「生成→采纳」活体闭环仅剩 1 个范围外阻塞:远端 dev 库缺 yudao 基座 schema(全栈 app 启动)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../knowledge/external-deps-and-gotchas.md | 2 +- docs/mvp/进度总账.md | 3 +- .../muse/MuseAiCandidateReviewService.java | 104 +++ .../muse/MuseAiRuntimeProjectionService.java | 84 ++- .../MuseAiCandidateReviewServiceTest.java | 63 ++ .../MuseAiRuntimeProjectionServiceTest.java | 6 + .../P1rContentMergeGeneratedSuggestionIT.java | 713 ++++++++++++++++++ 7 files changed, 967 insertions(+), 8 deletions(-) create mode 100644 muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewService.java create mode 100644 muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewServiceTest.java create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentMergeGeneratedSuggestionIT.java diff --git a/.agents/knowledge/external-deps-and-gotchas.md b/.agents/knowledge/external-deps-and-gotchas.md index 5945a762..f0fa0688 100644 --- a/.agents/knowledge/external-deps-and-gotchas.md +++ b/.agents/knowledge/external-deps-and-gotchas.md @@ -60,6 +60,6 @@ mise exec --cd muse-cloud -- mvn -pl muse-server -am test \ **起活体全栈 muse-server 的三大阻塞(到「用户在 app 里真能用」)**: 1. **远端 dev 库缺 yudao 基座 schema**:`muse_local` 有 Muse 表(muse_content_*/muse_ai_suggestion)但**无 `system_tenant` 等基座表、无 flyway_schema_history**;`muse_slice_live`(新建)全空。muse-server 装配 system/infra 等模块,Flyway 只迁 Muse(V1-V21,`sql/muse`),基座 schema **未 Flyway 化**(对抗复盘已记),故全栈 app 无法服务租户校验请求。起活体须先 provision 基座(yudao SQL dump)。**故本切片活体 UI e2e 未跑**;后端 merge 链路已由上述 IT 证明。 2. **studio AI 流 parser 漂移**(已修复 2026-06-14):`connectAIStream`(src/lib/sse.ts)原按 JSON `data.type` 单行分发,真后端(`MuseAiTaskStreamServiceImpl` 用 Spring `SseEmitter.name(event).data(json)`)把事件名放 SSE `event:` 行(`event:chunk/quality_check/done/error` + data 行 JSON 无 type)→ 旧实现真实生成流全收不到。修复=复用同文件 `createEventStreamParser`(按 `event:` 行分发 + id/comment/多行 data 拼接),`SSEEventHandler` 回调契约不变。**连带 gotcha**:done.data 的 `taskId`/`suggestionId` 后端是 Long→JSON **number**,而前端全链路(onDone 类型、采纳请求体 `MergeBlockSuggestionRequest`、OpenAPI)按 **string**——已在 sse.ts done 分支于解析边界 `String()` 归一(缺失值保持 undefined 以维持「无候选→禁用采纳」判定);mock/契约测试改用数字 suggestionId 复现后端形态。验证用 `./node_modules/.bin/` 的 tsc -b --force / vitest run(47) / vite build 全绿(corepack pnpm exec 会触发 install 校验失败,勿用)。 -3. **AI 运行时未写「审」字段**:`createRuntimeSuggestion` 不写 outputComplianceResultId/staticCheckResultId/licenseRestrictionSnapshot 且 source_status='verified'(merge 要 'active')→ 真实生成的候选不可合并。故活体 e2e 的「采纳」需走**已种(审齐)候选**,生成步骤需网络层桩掉(`e2e/accept-suggestion.spec.ts` 已写,只写不跑)。 +3. **AI 运行时未写「审」字段(已修复 2026-06-14,chip task_092cfc32)**:原 `createRuntimeSuggestion` 不写 outputComplianceResultId/staticCheckResultId/licenseRestrictionSnapshot 且 source_status='verified'(merge 要 'active')→ 真实生成的候选不可合并。现新增 `MuseAiCandidateReviewService` 做真实轻量审(输出合规扫描/静态检查/许可快照,通过才给可追溯审结果 ID),createRuntimeSuggestion 写齐三审字段(通过时)+ 顶层 content(脱敏 summary,供 facade 可用性)+ source_status='active' + 数值授权快照(envelope id 为数值时落 BIGINT 列,沿用 `MuseSuggestionServiceImpl` 的 envelope=authz 约定)。**遗留**:非数值 envelope id 暂无法落 BIGINT 授权快照列(已知列能力限制),待授权快照建模收口;完整 provider 正文按数据主权不持久化,采纳走 merge_after_edit(用户回传所审最终正文)。门禁 `P1rContentMergeGeneratedSuggestionIT`(真实 PG)证明:经真实 `createShadowSuggestionCandidate` 产出的候选可被 mergeBlockSuggestion 采纳写入 Canonical。 **本机替代与凭据(联调用)**:远端 Redis 需密码(未提供)→ 本机 `redis-server`(brew 8.x)+ `--requirepass` 替代(Redis 仅 session/cache 管道,非被测对象),infra.env 指 `127.0.0.1`。mock 登录:`application-local.yaml` `muse.security.mock-enable=true`,令牌 = mockSecret(`test`)+userId,即 `test1`=userId 1;tenant 来自请求头 `getTenantId(request)`。`~/.config/muse-repo/infra.env`(仓库外,**永不入库**)本会话已填 root PG 密码 + 本机 redis;注意 app 默认连用户 `muse_dev`(密码未知),联调改 `MUSE_POSTGRES_USERNAME=root`。**安全**:该文件含明文 root 口令,轮换/清理由用户掌握。 diff --git a/docs/mvp/进度总账.md b/docs/mvp/进度总账.md index 2ef0e905..71171786 100644 --- a/docs/mvp/进度总账.md +++ b/docs/mvp/进度总账.md @@ -46,7 +46,7 @@ > **2026-06-14 更新 —— P1 后端"环境阻塞"已解除**:此前"86 个 needs_verification 须真实 PG、本地不可验"的判断**已被实测推翻**。接通共享远端 PG(详见 §四 时间线 + [.agents/knowledge §四](../../.agents/knowledge/external-deps-and-gotchas.md))真跑 20 个 `P1r*IT`,**19/20 类零功能失败**——completed-approval / Flyway 迁移 / events-publish outbox 等后端路径在真实 PG 上获机械绿证据(非假绿)。**仍未验证/未达**:前端 muse-studio(无 FE 运行栈);唯一红 `P1rKnowledgeFlywayMigrationIT`(陈旧硬编码 V14,待改为动态版本);live-acceptance 中需 `MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 才真跑的用例;以及覆盖台账 completed≠端到端可用(FE 断链仍在)。 -> **2026-06-14 更新 —— MVP #1「前端 AI 候选闭环」代码已闭合 + 后端实证**:现状基线 §6.3 列首位的高危缺口(studio 未接 suggestion-merges、仅本地 setContent)已整改:前端真实编辑器接入唯一合法入口、后端 merge 主链路在真实 PG 上 5/5 绿(`P1rContentMergeSuggestionIT`)。**剩余到「用户在跑起来的 app 里真能用」的两个范围外阻塞**:远端 dev 库缺 yudao 基座 schema(全栈 app 起不来)、AI 运行时未写「审」字段(connectAIStream 的 SSE parser 漂移已修复)。详见 §四 时间线末条 + [.agents/knowledge §五](../../.agents/knowledge/external-deps-and-gotchas.md)。 +> **2026-06-14 更新 —— MVP #1「前端 AI 候选闭环」代码已闭合 + 后端实证**:现状基线 §6.3 列首位的高危缺口(studio 未接 suggestion-merges、仅本地 setContent)已整改:前端真实编辑器接入唯一合法入口、后端 merge 主链路在真实 PG 上 5/5 绿(`P1rContentMergeSuggestionIT`)。**剩余到「用户在跑起来的 app 里真能用」的范围外阻塞:仅 1 个**——远端 dev 库缺 yudao 基座 schema(全栈 app 起不来,需 provision)。connectAIStream 的 SSE parser 漂移、AI 运行时未写「审」字段**均已修复**(后者:AI 生成链路现真实产出审结果 + active + 数值授权快照 + content,生成候选可被 mergeBlockSuggestion 采纳,见 §四 时间线 + [.agents/knowledge §五](../../.agents/knowledge/external-deps-and-gotchas.md))。 --- @@ -79,4 +79,5 @@ 2026-06-14 P1 market 写路径整改(ultracode):member 暴露 MuseAccountRecordProjectionApi 写端口 + DTO,market 5 类去 member.dal → 豁免清空、BC 门全绿;附带补正 round-2 knowledge 旧测试假绿(JDK21 scoped 实跑:BcBoundary 1/0F+0 违例、knowledge 5/0F、member 4/0F、market 5 类全绿、契约/loop/覆盖门全绿,BUILD SUCCESS) 2026-06-14 P1 后端 real-PG IT 验收:接通共享远端 PG(100.64.0.8:5433,PG17.10;破本机 SOCKS 代理破坏 PG 协议的坑)真跑 20 个 P1r*IT,**19/20 类零功能失败**(~95 用例绿,含 completed-approval/Flyway 迁移/events-publish outbox + New-API live)。唯一红=P1rKnowledgeFlywayMigrationIT 硬编码断言 V14(schema 已 V21,陈旧用例,源自 commit 7155285、非本轮)。配方+代理坑入 [.agents/knowledge §四](../../.agents/knowledge/external-deps-and-gotchas.md)。 2026-06-14 MVP #1 纵切(AI候选→采纳入正文,据现状基线 §6.3 #1):前端真实编辑器(WorkspacePage)接入唯一合法入口 suggestion-merges——新增 useAcceptSuggestion + client 注入 Bearer + AIPanel 透传 suggestionId + CandidatePanel 异步防双写,**去除本地 setContent 绕过双轨主权**、connectAIStream 复用 createEventStreamParser 对齐真后端 SSE `event:` 线(修掉旧 `data.type` 漂移)(studio tsc/**47 单测**/build 全绿)。后端新增机械门禁 `P1rContentMergeSuggestionIT`:真实 PG + 真实 `AiSuggestionMergeProjectionFacade` 读真种 muse_ai_suggestion,**5/5 绿**(正向写 Canonical revision1→2/revision 冲突/幂等回放/非 pending/审缺失)。**未达(诚实)**:活体 UI e2e 未跑——阻塞于①远端 dev 库缺 yudao 基座 schema(system_tenant,全栈 app 起不来,需 provision)②AI 运行时未写「审」字段(生成流候选不可合并,chip task_092cfc32);均范围外。connectAIStream 的 SSE parser 漂移**已在本提交修复**。`e2e/accept-suggestion.spec.ts` 已写(只写不跑)。 +2026-06-14 AI「审」字段补齐(消除「生成候选永不可采纳」缺口,chip task_092cfc32):新增 `MuseAiCandidateReviewService`(真实轻量审——输出合规扫描/静态检查/许可快照,通过才给可追溯审结果 ID,否则不给→门禁如实拒绝);`MuseAiRuntimeProjectionService.createRuntimeSuggestion` 现写顶层 content(脱敏 summary)+ 三审字段 + `source_status`='active'(修 'verified')+ 数值授权快照(沿用 runtime permission envelope=authz 既有约定)。机械门禁 `P1rContentMergeGeneratedSuggestionIT`(真实 PG):经真实 `createShadowSuggestionCandidate` 产候选→断言 active/审/authz/content 由真实代码产出→`mergeBlockSuggestion`(merge_after_edit)写入 Canonical。验证:`MuseAiCandidateReviewServiceTest` 5/0、`MuseAiRuntimeProjectionServiceTest` 5/0(回归)、`P1rContentMergeGeneratedSuggestionIT` 1/0、`P1rContentMergeSuggestionIT` 5/0 = **16/16 绿**。遗留:非数值 envelope id 暂无法落 BIGINT 授权快照列(已知列能力限制),待授权快照建模收口。 ``` diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewService.java b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewService.java new file mode 100644 index 00000000..6cecf3b6 --- /dev/null +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewService.java @@ -0,0 +1,104 @@ +package cn.iocoder.muse.module.ai.application.muse; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * AI 生成候选的「审」服务:对生成正文做真实(轻量)审查——输出合规扫描 + 静态检查 + 许可限制快照, + * 供 {@link MuseAiRuntimeProjectionService} 在候选落库时产出 outputComplianceResultId / staticCheckResultId / + * licenseRestrictionSnapshot 三项「审」结果。 + * + *

WHY:Content 的 mergeBlockSuggestion 要求候选必须带齐这三项「审」结果且来源 active,才允许写入正文 Canonical + * (见 {@code AiSuggestionMergeProjectionFacade});否则视为「尚未通过先审后入的审」一律 unavailable。此前 AI 生成链路 + * 从不产出这些「审」字段,导致真实生成的候选永远无法被采纳。本服务让生成链路真实产出审结果(基于实际正文判定),而非硬编码。

+ * + *

边界与诚实:这是基于正文内容的真实但轻量的审(非完整合规/静态分析平台);审结果以「内容指纹」派生的可追溯 ID 表示, + * 完整判定明细随候选 quality_scores 落库可审计。任一项不通过 → {@link CandidateReview#passed()}=false, + * 调用方据此不写「审」字段(候选保持不可合并),绝不把未通过审查的输出伪装成可入正文——这正是反假绿的要点。

+ */ +@Component +public class MuseAiCandidateReviewService { + + /** 单块正文长度上限(字符);超过视为异常输出,静态检查不通过。 */ + private static final int MAX_CONTENT_LENGTH = 100_000; + /** 输出合规扫描的敏感凭据痕迹关键字(出现即判定为不可入正文的合规风险,防止把泄露密钥的输出写进正式正文)。 */ + private static final List CREDENTIAL_MARKERS = List.of( + "bearer ", "authorization:", "api_key", "apikey", "secret", "-----begin"); + + /** + * 对一段生成正文执行三项审查。 + * + * @param generationId 产出该候选的 generation ID(用于审结果可追溯 ID) + * @param content 生成正文(已脱敏 summary 或最终正文) + * @param sourceLicenseRestrictions 来源许可限制(无来源限制时传 null 或空列表) + * @return 审查结论;{@code passed=true} 时三项 ID/快照可写入候选「审」字段使其可合并 + */ + public CandidateReview review(Long generationId, String content, List sourceLicenseRestrictions) { + String text = content == null ? "" : content; + String fingerprint = fingerprint(generationId, text); + + // 1) 输出合规扫描:正文非空,且不含明显凭据痕迹。 + List complianceFindings = new ArrayList<>(); + if (!StringUtils.hasText(text)) { + complianceFindings.add("empty_content"); + } + String lower = text.toLowerCase(Locale.ROOT); + for (String marker : CREDENTIAL_MARKERS) { + if (lower.contains(marker)) { + complianceFindings.add("credential_marker:" + marker.trim()); + } + } + boolean compliancePassed = complianceFindings.isEmpty(); + + // 2) 静态检查:正文非空、长度在合理区间(防止超长/空白异常输出)。 + List staticFindings = new ArrayList<>(); + if (!StringUtils.hasText(text)) { + staticFindings.add("blank_text"); + } + if (text.length() > MAX_CONTENT_LENGTH) { + staticFindings.add("oversize:" + text.length()); + } + boolean staticPassed = staticFindings.isEmpty(); + + // 3) 许可限制快照:透传来源许可限制(AI 自生成、无来源限制时为空列表,语义=无限制)。 + List licenseRestrictions = sourceLicenseRestrictions == null + ? List.of() : List.copyOf(sourceLicenseRestrictions); + + boolean passed = compliancePassed && staticPassed; + Map findings = new LinkedHashMap<>(); + findings.put("compliancePassed", compliancePassed); + findings.put("complianceFindings", complianceFindings); + findings.put("staticPassed", staticPassed); + findings.put("staticFindings", staticFindings); + return new CandidateReview(passed, + passed ? "oc-" + fingerprint : null, + passed ? "sc-" + fingerprint : null, + licenseRestrictions, + findings); + } + + /** 审结果 ID 的内容指纹:绑定 generation + 正文(可追溯到「审了什么」),非随机、可重放一致。 */ + private String fingerprint(Long generationId, String text) { + long gen = generationId == null ? 0L : generationId; + return gen + "-" + Integer.toHexString(text.hashCode()) + "-" + text.length(); + } + + /** + * 审查结论。 + * + * @param passed 三项是否全部通过 + * @param outputComplianceResultId 输出合规结果 ID(passed=false 时为 null) + * @param staticCheckResultId 静态检查结果 ID(passed=false 时为 null) + * @param licenseRestrictions 许可限制快照(可为空列表=无限制) + * @param findings 审查明细(随候选 quality_scores 落库,可审计) + */ + public record CandidateReview(boolean passed, String outputComplianceResultId, String staticCheckResultId, + List licenseRestrictions, Map findings) { + } +} diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionService.java b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionService.java index b76c8af4..6115bf96 100644 --- a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionService.java +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionService.java @@ -20,7 +20,9 @@ import org.springframework.util.StringUtils; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -51,6 +53,8 @@ public class MuseAiRuntimeProjectionService { private MuseAiTaskEventMapper taskEventMapper; @Resource private MuseAiEventPublishOutboxService eventPublishOutboxService; + @Resource + private MuseAiCandidateReviewService candidateReviewService; @Transactional(rollbackFor = Exception.class) public void applyRuntimeResponse(MuseAiGenerationDO task, MuseAiJobDO job, @@ -160,25 +164,47 @@ public class MuseAiRuntimeProjectionService { return job.getRetryCount() == null ? 1 : job.getRetryCount() + 1; } + /** 仅当 runtime permission envelope id 为纯数值时,作为数值授权快照落 authorization_snapshot_id(BIGINT)列。 */ + private Long numericEnvelopeId(String envelopeId) { + if (!StringUtils.hasText(envelopeId)) { + return null; + } + try { + return Long.parseLong(envelopeId.trim()); + } catch (NumberFormatException ex) { + return null; + } + } + private Long createRuntimeSuggestion(MuseAiGenerationDO task, MuseAiJobDO job, MuseAiRuntimeClient.RuntimeResult result, Map qualityScores, String candidateType) { + // 解析候选正文(脱敏 summary;完整 provider 原文按数据主权不落库)+ 真实「审」:输出合规/静态检查/许可快照。 + String contentText = resolveContentText(result); + MuseAiCandidateReviewService.CandidateReview review = + candidateReviewService.review(task.getId(), contentText, sourceLicenseRestrictions(task)); MuseAiSuggestionDO suggestion = new MuseAiSuggestionDO(); suggestion.setWorkId(task.getWorkId()); suggestion.setChapterId(task.getChapterId()); suggestion.setBlockId(task.getBlockId()); suggestion.setGenerationId(task.getId()); suggestion.setCandidateType(candidateType); - suggestion.setContentSnapshot(JsonUtils.toJsonString(contentSnapshot(result, task.getSourceSnapshotId()))); + suggestion.setContentSnapshot(JsonUtils.toJsonString(contentSnapshot(result, task.getSourceSnapshotId(), contentText))); suggestion.setAgentId(task.getAgentId()); suggestion.setAgentVersion(task.getAgentVersionId() == null ? null : String.valueOf(task.getAgentVersionId())); suggestion.setStatus(SUGGESTION_PENDING); suggestion.setSourceRevision(sourceRevision(task.getSourceSummary())); suggestion.setDiffSummary(JsonUtils.toJsonString(diffSummary(result, task.getSourceSnapshotId(), - task.getRuntimePermissionEnvelopeId()))); - suggestion.setQualityScores(JsonUtils.toJsonString(Map.of("qualityScores", qualityScores))); - suggestion.setSourceStatus("verified"); + task.getRuntimePermissionEnvelopeId(), review))); + suggestion.setQualityScores(JsonUtils.toJsonString(reviewedQualityScores(qualityScores, review))); + // 来源状态:AI 生成候选在产出时其来源(本次生成)即 active;原 "verified" 既非来源状态机取值, + // 又会被 Content 合并门禁(要求 active)拒绝,导致真实生成的候选永不可采纳。 + suggestion.setSourceStatus("active"); + // 授权快照:沿用「runtime permission envelope = AI 候选授权快照」既有约定(见 MuseSuggestionServiceImpl); + // Content 合并门禁要求数值授权快照(authorization_snapshot_id 为 BIGINT 列),故仅当 envelope id 为数值时落列。 + // 非数值 envelope id 暂无法落该 BIGINT 列(已知列能力限制),此类候选不可自动合并,待授权快照建模收口。 + suggestion.setAuthorizationSnapshotId(numericEnvelopeId(task.getRuntimePermissionEnvelopeId())); suggestion.setSourceActionPolicy("allow_decision"); suggestion.setCommandId(commandIdForSuggestion(job, candidateType)); suggestion.setTenantId(task.getTenantId()); @@ -186,6 +212,15 @@ public class MuseAiRuntimeProjectionService { return suggestion.getId(); } + private Map reviewedQualityScores(Map qualityScores, + MuseAiCandidateReviewService.CandidateReview review) { + Map map = new LinkedHashMap<>(); + map.put("qualityScores", qualityScores); + // 审查明细随候选落库,使「审」结果可审计追溯。 + map.put("review", review.findings()); + return map; + } + private void appendTaskEvent(MuseAiGenerationDO task, MuseAiJobDO job, String eventType, String taskStatus, Map payloadSummary, String errorCode, String errorMessage) { if (!isValidTerminalEvent(eventType, taskStatus) || taskEventMapper.selectTerminalByTaskId(String.valueOf(task.getId())) != null) { @@ -216,21 +251,58 @@ public class MuseAiRuntimeProjectionService { || (EVENT_ERROR.equals(eventType) && STATUS_FAILED.equals(taskStatus)); } - private Map contentSnapshot(MuseAiRuntimeClient.RuntimeResult result, String sourceSnapshotId) { + private Map contentSnapshot(MuseAiRuntimeClient.RuntimeResult result, String sourceSnapshotId, + String contentText) { Map snapshot = new LinkedHashMap<>(); + // 顶层 content:供 Content 的 mergeBlockSuggestion facade 读取候选正文(脱敏 summary,非完整 provider 原文)。 + snapshot.put("content", contentText); snapshot.put("sourceSnapshotId", sourceSnapshotId); snapshot.put("outputSummary", result.outputSummary() == null ? Map.of() : result.outputSummary()); snapshot.put("finishReason", result.finishReason()); return snapshot; } + /** 从 runtime outputSummary 解析候选正文文本(优先 summary,再 content/text)。 */ + private String resolveContentText(MuseAiRuntimeClient.RuntimeResult result) { + if (result == null || result.outputSummary() == null) { + return null; + } + Map outputSummary = result.outputSummary(); + for (String key : new String[]{"summary", "content", "text"}) { + Object value = outputSummary.get(key); + if (value != null && StringUtils.hasText(String.valueOf(value))) { + return String.valueOf(value); + } + } + return null; + } + + /** 来源许可限制:source summary 带 licenseRestrictions 则透传,否则空(AI 自生成无来源限制)。 */ + private List sourceLicenseRestrictions(MuseAiGenerationDO task) { + Object raw = parseJsonMap(task.getSourceSummary()).get("licenseRestrictions"); + if (raw instanceof List list) { + List restrictions = new ArrayList<>(); + list.forEach(value -> restrictions.add(String.valueOf(value))); + return restrictions; + } + return List.of(); + } + private Map diffSummary(MuseAiRuntimeClient.RuntimeResult result, String sourceSnapshotId, - String permissionEnvelopeId) { + String permissionEnvelopeId, + MuseAiCandidateReviewService.CandidateReview review) { Map summary = new LinkedHashMap<>(); summary.put("sourceSnapshotId", sourceSnapshotId); summary.put("runtimePermissionEnvelopeId", permissionEnvelopeId); summary.put("finishReason", result.finishReason()); summary.put("providerRequestId", result.providerRequestId()); + // 「审」结果:仅在三项审查全部通过时写入,使候选成为 Content 可合并投影; + // 未通过则不写 → Content 合并门禁如实返回 EXTERNAL_OWNER_UNAVAILABLE(不假绿)。 + if (review.passed()) { + summary.put("outputComplianceResultId", review.outputComplianceResultId()); + summary.put("staticCheckResultId", review.staticCheckResultId()); + summary.put("licenseRestrictionSnapshot", review.licenseRestrictions()); + } return summary; } diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewServiceTest.java b/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewServiceTest.java new file mode 100644 index 00000000..afbe6d97 --- /dev/null +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiCandidateReviewServiceTest.java @@ -0,0 +1,63 @@ +package cn.iocoder.muse.module.ai.application.muse; + +import cn.iocoder.muse.module.ai.application.muse.MuseAiCandidateReviewService.CandidateReview; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link MuseAiCandidateReviewService} 单元测试:验证「审」是基于实际正文的真实判定(通过/不通过)、 + * 结果 ID 可追溯且仅在通过时给出、许可限制透传——而非硬编码。 + */ +class MuseAiCandidateReviewServiceTest { + + private final MuseAiCandidateReviewService service = new MuseAiCandidateReviewService(); + + @Test + void should_pass_forCleanContent_withTraceableResultIds() { + CandidateReview review = service.review(101L, "AI 优化后的正文:星海骤然铺展。", List.of()); + assertTrue(review.passed(), "正常正文应通过三项审查"); + assertNotNull(review.outputComplianceResultId()); + assertNotNull(review.staticCheckResultId()); + // 审结果 ID 绑定 generation,可追溯到「审了哪次生成」。 + assertTrue(review.outputComplianceResultId().startsWith("oc-101-")); + assertTrue(review.staticCheckResultId().startsWith("sc-101-")); + assertEquals(List.of(), review.licenseRestrictions(), "无来源限制时许可快照为空列表"); + } + + @Test + void should_passThroughSourceLicenseRestrictions() { + CandidateReview review = service.review(1L, "正文内容", List.of("no_export", "no_train")); + assertTrue(review.passed()); + assertEquals(List.of("no_export", "no_train"), review.licenseRestrictions(), "来源许可限制必须原样透传进快照"); + } + + @Test + void should_fail_forEmptyContent_withoutResultIds() { + CandidateReview review = service.review(1L, " ", List.of()); + assertFalse(review.passed(), "空白正文不应通过审查"); + // 不通过时不给审结果 ID → 调用方不会写「审」字段 → 合并门禁如实拒绝(不假绿)。 + assertNull(review.outputComplianceResultId()); + assertNull(review.staticCheckResultId()); + } + + @Test + void should_fail_forCredentialLeak() { + CandidateReview review = service.review(1L, "这里泄露了 Bearer abcdef123456 令牌", List.of()); + assertFalse(review.passed(), "含凭据痕迹的输出不得判为可入正文"); + assertNull(review.outputComplianceResultId()); + } + + @Test + void should_handleNullContentAndNullRestrictions() { + CandidateReview review = service.review(null, null, null); + assertFalse(review.passed()); + assertEquals(List.of(), review.licenseRestrictions()); + } +} diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionServiceTest.java b/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionServiceTest.java index 5a6d8179..db613a8f 100644 --- a/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionServiceTest.java +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeProjectionServiceTest.java @@ -18,6 +18,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import java.time.Instant; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,6 +44,8 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest { private MuseAiTaskEventMapper taskEventMapper; @Mock private MuseAiEventPublishOutboxService eventPublishOutboxService; + @Mock + private MuseAiCandidateReviewService candidateReviewService; @AfterEach void clearTenantContext() { @@ -86,6 +89,9 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest { MuseAiGenerationDO task = task("running"); MuseAiJobDO job = job("running"); when(jobMapper.selectByIdForUpdate(100L, 4001L)).thenReturn(job("running")); + // 成功路径会走 createRuntimeSuggestion 调用「审」服务;桩为通过,使候选落库逻辑可继续(本用例只断言 terminal event + outbox)。 + when(candidateReviewService.review(any(), any(), any())) + .thenReturn(new MuseAiCandidateReviewService.CandidateReview(true, "oc-x", "sc-x", List.of(), Map.of())); projectionService.applyRuntimeResponse(task, job, runtimeSuccess(), command()); diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentMergeGeneratedSuggestionIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentMergeGeneratedSuggestionIT.java new file mode 100644 index 00000000..8692dede --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentMergeGeneratedSuggestionIT.java @@ -0,0 +1,713 @@ +package cn.iocoder.muse.server.framework.api; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.muse.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import cn.iocoder.muse.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO; +import cn.iocoder.muse.framework.common.enums.UserTypeEnum; +import cn.iocoder.muse.framework.common.pojo.CommonResult; +import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.muse.framework.security.core.LoginUser; +import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.muse.framework.tenant.config.TenantProperties; +import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.muse.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration; +import cn.iocoder.muse.module.ai.application.muse.MuseAiCandidateReviewService; +import cn.iocoder.muse.module.ai.application.muse.MuseAiEventPublishOutboxService; +import cn.iocoder.muse.module.ai.application.muse.MuseAiRuntimeProjectionService; +import cn.iocoder.muse.module.ai.application.muse.facade.AiSuggestionMergeProjectionFacade; +import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient; +import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiJobDO; +import cn.iocoder.muse.module.content.application.ContentAuditServiceImpl; +import cn.iocoder.muse.module.content.application.ContentCommandServiceImpl; +import cn.iocoder.muse.module.content.application.ContentEventPublishOutboxServiceImpl; +import cn.iocoder.muse.module.content.application.ContentSourceServiceImpl; +import cn.iocoder.muse.module.content.application.facade.ContentAiSuggestionFacade; +import cn.iocoder.muse.module.content.controller.app.AppContentSourceController; +import cn.iocoder.muse.module.content.framework.config.MuseContentEventsProperties; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; +import org.apache.ibatis.annotations.Mapper; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P1R 「AI 生成候选可被采纳」机械门禁 IT —— 真实 PostgreSQL,证明经真实生成投影 + * ({@link MuseAiRuntimeProjectionService#createShadowSuggestionCandidate})产出的候选, + * 在真实「审」({@link MuseAiCandidateReviewService})+ source_status=active + 数值授权快照 + 顶层 content 之下, + * 被真实 {@link AiSuggestionMergeProjectionFacade} 判为可合并,并经 mergeBlockSuggestion 成功写入正文 Canonical。 + * + *

对照 {@code P1rContentMergeSuggestionIT}(手工种 审齐 候选验证 merge 主链路),本 IT 证明这些「审」字段 + * 由真实 AI 生成代码产出——即修复「生成候选永不可采纳」缺口。采纳走 merge_after_edit: + * 用户采纳时回传所审最终正文(完整 provider 原文按数据主权不持久化,候选 content 仅留脱敏 summary 作可用性/预览)。

+ */ +@SpringBootTest( + classes = P1rContentMergeGeneratedSuggestionIT.GeneratedMergeConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rContentMergeGeneratedSuggestionIT { + + private static final String TARGET_VERSION = "21"; + private static final Long TENANT_ID = 100L; + private static final Long LOGIN_USER_ID = 9001L; + private static final String API_VERSION = "1"; + /** 模拟生成 runtime 返回的脱敏 summary(落为候选 content,供合并门禁可用性检查/预览)。 */ + private static final String GENERATED_CONTENT = "AI 优化后的正文摘要:星海在视野中骤然铺展开来。"; + private static final String ORIGINAL_CONTENT = "原始正文,待 AI 优化。"; + /** 数值 runtime permission envelope id → 落为数值授权快照,使生成候选满足合并门禁。 */ + private static final String NUMERIC_ENVELOPE_ID = "9001"; + private static final Set CREDENTIAL_QUERY_KEYS = Set.of( + "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", + "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"); + + private static volatile GeneratedMergeSettings cachedSettings; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @Autowired + private DataSource dataSource; + @Autowired + private WebApplicationContext webApplicationContext; + @Autowired + private MuseAiRuntimeProjectionService projectionService; + + private MockMvc mockMvc; + private GeneratedSeed seed; + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + GeneratedMergeSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-content-merge-generated-suggestion-it"); + registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.content"); + registry.add("muse.web.admin-ui.url", () -> "http://localhost"); + registry.add("spring.datasource.url", settings::jdbcUrl); + registry.add("spring.datasource.username", settings::jdbcUser); + registry.add("spring.datasource.password", settings::jdbcPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.main.banner-mode", () -> "off"); + registry.add("spring.main.lazy-initialization", () -> "true"); + registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO"); + registry.add("muse.content.events.publish-worker.enabled", () -> "false"); + registry.add("muse.content.events.publish-worker.max-attempt", () -> "5"); + } + + @BeforeAll + void migrateSchema() { + GeneratedMergeSettings settings = settings(); + silenceFlywayInfoLogs(); + Flyway flyway = Flyway.configure() + .dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword()) + .locations(resolveMuseSqlLocation(settings.flywayLocations())) + .schemas("public") + .defaultSchema("public") + .target(TARGET_VERSION) + .cleanDisabled(false) + .load(); + cleanSchema(flyway, settings); + MigrateResult result = migrateSchema(flyway, settings); + assertEquals(21, result.migrationsExecuted, + "generated merge IT 必须在隔离库执行 V1-V21 全量迁移,实际: " + result.migrationsExecuted); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetTables(); + this.seed = seedContentFacts(); + setRuntimeContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_acceptGeneratedSuggestionIntoCanonical_onRealPostgresql() throws Exception { + // 1) 经真实生成投影产出候选(走 createRuntimeSuggestion:写 审 字段 + active + 数值授权快照 + 顶层 content)。 + MuseAiJobDO job = new MuseAiJobDO(); + job.setTenantId(TENANT_ID); + job.setOwnerUserId(LOGIN_USER_ID); + job.setCommandId("gen-merge-cmd"); + job.setInputSummary("{}"); + job.setRetryCount(0); + job.setRequestHash("req-hash-1"); + job.setJobId("job-1"); + job.setCorrelationId("corr-1"); + job.setRuntimePermissionEnvelopeId(NUMERIC_ENVELOPE_ID); + MuseAiRuntimeClient.RuntimeResult result = new MuseAiRuntimeClient.RuntimeResult( + "corr-1", "success", Map.of("summary", GENERATED_CONTENT), Map.of(), 0L, "prov-1", "stop", null); + Long suggestionId = projectionService.createShadowSuggestionCandidate( + job, result, seed.workId(), null, null, "src-snap-1", Map.of("sourceRevision", 1)); + + // 2) 断言「可合并」事实由真实生成代码产出(非手工种):active + 数值授权快照 + 三审字段 + 顶层 content。 + assertEquals("active", suggestionSourceStatus(suggestionId), "生成候选来源状态必须为 active(原 verified 会被合并门禁拒绝)"); + assertEquals(9001L, suggestionAuthorizationSnapshotId(suggestionId), "数值 envelope id 必须落为数值授权快照"); + String diffSummary = suggestionDiffSummary(suggestionId); + assertTrue(diffSummary.contains("outputComplianceResultId"), "diff_summary 必须含输出合规审结果 ID"); + assertTrue(diffSummary.contains("staticCheckResultId"), "diff_summary 必须含静态检查审结果 ID"); + assertTrue(diffSummary.contains("licenseRestrictionSnapshot"), "diff_summary 必须含许可限制快照"); + assertTrue(suggestionContentSnapshot(suggestionId).contains("\"content\""), + "content_snapshot 必须含顶层 content 供合并门禁读取"); + + // 3) createShadowSuggestionCandidate 为 work 级(不带 block);对齐到目标 block 后即可走块级合并。 + // 仅对齐 block/chapter 作用域(由上游 task 设定);本任务修复的 审/active/authz/content 均来自真实生成代码,未改。 + alignSuggestionToBlock(suggestionId, seed.chapterId(), seed.blockId()); + + // 4) 真实 mergeBlockSuggestion(merge_after_edit:用户采纳时回传所审最终正文,符合「完整正文不持久化」数据主权)。 + String finalContent = "用户审阅 AI 候选后确认并微调的最终正文。"; + mockMvc.perform(post("/app-api/muse/works/{workId}/blocks/{blockId}/suggestion-merges", + seed.workId(), seed.blockId()) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(acceptAfterEditBody("gen-accept", suggestionId, 1, finalContent))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.suggestionId").value(suggestionId.intValue())) + .andExpect(jsonPath("$.data.newRevision").value(2)) + .andExpect(jsonPath("$.data.sourceAttribution.sources", hasSize(1))) + .andExpect(jsonPath("$.data.sourceAttribution.sources[0].sourceType").value("ai_suggestion")); + + // 5) 穿透持久层:用户确认的最终正文真的写进了 Canonical Block,revision 推进。 + assertEquals(2, blockRevision(seed.blockId()), "采纳生成候选必须把 Block revision 推进到 2"); + assertEquals(finalContent, blockContent(seed.blockId()), "采纳必须把用户确认的最终正文写入 Canonical Block"); + } + + // —— 请求体 —— // + + private String acceptAfterEditBody(String commandId, Long suggestionId, int expectedBlockRevision, String finalContent) { + return """ + { + "commandId": "%s", + "suggestionId": %d, + "expectedBlockRevision": %d, + "decisionType": "modify_then_merge", + "mergeMode": "merge_after_edit", + "finalContent": "%s", + "sourceSnapshot": { "sourceType": "ai_suggestion" }, + "auditReason": "p1r-accept-generated" + } + """.formatted(commandId, suggestionId, expectedBlockRevision, finalContent); + } + + // —— 运行期上下文 —— // + + private void setRuntimeContext() { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(LOGIN_USER_ID); + loginUser.setUserType(UserTypeEnum.MEMBER.getValue()); + loginUser.setTenantId(TENANT_ID); + loginUser.setVisitTenantId(TENANT_ID); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + // —— 种子与重置 —— // + + private void resetTables() throws SQLException { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(""" + TRUNCATE TABLE + muse_ai_suggestion, + muse_ai_generation, + muse_content_event_publish_outbox, + muse_content_command_log, + muse_content_block_source_attribution, + muse_content_block, + muse_content_chapter, + muse_content_work + RESTART IDENTITY CASCADE + """); + } + } + + private GeneratedSeed seedContentFacts() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + Long workId = insertWork(connection); + Long chapterId = insertChapter(connection, workId); + Long blockId = insertBlock(connection, workId, chapterId); + return new GeneratedSeed(workId, chapterId, blockId); + } + } + + private Long insertWork(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_work(owner_user_id, title, description, genre, status, word_count, + chapter_count, revision, tenant_id) + VALUES (?, 'Generated Merge Work', 'desc', 'fiction', 'draft', 8, 1, 1, ?) + RETURNING id + """)) { + statement.setLong(1, LOGIN_USER_ID); + statement.setLong(2, TENANT_ID); + return returningId(statement, "work"); + } + } + + private Long insertChapter(Connection connection, Long workId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_chapter(work_id, title, order_no, status, revision, tenant_id) + VALUES (?, 'Chapter A', 1, 'draft', 1, ?) + RETURNING id + """)) { + statement.setLong(1, workId); + statement.setLong(2, TENANT_ID); + return returningId(statement, "chapter"); + } + } + + private Long insertBlock(Connection connection, Long workId, Long chapterId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_block(work_id, chapter_id, order_no, block_type, title, content_text, + revision, word_count, tenant_id) + VALUES (?, ?, 1, 'scene', 'Block A', ?, 1, ?, ?) + RETURNING id + """)) { + statement.setLong(1, workId); + statement.setLong(2, chapterId); + statement.setString(3, ORIGINAL_CONTENT); + statement.setInt(4, ORIGINAL_CONTENT.length()); + statement.setLong(5, TENANT_ID); + return returningId(statement, "block"); + } + } + + private void alignSuggestionToBlock(Long suggestionId, Long chapterId, Long blockId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE muse_ai_suggestion SET chapter_id = ?, block_id = ? WHERE tenant_id = ? AND id = ?")) { + statement.setLong(1, chapterId); + statement.setLong(2, blockId); + statement.setLong(3, TENANT_ID); + statement.setLong(4, suggestionId); + assertEquals(1, statement.executeUpdate(), "对齐候选 block 作用域必须命中一行"); + } + } + + private Long returningId(PreparedStatement statement, String name) throws SQLException { + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed " + name + " 必须返回 id"); + return resultSet.getLong(1); + } + } + + // —— 事实查询 —— // + + private String suggestionSourceStatus(Long suggestionId) throws SQLException { + return queryString("SELECT source_status FROM muse_ai_suggestion WHERE tenant_id = ? AND id = ?", + TENANT_ID, suggestionId); + } + + private Long suggestionAuthorizationSnapshotId(Long suggestionId) throws SQLException { + return queryLong("SELECT authorization_snapshot_id FROM muse_ai_suggestion WHERE tenant_id = ? AND id = ?", + TENANT_ID, suggestionId); + } + + private String suggestionDiffSummary(Long suggestionId) throws SQLException { + return queryString("SELECT diff_summary::text FROM muse_ai_suggestion WHERE tenant_id = ? AND id = ?", + TENANT_ID, suggestionId); + } + + private String suggestionContentSnapshot(Long suggestionId) throws SQLException { + return queryString("SELECT content_snapshot::text FROM muse_ai_suggestion WHERE tenant_id = ? AND id = ?", + TENANT_ID, suggestionId); + } + + private Integer blockRevision(Long blockId) throws SQLException { + return queryInt("SELECT revision FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId); + } + + private String blockContent(Long blockId) throws SQLException { + return queryString("SELECT content_text FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId); + } + + private int queryInt(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "计数查询必须返回一行"); + return resultSet.getInt(1); + } + } + } + + private Long queryLong(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "事实查询必须返回一行"); + long value = resultSet.getLong(1); + return resultSet.wasNull() ? null : value; + } + } + } + + private String queryString(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "事实查询必须返回一行"); + return resultSet.getString(1); + } + } + } + + private void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int i = 0; i < args.length; i++) { + Object value = args[i]; + if (value instanceof Long longValue) { + statement.setLong(i + 1, longValue); + } else if (value instanceof Integer intValue) { + statement.setInt(i + 1, intValue); + } else { + statement.setString(i + 1, String.valueOf(value)); + } + } + } + + // —— 连接设置与脱敏守卫(与既有 P1r IT 一致)—— // + + private static GeneratedMergeSettings settings() { + if (cachedSettings == null) { + cachedSettings = GeneratedMergeSettings.fromPropertiesAndEnvironment(); + } + return cachedSettings; + } + + private static void cleanSchema(Flyway flyway, GeneratedMergeSettings settings) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, GeneratedMergeSettings settings) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, GeneratedMergeSettings settings, + RuntimeException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace("for user '" + settings.jdbcUser() + "'", "for user ''"); + return new AssertionError(action + ": " + sanitizedMessage); + } + + 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) { + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static void assertNoPasswordSystemProperties() { + Properties properties = System.getProperties(); + List passwordProperties = properties.stringPropertyNames().stream() + .filter(P1rContentMergeGeneratedSuggestionIT::isForbiddenPasswordSystemProperty) + .sorted() + .toList(); + assertTrue(passwordProperties.isEmpty(), + "数据库密码不能通过 JVM system property 传入: " + passwordProperties); + } + + private static boolean isForbiddenPasswordSystemProperty(String name) { + String normalized = name.toLowerCase(Locale.ROOT); + return normalized.contains("password") + && (normalized.startsWith("p1r.") + || normalized.startsWith("p1r_") + || normalized.contains(".flyway.") + || normalized.contains(".content.") + || normalized.contains(".datasource.")); + } + + 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); + } + assertTrue(!isCredentialQueryKey(key), + "p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入"); + } + } + + private static boolean isCredentialQueryKey(String rawKey) { + 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) { + String databaseName = jdbcDatabaseName(url); + assertTrue(databaseName.endsWith("_test"), + "p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url)); + } + + private static String resolveMuseSqlLocation(String requestedLocations) { + assertEquals("filesystem:sql/muse", requestedLocations, + "generated merge IT 要求显式使用 filesystem:sql/muse"); + 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 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) { + 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 ? "" : "?"; + } + + private static String maskJdbcHost(String urlPart) { + return urlPart.replaceAll("//([^:/?#]+)", "//"); + } + + private static void redactFlywaySystemProperties(String url, String user) { + captureOriginalFlywaySystemProperties(); + System.setProperty("p1r.flyway.url", maskedUrl(url)); + System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : ""); + } + + private static void captureOriginalFlywaySystemProperties() { + if (originalFlywayPropertiesCaptured) { + return; + } + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty); + restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty); + } + + private static void restoreSystemProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + return; + } + System.setProperty(name, value); + } + + 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) { + // 日志实现不是 logback 时不影响迁移;测试自身仍只输出脱敏 URL。 + } + } + + private record GeneratedSeed(Long workId, Long chapterId, Long blockId) { + } + + private record GeneratedMergeSettings(String jdbcUrl, String jdbcUser, String jdbcPassword, String flywayLocations) { + + static GeneratedMergeSettings fromPropertiesAndEnvironment() { + assertNoPasswordSystemProperties(); + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String locations = requiredProperty("p1r.flyway.locations"); + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + return new GeneratedMergeSettings(url, user, password, locations); + } + } + + @SpringBootConfiguration + // 纳入 AI 的 suggestion/generation/job/event mapper(真实生成投影 + 合并 facade 需读写 muse_ai_* 表)。 + @MapperScan(basePackages = "cn.iocoder.muse.module.ai.dal.mysql.muse", annotationClass = Mapper.class) + @ImportAutoConfiguration({ + JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + TransactionAutoConfiguration.class, + RestTemplateAutoConfiguration.class, + WebMvcAutoConfiguration.class, + MuseDataSourceAutoConfiguration.class, + MuseMybatisAutoConfiguration.class, + MybatisPlusAutoConfiguration.class, + MybatisPlusJoinAutoConfiguration.class, + MuseWebAutoConfiguration.class + }) + @Import({ + AppContentSourceController.class, + ContentSourceServiceImpl.class, + ContentCommandServiceImpl.class, + ContentAuditServiceImpl.class, + ContentEventPublishOutboxServiceImpl.class, + MuseAiRuntimeProjectionService.class, + MuseAiCandidateReviewService.class, + SpringUtil.class + }) + static class GeneratedMergeConfiguration { + + @Bean + MuseContentEventsProperties museContentEventsProperties() { + return new MuseContentEventsProperties(); + } + + /** 真实跨 BC 合并 facade:从真实生成产出的 muse_ai_suggestion 读「可合并投影」。 */ + @Bean + ContentAiSuggestionFacade contentAiSuggestionFacade() { + return new AiSuggestionMergeProjectionFacade(); + } + + /** 生成 shadow 候选路径不触发 terminal event;提供空实现满足投影服务注入。 */ + @Bean + MuseAiEventPublishOutboxService museAiEventPublishOutboxService() { + return event -> { + }; + } + + @Bean + TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + @Bean + ApiErrorLogCommonApi apiErrorLogCommonApi() { + return new ApiErrorLogCommonApi() { + @Override + public CommonResult createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) { + return CommonResult.success(true); + } + }; + } + } +}