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 + "}"; + } +}