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:
lili 2026-06-21 01:48:15 -07:00
parent b615a70a44
commit 94a23797ff
9 changed files with 317 additions and 0 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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 是自由 mapkeyfieldKey字段级用量由
* {@code muse_content_planning_field_snapshot} 显式承载保存 planning 时按 usedFieldKeys 写入
* 本贡献者据快照真实计数
* <ul>
* <li>affectedInstanceCount = 绑该 schema distinct section 实例级关联范围</li>
* <li>各破坏维度 = 用到对应破坏字段集的 distinct section 字段级命中</li>
* </ul>
* realityKind REAL_COUNTPlanning 确为消费者即便某 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();
}
}

View File

@ -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;
}

View File

@ -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 是自由 mapkeyschema 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;
}

View File

@ -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));
}
}

View File

@ -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);

View File

@ -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+descsection 2 titlesection 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()); // titlesection 1,2
assertEquals(1, impact.typeChangedInstanceCount()); // descsection 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
}
}

View File

@ -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();