feat: 流程定义后台版本化发布(六类校验 + 集成测试,ADR 0015)

This commit is contained in:
2026-11-01 15:13:46 +08:00
parent ff3b9073ac
commit d670b26140
8 changed files with 872 additions and 0 deletions
+35
View File
@@ -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
);
@@ -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):后台编辑节点链并发布新版本。
*
* <p>只覆盖已注册的 flow_code,不能凭空新增单据类型;发布即发版,仅对之后提交的单据生效。</p>
*/
@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<ApprovalFlowVO>> list() {
return success(flowConfigService.list());
}
@PreAuthorize("hasAuthority('approval:flow:list')")
@ApiOperation("流程定义历史版本")
@GetMapping("/history")
public ApiResult<List<ApprovalFlowVersionVO>> history(@RequestParam("flowCode") String flowCode) {
return success(flowConfigService.history(flowCode));
}
@PreAuthorize("hasAuthority('approval:flow:publish')")
@ApiOperation("发布流程定义新版本")
@PostMapping("/publish")
public ApiResult<ApprovalFlowVO> publish(@RequestBody ApprovalFlowPublishParam param) {
return success(flowConfigService.publish(param));
}
}
@@ -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)。
*
* <p>节点用原始 JSON 树接收而不是 {@code List<ApprovalNode>}:节点模型开启了
* {@code ignoreUnknown},直接反序列化会把拼错的字段静默丢掉,校验必须看得见原始字段名。</p>
*/
@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;
}
@@ -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):在后台查看与发布流程定义。
*
* <p>不改 {@link ApprovalFlowService#getOrCreate}:后台发布的新版本号恒大于代码基线,
* 因此 getOrCreate 的「库中版本 >= 基线即用库中版本」判定自然选用后台配置。</p>
*
* <p>版本不可变:发布只是插入新行,已提交实例继续引用其 definition_id;新版本
* 只对之后提交的单据生效。保存即发布,没有草稿态。</p>
*/
@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<ApprovalFlowVO> list() {
List<ApprovalFlowVO> list = new ArrayList<>();
for (ApprovalDocumentType type : documentRegistry.all()) {
list.add(toVO(type, findLatest(type.flowCode())));
}
return list;
}
/** 历史版本(倒序,最多 HISTORY_LIMIT 条)。 */
public List<ApprovalFlowVersionVO> history(String flowCode) {
documentRegistry.require(flowCode);
List<ApprovalFlowDefinition> definitions = flowDefinitionMapper.selectList(
new LambdaQueryWrapper<ApprovalFlowDefinition>()
.eq(ApprovalFlowDefinition::getFlowCode, flowCode)
.orderByDesc(ApprovalFlowDefinition::getVersion)
.last("LIMIT " + HISTORY_LIMIT));
List<ApprovalFlowVersionVO> 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<ApprovalNode> 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<ApprovalFieldSpec> 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<ApprovalNode> parseNodes(String nodesJson) {
if (nodesJson == null || nodesJson.trim().isEmpty()) {
return Collections.emptyList();
}
try {
List<ApprovalNode> 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<ApprovalNode> 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<ApprovalFlowDefinition>()
.eq(ApprovalFlowDefinition::getFlowCode, flowCode)
.orderByDesc(ApprovalFlowDefinition::getVersion)
.last("LIMIT 1"));
}
}
@@ -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):把「能反序列化」升级为「能跑」。
*
* <p>节点模型开启了 ignoreUnknown,拼错的字段会被静默丢弃,所以字段名白名单必须
* 在原始 JSON 树上做,再转成节点对象。校验不通过一律抛 BusinessException,由
* GlobalExceptionHandler 转成 code=1 的 ApiResult。</p>
*/
@Service
public class ApprovalNodeValidator {
/** 节点对象允许的字段名。新增节点配置字段时两处同步:ApprovalNode 与此处。 */
private static final List<String> NODE_FIELDS = List.of(
"key", "type", "resolver", "orgCode", "name", "groups", "writableFields");
private static final List<String> GROUP_FIELDS = List.of(
"key", "name", "resolver", "orgCode");
private static final List<String> FIELD_FIELDS = List.of("field", "required");
private static final List<String> TYPES = List.of(
ApprovalConstants.NODE_APPROVE,
ApprovalConstants.NODE_COUNTERSIGN,
ApprovalConstants.NODE_DISPATCH,
ApprovalConstants.NODE_CONFIRM,
ApprovalConstants.NODE_ARCHIVE);
/** 按固定组织编码取负责人的解析器,必须配 orgCode。 */
private static final List<String> ORG_RESOLVERS = List.of(
ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE,
ApprovalConstants.RESOLVER_ORG_DEPUTY_BY_CODE);
private static final List<String> 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<String> resolvers() {
return RESOLVERS;
}
/**
* 校验节点数组并返回解析后的节点。
*
* @param flowCode 流程编码,必须已在 ApprovalDocumentRegistry 注册
* @param nodes 前端提交的原始节点 JSON 数组
*/
public List<ApprovalNode> 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<ApprovalNode> 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<String> allowed, String what) {
node.fieldNames().forEachRemaining(name -> {
if (!allowed.contains(name)) {
throw new BusinessException(what + "包含不支持的字段:" + name);
}
});
}
private List<ApprovalNode> parse(JsonNode nodes) {
try {
List<ApprovalNode> 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<ApprovalNode> nodes) {
Set<String> nodeKeys = new HashSet<>();
Set<String> 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<ApprovalNode.ApprovalGroup> groups = node.getGroups();
if (groups == null || groups.isEmpty()) {
throw new BusinessException(where + "是会签节点,必须配置会签组");
}
Set<String> 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<String> fieldKeys) {
List<ApprovalNode.WritableField> 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<String> 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<String> fieldKeys(ApprovalDocumentType type) {
Set<String> keys = new HashSet<>();
List<ApprovalFieldSpec> 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();
}
}
@@ -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<ApprovalNode> nodes = new ArrayList<>();
@ApiModelProperty("代码里的出厂模板节点")
private List<ApprovalNode> factoryNodes = new ArrayList<>();
@ApiModelProperty("代码基线版本号")
private Integer factoryVersion;
@ApiModelProperty("可写字段元数据")
private List<ApprovalFieldSpec> fieldSpecs = new ArrayList<>();
@ApiModelProperty("可用审批人解析器")
private List<String> resolvers = new ArrayList<>();
}
@@ -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<ApprovalNode> nodes = new ArrayList<>();
}
@@ -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):发布新版本、字段白名单、权限边界。
*
* <p>发布会在 approval_flow_definition 插入新行,tearDown 按 id 水位删除,避免污染测试库。</p>
*/
@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 + "}";
}
}