package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.engine.DefValidator; import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.engine.VariableContract; import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.enums.FormBindingErrorCode; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.UUID; import java.util.stream.Collectors; /** * 流程定义管理(M1:JSON 文本编辑模式,概览 §9.2): * 保存=草稿直改/已发布升版本(flowGuid 跨版本不变);发布跑静态校验(把"任务无主"拦在发布期)。 */ @Service @RequiredArgsConstructor public class FlowDefAdminService { private final FlwDefRepository defRepository; private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository; private final com.yunhai.flowcenter.repository.FlwCallbackRegRepository regRepository; private final com.yunhai.flowcenter.repository.FlwAppRepository appRepository; private final com.yunhai.flowcenter.repository.FlwInstanceRepository instanceRepository; private final FlowDeptAdminService deptAdminService; private final FlowEngine flowEngine; private final ObjectMapper objectMapper; private final FormCenterSchemaClient formCenterSchemaClient; /** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */ public List list(Long schId, Integer status, String appKey) { List rows = status != null ? defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status) : defRepository.findBySchIdOrderByUpdateTimeDesc(schId); String key = appKey == null ? null : appKey.trim(); if (key != null && !key.isEmpty()) { rows = rows.stream().filter(d -> key.equals(d.getAppKey())).collect(Collectors.toList()); } return rows; } /** 应用清单("按应用筛选"下拉数据源):本校 flw_def 已标注 ∪ 已注册回调的应用,去重排序 */ public List distinctAppKeys(Long schId) { TreeSet keys = new TreeSet<>(); keys.addAll(defRepository.findDistinctAppKeys(schId)); keys.addAll(regRepository.findDistinctAppKeysBySchId(schId)); return new ArrayList<>(keys); } /** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */ public List listAll(Long schId) { return defRepository.findPublishedLatestPerGuid(schId); } public FlwDef get(Long schId, Long id) { FlwDef def = requireDef(schId, id); return def; } /** * 保存(2026-10-10 起唯一键为 uk(sch,flow_guid,version),编码降级为可重名的分类标签): * - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1) * - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本" * - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示 * appKey=归属应用(筛选维度,2026-10-12 拍板必填):新建必须显式传;升版本/直改可继承源行, * 但源行为空时必须显式补填——存量 NULL 线随编辑自然收敛(白名单只校验显式传入值,继承值不重校) * changeLog=升版本改动说明(议题二③):只随新建行落库(三个建新行分支),草稿直改行不写也不清空 */ @Transactional public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid, String appKey, String changeLog) { String cleanLog = (changeLog == null || changeLog.trim().isEmpty()) ? null : changeLog.trim().substring(0, Math.min(500, changeLog.trim().length())); String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim(); // 归属应用白名单(2026-10-10 应用设置):显式传入非空时必须 ∈ flw_app(sch, enabled=1); // 继承值不重校(既成事实,应用停用不应卡死升版本)。不在校池/未开启的应用由此拦下(拍板 D2:不做存量兜底) if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) { throw new BizException(ErrorCode.PARAM_ERROR, "归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试"); } // JSON 先解析再落库(语法错误不进库) FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson); if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "code/name 必填"); } FlwDef def; if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) { // 在既有流程线上开新版本草稿(appKey 缺省继承源线) FlwDef base = defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在")); def = new FlwDef(); def.setSchId(schId); def.setFlowGuid(baseGuid.trim()); def.setCode(code.trim()); def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey()); def.setFormUuid(base.getFormUuid()); def.setFormVersion(base.getFormVersion()); } else if (id == null) { // 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用) def = new FlwDef(); def.setSchId(schId); def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase()); def.setCode(code.trim()); def.setVersion(1); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); def.setAppKey(cleanAppKey); } else { def = requireDef(schId, id); if (!def.getCode().equals(code.trim())) { throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改"); } if (cleanAppKey != null) { def.setAppKey(cleanAppKey); } if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) { // 升版本:复制行,保持 flowGuid FlwDef next = new FlwDef(); next.setSchId(schId); next.setFlowGuid(def.getFlowGuid()); next.setCode(def.getCode()); next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1); next.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey()); next.setFormUuid(def.getFormUuid()); next.setFormVersion(def.getFormVersion()); def = next; } } def.setCode(code.trim()); def.setName(name.trim()); // 归属应用必填(2026-10-12 拍板):各分支归一后落库值不得为空——新建必须显式传; // 升版本/直改继承源行,源行为空的存量线编辑时强制补填(19 条存量随编辑自然收敛) if (def.getAppKey() == null || def.getAppKey().trim().isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "归属应用必填:请选择归属应用后再保存(应用需先在 流程管理 > 应用设置 中开启)"); } // 改动说明只写新建行(新建/baseGuid 开版/已发布复制升版三分支 id 必为 null);草稿直改不动 if (def.getId() == null) { def.setChangeLog(cleanLog); } def.setContentJson(withoutFormContract(contentJson)); // Only publish may author a contract snapshot. // 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主) if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) { def.setCode(parsed.code.trim()); } if (parsed.name != null && !parsed.name.trim().isEmpty()) { def.setName(parsed.name.trim()); } try { return defRepository.save(def); } catch (DataIntegrityViolationException e) { // 撞唯一键 (sch,flow_guid,version):并发升版本同一线时可能撞号,转可理解提示(原样抛出=笼统"内部服务错误") throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试"); } } /** * 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。
* 发布后不可改,改=升版本由既有升版分派天然保证(非 DRAFT 直改必走升版新行); * 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库); * 非空=设置,保存前按确切版本远端核验,失败不写入。 *
v3/TASK_1 追加 {@code formVersion}:与 formUuid 成对,规则是—— * ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验); * ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜); * ③ formUuid 给空串=取消关联,同时清空版本(留着一个没人认领的版本号是下一轮误用的起点); * ④ formUuid 给 null=这条指令不存在(两个坐标都不动,兼容旧调用面)。 */ @Transactional public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid, String appKey, String changeLog, String formUuid, Integer formVersion) { boolean clearing = formUuid != null && formUuid.trim().isEmpty(); boolean pairing = formUuid != null && !clearing && formVersion == null; boolean versionOnly = formUuid == null && formVersion != null; if (pairing || versionOnly) { throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED : FormBindingErrorCode.INVALID_COORDINATE, "关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid=" + (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion); } if (formUuid != null && !clearing) { checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save. } FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog); if (formUuid != null) { String clean = formUuid.trim(); saved.setFormUuid(clean.isEmpty() ? null : clean); saved.setFormVersion(clean.isEmpty() ? null : formVersion); } try { return defRepository.save(saved); } catch (DataIntegrityViolationException e) { throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试"); } } /** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布。
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照 * 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验), * 并做表达式键查①红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试); * 运行期对 form-center 零依赖;新发布的无绑定版本必须显式声明 variables。 */ @Transactional public FlwDef publish(Long schId, Long id) { FlwDef def = requireDef(schId, id); if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) { throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态"); } if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) { throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿"); } FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) { Map snapshot = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "发布"); model.variables = VariableContract.fromSnapshot(snapshot); VariableContract.validateDefinition(model, true); DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables)); } else if (def.getFormVersion() != null) { throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid"); } if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) { VariableContract.validateDefinition(model, true); DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); } def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); return defRepository.save(def); } /** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */ private String embedFormContract(String contentJson, Map snapshot, List variables) { try { com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.readTree(contentJson); root.set("formContract", objectMapper.valueToTree(snapshot)); root.set("variables", objectMapper.valueToTree(variables)); 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) { FlwDef def = requireDef(schId, id); if (def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) { throw new BizException(ErrorCode.PARAM_ERROR, "草稿不能停用后启用,请先通过发布校验"); } def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); defRepository.save(def); } /** * 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。 * 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。 * 不改写任何发布内容字节,恢复走 {@link #enableLine}。 */ @Transactional public int offlineLine(Long schId, String flowGuid) { requireLine(schId, flowGuid); return defRepository.updateLineOffline(schId, flowGuid.trim(), 1); } /** * 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针 * 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。 */ @Transactional public int enableLine(Long schId, String flowGuid) { requireLine(schId, flowGuid); return defRepository.updateLineOffline(schId, flowGuid.trim(), 0); } private void requireLine(Long schId, String flowGuid) { if (flowGuid == null || flowGuid.trim().isEmpty() || defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim()) .isPresent()) { throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid); } } /** * 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。 * 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后 * 被停用,这里会点名报错(如"科室 X 无启用办理人"),管理员在办理人映射页点"启用"即可解。 */ @Transactional public FlwDef enable(Long schId, Long id) { FlwDef def = requireDef(schId, id); if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) { throw new BizException(ErrorCode.PARAM_ERROR, "该流程已是发布状态,无需启用"); } if (def.getStatus() != FlwEnums.DefStatus.DISABLED.getCode()) { throw new BizException(ErrorCode.PARAM_ERROR, "仅停用的流程可启用;草稿请走发布"); } FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) { checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用"); } else if (def.getFormVersion() != null) { throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid"); } def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); return defRepository.save(def); } private Map checkedSchema(Long schId, String uuid, Integer version, String operation) { if (version == null) { throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本," + operation + "已阻断,请重新选择表单与版本"); } if (version < 1 || uuid == null || uuid.trim().isEmpty()) { throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择"); } try { return formCenterSchemaClient.fetchSchema(schId, uuid, version); } catch (FormCenterSchemaClient.SchemaUnavailableException e) { throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE, "关联表单快照拉取失败," + operation + "已阻断(可重试)", e); } catch (FormCenterSchemaClient.SchemaContractViolationException e) { throw new BizException(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, "关联表单版本核验失败," + operation + "已阻断(请重新选择表单与版本)", e); } catch (FormCenterSchemaClient.SchemaResourceUnavailableException e) { throw new BizException(FormBindingErrorCode.RESOURCE_UNAVAILABLE, "关联表单或指定发布版本不存在或不可读," + operation + "已阻断", e); } catch (FormCenterSchemaClient.SchemaAccessDeniedException e) { throw new BizException(FormBindingErrorCode.ACCESS_DENIED, "关联表单访问身份或授权被拒绝," + operation + "已阻断,请检查授权", e); } } private String withoutFormContract(String contentJson) { try { com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.readTree(contentJson); root.remove("formContract"); return objectMapper.writeValueAsString(root); } catch (Exception e) { throw new BizException(ErrorCode.PARAM_ERROR, "流程定义必须是有效的JSON对象"); } } /** 同一流程线全部版本(降序) */ public List versions(Long schId, String flowGuid) { return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid); } /** 在途办件数(议题二④):flowGuid → 审批中(10)办件数;口径=def_id 精确匹配该线各版本;请求的 guid 全部有键(无在途=0) */ public Map inflightCounts(Long schId, Collection flowGuids) { Map result = new HashMap<>(); if (flowGuids == null || flowGuids.isEmpty()) return result; flowGuids.forEach(g -> result.put(g, 0L)); List defs = defRepository.findBySchIdAndFlowGuidIn(schId, flowGuids); if (defs.isEmpty()) return result; Map idToGuid = new HashMap<>(); defs.forEach(d -> idToGuid.put(d.getId(), d.getFlowGuid())); instanceRepository.countApprovingByDefIds(schId, idToGuid.keySet()).forEach(r -> { Long defId = ((Number) r[0]).longValue(); Long cnt = ((Number) r[1]).longValue(); result.merge(idToGuid.get(defId), cnt, Long::sum); }); return result; } /** * 元数据原地编辑(2026-10-10 四议题·议题2,拍板 D4):已发布/停用/草稿均可只改 name/appKey * 而不升版本——结构改动仍走 save 的升版本路径,两者完全隔离(本方法不碰 * code/flow_guid/version/status,不进升版本分支)。appKey 校验同 save 口径(显式传入须在白名单)。 * content_json 顶层 name 同步更新(只动顶层字段不动结构,消除"行 name 与 JSON name 的脏窗口"; * 亦修复 API 直调方经 parsed.name 覆盖行字段时读到旧名的口径)。 * 并发说明:与升版本路径无共享可变行(升版本建新行),不加锁。 */ @Transactional public FlwDef updateMeta(Long schId, Long id, String name, String appKey) { FlwDef def = requireDef(schId, id); if (name == null || name.trim().isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "流程名称不能为空"); } String cleanName = name.trim(); String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim(); if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) { throw new BizException(ErrorCode.PARAM_ERROR, "归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试"); } def.setName(cleanName); def.setAppKey(cleanAppKey); // 发布封存(2026-10-25 T03):非草稿版本的执行文档不可变——行 name 照改(目录元数据), // content_json 顶层 name 保持发布时字节,消除"已发布文档被原地改写"的旁路 //(审计 E02:updateMeta 曾同步改已发布 contentJson 顶层 name)。 if (def.getStatus() != null && def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) { def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName)); } try { return defRepository.save(def); } catch (DataIntegrityViolationException e) { throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时编辑,请刷新后重试"); } } /** contentJson 顶层 name 同步(ObjectNode 保字段顺序;结构零改动),返回新 JSON 串 */ private String syncTopLevelName(String contentJson, String name) { try { com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.readTree(contentJson); root.put("name", name); return objectMapper.writeValueAsString(root); } catch (Exception e) { // contentJson 在 save 期已保证可解析;防御口径:解析失败不同步(行 name 已更新,JSON 维持原值) return contentJson; } } private Set enabledDeptKeys(Long schId) { // 2026-10-10 科室管理切源:发布校验白名单改查 flw_dept/flw_dept_member(拍板 D1) return deptAdminService.enabledDeptKeys(schId); } /** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */ private Set enabledRouterKeys() { return routerRepository.findByStatusOrderByIdAsc(1).stream() .map(com.yunhai.flowcenter.entity.FlwRouter::getRouterKey) .collect(Collectors.toSet()); } private FlwDef requireDef(Long schId, Long id) { FlwDef def = defRepository.findById(id) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + id)); if (!def.getSchId().equals(schId)) { throw new BizException(ErrorCode.FORBIDDEN, "流程定义不属于该学校"); } return def; } }