feat(p1-ai): AI 生成候选补齐「审」字段+active+授权快照,打通生成→采纳

消除「经真实 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) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-14 09:58:49 -07:00
parent fe75b666b9
commit e8f616d5a5
7 changed files with 967 additions and 8 deletions

View File

@ -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 口令,轮换/清理由用户掌握。

View File

@ -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 授权快照列(已知列能力限制),待授权快照建模收口。
```

View File

@ -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 三项结果
*
* <p><b>WHY</b>:Content mergeBlockSuggestion 要求候选必须带齐这三项结果且来源 active,才允许写入正文 Canonical
* ( {@code AiSuggestionMergeProjectionFacade});否则视为尚未通过先审后入的审一律 unavailable此前 AI 生成链路
* 从不产出这些字段,导致真实生成的候选永远无法被采纳本服务让生成链路真实产出审结果(基于实际正文判定),而非硬编码</p>
*
* <p><b>边界与诚实</b>:这是基于正文内容的真实但轻量的审(非完整合规/静态分析平台);审结果以内容指纹派生的可追溯 ID 表示,
* 完整判定明细随候选 quality_scores 落库可审计任一项不通过 {@link CandidateReview#passed()}=false,
* 调用方据此<strong>不写字段</strong>(候选保持不可合并),绝不把未通过审查的输出伪装成可入正文这正是反假绿的要点</p>
*/
@Component
public class MuseAiCandidateReviewService {
/** 单块正文长度上限(字符);超过视为异常输出,静态检查不通过。 */
private static final int MAX_CONTENT_LENGTH = 100_000;
/** 输出合规扫描的敏感凭据痕迹关键字(出现即判定为不可入正文的合规风险,防止把泄露密钥的输出写进正式正文)。 */
private static final List<String> 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<String> sourceLicenseRestrictions) {
String text = content == null ? "" : content;
String fingerprint = fingerprint(generationId, text);
// 1) 输出合规扫描:正文非空,且不含明显凭据痕迹
List<String> 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<String> 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<String> licenseRestrictions = sourceLicenseRestrictions == null
? List.of() : List.copyOf(sourceLicenseRestrictions);
boolean passed = compliancePassed && staticPassed;
Map<String, Object> 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<String> licenseRestrictions, Map<String, Object> findings) {
}
}

View File

@ -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<String, Object> 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<String, Object> reviewedQualityScores(Map<String, Object> qualityScores,
MuseAiCandidateReviewService.CandidateReview review) {
Map<String, Object> 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<String, Object> 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<String, Object> contentSnapshot(MuseAiRuntimeClient.RuntimeResult result, String sourceSnapshotId) {
private Map<String, Object> contentSnapshot(MuseAiRuntimeClient.RuntimeResult result, String sourceSnapshotId,
String contentText) {
Map<String, Object> 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<String, Object> 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<String> sourceLicenseRestrictions(MuseAiGenerationDO task) {
Object raw = parseJsonMap(task.getSourceSummary()).get("licenseRestrictions");
if (raw instanceof List<?> list) {
List<String> restrictions = new ArrayList<>();
list.forEach(value -> restrictions.add(String.valueOf(value)));
return restrictions;
}
return List.of();
}
private Map<String, Object> diffSummary(MuseAiRuntimeClient.RuntimeResult result, String sourceSnapshotId,
String permissionEnvelopeId) {
String permissionEnvelopeId,
MuseAiCandidateReviewService.CandidateReview review) {
Map<String, Object> 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;
}

View File

@ -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());
}
}

View File

@ -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());

View File

@ -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
*
* <p>对照 {@code P1rContentMergeSuggestionIT}(手工种 审齐 候选验证 merge 主链路), IT 证明这些字段
* <strong>真实 AI 生成代码</strong>产出即修复生成候选永不可采纳缺口采纳走 merge_after_edit:
* 用户采纳时回传所审最终正文(完整 provider 原文按数据主权不持久化,候选 content 仅留脱敏 summary 作可用性/预览)</p>
*/
@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<String> 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 '<user-redacted>'");
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<String> 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 ? "" : "?<query-redacted>";
}
private static String maskJdbcHost(String urlPart) {
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
}
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() ? "<user-redacted>" : "<user-redacted>");
}
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<Boolean> createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
return CommonResult.success(true);
}
};
}
}
}