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:
parent
6389d463f3
commit
a1600d28ce
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 true→false)→ 入可见性变更集。
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user