C1第一刀:flw_def.form_uuid启用+发布期form-center schema快照嵌入formContract+表达式键查①红牌(audience按数组解析)
build / build-push (push) Failing after 2m6s

This commit is contained in:
chenxing
2026-10-20 22:50:58 +08:00
parent 93ecc79839
commit 97dec39d45
8 changed files with 550 additions and 7 deletions
@@ -103,7 +103,7 @@ public class FlowAdminController {
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
}
/** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;changeLog=升版本改动说明,仅新版本行落库,>500 截断) */
/** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;changeLog=升版本改动说明,仅新版本行落库,>500 截断;formUuid=关联表单定义 uuid(C1,2026-10-20):null=保持、空串=取消关联、发布后不可改改=升版) */
@PostMapping("/def/save")
public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
@@ -111,7 +111,8 @@ public class FlowAdminController {
return defAdminService.save(schId, id,
(String) body.get("code"), (String) body.get("name"),
(String) body.get("contentJson"), (String) body.get("baseGuid"),
(String) body.get("appKey"), (String) body.get("changeLog"));
(String) body.get("appKey"), (String) body.get("changeLog"),
(String) body.get("formUuid"));
}
/**
@@ -282,8 +282,12 @@ public final class DefValidator {
}
}
/** 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集) */
private static final Set<String> EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList(
/**
* 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集)。<br>
* 2026-10-20 C1 起 {@link com.yunhai.flowcenter.service.FlowDefAdminService} 的键查①红牌
* 复用本集合(表达式变量 ⊆ 快照字段 ∪ 内置变量)——两处必须是同一份,内置集扩了这边自动跟上。
*/
public static final Set<String> EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList(
"schId", "instanceId", "instanceVersion", "bizType", "bizId",
"nodeId", "nodeName", "submitterId"));
@@ -43,6 +43,7 @@ public class FlowDefAdminService {
private final FlowDeptAdminService deptAdminService;
private final FlowEngine flowEngine;
private final ObjectMapper objectMapper;
private final FormCenterSchemaClient formCenterSchemaClient;
/** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */
public List<FlwDef> list(Long schId, Integer status, String appKey) {
@@ -169,7 +170,32 @@ public class FlowDefAdminService {
}
}
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */
/**
* 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br>
* <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
* 非空=设置(不做格式强校验——寻址失败在发布期由快照拉取阻断,错误不进库但也不卡保存)。
*/
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
String baseGuid, String appKey, String changeLog, String formUuid) {
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
if (formUuid != null) {
String clean = formUuid.trim();
saved.setFormUuid(clean.isEmpty() ? null : clean);
}
try {
return defRepository.save(saved);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
}
}
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布。<br>
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照
* 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验),
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
* 运行期对 form-center 零依赖。form_uuid 为空(存量线/Mode C)行为逐字不变。 */
@Transactional
public FlwDef publish(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
@@ -178,11 +204,88 @@ public class FlowDefAdminService {
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
Map<String, Object> snapshot;
try {
snapshot = formCenterSchemaClient.fetchSchema(schId, def.getFormUuid().trim());
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
throw new BizException(ErrorCode.PARAM_ERROR,
"关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage());
}
List<String> problems = new ArrayList<>();
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
if (!problems.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"发布校验失败(表达式键查①):\n" + String.join("\n", problems));
}
def.setContentJson(embedFormContract(def.getContentJson(), snapshot));
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
/**
* 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式,
* 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。
* 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。
*/
private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map<String, Object> snapshot,
List<String> problems) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
java.util.Set<String> fieldKeys = new java.util.HashSet<>();
if (fields != null) {
for (Map<String, Object> f : fields) {
Object k = f.get("key");
if (k != null) {
fieldKeys.add(String.valueOf(k));
}
}
}
if (fieldKeys.isEmpty()) {
problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布");
return;
}
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) {
continue;
}
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) {
continue;
}
List<String> vars;
try {
vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim());
} catch (Exception ex) {
// 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过
continue;
}
for (String v : vars) {
if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) {
problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v
+ " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)");
}
}
}
}
}
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
private String embedFormContract(String contentJson, Map<String, Object> snapshot) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.set("formContract", objectMapper.valueToTree(snapshot));
return objectMapper.writeValueAsString(root);
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage());
}
}
/** 停用:不可新发起,存量跑完 */
@Transactional
public void offline(Long schId, Long id) {
@@ -0,0 +1,135 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br>
* GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid= ——按 form_uuid 拉当前已发布版 schema
* (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}:
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
* <b>只在发布期调用</b>(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取,
* 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。
*/
@Slf4j
@Service
public class FormCenterSchemaClient {
/** 服务目录 key(yh.open.services.form-center / targets 覆盖 / 枚举兜底三级解析) */
static final String TARGET = "form-center";
static final String SCHEMA_PATH = "/oapi/form/schema";
/** 快照是发布期一次性动作,超时给足(对照 RouteJudgeClient 的 3s 运行期预算) */
static final int TIMEOUT_MS = 10_000;
static final int MAX_ATTEMPTS = 2;
private final YschOpenCall openCall;
private final ObjectMapper objectMapper;
public FormCenterSchemaClient(YschOpenCall openCall, ObjectMapper objectMapper) {
this.openCall = openCall;
this.objectMapper = objectMapper;
}
/** 快照不可用(网络/信封/形态异常);发布期转 BizException 阻断,可重试 */
public static class SchemaUnavailableException extends RuntimeException {
public SchemaUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
/**
* 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。
* fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关);
* select/radio 类字段的 options 值域收进 enum(供域查③,本轮仅透传)。
*/
public Map<String, Object> fetchSchema(Long schId, String formUuid) {
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
query.put("uuid", formUuid);
Map<String, Object> data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class);
if (data == null) {
throw new IllegalStateException("schema 返回空 data");
}
return toSnapshot(data);
} catch (RuntimeException e) {
last = e;
log.warn("[flow-center] form-center schema 快照拉取失败(第 {}/{} 次):uuid={} {}",
attempt, MAX_ATTEMPTS, formUuid, e.toString());
} catch (Exception e) {
last = new IllegalStateException(e.toString(), e);
log.warn("[flow-center] form-center schema 快照拉取异常(第 {}/{} 次):uuid={} {}",
attempt, MAX_ATTEMPTS, formUuid, e.toString());
}
}
throw new SchemaUnavailableException(
"关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
}
/** SchemaView(data) → 快照节(fields 扁平化 + audience 数组归一) */
private Map<String, Object> toSnapshot(Map<String, Object> data) {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put("formUuid", data.get("formUuid"));
snap.put("formVersion", data.get("version"));
snap.put("audience", normalizeAudience(data.get("audience")));
List<Map<String, Object>> fields = new ArrayList<>();
JsonNode groups = data.get("groups") == null ? null
: objectMapper.valueToTree(data.get("groups"));
if (groups != null && groups.isArray()) {
for (JsonNode group : groups) {
JsonNode groupFields = group.get("fields");
if (groupFields == null || !groupFields.isArray()) {
continue;
}
for (JsonNode f : groupFields) {
Map<String, Object> field = new LinkedHashMap<>();
field.put("key", f.path("key").asText(null));
field.put("type", f.path("type").asText(null));
JsonNode required = f.get("required");
field.put("required", required == null ? Boolean.FALSE : required.asBoolean(false));
JsonNode options = f.get("options");
if (options != null && options.isArray() && options.size() > 0) {
List<String> enums = new ArrayList<>();
for (JsonNode opt : options) {
JsonNode v = opt.has("value") ? opt.get("value") : opt.get("label");
if (v != null) {
enums.add(v.asText());
}
}
field.put("enum", enums);
}
fields.add(field);
}
}
}
snap.put("fields", fields);
return snap;
}
/** audience 数组归一:数组照读;旧单值串包一层(迁移窗口容错);null → 空数组 */
private List<String> normalizeAudience(Object audience) {
List<String> out = new ArrayList<>();
if (audience instanceof Iterable) {
for (Object a : (Iterable<?>) audience) {
if (a != null) {
out.add(String.valueOf(a));
}
}
} else if (audience != null) {
out.add(String.valueOf(audience));
}
return out;
}
}
@@ -0,0 +1,177 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.repository.FlwAppRepository;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* publish() 的 C1 契约测试(2026-10-20 第一刀:发布期快照 + 表达式键查①红牌)。<br>
* 覆盖:① 关联表单(form_uuid 非空)发布成功=content_json 顶层嵌 formContract 且 status=1;
* ② 表达式变量 ∉ 快照字段 ∪ 内置变量 → 红牌阻断(点名节点/出口/变量);③ form_uuid 为空(存量线)
* 不拉快照、行为不变;④ 快照拉取失败=发布阻断(可重试语义)。
*/
class FlowDefAdminServicePublishContractTest {
private static final Long SCH = 27L;
/** 含 CONDITION(EXPRESSION) 节点的合法定义:days/grade 是表单字段、schId 是内置变量 */
private static final String JSON = "{\"start\":\"s\",\"nodes\":["
+ "{\"id\":\"s\",\"type\":\"START\"},"
+ "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
+ "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}},"
+ "{\"id\":\"e\",\"type\":\"END\"}],"
+ "\"edges\":["
+ "{\"from\":\"s\",\"to\":\"c\"},"
+ "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"},"
+ "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"},"
+ "{\"from\":\"a\",\"to\":\"e\"}]}";
private FlwDefRepository defRepository;
private FormCenterSchemaClient schemaClient;
private FlowDefAdminService service;
private final ObjectMapper mapper = new ObjectMapper();
@BeforeEach
void setUp() {
defRepository = mock(FlwDefRepository.class);
schemaClient = mock(FormCenterSchemaClient.class);
service = new FlowDefAdminService(defRepository,
mock(FlwRouterRepository.class),
mock(FlwCallbackRegRepository.class),
mock(FlwAppRepository.class),
mock(FlwInstanceRepository.class),
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
mapper,
schemaClient);
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
}
private FlwDef draft(String formUuid) {
FlwDef d = new FlwDef();
d.setId(9L);
d.setSchId(SCH);
d.setFlowGuid("g1");
d.setCode("work-leave");
d.setVersion(1);
d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
d.setAppKey("notice");
d.setContentJson(JSON);
d.setFormUuid(formUuid);
return d;
}
/** 快照:days(number)/grade(select enum g1,g2)/reason(text) */
private Map<String, Object> snapshot() {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put("formUuid", "FU1");
snap.put("formVersion", 3);
snap.put("audience", Arrays.asList("STUDENT", "TEACHER"));
snap.put("fields", Arrays.asList(
field("days", "number", true, null),
field("grade", "select", false, Arrays.asList("g1", "g2")),
field("reason", "text", true, null)));
return snap;
}
private static Map<String, Object> field(String key, String type, boolean required, List<String> enums) {
Map<String, Object> f = new LinkedHashMap<>();
f.put("key", key);
f.put("type", type);
f.put("required", required);
if (enums != null) {
f.put("enum", enums);
}
return f;
}
@Test
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
FlwDef published = service.publish(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
JsonNode contract = mapper.readTree(published.getContentJson()).get("formContract");
assertNotNull(contract, "content_json 顶层必须嵌 formContract");
assertEquals("FU1", contract.get("formUuid").asText());
assertEquals(3, contract.get("formVersion").asInt());
assertEquals(3, contract.get("fields").size(), "fields 扁平化嵌入");
assertTrue(contract.get("audience").isArray(), "audience 按数组解析入快照");
}
@Test
void unknownExpressionVariableIsRedCard() {
FlwDef d = draft("FU1");
// 出口条件改成引用快照外变量
d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
assertTrue(ex.getMessage().contains("键查"), "错误文案给可定位的键查提示");
verify(defRepository, never()).save(any());
}
@Test
void builtinVariablesPassKeyCheck() {
// days 与 schId(内置)都合法 → 上面的 happy path 已覆盖;此处单独断言纯内置表达式
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@Test
void legacyDefWithoutFormUuidSkipsSnapshotEntirely() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null)));
FlwDef published = service.publish(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
}
@Test
void schemaUnavailableBlocksPublishAsRetryableBizError() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1"))
.thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null));
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage());
verify(defRepository, never()).save(any());
}
}
@@ -51,7 +51,8 @@ class FlowDefAdminServiceSaveTest {
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper());
new ObjectMapper(),
mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class));
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// 归属应用白名单:非空 appKey 一律放行(校验拦截语义由专门用例覆盖)
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
@@ -43,7 +43,8 @@ class FlowDefAdminServiceUpdateMetaTest {
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper());
new ObjectMapper(),
mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class));
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
}
@@ -0,0 +1,121 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link FormCenterSchemaClient} 单元测试(2026-10-20 C1 第一刀)。<br>
* 覆盖:① SchemaView(data) → 快照节的 fields 扁平化(分组展开、enum 收 options 值域);
* ② audience <b>数组解析</b>(契约 v2 C5/G3 breaking)+ 旧单值串容错;③ 失败重试 2 次后转
* SchemaUnavailableException(发布阻断语义)。
*/
class FormCenterSchemaClientTest {
private YschOpenCall openCall;
private FormCenterSchemaClient client;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
client = new FormCenterSchemaClient(openCall, new ObjectMapper());
}
@SuppressWarnings("unchecked")
private Map<String, Object> schemaData(String uuid, int version, List<String> audience) {
Map<String, Object> data = new HashMap<>();
data.put("formUuid", uuid);
data.put("version", version);
data.put("code", "leave-form");
data.put("appCode", "svc-hall");
data.put("audience", audience);
data.put("groups", Arrays.asList(
map("key", "base", "label", "基本信息", "fields", Arrays.asList(
map("key", "days", "label", "天数", "type", "number", "required", true),
map("key", "grade", "label", "年级", "type", "select", "required", false,
"options", Arrays.asList(map("label", "高一", "value", "g1"),
map("label", "高二", "value", "g2"))))),
map("key", "extra", "label", "补充", "fields", Arrays.asList(
map("key", "reason", "label", "事由", "type", "text", "required", true)))));
return data;
}
private static Map<String, Object> map(Object... kv) {
Map<String, Object> m = new HashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
m.put((String) kv[i], kv[i + 1]);
}
return m;
}
@Test
@SuppressWarnings("unchecked")
void flattensGroupsIntoFieldsAndCollectsEnumFromOptions() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER")));
Map<String, Object> snap = client.fetchSchema(27L, "U1");
assertEquals("U1", snap.get("formUuid"));
assertEquals(3, snap.get("formVersion"));
assertEquals(Arrays.asList("STUDENT", "TEACHER"), snap.get("audience"));
List<Map<String, Object>> fields = (List<Map<String, Object>>) snap.get("fields");
assertEquals(3, fields.size(), "两个分组的字段要扁平化到一处");
Map<String, Object> grade = fields.stream()
.filter(f -> "grade".equals(f.get("key"))).findFirst().orElseThrow(AssertionError::new);
assertEquals("select", grade.get("type"));
assertEquals(Boolean.FALSE, grade.get("required"));
assertEquals(Arrays.asList("g1", "g2"), grade.get("enum"), "options 值域收进 enum(域查③的输入)");
}
@Test
void legacySingleStringAudienceIsWrappedAsArray() {
Map<String, Object> data = schemaData("U1", 1, null);
data.put("audience", "STUDENT");
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
Map<String, Object> snap = client.fetchSchema(27L, "U1");
assertEquals(Arrays.asList("STUDENT"), snap.get("audience"),
"C5 切换窗口的旧单值串按一元素数组容错");
}
@Test
void retriesTwiceThenThrowsUnavailable() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new RuntimeException("connect refused"));
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1"));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
@SuppressWarnings("unchecked")
void nullDataTreatedAsFailure() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null);
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1"));
assertTrue(true);
}
}