test(p1r): 验收债批20 真实 PG IT(content planning candidate 簇 5 op fail-closed) + 台账/.agent 同步

P1rContentPlanningCandidateClusterCompletedApprovalIT(真实 PG
muse_p1r_content_planning_candidate_test,3/3 绿,独立复跑两次均绿)覆盖
createPlanningCandidate/listPlanningCandidates/getPlanningCandidate/
confirmPlanningCandidate/discardPlanningCandidate,经真实
/app-api/muse/works/{id}/planning/candidates* 入口。

规划候选生成/运行事实/状态投影归 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 写。owner/存在性守卫先于外部调用——
跨属主→CONTENT_FORBIDDEN、缺作品→CONTENT_NOT_FOUND 且 0 写。

content needs_verification 验收债 26→21(余约 15 FileService/New-API/SSE
外部 owner fail-closed + parse-result/dynamic-fields/meta-projection 真实持久化 op)。
台账 completed flip + 覆盖门禁 APPROVED_COMPLETED_OPERATIONS 仍须人工批准,agent 不自批。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-17 02:25:20 -07:00
parent 5d34b3d853
commit 7d3a69ca4d
3 changed files with 748 additions and 2 deletions

View File

@ -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/平台预存红三项全清。**

View File

@ -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“先审后入”落点)。

View File

@ -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 approvalAI 规划候选先审后入链在 AI owner 未接入时的
* fail-closed HTTP + 真实 PostgreSQL 证据(content 验收债推进)
*
* <p>规划候选生成/运行事实/状态投影归 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 ,杜绝借候选接口探测他人作品</p>
*/
@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<String> CREDENTIAL_QUERY_KEYS = Set.of(
"user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
"token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token");
private static volatile CompletedApprovalSettings cachedSettings;
private static volatile boolean originalFlywayPropertiesCaptured;
private static volatile String originalFlywayUrlSystemProperty;
private static volatile String originalFlywayUserSystemProperty;
@Autowired
private DataSource dataSource;
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
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<String> 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<String> rows = new ArrayList<>();
while (resultSet.next()) {
rows.add(resultSet.getString(1));
}
return rows;
} catch (SQLException exception) {
throw new AssertionError("读取 " + tableName + " 快照失败", exception);
}
}
// ==================== 连接配置(密码仅 envURL 脱敏_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 '<user-redacted>'");
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(), "<user-redacted>");
return new AssertionError(action + ": " + sanitizedMessage);
}
private static String requiredProperty(String name) {
String value = System.getProperty(name);
assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name);
return value;
}
private static String requiredPasswordEnvironment() {
String password = firstNonBlankEnvironment("P1R_CONTENT_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<String> 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 ? "" : "?<query-redacted>";
}
private static String maskJdbcHost(String urlPart) {
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
}
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() ? "<user-redacted>" : "<user-redacted>");
}
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<String> works,
List<String> planningSections,
List<String> commands,
List<String> 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<Boolean> createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
return CommonResult.success(true);
}
};
}
}
}