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:
lili 2026-06-17 02:53:20 -07:00
parent 517abbdb2a
commit c8990fd2d2
3 changed files with 705 additions and 2 deletions

View File

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

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%;后端齐全,契约高度一致。**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“先审后入”落点)。

View File

@ -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);
}
};
}
}
}