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:
parent
888217deee
commit
2499c24272
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user