test(p1r): 验收债批24 真实 PG IT(content parse-job 生命周期 4 op fail-closed) + 台账/.agent 同步
P1rContentParseJobLifecycleCompletedApprovalIT(真实 PG
muse_p1r_content_parse_job_test,3/3 绿,独立复跑两次均绿)覆盖
getParseJob/listParseJobChapters/retryParseJob/batchConfirmChapters,经真实
/app-api/muse/parse-jobs/{jobId}{,/chapters,/retry,/chapters/batch-confirm} 入口。
AI 解析任务与章节结果投影归 AI owner;单体内 ContentParseJobFacade/
ContentKnowledgeDraftFacade/ContentFileFacade 仅有 fail-closed 默认实现
(全 default = unavailable,@ConditionalOnMissingBean 兜底)。断言:AI owner 不可用时
四 op 全部 fail-closed→CONTENT_EXTERNAL_OWNER_UNAVAILABLE(解析任务投影在
requireParseJobProjection 处即不可用,故不伪造任务/章节/重试/批量确认结果);
两写 op(retry/batchConfirm)已预占命令随 @Transactional 回滚 0 残留,四 op 全程 0 写;
非法 API version→CONTENT_API_VERSION_UNSUPPORTED 守卫先于服务体且 0 写。
content needs_verification 验收债 14→10(余 10 全为 FileService/New-API/SSE
外部 owner fail-closed:导入/导出/管理端任务/createParseJob/合并建议)。
台账 completed flip + 覆盖门禁 APPROVED_COMPLETED_OPERATIONS 仍须人工批准,agent 不自批。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
517abbdb2a
commit
c8990fd2d2
@ -139,7 +139,8 @@
|
||||
- **批21 ✅ 已交付(content meta-projection 簇 3 op fail-closed,2026-06-17)**:`P1rContentMetaProjectionClusterCompletedApprovalIT`(真实 PG `muse_p1r_content_meta_projection_test`,3/3 绿,独立复跑两次均绿)覆盖 listMetaProjections/getMetaProjection/validateDynamicFields;经真实 `/app-api/muse/works/{id}/meta-projections`、`/meta-projections/{key}`、`/dynamic-fields/validate`(AppContentMetaProjectionController + ContentMetaProjectionServiceImpl + 租户行拦截器,base-package=cn.iocoder.muse.module.content)。MetaSchema 解释/Meta 投影/动态字段校验规则均归 **Meta owner**,单体内 `ContentMetaFacade` 仅 fail-closed 默认实现(全 default = unavailable,`@ConditionalOnMissingBean` 兜底)。断言:Meta owner 不可用时三 op 全部 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`,绝不在 Content 侧伪造投影或复制 MetaSchema 规则,三 op 纯读全程 0 写;守卫先于外部调用——非法 API version→`CONTENT_API_VERSION_UNSUPPORTED`、跨属主作品→`CONTENT_FORBIDDEN`、缺作品→`CONTENT_NOT_FOUND` 且 0 写(validateDynamicFields 的 `fields` 经 `@NotEmpty`+元素 `@NotBlank`/`@NotNull` 在入服务前 @Valid 拦截)。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫;迁移 V1-V21。**证据就绪,completed 待人工批**。
|
||||
- **批22 ✅ 已交付(content style-check 簇 2 op fail-closed,2026-06-17)**:`P1rContentStyleCheckClusterCompletedApprovalIT`(真实 PG `muse_p1r_content_style_check_test`,3/3 绿,独立复跑两次均绿)覆盖 createStyleCheck/getStyleCheckResult;经真实 `/app-api/muse/works/{id}/planning/style-checks` 与 `/style-checks/{jobId}`(AppContentPlanningController + ContentPlanningServiceImpl + ContentCommandServiceImpl + ContentAuditServiceImpl + 租户行拦截器,base-package=cn.iocoder.muse.module.content)。风格检查任务生成与结果归 **AI owner**,单体内 `ContentStyleCheckFacade` 仅 fail-closed 默认实现(全 default = unavailable,`@ConditionalOnMissingBean` 兜底)。断言:AI owner 不可用时两 op 全部 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`,绝不伪造 queued 任务或结果;createStyleCheck 写 op 已预占命令随 `@Transactional` 回滚 0 残留,两 op 全程 0 写;owner/存在性守卫先于外部调用——跨属主作品→`CONTENT_FORBIDDEN`、缺作品→`CONTENT_NOT_FOUND` 且 0 写。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫;迁移 V1-V21。**证据就绪,completed 待人工批**。
|
||||
- **批23 ✅ 已交付(content chapter-parse-result 决策 2 op fail-closed,2026-06-17)**:`P1rContentChapterParseResultDecisionCompletedApprovalIT`(真实 PG `muse_p1r_content_import_parse_test`,3/3 绿,独立复跑两次均绿)覆盖 confirmChapterParseResult/rejectChapterParseResult;经真实 `/app-api/muse/chapter-parse-results/{resultId}/confirm`、`/reject`(AppContentImportParseController + ContentImportParseServiceImpl + ContentCommandServiceImpl + ContentAuditServiceImpl + 租户行拦截器,base-package=cn.iocoder.muse.module.content)。AI 解析任务/章节结果投影/知识草稿生成均归 **AI owner**;单体内 `ContentParseJobFacade`/`ContentKnowledgeDraftFacade`/`ContentFileFacade` 仅 fail-closed 默认实现(全 default = unavailable,`@ConditionalOnMissingBean` 兜底)。断言:AI owner 不可用时两 op 全部 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`(章节结果投影在 `requireChapterResultProjection` 处即不可用,故绝不生成知识草稿、绝不记录确认/拒绝决策);两 op 均写路,已预占命令随 `@Transactional` 回滚 0 残留,全程 0 写;非法 API version→`CONTENT_API_VERSION_UNSUPPORTED` 守卫先于服务体且 0 写。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫;迁移 V1-V21。**证据就绪,completed 待人工批**。
|
||||
- 剩余:content **14**(批20-23 补 planning candidate 5 + meta-projection 3 + style-check 2 + parse-result 决策 2 = 12 op,均为外部 owner(AI/Meta/解析)未接入 fail-closed;余 14 全为 FileService/New-API/SSE 外部 owner:导入 createImportTask/getImportTask、导出 exportWork/createExportTask/getExportTask/downloadExportPackage、管理端 adminListExportTasks/adminListImportTasks、解析任务 createParseJob/getParseJob/listParseJobChapters/batchConfirmChapters/retryParseJob、合并建议 mergeBlockSuggestion → 只能验"失败关闭"真实证据)+ 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 14。
|
||||
- **批24 ✅ 已交付(content parse-job 生命周期 4 op fail-closed,2026-06-17)**:`P1rContentParseJobLifecycleCompletedApprovalIT`(真实 PG `muse_p1r_content_parse_job_test`,3/3 绿,独立复跑两次均绿)覆盖 getParseJob/listParseJobChapters/retryParseJob/batchConfirmChapters;经真实 `/app-api/muse/parse-jobs/{jobId}`、`/chapters`、`/retry`、`/chapters/batch-confirm`(同 批23 配置:AppContentImportParseController + ContentImportParseServiceImpl + ContentCommandServiceImpl + ContentAuditServiceImpl + 3 外部 owner facade(`ContentParseJobFacade`/`ContentKnowledgeDraftFacade`/`ContentFileFacade`)默认 unavailable + 租户行拦截器,base-package=cn.iocoder.muse.module.content)。断言:AI 解析 owner 不可用时四 op 全部 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`(解析任务投影在 `requireParseJobProjection` 处即不可用,故绝不伪造任务/章节/重试/批量确认结果);两写 op(retry/batchConfirm)已预占命令随 `@Transactional` 回滚 0 残留,四 op 全程 0 写;非法 API version→`CONTENT_API_VERSION_UNSUPPORTED` 守卫先于服务体且 0 写。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫;迁移 V1-V21。**证据就绪,completed 待人工批**。
|
||||
- 剩余:content **10**(批20-24 补 planning candidate 5 + meta-projection 3 + style-check 2 + parse-result 决策 2 + parse-job 生命周期 4 = 16 op,均为外部 owner(AI/Meta/解析)未接入 fail-closed;余 10 全为 FileService/New-API/SSE 外部 owner:导入 createImportTask/getImportTask、导出 exportWork/createExportTask/getExportTask/downloadExportPackage、管理端 adminListExportTasks/adminListImportTasks、解析任务 createParseJob、合并建议 mergeBlockSuggestion → 只能验"失败关闭"真实证据)+ 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 10。
|
||||
- ✅ **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%;后端齐全,契约高度一致。**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 绿(独立复跑)。批21 `P1rContentMetaProjectionClusterCompletedApprovalIT` 补 meta-projection 簇 3 op(listMetaProjections/getMetaProjection/validateDynamicFields)的 Meta owner 不可用 fail-closed 证据(MetaSchema 解释/Meta 投影/动态字段校验归 Meta owner,`ContentMetaFacade` 单体仅 unavailable 兜底;三 op 纯读全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE` 不伪造投影/不复制 MetaSchema 规则 0 写;version/owner/存在性守卫先于外部调用),真实 PG 3/3 绿(独立复跑)。批22 `P1rContentStyleCheckClusterCompletedApprovalIT` 补 style-check 簇 2 op(createStyleCheck/getStyleCheckResult)的 AI owner 不可用 fail-closed 证据(风格检查任务/结果归 AI owner,`ContentStyleCheckFacade` 单体仅 unavailable 兜底;两 op 全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`,写 op createStyleCheck 预占命令随事务回滚 0 写;owner/存在性守卫先于外部调用),真实 PG 3/3 绿(独立复跑)。批23 `P1rContentChapterParseResultDecisionCompletedApprovalIT` 补 chapter-parse-result 决策 2 op(confirmChapterParseResult/rejectChapterParseResult)的 AI 解析 owner 不可用 fail-closed 证据(解析任务/章节结果投影/知识草稿归 AI owner,`ContentParseJobFacade`/`ContentKnowledgeDraftFacade`/`ContentFileFacade` 单体仅 unavailable 兜底;章节结果投影在 requireChapterResultProjection 即不可用 → 两 op 全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE` 不生成草稿/不记录决策,写路预占命令随事务回滚 0 写;version 守卫先于服务体),真实 PG 3/3 绿(独立复跑)。content needs_verification 验收债余 14 op(全为 FileService/New-API/SSE 外部 owner:导入/导出/管理端任务/解析任务/合并建议),证据就绪待人工批。**至此本战役 content 26→14(批1 补 11 work/chapter/block 生命周期 + 批20-23 补 12 外部 owner fail-closed),account 0 / market 0 已清零。**
|
||||
- **现状**:只读评估 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 绿(独立复跑)。批21 `P1rContentMetaProjectionClusterCompletedApprovalIT` 补 meta-projection 簇 3 op(listMetaProjections/getMetaProjection/validateDynamicFields)的 Meta owner 不可用 fail-closed 证据(MetaSchema 解释/Meta 投影/动态字段校验归 Meta owner,`ContentMetaFacade` 单体仅 unavailable 兜底;三 op 纯读全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE` 不伪造投影/不复制 MetaSchema 规则 0 写;version/owner/存在性守卫先于外部调用),真实 PG 3/3 绿(独立复跑)。批22 `P1rContentStyleCheckClusterCompletedApprovalIT` 补 style-check 簇 2 op(createStyleCheck/getStyleCheckResult)的 AI owner 不可用 fail-closed 证据(风格检查任务/结果归 AI owner,`ContentStyleCheckFacade` 单体仅 unavailable 兜底;两 op 全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`,写 op createStyleCheck 预占命令随事务回滚 0 写;owner/存在性守卫先于外部调用),真实 PG 3/3 绿(独立复跑)。批23 `P1rContentChapterParseResultDecisionCompletedApprovalIT` 补 chapter-parse-result 决策 2 op(confirmChapterParseResult/rejectChapterParseResult)的 AI 解析 owner 不可用 fail-closed 证据(解析任务/章节结果投影/知识草稿归 AI owner,`ContentParseJobFacade`/`ContentKnowledgeDraftFacade`/`ContentFileFacade` 单体仅 unavailable 兜底;章节结果投影在 requireChapterResultProjection 即不可用 → 两 op 全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE` 不生成草稿/不记录决策,写路预占命令随事务回滚 0 写;version 守卫先于服务体),真实 PG 3/3 绿(独立复跑)。批24 `P1rContentParseJobLifecycleCompletedApprovalIT` 补 parse-job 生命周期 4 op(getParseJob/listParseJobChapters/retryParseJob/batchConfirmChapters)的 AI 解析 owner 不可用 fail-closed 证据(解析任务投影在 requireParseJobProjection 即不可用 → 四 op 全 fail-closed→`CONTENT_EXTERNAL_OWNER_UNAVAILABLE`,两写 op 预占命令随事务回滚 0 写;version 守卫先于服务体),真实 PG 3/3 绿(独立复跑)。content needs_verification 验收债余 10 op(全为 FileService/New-API/SSE 外部 owner:导入 createImportTask/getImportTask、导出 4、管理端 2、createParseJob、mergeBlockSuggestion),证据就绪待人工批。**至此本战役 content 26→10(批1 补 11 work/chapter/block 生命周期 + 批20-24 补 16 外部 owner fail-closed),account 0 / market 0 已清零。**
|
||||
- **关键风险 / TODO**:**前端 studio 未接 `suggestion-merges`**,AI 候选进正文在用户端断链不可用(objective“先审后入”落点)。
|
||||
|
||||
@ -0,0 +1,702 @@
|
||||
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.ContentImportParseServiceImpl;
|
||||
import cn.iocoder.muse.module.content.application.facade.ContentFileFacade;
|
||||
import cn.iocoder.muse.module.content.application.facade.ContentKnowledgeDraftFacade;
|
||||
import cn.iocoder.muse.module.content.application.facade.ContentParseJobFacade;
|
||||
import cn.iocoder.muse.module.content.controller.app.AppContentImportParseController;
|
||||
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_API_VERSION_UNSUPPORTED;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_EXTERNAL_OWNER_UNAVAILABLE;
|
||||
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 parse-job 生命周期 completed approval:解析任务查询/章节列表/重试/批量确认在 AI 解析 owner
|
||||
* 未接入时的 fail-closed HTTP + 真实 PostgreSQL 证据(content 验收债推进)。
|
||||
*
|
||||
* <p>AI 解析任务与章节结果投影均归 AI owner;单体内 {@code ContentParseJobFacade}/
|
||||
* {@code ContentKnowledgeDraftFacade}/{@code ContentFileFacade} 仅有 fail-closed 默认实现(全 default
|
||||
* 方法返回 unavailable,`@ConditionalOnMissingBean` 兜底)。本测试只连接显式传入的 PostgreSQL {@code _test}
|
||||
* 隔离库,经真实 {@code /app-api/muse/parse-jobs/{jobId}*} 入口进入 Controller。断言:AI owner 不可用时
|
||||
* getParseJob/listParseJobChapters/retryParseJob/batchConfirmChapters 四 op 全部 fail-closed→
|
||||
* {@code CONTENT_EXTERNAL_OWNER_UNAVAILABLE}(解析任务投影在 `requireParseJobProjection` 处即不可用,
|
||||
* 故绝不伪造任务/章节/重试/批量确认结果);两写 op(retry/batchConfirm)已预占命令随 {@code @Transactional}
|
||||
* 回滚 0 残留,四 op 全程 0 写;非法 API version→{@code CONTENT_API_VERSION_UNSUPPORTED} 守卫先于服务体且 0 写。</p>
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = P1rContentParseJobLifecycleCompletedApprovalIT.ParseJobLifecycleConfiguration.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.MOCK
|
||||
)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class P1rContentParseJobLifecycleCompletedApprovalIT {
|
||||
|
||||
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 JOB_ID = 80_001L;
|
||||
private static final Long RESULT_ID = 70_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;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) {
|
||||
CompletedApprovalSettings settings = settings();
|
||||
redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
|
||||
registry.add("spring.application.name", () -> "p1r-content-parse-job-lifecycle-completed-approval-it");
|
||||
registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.content");
|
||||
registry.add("muse.web.admin-ui.url", () -> "http://localhost");
|
||||
registry.add("spring.datasource.url", settings::jdbcUrl);
|
||||
registry.add("spring.datasource.username", settings::jdbcUser);
|
||||
registry.add("spring.datasource.password", settings::jdbcPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.main.banner-mode", () -> "off");
|
||||
registry.add("spring.main.lazy-initialization", () -> "true");
|
||||
registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO");
|
||||
}
|
||||
|
||||
@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 command_log schema 在 V1-V21 内建成。
|
||||
assertEquals(21, result.migrationsExecuted,
|
||||
"Content parse-job lifecycle completed approval 必须在隔离库执行 V1-V21 全量迁移,实际: "
|
||||
+ result.migrationsExecuted);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
|
||||
resetContentTables();
|
||||
setRuntimeContext(LOGIN_USER_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void restoreFlywaySystemProperties() {
|
||||
restoreOriginalFlywaySystemProperties();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectUnsafeDatabaseConfigurationInputs() {
|
||||
System.setProperty("p1r.content.parse-job.password", "must-not-be-used");
|
||||
try {
|
||||
AssertionError error = assertThrows(AssertionError.class,
|
||||
P1rContentParseJobLifecycleCompletedApprovalIT::assertNoPasswordSystemProperties);
|
||||
assertTrue(error.getMessage().contains("p1r.content.parse-job.password"),
|
||||
"拒绝 JVM password system property 时必须指出属性名");
|
||||
} finally {
|
||||
System.clearProperty("p1r.content.parse-job.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_failClosedAcrossParseJobLifecycleWhenAiOwnerUnavailable() throws Exception {
|
||||
// getParseJob:解析任务投影归 AI owner,不可用时只读 fail-closed。
|
||||
assertNoContentMutationDuring("getParseJob 在 AI owner 不可用时必须 fail-closed",
|
||||
() -> mockMvc.perform(get("/app-api/muse/parse-jobs/{jobId}", JOB_ID)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// listParseJobChapters:章节结果投影归 AI owner,不可用时只读 fail-closed。
|
||||
assertNoContentMutationDuring("listParseJobChapters 在 AI owner 不可用时必须 fail-closed",
|
||||
() -> mockMvc.perform(get("/app-api/muse/parse-jobs/{jobId}/chapters", JOB_ID)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// retryParseJob:重试归 AI owner,不可用时不能伪造重试结果;已预占命令随事务回滚。
|
||||
assertNoContentMutationDuring("retryParseJob 在 AI owner 不可用时必须 fail-closed",
|
||||
() -> mockMvc.perform(post("/app-api/muse/parse-jobs/{jobId}/retry", JOB_ID)
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(retryBody("cmd-retry-1")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// batchConfirmChapters:批量确认归 AI owner,不可用时不能伪造确认结果;已预占命令随事务回滚。
|
||||
assertNoContentMutationDuring("batchConfirmChapters 在 AI owner 不可用时必须 fail-closed",
|
||||
() -> mockMvc.perform(post("/app-api/muse/parse-jobs/{jobId}/chapters/batch-confirm", JOB_ID)
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(batchConfirmBody("cmd-batch-1")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// 四 op 全程未落任何 Content fact:两写 op 预占命令已回滚。
|
||||
assertEquals(0, countRows("muse_content_command_log", "1 = 1"),
|
||||
"parse-job 写 op 预占命令必须随事务回滚,0 残留");
|
||||
assertEquals(0, countRows("muse_content_planning_section", "1 = 1"),
|
||||
"parse-job lifecycle fail-closed 不得写 planning section");
|
||||
assertEquals(0, countRows("muse_content_event_publish_outbox", "1 = 1"),
|
||||
"parse-job lifecycle fail-closed 不得写事件 outbox");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_enforceVersionGuardBeforeReachingAiOwner() throws Exception {
|
||||
// 读 op 非法 API version → 版本守卫先于服务体拒绝,0 写。
|
||||
assertNoContentMutationDuring("getParseJob 非法 API version 必须拒绝",
|
||||
() -> mockMvc.perform(get("/app-api/muse/parse-jobs/{jobId}", JOB_ID)
|
||||
.header("X-API-Version", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// 写 op 非法 API version → 版本守卫先于命令预占拒绝,0 写。
|
||||
assertNoContentMutationDuring("retryParseJob 非法 API version 必须拒绝",
|
||||
() -> mockMvc.perform(post("/app-api/muse/parse-jobs/{jobId}/retry", JOB_ID)
|
||||
.header("X-API-Version", "0")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(retryBody("cmd-bad-version")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(CONTENT_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
assertEquals(0, countRows("muse_content_command_log", "1 = 1"),
|
||||
"版本守卫拒绝路径不得残留任何命令");
|
||||
}
|
||||
|
||||
// ==================== 运行期上下文 ====================
|
||||
|
||||
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 retryBody(String commandId) {
|
||||
return """
|
||||
{"commandId":"%s","retryStage":"all"}
|
||||
""".formatted(commandId);
|
||||
}
|
||||
|
||||
private String batchConfirmBody(String commandId) {
|
||||
// resultIds(@NotEmpty)+ expectedRevisions(@NotEmpty Map)在入服务前 @Valid 校验;facade 不可用在服务体内 fail-closed。
|
||||
return """
|
||||
{"commandId":"%s","resultIds":[%d],"expectedRevisions":{"%d":1}}
|
||||
""".formatted(commandId, RESULT_ID, RESULT_ID);
|
||||
}
|
||||
|
||||
// ==================== 重置 ====================
|
||||
|
||||
private void resetContentTables() {
|
||||
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
|
||||
""");
|
||||
} catch (SQLException exception) {
|
||||
throw new AssertionError("重置 Content 相关表失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 库内事实查询 ====================
|
||||
|
||||
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_PARSE_JOB_COMPLETED_PASSWORD",
|
||||
"P1R_CONTENT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
|
||||
assertTrue(password != null,
|
||||
"缺少必需环境变量: P1R_CONTENT_PARSE_JOB_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(P1rContentParseJobLifecycleCompletedApprovalIT::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 parse-job lifecycle 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({
|
||||
AppContentImportParseController.class,
|
||||
ContentImportParseServiceImpl.class,
|
||||
ContentCommandServiceImpl.class,
|
||||
ContentAuditServiceImpl.class,
|
||||
SpringUtil.class
|
||||
})
|
||||
static class ParseJobLifecycleConfiguration {
|
||||
|
||||
/**
|
||||
* 三个外部 owner facade 均提供全 default(unavailable)兜底,等价生产 `@ConditionalOnMissingBean`
|
||||
* 兜底实现(AI 解析/知识草稿/FileService 未接入),用于复现真实 fail-closed 行为。
|
||||
*/
|
||||
@Bean
|
||||
ContentParseJobFacade contentParseJobFacade() {
|
||||
return new ContentParseJobFacade() {
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
ContentKnowledgeDraftFacade contentKnowledgeDraftFacade() {
|
||||
return new ContentKnowledgeDraftFacade() {
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
ContentFileFacade contentFileFacade() {
|
||||
return new ContentFileFacade() {
|
||||
};
|
||||
}
|
||||
|
||||
@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