diff --git a/docs/mvp/进度总账.md b/docs/mvp/进度总账.md index 6507d0c6..3b2f3777 100644 --- a/docs/mvp/进度总账.md +++ b/docs/mvp/进度总账.md @@ -135,7 +135,8 @@ - **批17 ✅ 已交付(marketplace 发现读 3 op,2026-06-17)**:`P1rMarketplaceDiscoveryReadsCompletedApprovalIT`(真实 PG `muse_p1r_marketplace_discovery_test`,4/4 绿,独立复跑两次均绿)覆盖 listMarketplaceAssets/listMarketplaceRecommendations/getGovernanceImpact;经真实 `/app-api/muse/marketplace/assets|recommendations|assets/{id}/governance-impact`(MarketAssetQueryServiceImpl + 收藏/命令依赖,base-package=cn.iocoder.muse.module),断言:列表按 App 可见性(listed 公开;draft 等非公开仅 publisher 自见)+ assetType 过滤分页 + 卡片字段回填(publisherName/version=unknown/isFavorite=false/isAcquired=false/isInstalled=false);fallback 推荐返回可见资产卡片 + 推荐理由;治理影响读按 publisher/受影响者门禁(publisher 自见返回治理结果摘要 + 空受影响列表;非属主且无关联授权/安装/绑定→fail-closed `MARKET_RESOURCE_FORBIDDEN`,杜绝借治理接口探测非公开资产);缺资产→`MARKET_ASSET_NOT_EXISTS`、非法 API version→`MARKET_API_VERSION_UNSUPPORTED`,三 op 全程纯读 no-write(资产/收藏/命令/治理表 row_to_json 快照零变更)。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。 - **批18 ✅ 已交付(market 采纳链 2 op,2026-06-17)**:`P1rMarketLicenseInstallCompletedApprovalIT`(真实 PG `muse_p1r_market_license_install_test`,3/3 绿,独立复跑两次均绿)覆盖 purchaseAsset/installMarketplaceAsset;经真实 `/app-api/muse/marketplace/assets/{id}/purchase|install`(MarketLicenseServiceImpl + MarketInstallServiceImpl + MarketCommandServiceImpl + MarketAccountProjectionProvider/Outbox,base-package=cn.iocoder.muse.module),断言:购买写授权快照 + 购买事实 + completed 命令并把 purchase + license 两条 Account 投影经写端口跨 BC 同步(RecordingAccountProjectionApi 真实捕获、读端绝不反写反假绿);安装要求已购 active 授权(无授权→`MARKET_LICENSE_NOT_EXISTS`)、仅 agent/kb 可安装(作品已购仍→`MARKET_INSTALL_NOT_BINDABLE` 且安装路径 0 写)、写 installed 安装记录 + completed 命令 + license 投影;purchase/install 命令幂等回放命中 completed 不写第二份事实;未上架(属主可见非 listed)→`MARKET_ASSET_NOT_LISTED`、缺资产→`MARKET_ASSET_NOT_EXISTS`、授权类型不符→`MARKET_LICENSE_NOT_PURCHASABLE`、API version→`MARKET_API_VERSION_UNSUPPORTED`,失败路径全程 0 写。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。 - **批19 ✅ 已交付(market handoff 簇 4 op,market 验收债清零,2026-06-17)**:`P1rMarketHandoffClusterCompletedApprovalIT`(真实 PG `muse_p1r_market_handoff_test`,3/3 绿,独立复跑两次均绿)覆盖 createBindPrecheck/createMarketplaceHandoff/getHandoffStatus/cancelHandoff;经真实 `/app-api/muse/marketplace/assets/{id}/bind-precheck`、`/handoffs`、`/handoffs/{token}`、`/handoffs/{token}/cancel`(MarketHandoffServiceImpl + MarketCommandServiceImpl + LocalMarketTargetOwnerFacade,base-package=cn.iocoder.muse.module),断言端到端链:bind-precheck 基于已购 active 授权快照生成来源侧授权摘要(handoffReady + sourceStatus=available + compatibilityResult=source_summary_ready + actionPolicy installPolicy/bindPolicy=allowed,落 active 摘要 + completed 命令)→ 凭摘要创建一次性 handoff(落 pending token 事件 + completed 命令,**token 明文不入库、只存 sha256 hash,事件快照不含明文 token**,targetPage 由 LocalMarketTargetOwnerFacade 白名单模板生成)→ 凭 token 只读回显 pending(纯读 no-write)→ 按 expectedStatus CAS 取消(pending→cancelled + completed_at + completed 命令);bind/handoff 相同 commandId 回放命中 completed 返回与首呼一致结果(handoff 回放确定性 token 一致)不双写、cancel 终态后再次取消被取消性守卫拒绝(`MARKET_HANDOFF_NOT_CANCELLABLE`,该守卫先于 replay 短路、故顺序回放观察到的是终态守卫而非幂等成功)且 0 写;fail-closed 全覆盖:非法 API version→`MARKET_API_VERSION_UNSUPPORTED`、缺资产→`MARKET_ASSET_NOT_EXISTS`、无活跃授权→`MARKET_LICENSE_NOT_EXISTS`(授权≠可达不变式)、目标 owner 白名单外→`MARKET_TARGET_OWNER_UNAVAILABLE`(本地目标页边界 fail-closed,buildTargetPage 先于预占命令抛错)、跨属主 token 读取/取消→`MARKET_RESOURCE_FORBIDDEN`(token 属主隔离)、取消 CAS 期望态不匹配→`MARKET_HANDOFF_NOT_CANCELLABLE`(reserveCommand 为 `Propagation.MANDATORY`,预占命令随 `@Transactional` 回滚 0 残留),失败路径全程 0 写。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。 - - 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market **0**(批15-19 补管理端资产治理读写 5 + marketplace 发现读 3 + 采纳链 purchase/install 2 + handoff 簇 4 = 14 op,叠加本战役前已补证的发布生产 5 + 管理端申诉 3 + 管理端发布审核 3 + 生产者申诉 3 = 14 op,合计 28 op 全补证 → market needs_verification 验收债清零,证据就绪待人工批)+ account **0**(adminListPurchaseRecords 已补证 → account needs_verification 验收债清零,批3/7-14 全部经真实 PG 证据、证据就绪待人工批),按批续推 content 26。 + - **批20 ✅ 已交付(content planning candidate 簇 5 op fail-closed,2026-06-17)**:`P1rContentPlanningCandidateClusterCompletedApprovalIT`(真实 PG `muse_p1r_content_planning_candidate_test`,3/3 绿,独立复跑两次均绿)覆盖 createPlanningCandidate/listPlanningCandidates/getPlanningCandidate/confirmPlanningCandidate/discardPlanningCandidate;经真实 `/app-api/muse/works/{id}/planning/candidates*`(AppContentPlanningController + ContentPlanningServiceImpl + ContentCommandServiceImpl + ContentAuditServiceImpl + 租户行拦截器,base-package=cn.iocoder.muse.module.content)。规划候选生成/运行事实/状态投影归 **AI owner**,单体内 `ContentPlanningCandidateFacade` 仅 fail-closed 默认实现(全 default = unavailable,等价生产 `UnavailableContentPlanningCandidateFacade` 的 `@ConditionalOnMissingBean` 兜底)。断言:AI owner 不可用时五 op 全部 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`,绝不伪造 queued/processing 任务、绝不在 Content 侧伪造 discarded;尤其 **confirmPlanningCandidate(候选进正文唯一合法入口)拒绝写正式 planning section**(无候选投影即不落 canonical),create/confirm/discard 写 op 已预占命令随 `@Transactional` 回滚 0 残留,五 op 全程 0 写(无 planning section / 无命令 / 无事件 outbox);owner/存在性守卫先于外部调用——跨属主作品→`CONTENT_FORBIDDEN`、缺作品→`CONTENT_NOT_FOUND` 且 0 写(杜绝借候选接口探测他人作品)。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫;迁移 V1-V21。**证据就绪,completed 待人工批**。 + - 剩余:content **21**(批20 补 planning candidate 簇 5 op fail-closed;余下约 15 为 FileService/New-API/SSE 外部 owner fail-closed → 只能验"失败关闭"真实证据,其余含 parse-result confirm/reject、dynamic-fields 校验、meta-projection 读等真实持久化 op)+ market **0**(批15-19 补管理端资产治理读写 5 + marketplace 发现读 3 + 采纳链 purchase/install 2 + handoff 簇 4 = 14 op,叠加本战役前已补证的发布生产 5 + 管理端申诉 3 + 管理端发布审核 3 + 生产者申诉 3 = 14 op,合计 28 op 全补证 → market needs_verification 验收债清零,证据就绪待人工批)+ account **0**(adminListPurchaseRecords 已补证 → account needs_verification 验收债清零,批3/7-14 全部经真实 PG 证据、证据就绪待人工批),按批续推 content 21。 - ✅ **P1r*IT 真实 PG 基线(2026-06-15,mini-infra PG)= 23/23 IT 类全绿**(99 用例 0F/0E;2 例 external-acceptance 因未设 `MUSE_P1R_EXTERNAL_ACCEPTANCE` 跳过)。`P1rKnowledgeFlywayMigrationIT` 版本断言由硬编码(V14→V21→V23 复发两次)改为 `MigrateResult` 动态自适应(commit 4d46d7a,Codex+Opus 双代理合并)。**完整跑法见 [.agents/knowledge §四](../../.agents/knowledge/external-deps-and-gotchas.md)**:`_test` 后缀隔离库 + `source infra.env`(密码仅 env)+ argLine(SOCKS 清 + p1r.flyway.url/user/locations)+ `-DreuseForks=false`(批量必加,避 Market IT 属性脱敏污染复用 fork)。 - 🔧 ai/平台预存红测试整改(CI 接电将暴露):`MuseAiTaskServiceTest` 桩缺失 11 例 NPE **✅ 已整改(补 eventPublishOutboxService/candidateReviewService 桩 + review lenient,40/40,2026-06-15)**;`QiniuSmsClientTest` 时区硬编码 **✅ 已整改(5/5,机器无关)**;`MuseAiEventPublishOutboxMapperTest` **✅ 真实 PG 实跑 5/5(mini-infra PG,harness=infra.env+argLine SOCKS 清;隔离 schema 自建自清)**。**ai/平台预存红三项全清。** diff --git a/muse-cloud/muse-module-content/.agent b/muse-cloud/muse-module-content/.agent index 34836557..7bdc1415 100644 --- a/muse-cloud/muse-module-content/.agent +++ b/muse-cloud/muse-module-content/.agent @@ -5,5 +5,5 @@ - **目标(owner 职责)**:Work / Chapter / Block / 正文版本 / Block Source Attribution / Import-Export / Planning Canonical / Narrative State。 - **边界(不可违反)**:**正文 Canonical 唯一写入方**;Block 写入须带 expectedRevision 且 revision 单调递增;**Accept Suggestion 是候选进正文的唯一合法入口**——只写正文 + 候选归档 + 来源归因,**不写 Local KB**;守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。 - **out-of-scope**:知识入库(归 knowledge BC)、AI 生成(归 ai BC)。 -- **现状**:只读评估 82%;后端齐全,契约高度一致。 +- **现状**:只读评估 82%;后端齐全,契约高度一致。**2026-06-17 验收债**:批1 已补 work/chapter/block 生命周期 11 op 真实 PG 证据;批20 `P1rContentPlanningCandidateClusterCompletedApprovalIT` 补 planning candidate 簇 5 op(create/list/get/confirm/discard)的 AI owner 不可用 fail-closed 证据(候选生成/运行事实/状态投影归 AI owner,`ContentPlanningCandidateFacade` 单体仅 `UnavailableContentPlanningCandidateFacade` 兜底;五 op 全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`,confirm 作为候选进正文唯一合法入口拒绝伪造正文 canonical、写 op 预占命令随 `@Transactional` 回滚 0 写;owner/存在性守卫先于外部调用),真实 PG 3/3 绿(独立复跑)。content needs_verification 验收债余 21 op(约 15 FileService/New-API/SSE 外部 owner fail-closed + parse-result confirm/reject、dynamic-fields 校验、meta-projection 读等真实持久化 op),证据就绪待人工批。 - **关键风险 / TODO**:**前端 studio 未接 `suggestion-merges`**,AI 候选进正文在用户端断链不可用(objective“先审后入”落点)。 diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentPlanningCandidateClusterCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentPlanningCandidateClusterCompletedApprovalIT.java new file mode 100644 index 00000000..fd061d97 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentPlanningCandidateClusterCompletedApprovalIT.java @@ -0,0 +1,745 @@ +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.ContentAuditServiceImpl; +import cn.iocoder.muse.module.content.application.ContentCommandServiceImpl; +import cn.iocoder.muse.module.content.application.ContentPlanningServiceImpl; +import cn.iocoder.muse.module.content.application.facade.ContentPlanningCandidateFacade; +import cn.iocoder.muse.module.content.application.facade.ContentStyleCheckFacade; +import cn.iocoder.muse.module.content.controller.app.AppContentPlanningController; +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.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +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_EXTERNAL_OWNER_UNAVAILABLE; +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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P1R Content planning candidate 簇 completed approval:AI 规划候选「先审后入」链在 AI owner 未接入时的 + * fail-closed HTTP + 真实 PostgreSQL 证据(content 验收债推进)。 + * + *

