diff --git a/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaImpactDimension.java b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaImpactDimension.java new file mode 100644 index 00000000..f94165a6 --- /dev/null +++ b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaImpactDimension.java @@ -0,0 +1,13 @@ +package cn.iocoder.muse.module.meta.api.impact; + +/** + * Meta Schema 用量影响的 5 个消费维度(各 BC owner 实现对应贡献者)。 + */ +public enum MetaImpactDimension { + + PLANNING, + WORK, + KNOWLEDGE_PROJECTION, + AI_CONTEXT, + EXPORT +} diff --git a/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaDimensionImpact.java b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaDimensionImpact.java new file mode 100644 index 00000000..bcd28123 --- /dev/null +++ b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaDimensionImpact.java @@ -0,0 +1,28 @@ +package cn.iocoder.muse.module.meta.api.impact; + +/** + * 贡献者→Meta 的单维度影响结果。 + * + *

无 unknown 态:无法真实作答的贡献者必须抛异常(由聚合器 fail-closed),绝不返回 0 蒙混。 + * realityKind 区分「真实计数」与「已验证真实 0」(后者须机械化可证)。 + */ +public record MetaSchemaDimensionImpact( + MetaImpactDimension dimension, + int affectedInstanceCount, + int fieldRemovedInstanceCount, + int typeChangedInstanceCount, + int visibilityChangedInstanceCount, + RealityKind realityKind) { + + public enum RealityKind { + /** 真实查询计数。 */ + REAL_COUNT, + /** 已验证本域确无 schema 消费(机械化可证),非 unknown。 */ + VERIFIED_ZERO + } + + /** 已验证真实 0:本域确不消费 schema,各计数恒 0。 */ + public static MetaSchemaDimensionImpact verifiedZero(MetaImpactDimension dimension) { + return new MetaSchemaDimensionImpact(dimension, 0, 0, 0, 0, RealityKind.VERIFIED_ZERO); + } +} diff --git a/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaImpactProbe.java b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaImpactProbe.java new file mode 100644 index 00000000..18c0a859 --- /dev/null +++ b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaImpactProbe.java @@ -0,0 +1,17 @@ +package cn.iocoder.muse.module.meta.api.impact; + +import java.util.Set; + +/** + * Meta→各消费域贡献者的探针:携带草稿标识 + G1 算出的三类破坏字段集。 + * + *

破坏字段集由 meta-server G1 统一算好传入,贡献者只在本域按 fieldKey 计数(守 BC 边界门,不回查 meta)。 + */ +public record MetaSchemaImpactProbe( + String schemaKey, + Integer draftVersion, + String draftHash, + Set removedFieldKeys, + Set typeChangedFieldKeys, + Set visibilityChangedFieldKeys) { +} diff --git a/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaUsageContributor.java b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaUsageContributor.java new file mode 100644 index 00000000..9fb5cfe9 --- /dev/null +++ b/muse-cloud/muse-module-meta/muse-module-meta-api/src/main/java/cn/iocoder/muse/module/meta/api/impact/MetaSchemaUsageContributor.java @@ -0,0 +1,16 @@ +package cn.iocoder.muse.module.meta.api.impact; + +/** + * Meta Schema 用量贡献者端口(进程内 @Service Bean,参照 MuseAccountRecordProjectionApi 先例,非 @FeignClient)。 + * + *

由 Meta 定义、各 owner BC 在本域实现:只读本域、按 probe 的破坏字段集计数,返回本维度影响。 + * 无法真实作答→抛异常,由聚合器 fail-closed(绝不返回 0 蒙混)。 + */ +public interface MetaSchemaUsageContributor { + + /** 计算本维度受影响实例(只读本域)。 */ + MetaSchemaDimensionImpact previewDimensionImpact(MetaSchemaImpactProbe probe); + + /** 本贡献者负责的维度(供聚合器校验 5 维度齐全)。 */ + MetaImpactDimension dimension(); +} diff --git a/muse-cloud/muse-module-meta/muse-module-meta-server/src/main/java/cn/iocoder/muse/module/meta/application/facade/RealMetaImpactFacade.java b/muse-cloud/muse-module-meta/muse-module-meta-server/src/main/java/cn/iocoder/muse/module/meta/application/facade/RealMetaImpactFacade.java new file mode 100644 index 00000000..0267c456 --- /dev/null +++ b/muse-cloud/muse-module-meta/muse-module-meta-server/src/main/java/cn/iocoder/muse/module/meta/application/facade/RealMetaImpactFacade.java @@ -0,0 +1,188 @@ +package cn.iocoder.muse.module.meta.application.facade; + +import cn.iocoder.muse.framework.common.exception.ServiceException; +import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.muse.module.meta.api.impact.MetaImpactDimension; +import cn.iocoder.muse.module.meta.api.impact.MetaSchemaDimensionImpact; +import cn.iocoder.muse.module.meta.api.impact.MetaSchemaImpactProbe; +import cn.iocoder.muse.module.meta.api.impact.MetaSchemaUsageContributor; +import cn.iocoder.muse.module.meta.application.MetaSchemaBreakingFieldSet; +import cn.iocoder.muse.module.meta.application.MetaSchemaBreakingFieldSetProducer; +import cn.iocoder.muse.module.meta.controller.admin.vo.AIContextImpactSummaryVO; +import cn.iocoder.muse.module.meta.controller.admin.vo.ExportImpactSummaryVO; +import cn.iocoder.muse.module.meta.controller.admin.vo.KnowledgeProjectionImpactSummaryVO; +import cn.iocoder.muse.module.meta.controller.admin.vo.PlanningImpactSummaryVO; +import cn.iocoder.muse.module.meta.controller.admin.vo.WorkImpactSummaryVO; +import cn.iocoder.muse.module.meta.dal.dataobject.MetaFieldDO; +import cn.iocoder.muse.module.meta.dal.dataobject.MetaSchemaDO; +import cn.iocoder.muse.module.meta.dal.dataobject.MetaSchemaVersionDO; +import cn.iocoder.muse.module.meta.dal.dataobject.MetaVisibilityPolicyDO; +import cn.iocoder.muse.module.meta.dal.mysql.MetaFieldMapper; +import cn.iocoder.muse.module.meta.dal.mysql.MetaSchemaMapper; +import cn.iocoder.muse.module.meta.dal.mysql.MetaSchemaVersionMapper; +import cn.iocoder.muse.module.meta.dal.mysql.MetaVisibilityPolicyMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.muse.module.meta.enums.ErrorCodeConstants.META_EXTERNAL_OWNER_UNAVAILABLE; +import static cn.iocoder.muse.module.meta.enums.ErrorCodeConstants.META_SCHEMA_IDENTITY_CONFLICT; +import static cn.iocoder.muse.module.meta.enums.ErrorCodeConstants.META_SCHEMA_NOT_FOUND; +import static cn.iocoder.muse.module.meta.enums.ErrorCodeConstants.META_SCHEMA_VERSION_NOT_FOUND; + +/** + * Meta 影响预览真实聚合器(P0b)。 + * + *

替换 {@code UnavailableMetaImpactFacade}:破坏判定(Meta 域 G1)与实例计数(各消费域贡献者)干净分离—— + * G1 算出破坏字段集、组 probe,fan-out 到各 BC 贡献者,贡献者只读本域返回本维度计数,聚合器组装既有 VO。 + * + *

all-real 门(绝不部分放行/不伪造/不降级):① 5 维度贡献者须齐全(缺维度/同维度多实现→fail-closed); + * ② 任一贡献者抛异常 / 返回 null / 无 realityKind→整体 fail-closed。当前 P1/P2/P3 贡献者未接入→注入空 List→ + * 缺 5 维度→fail-closed,等价于现状(preview 不放行),待贡献者接入后自然真实化。 + * + *

{@code @Primary @Service}(去 @ConditionalOnMissingBean,该注解在 @Service 上不可靠):退役 Unavailable @Service 后 + * 单体内仅此一个 {@link MetaImpactFacade} Bean。 + */ +@Primary +@Service +public class RealMetaImpactFacade implements MetaImpactFacade { + + /** all-real 门要求齐全的 5 个消费维度。 */ + private static final Set REQUIRED_DIMENSIONS = Set.of(MetaImpactDimension.values()); + + @Resource + private MetaSchemaMapper schemaMapper; + @Resource + private MetaSchemaVersionMapper schemaVersionMapper; + @Resource + private MetaFieldMapper fieldMapper; + @Resource + private MetaVisibilityPolicyMapper visibilityPolicyMapper; + @Resource + private MetaSchemaBreakingFieldSetProducer breakingFieldSetProducer; + /** 各 BC owner 贡献者(进程内 @Service Bean);未接入时 Spring 注入空 List。 */ + @Autowired(required = false) + private List contributors = new ArrayList<>(); + + @Override + public MetaSchemaImpactSummary previewMetaSchemaDraftImpact(String schemaKey, Integer draftVersion, String draftHash) { + // all-real 门 1:5 维度贡献者齐全(缺维度/重复→fail-closed,绝不部分放行)。 + Map byDimension = indexRequiredContributors(); + + // G1 算破坏字段集 → 组 probe(破坏集由 Meta 统一算好传入,贡献者只在本域计数)。 + MetaSchemaBreakingFieldSet breaking = computeBreakingFieldSet(schemaKey, draftVersion); + MetaSchemaImpactProbe probe = new MetaSchemaImpactProbe(schemaKey, draftVersion, draftHash, + breaking.removedFieldKeys(), breaking.typeChangedFieldKeys(), breaking.visibilityChangedFieldKeys()); + + // fan-out 各维度;任一异常/返回 null/无 realityKind → fail-closed(不吞/不降级/不伪造)。 + Map impacts = new EnumMap<>(MetaImpactDimension.class); + for (MetaImpactDimension dimension : MetaImpactDimension.values()) { + MetaSchemaDimensionImpact impact = byDimension.get(dimension).previewDimensionImpact(probe); + if (impact == null || impact.realityKind() == null) { + throw new ServiceException(META_EXTERNAL_OWNER_UNAVAILABLE); + } + impacts.put(dimension, impact); + } + return toSummary(impacts); + } + + /** 索引贡献者并校验恰好 5 维度齐全(维度为空 / 同维度重复 / 缺维度均 fail-closed)。 */ + private Map indexRequiredContributors() { + Map byDimension = new EnumMap<>(MetaImpactDimension.class); + for (MetaSchemaUsageContributor contributor : contributors) { + MetaImpactDimension dimension = contributor.dimension(); + if (dimension == null || byDimension.put(dimension, contributor) != null) { + // 维度为空 or 同维度重复贡献者 → 装配异常,fail-closed(防数据错乱)。 + throw new ServiceException(META_EXTERNAL_OWNER_UNAVAILABLE); + } + } + if (!byDimension.keySet().containsAll(REQUIRED_DIMENSIONS)) { + // 5 维度未齐 → fail-closed(回到现状:preview 不放行)。 + throw new ServiceException(META_EXTERNAL_OWNER_UNAVAILABLE); + } + return byDimension; + } + + /** 查 active/draft 字段与可见性快照,调 G1 产出破坏字段集。 */ + private MetaSchemaBreakingFieldSet computeBreakingFieldSet(String schemaKey, Integer draftVersion) { + MetaSchemaDO schema = requireUniqueSchema(schemaKey); + MetaSchemaVersionDO draft = schemaVersionMapper.selectByVersion(schemaKey, draftVersion); + if (draft == null) { + throw new ServiceException(META_SCHEMA_VERSION_NOT_FOUND); + } + MetaSchemaVersionDO active = schemaVersionMapper.selectActiveBySchemaId(schema.getId()); + List activeFields = active == null ? List.of() : fieldMapper.selectListByVersionId(active.getId()); + List draftFields = fieldMapper.selectListByVersionId(draft.getId()); + String activeSnapshot = active == null ? null : policySnapshot(active.getId()); + String draftSnapshot = policySnapshot(draft.getId()); + return breakingFieldSetProducer.produce(activeFields, draftFields, activeSnapshot, draftSnapshot); + } + + private String policySnapshot(Long versionId) { + MetaVisibilityPolicyDO policy = visibilityPolicyMapper.selectByVersionId(versionId); + return policy == null ? null : policy.getPolicySnapshot(); + } + + private MetaSchemaDO requireUniqueSchema(String schemaKey) { + List schemas = schemaMapper.selectList(new LambdaQueryWrapperX() + .eq(MetaSchemaDO::getSchemaKey, schemaKey)); + if (schemas == null || schemas.isEmpty()) { + throw new ServiceException(META_SCHEMA_NOT_FOUND); + } + if (schemas.size() > 1) { + throw new ServiceException(META_SCHEMA_IDENTITY_CONFLICT); + } + return schemas.getFirst(); + } + + /** DTO→既有 VO 映射(口径与 MetaSchemaImpactPreviewServiceTest 既有断言一致)。 */ + private MetaSchemaImpactSummary toSummary(Map impacts) { + MetaSchemaDimensionImpact work = impacts.get(MetaImpactDimension.WORK); + MetaSchemaDimensionImpact planning = impacts.get(MetaImpactDimension.PLANNING); + MetaSchemaDimensionImpact knowledge = impacts.get(MetaImpactDimension.KNOWLEDGE_PROJECTION); + MetaSchemaDimensionImpact ai = impacts.get(MetaImpactDimension.AI_CONTEXT); + MetaSchemaDimensionImpact export = impacts.get(MetaImpactDimension.EXPORT); + + WorkImpactSummaryVO workVO = new WorkImpactSummaryVO(); + workVO.setAffectedWorkCount(work.affectedInstanceCount()); + workVO.setAffectedDynamicFieldWorkCount(work.affectedInstanceCount()); + workVO.setWorksWithDeprecatedFields(breakingTotal(work)); + + PlanningImpactSummaryVO planningVO = new PlanningImpactSummaryVO(); + planningVO.setAffectedPlanningSections(planning.affectedInstanceCount()); + planningVO.setPlanningUsingDeprecatedFields(breakingTotal(planning)); + + KnowledgeProjectionImpactSummaryVO knowledgeVO = new KnowledgeProjectionImpactSummaryVO(); + knowledgeVO.setAffectedProjections(knowledge.affectedInstanceCount()); + knowledgeVO.setProjectionsNeedingRebuild( + knowledge.fieldRemovedInstanceCount() + knowledge.typeChangedInstanceCount()); + knowledgeVO.setProjectionsWithVisibilityChange(knowledge.visibilityChangedInstanceCount()); + + AIContextImpactSummaryVO aiVO = new AIContextImpactSummaryVO(); + aiVO.setAffectedAIContexts(ai.affectedInstanceCount()); + aiVO.setContextsWithFieldRemoved(ai.fieldRemovedInstanceCount()); + aiVO.setContextsWithVisibilityChange(ai.visibilityChangedInstanceCount()); + + ExportImpactSummaryVO exportVO = new ExportImpactSummaryVO(); + exportVO.setAffectedExportPolicies(export.affectedInstanceCount()); + exportVO.setExportsWithFieldRemoved(export.fieldRemovedInstanceCount()); + exportVO.setExportsWithVisibilityChange(export.visibilityChangedInstanceCount()); + + // totalAffectedProjectionCount 口径 = KnowledgeProjection 受影响投影数(与既有测试断言一致)。 + return new MetaSchemaImpactSummary(workVO, planningVO, knowledgeVO, aiVO, exportVO, + knowledge.affectedInstanceCount()); + } + + /** 单维度三类破坏实例之和(供 worksWithDeprecatedFields / planningUsingDeprecatedFields 口径)。 */ + private int breakingTotal(MetaSchemaDimensionImpact impact) { + return impact.fieldRemovedInstanceCount() + impact.typeChangedInstanceCount() + + impact.visibilityChangedInstanceCount(); + } +} diff --git a/muse-cloud/muse-module-meta/muse-module-meta-server/src/main/java/cn/iocoder/muse/module/meta/application/facade/UnavailableMetaImpactFacade.java b/muse-cloud/muse-module-meta/muse-module-meta-server/src/main/java/cn/iocoder/muse/module/meta/application/facade/UnavailableMetaImpactFacade.java deleted file mode 100644 index bfd22cba..00000000 --- a/muse-cloud/muse-module-meta/muse-module-meta-server/src/main/java/cn/iocoder/muse/module/meta/application/facade/UnavailableMetaImpactFacade.java +++ /dev/null @@ -1,25 +0,0 @@ -package cn.iocoder.muse.module.meta.application.facade; - -import cn.iocoder.muse.framework.common.exception.ServiceException; -import org.springframework.stereotype.Service; - -import static cn.iocoder.muse.module.meta.enums.ErrorCodeConstants.META_EXTERNAL_OWNER_UNAVAILABLE; - -/** - * 默认 Meta 影响 facade。 - * - *

P1R-2 Task 5 不能伪造 Content / Knowledge / AI / Export 的影响数量;在真实 owner facade 接入前, - * 统一返回业务不可用错误,由调用方以 CommonResult code/data/msg 透出。

- */ -// 单体装配下去除 @ConditionalOnMissingBean(@Service 上不可靠,曾致单体启动 MetaImpactFacade 不注册): -// 全仓仅此一个实现,无真实 impl 竞争,直接 @Service 总注册;语义仍 fail-closed(抛 unavailable)不变。 -@Service -public class UnavailableMetaImpactFacade implements MetaImpactFacade { - - @Override - public MetaSchemaImpactSummary previewMetaSchemaDraftImpact(String schemaKey, Integer draftVersion, - String draftHash) { - throw new ServiceException(META_EXTERNAL_OWNER_UNAVAILABLE); - } - -} diff --git a/muse-cloud/muse-module-meta/muse-module-meta-server/src/test/java/cn/iocoder/muse/module/meta/application/facade/RealMetaImpactFacadeTest.java b/muse-cloud/muse-module-meta/muse-module-meta-server/src/test/java/cn/iocoder/muse/module/meta/application/facade/RealMetaImpactFacadeTest.java new file mode 100644 index 00000000..3ac0b941 --- /dev/null +++ b/muse-cloud/muse-module-meta/muse-module-meta-server/src/test/java/cn/iocoder/muse/module/meta/application/facade/RealMetaImpactFacadeTest.java @@ -0,0 +1,171 @@ +package cn.iocoder.muse.module.meta.application.facade; + +import cn.iocoder.muse.framework.common.exception.ServiceException; +import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.muse.module.meta.api.impact.MetaImpactDimension; +import cn.iocoder.muse.module.meta.api.impact.MetaSchemaDimensionImpact; +import cn.iocoder.muse.module.meta.api.impact.MetaSchemaImpactProbe; +import cn.iocoder.muse.module.meta.api.impact.MetaSchemaUsageContributor; +import cn.iocoder.muse.module.meta.application.MetaSchemaBreakingFieldSet; +import cn.iocoder.muse.module.meta.application.MetaSchemaBreakingFieldSetProducer; +import cn.iocoder.muse.module.meta.dal.dataobject.MetaSchemaDO; +import cn.iocoder.muse.module.meta.dal.dataobject.MetaSchemaVersionDO; +import cn.iocoder.muse.module.meta.dal.mysql.MetaFieldMapper; +import cn.iocoder.muse.module.meta.dal.mysql.MetaSchemaMapper; +import cn.iocoder.muse.module.meta.dal.mysql.MetaSchemaVersionMapper; +import cn.iocoder.muse.module.meta.dal.mysql.MetaVisibilityPolicyMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static cn.iocoder.muse.module.meta.enums.ErrorCodeConstants.META_EXTERNAL_OWNER_UNAVAILABLE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * 真实聚合器测试:5 维度齐全正确聚合 + DTO→VO 映射口径;缺维度/重复维度/null realityKind 均 fail-closed。 + */ +class RealMetaImpactFacadeTest extends BaseMockitoUnitTest { + + @InjectMocks + private RealMetaImpactFacade facade; + @Mock + private MetaSchemaMapper schemaMapper; + @Mock + private MetaSchemaVersionMapper schemaVersionMapper; + @Mock + private MetaFieldMapper fieldMapper; + @Mock + private MetaVisibilityPolicyMapper visibilityPolicyMapper; + @Mock + private MetaSchemaBreakingFieldSetProducer breakingFieldSetProducer; + + @Test + void should_aggregate5Dimensions_andMapBreakingCounts_whenAllReal() { + stubSchemaLookup(); + // Planning 真实:7 受影响,破坏 1+0+2;KnowledgeProjection 11 受影响,破坏 3+0+1。 + setContributors(List.of( + stub(MetaImpactDimension.PLANNING, new MetaSchemaDimensionImpact( + MetaImpactDimension.PLANNING, 7, 1, 0, 2, MetaSchemaDimensionImpact.RealityKind.REAL_COUNT)), + stub(MetaImpactDimension.WORK, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.WORK)), + stub(MetaImpactDimension.KNOWLEDGE_PROJECTION, new MetaSchemaDimensionImpact( + MetaImpactDimension.KNOWLEDGE_PROJECTION, 11, 3, 0, 1, MetaSchemaDimensionImpact.RealityKind.REAL_COUNT)), + stub(MetaImpactDimension.AI_CONTEXT, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.AI_CONTEXT)), + stub(MetaImpactDimension.EXPORT, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.EXPORT)))); + + MetaImpactFacade.MetaSchemaImpactSummary summary = + facade.previewMetaSchemaDraftImpact("work_core", 3, "hash"); + + assertEquals(7, summary.planningImpact().getAffectedPlanningSections()); + // planningUsingDeprecatedFields = fieldRemoved + typeChanged + visibilityChanged = 1+0+2。 + assertEquals(3, summary.planningImpact().getPlanningUsingDeprecatedFields()); + assertEquals(11, summary.knowledgeProjectionImpact().getAffectedProjections()); + // projectionsNeedingRebuild = fieldRemoved + typeChanged = 3+0;visibilityChange = 1。 + assertEquals(3, summary.knowledgeProjectionImpact().getProjectionsNeedingRebuild()); + assertEquals(1, summary.knowledgeProjectionImpact().getProjectionsWithVisibilityChange()); + // VERIFIED_ZERO 维度全 0。 + assertEquals(0, summary.workImpact().getAffectedWorkCount()); + assertEquals(0, summary.aiContextImpact().getAffectedAIContexts()); + assertEquals(0, summary.exportImpact().getAffectedExportPolicies()); + // totalAffectedProjectionCount = KnowledgeProjection 受影响投影数。 + assertEquals(11, summary.totalAffectedProjectionCount()); + } + + @Test + void should_failClosed_whenDimensionMissing() { + // 仅 4 维度(缺 EXPORT)→ all-real 门 fail-closed。 + setContributors(List.of( + stub(MetaImpactDimension.PLANNING, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.PLANNING)), + stub(MetaImpactDimension.WORK, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.WORK)), + stub(MetaImpactDimension.KNOWLEDGE_PROJECTION, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.KNOWLEDGE_PROJECTION)), + stub(MetaImpactDimension.AI_CONTEXT, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.AI_CONTEXT)))); + + ServiceException ex = assertThrows(ServiceException.class, + () -> facade.previewMetaSchemaDraftImpact("work_core", 3, "hash")); + assertEquals(META_EXTERNAL_OWNER_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + void should_failClosed_whenNoContributors() { + // 空 List(P1/P2/P3 未接入的当前态)→ fail-closed,等价现状。 + setContributors(List.of()); + + ServiceException ex = assertThrows(ServiceException.class, + () -> facade.previewMetaSchemaDraftImpact("work_core", 3, "hash")); + assertEquals(META_EXTERNAL_OWNER_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + void should_failClosed_whenDuplicateDimension() { + // 同维度两个贡献者 → 装配错乱,fail-closed。 + setContributors(List.of( + stub(MetaImpactDimension.PLANNING, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.PLANNING)), + stub(MetaImpactDimension.PLANNING, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.PLANNING)), + stub(MetaImpactDimension.WORK, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.WORK)), + stub(MetaImpactDimension.KNOWLEDGE_PROJECTION, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.KNOWLEDGE_PROJECTION)), + stub(MetaImpactDimension.AI_CONTEXT, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.AI_CONTEXT)), + stub(MetaImpactDimension.EXPORT, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.EXPORT)))); + + ServiceException ex = assertThrows(ServiceException.class, + () -> facade.previewMetaSchemaDraftImpact("work_core", 3, "hash")); + assertEquals(META_EXTERNAL_OWNER_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + void should_failClosed_whenContributorReturnsNullRealityKind() { + stubSchemaLookup(); + // 某贡献者返回无 realityKind(unknown 蒙混)→ fail-closed,绝不放行。 + setContributors(List.of( + stub(MetaImpactDimension.PLANNING, new MetaSchemaDimensionImpact( + MetaImpactDimension.PLANNING, 1, 0, 0, 0, null)), + stub(MetaImpactDimension.WORK, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.WORK)), + stub(MetaImpactDimension.KNOWLEDGE_PROJECTION, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.KNOWLEDGE_PROJECTION)), + stub(MetaImpactDimension.AI_CONTEXT, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.AI_CONTEXT)), + stub(MetaImpactDimension.EXPORT, MetaSchemaDimensionImpact.verifiedZero(MetaImpactDimension.EXPORT)))); + + ServiceException ex = assertThrows(ServiceException.class, + () -> facade.previewMetaSchemaDraftImpact("work_core", 3, "hash")); + assertEquals(META_EXTERNAL_OWNER_UNAVAILABLE.getCode(), ex.getCode()); + } + + private void stubSchemaLookup() { + MetaSchemaDO schema = new MetaSchemaDO(); + schema.setId(1L); + schema.setSchemaKey("work_core"); + MetaSchemaVersionDO draft = new MetaSchemaVersionDO(); + draft.setId(12L); + lenient().when(schemaMapper.selectList(any())).thenReturn(List.of(schema)); + lenient().when(schemaVersionMapper.selectByVersion("work_core", 3)).thenReturn(draft); + lenient().when(schemaVersionMapper.selectActiveBySchemaId(1L)).thenReturn(null); + lenient().when(fieldMapper.selectListByVersionId(anyLong())).thenReturn(List.of()); + lenient().when(visibilityPolicyMapper.selectByVersionId(anyLong())).thenReturn(null); + lenient().when(breakingFieldSetProducer.produce(any(), any(), any(), any())) + .thenReturn(new MetaSchemaBreakingFieldSet(Set.of(), Set.of(), Set.of())); + } + + private void setContributors(List contributors) { + ReflectionTestUtils.setField(facade, "contributors", contributors); + } + + private static MetaSchemaUsageContributor stub(MetaImpactDimension dimension, MetaSchemaDimensionImpact impact) { + return new MetaSchemaUsageContributor() { + @Override + public MetaSchemaDimensionImpact previewDimensionImpact(MetaSchemaImpactProbe probe) { + return impact; + } + + @Override + public MetaImpactDimension dimension() { + return dimension; + } + }; + } +}