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:
parent
fe75b666b9
commit
e8f616d5a5
@ -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 口令,轮换/清理由用户掌握。
|
||||
|
||||
@ -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 授权快照列(已知列能力限制),待授权快照建模收口。
|
||||
```
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user