feat(fl2): 落地部门目录 / 退回重提(D14) / 授权管理后端(基线 origin/main)
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 派生方法属另一实现线)
This commit is contained in:
chenxing
2026-10-30 10:15:12 +08:00
parent b369527fe1
commit d72128672f
52 changed files with 3208 additions and 859 deletions
@@ -145,8 +145,8 @@ public class OapiFlowService {
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
}
private <T> T inTransaction(
org.springframework.transaction.support.TransactionCallback<T> action) {
private FlowInstanceService.GuardedOutcome inTransaction(
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
}
@@ -229,6 +229,7 @@ public class OapiFlowService {
/** 代发起(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
@@ -238,10 +239,10 @@ public class OapiFlowService {
}
requireSchoolMember(schId, body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
// T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报,
// 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。
FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey);
requireUsageGrant(schId, body.getFlowGuid(), source);
// 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()));
@@ -260,15 +261,9 @@ public class OapiFlowService {
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, source.appCode, assignJson);
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
cmd.sourcePrincipalId = source.id;
cmd.payloadHash = payloadHash(cmd);
FlowInstanceService.ApplyOutcome outcome;
try { outcome = inTransaction(tx -> instanceService.apply(cmd)); }
catch (DataIntegrityViolationException concurrent) {
// Outside the failed transaction: retry the SAME source-scoped lookup, never a broad business-key read.
outcome = inTransaction(tx -> instanceService.apply(cmd));
}
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
vo.setInstanceId(outcome.getInstanceId());
vo.setDuplicated(outcome.isDuplicated());
@@ -302,9 +297,15 @@ public class OapiFlowService {
}
requireSchoolMember(schId, body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey);
FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
schId, source.id, body.getBizType(), body.getBizId(), 1).orElse(null);
// 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());
@@ -328,15 +329,10 @@ public class OapiFlowService {
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, source.appCode, assignJson);
cmd.sourcePrincipalId = source.id;
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
cmd.payloadHash = payloadHash(cmd);
// Only an exact, source-owned, already accepted command can recover after revocation.
boolean provenReceipt = replay && source.id.equals(existing.getSourcePrincipalId())
&& existing.getApplyPayloadHash() != null && existing.getApplyPayloadHash().equals(cmd.payloadHash)
&& java.util.Objects.equals(existing.getSubmitterId(), cmd.submitterId);
if (!provenReceipt) requireUsageGrant(schId, body.getFlowGuid(), source);
cmd.sourcePrincipalId = source.id;
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
body.getExpectedPrevRound());
@@ -385,28 +381,76 @@ public class OapiFlowService {
/**
* T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
* 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
* 重启即新一轮申报。每次均强制通过独立的学校开通与流程使用授权校验。
* 既有实例状态——在途(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 FlowSourcePrincipalResolver.Principal requireApplySource(Long school, String appKey) {
if (school == null || school <= 0 || sourcePrincipalResolver == null) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校或来源解析服务");
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);
}
private void requireUsageGrant(Long school, String flow, FlowSourcePrincipalResolver.Principal source) {
if (usageGrantGuard == null) throw new BizException(ErrorCode.FORBIDDEN, "流程使用授权服务未装配");
usageGrantGuard.requireNewApplyGrant(school, flow, source);
/** 来源分区的业务坐标回读(与 {@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
&& (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
&& (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode());
}
@@ -427,7 +471,7 @@ public class OapiFlowService {
if (retryOnly) {
return true;
}
if (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
if (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
return true;
}
@@ -485,18 +529,40 @@ public class OapiFlowService {
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
FlowInstanceService.ApplyCommand cmd,
FlowInstanceService.ApplyRetryPolicy policy) {
FlowInstanceService.GuardedOutcome outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
requireNotConflict(outcome);
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(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 && cmd.sourcePrincipalId.equals(current.getSourcePrincipalId())) {
vo.setStatus(current.getStatus()); vo.setVersion(current.getVersion());
}
});
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;
}
@@ -711,9 +777,10 @@ public class OapiFlowService {
if (schId == null || assigneeId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
}
Page<FlwTask> p = formalCommandEnabled
? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)))
: taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)));
// 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()
@@ -732,9 +799,8 @@ public class OapiFlowService {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
}
Page<FlwInstance> p = formalCommandEnabled
? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)))
: instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)));
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);
@@ -942,6 +1008,22 @@ public class OapiFlowService {
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 已移交);
@@ -1052,10 +1134,11 @@ public class OapiFlowService {
return out;
}
/** 进度/换图共用的办件定位(schId 收敛在次) */
/** 进度/换图共用的办件定位(schId 收敛在次;来源分区必填,缺来源即拒) */
private java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(
school, FlowOapiSourceScope.current(), type, biz);
String source = FlowOapiSourceScope.current();
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByIdDesc(
school, source, type, biz);
}
private FlwInstance requireInstance(Long schId, Long instanceId) {
@@ -1324,14 +1407,10 @@ public class OapiFlowService {
}
{
FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
if (formalCommandEnabled) {
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 订阅权限");
}
} else {
requireUsageGrant(schId, body.getFlowGuid(), source);
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);
}