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); + } + }; + } + } +}