Files
flow-center-service/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
T

996 lines
52 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.callback.CallbackAppSigner;
import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwCc;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwTask;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.engine.AssigneeRef;
import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BasePageResult;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* 托管接入件(/oapi/flow/**,V2.0 Part D #5 前置到 M1):
* list/{flowGuid}/apply/instance/callback/reg——出入参与 svc-hall FlowCenterClient 逐字对齐。
* 鉴权靠 appKey 签名(RequireAppSignFilter 强制 401);submitterId 按概览 §5.5 做本校校验
* (经 UserBase /oapi/user/byId,UserBase 不可达时 fail-closed 拒绝发起)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OapiFlowService {
/**
* 提交人本校校验开关(概览 §5.5 fail-closed 默认开)。本地开发无 UserBase 可达时置 false
* (application.yml flow-center.submitter-school-check.enabled),生产必须保持 true。
*/
@Value("${flow-center.submitter-school-check.enabled:true}")
private boolean submitterCheckEnabled;
/**
* 办件进度嵌入页前端基址(flow-center 自身的对外前端地址,本方案新增的第一个前端地址配置:
* 此前 UserMsg jumpPath 均为裸相对路径由接收方解析)。未配置即调签发 → fail-fast 业务异常,
* 与 callback-url 同纪律——宁可不发也不发一个打不开的地址。
*/
@Value("${flow-center.embed.front-url:}")
private String embedFrontUrl;
private final FlwDefRepository defRepository;
private final FlwInstanceRepository instanceRepository;
private final FlwTaskRepository taskRepository;
private final FlwCcRepository ccRepository;
private final FlwCallbackRegRepository regRepository;
private final FlowInstanceService instanceService;
private final FlowTaskService taskService;
private final FlowEngine flowEngine;
private final AssigneeResolver assigneeResolver;
private final ObjectMapper objectMapper;
private final OpenApiUserClient userClient;
private final com.yunhai.platform.openapi.base.OpenApiAppClient appClient;
private final DiagramTokenService tokenService;
/** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider;
/** 已发布流程列表(每条流程线取最高已发布版本) */
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) {
out.add(toSummary(def));
}
return out;
}
/** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */
public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) {
return defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.map(this::toSummary).orElse(null);
}
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份,
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
@Transactional
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
if (schId == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
}
requireSchoolMember(schId, body.getSubmitterId());
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + body.getFlowGuid()));
// S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝,
// 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。
if (body.getExpectedDefVersion() != null
&& !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
vo.setInstanceId(outcome.getInstanceId());
vo.setDuplicated(outcome.isDuplicated());
return vo;
}
/**
* 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。
* 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply;
* jump_app 值域契约(=app_code)由本方法单点保证,替代历史上用 bizType 冒充的写法。
*/
private String resolveApplyAppCode(String appKey) {
if (appKey == null) {
return null;
}
try {
com.fasterxml.jackson.databind.JsonNode app = appClient.getAppByKey(appKey);
String code = app == null ? null : app.path("appCode").asText(null);
if (code == null || code.trim().isEmpty()) {
log.warn("[flow-center] apply 调用方 app_code 解析为空(by-key 查无):appKey={},消息归属将回落发送方", appKey);
return null;
}
return code.trim();
} catch (Exception e) {
log.warn("[flow-center] apply 调用方 app_code 解析失败(UserBase 不可达?):appKey={}:{}",
appKey, e.toString());
return null;
}
}
/**
* apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。
* assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。
*/
private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns,
Integer submitterId) {
if (assigns == null || assigns.isEmpty()) {
return null;
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
Set<String> seenNodeIds = new LinkedHashSet<>();
for (OapiModels.AssignItem item : assigns) {
if (item.getNodeId() == null || seenNodeIds.contains(item.getNodeId())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派 nodeId 缺失或重复:" + item.getNodeId());
}
seenNodeIds.add(item.getNodeId());
FlowDefModel.Node node = model.node(item.getNodeId());
if (node == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "指派节点不存在:" + item.getNodeId());
}
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点未开启发起时指派:" + item.getNodeId());
}
if (item.getUserIds() == null || item.getUserIds().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派人员为空:" + item.getNodeId());
}
Set<Integer> uids = new LinkedHashSet<>(item.getUserIds());
if (uids.contains(submitterId)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"不允许指派发起人本人:" + item.getNodeId());
}
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
Set<Integer> candidateIds = new LinkedHashSet<>();
for (AssigneeRef ref : candidates) {
candidateIds.add(ref.getUserId());
}
for (Integer uid : uids) {
if (!candidateIds.contains(uid)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派人员不在节点候选集内:node=" + item.getNodeId() + " userId=" + uid);
}
}
boolean single = FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode);
int n = uids.size();
if (single && n != 1) {
throw new BizException(ErrorCode.PARAM_ERROR,
"单选节点须恰指派 1 人:" + item.getNodeId());
}
if (!single) {
int min = node.assignPolicy.min == null ? 1 : node.assignPolicy.min;
int max = node.assignPolicy.max == null ? candidates.size() : node.assignPolicy.max;
if (n < min || n > max) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
}
}
List<Map<String, Object>> rows = new ArrayList<>();
for (Integer uid : uids) {
String userName = assigneeResolver.requireUserName(uid);
Map<String, Object> row = new HashMap<>();
row.put("userId", uid);
row.put("userName", userName);
rows.add(row);
}
snapshot.put(item.getNodeId(), rows);
}
try {
return objectMapper.writeValueAsString(snapshot);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "指派快照序列化失败:" + e.getMessage());
}
}
/**
* 发起预览(指派候选,方案 §5.1):只返回开启指派节点;candidates 剔除 submitter;
* 节点级解析异常降级空列表(I6)。
*/
public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) {
if (schId == null || flowGuid == null || submitterId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId");
}
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + flowGuid));
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo();
vo.setFlowGuid(def.getFlowGuid());
vo.setDefVersion(def.getVersion());
List<OapiModels.PreviewNodeVo> nodes = new ArrayList<>();
for (FlowDefModel.Node node : model.nodes) {
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
continue;
}
List<AssigneeRef> candidates;
try {
candidates = assigneeResolver.resolve(schId, node);
} catch (BizException e) {
log.warn("[flow-center] applyPreview 节点候选解析失败,降级空候选:flowGuid={} node={}:{}",
flowGuid, node.id, e.getMessage());
candidates = new ArrayList<>();
}
List<OapiModels.PreviewUserVo> users = new ArrayList<>();
for (AssigneeRef ref : candidates) {
if (submitterId.equals(ref.getUserId())) {
continue;
}
OapiModels.PreviewUserVo u = new OapiModels.PreviewUserVo();
u.setUserId(ref.getUserId());
u.setUserName(ref.getUserName());
users.add(u);
}
OapiModels.PreviewNodeVo nv = new OapiModels.PreviewNodeVo();
nv.setNodeId(node.id);
nv.setNodeName(node.name);
nv.setCountersign(node.countersign == null ? "ALL" : node.countersign);
nv.setCandidates(users);
OapiModels.PreviewSelectionVo selection = new OapiModels.PreviewSelectionVo();
selection.setMode(FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode)
? FlowDefModel.ASSIGN_MODE_SINGLE : FlowDefModel.ASSIGN_MODE_MULTI);
selection.setMin(node.assignPolicy.min == null ? 1 : node.assignPolicy.min);
selection.setMax(node.assignPolicy.max == null ? users.size() : node.assignPolicy.max);
selection.setRequired(node.assignPolicy.required == null
? Boolean.FALSE : node.assignPolicy.required);
nv.setSelection(selection);
nodes.add(nv);
}
vo.setNodes(nodes);
return vo;
}
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
.orElse(null);
return instance == null ? null : toInstanceVo(instance);
}
// ===== 应用内审批(2026-09 第三步):查询 + 办理 =====
/**
* 我的待办(oapi,应用内审批列表页数据源):assigneeId 维度分页,bizType 可选过滤。
* 办理人判定以 taskId 为准——每条待办任务行恰好一个 assignee,不再做集合判断。
*/
public BasePageResult<OapiModels.TodoTaskVo> pageTodoTasks(Long schId, Integer assigneeId,
String bizType, Integer page, Integer size) {
if (schId == null || assigneeId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
}
Page<FlwTask> p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType),
PageRequest.of(normPage(page), normSize(size)));
List<Long> instanceIds = p.getContent().stream()
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
Map<Long, FlwInstance> instances = instanceIds.isEmpty()
? java.util.Collections.emptyMap()
: instanceRepository.findAllById(instanceIds).stream()
.collect(java.util.stream.Collectors.toMap(FlwInstance::getId, i -> i));
List<OapiModels.TodoTaskVo> items = p.getContent().stream()
.map(t -> toTodoTaskVo(t, instances.get(t.getInstanceId())))
.collect(java.util.stream.Collectors.toList());
return toPageResult(p.getTotalElements(), items, page, size);
}
/** 办件分页(oapi,应用侧列表/对账):bizType/status 可选过滤 */
public BasePageResult<OapiModels.InstanceItemVo> pageInstances(Long schId, String bizType, Integer status,
Integer page, Integer size) {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
}
Page<FlwInstance> p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status,
PageRequest.of(normPage(page), normSize(size)));
List<OapiModels.InstanceItemVo> items = p.getContent().stream()
.map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList());
return toPageResult(p.getTotalElements(), items, page, size);
}
/**
* 同意(oapi,应用内审批):operatorId 由调用方应用按其登录态传入,本端按任务办理人
* 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。
* operatorName 供应用侧审计透传,事件流仍以任务行 assigneeName 为准。多出口节点必带
* edgeName({@code FlowTaskService.handle} 内校验)。审批后的回调/待办减一等事件链自动复用。
*/
@Transactional
public FlwTask approveTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion, String edgeName) {
requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, true, opinion, operatorId, edgeName);
}
/** 拒绝(oapi):opinion 必填由 {@code FlowTaskService.handle} 强制;其余口径同 {@link #approveTask} */
@Transactional
public FlwTask rejectTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion) {
requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, false, opinion, operatorId);
}
private void requireOperator(Long schId, Long taskId, Integer operatorId, String operatorName) {
if (schId == null || taskId == null || operatorId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/taskId/operatorId");
}
if (operatorName == null || operatorName.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:operatorName(操作人姓名,审计用)");
}
}
private OapiModels.TodoTaskVo toTodoTaskVo(FlwTask t, FlwInstance instance) {
OapiModels.TodoTaskVo vo = new OapiModels.TodoTaskVo();
vo.setTaskId(t.getId());
vo.setInstanceId(t.getInstanceId());
vo.setInstanceVersion(instance == null ? null : instance.getVersion());
vo.setBizType(instance == null ? null : instance.getBizType());
vo.setBizId(instance == null ? null : instance.getBizId());
vo.setBizTitle(instance == null ? null : instance.getBizTitle());
vo.setBizSnapshot(instance == null ? null : instance.getBizSnapshot());
vo.setNodeId(t.getNodeId());
vo.setNodeName(t.getNodeName());
vo.setAssigneeId(t.getAssigneeId());
vo.setAssigneeName(t.getAssigneeName());
vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
return vo;
}
private OapiModels.InstanceItemVo toInstanceItemVo(FlwInstance instance) {
OapiModels.InstanceItemVo vo = new OapiModels.InstanceItemVo();
vo.setInstanceId(instance.getId());
vo.setStatus(instance.getStatus());
vo.setVersion(instance.getVersion());
vo.setBizType(instance.getBizType());
vo.setBizId(instance.getBizId());
vo.setBizTitle(instance.getBizTitle());
vo.setBizUrl(instance.getBizUrl());
vo.setSubmitterName(instance.getSubmitterName());
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
vo.setCurrentNodes(instance.getCurrentNodes());
return vo;
}
/** 与 FlowQueryService 同口径(page<1→0;size 空/越界→10) */
private static int normPage(Integer page) {
return page == null || page < 1 ? 0 : page - 1;
}
private static int normSize(Integer size) {
return size == null || size < 1 || size > 100 ? 10 : size;
}
private static <T> BasePageResult<T> toPageResult(long total, List<T> content, Integer page, Integer size) {
BasePageResult<T> result = new BasePageResult<>();
result.setTotal((int) total);
result.setContent(content);
int pageIndex = page == null || page < 1 ? 1 : page;
int pageSize = size == null || size < 1 ? content.size() : size;
result.setPageIndex(pageIndex);
result.setPageSize(pageSize);
result.setTotalPages(pageSize == 0 ? 0 : (int) Math.ceil((double) total / pageSize));
result.setHasNext((long) pageIndex * pageSize < total);
return result;
}
private static String emptyToNull(String s) {
return s == null || s.trim().isEmpty() ? null : s.trim();
}
/**
* 撤回(托管接入件,2026-09-16 补:原先只有 CAS 用户面 {@code /api/flow/instance/{id}/withdraw},
* 业务应用只能让浏览器直调,票据与 CORS 一并外溢)。治理口径:
* <ul>
* <li>身份:{@code submitterId} 必传——应用间签名只证明"哪个应用在调",不证明"谁在撤",
* 对端按 submitterId 与办件提交人**逐字复核**,杜绝业务应用越权代撤他人办件;</li>
* <li>租户:schId 收敛 + requireInstance 校验办件归属;</li>
* <li>状态:仅审批中(10) 且尚无任务被办理(引擎既有纪律,见 FlowInstanceService.withdraw);</li>
* <li>不校验 submitterId 的本校成员资格——提交人身份在发起时已按 §5.5 校验过,撤回不改变归属。</li>
* </ul>
*/
@Transactional
public void withdraw(Long schId, Long instanceId, Integer submitterId) {
if (schId == null || instanceId == null || submitterId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/instanceId/submitterId");
}
FlwInstance instance = instanceService.requireInstance(schId, instanceId);
if (!submitterId.equals(instance.getSubmitterId())) {
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人本人可撤回");
}
instanceService.withdraw(schId, instanceId);
}
/**
* 办件进度(结构化轨迹):steps=发起申请 + 节点步骤(按任务 id 序;同节点同人多任务行合并为多人步骤)。
* 状态标签口径与前端流程轨迹一致(0 待审核 / 1 已同意 / 2 已拒绝 / 3 失效 / 4 已退回 / 5 已移交);
* 失效行保留(消费方自行决定是否隐藏——重启重提后的旧节点轨迹即 3/失效)。
*/
public OapiModels.ProgressVo progress(Long schId, Long instanceId, String bizType, String bizId) {
FlwInstance instance;
if (instanceId != null) {
instance = requireInstance(schId, instanceId);
} else {
if (bizType == null || bizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
}
instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见"));
}
return toProgressVo(instance);
}
/**
* 流程图换图(token 模式):app-sign 校验在本次请求完成(/oapi 强制验签),token 只向浏览器
* 传递"出图资格"。二选一:instanceId(办件口径,带运行时状态+可见性收紧)/ flowGuid(定义
* 口径,最高已发布版)。userId 无需传——appKey 调用方已视为可信(两级模型,可见性由发起方
* 业务边界保证),浏览器侧只认 token。
*/
public OapiModels.DiagramUrlVo diagramUrl(Long schId, Long instanceId, String flowGuid, String mode) {
if (flowGuid != null && !flowGuid.trim().isEmpty()) {
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid.trim(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + flowGuid));
return issueUrl(schId, null, def.getId(), mode);
}
if (instanceId != null) {
requireInstance(schId, instanceId);
return issueUrl(schId, instanceId, null, mode);
}
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 flowGuid");
}
private OapiModels.DiagramUrlVo issueUrl(Long schId, Long instanceId, Long defId, String mode) {
String token = tokenService.issue(schId, instanceId, defId, mode);
OapiModels.DiagramUrlVo vo = new OapiModels.DiagramUrlVo();
vo.setImgUrl("/open/flow/diagram/img?token=" + token);
vo.setExpiresIn(DiagramTokenService.TTL_MS / 1000);
return vo;
}
/**
* 办件进度嵌入页换址(token 模式,与换图同构):同一 token 两处消费——嵌入页数据
* /open/flow/embed/data 与流程图 /open/flow/diagram/img 均免签认领该 token。定位二选一:
* instanceId(与办件直接复核)/ bizType+bizId(对账口径,办件不存在与 progress 同文案)。
* 用户级可见性(本人/办理人/管理员/该办件抄送接收人)由子应用后端在调本端点前完成——oapi 层只认 schId。
* 2026-10-17 F7 前置补丁后,中心用户面口径({@link FlowAccessGuard#requireInstanceVisible})已含抄送
* 接收人;签发侧必须与之一致,否则"抄送我的"点开得、进度嵌入页换址却拒发(2026-10-17 记)。
*/
public OapiModels.EmbedUrlVo embedUrl(Long schId, Long instanceId, String bizType, String bizId, String mode) {
return embedUrl(schId, instanceId, bizType, bizId, mode, null);
}
/**
* mode=task(F9)直跳中心任务办理页:pageUrl = front-url + {@code #/task/{taskId}},
* 无 token(CAS 会话鉴权页,办理权由该页加载时的用户面接口按 assigneeId 复核)——
* 子应用"在自家页放办理入口"场景用;taskId 必填,缺则 PARAM_ERROR。
*/
public OapiModels.EmbedUrlVo embedUrl(Long schId, Long instanceId, String bizType, String bizId,
String mode, Long taskId) {
// 配置缺失优先于办件定位:fail-fast 纪律要的是"无论如何都不发打不开的地址"
if (embedFrontUrl == null || embedFrontUrl.trim().isEmpty()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址未配置:flow-center.embed.front-url");
}
String base = embedFrontUrl.trim();
if (!base.endsWith("/")) {
base = base + "/";
}
String normMode = mode == null || mode.trim().isEmpty() ? "auto" : mode.trim();
if ("task".equals(normMode)) {
if (taskId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "mode=task 需要 taskId(任务办理页定位)");
}
OapiModels.EmbedUrlVo out = new OapiModels.EmbedUrlVo();
out.setPageUrl(base + "#/task/" + taskId);
out.setExpiresIn(0);
return out;
}
Long resolvedInstanceId;
if (instanceId != null) {
resolvedInstanceId = requireInstance(schId, instanceId).getId();
} else {
if (bizType == null || bizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
}
OapiModels.InstanceVo vo = instanceByBiz(schId, bizType, bizId);
if (vo == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见");
}
resolvedInstanceId = vo.getInstanceId();
}
// mode 与 DiagramTokenService 内部缺省同值("auto"),URL 里显式带上仅为前端展示冗余
String token = tokenService.issue(schId, resolvedInstanceId, null, normMode);
OapiModels.EmbedUrlVo out = new OapiModels.EmbedUrlVo();
out.setPageUrl(base + "#/embed/instance?token=" + token + "&mode=" + normMode);
out.setExpiresIn(DiagramTokenService.TTL_MS / 1000);
return out;
}
/** 进度/换图共用的办件定位(schId 收敛在次) */
private FlwInstance requireInstance(Long schId, Long instanceId) {
FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
if (!schId.equals(instance.getSchId())) {
throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校");
}
return instance;
}
private OapiModels.ProgressVo toProgressVo(FlwInstance instance) {
OapiModels.ProgressVo vo = new OapiModels.ProgressVo();
vo.setInstanceId(instance.getId());
vo.setBizType(instance.getBizType());
vo.setBizId(instance.getBizId());
vo.setBizTitle(instance.getBizTitle());
vo.setStatus(instance.getStatus());
vo.setCurrentNodes(instance.getCurrentNodes());
vo.setSubmitterId(instance.getSubmitterId());
vo.setSubmitterName(instance.getSubmitterName());
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
Set<String> activeNodes = new LinkedHashSet<>();
if (instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) {
activeNodes.addAll(Arrays.asList(instance.getCurrentNodes().split(",")));
}
List<FlwTask> rows = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
boolean hasSubmitRow = rows.stream()
.anyMatch(t -> FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()));
List<OapiModels.ProgressStepVo> steps = new ArrayList<>();
// 步骤发生时间(排序键):任务步=办理人行最晚时间(handleTime?:arriveTime),submit/事件步=动作时间,
// 抄送步=最晚 sentTime——抄送步与任务步按真实时间交织,人读时间线不出现"抄送悬在最后"。
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> stepTimes =
new java.util.IdentityHashMap<>();
// 第 0 步"发起申请"(对外时间线图3/图4口径):2026-09-18 起发起会留痕成真实任务行,
// 有真实行时由下面的循环出这一步(可见内容与这里的合成步一致);老办件没有真实行,
// 仍按办件快照合成,存量数据对外表现不变。
if (!hasSubmitRow) {
OapiModels.ProgressStepVo submit = submitStep(instance);
steps.add(submit);
stepTimes.put(submit, instance.getSubmitTime());
}
// 轨迹留痕事件行(6 发起/重新提交、7 撤回)单独成步;步骤 label 直接用事件语义,
// 不参与下面的"任一拒绝>任一同意>全失效"汇总(否则会被判成"失效")。
Set<OapiModels.ProgressStepVo> eventSteps =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
// 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤
Map<String, OapiModels.ProgressStepVo> stepsByNode = new HashMap<>();
for (FlwTask t : rows) {
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
OapiModels.ProgressStepVo es = eventStep(t);
eventSteps.add(es);
steps.add(es);
stepTimes.put(es, t.getHandleTime() != null ? t.getHandleTime() : t.getArriveTime());
continue;
}
OapiModels.ProgressStepVo step = stepsByNode.computeIfAbsent(t.getNodeId(), k -> {
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
s.setNodeId(t.getNodeId());
s.setNodeName(t.getNodeName());
s.setAssignees(new ArrayList<>());
steps.add(s);
return s;
});
step.getAssignees().add(taskVo(t));
}
// 步骤 label:在办节点=待审核,其余取办理人行的汇总(任一拒绝=已拒绝 > 任一同意=已同意 > 全失效=失效)
for (OapiModels.ProgressStepVo s : steps) {
if (eventSteps.contains(s)) {
continue;
}
s.setActive(activeNodes.contains(s.getNodeId()));
s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees()));
java.time.LocalDateTime latest = null;
for (OapiModels.ProgressTaskVo r : s.getAssignees()) {
java.time.LocalDateTime t = r.getHandleTime() != null
? java.time.LocalDateTime.parse(r.getHandleTime())
: (r.getArriveTime() == null ? null : java.time.LocalDateTime.parse(r.getArriveTime()));
if (t != null && (latest == null || t.isAfter(latest))) {
latest = t;
}
}
stepTimes.put(s, latest);
}
// 抄送步(有才交织排序):无抄送时保持既有拼装顺序原样输出(存量办件出参零变化);
// 有抄送时按 (发生时间, 秩) 排——既有步=0、抄送步=1(同秒 DATETIME 分不出先后,任务步
// 恒在前;不给既有步分秩是因为驳回/重提步同秒靠 id 定序即真实先后,秩会颠倒)。
List<OapiModels.ProgressStepVo> ccStepList = ccSteps(instance.getId(), stepTimes);
if (!ccStepList.isEmpty()) {
Map<OapiModels.ProgressStepVo, Integer> ranks = new java.util.IdentityHashMap<>();
for (OapiModels.ProgressStepVo s : steps) {
ranks.put(s, 0);
}
for (OapiModels.ProgressStepVo s : ccStepList) {
ranks.put(s, 1);
}
steps.addAll(ccStepList);
steps.sort(java.util.Comparator
.comparing((OapiModels.ProgressStepVo s) -> stepTimes.get(s),
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(s -> ranks.get(s)));
}
vo.setSteps(steps);
return vo;
}
/**
* 抄送步(2026-09-23 NOTIFY 节点):按 nodeId 聚合(多人并排),label="抄送"、
* 参与行 status=1/已抄送(复用"已办过"取值,消费方无需适配新状态);时间取 sentTime,
* 与任务步按真实发生时间交织排序。无抄送返回空列表(存量办件出参零变化)。
*/
private List<OapiModels.ProgressStepVo> ccSteps(Long instanceId,
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> stepTimes) {
List<FlwCc> ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId);
if (ccs.isEmpty()) {
return new ArrayList<>();
}
Map<String, OapiModels.ProgressStepVo> byNode = new java.util.LinkedHashMap<>();
for (FlwCc c : ccs) {
OapiModels.ProgressStepVo step = byNode.computeIfAbsent(c.getNodeId(), k -> {
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
s.setNodeId(c.getNodeId());
s.setNodeName(c.getNodeName() == null || c.getNodeName().isEmpty() ? "抄送" : c.getNodeName());
s.setActive(false);
s.setLabel("抄送");
s.setAssignees(new ArrayList<>());
stepTimes.put(s, c.getSentTime());
return s;
});
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
row.setUserId(c.getUserId());
row.setUserName(c.getUserName());
row.setStatus(1);
row.setStatusLabel("已抄送");
row.setArriveTime(c.getSentTime() == null ? null : c.getSentTime().toString());
step.getAssignees().add(row);
java.time.LocalDateTime t = stepTimes.get(step);
if (c.getSentTime() != null && (t == null || c.getSentTime().isAfter(t))) {
stepTimes.put(step, c.getSentTime());
}
}
return new ArrayList<>(byNode.values());
}
/** 合成"发起申请"步(老办件:无真实留痕行,取办件快照的提交人/提交时间) */
private OapiModels.ProgressStepVo submitStep(FlwInstance instance) {
OapiModels.ProgressStepVo submit = new OapiModels.ProgressStepVo();
submit.setNodeId("submit");
submit.setNodeName("发起申请");
submit.setActive(false);
submit.setLabel("发起申请");
OapiModels.ProgressTaskVo submitter = new OapiModels.ProgressTaskVo();
submitter.setUserId(instance.getSubmitterId());
submitter.setUserName(instance.getSubmitterName());
submitter.setStatus(1);
submitter.setStatusLabel("已提交");
submitter.setArriveTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
submit.setAssignees(new ArrayList<>(java.util.Collections.singletonList(submitter)));
return submit;
}
/**
* 留痕事件步(发起/重新提交/撤回):nodeId 用保留值 submit/resubmit/withdraw。
* 发起步保留原合成步的口径(nodeName/label=发起申请、参与行 status=1/已提交),
* 使新老办件对外输出一致;撤回与重新提交是本轮新增的对外可见动作。
*/
private OapiModels.ProgressStepVo eventStep(FlwTask t) {
boolean submitted = FlwEnums.TaskStatus.SUBMITTED.getCode() == t.getStatus();
OapiModels.ProgressStepVo step = new OapiModels.ProgressStepVo();
step.setNodeId(t.getNodeId());
step.setNodeName(t.getNodeName());
step.setActive(false);
step.setLabel(!submitted ? "已撤回"
: (FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()) ? "发起申请" : "已提交"));
OapiModels.ProgressTaskVo vo = taskVo(t);
if (submitted) {
// 发起/重新提交沿用"已提交"文案(status=1 与老合成步一致,消费方无需适配)
vo.setStatus(1);
vo.setStatusLabel("已提交");
} else {
vo.setStatusLabel("已撤回");
}
step.setAssignees(new ArrayList<>(java.util.Collections.singletonList(vo)));
return step;
}
/** 任务行 → 对外步骤里的办理人行(展示时间截断到秒:列已升毫秒精度,对外口径不变) */
private OapiModels.ProgressTaskVo taskVo(FlwTask t) {
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
row.setTaskId(t.getId());
row.setUserId(t.getAssigneeId());
row.setUserName(t.getAssigneeName());
row.setStatus(t.getStatus());
row.setStatusLabel(taskLabel(t.getStatus()));
row.setOpinion(t.getOpinion());
row.setArriveTime(t.getArriveTime() == null ? null
: t.getArriveTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
row.setHandleTime(t.getHandleTime() == null ? null
: t.getHandleTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
row.setEdgeName(t.getEdgeName());
return row;
}
private String taskLabel(Integer status) {
if (status == null) {
return "";
}
switch (status) {
case 0: return "待审核";
case 1: return "已同意";
case 2: return "已拒绝";
case 3: return "失效";
case 4: return "已退回";
case 5: return "已移交";
case 6: return "已提交";
case 7: return "已撤回";
default: return String.valueOf(status);
}
}
private String stepLabel(List<OapiModels.ProgressTaskVo> rows) {
boolean denied = false, approved = false, invalid = true;
for (OapiModels.ProgressTaskVo r : rows) {
int s = r.getStatus() == null ? -1 : r.getStatus();
if (s == 2) {
denied = true;
}
if (s == 1) {
approved = true;
}
if (s == 0 || s == 1 || s == 2 || s == 4 || s == 5) {
invalid = false;
}
}
if (denied) {
return "已拒绝";
}
if (approved) {
return "已同意";
}
return invalid ? "失效" : "待审核";
}
/** 回调注册(uk: sch+appKey+flowGuid;重复注册=更新配置并重置 secret;注册时探活) */
@Transactional
public OapiModels.CallbackRegResultVo registerCallback(Long schId, String appKey,
OapiModels.CallbackRegBody body) {
if (schId == null || appKey == null || body.getFlowGuid() == null || body.getCallbackUrl() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/callbackUrl");
}
// 绑定的流程须存在(草稿也允许先注册,发布后事件即按订阅分发)
defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, body.getFlowGuid())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在:" + body.getFlowGuid()));
String secret = uuid32();
FlwCallbackReg reg = regRepository.findBySchIdAndAppKeyAndFlowGuid(schId, appKey, body.getFlowGuid())
.orElse(null);
if (reg == null) {
reg = new FlwCallbackReg();
reg.setSchId(schId);
reg.setAppKey(appKey);
reg.setFlowGuid(body.getFlowGuid());
}
reg.setCallbackUrl(body.getCallbackUrl());
reg.setEventTypes(body.getEventTypes());
reg.setBasePath(body.getBasePath());
reg.setSecret(secret);
reg.setStatus(0);
regRepository.saveAndFlush(reg);
probeReachable(body.getCallbackUrl(), body.getBasePath(), body.getFlowGuid(), secret);
OapiModels.CallbackRegResultVo vo = new OapiModels.CallbackRegResultVo();
vo.setSecret(secret);
return vo;
}
/**
* 可达性探活(概览 §十:拦住配错域名的低级故障)。只做连接级判定:
* 连不上(未知主机/拒绝/超时)视为不可达并拒绝注册;任何 HTTP 状态码(含 401/404)
* 都算可达——接收方验签过滤器在拿到 secret 前必然拒绝 ping,鉴权语义不在此验证。
* <p>
* 但探活请求必须带应用签名:回调端点挂在接收方 /oapi/* 下(强制应用验签)时,
* 裸 ping 会被 401 拦下,而 RestTemplate 对 4xx 抛异常 → 误判"不可达"拒绝注册。
*/
private void probeReachable(String callbackUrl, String basePath, String flowGuid, String secret) {
try {
RestTemplate template = CallbackAppSigner.signedTemplate(
appIdentityProvider.getIfAvailable(), basePath, 3000, 3000);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Flw-EventId", uuid32());
headers.set("X-Flw-Timestamp", String.valueOf(System.currentTimeMillis()));
headers.set("X-Flw-Nonce", uuid32().substring(0, 16));
headers.set("X-Flw-Signature", "ping");
Map<String, Object> ping = new HashMap<>();
ping.put("eventId", uuid32());
ping.put("event", "ping");
ping.put("flowGuid", flowGuid);
ping.put("occurredTime", System.currentTimeMillis());
template.exchange(callbackUrl, HttpMethod.POST, new HttpEntity<>(ping, headers), String.class);
} catch (Exception e) {
Throwable root = rootCause(e);
log.warn("[flow-center] 回调地址探活失败:url={} cause={}", callbackUrl, root.toString());
throw new BizException(ErrorCode.PARAM_ERROR, "回调地址不可达:" + root.getMessage());
}
}
private static Throwable rootCause(Throwable e) {
Throwable cur = e;
while (cur.getCause() != null && cur.getCause() != cur) {
cur = cur.getCause();
}
return cur;
}
/** submitterId 本校校验(防跨租户脏数据,概览 §5.5;UserBase 不可达 fail-closed) */
private void requireSchoolMember(Long schId, Integer submitterId) {
if (!submitterCheckEnabled) {
log.warn("[flow-center] 提交人本校校验已关闭(仅限本地开发): schId={} submitterId={}", schId, submitterId);
return;
}
BoaUser user;
try {
user = userClient.getUserById(String.valueOf(submitterId));
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "提交人身份核验失败(UserBase 不可达)");
}
if (user == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "提交人不存在:" + submitterId);
}
Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
? null : Long.valueOf(user.getSchool().getId());
if (!schId.equals(userSchId)) {
throw new BizException(ErrorCode.FORBIDDEN, "提交人不属于该学校(防跨租户脏数据)");
}
}
private OapiModels.FlowSummaryVo toSummary(FlwDef def) {
OapiModels.FlowSummaryVo vo = new OapiModels.FlowSummaryVo();
vo.setFlowGuid(def.getFlowGuid());
vo.setName(def.getName());
vo.setVersion(def.getVersion());
vo.setNodeSummary(FlowEngine.nodeSummary(flowEngine.parseModel(def)));
return vo;
}
/**
* 办件出参:轨迹(含 assigneeId,拍板项#3 办理人放行判定依赖)。
*
* <p>2026-09-18:留痕事件行(6 发起/重新提交、7 撤回)<b>不进</b>本出参——它是"办理人与进度"
* 的对账口径(assigneeIds 明确是"办理人集合",提交人另有 submitterId 字段,混进来会让
* 消费方把提交人当办理人)。撤回/重提的对外可见性走 /oapi/flow/progress 的 steps(人读轨迹)。
*/
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
vo.setInstanceId(instance.getId());
vo.setStatus(instance.getStatus());
vo.setVersion(instance.getVersion());
vo.setBizTitle(instance.getBizTitle());
vo.setCurrentNodes(instance.getCurrentNodes());
vo.setSubmitterId(instance.getSubmitterId());
vo.setSubmitterName(instance.getSubmitterName());
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
List<com.yunhai.flowcenter.entity.FlwTask> tasks =
taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
List<Map<String, Object>> trace = new ArrayList<>();
Set<Integer> assigneeIds = new LinkedHashSet<>();
for (com.yunhai.flowcenter.entity.FlwTask t : tasks) {
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
continue;
}
assigneeIds.add(t.getAssigneeId());
Map<String, Object> row = new HashMap<>();
row.put("taskId", t.getId());
row.put("nodeId", t.getNodeId());
row.put("nodeName", t.getNodeName());
row.put("assigneeId", t.getAssigneeId());
row.put("assigneeName", t.getAssigneeName());
row.put("status", t.getStatus());
row.put("opinion", t.getOpinion());
row.put("arriveTime", t.getArriveTime() == null ? null : t.getArriveTime().toString());
row.put("handleTime", t.getHandleTime() == null ? null : t.getHandleTime().toString());
trace.add(row);
}
try {
vo.setTraceJson(objectMapper.writeValueAsString(trace));
} catch (Exception e) {
vo.setTraceJson("[]");
}
vo.setAssigneeIds(new ArrayList<>(assigneeIds));
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
vo.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def)));
return vo;
}
private String asSnapshotString(Object bizSnapshot) {
if (bizSnapshot == null) {
return null;
}
if (bizSnapshot instanceof String) {
return (String) bizSnapshot;
}
try {
return objectMapper.writeValueAsString(bizSnapshot);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizSnapshot 不是可序列化的摘要内容");
}
}
private String asFormDataString(Map<String, Object> formData) {
if (formData == null || formData.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(formData);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "formData 不是合法的 JSON 对象");
}
}
private static String uuid32() {
return UUID.randomUUID().toString().replace("-", "").toLowerCase();
}
}