test(p1r): content work/chapter/block 生命周期 11 op 专属真实 PG IT(验收债批1)

新增 P1rContentWorkLifecycleCompletedApprovalIT(@SpringBootTest + 真实 PG _test 库 + MockMvc 打真实 content app 控制器),链式覆盖 11 个 needs_verification 写 op:createWork/updateWork/deleteWork、createChapter/updateChapter/deleteChapter/reorderChapters、createBlock/deleteBlock/mergeBlocks/splitBlock。每 op 断言 HTTP 200+code:0 + 真实 muse_content_* 持久化事实(revision/内容/逻辑删/合并拆分活块数 delta/来源归因)+ muse_content_command_log 命令行。1/1 绿(mini-infra PG,独立复跑确认)。

产出方式:Codex 起草(读真实控制器/DTO/Mapper)+ Opus fork 落地纠偏(乐观锁 revision 改为运行时实读、merge/split 改为活块数 delta 真实结构效果、auth 对齐基准类、@Import 补 StructureController)+ 父独立复跑 + 逐 assert 反假绿复核(无弱化)。遵守边界:仅新增该测试文件、零产代码改动、未改 coverage 台账/APPROVED 列表(completed 仍待人工批)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-15 23:42:08 -07:00
parent 888217deee
commit 2499c24272

View File

@ -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 证据。
*
* <p>覆盖 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 头)。</p>
*/
@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<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 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 '<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_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<String> 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 "<redacted-url>";
}
return urlWithoutQuery.substring(0, databaseStart + 1) + "<db-redacted>";
}
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<Boolean> createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
return CommonResult.success(true);
}
};
}
}
}