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.FlowEngine; import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.enums.FlwEnums; 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.List; 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 FlwAssigneeMapRepository mapRepository; 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 FlowEngine flowEngine; private final ObjectMapper objectMapper; /** 定义列表: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=归属应用(筛选维度):新建/草稿直改直接落库;升版本(baseGuid/已发布行)缺省继承源线,传非空则覆盖 */ @Transactional public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid, String appKey) { 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()); } 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()); def = next; } } def.setCode(code.trim()); def.setName(name.trim()); def.setContentJson(contentJson); // 定义 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, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试"); } } /** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */ @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, "已是发布状态"); } FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); return defRepository.save(def); } /** 停用:不可新发起,存量跑完 */ @Transactional public void offline(Long schId, Long id) { FlwDef def = requireDef(schId, id); def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); defRepository.save(def); } /** * 启用(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()); def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); return defRepository.save(def); } /** 同一流程线全部版本(降序) */ public List versions(Long schId, String flowGuid) { return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid); } private Set enabledDeptKeys(Long schId) { return mapRepository.findEnabledBySchId(schId).stream() .map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet()); } /** 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; } }