From 517abbdb2ab22572ddab9e523d9dd2ab21e3ef32 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 02:46:56 -0700 Subject: [PATCH] =?UTF-8?q?test(p1r):=20=E9=AA=8C=E6=94=B6=E5=80=BA?= =?UTF-8?q?=E6=89=B923=20=E7=9C=9F=E5=AE=9E=20PG=20IT(content=20chapter-pa?= =?UTF-8?q?rse-result=20=E5=86=B3=E7=AD=96=202=20op=20fail-closed)=20+=20?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6/.agent=20=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1rContentChapterParseResultDecisionCompletedApprovalIT(真实 PG muse_p1r_content_import_parse_test,3/3 绿,独立复跑两次均绿)覆盖 confirmChapterParseResult/rejectChapterParseResult,经真实 /app-api/muse/chapter-parse-results/{resultId}/confirm|reject 入口。 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 写。 content needs_verification 验收债 16→14(余 14 全为 FileService/New-API/SSE 外部 owner fail-closed:导入/导出/管理端任务/解析任务/合并建议)。台账 completed flip + 覆盖门禁 APPROVED_COMPLETED_OPERATIONS 仍须人工批准,agent 不自批。 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mvp/进度总账.md | 3 +- muse-cloud/muse-module-content/.agent | 2 +- ...arseResultDecisionCompletedApprovalIT.java | 684 ++++++++++++++++++ 3 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentChapterParseResultDecisionCompletedApprovalIT.java diff --git a/docs/mvp/进度总账.md b/docs/mvp/进度总账.md index 2a1a8dac..29014bc7 100644 --- a/docs/mvp/进度总账.md +++ b/docs/mvp/进度总账.md @@ -138,7 +138,8 @@ - **批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 待人工批**。 - **批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 待人工批**。 - - 剩余:content **16**(批20-22 补 planning candidate 5 + meta-projection 3 + style-check 2 = 10 op,均为外部 owner(AI/Meta)未接入 fail-closed;余 16 = parse-result confirm/reject 2 + 14 FileService/New-API/SSE 外部 owner(导入/导出/解析任务/合并建议/下载)→ 多数只能验"失败关闭"真实证据)+ 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 16。 + - **批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。 - ✅ **P1r*IT 真实 PG 基线(2026-06-15,mini-infra PG)= 23/23 IT 类全绿**(99 用例 0F/0E;2 例 external-acceptance 因未设 `MUSE_P1R_EXTERNAL_ACCEPTANCE` 跳过)。`P1rKnowledgeFlywayMigrationIT` 版本断言由硬编码(V14→V21→V23 复发两次)改为 `MigrateResult` 动态自适应(commit 4d46d7a,Codex+Opus 双代理合并)。**完整跑法见 [.agents/knowledge §四](../../.agents/knowledge/external-deps-and-gotchas.md)**:`_test` 后缀隔离库 + `source infra.env`(密码仅 env)+ argLine(SOCKS 清 + p1r.flyway.url/user/locations)+ `-DreuseForks=false`(批量必加,避 Market IT 属性脱敏污染复用 fork)。 - 🔧 ai/平台预存红测试整改(CI 接电将暴露):`MuseAiTaskServiceTest` 桩缺失 11 例 NPE **✅ 已整改(补 eventPublishOutboxService/candidateReviewService 桩 + review lenient,40/40,2026-06-15)**;`QiniuSmsClientTest` 时区硬编码 **✅ 已整改(5/5,机器无关)**;`MuseAiEventPublishOutboxMapperTest` **✅ 真实 PG 实跑 5/5(mini-infra PG,harness=infra.env+argLine SOCKS 清;隔离 schema 自建自清)**。**ai/平台预存红三项全清。** diff --git a/muse-cloud/muse-module-content/.agent b/muse-cloud/muse-module-content/.agent index 3fcdf101..ceb5c3ad 100644 --- a/muse-cloud/muse-module-content/.agent +++ b/muse-cloud/muse-module-content/.agent @@ -5,5 +5,5 @@ - **目标(owner 职责)**:Work / Chapter / Block / 正文版本 / Block Source Attribution / Import-Export / Planning Canonical / Narrative State。 - **边界(不可违反)**:**正文 Canonical 唯一写入方**;Block 写入须带 expectedRevision 且 revision 单调递增;**Accept Suggestion 是候选进正文的唯一合法入口**——只写正文 + 候选归档 + 来源归因,**不写 Local KB**;守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。 - **out-of-scope**:知识入库(归 knowledge BC)、AI 生成(归 ai BC)。 -- **现状**:只读评估 82%;后端齐全,契约高度一致。**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 绿(独立复跑)。content needs_verification 验收债余 16 op(parse-result confirm/reject 2 + 14 FileService/New-API/SSE 外部 owner:导入/导出/解析任务/合并建议/下载),证据就绪待人工批。 +- **现状**:只读评估 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 已清零。** - **关键风险 / TODO**:**前端 studio 未接 `suggestion-merges`**,AI 候选进正文在用户端断链不可用(objective“先审后入”落点)。 diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentChapterParseResultDecisionCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentChapterParseResultDecisionCompletedApprovalIT.java new file mode 100644 index 00000000..e0efbc55 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentChapterParseResultDecisionCompletedApprovalIT.java @@ -0,0 +1,684 @@ +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P1R Content chapter-parse-result 决策 completed approval:解析结果确认/拒绝在 AI 解析 owner 未接入时的 + * fail-closed HTTP + 真实 PostgreSQL 证据(content 验收债推进)。 + * + *