规划候选生成/运行事实/状态投影归 AI owner;单体内 {@code ContentPlanningCandidateFacade} 仅有 + * fail-closed 默认实现(全部 default 方法返回 unavailable,等价生产的 {@code UnavailableContentPlanningCandidateFacade} + * `@ConditionalOnMissingBean` 兜底)。本测试只连接显式传入的 PostgreSQL {@code _test} 隔离库,经真实 + * {@code /app-api/muse/works/{workId}/planning/candidates*} 入口进入 Controller。断言:在 AI owner 不可用时, + * createPlanningCandidate/listPlanningCandidates/getPlanningCandidate/confirmPlanningCandidate/discardPlanningCandidate + * 五 op 全部 fail-closed 返回 {@code CONTENT_EXTERNAL_OWNER_UNAVAILABLE},绝不伪造 queued/processing 任务、绝不在 + * Content 侧伪造 discarded;尤其 **confirmPlanningCandidate(候选进正文唯一合法入口)拒绝写正式 planning section** + * (无候选投影即不落 canonical),写 op 已预占的命令随 {@code @Transactional} 回滚 0 残留,失败路径全程 0 写。 + * 另证:owner/存在性守卫先于外部调用——跨属主作品→{@code CONTENT_FORBIDDEN}、缺作品→{@code CONTENT_NOT_FOUND} + * 且 0 写,杜绝借候选接口探测他人作品。

