feat(meta): G1 破坏字段集生产者(移除+类型变更+可见性diff) + 单测[P0a]

meta-schema 用量投影 P0a 地基第一块。补齐 v0.1 误称已存在、实则缺失的破坏集计算:现有 compatibility() 仅算 type_changed 且 draftField!=null 守卫使移除字段不产破坏;字段级可见性只在 policySnapshot JSON 不在列。

MetaSchemaBreakingFieldSetProducer(纯逻辑、无 DB 依赖、可单测)产出三类破坏集:removedFieldKeys(active−draft,补齐漏算)、typeChangedFieldKeys、visibilityChangedFieldKeys(解析两版 policySnapshot 逐维度 diff,5 维度与 MetaVisibilityPolicyDO 口径对齐)。fail-safe:可见性任一维度变化即报、移除字段不重复计入可见性。

验证:单测 5/5(三类各产非空集 + 无破坏空集 + 移除不双计)。真实 PG IT 待 P0b 聚合器接入后(producer 输入由聚合器查 DB 提供)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-20 23:45:12 -07:00
parent 6389d463f3
commit a1600d28ce
3 changed files with 243 additions and 0 deletions

View File

@ -0,0 +1,29 @@
package cn.iocoder.muse.module.meta.application;
import java.util.Set;
/**
* MetaSchema 草稿相对 active 版本的破坏字段集(G1 地基产出)
*
* <p>三类破坏分离供各消费域贡献者按 fieldKey 在本域计数( BC 边界门):
* <ul>
* <li>{@code removedFieldKeys}:active draft ( {@code compatibility()} {@code draftField != null}
* 守卫漏算移除,此处补齐)
* <li>{@code typeChangedFieldKeys}: fieldKey 字段类型变更
* <li>{@code visibilityChangedFieldKeys}: fieldKey 可见性策略
* (uiVisible/aiContext/userEditable/userSearchable/exportable)变更
* </ul>
* 仅做字段集计算,不做实例计数(实例计数由各 BC 贡献者完成)
*/
public record MetaSchemaBreakingFieldSet(
Set<String> removedFieldKeys,
Set<String> typeChangedFieldKeys,
Set<String> visibilityChangedFieldKeys) {
/** 无任何破坏(三集合皆空)→ 草稿向后兼容。 */
public boolean isEmpty() {
return removedFieldKeys.isEmpty()
&& typeChangedFieldKeys.isEmpty()
&& visibilityChangedFieldKeys.isEmpty();
}
}

View File

