feat: 流程定义后台版本化发布(六类校验 + 集成测试,ADR 0015)
This commit is contained in:
@@ -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 + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user