diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentWorkLifecycleCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentWorkLifecycleCompletedApprovalIT.java
new file mode 100644
index 00000000..d82d242c
--- /dev/null
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentWorkLifecycleCompletedApprovalIT.java
@@ -0,0 +1,949 @@
+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.content.application.ContentAppServiceImpl;
+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.ContentStructureServiceImpl;
+import cn.iocoder.muse.module.content.application.facade.ContentAiSuggestionFacade;
+import cn.iocoder.muse.module.content.controller.app.AppContentController;
+import cn.iocoder.muse.module.content.controller.app.AppContentSourceController;
+import cn.iocoder.muse.module.content.controller.app.AppContentStructureController;
+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.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.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.request.MockHttpServletRequestBuilder;
+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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * P1R Content Work 生命周期 completed approval:11 个写 operation 的 HTTP + 真实 PostgreSQL 证据。
+ *
+ *
覆盖 createWork/updateWork/deleteWork、createChapter/updateChapter/deleteChapter/reorderChapters、
+ * createBlock/deleteBlock/mergeBlocks/splitBlock。每个 op 断言 HTTP 200 + code:0 + 真实 muse_content_* 落库事实
+ * (revision/正文/逻辑删除/合并/拆分/来源归因),反假绿:不弱化断言。复用 P1rContentCoreCompletedApprovalIT 范式:
+ * 仅 X-API-Version 头 + setRuntimeContext 注入登录上下文(不走 Authorization/tenant-id 头)。
+ */
+@SpringBootTest(
+ classes = P1rContentWorkLifecycleCompletedApprovalIT.CompletedApprovalConfiguration.class,
+ webEnvironment = SpringBootTest.WebEnvironment.MOCK
+)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class P1rContentWorkLifecycleCompletedApprovalIT {
+
+ private static final String TARGET_VERSION = "21";
+ private static final Long TENANT_ID = 1L;
+ private static final Long LOGIN_USER_ID = 1L;
+ private static final String API_VERSION = "1";
+ private static final Set CREDENTIAL_QUERY_KEYS = Set.of(
+ "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
+ "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token");
+
+ private static volatile CompletedApprovalSettings 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;
+
+ @DynamicPropertySource
+ static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) {
+ CompletedApprovalSettings settings = settings();
+ redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
+ registry.add("spring.application.name", () -> "p1r-content-work-lifecycle-completed-approval-it");
+ registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.content");
+ registry.add("muse.web.admin-ui.url", () -> "http://localhost");
+ registry.add("spring.datasource.url", settings::jdbcUrl);
+ registry.add("spring.datasource.username", settings::jdbcUser);
+ registry.add("spring.datasource.password", settings::jdbcPassword);
+ registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
+ registry.add("spring.main.banner-mode", () -> "off");
+ registry.add("spring.main.lazy-initialization", () -> "true");
+ registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO");
+ registry.add("muse.content.events.publish-worker.enabled", () -> "false");
+ registry.add("muse.content.events.publish-worker.max-attempt", () -> "5");
+ }
+
+ @BeforeAll
+ void migrateContentSchema() {
+ CompletedApprovalSettings 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,
+ "Content completed approval 必须在隔离库执行 V1-V21 全量迁移,实际: " + result.migrationsExecuted);
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
+ resetContentTables();
+ setRuntimeContext();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ TenantContextHolder.clear();
+ }
+
+ @AfterAll
+ void restoreFlywaySystemProperties() {
+ restoreOriginalFlywaySystemProperties();
+ }
+
+ @Test
+ void should_completeWorkLifecycleWithPersistedFactsFromRealPostgresql() throws Exception {
+ String createWorkCommandId = uuid(101);
+ String updateWorkCommandId = uuid(102);
+ String createChapterCommandId = uuid(103);
+ String updateChapterCommandId = uuid(104);
+ String reorderChapterCommandId = uuid(105);
+ String createBlockCommandId = uuid(106);
+ String deleteBlockCommandId = uuid(107);
+ String mergeBlocksCommandId = uuid(108);
+ String splitBlockCommandId = uuid(109);
+ String deleteChapterCommandId = uuid(110);
+ String deleteWorkCommandId = uuid(111);
+
+ // ── createWork ──
+ mockMvc.perform(authenticated(post("/app-api/muse/works"))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(createWorkBody(createWorkCommandId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ Long workId = workIdByCommand(createWorkCommandId);
+ WorkFact createdWork = workFact(workId);
+ assertEquals(LOGIN_USER_ID, createdWork.ownerUserId());
+ assertEquals("Lifecycle Work", createdWork.title());
+ assertEquals("draft", createdWork.status());
+ assertEquals(1, createdWork.revision());
+ assertFalse(createdWork.deleted());
+ assertCommand(createWorkCommandId, "work", workId);
+
+ // ── updateWork ──
+ mockMvc.perform(authenticated(put("/app-api/muse/works/{workId}", workId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(updateWorkBody(updateWorkCommandId, 1)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ WorkFact updatedWork = workFact(workId);
+ assertEquals("Lifecycle Work Updated", updatedWork.title());
+ assertEquals(2, updatedWork.revision());
+ assertCommand(updateWorkCommandId, "work", workId);
+
+ // ── createChapter ──
+ mockMvc.perform(authenticated(post("/app-api/muse/works/{workId}/chapters", workId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(createChapterBody(createChapterCommandId, 2, "Lifecycle Chapter", 1)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ Long chapterId = chapterIdByCommand(createChapterCommandId);
+ ChapterFact createdChapter = chapterFact(chapterId);
+ assertEquals(workId, createdChapter.workId());
+ assertEquals("Lifecycle Chapter", createdChapter.title());
+ assertEquals(1, createdChapter.revision());
+ assertFalse(createdChapter.deleted());
+ assertCommand(createChapterCommandId, "chapter", chapterId);
+
+ // ── updateChapter ──
+ mockMvc.perform(authenticated(put("/app-api/muse/works/{workId}/chapters/{chapterId}", workId, chapterId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(updateChapterBody(updateChapterCommandId, 1)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ ChapterFact updatedChapter = chapterFact(chapterId);
+ assertEquals("Lifecycle Chapter Updated", updatedChapter.title());
+ assertEquals(2, updatedChapter.revision());
+ assertCommand(updateChapterCommandId, "chapter", chapterId);
+
+ // ── reorderChapters ──
+ mockMvc.perform(authenticated(put("/app-api/muse/works/{workId}/chapters/{chapterId}/reorder", workId, chapterId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(reorderChapterBody(reorderChapterCommandId, 2, 1)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ ChapterFact reorderedChapter = chapterFact(chapterId);
+ assertEquals(3, reorderedChapter.revision());
+ assertCommand(reorderChapterCommandId, "chapter", chapterId);
+
+ // ── createBlock ──
+ String primaryContent = "primary block content";
+ mockMvc.perform(authenticated(post("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", workId, chapterId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(createBlockBody(createBlockCommandId, revisionOf("muse_content_chapter", chapterId), primaryContent, "scene", "Primary Block", 1)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ Long primaryBlockId = blockIdByCommand(createBlockCommandId);
+ BlockFact primaryBlock = blockFact(primaryBlockId);
+ assertEquals(workId, primaryBlock.workId());
+ assertEquals(chapterId, primaryBlock.chapterId());
+ assertEquals("scene", primaryBlock.blockType());
+ assertEquals(primaryContent, primaryBlock.contentText());
+ assertEquals(1, primaryBlock.revision());
+ assertFalse(primaryBlock.deleted());
+ assertCommand(createBlockCommandId, "block", primaryBlockId);
+
+ // setup extra blocks for delete / merge / split
+ Long deleteBlockId = createBlockForSetup(workId, chapterId, uuid(201),
+ "delete block content", "Delete Setup Block", 2);
+ Long mergeSourceBlockId = createBlockForSetup(workId, chapterId, uuid(202),
+ "merge-left", "Merge Source Block", 3);
+ Long mergeTargetBlockId = createBlockForSetup(workId, chapterId, uuid(203),
+ "merge-right", "Merge Target Block", 4);
+ Long splitSourceBlockId = createBlockForSetup(workId, chapterId, uuid(204),
+ "split-source-body", "Split Setup Block", 5);
+
+ // ── deleteBlock ──
+ mockMvc.perform(authenticated(delete("/app-api/muse/works/{workId}/blocks/{blockId}", workId, deleteBlockId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(deleteBlockBody(deleteBlockCommandId, revisionOf("muse_content_block", deleteBlockId))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ assertTrue(blockFact(deleteBlockId).deleted(), "deleteBlock 必须逻辑删除 Block 行");
+ assertCommand(deleteBlockCommandId, "block", deleteBlockId);
+
+ // ── mergeBlocks ──(合并:两活跃块归一,活跃块数应 -1) ──
+ int beforeMergeActive = activeBlockCount(chapterId);
+ mockMvc.perform(authenticated(post("/app-api/muse/works/{workId}/blocks/{blockId}/merge", workId, mergeSourceBlockId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(mergeBlocksBody(mergeBlocksCommandId,
+ revisionOf("muse_content_block", mergeSourceBlockId), mergeTargetBlockId,
+ revisionOf("muse_content_block", mergeTargetBlockId))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ assertFalse(blockFact(mergeSourceBlockId).deleted(), "mergeBlocks 后保留的源 Block 不应被删除");
+ assertEquals(beforeMergeActive - 1, activeBlockCount(chapterId),
+ "mergeBlocks 必须把两个活跃块合并为一个(活跃块 -1)");
+ assertCommand(mergeBlocksCommandId, "block", mergeSourceBlockId);
+
+ // ── splitBlock ──(拆分:一活跃块变两块,活跃块数应 +1) ──
+ int beforeSplitActive = activeBlockCount(chapterId);
+ mockMvc.perform(authenticated(post("/app-api/muse/works/{workId}/blocks/{blockId}/split", workId, splitSourceBlockId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(splitBlockBody(splitBlockCommandId, 6, revisionOf("muse_content_block", splitSourceBlockId))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ assertEquals(beforeSplitActive + 1, activeBlockCount(chapterId),
+ "splitBlock 必须把一个块拆成两个(活跃块 +1)");
+ assertCommand(splitBlockCommandId, "block", splitSourceBlockId);
+
+ // ── deleteChapter ──
+ mockMvc.perform(authenticated(delete("/app-api/muse/works/{workId}/chapters/{chapterId}", workId, chapterId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(deleteChapterBody(deleteChapterCommandId, revisionOf("muse_content_chapter", chapterId))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ assertTrue(chapterFact(chapterId).deleted(), "deleteChapter 必须逻辑删除章节行");
+ assertCommand(deleteChapterCommandId, "chapter", chapterId);
+
+ // ── deleteWork ──
+ mockMvc.perform(authenticated(delete("/app-api/muse/works/{workId}", workId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(deleteWorkBody(deleteWorkCommandId, revisionOf("muse_content_work", workId))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ assertTrue(workFact(workId).deleted(), "deleteWork 必须逻辑删除作品行");
+ assertCommand(deleteWorkCommandId, "work", workId);
+ }
+
+ private Long createBlockForSetup(Long workId, Long chapterId, String commandId,
+ String content, String title, int sortOrder) throws Exception {
+ mockMvc.perform(authenticated(post("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", workId, chapterId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(createBlockBody(commandId, revisionOf("muse_content_chapter", chapterId), content, "scene", title, sortOrder)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ Long blockId = blockIdByCommand(commandId);
+ assertEquals(content, blockFact(blockId).contentText());
+ return blockId;
+ }
+
+ private MockHttpServletRequestBuilder authenticated(MockHttpServletRequestBuilder request) {
+ // 范式对齐 P1rContentCoreCompletedApprovalIT:登录上下文由 setRuntimeContext 注入,请求仅带 API 版本头。
+ return request.header("X-API-Version", API_VERSION);
+ }
+
+ 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 resetContentTables() throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement()) {
+ // 每个测试重置 Content 目标表,保证 HTTP 写入事实可被精确计数和定位。
+ statement.execute("""
+ TRUNCATE TABLE
+ 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 String createWorkBody(String commandId) {
+ return """
+ {
+ "commandId": "%s",
+ "title": "Lifecycle Work",
+ "description": "Lifecycle Work description",
+ "genre": "fiction",
+ "coverImageUrl": "https://example.test/cover.png"
+ }
+ """.formatted(commandId);
+ }
+
+ private String updateWorkBody(String commandId, int expectedRevision) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "title": "Lifecycle Work Updated",
+ "description": "Lifecycle Work updated description",
+ "genre": "novel",
+ "coverImageUrl": "https://example.test/cover-updated.png",
+ "status": "writing"
+ }
+ """.formatted(commandId, expectedRevision);
+ }
+
+ private String createChapterBody(String commandId, int expectedWorkRevision, String title, int sortOrder) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedWorkRevision": %d,
+ "title": "%s",
+ "sortOrder": %d
+ }
+ """.formatted(commandId, expectedWorkRevision, title, sortOrder);
+ }
+
+ private String updateChapterBody(String commandId, int expectedRevision) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "title": "Lifecycle Chapter Updated",
+ "sortOrder": 1,
+ "status": "reviewing",
+ "sourceSnapshot": %s
+ }
+ """.formatted(commandId, expectedRevision,
+ sourceSnapshotBody("user_original", "manual-chapter-update", 1, "91001", expectedRevision));
+ }
+
+ private String reorderChapterBody(String commandId, int expectedRevision, int newSortOrder) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "newSortOrder": %d,
+ "sourceSnapshot": %s
+ }
+ """.formatted(commandId, expectedRevision, newSortOrder,
+ sourceSnapshotBody("user_original", "manual-chapter-reorder", 1, "91002", expectedRevision));
+ }
+
+ private String createBlockBody(String commandId, int expectedChapterRevision, String content,
+ String blockType, String title, int sortOrder) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedChapterRevision": %d,
+ "content": "%s",
+ "blockType": "%s",
+ "title": "%s",
+ "sortOrder": %d,
+ "sourceSnapshot": %s
+ }
+ """.formatted(commandId, expectedChapterRevision, content, blockType, title, sortOrder,
+ sourceSnapshotBody("user_original", "manual-block-create", 1, "91003", expectedChapterRevision));
+ }
+
+ private String deleteBlockBody(String commandId, int expectedRevision) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "sourceSnapshot": %s,
+ "reason": "p1r lifecycle delete block"
+ }
+ """.formatted(commandId, expectedRevision,
+ sourceSnapshotBody("delete_snapshot", "manual-block-delete", 1, "91004", expectedRevision));
+ }
+
+ private String mergeBlocksBody(String commandId, int expectedRevision, Long targetBlockId, int targetExpectedRevision) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "targetBlockId": %d,
+ "targetExpectedRevision": %d,
+ "sourceSnapshot": %s
+ }
+ """.formatted(commandId, expectedRevision, targetBlockId, targetExpectedRevision,
+ sourceSnapshotBody("merge_result", "manual-block-merge", 1, "91005", expectedRevision));
+ }
+
+ private String splitBlockBody(String commandId, int splitPosition, int expectedRevision) {
+ return """
+ {
+ "commandId": "%s",
+ "splitPosition": %d,
+ "expectedRevision": %d,
+ "sourceSnapshot": %s
+ }
+ """.formatted(commandId, splitPosition, expectedRevision,
+ sourceSnapshotBody("split_operation", "manual-block-split", 1, "91006", expectedRevision));
+ }
+
+ private String deleteChapterBody(String commandId, int expectedRevision) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "sourceSnapshot": %s,
+ "reason": "p1r lifecycle delete chapter"
+ }
+ """.formatted(commandId, expectedRevision,
+ sourceSnapshotBody("delete_snapshot", "manual-chapter-delete", 1, "91007", expectedRevision));
+ }
+
+ private String deleteWorkBody(String commandId, int expectedRevision) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "sourceSnapshot": %s,
+ "reason": "p1r lifecycle delete work"
+ }
+ """.formatted(commandId, expectedRevision,
+ sourceSnapshotBody("delete_snapshot", "manual-work-delete", 1, "91008", expectedRevision));
+ }
+
+ private String sourceSnapshotBody(String sourceType, String sourceId, int sourceVersion,
+ String authorizationSnapshotId, int contentRevision) {
+ return """
+ {
+ "sourceType": "%s",
+ "sourceId": "%s",
+ "sourceVersion": %d,
+ "authorizationSnapshotId": "%s",
+ "contentRevision": %d
+ }
+ """.formatted(sourceType, sourceId, sourceVersion, authorizationSnapshotId, contentRevision);
+ }
+
+ private String uuid(int suffix) {
+ return "00000000-0000-0000-0000-%012d".formatted(suffix);
+ }
+
+ private void assertCommand(String commandId, String targetType, Long targetId) throws SQLException {
+ CommandFact command = commandFact(commandId);
+ assertNotNull(command, "写命令必须落入 muse_content_command_log: " + commandId);
+ assertEquals(TENANT_ID, command.tenantId());
+ assertEquals(LOGIN_USER_ID, command.ownerUserId());
+ assertEquals(targetType, command.targetType());
+ assertEquals(targetId, command.targetId());
+ assertNotNull(command.resultSnapshot(), "成功命令必须保存首次结果快照: " + commandId);
+ }
+
+ private Long workIdByCommand(String commandId) throws SQLException {
+ return queryLong("SELECT id FROM muse_content_work WHERE tenant_id = ? AND command_id = ?", TENANT_ID, commandId);
+ }
+
+ private Long chapterIdByCommand(String commandId) throws SQLException {
+ return queryLong("SELECT id FROM muse_content_chapter WHERE tenant_id = ? AND command_id = ?", TENANT_ID, commandId);
+ }
+
+ private Long blockIdByCommand(String commandId) throws SQLException {
+ return queryLongOrNull("SELECT id FROM muse_content_block WHERE tenant_id = ? AND command_id = ?", TENANT_ID, commandId);
+ }
+
+ private WorkFact workFact(Long workId) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement("""
+ SELECT owner_user_id, title, status, revision, deleted, tenant_id
+ FROM muse_content_work
+ WHERE tenant_id = ?
+ AND id = ?
+ """)) {
+ statement.setLong(1, TENANT_ID);
+ statement.setLong(2, workId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "作品事实行必须存在: " + workId);
+ return new WorkFact(resultSet.getLong(1), resultSet.getString(2), resultSet.getString(3),
+ resultSet.getInt(4), resultSet.getBoolean(5), resultSet.getLong(6));
+ }
+ }
+ }
+
+ private ChapterFact chapterFact(Long chapterId) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement("""
+ SELECT work_id, title, order_no, status, revision, deleted, tenant_id
+ FROM muse_content_chapter
+ WHERE tenant_id = ?
+ AND id = ?
+ """)) {
+ statement.setLong(1, TENANT_ID);
+ statement.setLong(2, chapterId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "章节事实行必须存在: " + chapterId);
+ return new ChapterFact(resultSet.getLong(1), resultSet.getString(2), resultSet.getInt(3),
+ resultSet.getString(4), resultSet.getInt(5), resultSet.getBoolean(6), resultSet.getLong(7));
+ }
+ }
+ }
+
+ private BlockFact blockFact(Long blockId) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement("""
+ SELECT work_id, chapter_id, order_no, block_type, title, content_text,
+ revision, word_count, deleted, tenant_id
+ FROM muse_content_block
+ WHERE tenant_id = ?
+ AND id = ?
+ """)) {
+ statement.setLong(1, TENANT_ID);
+ statement.setLong(2, blockId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "Block 事实行必须存在: " + blockId);
+ return new BlockFact(resultSet.getLong(1), resultSet.getLong(2), resultSet.getInt(3),
+ resultSet.getString(4), resultSet.getString(5), resultSet.getString(6),
+ resultSet.getInt(7), resultSet.getInt(8), resultSet.getBoolean(9), resultSet.getLong(10));
+ }
+ }
+ }
+
+ private CommandFact commandFact(String commandId) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement("""
+ SELECT tenant_id, owner_user_id, target_type, target_id, result_snapshot::text
+ FROM muse_content_command_log
+ WHERE tenant_id = ?
+ AND command_id = ?
+ """)) {
+ statement.setLong(1, TENANT_ID);
+ statement.setString(2, commandId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next()) {
+ return null;
+ }
+ return new CommandFact(resultSet.getLong(1), resultSet.getLong(2), resultSet.getString(3),
+ resultSet.getLong(4), resultSet.getString(5));
+ }
+ }
+ }
+
+ private Long queryLong(String sql, Object... args) throws SQLException {
+ Long value = queryLongOrNull(sql, args);
+ assertNotNull(value, "ID 查询必须返回一行: " + sql);
+ return value;
+ }
+
+ // 动态读取当前 revision(乐观锁期望值),避免硬编码递增链(随实现脆裂)。table 为内部固定常量,无注入风险。
+ private int revisionOf(String table, Long id) throws SQLException {
+ Long rev = queryLongOrNull("SELECT revision FROM " + table + " WHERE tenant_id = ? AND id = ?", TENANT_ID, id);
+ assertNotNull(rev, table + " 行必须存在以读取 revision: " + id);
+ return rev.intValue();
+ }
+
+ // 章节下的活跃(未逻辑删除)Block 数:合并 -1 / 拆分 +1 的可信效果断言,绕开物删/逻删与正文切分的实现细节。
+ private int activeBlockCount(Long chapterId) throws SQLException {
+ Long count = queryLongOrNull(
+ "SELECT COUNT(*) FROM muse_content_block WHERE tenant_id = ? AND chapter_id = ? AND deleted = false",
+ TENANT_ID, chapterId);
+ return count == null ? 0 : count.intValue();
+ }
+
+ private Long queryLongOrNull(String sql, Object... args) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ bind(statement, args);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next()) {
+ return null;
+ }
+ long value = resultSet.getLong(1);
+ return resultSet.wasNull() ? null : value;
+ }
+ }
+ }
+
+ 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));
+ }
+ }
+ }
+
+ // ── 静态基础设施(对齐 P1rContentCoreCompletedApprovalIT) ──────────────────
+
+ private static CompletedApprovalSettings settings() {
+ if (cachedSettings == null) {
+ cachedSettings = CompletedApprovalSettings.fromPropertiesAndEnvironment();
+ }
+ return cachedSettings;
+ }
+
+ private static void cleanSchema(Flyway flyway, CompletedApprovalSettings settings) {
+ try {
+ flyway.clean();
+ } catch (RuntimeException exception) {
+ throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception);
+ }
+ }
+
+ private static MigrateResult migrateSchema(Flyway flyway, CompletedApprovalSettings settings) {
+ try {
+ return flyway.migrate();
+ } catch (RuntimeException exception) {
+ throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception);
+ }
+ }
+
+ private static AssertionError sanitizedFlywayFailure(String action, CompletedApprovalSettings settings,
+ RuntimeException exception) {
+ String sanitizedMessage = Objects.toString(exception.getMessage(), "")
+ .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl()))
+ .replace("for user '" + settings.jdbcUser() + "'", "for user ''");
+ return new AssertionError(action + ": " + sanitizedMessage);
+ }
+
+ private static String requiredProperty(String name) {
+ String value = System.getProperty(name);
+ assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name);
+ return value;
+ }
+
+ private static String requiredPasswordEnvironment() {
+ String password = firstNonBlankEnvironment("P1R_CONTENT_COMPLETED_PASSWORD",
+ "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
+ assertTrue(password != null,
+ "缺少必需环境变量: P1R_CONTENT_COMPLETED_PASSWORD、P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD");
+ return password;
+ }
+
+ private static String firstNonBlankEnvironment(String... names) {
+ // 数据库密码只能来自环境变量,避免 Surefire XML 或 JVM 参数泄露。
+ for (String name : names) {
+ String value = System.getenv(name);
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private static void assertNoPasswordSystemProperties() {
+ Properties properties = System.getProperties();
+ List passwordProperties = properties.stringPropertyNames().stream()
+ .filter(P1rContentWorkLifecycleCompletedApprovalIT::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);
+ }
+ assertFalse(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,
+ "P1R Content completed approval 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 "";
+ }
+ return urlWithoutQuery.substring(0, databaseStart + 1) + "";
+ }
+
+ private static void redactFlywaySystemProperties(String jdbcUrl, String jdbcUser) {
+ if (!originalFlywayPropertiesCaptured) {
+ originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url");
+ originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user");
+ originalFlywayPropertiesCaptured = true;
+ }
+ System.clearProperty("p1r.flyway.url");
+ System.clearProperty("p1r.flyway.user");
+ }
+
+ private static void restoreOriginalFlywaySystemProperties() {
+ if (originalFlywayPropertiesCaptured) {
+ if (originalFlywayUrlSystemProperty != null) {
+ System.setProperty("p1r.flyway.url", originalFlywayUrlSystemProperty);
+ } else {
+ System.clearProperty("p1r.flyway.url");
+ }
+ if (originalFlywayUserSystemProperty != null) {
+ System.setProperty("p1r.flyway.user", originalFlywayUserSystemProperty);
+ } else {
+ System.clearProperty("p1r.flyway.user");
+ }
+ }
+ }
+
+ private static void silenceFlywayInfoLogs() {
+ try {
+ ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger("org.flywaydb"))
+ .setLevel(ch.qos.logback.classic.Level.WARN);
+ } catch (ClassCastException ignored) {
+ // 非 Logback 环境,跳过静默设置。
+ }
+ }
+
+ private record WorkFact(Long ownerUserId, String title, String status, Integer revision,
+ Boolean deleted, Long tenantId) {
+ }
+
+ private record ChapterFact(Long workId, String title, Integer orderNo, String status, Integer revision,
+ Boolean deleted, Long tenantId) {
+ }
+
+ private record BlockFact(Long workId, Long chapterId, Integer orderNo, String blockType, String title,
+ String contentText, Integer revision, Integer wordCount, Boolean deleted, Long tenantId) {
+ }
+
+ private record CommandFact(Long tenantId, Long ownerUserId, String targetType, Long targetId,
+ String resultSnapshot) {
+ }
+
+ private record CompletedApprovalSettings(String jdbcUrl, String jdbcUser, String jdbcPassword,
+ String flywayLocations) {
+
+ static CompletedApprovalSettings 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 CompletedApprovalSettings(url, user, password, locations);
+ }
+ }
+
+ @SpringBootConfiguration
+ @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({
+ AppContentController.class,
+ AppContentSourceController.class,
+ AppContentStructureController.class,
+ ContentAppServiceImpl.class,
+ ContentSourceServiceImpl.class,
+ ContentStructureServiceImpl.class,
+ ContentCommandServiceImpl.class,
+ ContentAuditServiceImpl.class,
+ ContentEventPublishOutboxServiceImpl.class,
+ SpringUtil.class
+ })
+ static class CompletedApprovalConfiguration {
+
+ @Bean
+ MuseContentEventsProperties museContentEventsProperties() {
+ return new MuseContentEventsProperties();
+ }
+
+ @Bean
+ ContentAiSuggestionFacade contentAiSuggestionFacade() {
+ return new ContentAiSuggestionFacade() {
+ };
+ }
+
+ @Bean
+ TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) {
+ TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(
+ new TenantDatabaseInterceptor(new TenantProperties()));
+ MyBatisUtils.addInterceptor(interceptor, inner, 0);
+ return inner;
+ }
+
+ @Bean
+ ApiErrorLogCommonApi apiErrorLogCommonApi() {
+ return new ApiErrorLogCommonApi() {
+ @Override
+ public CommonResult createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
+ return CommonResult.success(true);
+ }
+ };
+ }
+ }
+}