@ -0,0 +1,119 @@
package cn.iocoder.muse.module.meta.application;
import cn.iocoder.muse.module.meta.convert.MetaConvert;
import cn.iocoder.muse.module.meta.dal.dataobject.MetaFieldDO;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* G1 破坏字段集生产者(meta-server 地基层)
*
* <p> active/draft 字段比对之上产出移除/类型变更/可见性变更三类破坏集这是 v0.1 误称已复用
* 实则缺失的地基:现有 {@code MetaSchemaValidationServiceImpl#compatibility} 仅算 type_changed,
* {@code draftField != null} 守卫使移除字段不产破坏;字段级可见性事实只在 policySnapshot JSON不在列
*
* <p>纯逻辑 DB 依赖(active/draft 字段与 policySnapshot 由聚合器查好喂入),便于单测;计算结果交给
* 各消费域贡献者按 fieldKey 在本域计数
*/
@Component
public class MetaSchemaBreakingFieldSetProducer {
/** 可见性维度,与 {@code MetaVisibilityPolicyDO}/policySnapshot 写入口径(MetaSchemaServiceImpl)对齐。 */
private static final List<String> VISIBILITY_DIMENSIONS =
List.of("uiVisible", "aiContext", "userEditable", "userSearchable", "exportable");
/**
* 产出草稿相对 active 的破坏字段集
*
* @param activeFields active 版本字段
* @param draftFields draft 版本字段
* @param activePolicySnapshot active 版本可见性快照 JSON(MetaVisibilityPolicyDO.policySnapshot;可空)
* @param draftPolicySnapshot draft 版本可见性快照 JSON(可空)
*/
public MetaSchemaBreakingFieldSet produce(List<MetaFieldDO> activeFields, List<MetaFieldDO> draftFields,
String activePolicySnapshot, String draftPolicySnapshot) {
Map<String, MetaFieldDO> activeByKey = byKey(activeFields);
Map<String, MetaFieldDO> draftByKey = byKey(draftFields);
Set<String> removed = new LinkedHashSet<>();
Set<String> typeChanged = new LinkedHashSet<>();
// 移除集 + 类型变更集:遍历 active,draft 缺则移除(补齐 compatibility() 漏算),类型不同则类型变更
for (Map.Entry<String, MetaFieldDO> entry : activeByKey.entrySet()) {
MetaFieldDO draftField = draftByKey.get(entry.getKey());
if (draftField == null) {
removed.add(entry.getKey());
} else if (!Objects.equals(entry.getValue().getFieldType(), draftField.getFieldType())) {
typeChanged.add(entry.getKey());
}
}
// 可见性变更集:解析两版 policySnapshot,仅对两边都存在的字段逐维度比对(移除字段已归 removed,不重复计入)
Map<String, Map<String, Object>> activeVisibility = visibilityByField(activePolicySnapshot);
Map<String, Map<String, Object>> draftVisibility = visibilityByField(draftPolicySnapshot);
Set<String> visibilityChanged = new LinkedHashSet<>();
for (String fieldKey : activeByKey.keySet()) {
if (!draftByKey.containsKey(fieldKey)) {
continue;
}
if (visibilityDiffers(activeVisibility.get(fieldKey), draftVisibility.get(fieldKey))) {
visibilityChanged.add(fieldKey);
}
}
return new MetaSchemaBreakingFieldSet(removed, typeChanged, visibilityChanged);
}
private Map<String, MetaFieldDO> byKey(List<MetaFieldDO> fields) {
if (fields == null) {
return Map.of();
}
return fields.stream()
.filter(field -> field.getFieldKey() != null)
.collect(Collectors.toMap(MetaFieldDO::getFieldKey, Function.identity(),
(left, right) -> left, LinkedHashMap::new));
}
/** 解析 policySnapshot JSON 为 fieldKey→{维度:布尔值} 映射(缺失维度记 null)。 */
private Map<String, Map<String, Object>> visibilityByField(String policySnapshot) {
JsonNode root = MetaConvert.parseJson(policySnapshot);
JsonNode fields = root == null ? null : root.path("fields");
Map<String, Map<String, Object>> result = new LinkedHashMap<>();
if (fields == null || !fields.isArray()) {
return result;
}
for (JsonNode field : fields) {
JsonNode keyNode = field.get("fieldKey");
if (keyNode == null || keyNode.isNull()) {
continue;
}
JsonNode visibility = field.path("visibility");
Map<String, Object> dimensions = new LinkedHashMap<>();
for (String dimension : VISIBILITY_DIMENSIONS) {
JsonNode value = visibility.get(dimension);
dimensions.put(dimension, value == null || value.isNull() ? null : value.asBoolean());
}
result.put(keyNode.asText(), dimensions);
}
return result;
}
/** 任一可见性维度取值不同即视为破坏(fail-safe:宁可多报让人类复核,绝不漏报)。 */
private boolean visibilityDiffers(Map<String, Object> active, Map<String, Object> draft) {
Map<String, Object> left = active == null ? Map.of() : active;
Map<String, Object> right = draft == null ? Map.of() : draft;
for (String dimension : VISIBILITY_DIMENSIONS) {
if (!Objects.equals(left.get(dimension), right.get(dimension))) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,95 @@
package cn.iocoder.muse.module.meta.application;
import cn.iocoder.muse.module.meta.dal.dataobject.MetaFieldDO;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* G1 破坏字段集生产者单测:移除 / 类型变更 / 可见性变更各产非空集,无破坏则三集皆空,移除字段不重复计入可见性
*/
class MetaSchemaBreakingFieldSetProducerTest {
private final MetaSchemaBreakingFieldSetProducer producer = new MetaSchemaBreakingFieldSetProducer();
@Test
void should_detectRemovedField() {
// active title/body,draft 删掉 body body 入移除集(补齐 compatibility() draftField!=null 漏算)
List<MetaFieldDO> active = List.of(field("title", "string"), field("body", "text"));
List<MetaFieldDO> draft = List.of(field("title", "string"));
MetaSchemaBreakingFieldSet result = producer.produce(active, draft, null, null);
assertEquals(Set.of("body"), result.removedFieldKeys());
assertTrue(result.typeChangedFieldKeys().isEmpty());
assertFalse(result.isEmpty());
}
@Test
void should_detectTypeChangedField() {
List<MetaFieldDO> active = List.of(field("wordCount", "string"));
List<MetaFieldDO> draft = List.of(field("wordCount", "number"));
MetaSchemaBreakingFieldSet result = producer.produce(active, draft, null, null);
assertEquals(Set.of("wordCount"), result.typeChangedFieldKeys());
assertTrue(result.removedFieldKeys().isEmpty());
}
@Test
void should_detectVisibilityChangedField() {
// body 可见性收窄(exportable truefalse) 入可见性变更集
List<MetaFieldDO> fields = List.of(field("body", "text"));
String activeSnapshot = "{\"fields\":[{\"fieldKey\":\"body\",\"visibility\":"
+ "{\"uiVisible\":true,\"aiContext\":true,\"userEditable\":true,\"userSearchable\":true,\"exportable\":true}}]}";
String draftSnapshot = "{\"fields\":[{\"fieldKey\":\"body\",\"visibility\":"
+ "{\"uiVisible\":true,\"aiContext\":true,\"userEditable\":true,\"userSearchable\":true,\"exportable\":false}}]}";
MetaSchemaBreakingFieldSet result = producer.produce(fields, fields, activeSnapshot, draftSnapshot);
assertEquals(Set.of("body"), result.visibilityChangedFieldKeys());
assertTrue(result.removedFieldKeys().isEmpty());
assertTrue(result.typeChangedFieldKeys().isEmpty());
}
@Test
void should_returnEmpty_whenNoBreaking() {
// 字段与可见性完全一致 无破坏
List<MetaFieldDO> fields = List.of(field("title", "string"));
String snapshot = "{\"fields\":[{\"fieldKey\":\"title\",\"visibility\":"
+ "{\"uiVisible\":true,\"aiContext\":true,\"userEditable\":true,\"userSearchable\":true,\"exportable\":true}}]}";
MetaSchemaBreakingFieldSet result = producer.produce(fields, fields, snapshot, snapshot);
assertTrue(result.isEmpty());
}
@Test
void should_notDoubleCountRemovedFieldAsVisibilityChange() {
// 移除字段只入 removed,不因可见性快照缺失而重复计入 visibilityChanged
List<MetaFieldDO> active = List.of(field("title", "string"), field("body", "text"));
List<MetaFieldDO> draft = List.of(field("title", "string"));
String activeSnapshot = "{\"fields\":["
+ "{\"fieldKey\":\"title\",\"visibility\":{\"uiVisible\":true,\"aiContext\":true,\"userEditable\":true,\"userSearchable\":true,\"exportable\":true}},"
+ "{\"fieldKey\":\"body\",\"visibility\":{\"uiVisible\":true,\"aiContext\":true,\"userEditable\":true,\"userSearchable\":true,\"exportable\":true}}]}";
String draftSnapshot = "{\"fields\":["
+ "{\"fieldKey\":\"title\",\"visibility\":{\"uiVisible\":true,\"aiContext\":true,\"userEditable\":true,\"userSearchable\":true,\"exportable\":true}}]}";
MetaSchemaBreakingFieldSet result = producer.produce(active, draft, activeSnapshot, draftSnapshot);
assertEquals(Set.of("body"), result.removedFieldKeys());
assertTrue(result.visibilityChangedFieldKeys().isEmpty(), "移除字段不应重复计入可见性变更");
}
private static MetaFieldDO field(String fieldKey, String fieldType) {
MetaFieldDO field = new MetaFieldDO();
field.setFieldKey(fieldKey);
field.setFieldType(fieldType);
return field;
}
}