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:
parent
5d34b3d853
commit
7d3a69ca4d
@ -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/平台预存红三项全清。**
|
||||
|
||||
|
||||
@ -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“先审后入”落点)。
|
||||
|
||||
@ -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 验收债推进)。
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 连接配置(密码仅 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 '<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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user