Files
flow-center-service/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
T
chenxing d6b7a68ec3 feat(app): 应用设置·应用管理——flw_app 白名单 + 学校池同步 + defSave 归属应用校验
- flw_app(sch,app_key uk):学校池(UserBase list-by-school,学校组单点已开通、clientType=null 不过滤端)
  diff 同步——新增即 enabled=1、改名静默刷新、池外保留;UserBase 不可达降级本地行
- /admin/app/list(触发同步)/ /admin/app/enable;defSave 显式 appKey 须 ∈ 启用集合(不传不拦,升版本继承不受影响)
- SQL 备案:flw_app 建表 + UserBase 菜单 107 新增/104 下线(人工执行)
- 单测 +8;E2E 13/13(local-debug/four-topics-c-e2e.py)
- 关联 SDK 修复:BoaApp.createTime @JsonFormat 对齐 UserBase 时间契约(BasePlatform 单独 commit,Nexus -73)
2026-10-10 19:17:38 +08:00

223 lines
11 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<FlwDef> list(Long schId, Integer status, String appKey) {
List<FlwDef> 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<String> distinctAppKeys(Long schId) {
TreeSet<String> keys = new TreeSet<>();
keys.addAll(defRepository.findDistinctAppKeys(schId));
keys.addAll(regRepository.findDistinctAppKeysBySchId(schId));
return new ArrayList<>(keys);
}
/** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */
public List<FlwDef> 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<FlwDef> versions(Long schId, String flowGuid) {
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
}
private Set<String> enabledDeptKeys(Long schId) {
return mapRepository.findEnabledBySchId(schId).stream()
.map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet());
}
/** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */
private Set<String> 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;
}
}