diff --git a/docs/agent-specs/2026-06-20-meta-schema用量投影-execution.md b/docs/agent-specs/2026-06-20-meta-schema用量投影-execution.md
index 38a3147d..6d9d377a 100644
--- a/docs/agent-specs/2026-06-20-meta-schema用量投影-execution.md
+++ b/docs/agent-specs/2026-06-20-meta-schema用量投影-execution.md
@@ -7,6 +7,9 @@
> - **D1 schema_key 来源 = 激活 work 绑定**:`WorkDO.workSchemaId`(现死代码 Long)绑 `MetaSchemaDO.id`,schema_key 经 join `MetaSchemaDO.schemaKey` 得;配 planning `schemaVersion`;历史 `workSchemaId IS NULL` 行按 no-source 显式排除(不伪造 0)。(覆盖原 §四.4 A「写入捕获」推荐)
> - **D2 字段级 = 补每实例字段快照表**(上游原定最严谨方案,非「验证 contentPayload」)。
> - **D3 解阻范围 = 全 4 端点**(publish/activate/rollback/deprecate),须扩**非 draft preview 生成路径**(覆盖原「仅 publish」非目标)。
+> 决策落地进展(agent 2026-06-21):**D1 已完成**(work 绑 schemaId + studio 前端选 schema + e2e 真跑绿;附带修复 useWorkCreate 漏传 commandId 既有缺陷)。
+> 第三轮调查(agent 2026-06-21,双 Explore+三次代码确认,反假绿):**字段级当前不可达已坐实**——Muse 无任何实例表按 schema fieldKey 持久化值(Planning contentPayload 自由 map key≠fieldKey、Work 仅绑 schemaId、validateDynamicFields 只转调 meta 不落库)。5 维定性收敛:**Knowledge/AIContext/Export 均 VERIFIED_ZERO**(零 meta 实例消费;Export 的 `ExportTaskDO` 零 schema 引用、`ContentMetaProjectionService` 纯转调——原「Export 须 meta-server 计数」在 export 任务无消费下不适用);**Planning 字段级须先补数据模型**。
+> 决策(人类 2026-06-21 第三轮):**坚持 M 全量字段级[B]**——补 planning 按 fieldKey 持久化。落地链(前后端交织,否则字段级 all-real 仅空数据): ①后端地基(每实例字段快照表 Flyway+DO+Mapper、meta-api 列字段端口) ②planning 保存写入捕获 usedFieldKeys ③`ContentPlanningUsageContributor` 字段级 REAL_COUNT ④studio planning 编辑器按 schema 字段填值传 usedFieldKeys ⑤Work/Knowledge/AIContext/Export VERIFIED_ZERO+机械化断言 ⑥D3 解阻全4端点+全维 IT。
> 边界门:[bc-boundaries](../../.agents/rules/bc-boundaries.md)——Meta 不直连他域 `.dal`/`.application`;`BcBoundaryArchTest` 须保持 0 违例。
> ## ⚠️ v0.2 评审修订摘要(2026-06-20,5-persona + 自验 grep)
diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceImpl.java b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceImpl.java
index 6f0accb5..60980e12 100644
--- a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceImpl.java
+++ b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceImpl.java
@@ -6,8 +6,10 @@ import cn.iocoder.muse.module.content.application.facade.ContentPlanningCandidat
import cn.iocoder.muse.module.content.application.facade.ContentStyleCheckFacade;
import cn.iocoder.muse.module.content.controller.app.vo.*;
import cn.iocoder.muse.module.content.dal.dataobject.ContentCommandDO;
+import cn.iocoder.muse.module.content.dal.dataobject.PlanningFieldSnapshotDO;
import cn.iocoder.muse.module.content.dal.dataobject.PlanningSectionDO;
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
+import cn.iocoder.muse.module.content.dal.mysql.PlanningFieldSnapshotMapper;
import cn.iocoder.muse.module.content.dal.mysql.PlanningSectionMapper;
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
import cn.iocoder.muse.module.content.domain.ContentRevisionGuard;
@@ -47,6 +49,8 @@ public class ContentPlanningServiceImpl implements ContentPlanningService {
@Resource
private PlanningSectionMapper planningSectionMapper;
@Resource
+ private PlanningFieldSnapshotMapper planningFieldSnapshotMapper;
+ @Resource
private ContentCommandService commandService;
@Resource
private ContentAuditService auditService;
@@ -266,6 +270,8 @@ public class ContentPlanningServiceImpl implements ContentPlanningService {
throw new ServiceException(CONTENT_REVISION_CONFLICT.getCode(),
"Planning section " + sectionKey + " 已被创建,请刷新后重试");
}
+ // 写入字段用量快照(供 Meta Schema 用量投影 Planning 维度字段级真实计数)。
+ captureFieldSnapshot(workId, section.getId(), reqVO.getUsedFieldKeys());
return saveResult(sectionKey, 1, now);
}
@@ -285,9 +291,40 @@ public class ContentPlanningServiceImpl implements ContentPlanningService {
existing.setSourceSnapshot(toSourceSnapshotJson(sourceSnapshot));
LocalDateTime now = LocalDateTime.now();
existing.setUpdateTime(now);
+ // 写入字段用量快照(先删旧后写新,重复保存幂等)。
+ captureFieldSnapshot(workId, existing.getId(), reqVO.getUsedFieldKeys());
return saveResult(sectionKey, nextRevision, now);
}
+ /**
+ * 写入 planning section 的字段用量快照(供 Meta Schema 用量投影 Planning 维度字段级真实计数)。
+ *
+ *
取该 section 所属 work 绑定的 schema:未绑定(workSchemaId null)则只清旧快照不写新——
+ * 该 section 属 no-source cohort,自然不计入任何 schema 的字段级用量(不伪造 0)。
+ * 先按 sectionId 删旧、再按 usedFieldKeys 去重写新,保证重复保存幂等。
+ */
+ private void captureFieldSnapshot(Long workId, Long sectionId, List usedFieldKeys) {
+ // 先清旧快照,保证「本次声明」即「当前事实」。
+ planningFieldSnapshotMapper.deleteBySectionId(sectionId);
+ WorkDO work = workMapper.selectById(workId);
+ Long schemaId = work == null ? null : work.getWorkSchemaId();
+ if (schemaId == null || usedFieldKeys == null || usedFieldKeys.isEmpty()) {
+ return; // 无 schema 绑定或本次未声明字段用量→只清不写。
+ }
+ LocalDateTime now = LocalDateTime.now();
+ usedFieldKeys.stream()
+ .filter(fk -> fk != null && !fk.isBlank())
+ .distinct()
+ .forEach(fieldKey -> {
+ PlanningFieldSnapshotDO snapshot = PlanningFieldSnapshotDO.builder()
+ .sectionId(sectionId).workId(workId).schemaId(schemaId).fieldKey(fieldKey)
+ .build();
+ snapshot.setCreateTime(now);
+ snapshot.setUpdateTime(now);
+ planningFieldSnapshotMapper.insert(snapshot);
+ });
+ }
+
private PlanningItemSaveResultRespVO upsertPlanningSection(Long workId, String sectionKey,
ConfirmPlanningCandidateReqVO reqVO,
PlanningSectionDO existing,
diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributor.java b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributor.java
new file mode 100644
index 00000000..4449be5d
--- /dev/null
+++ b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributor.java
@@ -0,0 +1,75 @@
+package cn.iocoder.muse.module.content.application.impact;
+
+import cn.iocoder.muse.module.content.dal.dataobject.PlanningFieldSnapshotDO;
+import cn.iocoder.muse.module.content.dal.mysql.PlanningFieldSnapshotMapper;
+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 jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Planning 维度 Meta Schema 用量贡献者(content 域,实现 meta-api 端口,只读本域字段用量快照)。
+ *
+ * planning 的 content_payload 是自由 map(key≠fieldKey),字段级用量由
+ * {@code muse_content_planning_field_snapshot} 显式承载(保存 planning 时按 usedFieldKeys 写入)。
+ * 本贡献者据快照真实计数:
+ *
+ * - affectedInstanceCount = 绑该 schema 的 distinct section 数(实例级关联范围);
+ * - 各破坏维度 = 用到对应破坏字段集的 distinct section 数(字段级命中)。
+ *
+ * realityKind 恒 REAL_COUNT——Planning 确为消费者,即便某 schema 无关联快照(真实查得 0),
+ * 也是「真实计数 0」而非「本域不消费(VERIFIED_ZERO)」。
+ */
+@Service
+public class ContentPlanningUsageContributor implements MetaSchemaUsageContributor {
+
+ @Resource
+ private PlanningFieldSnapshotMapper planningFieldSnapshotMapper;
+
+ @Override
+ public MetaImpactDimension dimension() {
+ return MetaImpactDimension.PLANNING;
+ }
+
+ @Override
+ public MetaSchemaDimensionImpact previewDimensionImpact(MetaSchemaImpactProbe probe) {
+ Long schemaId = probe.schemaId();
+ // schema 数字主键缺失→无任何 section 能绑定→真实 0(仍 REAL_COUNT:本域是消费者,只是此 schema 无关联)。
+ if (schemaId == null) {
+ return realCount(0, 0, 0, 0);
+ }
+ List snapshots = planningFieldSnapshotMapper.selectListBySchemaId(schemaId);
+ int affected = countDistinctSection(snapshots, null);
+ int fieldRemoved = countDistinctSection(snapshots, probe.removedFieldKeys());
+ int typeChanged = countDistinctSection(snapshots, probe.typeChangedFieldKeys());
+ int visibilityChanged = countDistinctSection(snapshots, probe.visibilityChangedFieldKeys());
+ return realCount(affected, fieldRemoved, typeChanged, visibilityChanged);
+ }
+
+ private MetaSchemaDimensionImpact realCount(int affected, int fieldRemoved, int typeChanged, int visibilityChanged) {
+ return new MetaSchemaDimensionImpact(MetaImpactDimension.PLANNING,
+ affected, fieldRemoved, typeChanged, visibilityChanged,
+ MetaSchemaDimensionImpact.RealityKind.REAL_COUNT);
+ }
+
+ /**
+ * 统计 distinct section 数:
+ * fieldKeys 为 null → 全量(实例级关联范围);非空 → 仅用到这些 fieldKey 的 section(字段级命中);
+ * 空集 → 0(无破坏字段则无字段级命中,不退化为全量)。
+ */
+ private int countDistinctSection(List snapshots, Set fieldKeys) {
+ if (fieldKeys != null && fieldKeys.isEmpty()) {
+ return 0;
+ }
+ return (int) snapshots.stream()
+ .filter(s -> fieldKeys == null || fieldKeys.contains(s.getFieldKey()))
+ .map(PlanningFieldSnapshotDO::getSectionId)
+ .distinct()
+ .count();
+ }
+}
diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/vo/SavePlanningItemReqVO.java b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/vo/SavePlanningItemReqVO.java
index f5b7bc19..c9ae3e48 100644
--- a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/vo/SavePlanningItemReqVO.java
+++ b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/vo/SavePlanningItemReqVO.java
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
+import java.util.List;
import java.util.Map;
/**
@@ -40,4 +41,7 @@ public class SavePlanningItemReqVO {
@Schema(description = "保存原因")
private String auditReason;
+ @Schema(description = "本次保存用到的 Meta Schema 字段 key 列表(用于字段级用量快照;未按 schema 字段编辑时可空)")
+ private List usedFieldKeys;
+
}
diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/dataobject/PlanningFieldSnapshotDO.java b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/dataobject/PlanningFieldSnapshotDO.java
new file mode 100644
index 00000000..e955933a
--- /dev/null
+++ b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/dataobject/PlanningFieldSnapshotDO.java
@@ -0,0 +1,36 @@
+package cn.iocoder.muse.module.content.dal.dataobject;
+
+import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+/**
+ * Planning section 字段用量快照 DO。
+ *
+ * 记录某 planning section 用到了所属 work 绑定 schema 的哪个 fieldKey(每 fieldKey 一行)。
+ * planning 的 content_payload 是自由 map(key≠schema fieldKey),无法据其字段级计数,
+ * 故字段级用量须由本表显式承载,供 Meta Schema 用量投影 Planning 维度字段级真实计数。
+ */
+@TableName(value = "muse_content_planning_field_snapshot")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PlanningFieldSnapshotDO extends TenantBaseDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ /** 所属 planning section id */
+ private Long sectionId;
+ /** 冗余:所属 work id(便于按 work 归集与 no-source 排除) */
+ private Long workId;
+ /** 该 section 所属 work 绑定的 MetaSchema id(写入时取自 work.workSchemaId) */
+ private Long schemaId;
+ /** 用到的 schema 字段 key */
+ private String fieldKey;
+
+}
diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/mysql/PlanningFieldSnapshotMapper.java b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/mysql/PlanningFieldSnapshotMapper.java
new file mode 100644
index 00000000..bc62b704
--- /dev/null
+++ b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/mysql/PlanningFieldSnapshotMapper.java
@@ -0,0 +1,38 @@
+package cn.iocoder.muse.module.content.dal.mysql;
+
+import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.muse.module.content.dal.dataobject.PlanningFieldSnapshotDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * Planning section 字段用量快照 Mapper。
+ *
+ * 查询统一走 {@link LambdaQueryWrapperX}(由框架 tenant 插件 + 逻辑删除自动补 tenant_id / deleted),
+ * 不用原生 {@code @Select},避免聚合 SQL 的多租户注入不确定性。distinct section 与字段命中计数由
+ * 贡献者在内存完成(preview 为低频治理操作,单 schema 关联快照行有限,可接受)。
+ */
+@Mapper
+public interface PlanningFieldSnapshotMapper extends BaseMapperX {
+
+ /** 删除某 section 的旧快照(保存 planning 时先删后写,保证重复保存幂等)。 */
+ default int deleteBySectionId(Long sectionId) {
+ return delete(new LambdaQueryWrapperX()
+ .eq(PlanningFieldSnapshotDO::getSectionId, sectionId));
+ }
+
+ /** 查某 section 现有快照(测试 / 校验用)。 */
+ default List selectListBySectionId(Long sectionId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(PlanningFieldSnapshotDO::getSectionId, sectionId));
+ }
+
+ /** 查某 schema 关联的全部字段快照,供 Planning 贡献者内存做 distinct section 字段级计数。 */
+ default List selectListBySchemaId(Long schemaId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(PlanningFieldSnapshotDO::getSchemaId, schemaId));
+ }
+
+}
diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceTest.java b/muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceTest.java
index d5c884a9..b97c6522 100644
--- a/muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceTest.java
+++ b/muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/ContentPlanningServiceTest.java
@@ -11,6 +11,7 @@ import cn.iocoder.muse.module.content.dal.dataobject.ContentCommandDO;
import cn.iocoder.muse.module.content.dal.dataobject.PlanningSectionDO;
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
import cn.iocoder.muse.module.content.dal.mysql.ContentCommandMapper;
+import cn.iocoder.muse.module.content.dal.mysql.PlanningFieldSnapshotMapper;
import cn.iocoder.muse.module.content.dal.mysql.PlanningSectionMapper;
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
import com.fasterxml.jackson.databind.JsonNode;
@@ -52,6 +53,8 @@ class ContentPlanningServiceTest extends BaseMockitoUnitTest {
@Mock
private PlanningSectionMapper planningSectionMapper;
@Mock
+ private PlanningFieldSnapshotMapper planningFieldSnapshotMapper;
+ @Mock
private ContentCommandService commandService;
@Mock
private ContentAuditService auditService;
@@ -369,6 +372,7 @@ class ContentPlanningServiceTest extends BaseMockitoUnitTest {
ContentPlanningServiceImpl realService = new ContentPlanningServiceImpl();
ReflectionTestUtils.setField(realService, "workMapper", workMapper);
ReflectionTestUtils.setField(realService, "planningSectionMapper", planningSectionMapper);
+ ReflectionTestUtils.setField(realService, "planningFieldSnapshotMapper", planningFieldSnapshotMapper);
ReflectionTestUtils.setField(realService, "commandService", realCommandService);
ReflectionTestUtils.setField(realService, "auditService", realAuditService);
ReflectionTestUtils.setField(realService, "candidateFacade", candidateFacade);
diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributorTest.java b/muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributorTest.java
new file mode 100644
index 00000000..d6261298
--- /dev/null
+++ b/muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributorTest.java
@@ -0,0 +1,89 @@
+package cn.iocoder.muse.module.content.application.impact;
+
+import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
+import cn.iocoder.muse.module.content.dal.dataobject.PlanningFieldSnapshotDO;
+import cn.iocoder.muse.module.content.dal.mysql.PlanningFieldSnapshotMapper;
+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 org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Planning 维度用量贡献者单元测试:验证据字段快照的实例级 + 字段级真实计数。
+ */
+class ContentPlanningUsageContributorTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private ContentPlanningUsageContributor contributor;
+ @Mock
+ private PlanningFieldSnapshotMapper planningFieldSnapshotMapper;
+
+ private PlanningFieldSnapshotDO snapshot(Long sectionId, String fieldKey) {
+ return PlanningFieldSnapshotDO.builder()
+ .sectionId(sectionId).workId(1L).schemaId(100L).fieldKey(fieldKey).build();
+ }
+
+ private MetaSchemaImpactProbe probe(Set removed, Set typeChanged, Set visibility) {
+ return new MetaSchemaImpactProbe(100L, "work_core", 2, "hash", removed, typeChanged, visibility);
+ }
+
+ @Test
+ void dimension_isPlanning() {
+ assertEquals(MetaImpactDimension.PLANNING, contributor.dimension());
+ }
+
+ @Test
+ void preview_fieldLevelRealCount() {
+ // section 1 用 title+desc;section 2 用 title;section 3 用 outline。
+ when(planningFieldSnapshotMapper.selectListBySchemaId(100L)).thenReturn(List.of(
+ snapshot(1L, "title"), snapshot(1L, "desc"),
+ snapshot(2L, "title"),
+ snapshot(3L, "outline")));
+ MetaSchemaDimensionImpact impact = contributor.previewDimensionImpact(
+ probe(Set.of("title"), Set.of("desc"), Set.of("nope")));
+ assertEquals(MetaImpactDimension.PLANNING, impact.dimension());
+ assertEquals(MetaSchemaDimensionImpact.RealityKind.REAL_COUNT, impact.realityKind());
+ assertEquals(3, impact.affectedInstanceCount()); // distinct section 1,2,3
+ assertEquals(2, impact.fieldRemovedInstanceCount()); // title→section 1,2
+ assertEquals(1, impact.typeChangedInstanceCount()); // desc→section 1
+ assertEquals(0, impact.visibilityChangedInstanceCount()); // nope→无命中
+ }
+
+ @Test
+ void preview_emptyBreakingSet_countsZero() {
+ // 空破坏集不退化为全量:affected 仍为关联范围,但各破坏维度恒 0。
+ when(planningFieldSnapshotMapper.selectListBySchemaId(100L)).thenReturn(List.of(snapshot(1L, "title")));
+ MetaSchemaDimensionImpact impact = contributor.previewDimensionImpact(probe(Set.of(), Set.of(), Set.of()));
+ assertEquals(1, impact.affectedInstanceCount());
+ assertEquals(0, impact.fieldRemovedInstanceCount());
+ assertEquals(0, impact.typeChangedInstanceCount());
+ assertEquals(0, impact.visibilityChangedInstanceCount());
+ }
+
+ @Test
+ void preview_noSnapshots_zeroRealCount() {
+ // 该 schema 无任何关联快照→真实查得 0,仍 REAL_COUNT(非 VERIFIED_ZERO)。
+ when(planningFieldSnapshotMapper.selectListBySchemaId(100L)).thenReturn(List.of());
+ MetaSchemaDimensionImpact impact = contributor.previewDimensionImpact(probe(Set.of("title"), Set.of(), Set.of()));
+ assertEquals(0, impact.affectedInstanceCount());
+ assertEquals(MetaSchemaDimensionImpact.RealityKind.REAL_COUNT, impact.realityKind());
+ }
+
+ @Test
+ void preview_nullSchemaId_zeroWithoutQuery() {
+ MetaSchemaImpactProbe probe = new MetaSchemaImpactProbe(null, "work_core", 2, "hash",
+ Set.of("title"), Set.of(), Set.of());
+ MetaSchemaDimensionImpact impact = contributor.previewDimensionImpact(probe);
+ assertEquals(0, impact.affectedInstanceCount());
+ assertEquals(MetaSchemaDimensionImpact.RealityKind.REAL_COUNT, impact.realityKind());
+ verifyNoInteractions(planningFieldSnapshotMapper); // schemaId null 不查 mapper
+ }
+}
diff --git a/muse-cloud/sql/muse/V25__add_content_planning_field_snapshot.sql b/muse-cloud/sql/muse/V25__add_content_planning_field_snapshot.sql
new file mode 100644
index 00000000..19591255
--- /dev/null
+++ b/muse-cloud/sql/muse/V25__add_content_planning_field_snapshot.sql
@@ -0,0 +1,31 @@
+-- B(完整字段级)地基:planning section 的「每实例字段用量快照表」。
+-- 说明:planning 的 content_payload 是自由 map(key≠schema fieldKey),无法据其字段级计数。
+-- 本表显式记录「某 planning section 用到了所属 work 绑定 schema 的哪些 fieldKey」,
+-- 由保存 planning 时前端传入的 usedFieldKeys 写入捕获,供 Meta Schema 用量投影的 Planning 维度字段级真实计数。
+-- schema_id 来自该 section 所属 work 的 work_schema_id(未绑 schema 的 work→其 section 不写本表,自然 no-source 排除)。
+
+CREATE TABLE muse_content_planning_field_snapshot (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ section_id BIGINT NOT NULL,
+ work_id BIGINT NOT NULL,
+ schema_id BIGINT NOT NULL,
+ field_key VARCHAR(100) NOT NULL,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_content_planning_field_snapshot UNIQUE (tenant_id, section_id, field_key)
+);
+
+-- 供 Planning 贡献者按 schema_id + field_key IN(破坏集) 做字段级命中计数。
+CREATE INDEX idx_muse_content_planning_field_snapshot_schema
+ ON muse_content_planning_field_snapshot(tenant_id, schema_id, field_key);
+-- 供保存 planning 时按 section_id 删旧快照后重写。
+CREATE INDEX idx_muse_content_planning_field_snapshot_section
+ ON muse_content_planning_field_snapshot(tenant_id, section_id);
+
+CREATE TRIGGER trg_muse_content_planning_field_snapshot_updated_at
+ BEFORE UPDATE ON muse_content_planning_field_snapshot
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();