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);
+ }
+ };
+ }
+ }
+}