diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/FormPermissionConfig.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/FormPermissionConfig.java new file mode 100644 index 000000000..0567e1747 --- /dev/null +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/FormPermissionConfig.java @@ -0,0 +1,15 @@ +package com.pig4cloud.pigx.flow.dto; + +import lombok.Data; + +/** 打开动态表单时查询字段权限的 REST 配置。 */ +@Data +public class FormPermissionConfig { + + /** 是否启用 REST 权限查询;null 或 false 表示使用静态权限。 */ + private Boolean enabled; + + /** 已启用时必填的完整 HTTP(S) 地址,不能包含账号或片段。 */ + private String url; + +} diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java index 3f5804035..2c58db2e2 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java @@ -129,6 +129,9 @@ public class Node { */ private Map formPerms; + /** REST 返回值按字段覆盖静态权限;未启用时沿用 formPerms。 */ + private FormPermissionConfig formPermissionConfig; + /** * 无执行人处理配置 定义当节点找不到执行人时的处理策略 */ diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/OpenProcessFormDto.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/OpenProcessFormDto.java new file mode 100644 index 000000000..859cddda1 --- /dev/null +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/OpenProcessFormDto.java @@ -0,0 +1,22 @@ +package com.pig4cloud.pigx.flow.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.Map; + +/** 打开发起表单;initialData 为草稿与业务入口合并后的初始数据。 */ +@Data +public class OpenProcessFormDto { + + /** 待发起流程的业务标识,必填。 */ + @NotBlank + private String flowId; + + /** 可选初始数据,由调用方按草稿低于入口参数的优先级合并,覆盖表单默认值。 */ + private Map initialData; + + /** 恢复草稿时校验发布版本,防止将旧稿数据送给新版本的权限接口。 */ + private String draftVersion; + +} diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessVO.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessVO.java index 567e53678..8a0ec8e1a 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessVO.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessVO.java @@ -34,6 +34,9 @@ public class ProcessVO extends Process { */ private Map variableMap; + /** 打开发起表单时合并默认值、草稿和入口数据后的初始值。 */ + private Map formData; + /** * 发起人节点的表单权限配置 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessController.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessController.java index 47c267493..780bd03eb 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessController.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessController.java @@ -1,10 +1,13 @@ package com.pig4cloud.pigx.flow.controller; import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.flow.dto.OpenProcessFormDto; import com.pig4cloud.pigx.flow.entity.Process; import com.pig4cloud.pigx.flow.service.IProcessService; import com.pig4cloud.pigx.flow.vo.ProcessVO; import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; /** @@ -41,6 +44,18 @@ public class ProcessController { return processService.getDetail(flowId); } + /** + * 合并表单默认值与调用方初始数据,并查询发起节点的字段权限。 + * @param request 发起请求,不可为 null;初始数据和草稿版本可省略 + * @return 包含初始数据和权限的流程详情;草稿版本不匹配时返回失败结果 + * @throws IllegalStateException REST 权限加载失败时抛出,阻止继续填写 + */ + @Operation(summary = "打开发起表单", description = "合并初始数据并同步查询节点字段权限") + @PostMapping("openForm") + public R openForm(@Valid @RequestBody OpenProcessFormDto request) { + return processService.openForm(request); + } + /** * 创建新流程定义 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/FormPermissionService.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/FormPermissionService.java new file mode 100644 index 000000000..d4c7e53cf --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/FormPermissionService.java @@ -0,0 +1,141 @@ +package com.pig4cloud.pigx.flow.service; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.pig4cloud.pigx.flow.constant.NodeTypeEnum; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant.FormPermClass; +import com.pig4cloud.pigx.flow.dto.FormPermissionConfig; +import com.pig4cloud.pigx.flow.dto.Node; +import com.pig4cloud.pigx.flow.dto.ProcessNodeRecordAssignUserParamDto; +import com.pig4cloud.pigx.flow.entity.Process; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** 同步查询动态表单权限;调用失败时不回退、不重试,也不修改静态配置。 */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FormPermissionService { + + private final ObjectMapper objectMapper; + + /** + * 校验已启用的 REST 权限地址;未配置或未启用时跳过校验。 + * @param config 节点 REST 权限配置,可为 null + * @throws IllegalArgumentException 地址不是完整 HTTP(S) URL,或含账号、片段时抛出 + */ + public static void validateConfig(FormPermissionConfig config) { + if (Objects.isNull(config) || !Boolean.TRUE.equals(config.getEnabled())) return; + try { + URI uri = URI.create(StrUtil.trim(config.getUrl())); + if ((!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) + || StrUtil.isBlank(uri.getHost()) || Objects.nonNull(uri.getUserInfo()) + || Objects.nonNull(uri.getFragment())) { + throw new IllegalArgumentException(); + } + } + catch (Exception exception) { + throw new IllegalArgumentException("REST 表单权限地址必须是完整的 HTTP(S) URL,且不能包含账号或片段"); + } + } + + /** + * 同步查询字段权限并覆盖静态权限副本,未返回的字段保持静态值,抄送编辑权限转为只读。 + * 未启用 REST 或使用自定义业务表单时返回静态权限副本;失败不重试、不回退。 + * @param node 当前节点及其 REST 配置,不可为 null + * @param process 提供流程标识、表单配置和合法字段的流程定义,不可为 null + * @param staticPermissions 静态权限,可为 null;原映射不会被修改 + * @param context 当前用户和表单数据上下文,不可为 null;尚未生成的实例、任务标识可为空 + * @return 合并后的独立权限映射,不为 null + * @throws IllegalStateException 地址、外部请求、响应或表单元数据无效时阻止表单加载 + */ + public Map resolve(Node node, Process process, Map staticPermissions, + ProcessNodeRecordAssignUserParamDto context) { + Map permissions = new HashMap<>(); + if (Objects.nonNull(staticPermissions)) permissions.putAll(staticPermissions); + FormPermissionConfig config = node.getFormPermissionConfig(); + if (Objects.isNull(config) || !Boolean.TRUE.equals(config.getEnabled())) return permissions; + String requestId = UUID.randomUUID().toString(); + try { + if (StrUtil.isNotBlank(process.getFormConfig()) + && objectMapper.readTree(process.getFormConfig()).path("formType").asInt() == 20) { + return permissions; + } + validateConfig(config); + // 沿用任务完成 WebHook 的字段与类型,不伪造任务完成类型或审批意见。 + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("flowId", process.getFlowId()); + payload.put("processDefinitionId", context.getProcessDefinitionId()); + payload.put("processInstanceId", context.getProcessInstanceId()); + payload.put("nodeId", node.getId()); + payload.put("nodeName", node.getName()); + payload.put("taskId", context.getTaskId()); + payload.put("executionId", context.getExecutionId()); + payload.put("userId", context.getUserId()); + payload.put("data", context.getData()); + payload.put("localData", context.getLocalData()); + try (HttpResponse response = HttpRequest.post(config.getUrl().trim()) + .header("X-Flow-Event-Id", requestId) + .header("Idempotency-Key", requestId) + .body(objectMapper.writeValueAsString(payload)) + .timeout(10000) + .setFollowRedirects(false) + .execute()) { + if (response.getStatus() < 200 || response.getStatus() >= 300) { + throw new IllegalStateException("HTTP " + response.getStatus()); + } + JsonNode result = objectMapper.readTree(response.body()); + if (Objects.isNull(result) || !result.path("code").isIntegralNumber() || result.path("code").asLong() != 0 + || !result.path("data").isObject()) { + throw new IllegalStateException("响应必须为成功的 R>"); + } + Set fields = new HashSet<>(); + collectFields(objectMapper.readTree(process.getFormItems()), fields); + var entries = result.get("data").fields(); + while (entries.hasNext()) { + var entry = entries.next(); + JsonNode value = entry.getValue(); + if (!fields.contains(entry.getKey()) || !value.isTextual() + || !Set.of(FormPermClass.EDIT, FormPermClass.READ, FormPermClass.HIDE).contains(value.textValue())) { + throw new IllegalStateException("响应包含未知字段或非法权限值"); + } + permissions.put(entry.getKey(), value.textValue()); + } + } + if (NodeTypeEnum.CC.getValue().equals(node.getType())) { + permissions.replaceAll((field, permission) -> FormPermClass.EDIT.equals(permission) ? FormPermClass.READ + : permission); + } + return permissions; + } + catch (Exception exception) { + // 不记录业务数据、接口响应或 URL 中可能携带的凭据。 + log.warn("REST 表单权限加载失败,请求 {},流程 {},节点 {},原因 {}", requestId, process.getFlowId(), node.getId(), + exception.getClass().getSimpleName()); + throw new IllegalStateException("表单权限加载失败,无法填写或办理"); + } + } + + private void collectFields(JsonNode items, Set fields) { + if (Objects.isNull(items) || !items.isArray()) return; + for (JsonNode item : items) { + String field = item.path("field").asText(); + if (StrUtil.isNotBlank(field)) fields.add(field); + collectFields(item.get("children"), fields); + } + } + +} diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/IProcessService.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/IProcessService.java index abfa1f3c7..aa460c043 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/IProcessService.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/IProcessService.java @@ -3,6 +3,7 @@ package com.pig4cloud.pigx.flow.service; import com.baomidou.mybatisplus.extension.service.IService; import com.pig4cloud.pigx.common.core.util.R; import com.pig4cloud.pigx.flow.entity.Process; +import com.pig4cloud.pigx.flow.dto.OpenProcessFormDto; import com.pig4cloud.pigx.flow.vo.ProcessVO; /** @@ -26,6 +27,14 @@ public interface IProcessService extends IService { */ R getDetail(String flowId); + /** + * 合并表单默认值与调用方初始数据,并查询发起节点的字段权限。 + * @param request 发起请求,不可为 null;初始数据和草稿版本可省略 + * @return 包含初始数据和权限的流程详情;草稿版本不匹配时返回失败结果 + * @throws IllegalStateException REST 权限加载失败时抛出,阻止继续填写 + */ + R openForm(OpenProcessFormDto request); + /** * 根据流程ID获取流程定义实体 * diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessCopyServiceImpl.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessCopyServiceImpl.java index d061af43d..3d460b343 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessCopyServiceImpl.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessCopyServiceImpl.java @@ -4,13 +4,16 @@ import cn.hutool.core.lang.Dict; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.ObjectMapper; import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.common.security.util.SecurityUtils; import com.pig4cloud.pigx.flow.dto.Node; +import com.pig4cloud.pigx.flow.dto.ProcessNodeRecordAssignUserParamDto; import com.pig4cloud.pigx.flow.entity.Process; import com.pig4cloud.pigx.flow.entity.ProcessCopy; import com.pig4cloud.pigx.flow.mapper.ProcessCopyMapper; import com.pig4cloud.pigx.flow.service.IProcessCopyService; import com.pig4cloud.pigx.flow.service.IProcessNodeDataService; import com.pig4cloud.pigx.flow.service.IProcessService; +import com.pig4cloud.pigx.flow.service.FormPermissionService; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import org.springframework.stereotype.Service; @@ -46,6 +49,8 @@ public class ProcessCopyServiceImpl extends ServiceImpl @@ -93,13 +98,22 @@ public class ProcessCopyServiceImpl extends ServiceImpl impl private final FlowStatusEventPublisher flowStatusEventPublisher; + private final FormPermissionService formPermissionService; + /** * 获取流程详细信息 *

@@ -89,6 +98,65 @@ public class ProcessServiceImpl extends ServiceImpl impl return R.ok(processVO); } + /** + * 合并表单默认值和调用方初始数据,并同步加载发起节点的字段权限。 + * @param request 发起请求,不可为 null;initialData、draftVersion 可省略 + * @return 带初始数据及权限的流程;草稿版本不匹配时返回失败结果且不查询 REST + * @throws IllegalStateException REST 权限加载失败时抛出,阻止继续填写 + * @throws com.fasterxml.jackson.core.JsonProcessingException 表单元数据无法解析时抛出 + */ + @Override + @SneakyThrows + public R openForm(OpenProcessFormDto request) { + ProcessVO process = getProcessVO(request.getFlowId()); + if (Objects.nonNull(request.getDraftVersion()) + && !request.getDraftVersion().equals(Objects.toString(process.getUniqueId(), StrUtil.EMPTY))) { + return R.failed("流程已发布新版本,该草稿暂不能恢复。请保留草稿并重新填写新申请。"); + } + Node node = objectMapper.readValue(process.getProcess(), Node.class); + Map formData = new LinkedHashMap<>(); + boolean nestedPaths = StrUtil.isNotBlank(process.getFormConfig()) + && "buzzform".equals(objectMapper.readTree(process.getFormConfig()).path("engine").asText()); + collectInitialValues(objectMapper.readTree(process.getFormItems()), formData, nestedPaths); + if (Objects.nonNull(request.getInitialData())) formData.putAll(request.getInitialData()); + process.setFormData(formData); + ProcessNodeRecordAssignUserParamDto context = new ProcessNodeRecordAssignUserParamDto(); + var definition = repositoryService.createProcessDefinitionQuery() + .deploymentId(process.getUniqueId()) + .singleResult(); + if (Objects.nonNull(definition)) context.setProcessDefinitionId(definition.getId()); + context.setUserId(SecurityUtils.getUser().getId()); + context.setData(objectMapper.writeValueAsString(formData)); + process.setFormPerms(formPermissionService.resolve(node, process, process.getFormPerms(), context)); + return R.ok(process); + } + + /** 两种表单引擎共用 formItems 元数据,React 的嵌套字段路径需还原成对象。 */ + @SuppressWarnings("unchecked") + private void collectInitialValues(JsonNode items, Map values, boolean nestedPaths) { + if (Objects.isNull(items) || !items.isArray()) return; + for (JsonNode item : items) { + String field = item.path("field").asText(); + JsonNode value = item.get("value"); + if (Objects.isNull(value)) value = item.path("props").get("value"); + if (StrUtil.isNotBlank(field) && Objects.nonNull(value)) { + String[] path = nestedPaths ? StrUtil.splitToArray(field, StrUtil.C_DOT) : new String[] { field }; + Map target = values; + for (int i = 0; i < path.length - 1; i++) { + Object child = target.computeIfAbsent(path[i], key -> new LinkedHashMap()); + // 数组项的默认值由表单引擎逐项处理,不能写到根级字段。 + if (!(child instanceof Map)) { + target = null; + break; + } + target = (Map) child; + } + if (Objects.nonNull(target)) target.put(path[path.length - 1], objectMapper.convertValue(value, Object.class)); + } + collectInitialValues(item.get("children"), values, nestedPaths); + } + } + /** * 获取流程视图对象 *

@@ -334,6 +402,7 @@ public class ProcessServiceImpl extends ServiceImpl impl private void validateDeliveryConfig(Node node) { if (java.util.Objects.isNull(node)) return; + FormPermissionService.validateConfig(node.getFormPermissionConfig()); if (java.util.Objects.nonNull(node.getReminderMinutes()) && (node.getReminderMinutes() < 0 || node.getReminderMinutes() > 525600)) throw new IllegalArgumentException("超时提醒必须为 0 到 525600 分钟"); diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java index 53daa8f9b..72f6fc8fa 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java @@ -87,6 +87,8 @@ public class TaskServiceImpl implements ITaskService { private final ProcessTaskAddSignRecordMapper processTaskAddSignRecordMapper; + private final FormPermissionService formPermissionService; + /** * 查询首页数据看板 *

@@ -307,6 +309,20 @@ public class TaskServiceImpl implements ITaskService { return R.failed("流程不存在"); } + Map formPerms = NodeUtil.publishedFormPermissions(node, oaForms.getFormItems()); + if (!view && task.isPresent() && Objects.nonNull(node.getFormPermissionConfig()) + && Boolean.TRUE.equals(node.getFormPermissionConfig().getEnabled())) { + ProcessNodeRecordAssignUserParamDto context = new ProcessNodeRecordAssignUserParamDto(); + context.setProcessDefinitionId(taskResultDto.getProcessDefinitionId()); + context.setProcessInstanceId(taskResultDto.getProcessInstanceId()); + context.setTaskId(taskId); + context.setExecutionId(task.get().getExecutionId()); + context.setUserId(userId); + context.setData(objectMapper.writeValueAsString(paramMap)); + context.setLocalData(objectMapper.writeValueAsString(taskService.getVariablesLocal(taskId))); + formPerms = formPermissionService.resolve(node, oaForms, formPerms, context); + } + Dict set = Dict.create() .set("taskId", taskId) .set("nodeId", taskResultDto.getNodeId()) @@ -320,7 +336,7 @@ public class TaskServiceImpl implements ITaskService { .set("formItems", oaForms.getFormItems()) .set("formData", paramMap) .set("formConfig", oaForms.getFormConfig()) - .set("formPerms", NodeUtil.publishedFormPermissions(node, oaForms.getFormItems())); + .set("formPerms", formPerms); return R.ok(set); } diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/FormPermissionServiceTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/FormPermissionServiceTests.java new file mode 100644 index 000000000..db4ac9a17 --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/FormPermissionServiceTests.java @@ -0,0 +1,154 @@ +package com.pig4cloud.pigx.flow.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pig4cloud.pigx.common.security.service.PigxUser; +import com.pig4cloud.pigx.flow.dto.FormPermissionConfig; +import com.pig4cloud.pigx.flow.dto.Node; +import com.pig4cloud.pigx.flow.dto.OpenProcessFormDto; +import com.pig4cloud.pigx.flow.dto.ProcessNodeRecordAssignUserParamDto; +import com.pig4cloud.pigx.flow.entity.Process; +import com.pig4cloud.pigx.flow.service.impl.ProcessServiceImpl; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.flowable.engine.RepositoryService; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; + +class FormPermissionServiceTests { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void queriesWebhookAndOverlaysPermissionsWithoutMutatingStaticConfiguration() throws Exception { + var requests = new LinkedBlockingQueue(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/permissions", exchange -> { + try (exchange) { + requests.add(mapper.readTree(exchange.getRequestBody())); + byte[] body = "{\"code\":0,\"data\":{\"a\":\"H\",\"b\":\"E\"}}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + }); + server.start(); + try { + Node node = node(server); + Map original = Map.of("a", "E", "b", "R", "c", "R"); + node.setFormPerms(original); + ProcessNodeRecordAssignUserParamDto context = new ProcessNodeRecordAssignUserParamDto(); + context.setProcessDefinitionId("leave:1:definition"); + context.setUserId(1001L); + context.setData("{\"businessType\":2}"); + FormPermissionService service = new FormPermissionService(mapper); + Process process = process(); + process.setProcess(mapper.writeValueAsString(node)); + process.setUniqueId("deployment"); + process.setFormItems("[{\"field\":\"a\",\"value\":1},{\"field\":\"b\",\"value\":\"default\"},{\"field\":\"c\"}]"); + RepositoryService repository = mock(RepositoryService.class, RETURNS_DEEP_STUBS); + when(repository.createProcessDefinitionQuery().deploymentId("deployment").singleResult().getId()) + .thenReturn("leave:1:definition"); + ProcessServiceImpl opening = new ProcessServiceImpl(null, repository, null, mapper, null, null, service) { + @Override + public Process getByFlowId(String flowId) { + return process; + } + }; + try { + PigxUser user = mock(PigxUser.class); + when(user.getId()).thenReturn(1001L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, "unused", List.of())); + OpenProcessFormDto input = new OpenProcessFormDto(); + input.setFlowId("leave"); + input.setInitialData(Map.of("businessType", 2, "b", "draft")); + var opened = opening.openForm(input).getData(); + assertThat(opened.getFormPerms()).isEqualTo(Map.of("a", "H", "b", "E", "c", "R")); + assertThat(opened.getFormData()).containsEntry("a", 1).containsEntry("b", "draft") + .containsEntry("businessType", 2); + } + finally { + SecurityContextHolder.clearContext(); + } + JsonNode request = requests.poll(1, TimeUnit.SECONDS); + assertThat(request.get("data").isTextual()).isTrue(); + assertThat(mapper.readTree(request.get("data").asText()).get("businessType").asInt()).isEqualTo(2); + assertThat(request.get("userId").asLong()).isEqualTo(1001L); + assertThat(request.get("taskId").isNull()).isTrue(); + assertThat(request.has("taskType")).isFalse(); + assertThat(node.getFormPerms()).isEqualTo(original); + // 抄送仍然只读;关闭 REST 后不再外调。 + node.setType(2); + assertThat(service.resolve(node, process(), original, context)).containsEntry("b", "R"); + requests.clear(); + node.getFormPermissionConfig().setEnabled(false); + assertThat(service.resolve(node, process(), original, context)).isEqualTo(original); + assertThat(requests.poll(100, TimeUnit.MILLISECONDS)).isNull(); + } + finally { + server.stop(0); + } + } + + @Test + void invalidResponseBlocksTheWholeFormAndIsNotRetried() throws Exception { + var requests = new LinkedBlockingQueue(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/permissions", exchange -> { + try (exchange) { + requests.add(true); + byte[] body = "{\"code\":0,\"data\":{\"a\":\"H\",\"unknown\":\"E\"}}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + }); + server.start(); + try { + Map original = Map.of("a", "E"); + assertThatThrownBy(() -> new FormPermissionService(mapper).resolve(node(server), process(), original, + new ProcessNodeRecordAssignUserParamDto())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("表单权限加载失败,无法填写或办理"); + assertThat(requests.poll(1, TimeUnit.SECONDS)).isTrue(); + assertThat(requests.poll(100, TimeUnit.MILLISECONDS)).isNull(); + assertThat(original).containsEntry("a", "E"); + } + finally { + server.stop(0); + } + } + + private Node node(HttpServer server) { + FormPermissionConfig config = new FormPermissionConfig(); + config.setEnabled(true); + config.setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/permissions"); + Node node = new Node(); + node.setId("root"); + node.setType(0); + node.setName("发起人"); + node.setFormPermissionConfig(config); + return node; + } + + private Process process() { + Process process = new Process(); + process.setFlowId("leave"); + process.setFormConfig("{\"formType\":10}"); + process.setFormItems("[{\"field\":\"a\"},{\"field\":\"b\"},{\"field\":\"c\"}]"); + return process; + } + +} diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeVersionTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeVersionTests.java index 27dbb8ae2..5542df485 100644 --- a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeVersionTests.java +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeVersionTests.java @@ -175,7 +175,7 @@ class ProcessNodeVersionTests { ProcessDefinition a = publish("leave", 1001L, "R"); long before = jdbc.queryForObject("select count(*) from process_node_data", Long.class); ProcessServiceImpl target = new ProcessServiceImpl(mock(IProcessStarterService.class), - engine.getRepositoryService(), nodes, new ObjectMapper(), null, null) { + engine.getRepositoryService(), nodes, new ObjectMapper(), null, null, null) { @Override public Process getByFlowId(String flowId) { return null;