+ */ +@SpringBootTest( + classes = P1rContentPlanningCandidateClusterCompletedApprovalIT.PlanningCandidateClusterConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rContentPlanningCandidateClusterCompletedApprovalIT { + + private static final String TARGET_VERSION = "21"; + private static final Long TENANT_ID = 100L; + private static final Long LOGIN_USER_ID = 9001L; + private static final Long OTHER_USER_ID = 9002L; + private static final Long MISSING_WORK_ID = 999_999_999L; + private static final Long CANDIDATE_ID = 50_001L; + 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 long ownedWorkId; + private long otherOwnerWorkId; + + @DynamicPropertySource + static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) { + CompletedApprovalSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-content-planning-candidate-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"); + } + + @BeforeAll + void migrateContentSchema() { + CompletedApprovalSettings settings = settings(); + silenceFlywayInfoLogs(); + ensureTestDatabaseExists(settings); + 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); + // Content work/planning_section/command_log/outbox schema 在 V1-V21 内建成。 + assertEquals(21, result.migrationsExecuted, + "Content planning candidate completed approval 必须在隔离库执行 V1-V21 全量迁移,实际: " + + result.migrationsExecuted); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetContentTables(); + this.ownedWorkId = seedWork(TENANT_ID, LOGIN_USER_ID, "P1R 候选簇属主作品"); + this.otherOwnerWorkId = seedWork(TENANT_ID, OTHER_USER_ID, "P1R 候选簇他人作品"); + setRuntimeContext(LOGIN_USER_ID); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_rejectUnsafeDatabaseConfigurationInputs() { + System.setProperty("p1r.content.planning-candidate.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rContentPlanningCandidateClusterCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.content.planning-candidate.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.content.planning-candidate.password"); + } + + AssertionError credentialQueryError = assertThrows(AssertionError.class, + () -> assertNoCredentialQuery("jdbc:postgresql://localhost:5432/muse_test?password=secret")); + assertTrue(credentialQueryError.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"), + "拒绝 JDBC credential query 时必须说明连接串只能通过环境变量传密码"); + + AssertionError databaseNameError = assertThrows(AssertionError.class, + () -> assertTestDatabaseUrl("jdbc:postgresql://localhost:5432/muse_prod")); + assertTrue(databaseNameError.getMessage().contains("_test"), + "拒绝非 _test 数据库时必须指出隔离库后缀要求"); + } + + @Test + void should_failClosedAcrossPlanningCandidateClusterWhenAiOwnerUnavailable() throws Exception { + // create:AI owner 不可用时不能伪造 queued 任务;已预占命令随事务回滚。 + assertNoContentMutationDuring("createPlanningCandidate 在 AI owner 不可用时必须 fail-closed", + () -> mockMvc.perform(post("/app-api/muse/works/{workId}/planning/candidates", ownedWorkId) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(createBody("cmd-create-1", "outline", "draft"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // list:候选摘要投影归 AI owner,不可用时只读 fail-closed。 + assertNoContentMutationDuring("listPlanningCandidates 在 AI owner 不可用时必须 fail-closed", + () -> mockMvc.perform(get("/app-api/muse/works/{workId}/planning/candidates", ownedWorkId) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // get:候选详情投影归 AI owner,不可用时只读 fail-closed。 + assertNoContentMutationDuring("getPlanningCandidate 在 AI owner 不可用时必须 fail-closed", + () -> mockMvc.perform(get("/app-api/muse/works/{workId}/planning/candidates/{candidateId}", + ownedWorkId, CANDIDATE_ID) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // confirm:候选进正文唯一合法入口;无候选投影即拒绝写正式 planning section,已预占命令随事务回滚。 + assertNoContentMutationDuring("confirmPlanningCandidate 在 AI owner 不可用时必须拒绝写正文", + () -> mockMvc.perform(post("/app-api/muse/works/{workId}/planning/candidates/{candidateId}/confirm", + ownedWorkId, CANDIDATE_ID) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(confirmBody("cmd-confirm-1", 1))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // discard:丢弃状态归 AI owner,不可用时不能在 Content 侧伪造 discarded;已预占命令随事务回滚。 + assertNoContentMutationDuring("discardPlanningCandidate 在 AI owner 不可用时必须 fail-closed", + () -> mockMvc.perform(post("/app-api/muse/works/{workId}/planning/candidates/{candidateId}/discard", + ownedWorkId, CANDIDATE_ID) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(discardBody("cmd-discard-1", "用户放弃该候选"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // 五 op 全程未落任何 Content fact:无正式 planning section(候选未进正文)、无命令(写 op 预占已回滚)。 + assertEquals(0, countRows("muse_content_planning_section", "1 = 1"), + "候选簇 fail-closed 不得写任何正式 planning section"); + assertEquals(0, countRows("muse_content_command_log", "1 = 1"), + "候选簇写 op 预占命令必须随事务回滚,0 残留"); + assertEquals(0, countRows("muse_content_event_publish_outbox", "1 = 1"), + "候选簇 fail-closed 不得写事件 outbox"); + } + + @Test + void should_enforceOwnerAndExistenceGuardsBeforeReachingAiOwner() throws Exception { + // 跨属主:他人作品的候选列表 → owner 守卫先于外部调用拒绝,0 写。 + assertNoContentMutationDuring("listPlanningCandidates 跨属主作品必须拒绝", + () -> mockMvc.perform(get("/app-api/muse/works/{workId}/planning/candidates", otherOwnerWorkId) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_FORBIDDEN.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // 缺作品:候选列表 → not-found 守卫,0 写。 + assertNoContentMutationDuring("listPlanningCandidates 缺作品必须拒绝", + () -> mockMvc.perform(get("/app-api/muse/works/{workId}/planning/candidates", MISSING_WORK_ID) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // 跨属主:他人作品创建候选 → owner 守卫先于命令预占拒绝,0 写(不残留预占命令)。 + assertNoContentMutationDuring("createPlanningCandidate 跨属主作品必须拒绝", + () -> mockMvc.perform(post("/app-api/muse/works/{workId}/planning/candidates", otherOwnerWorkId) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(createBody("cmd-cross-owner", "outline", "draft"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_FORBIDDEN.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + assertEquals(0, countRows("muse_content_command_log", "1 = 1"), + "owner/存在性守卫拒绝路径不得残留任何命令"); + } + + // ==================== 运行期上下文 ==================== + + private void setRuntimeContext(Long userId) { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(userId); + loginUser.setUserType(UserTypeEnum.MEMBER.getValue()); + loginUser.setTenantId(TENANT_ID); + loginUser.setVisitTenantId(TENANT_ID); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + // ==================== 请求体 ==================== + + private String createBody(String commandId, String sectionKey, String taskType) { + return """ + {"sectionKey":"%s","taskType":"%s","commandId":"%s"} + """.formatted(sectionKey, taskType, commandId); + } + + private String confirmBody(String commandId, int expectedRevision) { + return """ + {"commandId":"%s","expectedRevision":%d} + """.formatted(commandId, expectedRevision); + } + + private String discardBody(String commandId, String reason) { + return """ + {"commandId":"%s","reason":"%s"} + """.formatted(commandId, reason); + } + + // ==================== 种子 / 重置 ==================== + + private void resetContentTables() throws SQLException { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(""" + TRUNCATE TABLE + muse_content_event_publish_outbox, + muse_content_command_log, + muse_content_planning_section, + muse_content_work + RESTART IDENTITY CASCADE + """); + } + } + + private long seedWork(Long tenantId, Long ownerUserId, String title) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_content_work(owner_user_id, title, description, genre, status, revision, tenant_id) + VALUES (?, ?, ?, 'fiction', 'draft', 1, ?) + RETURNING id + """)) { + statement.setLong(1, ownerUserId); + statement.setString(2, title); + statement.setString(3, title + " description"); + statement.setLong(4, tenantId); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed muse_content_work 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + // ==================== 库内事实查询 ==================== + + private int countRows(String tableName, String whereClause) { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT count(*) FROM " + tableName + " WHERE " + whereClause); + ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getInt(1); + } catch (SQLException exception) { + throw new AssertionError("统计 " + tableName + " 失败", exception); + } + } + + // ==================== 无突变断言(反假绿) ==================== + + private void assertNoContentMutationDuring(String message, CheckedOperation operation) throws Exception { + ContentFactSnapshot before = contentFactSnapshot(); + operation.run(); + assertEquals(before, contentFactSnapshot(), message + ":不能新增、删除或更新任何 Content fact"); + } + + private ContentFactSnapshot contentFactSnapshot() { + return new ContentFactSnapshot( + tableSnapshot("muse_content_work", "tenant_id, id"), + tableSnapshot("muse_content_planning_section", "tenant_id, id"), + tableSnapshot("muse_content_command_log", "tenant_id, id"), + tableSnapshot("muse_content_event_publish_outbox", "tenant_id, id") + ); + } + + private List tableSnapshot(String tableName, String orderBy) { + String sql = """ + SELECT row_to_json(t)::text + FROM %s t + ORDER BY %s + """.formatted(tableName, orderBy); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + List rows = new ArrayList<>(); + while (resultSet.next()) { + rows.add(resultSet.getString(1)); + } + return rows; + } catch (SQLException exception) { + throw new AssertionError("读取 " + tableName + " 快照失败", exception); + } + } + + // ==================== 连接配置(密码仅 env、URL 脱敏、_test 守卫) ==================== + + 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 void ensureTestDatabaseExists(CompletedApprovalSettings settings) { + String databaseName = jdbcDatabaseName(settings.jdbcUrl()); + try (Connection connection = DriverManager.getConnection( + maintenanceJdbcUrl(settings.jdbcUrl()), settings.jdbcUser(), settings.jdbcPassword()); + Statement statement = connection.createStatement()) { + connection.setAutoCommit(true); + statement.execute("CREATE DATABASE " + quotedIdentifier(databaseName)); + } catch (SQLException exception) { + if ("42P04".equals(exception.getSQLState())) { + return; + } + throw sanitizedSqlFailure("创建 PostgreSQL _test 数据库失败", 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 AssertionError sanitizedSqlFailure(String action, CompletedApprovalSettings settings, + SQLException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace(settings.jdbcUser(), ""); + 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_PLANNING_CANDIDATE_COMPLETED_PASSWORD", + "P1R_CONTENT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_CONTENT_PLANNING_CANDIDATE_COMPLETED_PASSWORD、P1R_CONTENT_COMPLETED_PASSWORD、" + + "P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); + return password; + } + + private static String firstNonBlankEnvironment(String... names) { + 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(P1rContentPlanningCandidateClusterCompletedApprovalIT::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 planning candidate 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 maintenanceJdbcUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + String querySuffix = url.indexOf('?') < 0 ? "" : url.substring(url.indexOf('?')); + return urlWithoutQuery.substring(0, databaseStart + 1) + "postgres" + querySuffix; + } + + private static String quotedIdentifier(String identifier) { + assertTrue(identifier.matches("[A-Za-z0-9_]+"), + "测试数据库名只能包含字母、数字和下划线: " + identifier); + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + 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; + } + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + 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。 + } + } + + @FunctionalInterface + private interface CheckedOperation { + + void run() throws Exception; + } + + private record ContentFactSnapshot(List works, + List planningSections, + List commands, + List outbox) { + } + + 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({ + AppContentPlanningController.class, + ContentPlanningServiceImpl.class, + ContentCommandServiceImpl.class, + ContentAuditServiceImpl.class, + SpringUtil.class + }) + static class PlanningCandidateClusterConfiguration { + + /** + * 全 default 的候选 facade 等价生产 UnavailableContentPlanningCandidateFacade(AI owner 未接入兜底), + * 用于复现真实 fail-closed 行为(不可在测试侧伪造可用投影)。 + */ + @Bean + ContentPlanningCandidateFacade contentPlanningCandidateFacade() { + return new ContentPlanningCandidateFacade() { + }; + } + + @Bean + ContentStyleCheckFacade contentStyleCheckFacade() { + return new ContentStyleCheckFacade() { + }; + } + + @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); + } + }; + } + } +}