feat(p1-mvp): AI候选采纳入正文纵切——前端接 suggestion-merges + 后端 real-PG 机械门禁
据现状基线 §6.3 #1(前端 AI 候选闭环断链)整改:
前端(muse-studio):
- WorkspacePage 真实编辑器接入唯一合法入口 POST .../suggestion-merges(新增 useAcceptSuggestion),
去除 EditorPage 仅本地 setContent 的绕过(违背 Shadow→Canonical 主权)
- client.ts 注入 Authorization Bearer(后端 Sa-Token 鉴权);新增 auth.ts 统一令牌读取,sse.ts 复用去重
- AIPanel 透传 suggestionId;connectAIStream 复用 createEventStreamParser 对齐真后端 SSE event: 线(修旧 data.type 漂移);
sse.ts onDone 类型对齐 {taskId,suggestionId};CandidatePanel 异步采纳+防双写
- tsc / 47 单测 / build 全绿
后端(muse-cloud):
- 新增机械门禁 P1rContentMergeSuggestionIT:真实 PG + 真实 AiSuggestionMergeProjectionFacade 读真种 muse_ai_suggestion
- 5/5 绿:正向写 Canonical(rev1→2)/revision 冲突不脏写/幂等回放/非 pending/「审」字段缺失
未达(诚实):活体全栈 UI e2e 未跑——阻塞于①远端 dev 库缺 yudao 基座 schema(全栈 app 起不来)
②AI 运行时未写「审」字段(生成流候选不可合并,chip task_092cfc32);均范围外。
connectAIStream 的 SSE parser 漂移已在本提交修复。e2e/accept-suggestion.spec.ts 只写不跑。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1a76a14995
commit
fe75b666b9
@ -52,3 +52,14 @@ mise exec --cd muse-cloud -- mvn -pl muse-server -am test \
|
||||
要点:① 用 **`test` 阶段 + `-Dtest=<IT类>`**(surefire 显式点名即可跑 `*IT`),**别用 `verify`/`integration-test`**——那会触发 `package`/spring-boot repackage 把 member-server 等打成 fat jar,破坏跨模块编译(`package ... does not exist`),且 surefire 会先跑到平台陈旧红(`QiniuSmsClientTest` 时区)而中止整个 reactor。② `p1r.flyway.url` 库名**必须 `_test` 结尾**(IT 有 assertTestDatabaseUrl 守卫 + 会 `flyway.clean()` DROP 全库,**绝不能指 muse_local / muse**);各 IT 用各自专属 `_test` 库(`muse_p1r_*_test` 系列已存在;缺的用 root `CREATE DATABASE`,跑完可 DROP)。③ `p1r.flyway.locations` 必须**恰为** `filesystem:sql/muse`(IT 断言该精确字符串,再从 `user.dir` 上溯定位 `sql/muse`)。④ 密码**只走 env**(`MUSE_POSTGRES_PASSWORD`/`P1R_FLYWAY_PASSWORD`;IT 有 assertNoPasswordSystemProperties,用 -D 传会红)。⑤ live-acceptance IT 需 `set -a; . scripts/dev/p1r-external-acceptance.env; set +a` 载入 token,且部分用例额外需 `MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 才真跑(否则 assumeTrue 跳过、显示 Skipped)。
|
||||
|
||||
> 远端 PG 实为 **PostgreSQL 17.10**(§一表内"15"为旧记,以此为准),超管用户 `root`;本机无本地 PG(127.0.0.1:5432 关闭)。批量顺序跑见交付报告(禁止并发 maven,会损坏 target)。
|
||||
|
||||
## 五、MVP #1「AI 候选采纳」纵切:后端门禁 + 活体全栈三大阻塞(2026-06-14 实证)
|
||||
|
||||
**后端机械门禁 IT(已绿,确定性)**:`muse-server/.../api/P1rContentMergeSuggestionIT`——真实 PG + 真实 `AiSuggestionMergeProjectionFacade`(`@Bean` 直接实例化注入真实 `MuseAiSuggestionMapper`,规避 `@ConditionalOnBean` 时序;配套 `@MapperScan("cn.iocoder.muse.module.ai.dal.mysql.muse")`)读真种 `muse_ai_suggestion`,经 MockMvc 打 `POST /app-api/muse/works/{workId}/blocks/{blockId}/suggestion-merges`。5 路径:正向写 Canonical(revision1→2)/revision 冲突/幂等回放/非 pending/「审」缺失。跑法同 §四配方,`-Dtest=P1rContentMergeSuggestionIT`,**需预建 `muse_p1r_merge_slice_test` 库**(IT 自身 flyway.clean+迁移)。可合并候选的种子契约见 IT 内 `insertMergeableSuggestion`:status=pending、source_status=active、数值 authorization_snapshot_id、content_snapshot 带 content、diff_summary 带齐 outputComplianceResultId/staticCheckResultId/licenseRestrictionSnapshot(facade 缺一即判 unavailable)。
|
||||
|
||||
**起活体全栈 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` 已写,只写不跑)。
|
||||
|
||||
**本机替代与凭据(联调用)**:远端 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,6 +46,8 @@
|
||||
|
||||
> **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)。
|
||||
|
||||
---
|
||||
|
||||
## 三、最大共性风险(跨模块,来自基线)
|
||||
@ -76,4 +78,5 @@
|
||||
2026-06-14 P1 harness 验证:消除 BC 违例 ContentMuseWorkOwnerFacade(content-api 端口 + AI 改消费)→ ArchUnit 豁免删除、门禁收紧(反向红 31 例 / 正向绿;13+7 单测)
|
||||
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` 已写(只写不跑)。
|
||||
```
|
||||
|
||||
@ -0,0 +1,802 @@
|
||||
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.facade.AiSuggestionMergeProjectionFacade;
|
||||
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.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_EXTERNAL_OWNER_UNAVAILABLE;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_INVALID_STATUS_TRANSITION;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_REVISION_CONFLICT;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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 Content «AI 候选采纳入正文» 机械门禁 IT —— 真实 PostgreSQL + 真实跨 BC facade。
|
||||
*
|
||||
* <p>这是 MVP #1「前端 AI 候选闭环」的后端纵深门禁:证明 studio 即将调用的唯一合法入口
|
||||
* {@code POST /app-api/muse/works/{workId}/blocks/{blockId}/suggestion-merges} 在真实 PG 上端到端成立——
|
||||
* 经真实 {@link AiSuggestionMergeProjectionFacade} 从真种的 {@code muse_ai_suggestion} 读「可合并投影」,
|
||||
* 通过 {@link ContentSourceServiceImpl} 的 15 项前置校验后,把 Shadow 候选写入 Canonical {@code muse_content_block},
|
||||
* 并落来源归因 + outbox 事件 + 审计,全程幂等与乐观锁。</p>
|
||||
*
|
||||
* <p><b>反假绿要点</b>:① 用真实 facade 读真实 AI 表(非桩),只有「审」字段齐 + status=pending + source_status=active
|
||||
* 才可合并;② 负路径(revision 冲突 / 非 pending / 审缺失)必须报错且不脏写;③ 幂等回放不得二次写。
|
||||
* 只连显式传入的 {@code _test} 库(assertTestDatabaseUrl 守卫),密码只从 env 读(assertNoPasswordSystemProperties)。</p>
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = P1rContentMergeSuggestionIT.MergeSuggestionConfiguration.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.MOCK
|
||||
)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class P1rContentMergeSuggestionIT {
|
||||
|
||||
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";
|
||||
/** 种子候选正文,采纳后应原样写入 Canonical Block。 */
|
||||
private static final String SUGGESTION_CONTENT = "AI 优化后的正文:星海在视野中骤然铺展开来。";
|
||||
private static final String ORIGINAL_CONTENT = "原始正文,待 AI 优化。";
|
||||
/** 种子授权快照(数值),与请求快照一致避免 409。 */
|
||||
private static final long AUTHORIZATION_SNAPSHOT_ID = 9001L;
|
||||
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 MergeSuggestionSettings cachedSettings;
|
||||
private static volatile boolean originalFlywayPropertiesCaptured;
|
||||
private static volatile String originalFlywayUrlSystemProperty;
|
||||
private static volatile String originalFlywayUserSystemProperty;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
@Autowired
|
||||
private WebApplicationContext webApplicationContext;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private MergeSeed seed;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void registerProperties(DynamicPropertyRegistry registry) {
|
||||
MergeSuggestionSettings settings = settings();
|
||||
redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
|
||||
registry.add("spring.application.name", () -> "p1r-content-merge-suggestion-it");
|
||||
// base-package 驱动框架的 @MapperScan 扫到 Content 自有 mapper;AI 的 suggestion mapper 由本类配置上的
|
||||
// 显式 @MapperScan 单独纳入(真实 facade 需要它读 muse_ai_suggestion)。
|
||||
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() {
|
||||
MergeSuggestionSettings 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,
|
||||
"merge suggestion IT 必须在隔离库执行 V1-V21 全量迁移,实际: " + result.migrationsExecuted);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
|
||||
resetTables();
|
||||
this.seed = seedMergeableFacts();
|
||||
setRuntimeContext();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void restoreFlywaySystemProperties() {
|
||||
restoreOriginalFlywaySystemProperties();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_mergeAiSuggestionIntoCanonicalBlock_onRealPostgresql() throws Exception {
|
||||
String commandId = "p1r-merge-accept";
|
||||
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(acceptBody(commandId, seed.suggestionId(), 1)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.blockId").value(seed.blockId().intValue()))
|
||||
.andExpect(jsonPath("$.data.suggestionId").value(seed.suggestionId().intValue()))
|
||||
.andExpect(jsonPath("$.data.newRevision").value(2))
|
||||
.andExpect(jsonPath("$.data.decisionType").value("accept"))
|
||||
.andExpect(jsonPath("$.data.sourceAttribution.sources", hasSize(1)))
|
||||
.andExpect(jsonPath("$.data.sourceAttribution.sources[0].sourceType").value("ai_suggestion"))
|
||||
.andExpect(jsonPath("$.data.sourceAttribution.lineage.parentSuggestionId")
|
||||
.value(String.valueOf(seed.suggestionId())));
|
||||
|
||||
// 穿透持久层:候选正文必须真的写进 Canonical Block,revision 单调推进,而非前端本地替换。
|
||||
assertEquals(2, blockRevision(seed.blockId()), "采纳必须把 Block revision 从 1 推进到 2");
|
||||
assertEquals(SUGGESTION_CONTENT, blockContent(seed.blockId()), "采纳必须把候选正文写入 Canonical Block");
|
||||
assertEquals(SUGGESTION_CONTENT.length(), blockWordCount(seed.blockId()), "word_count 必须来自合并后的正文");
|
||||
assertEquals(1, sourceAttributionCount(seed.blockId(), 2), "采纳必须为新 revision 写一条 ai_suggestion 来源归因");
|
||||
assertEquals(1, outboxCount(), "采纳必须写一条 Content Events outbox(驱动 SSE 回流)");
|
||||
assertEquals(1, commandCount(commandId), "采纳成功必须写一条幂等命令事实");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMerge_onBlockRevisionConflict_withoutDirtyWrite() throws Exception {
|
||||
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(acceptBody("p1r-merge-stale", seed.suggestionId(), 999)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_REVISION_CONFLICT.getCode()))
|
||||
.andExpect(jsonPath("$.msg", containsString("版本")));
|
||||
|
||||
assertEquals(1, blockRevision(seed.blockId()), "revision 冲突不得推进 Block revision");
|
||||
assertEquals(ORIGINAL_CONTENT, blockContent(seed.blockId()), "revision 冲突不得改写 Canonical 正文");
|
||||
assertEquals(0, sourceAttributionCount(seed.blockId(), 2), "revision 冲突不得写来源归因");
|
||||
assertEquals(0, outboxCount(), "revision 冲突不得写 outbox");
|
||||
assertEquals(0, commandCount("p1r-merge-stale"), "revision 冲突事务回滚后不得残留 reserved command");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_replayMerge_idempotentlyWithoutDoubleWrite() throws Exception {
|
||||
String commandId = "p1r-merge-replay";
|
||||
String body = acceptBody(commandId, seed.suggestionId(), 1);
|
||||
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(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.newRevision").value(2));
|
||||
// 同 commandId 重放:必须回放首次结果,不能继续推进 revision 或重复写。
|
||||
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(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.newRevision").value(2));
|
||||
|
||||
assertEquals(2, blockRevision(seed.blockId()), "重放相同 commandId 不得再次推进 revision");
|
||||
assertEquals(1, sourceAttributionCount(seed.blockId(), 2), "重放相同 commandId 不得重复写来源归因");
|
||||
assertEquals(1, outboxCount(), "重放相同 commandId 不得重复写 outbox");
|
||||
assertEquals(1, commandCount(commandId), "重放相同 commandId 只能保留一条命令事实");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMerge_whenSuggestionNotPending() throws Exception {
|
||||
// 候选已被处置(accepted)→ 不能再次合并,真实 facade 透出真实 status,Content 闸门拦截。
|
||||
updateSuggestionStatus(seed.suggestionId(), "accepted");
|
||||
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(acceptBody("p1r-merge-not-pending", seed.suggestionId(), 1)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_INVALID_STATUS_TRANSITION.getCode()));
|
||||
|
||||
assertEquals(1, blockRevision(seed.blockId()), "非 pending 候选不得改写 Canonical");
|
||||
assertEquals(ORIGINAL_CONTENT, blockContent(seed.blockId()), "非 pending 候选不得写入正文");
|
||||
assertEquals(0, commandCount("p1r-merge-not-pending"), "非 pending 候选回滚后不得残留 command");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMerge_whenAuditProjectionMissing() throws Exception {
|
||||
// 「审」字段缺失(无 outputComplianceResultId / staticCheckResultId / licenseRestrictionSnapshot)
|
||||
// → 真实 facade 判 unavailable,Content 诚实返回 EXTERNAL_OWNER_UNAVAILABLE,绝不把未审候选写成正式正文。
|
||||
updateSuggestionDiffSummary(seed.suggestionId(), "{}");
|
||||
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(acceptBody("p1r-merge-unaudited", seed.suggestionId(), 1)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode()));
|
||||
|
||||
assertEquals(1, blockRevision(seed.blockId()), "审缺失候选不得改写 Canonical");
|
||||
assertEquals(ORIGINAL_CONTENT, blockContent(seed.blockId()), "审缺失候选不得写入正文");
|
||||
assertEquals(0, commandCount("p1r-merge-unaudited"), "审缺失候选回滚后不得残留 command");
|
||||
}
|
||||
|
||||
// —— 请求体 —— //
|
||||
|
||||
private String acceptBody(String commandId, Long suggestionId, int expectedBlockRevision) {
|
||||
// 原样接受:decisionType=accept + mergeMode=accept_as_is;sourceSnapshot 与候选自身快照一致避免 409。
|
||||
// facade 默认 sourceId=String(suggestion.id),sourceVersion=source_revision(=1),authz=授权快照(=9001)。
|
||||
return """
|
||||
{
|
||||
"commandId": "%s",
|
||||
"suggestionId": %d,
|
||||
"expectedBlockRevision": %d,
|
||||
"decisionType": "accept",
|
||||
"mergeMode": "accept_as_is",
|
||||
"sourceSnapshot": {
|
||||
"sourceType": "ai_suggestion",
|
||||
"sourceId": "%d",
|
||||
"sourceVersion": 1,
|
||||
"authorizationSnapshotId": "%d"
|
||||
},
|
||||
"auditReason": "p1r-merge-it"
|
||||
}
|
||||
""".formatted(commandId, suggestionId, expectedBlockRevision, suggestionId, AUTHORIZATION_SNAPSHOT_ID);
|
||||
}
|
||||
|
||||
// —— 运行期上下文 —— //
|
||||
|
||||
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_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 MergeSeed seedMergeableFacts() throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
Long workId = insertWork(connection, "Merge Slice Work");
|
||||
Long chapterId = insertChapter(connection, workId, "Chapter A");
|
||||
Long blockId = insertBlock(connection, workId, chapterId, ORIGINAL_CONTENT);
|
||||
Long suggestionId = insertMergeableSuggestion(connection, workId, chapterId, blockId);
|
||||
return new MergeSeed(workId, chapterId, blockId, suggestionId);
|
||||
}
|
||||
}
|
||||
|
||||
private Long insertWork(Connection connection, String title) 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 (?, ?, ?, 'fiction', 'draft', 8, 1, 1, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setLong(1, LOGIN_USER_ID);
|
||||
statement.setString(2, title);
|
||||
statement.setString(3, title + " description");
|
||||
statement.setLong(4, TENANT_ID);
|
||||
return returningId(statement, "work");
|
||||
}
|
||||
}
|
||||
|
||||
private Long insertChapter(Connection connection, Long workId, String title) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_content_chapter(work_id, title, order_no, status, revision, tenant_id)
|
||||
VALUES (?, ?, 1, 'draft', 1, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setLong(1, workId);
|
||||
statement.setString(2, title);
|
||||
statement.setLong(3, TENANT_ID);
|
||||
return returningId(statement, "chapter");
|
||||
}
|
||||
}
|
||||
|
||||
private Long insertBlock(Connection connection, Long workId, Long chapterId, String content) 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, content);
|
||||
statement.setInt(4, content.length());
|
||||
statement.setLong(5, TENANT_ID);
|
||||
return returningId(statement, "block");
|
||||
}
|
||||
}
|
||||
|
||||
private Long insertMergeableSuggestion(Connection connection, Long workId, Long chapterId, Long blockId)
|
||||
throws SQLException {
|
||||
// 关键:status=pending、source_status=active、authorization_snapshot_id 为数值、content_snapshot 有正文,
|
||||
// diff_summary 带齐「审」三字段(输出合规 / 静态检查 / 许可限制快照),才会被真实 facade 判为「可合并」。
|
||||
String contentSnapshot = "{\"content\": \"" + SUGGESTION_CONTENT + "\"}";
|
||||
String diffSummary = "{\"outputComplianceResultId\": \"oc-seed-1\","
|
||||
+ " \"staticCheckResultId\": \"sc-seed-1\","
|
||||
+ " \"licenseRestrictionSnapshot\": [\"none\"]}";
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_ai_suggestion(work_id, chapter_id, block_id, generation_id, candidate_type,
|
||||
content_snapshot, status, source_revision, diff_summary,
|
||||
authorization_snapshot_id, source_status, creator, tenant_id)
|
||||
VALUES (?, ?, ?, 1, 'suggestion', ?::jsonb, 'pending', 1, ?::jsonb, ?, 'active', '9001', ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setLong(1, workId);
|
||||
statement.setLong(2, chapterId);
|
||||
statement.setLong(3, blockId);
|
||||
statement.setString(4, contentSnapshot);
|
||||
statement.setString(5, diffSummary);
|
||||
statement.setLong(6, AUTHORIZATION_SNAPSHOT_ID);
|
||||
statement.setLong(7, TENANT_ID);
|
||||
return returningId(statement, "suggestion");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSuggestionStatus(Long suggestionId, String status) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"UPDATE muse_ai_suggestion SET status = ? WHERE tenant_id = ? AND id = ?")) {
|
||||
statement.setString(1, status);
|
||||
statement.setLong(2, TENANT_ID);
|
||||
statement.setLong(3, suggestionId);
|
||||
assertEquals(1, statement.executeUpdate(), "更新候选 status 必须命中一行");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSuggestionDiffSummary(Long suggestionId, String diffSummaryJson) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"UPDATE muse_ai_suggestion SET diff_summary = ?::jsonb WHERE tenant_id = ? AND id = ?")) {
|
||||
statement.setString(1, diffSummaryJson);
|
||||
statement.setLong(2, TENANT_ID);
|
||||
statement.setLong(3, suggestionId);
|
||||
assertEquals(1, statement.executeUpdate(), "更新候选 diff_summary 必须命中一行");
|
||||
}
|
||||
}
|
||||
|
||||
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 Integer blockRevision(Long blockId) throws SQLException {
|
||||
return queryInt("SELECT revision FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId);
|
||||
}
|
||||
|
||||
private Integer blockWordCount(Long blockId) throws SQLException {
|
||||
return queryInt("SELECT word_count 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 sourceAttributionCount(Long blockId, int revision) throws SQLException {
|
||||
return queryInt("""
|
||||
SELECT COUNT(*) FROM muse_content_block_source_attribution
|
||||
WHERE tenant_id = ? AND block_id = ? AND revision = ?
|
||||
""", TENANT_ID, blockId, revision);
|
||||
}
|
||||
|
||||
private int outboxCount() throws SQLException {
|
||||
return queryInt("SELECT COUNT(*) FROM muse_content_event_publish_outbox WHERE tenant_id = ?", TENANT_ID);
|
||||
}
|
||||
|
||||
private int commandCount(String commandId) throws SQLException {
|
||||
return queryInt("SELECT COUNT(*) FROM muse_content_command_log WHERE tenant_id = ? AND command_id = ?",
|
||||
TENANT_ID, commandId);
|
||||
}
|
||||
|
||||
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 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 MergeSuggestionSettings settings() {
|
||||
if (cachedSettings == null) {
|
||||
cachedSettings = MergeSuggestionSettings.fromPropertiesAndEnvironment();
|
||||
}
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
private static void cleanSchema(Flyway flyway, MergeSuggestionSettings settings) {
|
||||
try {
|
||||
flyway.clean();
|
||||
} catch (RuntimeException exception) {
|
||||
throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static MigrateResult migrateSchema(Flyway flyway, MergeSuggestionSettings settings) {
|
||||
try {
|
||||
return flyway.migrate();
|
||||
} catch (RuntimeException exception) {
|
||||
throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static AssertionError sanitizedFlywayFailure(String action, MergeSuggestionSettings 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(P1rContentMergeSuggestionIT::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,
|
||||
"merge suggestion 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 MergeSeed(Long workId, Long chapterId, Long blockId, Long suggestionId) {
|
||||
}
|
||||
|
||||
private record MergeSuggestionSettings(String jdbcUrl, String jdbcUser, String jdbcPassword,
|
||||
String flywayLocations) {
|
||||
|
||||
static MergeSuggestionSettings 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 MergeSuggestionSettings(url, user, password, locations);
|
||||
}
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
// 显式纳入 AI 的 suggestion mapper:真实 AiSuggestionMergeProjectionFacade 需要它从 muse_ai_suggestion 读投影。
|
||||
@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,
|
||||
SpringUtil.class
|
||||
})
|
||||
static class MergeSuggestionConfiguration {
|
||||
|
||||
@Bean
|
||||
MuseContentEventsProperties museContentEventsProperties() {
|
||||
return new MuseContentEventsProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实跨 BC facade:直接实例化 AI 域的 {@link AiSuggestionMergeProjectionFacade},
|
||||
* 其 {@code @Resource MuseAiSuggestionMapper} 由容器注入(经上面的 @MapperScan 注册)。
|
||||
* 用真实实现而非桩,才是「AI 候选可合并投影」的真门禁。
|
||||
*/
|
||||
@Bean
|
||||
ContentAiSuggestionFacade contentAiSuggestionFacade() {
|
||||
return new AiSuggestionMergeProjectionFacade();
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
118
muse-studio/e2e/accept-suggestion.spec.ts
Normal file
118
muse-studio/e2e/accept-suggestion.spec.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* AI 候选采纳端到端冒烟测试(真实后端,非 mock)。
|
||||
*
|
||||
* 重要:本 spec 只写不跑。它需要一个已起的真实 muse-cloud 后端 + 已种子化的作品/Block/候选数据。
|
||||
* MSW 在 e2e 下保持 onUnhandledRequest='bypass' 且不拦截 suggestion-merges,确保这里打的是真实接口,
|
||||
* 不会被 mock 假绿。运行前必须先填好下方 TODO 种子常量并启动真实后端。
|
||||
*
|
||||
* 已知前置阻塞(2026-06-14):
|
||||
* 1) 全栈 app 启动需 yudao 基座 schema(system_tenant 等),当前远端 muse_local/muse_slice_live 均缺,需先 provision。
|
||||
* 2) 「生成候选」happy-path:connectAIStream 已修复为按 SSE event: 行解析(对齐真后端 SseEmitter);但 AI 运行时尚未
|
||||
* 产出「审」字段(生成的候选不可合并,chip task_092cfc32)。故 happy-path 需在网络层桩掉 AI 流、令其 onDone 回传一个
|
||||
* 已种子化(审齐)的 suggestionId(采纳 POST 仍打真后端),或先补齐 AI「审」字段。
|
||||
* 后端 merge 主链路已由 P1rContentMergeSuggestionIT 在真实 PG 上证明(5/5 绿,含正/负/幂等/状态/审门)。
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────────┐
|
||||
* │ TODO(必须由维护者填写真实种子值后才能运行) │
|
||||
* └─────────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
// TODO: 真实后端 mock 登录模式下的访问令牌(mockSecret 'test' + userId 1 → 'test1')。若改用真实登录需替换。
|
||||
const SEEDED_ACCESS_TOKEN = 'test1';
|
||||
// TODO: 已种子化、归属于上述登录用户的作品 ID。
|
||||
const SEEDED_WORK_ID = '<FILL_ME_work_id>';
|
||||
// TODO: 上述作品下、已种子化的目标 Block ID(采纳写入的目标 Canonical Block)。
|
||||
const SEEDED_BLOCK_ID = '<FILL_ME_block_id>';
|
||||
// TODO: 已种子化、状态可被采纳、归属上述 Block 的 AI 候选 ID。
|
||||
const SEEDED_SUGGESTION_ID = '<FILL_ME_suggestion_id>';
|
||||
// TODO: 采纳前该 Block 的当前 revision(用于断言采纳后 newRevision 自增;通常为 1)。
|
||||
const SEEDED_BLOCK_REVISION = 1;
|
||||
|
||||
/**
|
||||
* 在页面加载前注入登录令牌到 localStorage。
|
||||
* WHY:studio 的 HTTP 客户端从 localStorage 读取 accessToken 注入 Bearer;e2e 必须在首屏脚本执行前写入,
|
||||
* 故使用 addInitScript 而非加载后再 setItem(后者会晚于首批请求)。
|
||||
*/
|
||||
async function seedAccessToken(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.addInitScript((token) => {
|
||||
window.localStorage.setItem('accessToken', token);
|
||||
}, SEEDED_ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
test.describe('AI 候选采纳写入 Canonical(真实后端)', () => {
|
||||
test('采纳候选后 newRevision 自增,且刷新后 Canonical 正文已更新', async ({ page }) => {
|
||||
await seedAccessToken(page);
|
||||
|
||||
// 导航到真实创作工作台路由(见 src/app/routes/index.tsx 的 workspace/:workId)。
|
||||
await page.goto(`/workspace/${SEEDED_WORK_ID}`);
|
||||
|
||||
// 触发 AI 面板生成候选(指令文本任意),等待流式生成结束、对比面板出现。
|
||||
// 注意:此处依赖真实 AI 任务能产出已种子化的 suggestionId;若环境改为直接种子候选,可改为直接调用采纳入口。
|
||||
const promptBox = page.getByPlaceholder(/在此处输入创作描述/);
|
||||
await promptBox.fill('请基于本章续写一段(e2e 种子)');
|
||||
await page.getByRole('button', { name: /发送指令/ }).click();
|
||||
|
||||
// 等待对比面板的「采纳替换」按钮出现,证明候选已就绪。
|
||||
const acceptButton = page.getByRole('button', { name: /采纳替换/ });
|
||||
await expect(acceptButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 点击采纳,并捕获真实 suggestion-merges 写入响应。
|
||||
const [mergeResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => /\/suggestion-merges$/.test(resp.url()) && resp.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
),
|
||||
acceptButton.click(),
|
||||
]);
|
||||
|
||||
// (d) 断言写入命令 HTTP 200。
|
||||
expect(mergeResponse.status()).toBe(200);
|
||||
|
||||
// (e) 断言响应 newRevision 自增(相对采纳前 SEEDED_BLOCK_REVISION)。
|
||||
const mergeBody = await mergeResponse.json();
|
||||
const newRevision = mergeBody?.data?.newRevision;
|
||||
expect(typeof newRevision).toBe('number');
|
||||
expect(newRevision).toBeGreaterThan(SEEDED_BLOCK_REVISION);
|
||||
|
||||
// (f) 刷新后断言 Canonical Block 正文已变(对比面板关闭、编辑器仍渲染该作品工作台)。
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
|
||||
// TODO: 如需断言具体正文文本,请在此处用 SEEDED 的「采纳后预期正文片段」替换下面占位断言。
|
||||
// await expect(page.getByText('<FILL_ME_expected_canonical_text>')).toBeVisible();
|
||||
});
|
||||
|
||||
test('错误的 expectedBlockRevision 导致采纳因乐观锁冲突被拒(HTTP 200 + 业务错误码)', async ({ page }) => {
|
||||
await seedAccessToken(page);
|
||||
|
||||
// 直接以错误 revision 调用真实 suggestion-merges,断言乐观锁冲突。
|
||||
// WHY:用 page.request 直接打接口,绕开 UI,专注验证后端乐观锁负路径;Bearer 由 Authorization 头显式带上。
|
||||
// 注意:Yudao 全局异常处理把 ServiceException 包成 HTTP 200 + CommonResult{code≠0,msg},并非 REST 409
|
||||
//(P1rContentMergeSuggestionIT 已证实该口径)。
|
||||
const wrongRevision = SEEDED_BLOCK_REVISION + 999;
|
||||
const response = await page.request.post(
|
||||
`/app-api/muse/works/${SEEDED_WORK_ID}/blocks/${SEEDED_BLOCK_ID}/suggestion-merges`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Version': '1',
|
||||
'Authorization': `Bearer ${SEEDED_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
commandId: crypto.randomUUID(),
|
||||
suggestionId: SEEDED_SUGGESTION_ID,
|
||||
expectedBlockRevision: wrongRevision,
|
||||
decisionType: 'accept',
|
||||
mergeMode: 'accept_as_is',
|
||||
sourceSnapshot: { sourceType: 'ai_suggestion' },
|
||||
auditReason: 'e2e negative path: stale revision',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.code).not.toBe(0);
|
||||
expect(String(body.msg)).toContain('版本');
|
||||
});
|
||||
});
|
||||
70
muse-studio/src/api/auth.ts
Normal file
70
muse-studio/src/api/auth.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 前端登录凭证读取统一入口。
|
||||
*
|
||||
* WHY:
|
||||
* - studio 历史上只有 src/lib/sse.ts 内部读取 token(仅用于全局事件流),HTTP 请求层完全不带 Authorization。
|
||||
* 后端用 Sa-Token getLoginUserId() 鉴权,AI 候选写入正文 Canonical 的 suggestion-merges 等命令必须带 Bearer,
|
||||
* 否则会被判未登录。为避免在多处重复实现 token 读取,这里抽出唯一 helper,供 client.ts 与 sse.ts 共同复用。
|
||||
* - 键名顺序与含义必须与 sse.ts 历史实现保持一致(见 ACCESS_TOKEN_STORAGE_KEYS),避免改变既有事件流取 token 行为。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 登录 token 在浏览器 storage 中可能使用的键名(按优先级从前到后)。
|
||||
* 注意:该顺序与原 sse.ts 的 EVENT_STREAM_TOKEN_KEYS 完全一致,不能调整,否则会改变事件流的 token 命中行为。
|
||||
*/
|
||||
export const ACCESS_TOKEN_STORAGE_KEYS = [
|
||||
'accessToken',
|
||||
'access_token',
|
||||
'token',
|
||||
'authToken',
|
||||
'authorization',
|
||||
'Authorization',
|
||||
'museAccessToken',
|
||||
'muse_access_token',
|
||||
] as const;
|
||||
|
||||
/** 安全读取浏览器 storage,兼容隐私模式或测试环境中 storage 访问抛错的场景。 */
|
||||
function getBrowserStorage(name: 'localStorage' | 'sessionStorage'): Storage | null {
|
||||
try {
|
||||
return window[name];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 storage 中可能已经带 "Bearer " 前缀的值规范化为裸 token,空值返回 null。 */
|
||||
function normalizeBearerToken(rawToken: string): string | null {
|
||||
const trimmedToken = rawToken.trim();
|
||||
if (!trimmedToken) return null;
|
||||
|
||||
// 去掉可能存在的 Bearer 前缀,统一对外暴露裸 token,由调用方自行决定如何拼接 Authorization。
|
||||
return trimmedToken.replace(/^Bearer\s+/i, '').trim() || null;
|
||||
}
|
||||
|
||||
/** 从单个 storage 中按约定键名顺序读取首个非空 token。 */
|
||||
function readTokenFromStorage(storage: Storage | null): string | null {
|
||||
if (!storage) return null;
|
||||
|
||||
for (const key of ACCESS_TOKEN_STORAGE_KEYS) {
|
||||
const rawToken = storage.getItem(key);
|
||||
if (!rawToken) continue;
|
||||
|
||||
const token = normalizeBearerToken(rawToken);
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前登录的访问令牌(裸 token,不含 Bearer 前缀)。
|
||||
* 读取优先级:localStorage 优先于 sessionStorage;同一 storage 内按 ACCESS_TOKEN_STORAGE_KEYS 顺序取首个非空值。
|
||||
*
|
||||
* @returns 命中的裸 token;未登录或读取失败返回 null。
|
||||
*/
|
||||
export function readAccessToken(): string | null {
|
||||
return (
|
||||
readTokenFromStorage(getBrowserStorage('localStorage')) ??
|
||||
readTokenFromStorage(getBrowserStorage('sessionStorage'))
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import type { components } from '@/types/openapi';
|
||||
import { readAccessToken } from '@/api/auth';
|
||||
|
||||
// 后端 API 前缀路径
|
||||
const BASE_URL = '/app-api/muse';
|
||||
@ -54,6 +55,13 @@ export async function request<T>(
|
||||
'X-API-Version': '1',
|
||||
};
|
||||
|
||||
// 注入登录态 Bearer:后端用 Sa-Token getLoginUserId() 鉴权,写命令(如 suggestion-merges)必须带 Authorization,
|
||||
// 否则会被判未登录。只有读到非空 token 时才注入,避免发送空 Bearer 头污染请求。
|
||||
const accessToken = readAccessToken();
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
// 仅在 body 不是 FormData 实例时,默认追加 application/json 请求头
|
||||
if (!isFormData(options.body)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
|
||||
@ -319,15 +319,17 @@ export const aiHandlers = [
|
||||
// 模拟 Token 渲染的轻微延时效果
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
// 按 SSE 格式要求,以 "data: " 开头并以 "\n\n" 结尾进行数据组装与入队
|
||||
// 对齐真实后端 SseEmitter 线格式:event 行带事件名、id 行带顺序、data 行 JSON 只含 payload(无 type 字段)。
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'chunk', content: chunk, sequenceNo: chunks.indexOf(chunk) })}\n\n`)
|
||||
encoder.encode(`event: chunk\nid: ${chunks.indexOf(chunk)}\ndata: ${JSON.stringify({ content: chunk, sequenceNo: chunks.indexOf(chunk) })}\n\n`)
|
||||
);
|
||||
}
|
||||
|
||||
// 发送流结束标记完成整个推送
|
||||
// 发送流结束标记完成整个推送,对齐真实后端 done 事件线格式(event:done + data 行 payload,无 type 字段)。
|
||||
// suggestionId 用数字复现后端 Long 序列化为 JSON number 的真实形态;前端在 SSE 解析边界统一 String 化,
|
||||
// 因此本地采纳链路仍拿到字符串候选 ID(采纳写入 Canonical 的唯一入参),不会因数字类型错位而失败。
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done', generationId: 'g-mock-123', candidateId: 'c-mock-456' })}\n\n`)
|
||||
encoder.encode(`event: done\ndata: ${JSON.stringify({ taskId: 't-mock-123', suggestionId: 6001 })}\n\n`)
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
|
||||
@ -29,8 +29,10 @@ describe('AIPanel task stream contract', () => {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"type":"chunk","content":"星海","sequenceNo":1}\n\n'));
|
||||
controller.enqueue(encoder.encode('data: {"type":"done","generationId":"g-1","candidateId":"c-1"}\n\n'));
|
||||
// 对齐真实后端 SseEmitter 线格式:事件名在 event 行、顺序在 id 行、data 行 JSON 只含 payload(无 type 字段)。
|
||||
controller.enqueue(encoder.encode('id: 1\nevent: chunk\ndata: {"content":"星海","sequenceNo":1}\n\n'));
|
||||
// suggestionId 用数字复现后端 Long→JSON number 的真实形态;前端解析边界会归一为字符串,故下方断言仍是 '6001'。
|
||||
controller.enqueue(encoder.encode('id: 2\nevent: done\ndata: {"taskId":1,"suggestionId":6001}\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
@ -49,7 +51,8 @@ describe('AIPanel task stream contract', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /发送指令/ }));
|
||||
|
||||
await waitFor(() => expect(onCandidateGenerated).toHaveBeenCalledWith('星海'));
|
||||
// done 事件须把累计正文与后端回传的 suggestionId 一并传给上层,suggestionId 是采纳写入 Canonical 的唯一入参。
|
||||
await waitFor(() => expect(onCandidateGenerated).toHaveBeenCalledWith('星海', '6001'));
|
||||
expect(createRequests).toHaveLength(1);
|
||||
expect(createRequests[0]?.url).not.toContain('秘密提示词');
|
||||
expect(createRequests[0]?.body).toMatchObject({
|
||||
|
||||
@ -5,8 +5,12 @@ import type { CreateAiTaskRequest, CreateAiTaskResult } from '@/types/openapi';
|
||||
|
||||
/** AI 面板 Props 定义 */
|
||||
interface AIPanelProps {
|
||||
/** 触发生成新候选大纲/文本时的回调,传入 AI 生成的文本 */
|
||||
onCandidateGenerated: (content: string) => void;
|
||||
/**
|
||||
* 触发生成新候选大纲/文本时的回调。
|
||||
* @param content AI 流式累计生成的完整文本
|
||||
* @param suggestionId 后端 done 事件回传的候选 ID;用户采纳写入 Canonical 时必须携带,缺失时为 null(无法采纳,仅可预览)
|
||||
*/
|
||||
onCandidateGenerated: (content: string, suggestionId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -55,11 +59,14 @@ export default function AIPanel({ onCandidateGenerated }: AIPanelProps) {
|
||||
onChunk: (data) => {
|
||||
setStreamContent((prev) => prev + data.content);
|
||||
},
|
||||
onDone: () => {
|
||||
onDone: (data) => {
|
||||
setIsGenerating(false);
|
||||
// 通过回调将生成正文提交给上层,以渲染候选 Diff 视图
|
||||
// 捕获后端 done 事件回传的候选 ID;它是后续采纳写入 Canonical 的唯一合法入参。
|
||||
// 缺失时回传 null(上层据此禁用采纳按钮,只允许预览)。
|
||||
const suggestionId = data?.suggestionId ?? null;
|
||||
// 通过回调将生成正文与候选 ID 一并提交给上层,以渲染候选 Diff 视图并支持采纳。
|
||||
setStreamContent((current) => {
|
||||
onCandidateGenerated(current);
|
||||
onCandidateGenerated(current, suggestionId);
|
||||
return current;
|
||||
});
|
||||
abortControllerRef.current = null;
|
||||
|
||||
@ -7,10 +7,12 @@ interface CandidatePanelProps {
|
||||
originalText: string;
|
||||
/** AI 续写/改写生成的待选内容 */
|
||||
candidateText: string;
|
||||
/** 作者采纳建议时的触发回调 */
|
||||
onAccept: () => void;
|
||||
/** 作者采纳建议时的触发回调;支持异步(采纳需调用后端 suggestion-merges 写入 Canonical) */
|
||||
onAccept: () => void | Promise<void>;
|
||||
/** 作者拒绝修改时的触发回调 */
|
||||
onReject: () => void;
|
||||
/** 是否正在提交采纳请求;为 true 时禁用采纳按钮以防连击导致重复双写 */
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -22,6 +24,7 @@ export default function CandidatePanel({
|
||||
candidateText,
|
||||
onAccept,
|
||||
onReject,
|
||||
isSubmitting = false,
|
||||
}: CandidatePanelProps) {
|
||||
// 使用 useMemo 缓存 Diff 运算开销,杜绝打字重绘导致的多余计算
|
||||
const diffChunks = useMemo(() => {
|
||||
@ -92,15 +95,20 @@ export default function CandidatePanel({
|
||||
<div className="p-4 bg-white border-t border-gray-200 flex gap-3">
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="flex-1 border border-gray-200 hover:bg-gray-50 text-gray-700 text-xs py-2.5 rounded-xl font-semibold shadow-sm transition-colors"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-200 hover:bg-gray-50 text-gray-700 text-xs py-2.5 rounded-xl font-semibold shadow-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
🙅♂️ 放弃修改
|
||||
</button>
|
||||
<button
|
||||
onClick={onAccept}
|
||||
className="flex-1 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white text-xs py-2.5 rounded-xl font-semibold shadow-md transition-all transform hover:-translate-y-0.5 active:translate-y-0"
|
||||
onClick={() => {
|
||||
// 采纳为异步写入 Canonical,统一以 Promise 处理;提交中禁用避免连击双写。
|
||||
void onAccept();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white text-xs py-2.5 rounded-xl font-semibold shadow-md transition-all transform hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
🤝 采纳替换
|
||||
{isSubmitting ? '⏳ 写入中…' : '🤝 采纳替换'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
59
muse-studio/src/features/editor/hooks/useAcceptSuggestion.ts
Normal file
59
muse-studio/src/features/editor/hooks/useAcceptSuggestion.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/api/client';
|
||||
import type { MergeBlockSuggestionRequest, MergeBlockSuggestionResult } from '@/types/openapi';
|
||||
|
||||
/** 采纳候选 mutation 的入参:仅需候选 ID 与目标 Block 当前 revision(乐观锁)。 */
|
||||
interface AcceptSuggestionVars {
|
||||
/** AI Orchestration 产生的候选 ID(后端 done 事件回传) */
|
||||
suggestionId: string;
|
||||
/** 目标 Block 当前 revision;与后端不一致会返回 409,需刷新后重试 */
|
||||
expectedBlockRevision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采纳 AI 候选并原样写入 Canonical Block 的 Mutation Hook。
|
||||
*
|
||||
* WHY:
|
||||
* - POST /works/{workId}/blocks/{blockId}/suggestion-merges 是 AI 候选写入正文 Canonical 的唯一合法入口,
|
||||
* 绝不能用编辑器 setContent 在前端绕过 Shadow→Canonical 主权。
|
||||
* - 本切片只做「原样接受」:decisionType='accept' + mergeMode='accept_as_is',因此不传 finalContent。
|
||||
* - sourceSnapshot 只发最小快照 { sourceType: 'ai_suggestion' }:后端仅对调用方传入的非空字段与候选自身快照比对,
|
||||
* 前端不掌握候选的 authorizationSnapshotId / sourceVersion,发最小快照可避免误触发 409;
|
||||
* ContentSourceSnapshotVO 各字段均无 @NotBlank/@NotNull,最小快照合法。
|
||||
*
|
||||
* @param workId 作品 ID
|
||||
* @param blockId 目标 Block ID
|
||||
* @returns React Query mutation 对象;mutateAsync 返回 MergeBlockSuggestionResult,供调用方更新本地 revision。
|
||||
*/
|
||||
export function useAcceptSuggestion(workId: string, blockId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ suggestionId, expectedBlockRevision }: AcceptSuggestionVars) => {
|
||||
// 严格按后端 MergeBlockSuggestionReqVO 契约组装请求体
|
||||
const payload: MergeBlockSuggestionRequest = {
|
||||
commandId: crypto.randomUUID(), // 幂等键,防止重复写入
|
||||
suggestionId,
|
||||
expectedBlockRevision,
|
||||
decisionType: 'accept',
|
||||
mergeMode: 'accept_as_is',
|
||||
sourceSnapshot: {
|
||||
// 最小来源快照:仅声明来源类型为 AI 候选,其余字段交由后端按候选自身快照补齐
|
||||
sourceType: 'ai_suggestion',
|
||||
},
|
||||
auditReason: 'studio accept suggestion',
|
||||
};
|
||||
return api.post<MergeBlockSuggestionResult>(
|
||||
`/works/${workId}/blocks/${blockId}/suggestion-merges`,
|
||||
payload
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 写入成功后该章节正文 Block 已变更,失效章节详情缓存以拉取最新 Canonical 内容与 revision。
|
||||
// queryKey 与 useChapterDetail 保持一致(['chapterDetail', chapterId]);这里以 blockId 关联的章节为目标,
|
||||
// 由于本切片以「章节首个 Block」为编辑单元,调用方在 onSuccess 后会以返回的 newRevision 更新本地 revision,
|
||||
// 同时失效章节详情让下次进入时回源校准。
|
||||
queryClient.invalidateQueries({ queryKey: ['chapterDetail'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connectEventStream } from './sse';
|
||||
import { connectAIStream, connectEventStream } from './sse';
|
||||
|
||||
type FetchCallRecorder = {
|
||||
mock: {
|
||||
@ -340,3 +340,75 @@ describe('connectEventStream fetch SSE', () => {
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectAIStream fetch SSE', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should_dispatchEventsByEventLine_matchingRealBackendFormat', async () => {
|
||||
// 复刻真实后端 MuseAiTaskStreamServiceImpl 的线格式:id 行 + event 行 + data 行(JSON 仅 payload,无 type 字段)。
|
||||
// 这是本次修复的核心断言:旧实现按 data.type 分发会全部漏掉这些事件。
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
createStreamResponse([
|
||||
'id: 1\nevent: chunk\ndata: {"content":"星","sequenceNo":1}\n\n',
|
||||
'event: quality_check\ndata: {"dimension":"coherence","score":0.9,"passed":true}\n\n',
|
||||
'id: 3\nevent: done\ndata: {"taskId":1,"suggestionId":6001}\n\n',
|
||||
])
|
||||
);
|
||||
const onChunk = vi.fn();
|
||||
const onQualityCheck = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
connectAIStream('/app-api/muse/ai/tasks/t-1/stream', { onChunk, onQualityCheck, onDone, onError });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(onChunk).toHaveBeenCalledWith({ content: '星', sequenceNo: 1 });
|
||||
expect(onQualityCheck).toHaveBeenCalledWith({ dimension: 'coherence', score: 0.9, passed: true });
|
||||
// 后端把 Long 序列化为 JSON 数字;done 解析边界须把 taskId/suggestionId 归一为字符串,避免数字流入采纳请求体。
|
||||
expect(onDone).toHaveBeenCalledWith({ taskId: '1', suggestionId: '6001' });
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should_dispatchErrorEvent_fromEventLine', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
createStreamResponse(['event: error\ndata: {"code":"AI_RUNTIME_ERROR","message":"上游模型超时"}\n\n'])
|
||||
);
|
||||
const onError = vi.fn();
|
||||
|
||||
connectAIStream('/app-api/muse/ai/tasks/t-1/stream', { onError });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith({ code: 'AI_RUNTIME_ERROR', message: '上游模型超时' });
|
||||
});
|
||||
|
||||
it('should_reportParseError_whenDataJsonInvalid', async () => {
|
||||
// data 行 JSON 非法时必须经 onError 暴露 SSE_PARSE_ERROR,沿用旧实现的容错语义。
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
createStreamResponse(['event: chunk\ndata: {not-json}\n\n'])
|
||||
);
|
||||
const onError = vi.fn();
|
||||
|
||||
connectAIStream('/app-api/muse/ai/tasks/t-1/stream', { onError });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith({
|
||||
code: 'SSE_PARSE_ERROR',
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should_reassembleEvent_splitAcrossChunks', async () => {
|
||||
// 验证跨网络包的半行事件能被 parser 缓冲拼接后再分发,证明复用 parser 的流式能力在 AI 通道同样生效。
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
createStreamResponse(['event: do', 'ne\ndata: {"taskId":"t-1","sugg', 'estionId":"6001"}\n\n'])
|
||||
);
|
||||
const onDone = vi.fn();
|
||||
|
||||
connectAIStream('/app-api/muse/ai/tasks/t-1/stream', { onDone });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(onDone).toHaveBeenCalledWith({ taskId: 't-1', suggestionId: '6001' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { readAccessToken } from '@/api/auth';
|
||||
|
||||
/**
|
||||
* AI 生成通道的回调事件声明
|
||||
*/
|
||||
@ -6,56 +8,16 @@ export interface SSEEventHandler {
|
||||
onChunk?: (data: { content: string; sequenceNo: number }) => void;
|
||||
/** 接收到 AI 影子层的质检结果推送 */
|
||||
onQualityCheck?: (data: { dimension: string; score: number; passed: boolean }) => void;
|
||||
/** AI 生成任务全部结束 */
|
||||
onDone?: (data: { generationId: string; candidateId: string }) => void;
|
||||
/**
|
||||
* AI 生成任务全部结束。
|
||||
* WHY 字段名:后端 done 事件 data 携带 { taskId, suggestionId }(见 muse-module-ai 的 doneData),
|
||||
* suggestionId 是用户后续采纳写入 Canonical 的唯一合法入参,因此这里改为对齐后端真实契约。
|
||||
*/
|
||||
onDone?: (data: { taskId: string; suggestionId: string }) => void;
|
||||
/** 连接或解析发生错误 */
|
||||
onError?: (data: { code: string; message: string }) => void;
|
||||
}
|
||||
|
||||
/** 解析单行 SSE data 事件,并派发到应用层处理器。 */
|
||||
function dispatchSSELine(line: string, handlers: SSEEventHandler): void {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data: ')) return;
|
||||
|
||||
try {
|
||||
const rawData = trimmed.slice(6);
|
||||
const data = JSON.parse(rawData);
|
||||
|
||||
// 触发相应的应用层回调
|
||||
switch (data.type) {
|
||||
case 'chunk':
|
||||
handlers.onChunk?.(data);
|
||||
break;
|
||||
case 'quality_check':
|
||||
handlers.onQualityCheck?.(data);
|
||||
break;
|
||||
case 'done':
|
||||
handlers.onDone?.(data);
|
||||
break;
|
||||
case 'error':
|
||||
handlers.onError?.(data);
|
||||
break;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
handlers.onError?.({
|
||||
code: 'SSE_PARSE_ERROR',
|
||||
message: parseErr instanceof Error ? parseErr.message : 'JSON 解析失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 SSE 换行协议解析文本块,返回尚未闭合的半行缓冲。 */
|
||||
function dispatchSSEText(text: string, handlers: SSEEventHandler, previousBuffer = ''): string {
|
||||
const lines = `${previousBuffer}${text}`.split('\n');
|
||||
const nextBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
dispatchSSELine(line, handlers);
|
||||
}
|
||||
|
||||
return nextBuffer;
|
||||
}
|
||||
|
||||
/** 将浏览器内相对路径补齐为绝对 URL,兼容 Node/Vitest fetch 对相对 URL 的限制。 */
|
||||
function normalizeStreamUrl(url: string): string {
|
||||
try {
|
||||
@ -67,16 +29,6 @@ function normalizeStreamUrl(url: string): string {
|
||||
|
||||
const EVENT_STREAM_PATH = '/app-api/muse/events';
|
||||
const EVENT_STREAM_RECONNECT_DELAYS = [1_000, 2_000, 5_000, 10_000] as const;
|
||||
const EVENT_STREAM_TOKEN_KEYS = [
|
||||
'accessToken',
|
||||
'access_token',
|
||||
'token',
|
||||
'authToken',
|
||||
'authorization',
|
||||
'Authorization',
|
||||
'museAccessToken',
|
||||
'muse_access_token',
|
||||
] as const;
|
||||
|
||||
/** 全局系统事件流的应用层回调声明。 */
|
||||
type EventStreamHandler = (data: unknown) => void;
|
||||
@ -97,44 +49,13 @@ type ParsedEventStreamMessage = {
|
||||
data: string;
|
||||
};
|
||||
|
||||
/** 安全读取浏览器 storage,兼容隐私模式或测试环境中 storage 访问抛错。 */
|
||||
function getBrowserStorage(name: 'localStorage' | 'sessionStorage'): Storage | null {
|
||||
try {
|
||||
return window[name];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 storage 中可能已经带 Bearer 前缀的值规范化为裸 token。 */
|
||||
function normalizeBearerToken(rawToken: string): string | null {
|
||||
const trimmedToken = rawToken.trim();
|
||||
if (!trimmedToken) return null;
|
||||
|
||||
return trimmedToken.replace(/^Bearer\s+/i, '').trim() || null;
|
||||
}
|
||||
|
||||
/** 从单个 storage 中按常见键名读取登录 token,避免把 token 放到 SSE query 中泄漏。 */
|
||||
function readTokenFromStorage(storage: Storage | null): string | null {
|
||||
if (!storage) return null;
|
||||
|
||||
for (const key of EVENT_STREAM_TOKEN_KEYS) {
|
||||
const rawToken = storage.getItem(key);
|
||||
if (!rawToken) continue;
|
||||
|
||||
const token = normalizeBearerToken(rawToken);
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 读取全局事件流 Bearer token;项目当前没有独立 auth helper,因此只做局部最小读取。 */
|
||||
/**
|
||||
* 读取全局事件流 Bearer token。
|
||||
* WHY:token 读取逻辑已统一抽到 src/api/auth.ts,这里直接复用,避免与 HTTP 请求层重复实现导致行为漂移;
|
||||
* 读取键名顺序与优先级保持不变(见 auth.ts 的 ACCESS_TOKEN_STORAGE_KEYS),不改变既有事件流取 token 行为。
|
||||
*/
|
||||
function readEventStreamToken(): string | null {
|
||||
return (
|
||||
readTokenFromStorage(getBrowserStorage('localStorage')) ??
|
||||
readTokenFromStorage(getBrowserStorage('sessionStorage'))
|
||||
);
|
||||
return readAccessToken();
|
||||
}
|
||||
|
||||
/** 构造全局事件流 URL,只有存在 cursor 时才追加 lastEventId 查询参数。 */
|
||||
@ -248,6 +169,63 @@ function createEventStreamParser(callbacks: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端可能为数字(Long)的 ID 归一为字符串;null/undefined 保持原样(undefined),
|
||||
* 避免缺失候选被转成 "null"/"undefined" 字符串而绕过上层"无候选→禁用采纳"的判定。
|
||||
*/
|
||||
function idToString(value: unknown): string | undefined {
|
||||
return value === null || value === undefined ? undefined : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把全局事件流 parser 解析出的命名事件派发到 AI 流回调。
|
||||
* WHY:真实后端 muse-module-ai 的 MuseAiTaskStreamServiceImpl 用 Spring SseEmitter 的
|
||||
* `.name(event).data(json)` 发送——事件类型在 SSE `event:` 行,`data:` 行 JSON 只含 payload(不含 type 字段)。
|
||||
* 旧实现按 `data.type` 单行分发,对接真实后端时永远命不中 chunk/quality_check/done/error,故改为按 event 行分发、payload 整体回传。
|
||||
* 字段形状对齐后端各 event.data:chunk={content,sequenceNo}、quality_check={dimension,score,passed}、done={taskId,suggestionId}、error={code,message}。
|
||||
*/
|
||||
function dispatchAIStreamMessage(message: ParsedEventStreamMessage, handlers: SSEEventHandler): void {
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(message.data);
|
||||
} catch (parseErr) {
|
||||
// 保留旧实现的容错语义:data 行 JSON 非法时走 onError 暴露 SSE_PARSE_ERROR,不让异常吞掉整条流。
|
||||
handlers.onError?.({
|
||||
code: 'SSE_PARSE_ERROR',
|
||||
message: parseErr instanceof Error ? parseErr.message : 'AI 流事件 JSON 解析失败',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.event) {
|
||||
case 'chunk':
|
||||
handlers.onChunk?.(payload as { content: string; sequenceNo: number });
|
||||
break;
|
||||
case 'quality_check':
|
||||
handlers.onQualityCheck?.(payload as { dimension: string; score: number; passed: boolean });
|
||||
break;
|
||||
case 'done': {
|
||||
// WHY 强制 String 化:后端 done.data 的 taskId/suggestionId 是 Long,会序列化为 JSON 数字;
|
||||
// 而前端全链路(onDone 类型、采纳请求体 MergeBlockSuggestionRequest、OpenAPI 契约)均按 string 处理。
|
||||
// 在 SSE 解析边界统一归一为字符串,避免数字一路流入采纳请求体 {"suggestionId": 6001}。
|
||||
// 用 spread 保留 summary 等可选字段,仅覆盖两个 ID;缺失值保持 undefined 以维持上层"无候选→禁用采纳"判定。
|
||||
const raw = (payload ?? {}) as Record<string, unknown>;
|
||||
handlers.onDone?.({
|
||||
...raw,
|
||||
taskId: idToString(raw.taskId),
|
||||
suggestionId: idToString(raw.suggestionId),
|
||||
} as { taskId: string; suggestionId: string });
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
handlers.onError?.(payload as { code: string; message: string });
|
||||
break;
|
||||
default:
|
||||
// message 默认事件名或未来扩展事件不属于 AI 流合同,忽略以兼容服务端演进。
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 AI 生成专用流式通道连接 (独立连接,单次生命周期,支持中止控制器)
|
||||
* @param url 请求的目标流地址
|
||||
@ -260,6 +238,13 @@ export function connectAIStream(
|
||||
): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
// 复用全局事件流 parser:它已正确处理 event/id/comment 与多行 data 拼接,与真实后端 SseEmitter 线格式一致。
|
||||
// AI 流是一次性连接、无断线续传需求,故 onLastEventId 留空(不维护可恢复 cursor)。
|
||||
const parser = createEventStreamParser({
|
||||
onMessage: (message) => dispatchAIStreamMessage(message, handlers),
|
||||
onLastEventId: () => {},
|
||||
});
|
||||
|
||||
fetch(normalizeStreamUrl(url), {
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
@ -275,24 +260,23 @@ export function connectAIStream(
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
// 部分测试环境或 polyfill 不暴露 reader,但仍能一次性读取 text。
|
||||
dispatchSSEText(await response.text(), handlers);
|
||||
parser.consume(await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// 解码获取的二进制包并存入缓冲区
|
||||
buffer = dispatchSSEText(decoder.decode(value, { stream: true }), handlers, buffer);
|
||||
// 解码当前二进制包并交给 parser,跨包/半行事件由 parser 内部缓冲拼接。
|
||||
parser.consume(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
|
||||
const tail = decoder.decode();
|
||||
if (tail || buffer) {
|
||||
dispatchSSEText(tail, handlers, buffer);
|
||||
if (tail) {
|
||||
parser.consume(tail);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@ -35,7 +35,9 @@ export default function EditorPage() {
|
||||
|
||||
const selectedChapter = chapters.find((c) => c.id === selectedChapterId) || chapters[0];
|
||||
|
||||
// 当 AI 流式生成完毕时被触发,缓存文本并转为差异对比视图
|
||||
// 当 AI 流式生成完毕时被触发,缓存文本并转为差异对比视图。
|
||||
// 注意:本页是早期演示页(绕过 Canonical 主权,仅本地 setContent),不需要 suggestionId;
|
||||
// 真实采纳链路见 WorkspacePage。这里只取首参 text——少参函数可安全赋给 (content, suggestionId) 类型的回调。
|
||||
const handleCandidateGenerated = (text: string) => {
|
||||
// 在事件回调中读取编辑器 ref,避免渲染期访问 ref 触发 React Hooks 静态规则。
|
||||
setOriginalText(editorRef.current ? editorRef.current.getText() : '');
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/api/client';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useChapterList, useChapterDetail } from '@/features/editor/hooks/useWorks';
|
||||
import { useAcceptSuggestion } from '@/features/editor/hooks/useAcceptSuggestion';
|
||||
import { removeBlockDraft } from '@/lib/indexed-db';
|
||||
import ChapterPanel from '../features/editor/components/ChapterPanel';
|
||||
import MuseEditor from '../features/editor/components/MuseEditor';
|
||||
import type { MuseEditorInstance } from '../features/editor/components/MuseEditor';
|
||||
import AIPanel from '../features/editor/components/AIPanel';
|
||||
import CandidatePanel from '../features/editor/components/CandidatePanel';
|
||||
import type { WorkVO } from '@/types/openapi';
|
||||
|
||||
/**
|
||||
@ -44,6 +49,82 @@ export default function WorkspacePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// === AI 候选 → 采纳写入 Canonical 的接线状态(参考 EditorPage 的 showCompare 状态机形态) ===
|
||||
// candidateText:AI 流式生成的待采纳正文;candidateSuggestionId:采纳写入 Canonical 的唯一入参(为 null 时只能预览)。
|
||||
const [candidateText, setCandidateText] = useState('');
|
||||
const [candidateSuggestionId, setCandidateSuggestionId] = useState<string | null>(null);
|
||||
const [originalText, setOriginalText] = useState('');
|
||||
const [showCompare, setShowCompare] = useState(false);
|
||||
// 本地 revision 覆盖:采纳成功后后端返回 newRevision,先用它即时校准本地乐观锁,避免连续采纳因 revision 滞后误触 409。
|
||||
const [revisionOverride, setRevisionOverride] = useState<number | null>(null);
|
||||
|
||||
// 用 ref 持有真实编辑器实例,仅用于在生成候选时读取当前正文做 Diff 原文,不用于绕过 Canonical 写入。
|
||||
const editorRef = useRef<MuseEditorInstance | null>(null);
|
||||
|
||||
// 切换激活 Block 时,重置候选对比与本地 revision 覆盖,防止跨章节状态污染。
|
||||
// 采用 React 官方「渲染期根据 prop 变化调整 state」模式(追踪上一个 blockId),
|
||||
// 而非在 useEffect 内同步 setState——后者会触发级联重渲染且被 lint 规则禁止。
|
||||
const [trackedBlockId, setTrackedBlockId] = useState<string | undefined>(activeBlock?.id);
|
||||
if (trackedBlockId !== activeBlock?.id) {
|
||||
setTrackedBlockId(activeBlock?.id);
|
||||
setShowCompare(false);
|
||||
setCandidateText('');
|
||||
setCandidateSuggestionId(null);
|
||||
setOriginalText('');
|
||||
setRevisionOverride(null);
|
||||
}
|
||||
|
||||
// 当前 Block 的有效 revision:优先本地覆盖值,否则取查询返回的 version。
|
||||
const effectiveRevision = revisionOverride ?? activeBlock?.version ?? 1;
|
||||
|
||||
// 采纳候选 mutation:固定绑定当前作品与当前激活 Block。
|
||||
const acceptSuggestion = useAcceptSuggestion(workId || '', activeBlock?.id || '');
|
||||
|
||||
// AI 生成完毕回调:缓存候选文本与 suggestionId,并切到对比视图。
|
||||
const handleCandidateGenerated = (text: string, suggestionId: string | null) => {
|
||||
// 在事件回调中读取编辑器 ref,避免渲染期访问 ref 触发 React Hooks 静态规则。
|
||||
setOriginalText(editorRef.current ? editorRef.current.getText() : '');
|
||||
setCandidateText(text);
|
||||
setCandidateSuggestionId(suggestionId);
|
||||
setShowCompare(true);
|
||||
};
|
||||
|
||||
// 采纳候选:唯一合法路径是调用后端 suggestion-merges 写入 Canonical,绝不用 editorRef.setContent 绕过主权。
|
||||
const handleAcceptCandidate = async () => {
|
||||
// suggestionId 缺失或无激活 Block 时无法写入,直接返回(按钮在此前提下也应不可点)。
|
||||
if (!candidateSuggestionId || !activeBlock?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await acceptSuggestion.mutateAsync({
|
||||
suggestionId: candidateSuggestionId,
|
||||
expectedBlockRevision: effectiveRevision,
|
||||
});
|
||||
// 用后端返回的 newRevision 即时校准本地乐观锁版本。
|
||||
if (result?.newRevision != null) {
|
||||
setRevisionOverride(result.newRevision);
|
||||
}
|
||||
// 写入成功后清理该 Block 的本地离线草稿,避免旧草稿覆盖新 Canonical 正文。
|
||||
await removeBlockDraft(activeBlock.id);
|
||||
// 收起对比视图,回到 AI 面板。
|
||||
setShowCompare(false);
|
||||
setCandidateText('');
|
||||
setCandidateSuggestionId(null);
|
||||
setOriginalText('');
|
||||
} catch (err) {
|
||||
// 失败(如 409 revision 冲突)保留对比视图,让用户感知并可重试;错误细节由 mutation.error 暴露。
|
||||
console.error('采纳 AI 候选写入 Canonical 失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 放弃候选:仅清理本地对比状态,不触达后端。
|
||||
const handleRejectCandidate = () => {
|
||||
setShowCompare(false);
|
||||
setCandidateText('');
|
||||
setCandidateSuggestionId(null);
|
||||
setOriginalText('');
|
||||
};
|
||||
|
||||
// 4. 首屏刷新与空值防死锁激活逻辑
|
||||
useEffect(() => {
|
||||
// 大纲加载完毕且不为空,但当前 Zustand 暂无激活章节时触发
|
||||
@ -124,13 +205,17 @@ export default function WorkspacePage() {
|
||||
<div className="h-6 bg-slate-50/80 rounded-xl w-4/5" />
|
||||
</div>
|
||||
) : chapterDetail && activeBlock ? (
|
||||
// 强制绑定 key={activeChapterId}!在切换章节时强制进行 React 销毁卸载重构,完美断绝 ProseMirror 的状态污染残留
|
||||
// 强制绑定 key={activeBlock.id}!在切换 Block 时强制 React 销毁重建,断绝 ProseMirror 状态污染残留
|
||||
<MuseEditor
|
||||
key={activeBlock.id}
|
||||
blockId={activeBlock.id}
|
||||
workId={workId}
|
||||
initialContent={initialBlockContent}
|
||||
revision={activeBlock.version}
|
||||
revision={effectiveRevision}
|
||||
onEditorReady={(editor) => {
|
||||
// 持有真实编辑器实例,仅用于生成候选时读取当前正文作 Diff 原文。
|
||||
editorRef.current = editor;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 bg-white">
|
||||
@ -149,6 +234,23 @@ export default function WorkspacePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧 AI 助手 / 候选对比面板:仅在已激活 Block 时挂载(AI 生成与采纳都需要目标 Block) */}
|
||||
{activeBlock && (
|
||||
<div className="h-full flex overflow-hidden">
|
||||
{showCompare ? (
|
||||
<CandidatePanel
|
||||
originalText={originalText}
|
||||
candidateText={candidateText}
|
||||
onAccept={handleAcceptCandidate}
|
||||
onReject={handleRejectCandidate}
|
||||
isSubmitting={acceptSuggestion.isPending}
|
||||
/>
|
||||
) : (
|
||||
<AIPanel onCandidateGenerated={handleCandidateGenerated} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -24,6 +24,11 @@ export type SaveBlockRequest = ContentComponents['schemas']['SaveBlockRequest'];
|
||||
export type SaveBlockResult = NonNullable<
|
||||
ContentPaths['/app-api/muse/works/{workId}/blocks/{blockId}']['put']['responses'][200]['content']['application/json']['data']
|
||||
>;
|
||||
// AI 候选原样接受 / 修改后合并写入 Canonical Block 的请求与结果类型(suggestion-merges 命令)
|
||||
export type MergeBlockSuggestionRequest = ContentComponents['schemas']['MergeBlockSuggestionRequest'];
|
||||
export type MergeBlockSuggestionResult = NonNullable<
|
||||
ContentPaths['/app-api/muse/works/{workId}/blocks/{blockId}/suggestion-merges']['post']['responses'][200]['content']['application/json']['data']
|
||||
>;
|
||||
export type AgentSummary = AIComponents['schemas']['AgentSummary'];
|
||||
export type CreateAiTaskRequest = AIComponents['schemas']['CreateAiTaskRequest'];
|
||||
export type CreateAiTaskResult = NonNullable<
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user