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:
lili 2026-06-14 09:23:52 -07:00
parent 1a76a14995
commit fe75b666b9
16 changed files with 1378 additions and 122 deletions

View File

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

View File

@ -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` 已写(只写不跑)。
```

View File

@ -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=pendingsource_status=activeauthorization_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);
}
};
}
}
}

View 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 schemasystem_tenant muse_local/muse_slice_live provision
* 2) happy-pathconnectAIStream 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
* WHYstudio HTTP localStorage accessToken Bearere2e
* 使 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('版本');
});
});

View File

@ -0,0 +1,70 @@
/**
*
*
* WHY:
* - studio src/lib/sse.ts tokenHTTP 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'))
);
}

View File

@ -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';

View File

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

View File

@ -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({

View File

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

View File

@ -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>

View 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 ShadowCanonical
* - 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'] });
},
});
}

View File

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

View File

@ -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
* WHYtoken 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.datachunk={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) => {

View File

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

View File

@ -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 状态机形态) ===
// candidateTextAI 流式生成的待采纳正文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>
);

View File

@ -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<