AI 解析任务、章节结果投影、知识草稿生成均归 AI owner;单体内 {@code ContentParseJobFacade}/ + * {@code ContentKnowledgeDraftFacade}/{@code ContentFileFacade} 仅有 fail-closed 默认实现(全 default + * 方法返回 unavailable,`@ConditionalOnMissingBean` 兜底)。本测试只连接显式传入的 PostgreSQL {@code _test} + * 隔离库,经真实 {@code /app-api/muse/chapter-parse-results/{resultId}/confirm|reject} 入口进入 Controller。 + * 断言:AI owner 不可用时 confirmChapterParseResult/rejectChapterParseResult 两 op 全部 fail-closed→ + * {@code CONTENT_EXTERNAL_OWNER_UNAVAILABLE}(章节结果投影在 `requireChapterResultProjection` 处即不可用, + * 故绝不生成知识草稿、绝不记录确认/拒绝决策);两 op 均为写路,已预占命令随 {@code @Transactional} 回滚 0 残留, + * 全程 0 写;非法 API version→{@code CONTENT_API_VERSION_UNSUPPORTED} 守卫先于服务体且 0 写。

+ */ +@SpringBootTest( + classes = P1rContentChapterParseResultDecisionCompletedApprovalIT.ChapterParseResultDecisionConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rContentChapterParseResultDecisionCompletedApprovalIT { + + 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 RESULT_ID = 70_001L; + private static final String API_VERSION = "1"; + private static final Set CREDENTIAL_QUERY_KEYS = Set.of( + "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", + "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"); + + private static volatile CompletedApprovalSettings cachedSettings; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @Autowired + private DataSource dataSource; + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + + @DynamicPropertySource + static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) { + CompletedApprovalSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-content-chapter-parse-result-decision-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 chapter-parse-result decision 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.import-parse.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rContentChapterParseResultDecisionCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.content.import-parse.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.content.import-parse.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_failClosedOnChapterParseResultDecisionsWhenAiOwnerUnavailable() throws Exception { + // confirm:章节结果投影/知识草稿生成归 AI owner,不可用时不能伪造草稿或确认决策;已预占命令随事务回滚。 + assertNoContentMutationDuring("confirmChapterParseResult 在 AI owner 不可用时必须 fail-closed", + () -> mockMvc.perform(post("/app-api/muse/chapter-parse-results/{resultId}/confirm", RESULT_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())); + + // reject:章节结果投影归 AI owner,不可用时不能伪造拒绝决策;已预占命令随事务回滚。 + assertNoContentMutationDuring("rejectChapterParseResult 在 AI owner 不可用时必须 fail-closed", + () -> mockMvc.perform(post("/app-api/muse/chapter-parse-results/{resultId}/reject", RESULT_ID) + .header("X-API-Version", API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(rejectBody("cmd-reject-1", "解析质量不佳", 1))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // 两 op 全程未落任何 Content fact:写路预占命令已回滚。 + assertEquals(0, countRows("muse_content_command_log", "1 = 1"), + "parse-result 决策写 op 预占命令必须随事务回滚,0 残留"); + assertEquals(0, countRows("muse_content_planning_section", "1 = 1"), + "parse-result 决策 fail-closed 不得写 planning section"); + assertEquals(0, countRows("muse_content_event_publish_outbox", "1 = 1"), + "parse-result 决策 fail-closed 不得写事件 outbox"); + } + + @Test + void should_enforceVersionGuardBeforeReachingAiOwner() throws Exception { + // 非法 API version → 版本守卫先于服务体拒绝,0 写。 + assertNoContentMutationDuring("confirmChapterParseResult 非法 API version 必须拒绝", + () -> mockMvc.perform(post("/app-api/muse/chapter-parse-results/{resultId}/confirm", RESULT_ID) + .header("X-API-Version", "0") + .contentType(MediaType.APPLICATION_JSON) + .content(confirmBody("cmd-bad-version", 1))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(CONTENT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + assertNoContentMutationDuring("rejectChapterParseResult 非法 API version 必须拒绝", + () -> mockMvc.perform(post("/app-api/muse/chapter-parse-results/{resultId}/reject", RESULT_ID) + .header("X-API-Version", "0") + .contentType(MediaType.APPLICATION_JSON) + .content(rejectBody("cmd-bad-version-2", "拒绝", 1))) + .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 confirmBody(String commandId, int expectedRevision) { + return """ + {"commandId":"%s","expectedRevision":%d} + """.formatted(commandId, expectedRevision); + } + + private String rejectBody(String commandId, String reason, int expectedRevision) { + return """ + {"commandId":"%s","reason":"%s","expectedRevision":%d} + """.formatted(commandId, reason, expectedRevision); + } + + // ==================== 重置 ==================== + + 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 tableSnapshot(String tableName, String orderBy) { + String sql = """ + SELECT row_to_json(t)::text + FROM %s t + ORDER BY %s + """.formatted(tableName, orderBy); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + List rows = new ArrayList<>(); + while (resultSet.next()) { + rows.add(resultSet.getString(1)); + } + return rows; + } catch (SQLException exception) { + throw new AssertionError("读取 " + tableName + " 快照失败", exception); + } + } + + // ==================== 连接配置(密码仅 env、URL 脱敏、_test 守卫) ==================== + + private static CompletedApprovalSettings settings() { + if (cachedSettings == null) { + cachedSettings = CompletedApprovalSettings.fromPropertiesAndEnvironment(); + } + return cachedSettings; + } + + private static void cleanSchema(Flyway flyway, CompletedApprovalSettings settings) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, CompletedApprovalSettings settings) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception); + } + } + + private static void ensureTestDatabaseExists(CompletedApprovalSettings settings) { + String databaseName = jdbcDatabaseName(settings.jdbcUrl()); + try (Connection connection = DriverManager.getConnection( + maintenanceJdbcUrl(settings.jdbcUrl()), settings.jdbcUser(), settings.jdbcPassword()); + Statement statement = connection.createStatement()) { + connection.setAutoCommit(true); + statement.execute("CREATE DATABASE " + quotedIdentifier(databaseName)); + } catch (SQLException exception) { + if ("42P04".equals(exception.getSQLState())) { + return; + } + throw sanitizedSqlFailure("创建 PostgreSQL _test 数据库失败", settings, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, CompletedApprovalSettings settings, + RuntimeException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace("for user '" + settings.jdbcUser() + "'", "for user ''"); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static AssertionError sanitizedSqlFailure(String action, CompletedApprovalSettings settings, + SQLException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace(settings.jdbcUser(), ""); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name); + return value; + } + + private static String requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_CONTENT_IMPORT_PARSE_COMPLETED_PASSWORD", + "P1R_CONTENT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_CONTENT_IMPORT_PARSE_COMPLETED_PASSWORD、P1R_CONTENT_COMPLETED_PASSWORD、" + + "P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); + return password; + } + + private static String firstNonBlankEnvironment(String... names) { + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static void assertNoPasswordSystemProperties() { + Properties properties = System.getProperties(); + List passwordProperties = properties.stringPropertyNames().stream() + .filter(P1rContentChapterParseResultDecisionCompletedApprovalIT::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 chapter-parse-result decision completed approval IT 要求显式使用 filesystem:sql/muse"); + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + String relativeLocation = requestedLocations.substring("filesystem:".length()); + for (Path cursor = current; cursor != null; cursor = cursor.getParent()) { + Path candidate = cursor.resolve(relativeLocation); + if (Files.isDirectory(candidate)) { + return "filesystem:" + candidate; + } + } + throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current); + } + + private static String jdbcDatabaseName(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + return urlWithoutQuery.substring(databaseStart + 1); + } + + private static String jdbcUrlWithoutQuery(String url) { + int queryStart = url.indexOf('?'); + return queryStart < 0 ? url : url.substring(0, queryStart); + } + + private static String maintenanceJdbcUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + String querySuffix = url.indexOf('?') < 0 ? "" : url.substring(url.indexOf('?')); + return urlWithoutQuery.substring(0, databaseStart + 1) + "postgres" + querySuffix; + } + + private static String quotedIdentifier(String identifier) { + assertTrue(identifier.matches("[A-Za-z0-9_]+"), + "测试数据库名只能包含字母、数字和下划线: " + identifier); + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static String maskedUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + if (databaseStart < 0) { + return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url); + } + String prefix = urlWithoutQuery.substring(0, databaseStart + 1); + String database = urlWithoutQuery.substring(databaseStart + 1); + return maskJdbcHost(prefix) + database + maskedQuerySuffix(url); + } + + private static String maskedQuerySuffix(String url) { + return url.indexOf('?') < 0 ? "" : "?"; + } + + private static String maskJdbcHost(String urlPart) { + return urlPart.replaceAll("//([^:/?#]+)", "//"); + } + + private static void redactFlywaySystemProperties(String url, String user) { + captureOriginalFlywaySystemProperties(); + System.setProperty("p1r.flyway.url", maskedUrl(url)); + System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : ""); + } + + private static void captureOriginalFlywaySystemProperties() { + if (originalFlywayPropertiesCaptured) { + return; + } + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty); + restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty); + } + + private static void restoreSystemProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + return; + } + System.setProperty(name, value); + } + + private static void silenceFlywayInfoLogs() { + try { + Object flywayLogger = LoggerFactory.getLogger("org.flywaydb"); + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object warnLevel = levelClass.getField("WARN").get(null); + flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel); + } catch (ReflectiveOperationException | LinkageError ignored) { + // 日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。 + } + } + + @FunctionalInterface + private interface CheckedOperation { + + void run() throws Exception; + } + + private record ContentFactSnapshot(List works, + List planningSections, + List commands, + List outbox) { + } + + private record CompletedApprovalSettings(String jdbcUrl, + String jdbcUser, + String jdbcPassword, + String flywayLocations) { + + static CompletedApprovalSettings fromPropertiesAndEnvironment() { + assertNoPasswordSystemProperties(); + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String locations = requiredProperty("p1r.flyway.locations"); + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + return new CompletedApprovalSettings(url, user, password, locations); + } + } + + @SpringBootConfiguration + @ImportAutoConfiguration({ + JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + TransactionAutoConfiguration.class, + RestTemplateAutoConfiguration.class, + WebMvcAutoConfiguration.class, + MuseDataSourceAutoConfiguration.class, + MuseMybatisAutoConfiguration.class, + MybatisPlusAutoConfiguration.class, + MybatisPlusJoinAutoConfiguration.class, + MuseWebAutoConfiguration.class + }) + @Import({ + AppContentImportParseController.class, + ContentImportParseServiceImpl.class, + ContentCommandServiceImpl.class, + ContentAuditServiceImpl.class, + SpringUtil.class + }) + static class ChapterParseResultDecisionConfiguration { + + /** + * 三个外部 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 createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) { + return CommonResult.success(true); + } + }; + } + } +}