APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of(
+ "listWorks",
+ "getWork",
+ "listChapters",
+ "getChapter",
+ "listBlocks",
+ "getBlock",
+ "saveBlock",
+ "getBlockSourceAttribution"
+ );
+
private final ObjectMapper objectMapper = new ObjectMapper();
/**
@@ -234,12 +254,12 @@ class P1rApiCoverageReportTest {
}
/**
- * 验证 completed approval 只推进已批准的 Events SSE、Meta、Account 与 Market operation,不连带推进其它 owner domain。
+ * 验证 completed approval 只推进已批准的 Events SSE、Meta、Account、Market 与 Content operation,不连带推进其它 owner domain。
*
* @throws IOException 读取覆盖报告失败时抛出
*/
@Test
- void should_only_promote_approved_events_meta_account_and_market_operations() throws IOException {
+ void should_only_promote_approved_events_meta_account_market_and_content_operations() throws IOException {
JsonNode operations = readOperations();
int completed = 0;
@@ -248,7 +268,7 @@ class P1rApiCoverageReportTest {
completed++;
}
}
- assertEquals(131, completed, "Market 第一批 4 个 operation-level approval 后 completed 总数只能从 127 增至 131");
+ assertEquals(139, completed, "Content 第一批 8 个 operation-level approval 后 completed 总数只能从 131 增至 139");
assertOperationStatus("events", "streamEvents", "dedicated", "completed");
for (String operationId : APPROVED_META_SCHEMA_COMPLETED_OPERATIONS) {
@@ -263,13 +283,23 @@ class P1rApiCoverageReportTest {
for (String operationId : APPROVED_MARKET_COMPLETED_OPERATIONS) {
assertOperationStatus("market", operationId, "dedicated", "completed");
}
+ for (String operationId : APPROVED_CONTENT_COMPLETED_OPERATIONS) {
+ assertOperationStatus("content", operationId, "dedicated", "completed");
+ }
assertOperationStatus("market", "listMarketplaceAssets", "dedicated", "needs_verification");
assertOperationStatus("market", "listMarketplaceRecommendations", "dedicated", "needs_verification");
assertOperationStatus("market", "favoriteAsset", "dedicated", "completed", true);
assertOperationStatus("market", "unfavoriteAsset", "dedicated", "completed", true);
+ assertOperationStatus("content", "createWork", "dedicated", "needs_verification");
+ assertOperationStatus("content", "updateWork", "dedicated", "needs_verification");
+ assertOperationStatus("content", "createChapter", "dedicated", "needs_verification");
+ assertOperationStatus("content", "mergeBlockSuggestion", "dedicated", "needs_verification");
+ assertOperationStatus("content", "exportWork", "dedicated", "needs_verification");
+ assertOperationStatus("content", "getPlanning", "dedicated", "needs_verification");
+ assertOperationStatus("content", "adminRiskAction", "dedicated", "needs_verification");
assertDomainStatusCount("market", "dedicated", "needs_verification", 28);
assertDomainStatusCount("account", "dedicated", "needs_verification", 23);
- assertDomainStatusCount("content", "dedicated", "needs_verification", 51);
+ assertDomainStatusCount("content", "dedicated", "needs_verification", 43);
}
/**
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java
new file mode 100644
index 00000000..b2146685
--- /dev/null
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentCoreCompletedApprovalIT.java
@@ -0,0 +1,1176 @@
+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.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.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.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import javax.sql.DataSource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.Set;
+
+import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_COMMAND_ID_CONFLICT;
+import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_FORBIDDEN;
+import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_NOT_FOUND;
+import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_REVISION_CONFLICT;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+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 第一批 completed approval:8 个核心读写 operation 的 HTTP + 真实 PostgreSQL 证据。
+ *
+ * 本测试只允许连接显式传入的 PostgreSQL {@code _test} 库,启动最小 Spring MVC 上下文,
+ * 通过 {@code /app-api/muse/**} 真实前缀进入 Controller,并读取数据库事实验证 owner guard、
+ * tenant 隔离、saveBlock 事务写入、command 幂等和 Content Events outbox。本测试不导入 Content 全包,
+ * 避免未审阅的导入导出、规划、worker 等能力污染这批 operation 的 evidence。
+ */
+@SpringBootTest(
+ classes = P1rContentCoreCompletedApprovalIT.CompletedApprovalConfiguration.class,
+ webEnvironment = SpringBootTest.WebEnvironment.MOCK
+)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class P1rContentCoreCompletedApprovalIT {
+
+ private static final String TARGET_VERSION = "21";
+ private static final Long TENANT_ID = 100L;
+ private static final Long OTHER_TENANT_ID = 200L;
+ private static final Long LOGIN_USER_ID = 9001L;
+ private static final Long OTHER_USER_ID = 9002L;
+ 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;
+ private SeedFacts seedFacts;
+
+ @DynamicPropertySource
+ static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) {
+ CompletedApprovalSettings settings = settings();
+ redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
+ registry.add("spring.application.name", () -> "p1r-content-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();
+ this.seedFacts = seedContentFacts();
+ setRuntimeContext();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ TenantContextHolder.clear();
+ }
+
+ @AfterAll
+ void restoreFlywaySystemProperties() {
+ restoreOriginalFlywaySystemProperties();
+ }
+
+ @Test
+ void should_rejectPasswordSystemProperty() {
+ System.setProperty("p1r.content.completed.password", "must-not-be-used");
+ try {
+ AssertionError error = assertThrows(AssertionError.class,
+ P1rContentCoreCompletedApprovalIT::assertNoPasswordSystemProperties);
+ assertTrue(error.getMessage().contains("p1r.content.completed.password"),
+ "拒绝 JVM password system property 时必须指出属性名");
+ } finally {
+ System.clearProperty("p1r.content.completed.password");
+ }
+ }
+
+ @Test
+ void should_listWorksWithPaginationStatusOwnerAndTenantIsolationFromRealPostgresql() throws Exception {
+ mockMvc.perform(get("/app-api/muse/works")
+ .header("X-API-Version", API_VERSION)
+ .param("pageNo", "1")
+ .param("pageSize", "10")
+ .param("status", "draft"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(2))
+ .andExpect(jsonPath("$.data.list[*].id", hasItem(seedFacts.workId().intValue())))
+ .andExpect(jsonPath("$.data.list[*].id", hasItem(seedFacts.emptyWorkId().intValue())))
+ .andExpect(jsonPath("$.data.list[*].id").value(hasSize(2)))
+ .andExpect(jsonPath("$.data.list[0].description").doesNotExist());
+
+ mockMvc.perform(get("/app-api/muse/works")
+ .header("X-API-Version", API_VERSION)
+ .param("pageNo", "3")
+ .param("pageSize", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(3))
+ .andExpect(jsonPath("$.data.list").isEmpty());
+
+ assertEquals(0, commandCount(), "listWorks 是纯查询,不能写 Content command fact");
+ assertEquals(0, outboxCount(), "listWorks 是纯查询,不能写 Content Events outbox");
+ }
+
+ @Test
+ void should_getWorkAndRejectMissingCrossOwnerAndCrossTenantWithoutWrite() throws Exception {
+ mockMvc.perform(get("/app-api/muse/works/{workId}", seedFacts.workId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value(seedFacts.workId().intValue()))
+ .andExpect(jsonPath("$.data.title").value("P1R Content Work"))
+ .andExpect(jsonPath("$.data.revision").value(1));
+
+ assertContentError(get("/app-api/muse/works/{workId}", 999999L), CONTENT_NOT_FOUND.getCode(), "missing work");
+ assertContentError(get("/app-api/muse/works/{workId}", seedFacts.otherOwnerWorkId()),
+ CONTENT_FORBIDDEN.getCode(), "cross-owner work");
+ assertContentError(get("/app-api/muse/works/{workId}", seedFacts.otherTenantWorkId()),
+ CONTENT_NOT_FOUND.getCode(), "cross-tenant work");
+ assertEquals(0, commandCount(), "getWork 错误路径不能写 command fact");
+ assertEquals(0, outboxCount(), "getWork 错误路径不能写 outbox");
+ }
+
+ @Test
+ void should_listChaptersAndRejectOwnerTenantLeaks() throws Exception {
+ mockMvc.perform(get("/app-api/muse/works/{workId}/chapters", seedFacts.workId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data", hasSize(2)))
+ .andExpect(jsonPath("$.data[0].id").value(seedFacts.chapterId().intValue()))
+ .andExpect(jsonPath("$.data[0].blockCount").value(2))
+ .andExpect(jsonPath("$.data[1].id").value(seedFacts.emptyChapterId().intValue()))
+ .andExpect(jsonPath("$.data[1].blockCount").value(0));
+
+ assertContentError(get("/app-api/muse/works/{workId}/chapters", seedFacts.otherOwnerWorkId()),
+ CONTENT_FORBIDDEN.getCode(), "cross-owner chapter list");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters", seedFacts.otherTenantWorkId()),
+ CONTENT_NOT_FOUND.getCode(), "cross-tenant chapter list");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters", 999999L),
+ CONTENT_NOT_FOUND.getCode(), "missing work chapter list");
+ assertEquals(0, commandCount(), "listChapters 是纯查询,不能写 command fact");
+ assertEquals(0, outboxCount(), "listChapters 是纯查询,不能写 outbox");
+ }
+
+ @Test
+ void should_getChapterWithBlocksAndRejectPathMismatchOwnerTenantLeaks() throws Exception {
+ mockMvc.perform(get("/app-api/muse/works/{workId}/chapters/{chapterId}",
+ seedFacts.workId(), seedFacts.chapterId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value(seedFacts.chapterId().intValue()))
+ .andExpect(jsonPath("$.data.blocks", hasSize(2)))
+ .andExpect(jsonPath("$.data.blocks[0].id").value(seedFacts.blockId().intValue()));
+
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}",
+ seedFacts.mismatchWorkId(), seedFacts.chapterId()),
+ CONTENT_NOT_FOUND.getCode(), "chapter path mismatch");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}",
+ seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerChapterId()),
+ CONTENT_FORBIDDEN.getCode(), "cross-owner chapter detail");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}",
+ seedFacts.otherTenantWorkId(), seedFacts.otherTenantChapterId()),
+ CONTENT_NOT_FOUND.getCode(), "cross-tenant chapter detail");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}",
+ seedFacts.workId(), 999999L),
+ CONTENT_NOT_FOUND.getCode(), "missing chapter detail");
+ assertEquals(0, commandCount(), "getChapter 错误路径不能写 command fact");
+ assertEquals(0, outboxCount(), "getChapter 错误路径不能写 outbox");
+ }
+
+ @Test
+ void should_listBlocksAndRejectPathMismatchOwnerTenantLeaks() throws Exception {
+ mockMvc.perform(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks",
+ seedFacts.workId(), seedFacts.chapterId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data", hasSize(2)))
+ .andExpect(jsonPath("$.data[0].id").value(seedFacts.blockId().intValue()))
+ .andExpect(jsonPath("$.data[1].id").value(seedFacts.emptySourceBlockId().intValue()));
+
+ mockMvc.perform(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks",
+ seedFacts.workId(), seedFacts.emptyChapterId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data").isEmpty());
+
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks",
+ seedFacts.mismatchWorkId(), seedFacts.chapterId()),
+ CONTENT_NOT_FOUND.getCode(), "block list path mismatch");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks",
+ seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerChapterId()),
+ CONTENT_FORBIDDEN.getCode(), "cross-owner block list");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks",
+ seedFacts.otherTenantWorkId(), seedFacts.otherTenantChapterId()),
+ CONTENT_NOT_FOUND.getCode(), "cross-tenant block list");
+ assertContentError(get("/app-api/muse/works/{workId}/chapters/{chapterId}/blocks",
+ seedFacts.workId(), 999999L),
+ CONTENT_NOT_FOUND.getCode(), "missing chapter block list");
+ assertEquals(0, commandCount(), "listBlocks 是纯查询,不能写 command fact");
+ assertEquals(0, outboxCount(), "listBlocks 是纯查询,不能写 outbox");
+ }
+
+ @Test
+ void should_getBlockAndRejectPathMismatchOwnerTenantLeaks() throws Exception {
+ mockMvc.perform(get("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value(seedFacts.blockId().intValue()))
+ .andExpect(jsonPath("$.data.content").value("original block"))
+ .andExpect(jsonPath("$.data.revision").value(1));
+
+ assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}",
+ seedFacts.mismatchWorkId(), seedFacts.blockId()),
+ CONTENT_NOT_FOUND.getCode(), "block path mismatch");
+ assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}",
+ seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerBlockId()),
+ CONTENT_FORBIDDEN.getCode(), "cross-owner block detail");
+ assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}",
+ seedFacts.otherTenantWorkId(), seedFacts.otherTenantBlockId()),
+ CONTENT_NOT_FOUND.getCode(), "cross-tenant block detail");
+ assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}",
+ seedFacts.workId(), 999999L),
+ CONTENT_NOT_FOUND.getCode(), "missing block detail");
+ assertEquals(0, commandCount(), "getBlock 错误路径不能写 command fact");
+ assertEquals(0, outboxCount(), "getBlock 错误路径不能写 outbox");
+ }
+
+ @Test
+ void should_saveBlockWriteSourceAttributionOutboxCommandAndTenantOwnerFacts() throws Exception {
+ String commandId = "p1r-content-save-block";
+ String content = "updated canonical content";
+
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(saveBlockBody(commandId, 1, content, "ai_suggestion", "501", 7, "91001")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.revision").value(2));
+
+ assertEquals(2, blockRevision(seedFacts.blockId()), "saveBlock 必须推进 block revision");
+ assertEquals(content, blockContent(seedFacts.blockId()), "saveBlock 必须写入 Canonical Block 正文");
+ assertEquals(content.length(), blockWordCount(seedFacts.blockId()), "word_count 必须来自保存后的正文");
+ assertEquals(1, sourceAttributionCount(seedFacts.blockId(), 2),
+ "saveBlock 必须为新 revision 写一条 source attribution");
+ assertEquals(1, outboxCount(), "active source attribution 必须写入 Content Events outbox");
+ OutboxFact outbox = outboxFact(seedFacts.blockId(), 2L);
+ assertNotNull(outbox, "saveBlock 必须能读取到 outbox fact");
+ assertEquals(LOGIN_USER_ID, outbox.ownerUserId());
+ assertEquals(commandId, outbox.sourceCommandId());
+ assertEquals("notification", outbox.eventType());
+ assertEquals("source_status_change", outbox.notificationType());
+ assertEquals("content_block", outbox.resourceRefType());
+ assertEquals("queued", outbox.publishStatus());
+ assertEquals(0, outbox.attemptCount());
+ assertEquals(5, outbox.maxAttempt());
+ assertTrue(outbox.payloadSummary().contains("source_status_change"),
+ "outbox payload_summary 只能包含 notification 摘要");
+ assertFalse(outbox.payloadSummary().contains(content),
+ "outbox payload_summary 禁止外发正文内容");
+
+ CommandFact command = commandFact(commandId);
+ assertNotNull(command, "saveBlock 成功必须写 command fact");
+ assertEquals("save_block", command.commandType());
+ assertEquals(LOGIN_USER_ID, command.ownerUserId());
+ assertEquals("block", command.targetType());
+ assertEquals(seedFacts.blockId(), command.targetId());
+ assertEquals("2", command.resultRevision(),
+ "command result_snapshot 必须保存首次成功 revision");
+ }
+
+ @Test
+ void should_replaySaveBlockWithoutDuplicateSourceOutboxOrRevisionChange() throws Exception {
+ String commandId = "p1r-content-save-replay";
+ String content = "first replay content";
+ String requestBody = saveBlockBody(commandId, 1, content, "user_original", "manual-1", 1, "91002");
+
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.revision").value(2));
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.revision").value(2));
+
+ assertEquals(2, blockRevision(seedFacts.blockId()), "重复相同 commandId 必须回放首次结果,不能继续推进 revision");
+ assertEquals(1, sourceAttributionCount(seedFacts.blockId(), 2),
+ "重复相同 commandId 不能重复插入 source attribution");
+ assertEquals(1, outboxCount(), "重复相同 commandId 不能重复插入 outbox");
+ assertEquals(1, commandCount(commandId), "重复相同 commandId 只能保留一条 command fact");
+ }
+
+ @Test
+ void should_rejectSaveBlockValidationRevisionConflictAndCommandConflictWithoutDirtyWrite() throws Exception {
+ Snapshot before = snapshot(seedFacts.blockId());
+
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "expectedRevision": 1,
+ "content": "missing command",
+ "sourceSnapshot": {"sourceType":"user_original"}
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.msg").exists());
+ assertSnapshotUnchanged(before, "缺少 commandId 时不能写入任何 Content fact");
+
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "commandId": "p1r-content-missing-expected-revision",
+ "content": "missing expected revision",
+ "sourceSnapshot": {"sourceType":"user_original"}
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.msg").exists());
+ assertSnapshotUnchanged(before, "缺少 expectedRevision 时不能写入任何 Content fact");
+
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "commandId": "p1r-content-missing-source-snapshot",
+ "expectedRevision": 1,
+ "content": "missing source snapshot"
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.msg").exists());
+ assertSnapshotUnchanged(before, "缺少 sourceSnapshot 时不能写入任何 Content fact");
+
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(saveBlockBody("p1r-content-conflict-revision", 99,
+ "wrong revision", "user_original", "manual-2", 1, "91003")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(CONTENT_REVISION_CONFLICT.getCode()))
+ .andExpect(jsonPath("$.msg", containsString("版本")));
+ assertSnapshotUnchanged(before, "revision conflict 必须整体回滚已预占 commandId");
+ assertEquals(0, commandCount("p1r-content-conflict-revision"),
+ "revision conflict 事务回滚后不能残留 reserved command");
+
+ String commandId = "p1r-content-command-conflict";
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(saveBlockBody(commandId, 1, "first command body", "user_original",
+ "manual-3", 1, "91004")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ Snapshot afterSuccess = snapshot(seedFacts.blockId());
+ mockMvc.perform(put("/app-api/muse/works/{workId}/blocks/{blockId}", seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(saveBlockBody(commandId, 2, "different body", "user_original",
+ "manual-4", 1, "91005")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(CONTENT_COMMAND_ID_CONFLICT.getCode()));
+ assertSnapshotUnchanged(afterSuccess, "复用 commandId 到不同请求不能追加写 source/outbox/revision");
+ }
+
+ @Test
+ void should_rejectSaveBlockPathMismatchCrossOwnerAndCrossTenantWithoutDirtyWrite() throws Exception {
+ Snapshot before = snapshot(seedFacts.blockId());
+ assertContentError(put("/app-api/muse/works/{workId}/blocks/{blockId}",
+ seedFacts.mismatchWorkId(), seedFacts.blockId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(saveBlockBody("p1r-content-mismatch", 1, "mismatch",
+ "user_original", "manual-5", 1, "91006")),
+ CONTENT_NOT_FOUND.getCode(), "saveBlock path mismatch");
+ assertSnapshotUnchanged(before, "path mismatch 事务必须整体回滚 command/source/outbox");
+
+ assertContentError(put("/app-api/muse/works/{workId}/blocks/{blockId}",
+ seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerBlockId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(saveBlockBody("p1r-content-cross-owner", 1, "cross owner",
+ "user_original", "manual-6", 1, "91007")),
+ CONTENT_FORBIDDEN.getCode(), "saveBlock cross owner");
+ assertEquals(0, commandCount("p1r-content-cross-owner"),
+ "cross-owner saveBlock 不能残留 command fact");
+
+ assertContentError(put("/app-api/muse/works/{workId}/blocks/{blockId}",
+ seedFacts.otherTenantWorkId(), seedFacts.otherTenantBlockId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(saveBlockBody("p1r-content-cross-tenant", 1, "cross tenant",
+ "user_original", "manual-7", 1, "91008")),
+ CONTENT_NOT_FOUND.getCode(), "saveBlock cross tenant");
+ assertEquals(0, commandCount("p1r-content-cross-tenant"),
+ "cross-tenant saveBlock 不能残留 command fact");
+ }
+
+ @Test
+ void should_getBlockSourceAttributionForActiveAndEmptyCurrentRevisionAndRejectLeaks() throws Exception {
+ mockMvc.perform(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution",
+ seedFacts.workId(), seedFacts.blockId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.blockId").value(seedFacts.blockId().intValue()))
+ .andExpect(jsonPath("$.data.revision").value(1))
+ .andExpect(jsonPath("$.data.sources", hasSize(1)))
+ .andExpect(jsonPath("$.data.sources[0].sourceType").value("ai_suggestion"))
+ .andExpect(jsonPath("$.data.sources[0].sourceId").value("501"))
+ .andExpect(jsonPath("$.data.sources[0].authorizationSnapshotId").value("91001"))
+ .andExpect(jsonPath("$.data.sources[0].sourceVersion").value(7))
+ .andExpect(jsonPath("$.data.sources[0].licenseRestrictions[0]").value("no_export"));
+
+ mockMvc.perform(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution",
+ seedFacts.workId(), seedFacts.emptySourceBlockId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.blockId").value(seedFacts.emptySourceBlockId().intValue()))
+ .andExpect(jsonPath("$.data.sources").isEmpty())
+ .andExpect(jsonPath("$.data.lineage").doesNotExist());
+
+ assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution",
+ seedFacts.mismatchWorkId(), seedFacts.blockId()),
+ CONTENT_NOT_FOUND.getCode(), "source attribution path mismatch");
+ assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution",
+ seedFacts.otherOwnerWorkId(), seedFacts.otherOwnerBlockId()),
+ CONTENT_FORBIDDEN.getCode(), "source attribution cross owner");
+ assertContentError(get("/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution",
+ seedFacts.otherTenantWorkId(), seedFacts.otherTenantBlockId()),
+ CONTENT_NOT_FOUND.getCode(), "source attribution cross tenant");
+ assertEquals(0, commandCount(), "getBlockSourceAttribution 是纯查询,不能写 command fact");
+ assertEquals(0, outboxCount(), "getBlockSourceAttribution 是纯查询,不能写 outbox");
+ }
+
+ private void assertContentError(org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request,
+ int expectedCode, String scenario) throws Exception {
+ mockMvc.perform(request.header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(expectedCode))
+ .andExpect(jsonPath("$.msg").exists());
+ assertTrue(commandCount() >= 0, "scenario 必须执行到 DB 断言路径: " + scenario);
+ }
+
+ 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 请求写出的 command/source/outbox fact 可被精确计数。
+ 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 SeedFacts seedContentFacts() throws SQLException {
+ try (Connection connection = dataSource.getConnection()) {
+ Long workId = insertWork(connection, TENANT_ID, LOGIN_USER_ID, "P1R Content Work", "draft", 12, 2);
+ Long chapterId = insertChapter(connection, TENANT_ID, workId, "Chapter A", 1);
+ Long blockId = insertBlock(connection, TENANT_ID, workId, chapterId, "Block A", "original block", 1);
+ Long emptySourceBlockId = insertBlock(connection, TENANT_ID, workId, chapterId, "Block Empty Source", "empty source", 2);
+ Long emptyChapterId = insertChapter(connection, TENANT_ID, workId, "Chapter Empty", 2);
+ Long emptyWorkId = insertWork(connection, TENANT_ID, LOGIN_USER_ID, "P1R Empty Work", "draft", 0, 0);
+ Long mismatchWorkId = insertWork(connection, TENANT_ID, LOGIN_USER_ID, "P1R Mismatch Work", "published", 0, 0);
+ Long otherOwnerWorkId = insertWork(connection, TENANT_ID, OTHER_USER_ID, "P1R Other Owner Work", "draft", 5, 1);
+ Long otherOwnerChapterId = insertChapter(connection, TENANT_ID, otherOwnerWorkId, "Other Owner Chapter", 1);
+ Long otherOwnerBlockId = insertBlock(connection, TENANT_ID, otherOwnerWorkId, otherOwnerChapterId,
+ "Other Owner Block", "other owner block", 1);
+ Long otherTenantWorkId = insertWork(connection, OTHER_TENANT_ID, LOGIN_USER_ID, "P1R Other Tenant Work", "draft", 5, 1);
+ Long otherTenantChapterId = insertChapter(connection, OTHER_TENANT_ID, otherTenantWorkId, "Other Tenant Chapter", 1);
+ Long otherTenantBlockId = insertBlock(connection, OTHER_TENANT_ID, otherTenantWorkId, otherTenantChapterId,
+ "Other Tenant Block", "other tenant block", 1);
+ insertSourceAttribution(connection, TENANT_ID, workId, blockId, 1);
+ return new SeedFacts(workId, chapterId, blockId, emptySourceBlockId, emptyChapterId, emptyWorkId,
+ mismatchWorkId, otherOwnerWorkId, otherOwnerChapterId, otherOwnerBlockId, otherTenantWorkId,
+ otherTenantChapterId, otherTenantBlockId);
+ }
+ }
+
+ private Long insertWork(Connection connection, Long tenantId, Long ownerUserId, String title,
+ String status, int wordCount, int chapterCount) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_content_work(owner_user_id, title, description, genre, status, word_count,
+ chapter_count, revision, tenant_id)
+ VALUES (?, ?, ?, 'fiction', ?, ?, ?, 1, ?)
+ RETURNING id
+ """)) {
+ statement.setLong(1, ownerUserId);
+ statement.setString(2, title);
+ statement.setString(3, title + " description");
+ statement.setString(4, status);
+ statement.setInt(5, wordCount);
+ statement.setInt(6, chapterCount);
+ statement.setLong(7, tenantId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "seed Content work 必须返回 id");
+ return resultSet.getLong(1);
+ }
+ }
+ }
+
+ private Long insertChapter(Connection connection, Long tenantId, Long workId, String title, int orderNo)
+ throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_content_chapter(work_id, title, order_no, status, revision, tenant_id)
+ VALUES (?, ?, ?, 'draft', 1, ?)
+ RETURNING id
+ """)) {
+ statement.setLong(1, workId);
+ statement.setString(2, title);
+ statement.setInt(3, orderNo);
+ statement.setLong(4, tenantId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "seed Content chapter 必须返回 id");
+ return resultSet.getLong(1);
+ }
+ }
+ }
+
+ private Long insertBlock(Connection connection, Long tenantId, Long workId, Long chapterId, String title,
+ String content, int orderNo) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_content_block(work_id, chapter_id, order_no, block_type, title, content_text,
+ revision, word_count, tenant_id)
+ VALUES (?, ?, ?, 'scene', ?, ?, 1, ?, ?)
+ RETURNING id
+ """)) {
+ statement.setLong(1, workId);
+ statement.setLong(2, chapterId);
+ statement.setInt(3, orderNo);
+ statement.setString(4, title);
+ statement.setString(5, content);
+ statement.setInt(6, content.length());
+ statement.setLong(7, tenantId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "seed Content block 必须返回 id");
+ return resultSet.getLong(1);
+ }
+ }
+ }
+
+ private void insertSourceAttribution(Connection connection, Long tenantId, Long workId, Long blockId,
+ int revision) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_content_block_source_attribution(work_id, block_id, revision, source_type,
+ source_object_id, source_version,
+ authorization_snapshot_id, source_status,
+ license_restriction_snapshot, tenant_id)
+ VALUES (?, ?, ?, 'ai_suggestion', '501', 7, 91001, 'active', '[\"no_export\"]'::jsonb, ?)
+ """)) {
+ statement.setLong(1, workId);
+ statement.setLong(2, blockId);
+ statement.setInt(3, revision);
+ statement.setLong(4, tenantId);
+ assertEquals(1, statement.executeUpdate(), "seed Content source attribution 必须插入一行");
+ }
+ }
+
+ private String saveBlockBody(String commandId, int expectedRevision, String content,
+ String sourceType, String sourceId, int sourceVersion,
+ String authorizationSnapshotId) {
+ return """
+ {
+ "commandId": "%s",
+ "expectedRevision": %d,
+ "content": "%s",
+ "sourceSnapshot": {
+ "sourceType": "%s",
+ "sourceId": "%s",
+ "sourceVersion": %d,
+ "authorizationSnapshotId": "%s"
+ },
+ "auditReason": "p1r-content-completed-approval"
+ }
+ """.formatted(commandId, expectedRevision, content, sourceType, sourceId, sourceVersion,
+ authorizationSnapshotId);
+ }
+
+ private Snapshot snapshot(Long blockId) throws SQLException {
+ return new Snapshot(blockRevision(blockId), blockContent(blockId), sourceAttributionCount(blockId),
+ outboxCount(), commandCount());
+ }
+
+ private void assertSnapshotUnchanged(Snapshot expected, String message) throws SQLException {
+ Snapshot actual = snapshot(seedFacts.blockId());
+ assertEquals(expected, actual, message);
+ }
+
+ private Integer blockRevision(Long blockId) throws SQLException {
+ return queryInt("SELECT revision FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId);
+ }
+
+ private Integer blockWordCount(Long blockId) throws SQLException {
+ return queryInt("SELECT word_count FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId);
+ }
+
+ private String blockContent(Long blockId) throws SQLException {
+ return queryString("SELECT content_text FROM muse_content_block WHERE tenant_id = ? AND id = ?", TENANT_ID, blockId);
+ }
+
+ private int sourceAttributionCount(Long blockId) throws SQLException {
+ return queryInt("SELECT COUNT(*) FROM muse_content_block_source_attribution WHERE tenant_id = ? AND block_id = ?",
+ TENANT_ID, blockId);
+ }
+
+ private int sourceAttributionCount(Long blockId, int revision) throws SQLException {
+ return queryInt("""
+ SELECT COUNT(*)
+ FROM muse_content_block_source_attribution
+ WHERE tenant_id = ?
+ AND block_id = ?
+ AND revision = ?
+ """, TENANT_ID, blockId, revision);
+ }
+
+ private int outboxCount() throws SQLException {
+ return queryInt("SELECT COUNT(*) FROM muse_content_event_publish_outbox WHERE tenant_id = ?", TENANT_ID);
+ }
+
+ private int commandCount() throws SQLException {
+ return queryInt("SELECT COUNT(*) FROM muse_content_command_log WHERE tenant_id = ?", TENANT_ID);
+ }
+
+ private int commandCount(String commandId) throws SQLException {
+ return queryInt("SELECT COUNT(*) FROM muse_content_command_log WHERE tenant_id = ? AND command_id = ?",
+ TENANT_ID, commandId);
+ }
+
+ private CommandFact commandFact(String commandId) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement("""
+ SELECT command_type, owner_user_id, target_type, target_id,
+ result_snapshot #>> '{result,revision}', 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.getString(1), resultSet.getLong(2), resultSet.getString(3),
+ resultSet.getLong(4), resultSet.getString(5), resultSet.getString(6));
+ }
+ }
+ }
+
+ private OutboxFact outboxFact(Long blockId, Long blockRevision) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement("""
+ SELECT owner_user_id, source_command_id, event_type, notification_type, resource_ref_type,
+ publish_status, attempt_count, max_attempt, payload_summary::text
+ FROM muse_content_event_publish_outbox
+ WHERE tenant_id = ?
+ AND block_id = ?
+ AND block_revision = ?
+ """)) {
+ statement.setLong(1, TENANT_ID);
+ statement.setLong(2, blockId);
+ statement.setLong(3, blockRevision);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next()) {
+ return null;
+ }
+ return new OutboxFact(resultSet.getLong(1), resultSet.getString(2), resultSet.getString(3),
+ resultSet.getString(4), resultSet.getString(5), resultSet.getString(6),
+ resultSet.getInt(7), resultSet.getInt(8), resultSet.getString(9));
+ }
+ }
+ }
+
+ private int queryInt(String sql, Object... args) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ bind(statement, args);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "计数查询必须返回一行");
+ return resultSet.getInt(1);
+ }
+ }
+ }
+
+ private String queryString(String sql, Object... args) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ bind(statement, args);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "事实查询必须返回一行");
+ return resultSet.getString(1);
+ }
+ }
+ }
+
+ private void bind(PreparedStatement statement, Object... args) throws SQLException {
+ for (int i = 0; i < args.length; i++) {
+ Object value = args[i];
+ if (value instanceof Long longValue) {
+ statement.setLong(i + 1, longValue);
+ } else if (value instanceof Integer intValue) {
+ statement.setInt(i + 1, intValue);
+ } else {
+ statement.setString(i + 1, String.valueOf(value));
+ }
+ }
+ }
+
+ 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(P1rContentCoreCompletedApprovalIT::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 maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url);
+ }
+ String prefix = urlWithoutQuery.substring(0, databaseStart + 1);
+ String database = urlWithoutQuery.substring(databaseStart + 1);
+ return maskJdbcHost(prefix) + database + maskedQuerySuffix(url);
+ }
+
+ private static String maskedQuerySuffix(String url) {
+ return url.indexOf('?') < 0 ? "" : "?";
+ }
+
+ private static String maskJdbcHost(String urlPart) {
+ return urlPart.replaceAll("//([^:/?#]+)", "//");
+ }
+
+ private static void redactFlywaySystemProperties(String url, String user) {
+ captureOriginalFlywaySystemProperties();
+ System.setProperty("p1r.flyway.url", maskedUrl(url));
+ System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : "");
+ }
+
+ private static void captureOriginalFlywaySystemProperties() {
+ if (originalFlywayPropertiesCaptured) {
+ return;
+ }
+ // 该 IT 会为日志脱敏 p1r.flyway.*;先保存原值,避免同一 Surefire JVM 中污染后续 Flyway IT。
+ originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url");
+ originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user");
+ originalFlywayPropertiesCaptured = true;
+ }
+
+ private static void restoreOriginalFlywaySystemProperties() {
+ if (!originalFlywayPropertiesCaptured) {
+ return;
+ }
+ // 恢复用户传入的原始连接属性,让组合运行的 Flyway 验收测试继续读取真实 _test 库地址。
+ restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty);
+ restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty);
+ }
+
+ private static void restoreSystemProperty(String name, String value) {
+ if (value == null) {
+ System.clearProperty(name);
+ return;
+ }
+ System.setProperty(name, value);
+ }
+
+ private static void silenceFlywayInfoLogs() {
+ try {
+ Object flywayLogger = LoggerFactory.getLogger("org.flywaydb");
+ Class> levelClass = Class.forName("ch.qos.logback.classic.Level");
+ Object warnLevel = levelClass.getField("WARN").get(null);
+ flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel);
+ } catch (ReflectiveOperationException | LinkageError ignored) {
+ // 日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。
+ }
+ }
+
+ private record SeedFacts(Long workId,
+ Long chapterId,
+ Long blockId,
+ Long emptySourceBlockId,
+ Long emptyChapterId,
+ Long emptyWorkId,
+ Long mismatchWorkId,
+ Long otherOwnerWorkId,
+ Long otherOwnerChapterId,
+ Long otherOwnerBlockId,
+ Long otherTenantWorkId,
+ Long otherTenantChapterId,
+ Long otherTenantBlockId) {
+ }
+
+ private record Snapshot(Integer revision,
+ String content,
+ int sourceAttributionCount,
+ int outboxCount,
+ int commandCount) {
+ }
+
+ private record CommandFact(String commandType,
+ Long ownerUserId,
+ String targetType,
+ Long targetId,
+ String resultRevision,
+ String resultSnapshot) {
+ }
+
+ private record OutboxFact(Long ownerUserId,
+ String sourceCommandId,
+ String eventType,
+ String notificationType,
+ String resourceRefType,
+ String publishStatus,
+ int attemptCount,
+ int maxAttempt,
+ String payloadSummary) {
+ }
+
+ 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,
+ ContentAppServiceImpl.class,
+ ContentSourceServiceImpl.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);
+ }
+ };
+ }
+ }
+}
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java
index 97c21dd2..001dd5a7 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentEventsPublishFlywayMigrationIT.java
@@ -3,6 +3,7 @@ package cn.iocoder.muse.server.framework.api;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationInfo;
import org.flywaydb.core.api.output.MigrateResult;
+import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
@@ -67,6 +68,14 @@ class P1rContentEventsPublishFlywayMigrationIT {
"user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
"token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"
);
+ private static volatile boolean originalFlywayPropertiesCaptured;
+ private static volatile String originalFlywayUrlSystemProperty;
+ private static volatile String originalFlywayUserSystemProperty;
+
+ @AfterAll
+ static void restoreFlywaySystemProperties() {
+ restoreOriginalFlywaySystemProperties();
+ }
@Test
void should_migrateV1ToV21OnRealPostgresqlAndVerifyContentPublishOutboxSchema() throws Exception {
@@ -205,10 +214,38 @@ class P1rContentEventsPublishFlywayMigrationIT {
private static void redactFlywaySystemProperties(String url, String user) {
// 原因:Surefire XML 会记录 JVM system property;读取后立即脱敏,避免报告文件残留真实连接信息。
+ captureOriginalFlywaySystemProperties();
System.setProperty("p1r.flyway.url", maskedUrl(url));
System.setProperty("p1r.flyway.user", maskUser(user));
}
+ private static void captureOriginalFlywaySystemProperties() {
+ if (originalFlywayPropertiesCaptured) {
+ return;
+ }
+ // 该 IT 会为日志脱敏 p1r.flyway.*;先保存原值,避免同一 Surefire JVM 中污染后续 Flyway IT。
+ originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url");
+ originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user");
+ originalFlywayPropertiesCaptured = true;
+ }
+
+ private static void restoreOriginalFlywaySystemProperties() {
+ if (!originalFlywayPropertiesCaptured) {
+ return;
+ }
+ // 恢复用户传入的原始连接属性,让组合运行的 Flyway 验收测试继续读取真实 _test 库地址。
+ restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty);
+ restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty);
+ }
+
+ private static void restoreSystemProperty(String name, String value) {
+ if (value == null) {
+ System.clearProperty(name);
+ return;
+ }
+ System.setProperty(name, value);
+ }
+
private static void cleanSchema(Flyway flyway, String url, String user) {
try {
flyway.clean();
@@ -488,6 +525,10 @@ class P1rContentEventsPublishFlywayMigrationIT {
"block_revision<=0 必须被 V21 check 拒绝");
assertInsertRejected(connection, rowWith("resource_ref_id", 0L),
"resource_ref_id<=0 必须被 V21 check 拒绝");
+ assertInsertRejected(connection, rowWith("attempt_count", -1),
+ "attempt_count<0 必须被 V21 check 拒绝");
+ assertInsertRejected(connection, rowWith("max_attempt", 0),
+ "max_attempt<=0 必须被 V21 check 拒绝");
connection.setAutoCommit(true);
}
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java
index 7e904bae..c6867c98 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java
@@ -93,6 +93,29 @@ class P1rContentRealApiGateTest {
"savePlanningItem"
);
+ /** 已批准的 Content 第一批用户端核心作品、章节、Block 与来源归因接口,仍保持 operation-level approval。 */
+ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of(
+ "listWorks",
+ "getWork",
+ "listChapters",
+ "getChapter",
+ "listBlocks",
+ "getBlock",
+ "saveBlock",
+ "getBlockSourceAttribution"
+ );
+
+ /** 必须继续保持 needs_verification 的代表性未批准 Content operation,防止 CRUD、导出、规划和 admin 误推进。 */
+ private static final Set REPRESENTATIVE_UNAPPROVED_CONTENT_OPERATIONS = Set.of(
+ "createWork",
+ "updateWork",
+ "createChapter",
+ "mergeBlockSuggestion",
+ "exportWork",
+ "getPlanning",
+ "adminRiskAction"
+ );
+
private final ObjectMapper objectMapper = new ObjectMapper();
/**
@@ -173,20 +196,34 @@ class P1rContentRealApiGateTest {
}
/**
- * 验证当前 P1R-1 收口口径:Content 全部进入 dedicated,但仍保持 needs_verification。
+ * 验证当前 completed approval 只推进第一批 8 个 Content operation,其余 43 个仍保持 needs_verification。
*
* @throws IOException 读取覆盖报告失败时抛出
*/
@Test
- void should_keep_current_content_operations_dedicated_and_needs_verification() throws IOException {
+ void should_promote_only_first_batch_content_operations_to_completed() throws IOException {
JsonNode contentOperations = readContentOperations();
+ int completed = 0;
+ int needsVerification = 0;
for (JsonNode operation : contentOperations) {
String operationId = operation.path("operationId").asText("");
assertEquals("dedicated", operation.path("implementationStatus").asText(),
operationId + " 当前 P1R-1 目标必须是 dedicated");
- assertEquals("needs_verification", operation.path("completionStatus").asText(),
- operationId + " 当前 P1R-1 目标必须是 needs_verification,不能提前宣称 completed");
+ if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) {
+ completed++;
+ assertEquals("completed", operation.path("completionStatus").asText(),
+ operationId + " 已获 Content 第一批批准后必须标记 completed");
+ } else {
+ needsVerification++;
+ assertEquals("needs_verification", operation.path("completionStatus").asText(),
+ operationId + " 未获 Content 第一批批准,必须继续保持 needs_verification");
+ }
+ }
+ assertEquals(8, completed, "Content 第一批 operation-level completed approval 只能推进 8 个 operation");
+ assertEquals(43, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification");
+ for (String operationId : REPRESENTATIVE_UNAPPROVED_CONTENT_OPERATIONS) {
+ assertContentOperationStatus(operationId, "needs_verification");
}
}
@@ -213,6 +250,18 @@ class P1rContentRealApiGateTest {
return operationIds;
}
+ private void assertContentOperationStatus(String operationId, String expectedCompletionStatus) throws IOException {
+ for (JsonNode operation : readContentOperations()) {
+ if (!operationId.equals(operation.path("operationId").asText())) {
+ continue;
+ }
+ assertEquals(expectedCompletionStatus, operation.path("completionStatus").asText(),
+ "content/" + operationId + " completionStatus 不符合本轮审批边界");
+ return;
+ }
+ throw new AssertionError("content/" + operationId + " 必须存在于 coverage report");
+ }
+
/**
* 从当前 Maven 执行目录逐级向上查找仓库根目录,避免 surefire 在不同模块目录执行时路径失效。
*/
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java
index 582ac16a..8a3c5834 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java
@@ -45,6 +45,18 @@ class P1rEventsRealApiGateTest {
"unfavoriteAsset"
);
+ /** Content completed approval 第一批只允许推进的 8 个 operation。 */
+ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of(
+ "listWorks",
+ "getWork",
+ "listChapters",
+ "getChapter",
+ "listBlocks",
+ "getBlock",
+ "saveBlock",
+ "getBlockSourceAttribution"
+ );
+
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@@ -88,10 +100,10 @@ class P1rEventsRealApiGateTest {
void should_keep_completed_approval_summary_at_approved_operation_boundary() throws IOException {
JsonNode summary = readReport().path("summary");
- assertEquals(131, summary.path("completedOperations").asInt(),
- "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4");
- assertEquals(102, summary.path("needsVerificationOperations").asInt(),
- "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 10 和 Market 4");
+ assertEquals(139, summary.path("completedOperations").asInt(),
+ "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 8");
+ assertEquals(94, summary.path("needsVerificationOperations").asInt(),
+ "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 10、Market 4 和 Content 8");
assertEquals(0, summary.path("incompleteOperations").asInt(),
"Events 退出 placeholder 后不应再留下 incomplete operation");
assertEquals(0, summary.path("genericPersistenceOperations").asInt(),
@@ -154,6 +166,31 @@ class P1rEventsRealApiGateTest {
assertEquals(23, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification");
}
+ @Test
+ void should_keep_content_partial_approval_at_8_completed_operations() throws IOException {
+ JsonNode contentOperations = readContentOperations();
+ int completed = 0;
+ int needsVerification = 0;
+
+ assertEquals(51, contentOperations.size(), "Content operation 数量必须保持 51");
+ for (JsonNode operation : contentOperations) {
+ String operationId = operation.path("operationId").asText("");
+ assertEquals("dedicated", operation.path("implementationStatus").asText(),
+ "Content/" + operationId + " 必须继续保持 dedicated");
+ if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) {
+ completed++;
+ assertEquals("completed", operation.path("completionStatus").asText(),
+ "Content/" + operationId + " 已获本轮批准后必须 completed");
+ } else {
+ needsVerification++;
+ assertEquals("needs_verification", operation.path("completionStatus").asText(),
+ "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification");
+ }
+ }
+ assertEquals(8, completed, "Content 第一批 completed approval 只能推进 8 个 operation");
+ assertEquals(43, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification");
+ }
+
private JsonNode readSingleEventsOperation() throws IOException {
JsonNode eventsOperations = readEventsOperations();
assertEquals(EXPECTED_EVENTS_OPERATION_COUNT, eventsOperations.size(),
@@ -173,6 +210,10 @@ class P1rEventsRealApiGateTest {
return readOperationsByDomain("account");
}
+ private JsonNode readContentOperations() throws IOException {
+ return readOperationsByDomain("content");
+ }
+
private void assertMarketOperationStatus(String operationId, String expectedCompletionStatus,
boolean expectedRequiresCommandId) throws IOException {
for (JsonNode operation : readMarketOperations()) {
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java
index dae9e20a..f438951c 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java
@@ -107,6 +107,18 @@ class P1rKnowledgeRealApiGateTest {
"unfavoriteAsset"
);
+ /** Content completed approval 第一批只允许推进的 8 个 operation。 */
+ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of(
+ "listWorks",
+ "getWork",
+ "listChapters",
+ "getChapter",
+ "listBlocks",
+ "getBlock",
+ "saveBlock",
+ "getBlockSourceAttribution"
+ );
+
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@@ -166,19 +178,46 @@ class P1rKnowledgeRealApiGateTest {
void should_count_ai_and_knowledge_operations_as_completed() throws IOException {
JsonNode report = readReport();
- assertEquals(131, report.path("summary").path("completedOperations").asInt(),
- "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4");
+ assertEquals(139, report.path("summary").path("completedOperations").asInt(),
+ "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 8");
}
@Test
void should_keep_existing_dedicated_domains_unchanged() throws IOException {
assertDomainStatusCount("ai", 41, "completed");
- assertDomainStatusCount("content", 51);
+ assertContentStatusCount(8, 43);
assertMetaStatusCount(16, 0);
assertAccountStatusCount(10, 23);
assertMarketStatusCount(4, 28);
}
+ private void assertContentStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException {
+ int completed = 0;
+ int needsVerification = 0;
+ int total = 0;
+ for (JsonNode operation : readOperations()) {
+ if (!"content".equals(operation.path("domain").asText())) {
+ continue;
+ }
+ total++;
+ String operationId = operation.path("operationId").asText("");
+ assertEquals("dedicated", operation.path("implementationStatus").asText(),
+ "content/" + operationId + " 必须保持 dedicated");
+ if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) {
+ completed++;
+ assertEquals("completed", operation.path("completionStatus").asText(),
+ "content/" + operationId + " 已获本轮批准后必须 completed");
+ } else {
+ needsVerification++;
+ assertEquals("needs_verification", operation.path("completionStatus").asText(),
+ "content/" + operationId + " 未获本轮批准,必须继续 needs_verification");
+ }
+ }
+ assertEquals(51, total, "Content operation 数量必须保持 51");
+ assertEquals(expectedCompleted, completed, "Content 第一批 completed approval 只能推进 8 个 operation");
+ assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification");
+ }
+
@Test
void should_promote_p1r7_events_stream_without_reintroducing_sse_placeholder() throws IOException {
int ssePlaceholderCount = 0;
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java
index 880329e5..019e3725 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java
@@ -78,6 +78,18 @@ class P1rMarketRealApiGateTest {
"unfavoriteAsset"
);
+ /** Content completed approval 第一批只允许推进的 8 个 operation。 */
+ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of(
+ "listWorks",
+ "getWork",
+ "listChapters",
+ "getChapter",
+ "listBlocks",
+ "getBlock",
+ "saveBlock",
+ "getBlockSourceAttribution"
+ );
+
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@@ -133,19 +145,46 @@ class P1rMarketRealApiGateTest {
void should_keep_completion_summary_owned_by_approved_scopes_only() throws IOException {
JsonNode report = readReport();
- assertEquals(131, report.path("summary").path("completedOperations").asInt(),
- "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4");
+ assertEquals(139, report.path("summary").path("completedOperations").asInt(),
+ "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 8");
}
@Test
void should_keep_existing_domain_statuses_unchanged() throws IOException {
assertDomainStatusCount("ai", 41, "completed");
assertDomainStatusCount("knowledge", 59, "completed");
- assertDomainStatusCount("content", 51, "needs_verification");
+ assertContentStatusCount(8, 43);
assertMetaStatusCount(16, 0);
assertAccountStatusCount(10, 23);
}
+ private void assertContentStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException {
+ int completed = 0;
+ int needsVerification = 0;
+ int total = 0;
+ for (JsonNode operation : readOperations()) {
+ if (!"content".equals(operation.path("domain").asText())) {
+ continue;
+ }
+ total++;
+ String operationId = operation.path("operationId").asText("");
+ assertEquals("dedicated", operation.path("implementationStatus").asText(),
+ "content/" + operationId + " 必须保持 dedicated");
+ if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) {
+ completed++;
+ assertEquals("completed", operation.path("completionStatus").asText(),
+ "content/" + operationId + " 已获本轮批准后必须 completed");
+ } else {
+ needsVerification++;
+ assertEquals("needs_verification", operation.path("completionStatus").asText(),
+ "content/" + operationId + " 未获本轮批准,必须继续 needs_verification");
+ }
+ }
+ assertEquals(51, total, "Content operation 数量必须保持 51");
+ assertEquals(expectedCompleted, completed, "Content 第一批 completed approval 只能推进 8 个 operation");
+ assertEquals(expectedNeedsVerification, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification");
+ }
+
@Test
void should_promote_p1r7_events_stream_without_reintroducing_sse_placeholder() throws IOException {
int ssePlaceholderCount = 0;
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java
index f30966c0..e1d77bf2 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java
@@ -76,6 +76,18 @@ class P1rMetaRealApiGateTest {
"activateFunctionChainVersion"
);
+ /** Content completed approval 第一批只允许推进的 8 个 operation。 */
+ private static final Set APPROVED_CONTENT_COMPLETED_OPERATIONS = Set.of(
+ "listWorks",
+ "getWork",
+ "listChapters",
+ "getChapter",
+ "listBlocks",
+ "getBlock",
+ "saveBlock",
+ "getBlockSourceAttribution"
+ );
+
private final ObjectMapper objectMapper = new ObjectMapper();
/**
@@ -155,11 +167,46 @@ class P1rMetaRealApiGateTest {
assertEquals(0, needsVerification, "Meta operation-level approval 后不应再保留 needs_verification operation");
}
+ /**
+ * 验证 Content 只推进第一批 8 个 operation,防止 Meta gate 漏掉 Content 未批准项回退检查。
+ *
+ * @throws IOException 读取覆盖报告失败时抛出
+ */
+ @Test
+ void should_keep_content_partial_approval_at_8_completed_operations() throws IOException {
+ JsonNode contentOperations = readContentOperations();
+ int completed = 0;
+ int needsVerification = 0;
+
+ assertEquals(51, contentOperations.size(), "Content operation 数量必须保持 51");
+ for (JsonNode operation : contentOperations) {
+ String operationId = operation.path("operationId").asText("");
+ assertEquals("dedicated", operation.path("implementationStatus").asText(),
+ "Content/" + operationId + " 必须继续保持 dedicated");
+ if (APPROVED_CONTENT_COMPLETED_OPERATIONS.contains(operationId)) {
+ completed++;
+ assertEquals("completed", operation.path("completionStatus").asText(),
+ "Content/" + operationId + " 已获本轮批准后必须 completed");
+ } else {
+ needsVerification++;
+ assertEquals("needs_verification", operation.path("completionStatus").asText(),
+ "Content/" + operationId + " 未获本轮批准,必须继续 needs_verification");
+ }
+ }
+ assertEquals(8, completed, "Content 第一批 completed approval 只能推进 8 个 operation");
+ assertEquals(43, needsVerification, "Content 剩余 43 个 operation 必须继续 needs_verification");
+ }
+
private JsonNode readMetaOperations() throws IOException {
JsonNode operations = objectMapper.readTree(findReportPath().toFile()).path("operations");
return filterMetaOperations(operations);
}
+ private JsonNode readContentOperations() throws IOException {
+ JsonNode operations = objectMapper.readTree(findReportPath().toFile()).path("operations");
+ return filterContentOperations(operations);
+ }
+
private JsonNode filterMetaOperations(JsonNode operations) {
ArrayNode metaOperations = objectMapper.createArrayNode();
for (JsonNode operation : operations) {
@@ -170,6 +217,16 @@ class P1rMetaRealApiGateTest {
return metaOperations;
}
+ private JsonNode filterContentOperations(JsonNode operations) {
+ ArrayNode contentOperations = objectMapper.createArrayNode();
+ for (JsonNode operation : operations) {
+ if ("content".equals(operation.path("domain").asText())) {
+ contentOperations.add(operation);
+ }
+ }
+ return contentOperations;
+ }
+
private static Map toOperationSignatures(JsonNode operations) {
Map operationSignatures = new LinkedHashMap<>();
for (JsonNode operation : operations) {
diff --git a/muse-cloud/scripts/p1r-audit-api-coverage.py b/muse-cloud/scripts/p1r-audit-api-coverage.py
index 24f0b757..6aeb5033 100755
--- a/muse-cloud/scripts/p1r-audit-api-coverage.py
+++ b/muse-cloud/scripts/p1r-audit-api-coverage.py
@@ -79,6 +79,15 @@ APPROVED_COMPLETED_OPERATIONS = {
"market:listMarketplaceCategories",
"market:favoriteAsset",
"market:unfavoriteAsset",
+ # P1R Content completed approval 第一批仍使用 operation-level 清单,避免整域 Content 被误升 completed。
+ "content:listWorks",
+ "content:getWork",
+ "content:listChapters",
+ "content:getChapter",
+ "content:listBlocks",
+ "content:getBlock",
+ "content:saveBlock",
+ "content:getBlockSourceAttribution",
}