feat(content): B 字段级地基 - planning 字段用量快照表 + Planning 贡献者[P1]
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) <noreply@anthropic.com>
This commit is contained in:
parent
b615a70a44
commit
94a23797ff
@ -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)
|
||||
|
||||
@ -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 维度字段级真实计数)。
|
||||
*
|
||||
* <p>取该 section 所属 work 绑定的 schema:未绑定(workSchemaId null)则只清旧快照不写新——
|
||||
* 该 section 属 no-source cohort,自然不计入任何 schema 的字段级用量(不伪造 0)。
|
||||
* 先按 sectionId 删旧、再按 usedFieldKeys 去重写新,保证重复保存幂等。</p>
|
||||
*/
|
||||
private void captureFieldSnapshot(Long workId, Long sectionId, List<String> 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,
|
||||
|
||||
@ -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 端口,只读本域字段用量快照)。
|
||||
*
|
||||
* <p>planning 的 content_payload 是自由 map(key≠fieldKey),字段级用量由
|
||||
* {@code muse_content_planning_field_snapshot} 显式承载(保存 planning 时按 usedFieldKeys 写入)。
|
||||
* 本贡献者据快照真实计数:
|
||||
* <ul>
|
||||
* <li>affectedInstanceCount = 绑该 schema 的 distinct section 数(实例级关联范围);</li>
|
||||
* <li>各破坏维度 = 用到对应破坏字段集的 distinct section 数(字段级命中)。</li>
|
||||
* </ul>
|
||||
* realityKind 恒 REAL_COUNT——Planning 确为消费者,即便某 schema 无关联快照(真实查得 0),
|
||||
* 也是「真实计数 0」而非「本域不消费(VERIFIED_ZERO)」。</p>
|
||||
*/
|
||||
@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<PlanningFieldSnapshotDO> 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<PlanningFieldSnapshotDO> snapshots, Set<String> 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();
|
||||
}
|
||||
}
|
||||
@ -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<String> usedFieldKeys;
|
||||
|
||||
}
|
||||
|
||||
@ -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。
|
||||
*
|
||||
* <p>记录某 planning section 用到了所属 work 绑定 schema 的哪个 fieldKey(每 fieldKey 一行)。
|
||||
* planning 的 content_payload 是自由 map(key≠schema fieldKey),无法据其字段级计数,
|
||||
* 故字段级用量须由本表显式承载,供 Meta Schema 用量投影 Planning 维度字段级真实计数。</p>
|
||||
*/
|
||||
@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;
|
||||
|
||||
}
|
||||
@ -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。
|
||||
*
|
||||
* <p>查询统一走 {@link LambdaQueryWrapperX}(由框架 tenant 插件 + 逻辑删除自动补 tenant_id / deleted),
|
||||
* 不用原生 {@code @Select},避免聚合 SQL 的多租户注入不确定性。distinct section 与字段命中计数由
|
||||
* 贡献者在内存完成(preview 为低频治理操作,单 schema 关联快照行有限,可接受)。</p>
|
||||
*/
|
||||
@Mapper
|
||||
public interface PlanningFieldSnapshotMapper extends BaseMapperX<PlanningFieldSnapshotDO> {
|
||||
|
||||
/** 删除某 section 的旧快照(保存 planning 时先删后写,保证重复保存幂等)。 */
|
||||
default int deleteBySectionId(Long sectionId) {
|
||||
return delete(new LambdaQueryWrapperX<PlanningFieldSnapshotDO>()
|
||||
.eq(PlanningFieldSnapshotDO::getSectionId, sectionId));
|
||||
}
|
||||
|
||||
/** 查某 section 现有快照(测试 / 校验用)。 */
|
||||
default List<PlanningFieldSnapshotDO> selectListBySectionId(Long sectionId) {
|
||||
return selectList(new LambdaQueryWrapperX<PlanningFieldSnapshotDO>()
|
||||
.eq(PlanningFieldSnapshotDO::getSectionId, sectionId));
|
||||
}
|
||||
|
||||
/** 查某 schema 关联的全部字段快照,供 Planning 贡献者内存做 distinct section 字段级计数。 */
|
||||
default List<PlanningFieldSnapshotDO> selectListBySchemaId(Long schemaId) {
|
||||
return selectList(new LambdaQueryWrapperX<PlanningFieldSnapshotDO>()
|
||||
.eq(PlanningFieldSnapshotDO::getSchemaId, schemaId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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<String> removed, Set<String> typeChanged, Set<String> 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
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
Loading…
x
Reference in New Issue
Block a user