From 94a23797ffac4faec3bdce7f4e5469beeb950ae3 Mon Sep 17 00:00:00 2001 From: lili Date: Sun, 21 Jun 2026 01:48:15 -0700 Subject: [PATCH] =?UTF-8?q?feat(content):=20B=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=BA=A7=E5=9C=B0=E5=9F=BA=20-=20planning=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E5=BF=AB=E7=85=A7=E8=A1=A8=20+=20Planning=20?= =?UTF-8?q?=E8=B4=A1=E7=8C=AE=E8=80=85[P1]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M 全量字段级落地(人类第三轮确认 B):planning content_payload 是自由 map(key≠fieldKey)无法字段级计数,故补每实例字段快照表显式承载。 - V25 muse_content_planning_field_snapshot(section_id/work_id/schema_id/field_key)+ DO + Mapper(走 LambdaQueryWrapperX,tenant/deleted 框架自动注入,避免聚合 SQL 多租户不确定性)。 - SavePlanningItemReqVO 加 usedFieldKeys;ContentPlanningServiceImpl.captureFieldSnapshot 保存时按 work.workSchemaId 写快照(未绑 schema→no-source 只清不写,不伪造 0)。 - ContentPlanningUsageContributor(PLANNING/REAL_COUNT):据快照 distinct section 算实例级关联 + removed/typeChanged/visibility 字段级命中。 验证:content 38/38(ContentPlanningServiceTest 15 含 captureFieldSnapshot、ContentPlanningUsageContributorTest 5、ContentAppServiceTest 18 不破)。execution doc 记录第三轮决策与调查发现。 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-20-meta-schema用量投影-execution.md | 3 + .../ContentPlanningServiceImpl.java | 37 ++++++++ .../ContentPlanningUsageContributor.java | 75 ++++++++++++++++ .../app/vo/SavePlanningItemReqVO.java | 4 + .../dataobject/PlanningFieldSnapshotDO.java | 36 ++++++++ .../mysql/PlanningFieldSnapshotMapper.java | 38 ++++++++ .../ContentPlanningServiceTest.java | 4 + .../ContentPlanningUsageContributorTest.java | 89 +++++++++++++++++++ ...5__add_content_planning_field_snapshot.sql | 31 +++++++ 9 files changed, 317 insertions(+) create mode 100644 muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributor.java create mode 100644 muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/dataobject/PlanningFieldSnapshotDO.java create mode 100644 muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/dal/mysql/PlanningFieldSnapshotMapper.java create mode 100644 muse-cloud/muse-module-content/muse-module-content-server/src/test/java/cn/iocoder/muse/module/content/application/impact/ContentPlanningUsageContributorTest.java create mode 100644 muse-cloud/sql/muse/V25__add_content_planning_field_snapshot.sql 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();