build / build-push (push) Failing after 55s
在 b369527 之上增量移植 FL2.0 后端能力,不改既有线级停发/指派预览/流程图入口:
- 部门目录:FlowDepartmentController(/admin/department/{groups,options,resolve,members})
+ UserBaseDepartmentDirectory(先按会话校域解析引用,再读成员;目录不可用 fail-closed)
- 退回重提 D14:POST /oapi/flow/instance/{instanceId}/resubmit,仅原提交人 CAS 路径放行,
OAPI 侧 submitterId 路径继续 fail-closed
- 授权管理:/api/flow/admin/usage-grant/{schoolList,summary}(校域取管理员会话,无 schId 入参)
- 来源主体:解析器派生 ub-app:{applicationInfo.id},不再读取 UserBase 的 sourcePrincipalId 字段
- 回调来源策略:SELF 身份强校验;NULL 来源不广播;复合幂等键 (event_id,reg_id)
- 测试夹具:许可/主体/开通桩按新契约修正,FlowEngineIT/FormalApplyCommandIT/
CallbackDispatchIT/OapiFlowContractIT 共 117 例在真实 MySQL 8 通过
不含:FlSourceAuthorizationTest(其断言的两个 Repository 派生方法属另一实现线)
1581 lines
86 KiB
Java
1581 lines
86 KiB
Java
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.FlowApplyRetryModels;
|
||
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.dao.DataIntegrityViolationException;
|
||
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;
|
||
|
||
/**
|
||
* v2 发起的事务模板。<b>用 setter/字段注入而不是加进构造器</b>:本类构造被
|
||
* {@code OapiFlowApplyDefVersionTest}/{@code OapiFlowEmbedTaskUrlTest} 逐位钉住,
|
||
* 多一个参数就打不进来(同 svc-hall ServiceItemService 的 setter 先例)。
|
||
* 未装配(老单测直接 new)时 {@link #applyV2} 回落到"直接调用",事务语义由调用方的
|
||
* {@code @Transactional} 承担——那条路径只用于测试,不影响生产装配。
|
||
*/
|
||
private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
|
||
|
||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||
public void setTransactionTemplate(org.springframework.transaction.support.TransactionTemplate template) {
|
||
this.transactionTemplate = template;
|
||
}
|
||
|
||
/**
|
||
* T01 应用使用许可闸(2026-10-25):新发起必须持有 (school, flowGuid, appId) 生效许可。
|
||
* setter 注入(构造被单测逐位钉住,同 transactionTemplate 先例);未装配(老单测直接 new)
|
||
* 视为关闭,生产装配后由 {@code yh.flow.school-activation-enforce} 控制生效。
|
||
* enforce 开启时 UserBase 不可达 fail-closed(见 {@link FlowUsageGrantGuard})。
|
||
*/
|
||
private FlowUsageGrantGuard usageGrantGuard;
|
||
|
||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||
public void setUsageGrantGuard(FlowUsageGrantGuard guard) {
|
||
this.usageGrantGuard = guard;
|
||
}
|
||
|
||
/**
|
||
* 发起期变量终审(字段注入:保持既有构造签名)。手工构造服务时也使用同一校验器,
|
||
* 所有拒绝均发生在实例创建之前。
|
||
*/
|
||
private ApplyVariableValidator applyVariableValidator;
|
||
|
||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||
public void setApplyVariableValidator(ApplyVariableValidator validator) {
|
||
this.applyVariableValidator = validator;
|
||
}
|
||
|
||
/** R1 SDK 快捷审批门禁(字段注入同上):门禁无条件 fail-closed,装配与否均不放行 */
|
||
private FlowActorContextGate actorContextGate;
|
||
|
||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||
public void setActorContextGate(FlowActorContextGate gate) {
|
||
this.actorContextGate = gate;
|
||
}
|
||
|
||
private void requireExpressionVariablesPresent(FlwDef def, String formDataJson) {
|
||
ApplyVariableValidator validator = applyVariableValidator == null
|
||
? new ApplyVariableValidator(objectMapper) : applyVariableValidator;
|
||
validator.requireVariablesPresent(
|
||
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
|
||
}
|
||
|
||
private FlowInstanceService.GuardedOutcome inTransaction(
|
||
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
|
||
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
|
||
}
|
||
|
||
@Value("${flow-center.formal-command.enabled:false}")
|
||
private boolean formalCommandEnabled;
|
||
|
||
@org.springframework.beans.factory.annotation.Autowired
|
||
private FlowSourcePrincipalResolver sourcePrincipalResolver;
|
||
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwApplyCommandRepository formalCommands;
|
||
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwUsageGrantRepository formalGrants;
|
||
|
||
private void requireLegacyWriter() {
|
||
if (formalCommandEnabled) throw new BizException(ErrorCode.FORBIDDEN,
|
||
"正式命令模式已启用,请迁移到 /oapi/flow/commands;旧发起接口已关闭");
|
||
}
|
||
|
||
/** New-command validation only. Never call this while recovering a settled receipt. */
|
||
FlowInstanceService.ApplyCommand prepareFormal(Long schId, FlowSourcePrincipalResolver.Principal source,
|
||
com.yunhai.flowcenter.model.FormalApplyModels.Request body) {
|
||
BoaUser user;
|
||
try { user = userClient.getUserById(String.valueOf(body.getSubmitterId())); }
|
||
catch (RuntimeException unavailable) { throw new IllegalStateException("提交人权威查询不可用", unavailable); }
|
||
if (user == null || !Integer.valueOf(1).equals(user.getStatus()) || user.getSchool() == null || user.getSchool().getId() == null
|
||
|| !schId.equals(Long.valueOf(user.getSchool().getId()))) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "提交人不存在或不属于该学校");
|
||
}
|
||
if (body.getAssigns() != null && !body.getAssigns().isEmpty() && body.getExpectedDefVersion() == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "正式指派须携带预览定义版本锚");
|
||
}
|
||
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
|
||
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "流程版本已改变,请刷新后使用新的请求 ID");
|
||
}
|
||
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
||
// D11:固定 USERS 属审批责任人,正式命令在受理阶段整体终审——任一非法即 VALIDATION_REJECTED,
|
||
// 不落实例、不做“过滤后继续”。运行期建任务仍由 FlowEngine/AssigneeResolver 再兜底。
|
||
assigneeResolver.requireEligibleFixedAssignees(schId, FlowDefModel.parse(objectMapper, def.getContentJson()));
|
||
String assigns = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
|
||
return new FlowInstanceService.ApplyCommand(schId, def, body.getBizType(), body.getBizId(),
|
||
body.getBizTitle(), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
||
body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()),
|
||
source.appKey, source.appCode, assigns);
|
||
}
|
||
|
||
private boolean canReadFormalDefinition(Long school, String flow) {
|
||
String source = FlowOapiSourceScope.current();
|
||
return formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, source, 1)
|
||
|| formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(school, source, flow, "ACCEPTED");
|
||
}
|
||
|
||
private void requireFormalPreviewGrant(Long school, String flow) {
|
||
if (!formalCommandEnabled) return;
|
||
FlowSourcePrincipalResolver.Principal principal = sourcePrincipalResolver.resolve(
|
||
(String)((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes()).getRequest().getAttribute("yh.app.auth"));
|
||
if (!formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, principal.id, 1)
|
||
|| !formalActivation.isAppActivatedStrict(principal.appId, school)) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "没有此流程线的发起预览权限");
|
||
}
|
||
}
|
||
|
||
@org.springframework.beans.factory.annotation.Autowired private UserBaseActivationClient formalActivation;
|
||
|
||
/** 已发布流程列表(每条流程线取最高已发布版本) */
|
||
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
|
||
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
|
||
for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) {
|
||
if (formalCommandEnabled && !canReadFormalDefinition(schId, def.getFlowGuid())) continue;
|
||
out.add(toSummary(def));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */
|
||
public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) {
|
||
if (formalCommandEnabled && !canReadFormalDefinition(schId, flowGuid)) return null;
|
||
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) {
|
||
requireLegacyWriter();
|
||
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());
|
||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||
// D02/D09/D11:来源主体只从验签上下文解析(缺失即拒);T01 使用许可(v1 路径)——
|
||
// 回执幂等(既有在途/退回待修改/通过件)不拦;无实例或终态件 R4 重启=新申报,必须持有效许可。
|
||
FlowSourcePrincipalResolver.Principal source = requireApplySource(cleanAppKey);
|
||
requireUsageGrantLegacyApply(schId, body.getFlowGuid(), source, body.getBizType(), body.getBizId());
|
||
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
|
||
// T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行
|
||
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
||
// 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());
|
||
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);
|
||
cmd.sourcePrincipalId = source.id;
|
||
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
|
||
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
|
||
vo.setInstanceId(outcome.getInstanceId());
|
||
vo.setDuplicated(outcome.isDuplicated());
|
||
return vo;
|
||
}
|
||
|
||
/**
|
||
* 带请求标识/轮次守卫的代发起({@code POST /oapi/flow/apply/v2},v2 §8,TASK_2 C.3)。
|
||
*
|
||
* <p>与 {@link #apply} 的三点不同,都是"幂等修复"要求而不是新功能:</p>
|
||
* <ol>
|
||
* <li><b>事务边界收窄到一次发起</b>:用 {@code TransactionTemplate} 而不是方法级
|
||
* {@code @Transactional}——唯一键冲突必须在<b>事务外</b>重新读既有实例
|
||
* (撞键后当前事务已被标记回滚,事务内再读不可靠),这与 svc-hall
|
||
* {@code AnswerService#executeSubmit} 同一形态;</li>
|
||
* <li><b>并发撞键后先核归属再返回</b>:读到的实例若属于另一应用/另一提交者/另一流程线,
|
||
* 一律拒绝,<b>不把别人的 bizType+bizId 当自己的结果</b>;</li>
|
||
* <li><b>回带稳定分支码</b>(CREATED/IDEMPOTENT/SAME_REQUEST/STALE_ROUND/REPLAY_ONLY/
|
||
* RESTARTED/CONCURRENT_CREATED),消费方按码处置,不再猜"duplicated 是哪一种重复"。</li>
|
||
* </ol>
|
||
*
|
||
* <p>已发布定义校验、提交人本校校验与旧路径同一条,不放宽;流程节点算法一行未改。</p>
|
||
*/
|
||
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
|
||
FlowApplyRetryModels.ApplyRequestV2 body) {
|
||
requireLegacyWriter();
|
||
if (schId == null || body == 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());
|
||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||
// D02/D09/D11:可信来源只从验签上下文推导。T01 使用许可(v2 路径)——恢复感知:
|
||
// SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND 四类回执分支不拦(已受理命令的回执不因许可
|
||
// 撤销丢失);CREATED/RESTARTED(新申报/新轮)必须持有效使用许可。分支判定与
|
||
// FlowInstanceService.applyGuarded 同键同序;完整命令协议(requestId/指纹/服务端轮次)归 T04。
|
||
FlowSourcePrincipalResolver.Principal source = requireApplySource(cleanAppKey);
|
||
requireUsageGrantGuardedApply(schId, body.getFlowGuid(), source, body.getBizType(),
|
||
body.getBizId(), body.getRequestToken(), body.getAttempt(),
|
||
Boolean.TRUE.equals(body.getRetryOnly()));
|
||
FlwInstance existing = sourceScopedInstance(schId, source, body.getBizType(), body.getBizId());
|
||
boolean replay = existing != null && body.getRequestToken() != null
|
||
&& !body.getRequestToken().trim().isEmpty()
|
||
&& body.getRequestToken().trim().equals(existing.getApplyRequestToken());
|
||
// 同 token 用已受理版本构造原指纹;归属和异载荷冲突仍由 applyGuarded 原守卫裁决。
|
||
FlwDef def = replay ? defRepository.findById(existing.getDefId())
|
||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "原请求的定义版本不存在"))
|
||
: requirePublishedDefForApply(schId, body.getFlowGuid());
|
||
if (replay && !body.getFlowGuid().equals(def.getFlowGuid())) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件绑定的流程线不同");
|
||
}
|
||
if (!replay) requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
||
if (!replay && 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());
|
||
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);
|
||
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
|
||
cmd.payloadHash = payloadHash(cmd);
|
||
cmd.sourcePrincipalId = source.id;
|
||
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
|
||
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
|
||
body.getExpectedPrevRound());
|
||
|
||
FlowInstanceService.GuardedOutcome outcome;
|
||
try {
|
||
outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
|
||
} catch (DataIntegrityViolationException concurrent) {
|
||
// 事务边界外重读:另一个并发请求已用同一业务键建好办件
|
||
return fromConcurrent(schId, cmd, policy);
|
||
}
|
||
requireNotConflict(outcome);
|
||
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
|
||
vo.setInstanceId(outcome.instanceId);
|
||
vo.setDuplicated(outcome.duplicated);
|
||
vo.setBranch(outcome.branch);
|
||
vo.setApplyRound(outcome.applyRound);
|
||
instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
|
||
int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
|
||
int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
|
||
if (currentRound == outcomeRound) {
|
||
vo.setStatus(current.getStatus());
|
||
vo.setVersion(current.getVersion());
|
||
} // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。
|
||
|
||
});
|
||
return vo;
|
||
}
|
||
|
||
/**
|
||
* 新发起的当前有效发布版解析(2026-10-25 T03 收口):最高已发布版 + 线级停发守卫——
|
||
* 整线停发(line_offline=1)即拒绝新发起,且不回退到更旧的已发布版本(停发不是降级)。
|
||
* 在途实例不受影响(推进按实例固定 defId,不经本方法)。
|
||
*/
|
||
private FlwDef requirePublishedDefForApply(Long schId, String flowGuid) {
|
||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
|
||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||
"流程不存在或未发布:" + flowGuid));
|
||
if (def.getLineOffline() != null && def.getLineOffline() == 1) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"流程线已停发,暂停新发起(在途办件不受影响):" + flowGuid);
|
||
}
|
||
return def;
|
||
}
|
||
|
||
/**
|
||
* T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
|
||
* 既有实例状态——在途(10/50)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
|
||
* 重启即新一轮申报。
|
||
*
|
||
* <p><b>F01 整改(2026-10-29):本方法不再有直通分支。</b>旧实现
|
||
* {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"闸门未装配"、
|
||
* "灰度开关关闭"、"缺验签主体"三种情况都变成放行。现三者全部 fail-closed 拒绝。</p>
|
||
*/
|
||
private void requireUsageGrantLegacyApply(Long schId, String flowGuid,
|
||
FlowSourcePrincipalResolver.Principal source,
|
||
String bizType, String bizId) {
|
||
if (!legacyApplyIsReceiptOnly(sourceScopedInstance(schId, source, bizType, bizId))) {
|
||
requireGrantGuard().requireNewApplyGrant(schId, flowGuid, source);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting}
|
||
* 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly});
|
||
* 新申报(CREATED/RESTARTED)必须过 {@link FlowUsageGrantGuard#requireNewApplyGrant}。
|
||
* 完整命令协议统一归 T04。
|
||
*/
|
||
private void requireUsageGrantGuardedApply(Long schId, String flowGuid,
|
||
FlowSourcePrincipalResolver.Principal source,
|
||
String bizType, String bizId,
|
||
String requestToken, Integer attempt, boolean retryOnly) {
|
||
if (!guardedApplyIsReceiptOnly(sourceScopedInstance(schId, source, bizType, bizId),
|
||
requestToken, attempt, retryOnly)) {
|
||
requireGrantGuard().requireNewApplyGrant(schId, flowGuid, source);
|
||
}
|
||
}
|
||
|
||
/** 闸门缺失=拒绝而不是放行(生产装配必有该 bean;手工构造服务时必须显式注入测试替身)。 */
|
||
private FlowUsageGrantGuard requireGrantGuard() {
|
||
if (usageGrantGuard == null) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "应用使用许可闸门未装配,拒绝发起");
|
||
}
|
||
return usageGrantGuard;
|
||
}
|
||
|
||
/**
|
||
* 验签上下文 → 可信来源主体(F02)。appKey 缺失、UserBase 不可达或注册表查无一律拒绝,
|
||
* 不回退成"无来源"的宽松路径;通道未装配同样拒绝。
|
||
*/
|
||
private FlowSourcePrincipalResolver.Principal requireApplySource(String appKey) {
|
||
if (sourcePrincipalResolver == null) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析通道未装配,拒绝发起");
|
||
}
|
||
return sourcePrincipalResolver.resolve(appKey);
|
||
}
|
||
|
||
/** 来源分区的业务坐标回读(与 {@code uk_flw_instance_legacy} 同列同序)。 */
|
||
private FlwInstance sourceScopedInstance(Long schId, FlowSourcePrincipalResolver.Principal source,
|
||
String bizType, String bizId) {
|
||
if (source == null) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能按业务坐标定位办件");
|
||
}
|
||
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||
schId, source.id, bizType, bizId, LEGACY_SLOT).orElse(null);
|
||
}
|
||
|
||
/** 旧 OAPI 入口的坐标槽位(正式命令走 NULL 槽位,两条分区互不复用)。 */
|
||
private static final Integer LEGACY_SLOT = 1;
|
||
|
||
/**
|
||
* v1 回执判定:true=该请求只会命中幂等回读(不建件不重启),false=会触发新申报或 R4 重启。
|
||
* 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。
|
||
*/
|
||
static boolean legacyApplyIsReceiptOnly(FlwInstance existing) {
|
||
return existing != null
|
||
&& (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|
||
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode());
|
||
}
|
||
|
||
/**
|
||
* v2 回执判定:与 {@code applyGuarded/settleExisting} 同键同序——
|
||
* SAME_REQUEST(同 token)→ REPLAY_ONLY(retryOnly)→ IDEMPOTENT(在途/通过)→
|
||
* STALE_ROUND(请求轮次≤行轮次,缺省按 1)四类回执返回 true;CREATED/RESTARTED 返回 false。
|
||
*/
|
||
static boolean guardedApplyIsReceiptOnly(FlwInstance existing, String requestToken,
|
||
Integer attempt, boolean retryOnly) {
|
||
if (existing == null) {
|
||
return false;
|
||
}
|
||
String token = requestToken == null ? null : requestToken.trim();
|
||
if (token != null && !token.isEmpty() && token.equals(existing.getApplyRequestToken())) {
|
||
return true;
|
||
}
|
||
if (retryOnly) {
|
||
return true;
|
||
}
|
||
if (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|
||
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
||
return true;
|
||
}
|
||
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
|
||
int incomingRound = attempt == null ? 1 : attempt;
|
||
return incomingRound <= currentRound;
|
||
}
|
||
|
||
/**
|
||
* T04:CONFLICT 分支统一翻译成业务异常——冲突不是成功,调用方(SH 恢复链)收到 4xx
|
||
* 走 UNKNOWN → 按来源+token 复核,不得把他人/其他轮次的在途件当自己的受理结果。
|
||
*/
|
||
private static void requireNotConflict(FlowInstanceService.GuardedOutcome outcome) {
|
||
if (outcome != null && FlowApplyRetryModels.Branch.CONFLICT.equals(outcome.branch)) {
|
||
throw new BizException(ErrorCode.CUSTOM,
|
||
"该业务键已有在途/终态申报且请求标识不符(另一命令或另一轮次),"
|
||
+ "请按实例查询恢复,禁止跨命令认领;终态后重新申报请走显式重提");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 受理载荷指纹(T04):SHA-256(流序规范化) over flowGuid|bizType|bizId|submitterId|bizTitle|
|
||
* bizUrl|bizSnapshot|formData|assignJson。重发链载荷逐字节同源 → 同指纹;
|
||
* 任何业务字段改动 → 不同指纹 → 冲突。expectedDefVersion 刻意不参与(重试期间发布新版本
|
||
* 不应把同一次命令误判成两份)。
|
||
*/
|
||
private String payloadHash(FlowInstanceService.ApplyCommand cmd) {
|
||
String canon = String.join("|",
|
||
nullSafe(cmd.def == null ? null : cmd.def.getFlowGuid()),
|
||
nullSafe(cmd.bizType), nullSafe(cmd.bizId),
|
||
cmd.submitterId == null ? "" : String.valueOf(cmd.submitterId),
|
||
nullSafe(cmd.bizTitle), nullSafe(cmd.bizUrl),
|
||
nullSafe(cmd.bizSnapshot), nullSafe(cmd.formDataJson), nullSafe(cmd.assignJson));
|
||
try {
|
||
byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
|
||
.digest(canon.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||
StringBuilder sb = new StringBuilder(64);
|
||
for (byte b : digest) {
|
||
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
|
||
}
|
||
return sb.toString();
|
||
} catch (java.security.NoSuchAlgorithmException e) {
|
||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||
}
|
||
}
|
||
|
||
private static String nullSafe(String v) {
|
||
return v == null ? "" : v;
|
||
}
|
||
|
||
/**
|
||
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST},
|
||
* 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。
|
||
*/
|
||
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
|
||
FlowInstanceService.ApplyCommand cmd,
|
||
FlowInstanceService.ApplyRetryPolicy policy) {
|
||
FlwInstance existing = instanceRepository
|
||
.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||
schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, LEGACY_SLOT)
|
||
.orElse(null);
|
||
if (existing == null) {
|
||
throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试");
|
||
}
|
||
if (!cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
|
||
throw new BizException(ErrorCode.FORBIDDEN,
|
||
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
|
||
}
|
||
if (existing.getSubmitterId() != null && cmd.submitterId != null
|
||
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
|
||
}
|
||
String token = policy.requestToken == null ? null : policy.requestToken.trim();
|
||
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
|
||
vo.setInstanceId(existing.getId());
|
||
vo.setDuplicated(true);
|
||
vo.setApplyRound(existing.getApplyRound());
|
||
vo.setStatus(existing.getStatus());
|
||
vo.setVersion(existing.getVersion());
|
||
if (token != null && !token.equals(existing.getApplyRequestToken())) {
|
||
// T04:并发撞键且 token 不符=两份不同命令抢同一业务键——后到者冲突,不伪装幂等成功
|
||
//(旧 CONCURRENT_CREATED 分支只保留给无 token 的存量调用方)。
|
||
log.warn("[flow-center] 并发建件 token 不符(冲突拒绝):instance={} biz={}/{}",
|
||
existing.getId(), cmd.bizType, cmd.bizId);
|
||
throw new BizException(ErrorCode.CUSTOM,
|
||
"该业务键已被另一并发命令创建(请求标识不符),请按实例查询恢复,不要换 ID 重发");
|
||
}
|
||
vo.setBranch(token != null ? FlowApplyRetryModels.Branch.SAME_REQUEST
|
||
: FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
|
||
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}",
|
||
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch());
|
||
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) 拒绝发起。
|
||
*/
|
||
/**
|
||
* T06 required 覆盖判定(包私有纯函数,规则单测钉住):定义里全部开启且必选的指派节点中,
|
||
* 提交项未覆盖的 nodeId 列表。空 assigns + 存在必选节点 ⇒ 全部缺失(空 assigns 不绕过)。
|
||
*/
|
||
static List<String> missingRequiredAssignNodes(FlowDefModel model, List<OapiModels.AssignItem> assigns) {
|
||
java.util.Set<String> submitted = new java.util.HashSet<>();
|
||
if (assigns != null) {
|
||
for (OapiModels.AssignItem item : assigns) {
|
||
if (item.getNodeId() != null) {
|
||
submitted.add(item.getNodeId());
|
||
}
|
||
}
|
||
}
|
||
List<String> missing = new ArrayList<>();
|
||
for (FlowDefModel.Node n : model.nodes) {
|
||
if (n == null || !FlowDefModel.TYPE_APPROVAL.equals(n.type) || n.assignPolicy == null
|
||
|| !Boolean.TRUE.equals(n.assignPolicy.required)) {
|
||
continue;
|
||
}
|
||
if (!submitted.contains(n.id)) {
|
||
missing.add(n.id);
|
||
}
|
||
}
|
||
return missing;
|
||
}
|
||
|
||
private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns,
|
||
Integer submitterId) {
|
||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||
// T06(2026-10-25)required 覆盖终审:遍历<b>定义里全部</b>开启且必选的指派节点——
|
||
// 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。
|
||
List<String> missing = missingRequiredAssignNodes(model, assigns);
|
||
if (!missing.isEmpty()) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"必选指派节点未提交人选(node=" + String.join(", ", missing) + "),请重新选择后提交");
|
||
}
|
||
if (assigns == null || assigns.isEmpty()) {
|
||
return null;
|
||
}
|
||
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());
|
||
}
|
||
// D11:手选人先逐人资格终审(存在/启用/校域),再核候选集——任一人不合格整体拒绝,
|
||
// 快照与实例均不落;不能只信前端候选列表,也不能以“在候选 ID 中”替代资格。
|
||
List<Map<String, Object>> rows = new ArrayList<>();
|
||
for (Integer uid : uids) {
|
||
String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid);
|
||
Map<String, Object> row = new HashMap<>();
|
||
row.put("userId", uid);
|
||
row.put("userName", userName);
|
||
rows.add(row);
|
||
}
|
||
// 候选集取 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());
|
||
}
|
||
}
|
||
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) {
|
||
requireFormalPreviewGrant(schId, flowGuid);
|
||
if (schId == null || flowGuid == null || submitterId == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId");
|
||
}
|
||
FlwDef def = requirePublishedDefForApply(schId, 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 = findByBizForOapi(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");
|
||
}
|
||
// D11/F02:OAPI 列表一律按可信来源分区,A 应用不会看到 B 应用的待办。
|
||
// 本次改造之前落库、来源列为空的实例不在本口径内(不回填、不猜测归属,见任务书 §2.3)。
|
||
Page<FlwTask> p = taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(),
|
||
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.pageSelf(schId, FlowOapiSourceScope.current(),
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 按业务单据查当前人待办(v2.1 应用内审批的业务详情卡片数据源):
|
||
* 补齐 tasks/todo 缺的那一侧——todo 是"人 → 单据列表",本方法是"单据 → 我的待办",
|
||
* 子应用据此判断"当前人是否该办件的待办人",不必再拉全量待办在前端过滤。
|
||
* <p>治理口径:
|
||
* <ul>
|
||
* <li>只返回 {@code assigneeId=operatorId} 且 status=PENDING 的任务;他人待办与"办件不存在"
|
||
* 一律出空数组(HTTP 200),出参不区分二者,避免用本端点探测别人的办件;</li>
|
||
* <li>租户收敛在定位查询本身({@code findBySchIdAndBizTypeAndBizId}),bizType 不匹配即空;</li>
|
||
* <li>{@code outlets} 只为当前人的任务计算,且出边 >1 才透出(与
|
||
* {@code FlowQueryService.detail} 的办理面板口径一致——单出口无需选择,不给前端多余分支);</li>
|
||
* <li>不透出他人办理人、意见或定义内部 id;taskId 是办理必需的运行时身份,保留。</li>
|
||
* </ul>
|
||
*/
|
||
public OapiModels.ByBizTasksVo tasksByBiz(Long schId, String bizType, String bizId, Integer operatorId) {
|
||
if (schId == null || schId <= 0 || operatorId == null || operatorId <= 0) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/operatorId(均须为正)");
|
||
}
|
||
String cleanBizType = emptyToNull(bizType);
|
||
String cleanBizId = emptyToNull(bizId);
|
||
if (cleanBizType == null || cleanBizId == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:bizType/bizId");
|
||
}
|
||
OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo();
|
||
vo.setBizType(cleanBizType);
|
||
vo.setBizId(cleanBizId);
|
||
FlwInstance instance = findByBizForOapi(schId, cleanBizType, cleanBizId).orElse(null);
|
||
if (instance == null) {
|
||
return vo;
|
||
}
|
||
vo.setInstanceId(instance.getId());
|
||
vo.setStatus(instance.getStatus());
|
||
vo.setCurrentNodes(instance.getCurrentNodes());
|
||
List<FlwTask> mine = taskRepository
|
||
.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()).stream()
|
||
.filter(t -> operatorId.equals(t.getAssigneeId()))
|
||
.sorted(java.util.Comparator.comparing(FlwTask::getId))
|
||
.collect(java.util.stream.Collectors.toList());
|
||
if (mine.isEmpty()) {
|
||
return vo;
|
||
}
|
||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||
FlowDefModel model = def == null ? null : flowEngine.parseModel(def);
|
||
for (FlwTask t : mine) {
|
||
vo.getTasks().add(toByBizTaskVo(t, model));
|
||
}
|
||
return vo;
|
||
}
|
||
|
||
private OapiModels.ByBizTaskVo toByBizTaskVo(FlwTask t, FlowDefModel model) {
|
||
OapiModels.ByBizTaskVo vo = new OapiModels.ByBizTaskVo();
|
||
vo.setTaskId(t.getId());
|
||
vo.setNodeId(t.getNodeId());
|
||
vo.setNodeName(t.getNodeName());
|
||
vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
|
||
if (model == null) {
|
||
return vo;
|
||
}
|
||
List<FlowDefModel.Edge> outs = model.outEdges(t.getNodeId());
|
||
if (outs.size() > 1) {
|
||
for (FlowDefModel.Edge e : outs) {
|
||
FlowDefModel.Node to = model.node(e.to);
|
||
OapiModels.ByBizOutletVo outlet = new OapiModels.ByBizOutletVo();
|
||
outlet.setName(e.name);
|
||
outlet.setToNodeId(e.to);
|
||
outlet.setToNodeName(to == null || to.name == null || to.name.isEmpty() ? e.to : to.name);
|
||
vo.getOutlets().add(outlet);
|
||
}
|
||
}
|
||
return vo;
|
||
}
|
||
|
||
/**
|
||
* 同意(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) {
|
||
requireActorProof(schId, taskId, operatorId);
|
||
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) {
|
||
requireActorProof(schId, taskId, operatorId);
|
||
requireOperator(schId, taskId, operatorId, operatorName);
|
||
return taskService.handle(schId, taskId, false, opinion, operatorId);
|
||
}
|
||
|
||
/**
|
||
* R1(Codex D03):oapi 审批统一过 ActorContext 门——门禁本身无条件 fail-closed;
|
||
* 未装配(手工构造的老单测)时同样拒绝,不存在"未装配=直通"豁免。
|
||
*/
|
||
private void requireActorProof(Long schId, Long taskId, Integer operatorId) {
|
||
if (actorContextGate != null) {
|
||
actorContextGate.requireActorProof(schId, taskId, operatorId);
|
||
return;
|
||
}
|
||
log.warn("[actor-gate] oapi 快捷审批被拒(门禁未装配,fail-closed):schId={} taskId={} operatorId={}",
|
||
schId, taskId, operatorId);
|
||
throw new BizException(ErrorCode.FORBIDDEN,
|
||
"SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。"
|
||
+ "请通过流程中心人工办理页(CAS)办理");
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* D14 发起人重提(托管接入件,2026-10-29)。治理口径与 {@link #withdraw} 一致:
|
||
* <ul>
|
||
* <li>来源:{@code FlowOapiSourceScope} 已按实例 id 核验归属,非本应用发起的办件到不了这里;</li>
|
||
* <li>身份:{@code submitterId} 与办件提交人逐字复核——签名只证明"哪个应用在调";</li>
|
||
* <li>状态:仅 50 退回待修改(幂等:已是 10 视为已重提,回原实例不重开一代任务);</li>
|
||
* <li>轮次/版本:不改 applyRound、不改 defId/defVersion(D04/D05 在途固定版本);</li>
|
||
* <li>业务字段:FL 不做 Diff,{@code formData} 为空即沿用原快照。</li>
|
||
* </ul>
|
||
*/
|
||
@Transactional
|
||
public void resubmit(Long schId, Long instanceId, Integer submitterId, String formData) {
|
||
throw new BizException(ErrorCode.FORBIDDEN,
|
||
"OAPI 重提缺少可信用户身份证明,请由原提交人通过 CAS 办理页重提");
|
||
}
|
||
|
||
/**
|
||
* 办件进度(结构化轨迹):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 = findByBizForOapi(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 (formalCommandEnabled && instanceId == null && !canReadFormalDefinition(schId, flowGuid)) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "没有此流程定义的读取权限");
|
||
}
|
||
|
||
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 java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
|
||
String source = FlowOapiSourceScope.current();
|
||
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByIdDesc(
|
||
school, source, type, biz);
|
||
}
|
||
|
||
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());
|
||
}
|
||
{
|
||
FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
|
||
com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null);
|
||
boolean permitted = grant != null && Integer.valueOf(1).equals(grant.getEnabled()) && source.id.equals(grant.getSourcePrincipalId());
|
||
if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
|
||
}
|
||
reg.setSourcePrincipalId(source.id);
|
||
}
|
||
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.setApplyRound(instance.getApplyRound());
|
||
vo.setRequestToken(instance.getApplyRequestToken());
|
||
vo.setApplyAppKey(instance.getApplyAppKey());
|
||
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();
|
||
}
|
||
}
|