From d670b26140589bb747904f3c2c7f127848db1882 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Sun, 1 Nov 2026 15:13:46 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B5=81=E7=A8=8B=E5=AE=9A=E4=B9=89?=
=?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=89=88=E6=9C=AC=E5=8C=96=E5=8F=91=E5=B8=83?=
=?UTF-8?q?=EF=BC=88=E5=85=AD=E7=B1=BB=E6=A0=A1=E9=AA=8C=20+=20=E9=9B=86?=
=?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95=EF=BC=8CADR=200015=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
sql/12-approval-flow-menu.sql | 35 ++
.../controller/ApprovalFlowController.java | 55 +++
.../param/ApprovalFlowPublishParam.java | 26 ++
.../service/ApprovalFlowConfigService.java | 183 ++++++++++
.../service/ApprovalNodeValidator.java | 315 ++++++++++++++++++
.../gxwebsoft/approval/vo/ApprovalFlowVO.java | 50 +++
.../approval/vo/ApprovalFlowVersionVO.java | 34 ++
.../approval/ApprovalFlowConfigIT.java | 174 ++++++++++
8 files changed, 872 insertions(+)
create mode 100644 sql/12-approval-flow-menu.sql
create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalFlowController.java
create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalFlowPublishParam.java
create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalFlowConfigService.java
create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVO.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVersionVO.java
create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java
diff --git a/sql/12-approval-flow-menu.sql b/sql/12-approval-flow-menu.sql
new file mode 100644
index 0000000..5dacfd0
--- /dev/null
+++ b/sql/12-approval-flow-menu.sql
@@ -0,0 +1,35 @@
+-- 流程配置菜单与权限种子(可重复执行)
+-- 目标库:gxtyzx_core(server)
+-- 依赖:02-approval_menu.sql 已创建「审批管理」顶级菜单
+-- ADR 0015:后台编辑节点链并发布新版本,权限点 approval:flow:list / approval:flow:publish
+
+SET @parent = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
+
+-- 子菜单:流程配置
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @parent, '流程配置', '/approval/flow', '/approval/flow', 4, 'approval:flow:list', '', 0, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/flow' AND menu_type = 0 AND deleted = 0
+);
+
+SET @flowMenu = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/flow' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
+
+-- 按钮:发布流程
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @flowMenu, '发布流程', '', '', 1, 'approval:flow:publish', '', 1, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:flow:publish' AND menu_type = 1 AND deleted = 0
+);
+
+-- 授权 superAdmin(role_id = 586, tenant 10150)
+INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
+SELECT 586, m.menu_id, 10150
+FROM sys_menu m
+WHERE m.tenant_id = 10150
+ AND m.deleted = 0
+ AND (m.path = '/approval/flow' OR m.authority LIKE 'approval:flow:%')
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 586 AND rm.menu_id = m.menu_id AND rm.tenant_id = 10150
+ );
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalFlowController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalFlowController.java
new file mode 100644
index 0000000..0b7f453
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalFlowController.java
@@ -0,0 +1,55 @@
+package com.gxwebsoft.approval.controller;
+
+import com.gxwebsoft.approval.param.ApprovalFlowPublishParam;
+import com.gxwebsoft.approval.service.ApprovalFlowConfigService;
+import com.gxwebsoft.approval.vo.ApprovalFlowVO;
+import com.gxwebsoft.approval.vo.ApprovalFlowVersionVO;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 流程配置接口(ADR 0015):后台编辑节点链并发布新版本。
+ *
+ *
只覆盖已注册的 flow_code,不能凭空新增单据类型;发布即发版,仅对之后提交的单据生效。
+ */
+@Api(tags = "流程配置")
+@RestController
+@RequestMapping("/api/approval/flow")
+public class ApprovalFlowController extends BaseController {
+
+ @Resource
+ private ApprovalFlowConfigService flowConfigService;
+
+ @PreAuthorize("hasAuthority('approval:flow:list')")
+ @ApiOperation("流程定义列表(当前版本与出厂模板)")
+ @GetMapping
+ public ApiResult> list() {
+ return success(flowConfigService.list());
+ }
+
+ @PreAuthorize("hasAuthority('approval:flow:list')")
+ @ApiOperation("流程定义历史版本")
+ @GetMapping("/history")
+ public ApiResult> history(@RequestParam("flowCode") String flowCode) {
+ return success(flowConfigService.history(flowCode));
+ }
+
+ @PreAuthorize("hasAuthority('approval:flow:publish')")
+ @ApiOperation("发布流程定义新版本")
+ @PostMapping("/publish")
+ public ApiResult publish(@RequestBody ApprovalFlowPublishParam param) {
+ return success(flowConfigService.publish(param));
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalFlowPublishParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalFlowPublishParam.java
new file mode 100644
index 0000000..52988d5
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalFlowPublishParam.java
@@ -0,0 +1,26 @@
+package com.gxwebsoft.approval.param;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 流程定义发布参数(ADR 0015)。
+ *
+ * 节点用原始 JSON 树接收而不是 {@code List}:节点模型开启了
+ * {@code ignoreUnknown},直接反序列化会把拼错的字段静默丢掉,校验必须看得见原始字段名。
+ */
+@Data
+@ApiModel(description = "流程定义发布参数")
+public class ApprovalFlowPublishParam implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty(value = "流程编码", required = true)
+ private String flowCode;
+
+ @ApiModelProperty(value = "节点数组(结构同流程定义的 nodes_json)", required = true)
+ private JsonNode nodes;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowConfigService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowConfigService.java
new file mode 100644
index 0000000..c1be961
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowConfigService.java
@@ -0,0 +1,183 @@
+package com.gxwebsoft.approval.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.approval.document.ApprovalDocumentRegistry;
+import com.gxwebsoft.approval.document.ApprovalDocumentType;
+import com.gxwebsoft.approval.entity.ApprovalFlowDefinition;
+import com.gxwebsoft.approval.mapper.ApprovalFlowDefinitionMapper;
+import com.gxwebsoft.approval.param.ApprovalFlowPublishParam;
+import com.gxwebsoft.approval.support.ApprovalFieldSpec;
+import com.gxwebsoft.approval.support.ApprovalNode;
+import com.gxwebsoft.approval.vo.ApprovalFlowVO;
+import com.gxwebsoft.approval.vo.ApprovalFlowVersionVO;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 流程配置服务(ADR 0015):在后台查看与发布流程定义。
+ *
+ * 不改 {@link ApprovalFlowService#getOrCreate}:后台发布的新版本号恒大于代码基线,
+ * 因此 getOrCreate 的「库中版本 >= 基线即用库中版本」判定自然选用后台配置。
+ *
+ * 版本不可变:发布只是插入新行,已提交实例继续引用其 definition_id;新版本
+ * 只对之后提交的单据生效。保存即发布,没有草稿态。
+ */
+@Service
+public class ApprovalFlowConfigService {
+
+ private static final Logger log = LoggerFactory.getLogger(ApprovalFlowConfigService.class);
+
+ private static final DateTimeFormatter TIME_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private static final int MAX_PUBLISH_ATTEMPTS = 3;
+
+ private static final int HISTORY_LIMIT = 50;
+
+ @Resource
+ private ApprovalFlowDefinitionMapper flowDefinitionMapper;
+
+ @Resource
+ private ApprovalDocumentRegistry documentRegistry;
+
+ @Resource
+ private ApprovalNodeValidator nodeValidator;
+
+ @Resource
+ private ObjectMapper objectMapper;
+
+ /** 全部已注册流程的当前版本;未发布过的流程返回空节点,前端据此显示「尚未发布」。 */
+ public List list() {
+ List list = new ArrayList<>();
+ for (ApprovalDocumentType type : documentRegistry.all()) {
+ list.add(toVO(type, findLatest(type.flowCode())));
+ }
+ return list;
+ }
+
+ /** 历史版本(倒序,最多 HISTORY_LIMIT 条)。 */
+ public List history(String flowCode) {
+ documentRegistry.require(flowCode);
+ List definitions = flowDefinitionMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ApprovalFlowDefinition::getFlowCode, flowCode)
+ .orderByDesc(ApprovalFlowDefinition::getVersion)
+ .last("LIMIT " + HISTORY_LIMIT));
+ List list = new ArrayList<>();
+ for (ApprovalFlowDefinition definition : definitions) {
+ ApprovalFlowVersionVO vo = new ApprovalFlowVersionVO();
+ vo.setDefinitionId(definition.getId());
+ vo.setVersion(definition.getVersion());
+ vo.setName(definition.getName());
+ vo.setPublishTime(formatTime(definition));
+ vo.setNodes(parseNodes(definition.getNodesJson()));
+ list.add(vo);
+ }
+ return list;
+ }
+
+ /** 发布新版本:校验节点后插入新行,版本号取库中最新版与代码基线的较大者加一。 */
+ public ApprovalFlowVO publish(ApprovalFlowPublishParam param) {
+ if (param == null || param.getFlowCode() == null) {
+ throw new BusinessException("请选择要发布的流程");
+ }
+ String flowCode = param.getFlowCode();
+ ApprovalDocumentType type = documentRegistry.require(flowCode);
+ List nodes = nodeValidator.validate(flowCode, param.getNodes());
+ String nodesJson = writeNodes(nodes);
+ for (int attempt = 0; attempt < MAX_PUBLISH_ATTEMPTS; attempt++) {
+ ApprovalFlowDefinition latest = findLatest(flowCode);
+ ApprovalFlowDefinition created = new ApprovalFlowDefinition();
+ created.setFlowCode(flowCode);
+ created.setVersion(nextVersion(latest, type));
+ created.setName(flowName(latest, type));
+ created.setNodesJson(nodesJson);
+ try {
+ flowDefinitionMapper.insert(created);
+ log.info("审批流程定义已发布: flowCode={}, version={}", flowCode, created.getVersion());
+ return toVO(type, created);
+ } catch (DuplicateKeyException e) {
+ log.info("审批流程定义发布冲突,重试: flowCode={}", flowCode);
+ }
+ }
+ throw new BusinessException("流程定义发布冲突,请重试");
+ }
+
+ private int nextVersion(ApprovalFlowDefinition latest, ApprovalDocumentType type) {
+ int baseline = Math.max(1, type.latestVersion());
+ if (latest == null) {
+ return baseline;
+ }
+ return Math.max(latest.getVersion(), baseline) + 1;
+ }
+
+ private String flowName(ApprovalFlowDefinition latest, ApprovalDocumentType type) {
+ if (latest != null && latest.getName() != null && !latest.getName().trim().isEmpty()) {
+ return latest.getName();
+ }
+ return type.flowName();
+ }
+
+ private ApprovalFlowVO toVO(ApprovalDocumentType type, ApprovalFlowDefinition latest) {
+ ApprovalFlowVO vo = new ApprovalFlowVO();
+ vo.setFlowCode(type.flowCode());
+ vo.setFlowName(type.flowName());
+ vo.setFactoryVersion(type.latestVersion());
+ vo.setFactoryNodes(parseNodes(type.nodesJson()));
+ vo.setResolvers(nodeValidator.resolvers());
+ List specs = type.fieldSpecs();
+ vo.setFieldSpecs(specs == null ? Collections.emptyList() : specs);
+ if (latest != null) {
+ vo.setDefinitionId(latest.getId());
+ vo.setVersion(latest.getVersion());
+ vo.setPublishTime(formatTime(latest));
+ vo.setNodes(parseNodes(latest.getNodesJson()));
+ } else {
+ vo.setNodes(Collections.emptyList());
+ }
+ return vo;
+ }
+
+ private List parseNodes(String nodesJson) {
+ if (nodesJson == null || nodesJson.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+ try {
+ List nodes = objectMapper.readValue(nodesJson,
+ objectMapper.getTypeFactory().constructCollectionType(List.class, ApprovalNode.class));
+ return nodes == null ? Collections.emptyList() : nodes;
+ } catch (Exception e) {
+ log.warn("审批流程定义节点解析失败", e);
+ throw new BusinessException("审批流程定义格式错误");
+ }
+ }
+
+ private String writeNodes(List nodes) {
+ try {
+ return objectMapper.writeValueAsString(nodes);
+ } catch (Exception e) {
+ throw new BusinessException("流程节点格式错误");
+ }
+ }
+
+ private String formatTime(ApprovalFlowDefinition definition) {
+ return definition.getCreateTime() == null ? null : definition.getCreateTime().format(TIME_FORMAT);
+ }
+
+ private ApprovalFlowDefinition findLatest(String flowCode) {
+ return flowDefinitionMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ApprovalFlowDefinition::getFlowCode, flowCode)
+ .orderByDesc(ApprovalFlowDefinition::getVersion)
+ .last("LIMIT 1"));
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java
new file mode 100644
index 0000000..e9d6307
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java
@@ -0,0 +1,315 @@
+package com.gxwebsoft.approval.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.document.ApprovalDocumentRegistry;
+import com.gxwebsoft.approval.document.ApprovalDocumentType;
+import com.gxwebsoft.approval.support.ApprovalFieldSpec;
+import com.gxwebsoft.approval.support.ApprovalNode;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 流程节点校验(ADR 0015):把「能反序列化」升级为「能跑」。
+ *
+ * 节点模型开启了 ignoreUnknown,拼错的字段会被静默丢弃,所以字段名白名单必须
+ * 在原始 JSON 树上做,再转成节点对象。校验不通过一律抛 BusinessException,由
+ * GlobalExceptionHandler 转成 code=1 的 ApiResult。
+ */
+@Service
+public class ApprovalNodeValidator {
+
+ /** 节点对象允许的字段名。新增节点配置字段时两处同步:ApprovalNode 与此处。 */
+ private static final List NODE_FIELDS = List.of(
+ "key", "type", "resolver", "orgCode", "name", "groups", "writableFields");
+
+ private static final List GROUP_FIELDS = List.of(
+ "key", "name", "resolver", "orgCode");
+
+ private static final List FIELD_FIELDS = List.of("field", "required");
+
+ private static final List TYPES = List.of(
+ ApprovalConstants.NODE_APPROVE,
+ ApprovalConstants.NODE_COUNTERSIGN,
+ ApprovalConstants.NODE_DISPATCH,
+ ApprovalConstants.NODE_CONFIRM,
+ ApprovalConstants.NODE_ARCHIVE);
+
+ /** 按固定组织编码取负责人的解析器,必须配 orgCode。 */
+ private static final List ORG_RESOLVERS = List.of(
+ ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE,
+ ApprovalConstants.RESOLVER_ORG_DEPUTY_BY_CODE);
+
+ private static final List RESOLVERS = List.of(
+ ApprovalConstants.RESOLVER_REPORTER_ORG_LEADER,
+ ApprovalConstants.RESOLVER_REPORTER_ORG_DEPUTY,
+ ApprovalConstants.RESOLVER_ACCEPT_ORG_LEADER,
+ ApprovalConstants.RESOLVER_ACCEPT_ORG_DEPUTY,
+ ApprovalConstants.RESOLVER_ARCHIVIST,
+ ApprovalConstants.RESOLVER_REPORTER,
+ ApprovalConstants.RESOLVER_REPORTER_AND_DISPATCH_TARGET,
+ ApprovalConstants.RESOLVER_ACCEPT_ORG_MEMBERS,
+ ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE,
+ ApprovalConstants.RESOLVER_ORG_DEPUTY_BY_CODE,
+ ApprovalConstants.RESOLVER_ROLE_CASHIER,
+ ApprovalConstants.RESOLVER_ROLE_TYPIST,
+ ApprovalConstants.RESOLVER_ROLE_ISSUER,
+ ApprovalConstants.RESOLVER_ROLE_GENERAL_MANAGER,
+ ApprovalConstants.RESOLVER_ROLE_RELATED_DEPT,
+ ApprovalConstants.RESOLVER_ROLE_ARCHIVIST);
+
+ private static final int MAX_KEY_LENGTH = 40;
+
+ private static final int MAX_NAME_LENGTH = 50;
+
+ @Resource
+ private ObjectMapper objectMapper;
+
+ @Resource
+ private ApprovalDocumentRegistry documentRegistry;
+
+ @Resource
+ private ApprovalDirectoryService directoryService;
+
+ /** 前端下拉用的解析器清单。 */
+ public List resolvers() {
+ return RESOLVERS;
+ }
+
+ /**
+ * 校验节点数组并返回解析后的节点。
+ *
+ * @param flowCode 流程编码,必须已在 ApprovalDocumentRegistry 注册
+ * @param nodes 前端提交的原始节点 JSON 数组
+ */
+ public List validate(String flowCode, JsonNode nodes) {
+ if (nodes == null || nodes.isNull() || !nodes.isArray() || nodes.isEmpty()) {
+ throw new BusinessException("流程节点不能为空");
+ }
+ ApprovalDocumentType type = documentRegistry.require(flowCode);
+ checkFields(nodes);
+ List parsed = parse(nodes);
+ checkStructure(type, parsed);
+ return parsed;
+ }
+
+ /** 原始 JSON 层面的字段白名单:未知字段直接拒绝,不做静默忽略。 */
+ private void checkFields(JsonNode nodes) {
+ for (JsonNode node : nodes) {
+ if (!node.isObject()) {
+ throw new BusinessException("流程节点格式错误");
+ }
+ requireKnownFields(node, NODE_FIELDS, "节点");
+ JsonNode groups = node.get("groups");
+ if (groups != null && !groups.isNull()) {
+ if (!groups.isArray()) {
+ throw new BusinessException("会签组格式错误");
+ }
+ for (JsonNode group : groups) {
+ if (!group.isObject()) {
+ throw new BusinessException("会签组格式错误");
+ }
+ requireKnownFields(group, GROUP_FIELDS, "会签组");
+ }
+ }
+ JsonNode writableFields = node.get("writableFields");
+ if (writableFields != null && !writableFields.isNull()) {
+ if (!writableFields.isArray()) {
+ throw new BusinessException("可写字段格式错误");
+ }
+ for (JsonNode field : writableFields) {
+ if (!field.isObject()) {
+ throw new BusinessException("可写字段格式错误");
+ }
+ requireKnownFields(field, FIELD_FIELDS, "可写字段");
+ }
+ }
+ }
+ }
+
+ private void requireKnownFields(JsonNode node, List allowed, String what) {
+ node.fieldNames().forEachRemaining(name -> {
+ if (!allowed.contains(name)) {
+ throw new BusinessException(what + "包含不支持的字段:" + name);
+ }
+ });
+ }
+
+ private List parse(JsonNode nodes) {
+ try {
+ List parsed = objectMapper.convertValue(nodes,
+ objectMapper.getTypeFactory().constructCollectionType(List.class, ApprovalNode.class));
+ if (parsed == null || parsed.isEmpty()) {
+ throw new BusinessException("流程节点不能为空");
+ }
+ return parsed;
+ } catch (BusinessException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new BusinessException("流程节点格式错误");
+ }
+ }
+
+ private void checkStructure(ApprovalDocumentType type, List nodes) {
+ Set nodeKeys = new HashSet<>();
+ Set fieldKeys = fieldKeys(type);
+ int approveCount = 0;
+ int dispatchCount = 0;
+ for (int i = 0; i < nodes.size(); i++) {
+ ApprovalNode node = nodes.get(i);
+ String where = "第 " + (i + 1) + " 个节点";
+ if (isBlank(node.getKey())) {
+ throw new BusinessException(where + "缺少节点标识");
+ }
+ if (node.getKey().length() > MAX_KEY_LENGTH) {
+ throw new BusinessException(where + "节点标识过长");
+ }
+ if (!nodeKeys.add(node.getKey())) {
+ throw new BusinessException("节点标识重复:" + node.getKey());
+ }
+ if (!TYPES.contains(node.getType())) {
+ throw new BusinessException(where + "节点类型不合法:" + node.getType());
+ }
+ if (isBlank(node.getName())) {
+ throw new BusinessException(where + "缺少节点名称");
+ }
+ if (node.getName().length() > MAX_NAME_LENGTH) {
+ throw new BusinessException(where + "节点名称过长");
+ }
+ String nodeType = node.getType();
+ if (ApprovalConstants.NODE_APPROVE.equals(nodeType)
+ || ApprovalConstants.NODE_COUNTERSIGN.equals(nodeType)
+ || ApprovalConstants.NODE_DISPATCH.equals(nodeType)) {
+ approveCount++;
+ }
+ if (ApprovalConstants.NODE_DISPATCH.equals(nodeType)) {
+ dispatchCount++;
+ }
+ if (ApprovalConstants.NODE_COUNTERSIGN.equals(nodeType)) {
+ checkGroups(where, node);
+ } else {
+ if (node.getGroups() != null && !node.getGroups().isEmpty()) {
+ throw new BusinessException(where + "只有会签节点可以配置会签组");
+ }
+ checkResolver(where, node.getResolver(), node.getOrgCode());
+ }
+ checkWritableFields(where, nodeType, node, fieldKeys);
+ }
+ if (approveCount == 0) {
+ throw new BusinessException("流程至少需要一个审批节点");
+ }
+ if (dispatchCount > 1) {
+ throw new BusinessException("流程至多一个派单节点");
+ }
+ ApprovalNode last = nodes.get(nodes.size() - 1);
+ if (!ApprovalConstants.NODE_ARCHIVE.equals(last.getType())) {
+ throw new BusinessException("流程最后一个节点必须是归档节点");
+ }
+ for (int i = 0; i < nodes.size() - 1; i++) {
+ if (ApprovalConstants.NODE_ARCHIVE.equals(nodes.get(i).getType())) {
+ throw new BusinessException("归档节点只能位于流程末尾");
+ }
+ }
+ }
+
+ private void checkGroups(String where, ApprovalNode node) {
+ List groups = node.getGroups();
+ if (groups == null || groups.isEmpty()) {
+ throw new BusinessException(where + "是会签节点,必须配置会签组");
+ }
+ Set keys = new HashSet<>();
+ for (int i = 0; i < groups.size(); i++) {
+ ApprovalNode.ApprovalGroup group = groups.get(i);
+ String groupWhere = where + "第 " + (i + 1) + " 个会签组";
+ if (isBlank(group.getKey())) {
+ throw new BusinessException(groupWhere + "缺少组标识");
+ }
+ if (!keys.add(group.getKey())) {
+ throw new BusinessException(where + "会签组标识重复:" + group.getKey());
+ }
+ if (isBlank(group.getName())) {
+ throw new BusinessException(groupWhere + "缺少组名称");
+ }
+ checkResolver(groupWhere, group.getResolver(), group.getOrgCode());
+ }
+ }
+
+ private void checkResolver(String where, String resolver, String orgCode) {
+ if (isBlank(resolver)) {
+ throw new BusinessException(where + "缺少审批人解析器");
+ }
+ if (!RESOLVERS.contains(resolver)) {
+ throw new BusinessException(where + "审批人解析器不存在:" + resolver);
+ }
+ if (ORG_RESOLVERS.contains(resolver)) {
+ if (isBlank(orgCode)) {
+ throw new BusinessException(where + "解析器 " + resolver + " 必须指定组织编码");
+ }
+ requireOrganization(where, orgCode);
+ } else if (!isBlank(orgCode)) {
+ throw new BusinessException(where + "只有按组织取负责人的解析器可以指定组织编码");
+ }
+ }
+
+ /** 组织编码必须在 gxtyzx_core 的组织表里存在(事务外查询,ADR 0015)。 */
+ private void requireOrganization(String where, String orgCode) {
+ Object organization;
+ try {
+ organization = directoryService.getOrganizationByCode(orgCode);
+ } catch (Exception e) {
+ organization = null;
+ }
+ if (organization == null) {
+ throw new BusinessException(where + "组织编码不存在:" + orgCode);
+ }
+ }
+
+ private void checkWritableFields(String where, String nodeType, ApprovalNode node,
+ Set fieldKeys) {
+ List fields = node.getWritableFields();
+ if (fields == null || fields.isEmpty()) {
+ return;
+ }
+ if (!ApprovalConstants.NODE_APPROVE.equals(nodeType)
+ && !ApprovalConstants.NODE_COUNTERSIGN.equals(nodeType)
+ && !ApprovalConstants.NODE_CONFIRM.equals(nodeType)) {
+ throw new BusinessException(where + "不允许配置回填字段");
+ }
+ Set seen = new HashSet<>();
+ for (ApprovalNode.WritableField field : fields) {
+ if (isBlank(field.getField())) {
+ throw new BusinessException(where + "回填字段缺少字段名");
+ }
+ if (!seen.add(field.getField())) {
+ throw new BusinessException(where + "回填字段重复:" + field.getField());
+ }
+ if (!fieldKeys.contains(field.getField())) {
+ throw new BusinessException(where + "回填字段不存在:" + field.getField());
+ }
+ }
+ }
+
+ private Set fieldKeys(ApprovalDocumentType type) {
+ Set keys = new HashSet<>();
+ List specs = type.fieldSpecs();
+ if (specs == null) {
+ return keys;
+ }
+ for (ApprovalFieldSpec spec : specs) {
+ if (spec.getKey() != null) {
+ keys.add(spec.getKey());
+ }
+ }
+ return keys;
+ }
+
+ private boolean isBlank(String text) {
+ return text == null || text.trim().isEmpty();
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVO.java
new file mode 100644
index 0000000..9692d96
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVO.java
@@ -0,0 +1,50 @@
+package com.gxwebsoft.approval.vo;
+
+import com.gxwebsoft.approval.support.ApprovalFieldSpec;
+import com.gxwebsoft.approval.support.ApprovalNode;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 流程配置列表项:当前最新版本节点 + 代码里的出厂模板(ADR 0015)。
+ */
+@Data
+@ApiModel(description = "流程定义当前版本")
+public class ApprovalFlowVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("流程编码")
+ private String flowCode;
+
+ @ApiModelProperty("流程名称")
+ private String flowName;
+
+ @ApiModelProperty("最新版本号;尚未发布时为空")
+ private Integer version;
+
+ @ApiModelProperty("最新版本定义id;尚未发布时为空")
+ private Long definitionId;
+
+ @ApiModelProperty("最新版本发布时间")
+ private String publishTime;
+
+ @ApiModelProperty("最新版本节点")
+ private List nodes = new ArrayList<>();
+
+ @ApiModelProperty("代码里的出厂模板节点")
+ private List factoryNodes = new ArrayList<>();
+
+ @ApiModelProperty("代码基线版本号")
+ private Integer factoryVersion;
+
+ @ApiModelProperty("可写字段元数据")
+ private List fieldSpecs = new ArrayList<>();
+
+ @ApiModelProperty("可用审批人解析器")
+ private List resolvers = new ArrayList<>();
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVersionVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVersionVO.java
new file mode 100644
index 0000000..2dbc03d
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalFlowVersionVO.java
@@ -0,0 +1,34 @@
+package com.gxwebsoft.approval.vo;
+
+import com.gxwebsoft.approval.support.ApprovalNode;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 流程定义历史版本(只读,ADR 0015)。
+ */
+@Data
+@ApiModel(description = "流程定义历史版本")
+public class ApprovalFlowVersionVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("定义id")
+ private Long definitionId;
+
+ @ApiModelProperty("版本号")
+ private Integer version;
+
+ @ApiModelProperty("流程名称")
+ private String name;
+
+ @ApiModelProperty("发布时间")
+ private String publishTime;
+
+ @ApiModelProperty("节点")
+ private List nodes = new ArrayList<>();
+}
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java
new file mode 100644
index 0000000..6df8f13
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java
@@ -0,0 +1,174 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 流程配置接口(ADR 0015):发布新版本、字段白名单、权限边界。
+ *
+ * 发布会在 approval_flow_definition 插入新行,tearDown 按 id 水位删除,避免污染测试库。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalFlowConfigIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+
+ private static final String FLOW = "completion";
+
+ private static final String LIST = "approval:flow:list";
+
+ private static final String PUBLISH = "approval:flow:publish";
+
+ private static final String NODES = "["
+ + "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\","
+ + "\"resolver\":\"reporterOrgLeader\"},"
+ + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"name\":\"存档\","
+ + "\"resolver\":\"reporter\"},"
+ + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"name\":\"归档\","
+ + "\"resolver\":\"archivist\"}]";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private DataSource dataSource;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ private long maxDefinitionId;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery(
+ "SELECT COALESCE(MAX(id), 0) FROM approval_flow_definition WHERE flow_code = '"
+ + FLOW + "'")) {
+ resultSet.next();
+ maxDefinitionId = resultSet.getLong(1);
+ }
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement()) {
+ statement.executeUpdate("DELETE FROM approval_flow_definition WHERE flow_code = '"
+ + FLOW + "' AND id > " + maxDefinitionId);
+ }
+ }
+
+ @Test
+ void listReturnsRegisteredFlowsWithFactoryNodes() throws Exception {
+ mockMvc.perform(get("/api/approval/flow")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.admin(LIST))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data[?(@.flowCode == 'completion')]").isNotEmpty())
+ .andExpect(jsonPath("$.data[?(@.flowCode == 'completion')].resolvers").isArray());
+ }
+
+ @Test
+ void publishCreatesHigherVersionAndShowsNewNodes() throws Exception {
+ int first = publish(NODES);
+ int second = publish(NODES);
+ assertTrue(second > first, "第二次发布版本号应更大");
+
+ mockMvc.perform(get("/api/approval/flow")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.admin(LIST))))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data[?(@.flowCode == 'completion')].nodes[0].key")
+ .value("deptLeader"));
+
+ mockMvc.perform(get("/api/approval/flow/history")
+ .param("flowCode", FLOW)
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.admin(LIST))))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data[0].version").value(second))
+ .andExpect(jsonPath("$.data[1].version").value(first));
+ }
+
+ @Test
+ void unknownNodeFieldIsRejected() throws Exception {
+ String nodes = "[{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\","
+ + "\"resolver\":\"reporterOrgLeader\",\"resolvr\":\"typo\"}]";
+ mockMvc.perform(post("/api/approval/flow/publish")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(nodes))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.admin(LIST, PUBLISH))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value("节点包含不支持的字段:resolvr"));
+ }
+
+ @Test
+ void nodesMustEndWithArchive() throws Exception {
+ String nodes = "[{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\","
+ + "\"resolver\":\"reporterOrgLeader\"}]";
+ mockMvc.perform(post("/api/approval/flow/publish")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(nodes))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.admin(LIST, PUBLISH))))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value("流程最后一个节点必须是归档节点"));
+ }
+
+ @Test
+ void listPermissionAloneCannotPublish() throws Exception {
+ mockMvc.perform(post("/api/approval/flow/publish")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(NODES))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.admin(LIST))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(403));
+ }
+
+ private int publish(String nodes) throws Exception {
+ MvcResult result = mockMvc.perform(post("/api/approval/flow/publish")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(nodes))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.admin(LIST, PUBLISH))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn();
+ String response = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
+ return objectMapper.readTree(response).path("data").path("version").asInt();
+ }
+
+ private String body(String nodes) {
+ return "{\"flowCode\":\"" + FLOW + "\",\"nodes\":" + nodes + "}";
+ }
+}