diff --git a/ci/flow-test-schema.sql b/ci/flow-test-schema.sql index 7b1c308..0f16c4c 100644 --- a/ci/flow-test-schema.sql +++ b/ci/flow-test-schema.sql @@ -339,7 +339,7 @@ CREATE TABLE `flw_instance` ( `submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, `version` int NOT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `uk_flw_instance_legacy` (`sch_id`,`biz_type`,`biz_id`,`legacy_slot`), + UNIQUE KEY `uk_flw_instance_legacy` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`legacy_slot`), UNIQUE KEY `uk_flw_instance_round` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`apply_round`), KEY `idx_flw_inst_submitter` (`sch_id`,`submitter_id`,`status`), KEY `idx_flw_inst_status_time` (`sch_id`,`status`,`submit_time`) diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java index b8e8143..67e9426 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java @@ -16,7 +16,7 @@ import java.time.LocalDateTime; * 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存 * biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为 * (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。 - * sourcePrincipalId=NULL / legacySlot=1 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。 + * 旧入口以 sourcePrincipalId + legacySlot=1 实现每来源一业务一行;正式命令 legacySlot=NULL。 * *

version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件), * 由 JPA @Version 在 flush 时自增。 @@ -26,7 +26,7 @@ import java.time.LocalDateTime; @Entity @Table(name = "flw_instance", uniqueConstraints = { - @UniqueConstraint(name = "uk_flw_instance_legacy", columnNames = {"sch_id", "biz_type", "biz_id", "legacy_slot"}), + @UniqueConstraint(name = "uk_flw_instance_legacy", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "legacy_slot"}), @UniqueConstraint(name = "uk_flw_instance_round", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "apply_round"})}, indexes = { @Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"), @@ -34,7 +34,7 @@ import java.time.LocalDateTime; public class FlwInstance extends BaseEntity { @Column(name="source_principal_id",length=36,updatable=false) private String sourcePrincipalId; - /** 1 for legacy writers, NULL for formal commands. Migration must drop the old three-column UK. */ + /** 1 for source-scoped legacy API writers, NULL for formal commands. */ @Column(name="legacy_slot") private Integer legacySlot = 1; diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java index 05e6cd0..0bdcb45 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java @@ -39,7 +39,10 @@ public interface FlwInstanceRepository extends JpaRepository @Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc") Page pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable); - /** biz uk 幂等主键 */ + Optional findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + Long school, String source, String bizType, String bizId, Integer slot); + + /** Unknown-source rows are never used to recover a caller's instance. */ @Query("select i from FlwInstance i where i.schId=?1 and i.bizType=?2 and i.bizId=?3 and i.sourcePrincipalId is null") Optional findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java index 0f54436..5f843d1 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java @@ -71,51 +71,44 @@ public class FlowInstanceService { * null=存量调用方,指纹核对缺省关)。 */ public String payloadHash; + /** Assigned only by authenticated server-side source resolution; not a request DTO field. */ + public String sourcePrincipalId; } /** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */ @Transactional public ApplyOutcome apply(ApplyCommand cmd) { - FlwInstance existing = instanceRepository - .findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null); + FlwInstance existing = findOwned(cmd); if (existing != null) { + requireSameOwner(existing, cmd); int status = existing.getStatus(); if (status == FlwEnums.InstanceStatus.APPROVING.getCode() || status == FlwEnums.InstanceStatus.COMPLETED.getCode()) { return new ApplyOutcome(existing.getId(), true); } - // 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START) restart(existing, cmd); return new ApplyOutcome(existing.getId(), true); } - FlwInstance instance = new FlwInstance(); - instance.setSchId(cmd.schId); - instance.setDefId(cmd.def.getId()); - instance.setDefCode(cmd.def.getCode()); - instance.setDefVersion(cmd.def.getVersion()); - instance.setBizType(cmd.bizType); - instance.setBizId(cmd.bizId); - instance.setBizTitle(cmd.bizTitle); - instance.setBizUrl(cmd.bizUrl); - instance.setBizSnapshot(cmd.bizSnapshot); - instance.setFormDataJson(cmd.formDataJson); - instance.setAssignJson(cmd.assignJson); - instance.setApplyAppKey(cmd.applyAppKey); - instance.setApplyAppCode(cmd.applyAppCode); - instance.setSubmitterId(cmd.submitterId); - instance.setSubmitterName(cmd.submitterName); - instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); - instance.setSubmitTime(LocalDateTime.now()); - instanceRepository.saveAndFlush(instance); - appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请", - FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName); - flowEngine.startAdvance(instance, cmd.def); - publishSubmitted(instance); - return new ApplyOutcome(instance.getId(), false); + FlwInstance created = createInstance(cmd, java.util.UUID.randomUUID().toString(), 1, cmd.sourcePrincipalId, 1, true); + return new ApplyOutcome(created.getId(), false); + } + + private FlwInstance findOwned(ApplyCommand cmd) { + if (cmd.schId == null || cmd.schId <= 0 || cmd.sourcePrincipalId == null + || !cmd.sourcePrincipalId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { + throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源或有效申报学校"); + } + FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, 1).orElse(null); + if (existing == null && instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc( + cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId).isPresent()) { + throw new BizException(ErrorCode.FORBIDDEN, "该业务对象已有正式命令实例,请使用原命令通道"); + } + return existing; } private void restart(FlwInstance instance, ApplyCommand cmd) { - restart(instance, cmd, null, null); + restart(instance, cmd, java.util.UUID.randomUUID().toString(), instance.getApplyRound()); } /** @@ -349,15 +342,14 @@ public class FlowInstanceService { */ @Transactional public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) { - FlwInstance existing = instanceRepository - .findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null); + FlwInstance existing = findOwned(cmd); if (existing != null) { return settleExisting(existing, cmd, policy); } String token = policy == null ? null : trimToNull(policy.requestToken); Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt; - createInstance(cmd, token, round); - return new GuardedOutcome(instanceOf(cmd), false, FlowApplyRetryModels.Branch.CREATED, round); + FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, 1, true); + return new GuardedOutcome(created.getId(), false, FlowApplyRetryModels.Branch.CREATED, round); } private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) { @@ -441,14 +433,15 @@ public class FlowInstanceService { publishSubmitted(instance); } - private void createInstance(ApplyCommand cmd, String token, Integer round) { - createInstance(cmd, token, round, null); + private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) { + return createInstance(cmd, token, round, source, null, false); } - private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) { + private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source, + Integer legacySlot, boolean start) { FlwInstance instance = new FlwInstance(); instance.setSourcePrincipalId(source); - instance.setLegacySlot(source == null ? 1 : null); + instance.setLegacySlot(legacySlot); instance.setSchId(cmd.schId); instance.setDefId(cmd.def.getId()); instance.setDefCode(cmd.def.getCode()); @@ -472,31 +465,19 @@ public class FlowInstanceService { instanceRepository.saveAndFlush(instance); appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请", FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName); - if (source == null) { + if (start) { flowEngine.startAdvance(instance, cmd.def); publishSubmitted(instance); } return instance; } - /** - * 旧 {@link #apply(ApplyCommand)} 的建件段复用(行为逐字不变,只是把 new 出来的那段抽出来)。 - * 这里不复用 createInstance 是因为旧路径不写 token/round(保持存量行 NULL,不伪装"有守卫")。 - */ - private Long instanceOf(ApplyCommand cmd) { - FlwInstance created = instanceRepository - .findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null); - return created == null ? null : created.getId(); - } - /** * 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用 - * 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照, - * 不能因为"当年没记"就把存量件判成别人的。 + * 相同 bizType 键当自己的结果")。来源必须存在且匹配,不用 appKey 快照替代来源 UUID。 */ private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) { - if (existing.getApplyAppKey() != null && cmd.applyAppKey != null - && !existing.getApplyAppKey().equals(cmd.applyAppKey)) { + if (cmd.sourcePrincipalId == null || !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) { throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")"); } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java b/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java index f70ed42..79a22dc 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java @@ -6,7 +6,6 @@ import com.yunhai.flowcenter.repository.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -22,11 +21,9 @@ public class FlowOapiSourceScope implements HandlerInterceptor { private final FlowSourcePrincipalResolver resolver; private final FlwInstanceRepository instances; private final FlwTaskRepository tasks; - @Value("${flow-center.formal-command.enabled:false}") private boolean enabled; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - if (!enabled) return true; String source = resolver.resolve((String) request.getAttribute("yh.app.auth")).id; request.setAttribute(ATTRIBUTE, source); String id = request.getParameter("instanceId"); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java b/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java index 56da8dc..a2bc4c5 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java @@ -18,8 +18,12 @@ public class FlowSourcePrincipalResolver { if (authenticatedAppKey == null || authenticatedAppKey.trim().isEmpty()) { throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已认证的调用应用"); } - JsonNode app = appClient.getAppByKey(authenticatedAppKey); - if (app == null || !app.path("id").isIntegralNumber()) { + JsonNode app; + try { app = appClient.getAppByKey(authenticatedAppKey.trim()); } + catch (RuntimeException unavailable) { + throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体查询不可用"); + } + if (app == null || !app.path("id").isIntegralNumber() || app.path("id").intValue() <= 0) { throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体不可解析"); } String source = app.path("sourcePrincipalId").asText(""); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java index 3e0e9ea..a39df24 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java @@ -32,9 +32,6 @@ public class FlowUsageGrantAdminService { @Autowired(required = false) private OpenApiAppClient appClient; - @org.springframework.beans.factory.annotation.Value("${flow-center.formal-command.enabled:false}") - private boolean formalEnabled; - public List listByFlow(Long schId, String flowGuid) { return grantRepository.findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(schId, flowGuid); } @@ -62,8 +59,7 @@ public class FlowUsageGrantAdminService { throw new BizException(ErrorCode.PARAM_ERROR, "appKey 未能在 UserBase 解析出稳定应用主体,拒绝授权:" + cleanKey); } - if (activationClient.isEnforce() - && !activationClient.isAppActivatedAtSchool(appId, schId)) { + if (!activationClient.isAppActivatedStrict(appId, schId)) { throw new BizException(ErrorCode.FORBIDDEN, "该应用未在学校 " + schId + " 开通,不能授予流程使用许可"); } @@ -76,14 +72,13 @@ public class FlowUsageGrantAdminService { grant.setFlowGuid(flowGuid.trim()); grant.setAppId(appId); } - if (formalEnabled) { + { String source = registered.path("sourcePrincipalId").asText(""); try { if (!java.util.UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException(); } catch (IllegalArgumentException invalid) { throw new BizException(ErrorCode.FORBIDDEN, "UserBase 稳定来源未迁移,不能发放正式许可"); } - if (!activationClient.isAppActivatedStrict(appId, schId)) throw new BizException(ErrorCode.FORBIDDEN, "学校未开通"); grant.setSourcePrincipalId(source); } grant.setAppKeySnapshot(cleanKey); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java index f8c8512..f4d26c9 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java @@ -14,7 +14,7 @@ import org.springframework.stereotype.Service; /** * 应用使用许可闸门(2026-10-25 T01):新发起必须持有生效的 (school, flowGuid, appId) 许可。 * - *

三层独立事实,缺一不可(enforce 开启时):

+ *

三层独立事实,缺一不可(所有环境):

*
    *
  1. 稳定主体:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用, * 不是主体;主体解析不可达=fail-closed 拒绝);
  2. @@ -37,38 +37,44 @@ public class FlowUsageGrantGuard { @Autowired(required = false) private OpenApiAppClient appClient; - /** enforce 关闭时直通(灰度开关,与 FC 同款迁移策略);开启后任何一关不过即拒 */ + /** Mandatory in every profile; the historical enforce flag cannot disable authorization. */ public boolean isEnforce() { - return activationClient.isEnforce(); + return true; } /** * 新发起资格核验:返回稳定主体 appId(供命令快照/审计使用)。 * - * @throws BizException enforce 开启时:主体解析不可用、无生效许可、未在该校开通、 + * @throws BizException 所有环境:主体解析不可用、无生效许可、未在该校开通、 * UB 资格核验不可用——四类都拒绝,不猜不降级 */ public Integer requireNewApplyGrant(Long schId, String flowGuid, String appKey) { - if (!isEnforce()) { - return null; - } if (schId == null || flowGuid == null || flowGuid.trim().isEmpty() || appKey == null || appKey.trim().isEmpty()) { throw new BizException(ErrorCode.FORBIDDEN, "缺少使用许可判定所需的学校/流程线/调用主体"); } - Integer appId = resolveAppId(appKey.trim()); - if (appId == null) { + return requireNewApplyGrant(schId, flowGuid, new FlowSourcePrincipalResolver(appClient).resolve(appKey)); + } + + /** UserBase app-school and FL app-flow are independent mandatory gates. */ + public Integer requireNewApplyGrant(Long schId, String flowGuid, FlowSourcePrincipalResolver.Principal source) { + if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty() || source == null) { + throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校/流程线/来源主体"); + } + Integer appId = source.appId; + if (appId == null || appId <= 0 || source.id == null) { throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析不可用,已按未授权拒绝"); } FlwUsageGrant grant = grantRepository.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId) .orElse(null); - if (grant == null || !Integer.valueOf(1).equals(grant.getEnabled())) { + if (grant == null || !Integer.valueOf(1).equals(grant.getEnabled()) + || !source.id.equals(grant.getSourcePrincipalId())) { log.warn("[usage-grant] 拒绝新发起(无生效使用许可):schId={} flowGuid={} appId={} appKey={}", - schId, flowGuid, appId, appKey); + schId, flowGuid, appId, source.appKey); throw new BizException(ErrorCode.FORBIDDEN, "该应用未获此流程线的使用许可(school=" + schId + " flow=" + flowGuid + ")"); } - if (!activationClient.isAppActivatedAtSchool(appId, schId)) { + if (!activationClient.isAppActivatedStrict(appId, schId)) { log.warn("[usage-grant] 拒绝新发起(应用未在该校开通):schId={} appId={}", schId, appId); throw new BizException(ErrorCode.FORBIDDEN, "该应用未在学校 " + schId + " 开通,不能发起新申报"); @@ -76,21 +82,4 @@ public class FlowUsageGrantGuard { return appId; } - /** appKey → UserBase 稳定主体主键;注册表不可达/查无/缺 id 一律 null(调用方拒绝) */ - private Integer resolveAppId(String appKey) { - if (appClient == null) { - return null; - } - try { - JsonNode registered = appClient.getAppByKey(appKey); - if (registered == null || registered.isNull()) { - return null; - } - JsonNode idNode = registered.path("id"); - return idNode.isNumber() ? idNode.intValue() : null; - } catch (RuntimeException e) { - log.warn("[usage-grant] UserBase 主体解析不可用 appKey={}:{}", appKey, e.toString()); - return null; - } - } } diff --git a/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java b/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java index edd91ea..e95fa4f 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java @@ -93,7 +93,9 @@ public class FormalApplyCommandService { coordinate.setBizType(request.getBizType()); coordinate.setBizId(request.getBizId()); coordinates.saveAndFlush(coordinate); } - if (instances.findBySchIdAndBizTypeAndBizId(school, request.getBizType(), request.getBizId()).isPresent()) { + if (instances.findBySchIdAndBizTypeAndBizId(school, request.getBizType(), request.getBizId()).isPresent() + || instances.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + school, source.id, request.getBizType(), request.getBizId(), 1).isPresent()) { return reject(command, "LEGACY_COORDINATE_NOT_MIGRATED", "旧业务坐标尚未审核迁移"); } if (coordinate.getActiveInstanceId() != null) return reject(command, "IN_FLIGHT", "同一业务坐标已有在途实例"); diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index 2f1d870..6e84806 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -145,8 +145,8 @@ public class OapiFlowService { FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson); } - private FlowInstanceService.GuardedOutcome inTransaction( - org.springframework.transaction.support.TransactionCallback action) { + private T inTransaction( + org.springframework.transaction.support.TransactionCallback action) { return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action); } @@ -229,7 +229,6 @@ 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 @@ -241,7 +240,8 @@ public class OapiFlowService { String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); // T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报, // 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。 - requireUsageGrantLegacyApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(), body.getBizId()); + FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey); + requireUsageGrant(schId, body.getFlowGuid(), source); FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid()); // T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行 requireExpressionVariablesPresent(def, asFormDataString(body.getFormData())); @@ -260,8 +260,15 @@ public class OapiFlowService { body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()), - cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); - FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd); + cleanAppKey, source.appCode, 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)); + } OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo(); vo.setInstanceId(outcome.getInstanceId()); vo.setDuplicated(outcome.isDuplicated()); @@ -295,15 +302,9 @@ public class OapiFlowService { } requireSchoolMember(schId, body.getSubmitterId()); String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); - // T01 使用许可(v2 路径):恢复感知——SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND - // 四类回执分支不拦(已受理命令的回执不因许可撤销丢失);CREATED/RESTARTED(新申报/新轮) - // 必须持有效使用许可。分支判定与 FlowInstanceService.applyGuarded 同键同序; - // 完整命令协议(requestId/指纹/服务端轮次)统一归 T04 重排,此处不重构。 - requireUsageGrantGuardedApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(), - body.getBizId(), body.getRequestToken(), body.getAttempt(), - Boolean.TRUE.equals(body.getRetryOnly())); - FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId( - schId, body.getBizType(), body.getBizId()).orElse(null); + FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey); + FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + schId, source.id, body.getBizType(), body.getBizId(), 1).orElse(null); boolean replay = existing != null && body.getRequestToken() != null && !body.getRequestToken().trim().isEmpty() && body.getRequestToken().trim().equals(existing.getApplyRequestToken()); @@ -327,9 +328,15 @@ public class OapiFlowService { body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()), - cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); + cleanAppKey, source.appCode, assignJson); + cmd.sourcePrincipalId = source.id; // 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); FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy( body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()), body.getExpectedPrevRound()); @@ -379,37 +386,18 @@ public class OapiFlowService { /** * T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠 * 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4 - * 重启即新一轮申报。guard 未装配或未 enforce 时零行为(灰度开关,见 {@link FlowUsageGrantGuard})。 + * 重启即新一轮申报。每次均强制通过独立的学校开通与流程使用授权校验。 */ - private void requireUsageGrantLegacyApply(Long schId, String flowGuid, String appKey, - String bizType, String bizId) { - if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) { - return; - } - FlwInstance existing = instanceRepository - .findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null); - if (!legacyApplyIsReceiptOnly(existing)) { - usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey); + private FlowSourcePrincipalResolver.Principal requireApplySource(Long school, String appKey) { + if (school == null || school <= 0 || sourcePrincipalResolver == null) { + throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校或来源解析服务"); } + return sourcePrincipalResolver.resolve(appKey); } - /** - * T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting} - * 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly}); - * 新申报(CREATED/RESTARTED)必须过 {@link FlowUsageGrantGuard#requireNewApplyGrant}。 - * 完整命令协议统一归 T04。 - */ - private void requireUsageGrantGuardedApply(Long schId, String flowGuid, String appKey, - String bizType, String bizId, - String requestToken, Integer attempt, boolean retryOnly) { - if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) { - return; - } - FlwInstance existing = instanceRepository - .findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null); - if (!guardedApplyIsReceiptOnly(existing, requestToken, attempt, retryOnly)) { - usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey); - } + private void requireUsageGrant(Long school, String flow, FlowSourcePrincipalResolver.Principal source) { + if (usageGrantGuard == null) throw new BizException(ErrorCode.FORBIDDEN, "流程使用授权服务未装配"); + usageGrantGuard.requireNewApplyGrant(school, flow, source); } /** @@ -497,39 +485,18 @@ public class OapiFlowService { private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId, FlowInstanceService.ApplyCommand cmd, FlowInstanceService.ApplyRetryPolicy policy) { - FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId( - schId, cmd.bizType, cmd.bizId).orElse(null); - if (existing == null) { - throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试"); - } - if (existing.getApplyAppKey() != null && cmd.applyAppKey != null - && !existing.getApplyAppKey().equals(cmd.applyAppKey)) { - 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(); + FlowInstanceService.GuardedOutcome outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy)); + requireNotConflict(outcome); 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()); + 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()); + } + }); return vo; } @@ -1087,9 +1054,8 @@ public class OapiFlowService { /** 进度/换图共用的办件定位(schId 收敛在次) */ private java.util.Optional findByBizForOapi(Long school, String type, String biz) { - return formalCommandEnabled - ? instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(school, FlowOapiSourceScope.current(), type, biz) - : instanceRepository.findBySchIdAndBizTypeAndBizId(school, type, biz); + return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc( + school, FlowOapiSourceScope.current(), type, biz); } private FlwInstance requireInstance(Long schId, Long instanceId) { @@ -1356,12 +1322,16 @@ public class OapiFlowService { reg.setAppKey(appKey); reg.setFlowGuid(body.getFlowGuid()); } - if (formalCommandEnabled) { + { 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 订阅权限"); + 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); } reg.setSourcePrincipalId(source.id); } diff --git a/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java b/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java index a958b08..f33664f 100644 --- a/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java +++ b/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java @@ -29,7 +29,7 @@ public class UserBaseActivationClient { private final YschOpenCall openCall; /** - * 资格核验总开关(T01 灰度):false=不核(存量环境兼容);true=强制核验。 + * 历史配置保留绑定;任何值均不能关闭学校开通校验。 * 生产开启前须确认 UserBase 已部署 {@code /oapi/app/activated} 且开通数据齐备。 */ @Value("${yh.flow.school-activation-enforce:false}") @@ -40,7 +40,7 @@ public class UserBaseActivationClient { } public boolean isEnforce() { - return enforce; + return true; } /** @@ -48,15 +48,12 @@ public class UserBaseActivationClient { * @throws BizException enforce 开启且 UserBase 不可达/返回异常——fail-closed */ public boolean isAppActivatedAtSchool(Integer appId, Long schoolId) { - if (!enforce) { - return true; - } return isAppActivatedStrict(appId, schoolId); } /** Formal commands never inherit the legacy bypass switch. */ public boolean isAppActivatedStrict(Integer appId, Long schoolId) { - if (appId == null || schoolId == null || schoolId <= 0) { + if (appId == null || appId <= 0 || schoolId == null || schoolId <= 0) { throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验缺少主体或校域"); } if (openCall == null) { @@ -76,6 +73,11 @@ public class UserBaseActivationClient { log.warn("[usage-grant] UserBase 开通资格核验返回异常 appId={} schoolId={}", appId, schoolId); throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试"); } - return data.path("activated").asBoolean(false); + if (!data.path("activated").isBoolean() + || !data.path("appId").isIntegralNumber() || data.path("appId").intValue() != appId + || !data.path("schoolId").isIntegralNumber() || data.path("schoolId").longValue() != schoolId) { + throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格响应不匹配"); + } + return data.path("activated").booleanValue(); } } diff --git a/src/main/resources/sql/20261028_f01_f02_source_unique.sql b/src/main/resources/sql/20261028_f01_f02_source_unique.sql new file mode 100644 index 0000000..0ae186e --- /dev/null +++ b/src/main/resources/sql/20261028_f01_f02_source_unique.sql @@ -0,0 +1,15 @@ +-- F01/F02. Apply only to the verified FL schema after reviewing SHOW INDEX. +-- No grant INSERT, source backfill, instance deletion or other database changes. +-- Preconditions: source_principal_id and legacy_slot already exist (302216a schema). +-- If the older 3-column uk_flw_instance_biz also exists, it MUST be dropped separately +-- after SHOW INDEX confirms its exact name; otherwise it still blocks distinct sources. +ALTER TABLE flw_instance + DROP INDEX uk_flw_instance_legacy, + ADD UNIQUE KEY uk_flw_instance_legacy + (sch_id, source_principal_id, biz_type, biz_id, legacy_slot); +-- Keep uk_flw_instance_round for the formal command path. +-- Existing NULL-source rows are not reassigned or used as callers' receipts. +-- Rollback: first stop new writers and check cross-source duplicate coordinates. +-- Restoring the old index can FAIL once two sources have created identical coordinates. +-- Never delete those rows to force rollback; restore a verified pre-change backup +-- only under an explicitly approved FL-only recovery plan. diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java index f08b6b2..f3ee1f4 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java @@ -51,6 +51,7 @@ import static org.mockito.Mockito.when; class FlApplyRecoveryTest { private static final long SCH = 1L; + private static final String SOURCE = "11111111-1111-1111-1111-111111111111"; private static final String BIZ_TYPE = "svc-hall"; private static final String BIZ_ID = "42"; private static final String FLOW_GUID = "fg-1"; @@ -95,7 +96,7 @@ class FlApplyRecoveryTest { @DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进") void sameTokenOnRejectedInstanceDoesNotRestart() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.GuardedOutcome outcome = @@ -112,7 +113,7 @@ class FlApplyRecoveryTest { @DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST") void sameTokenOnApprovingInstanceIsSameRequest() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch) @@ -124,7 +125,7 @@ class FlApplyRecoveryTest { @DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)") void retryOnlyNeverRestartsTerminal() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.GuardedOutcome outcome = @@ -141,7 +142,7 @@ class FlApplyRecoveryTest { // T04(2026-10-25)改判:带 token 的请求撞在途/通过件且 token 不符 = 另一份命令 → // CONFLICT(不把他人命令的成功当自己的,审计 P0-2);无 token 的存量调用方保持 IDEMPOTENT。 FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(approving)); assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch) .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT); @@ -161,7 +162,7 @@ class FlApplyRecoveryTest { void sameTokenDifferentPayloadIsConflict() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018"); existing.setApplyPayloadHash("hash-a"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A); @@ -179,7 +180,7 @@ class FlApplyRecoveryTest { @DisplayName("expectedPrevRound 命中行轮次:服务端只分配 N+1,不信客户端 attempt(T04)") void serverAllocatesNextRoundFromExpectedPrev() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); // 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮 @@ -189,7 +190,7 @@ class FlApplyRecoveryTest { assertThat(outcome.applyRound).isEqualTo(3); // expectedPrev 与行轮次不符:STALE_ROUND 不重启(restart 会原地改 token,这里换新实例) - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of( instance(901L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"))); assertThat(service.applyGuarded(cmd(TOKEN_B), @@ -204,7 +205,7 @@ class FlApplyRecoveryTest { @DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进") void higherRoundRestartsPerExistingR4Semantics() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.GuardedOutcome outcome = @@ -224,7 +225,7 @@ class FlApplyRecoveryTest { @DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启") void staleRoundIsRejectedForRestart() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch) @@ -237,7 +238,7 @@ class FlApplyRecoveryTest { @DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它") void legacyRowWithoutRoundDefaultsToGuarded() { FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(legacy)); assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch) @@ -254,7 +255,7 @@ class FlApplyRecoveryTest { @DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果") void otherApplicationSameBizKeyIsRefused() { FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(foreign)); assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false))) @@ -267,14 +268,14 @@ class FlApplyRecoveryTest { FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); otherSubmitter.setSubmitterId(2002); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(otherSubmitter)); assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false))) .isInstanceOf(BizException.class).hasMessageContaining("提交者"); FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(otherFlow)); when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1))); assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false))) @@ -286,7 +287,7 @@ class FlApplyRecoveryTest { @Test @DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)") void createsWhenAbsentAndStampsIdentity() { - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.empty()); FlowInstanceService.GuardedOutcome outcome = @@ -307,7 +308,7 @@ class FlApplyRecoveryTest { void concurrentUkCollisionIsResolvedOutsideTransaction() { // 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽 FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.empty()) .thenReturn(Optional.of(existing)); assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch) @@ -321,23 +322,24 @@ class FlApplyRecoveryTest { } @Test - @DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)") - void legacyApplyPathIsUnchanged() { + @DisplayName("旧 apply 同来源终态仍按 R4 重启,并记录服务端请求身份") + void legacyApplyRetainsRestartButStampsTrustedIdentity() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018"); - when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null)); assertThat(outcome.isDuplicated()).isTrue(); assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); - assertThat(existing.getApplyRequestToken()).isNull(); + assertThat(existing.getApplyRequestToken()).isNotBlank(); assertThat(existing.getApplyRound()).isNull(); } @Test void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception { FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018"); + row.setSourcePrincipalId(null); // This existing test exercises the legacy callback fixture, not source admission. row.setVersion(3); when(instanceRepository.findById(900L)).thenReturn(Optional.of(row)); com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of( @@ -370,9 +372,11 @@ class FlApplyRecoveryTest { /** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */ private FlowInstanceService.ApplyCommand cmd(String token) { - return new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID, + FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID, "张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001, "张三", "{}", "100018", "svc-hall", null); + command.sourcePrincipalId = SOURCE; + return command; } private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) { @@ -403,6 +407,7 @@ class FlApplyRecoveryTest { instance.setBizId(BIZ_ID); instance.setStatus(status); instance.setApplyAppKey(appKey); + instance.setSourcePrincipalId("100020".equals(appKey) ? "22222222-2222-2222-2222-222222222222" : SOURCE); instance.setApplyRequestToken(token); instance.setApplyRound(round); instance.setSubmitterId(1001); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java index 3cff9e9..fb97fd8 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java @@ -76,6 +76,11 @@ class FlDefLineOfflineTest { mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(), mock(OpenApiUserClient.class), mock(OpenApiAppClient.class), mock(DiagramTokenService.class), mock(ObjectProvider.class)); + FlowSourcePrincipalResolver resolver = mock(FlowSourcePrincipalResolver.class); + when(resolver.resolve(anyString())).thenReturn(new FlowSourcePrincipalResolver.Principal( + "11111111-1111-1111-1111-111111111111", 1, "100018", "test")); + ReflectionTestUtils.setField(oapiService, "sourcePrincipalResolver", resolver); + oapiService.setUsageGrantGuard(mock(FlowUsageGrantGuard.class)); when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID))) .thenReturn(Optional.of(def(9, 3, 0))); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java index 0c35bb7..1ebf951 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java @@ -81,6 +81,7 @@ class FlNewInstancePathTest { FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand( 1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant", null, "100001", "notice", null); + command.sourcePrincipalId = "11111111-1111-1111-1111-111111111111"; FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command, new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false)); assertThat(outcome.duplicated).isFalse(); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlSourceAuthorizationTest.java b/src/test/java/com/yunhai/flowcenter/service/FlSourceAuthorizationTest.java new file mode 100644 index 0000000..a3a6687 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlSourceAuthorizationTest.java @@ -0,0 +1,183 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.*; +import com.yunhai.flowcenter.entity.*; +import com.yunhai.flowcenter.model.*; +import com.yunhai.flowcenter.repository.*; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.*; +import com.yunhai.platform.openapi.core.YschOpenCall; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** Real OAPI, source resolver, authorization, activation client and instance service. + * Only repositories, outbound UserBase transport, people resolver and engine are mocks. + * No real DB/transaction/unique-index claim. */ +class FlSourceAuthorizationTest { + static final String A="11111111-1111-1111-1111-111111111111"; + static final String B="22222222-2222-2222-2222-222222222222"; + final ObjectMapper mapper=new ObjectMapper(); + FlwInstanceRepository instances; FlwDefRepository defs; FlwUsageGrantRepository grants; + FlwTaskRepository tasks; FlwBranchRepository branches; + OpenApiAppClient apps; YschOpenCall outbound; UserBaseActivationClient activation; + ApplicationEventPublisher publisher; FlowEngine engine; FlowUsageGrantGuard guard; + FlowInstanceService lifecycle; OapiFlowService api; FlwDef def; + Map rows; + @BeforeEach void setup() throws Exception { + instances=mock(FlwInstanceRepository.class);defs=mock(FlwDefRepository.class); + grants=mock(FlwUsageGrantRepository.class);tasks=mock(FlwTaskRepository.class);branches=mock(FlwBranchRepository.class); + apps=mock(OpenApiAppClient.class);outbound=mock(YschOpenCall.class); + activation=new UserBaseActivationClient(outbound); + // Even an explicitly false legacy value cannot disable either gate. + ReflectionTestUtils.setField(activation,"enforce",false); + guard=new FlowUsageGrantGuard(grants,activation);ReflectionTestUtils.setField(guard,"appClient",apps); + when(apps.getAppByKey(anyString())).thenAnswer(c -> mapper.readTree("{\"id\":"+("app-b".equals(c.getArgument(0))?2:1)+",\"sourcePrincipalId\":\""+("app-b".equals(c.getArgument(0))?B:A)+"\",\"appCode\":\"test\"}")); + when(outbound.get(anyString(),anyString(),anyMap(),any())).thenAnswer(c -> { + Map q=c.getArgument(2); + return mapper.readTree("{\"appId\":"+q.get("appId")+",\"schoolId\":"+q.get("schoolId")+",\"activated\":true}"); + }); + when(grants.findBySchIdAndFlowGuidAndAppId(eq(27L),eq("flow"),anyInt())).thenAnswer(c -> { + int id=c.getArgument(2);FlwUsageGrant g=new FlwUsageGrant();g.setAppId(id);g.setSchId(27L);g.setFlowGuid("flow");g.setEnabled(1);g.setSourcePrincipalId(id==1?A:B);return Optional.of(g); + }); + def=new FlwDef();def.setId(7L);def.setSchId(27L);def.setFlowGuid("flow");def.setCode("test");def.setVersion(1);def.setStatus(1);def.setContentJson("{\"nodes\":[],\"edges\":[]}"); + when(defs.findById(7L)).thenReturn(Optional.of(def)); + when(defs.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(27L,"flow",1)).thenReturn(Optional.of(def)); + engine=mock(FlowEngine.class);publisher=mock(ApplicationEventPublisher.class); + when(engine.parseModel(any())).thenReturn(FlowDefModel.parse(mapper,def.getContentJson())); + lifecycle=new FlowInstanceService(instances,defs,tasks,branches,engine,publisher); + api=new OapiFlowService(defs,instances,tasks,mock(FlwCcRepository.class),mock(FlwCallbackRegRepository.class),lifecycle,mock(FlowTaskService.class),engine,mock(AssigneeResolver.class),mapper,mock(OpenApiUserClient.class),apps,mock(DiagramTokenService.class),mock(ObjectProvider.class)); + api.setUsageGrantGuard(guard); + ReflectionTestUtils.setField(api,"sourcePrincipalResolver",new FlowSourcePrincipalResolver(apps)); + rows=new HashMap<>();AtomicLong seq=new AtomicLong(100); + when(instances.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(anyLong(),anyString(),anyString(),anyString(),eq(1))) + .thenAnswer(c -> Optional.ofNullable(rows.get(c.getArgument(1)))); + when(instances.saveAndFlush(any())).thenAnswer(c -> {FlwInstance i=c.getArgument(0);if(i.getId()==null)i.setId(seq.incrementAndGet());rows.put(i.getSourcePrincipalId(),i);return i;}); + when(instances.findById(anyLong())).thenAnswer(c -> rows.values().stream().filter(i -> i.getId().equals(c.getArgument(0))).findFirst()); + when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(anyLong(),anyString(),anyString(),anyString())) + .thenAnswer(c -> Optional.ofNullable(rows.get(c.getArgument(1)))); + } + OapiModels.ApplyBody body() { + OapiModels.ApplyBody r=new OapiModels.ApplyBody();r.setFlowGuid("flow");r.setBizType("type");r.setBizId("same-id");r.setSubmitterId(5);return r; + } + FlowApplyRetryModels.ApplyRequestV2 v2(String token) { + FlowApplyRetryModels.ApplyRequestV2 r=new FlowApplyRetryModels.ApplyRequestV2();r.setFlowGuid("flow");r.setBizType("type");r.setBizId("same-id");r.setSubmitterId(5);r.setRequestToken(token);r.setAttempt(1);return r; + } + void noWrites() {verify(instances,never()).saveAndFlush(any());verifyNoInteractions(tasks,branches,engine,publisher);} + @Test void bothGatesRequiredAndSameSourceIsIdempotent() { + long a=api.apply(27L,"app-a",body()).getInstanceId(); + assertThat(api.apply(27L,"app-a",body()).getInstanceId()).isEqualTo(a); + assertThat(rows).hasSize(1);verify(engine,times(1)).startAdvance(any(),any()); + assertThat(rows.get(A).getSourcePrincipalId()).isEqualTo(A); + } + @Test void distinctSourcesCreateIndependentInstancesAndNeverRestartOthers() { + long a=api.apply(27L,"app-a",body()).getInstanceId();rows.get(A).setStatus(30); + long b=api.apply(27L,"app-b",body()).getInstanceId(); + assertThat(b).isNotEqualTo(a);assertThat(rows.get(A).getStatus()).isEqualTo(30); + assertThat(rows).hasSize(2); + assertThat(api.apply(27L,"app-a",body()).getInstanceId()).isEqualTo(a); + assertThat(rows.get(A).getStatus()).isEqualTo(10); + assertThat(rows.get(B).getId()).isEqualTo(b); + verify(instances,never()).findBySchIdAndBizTypeAndBizId(anyLong(),anyString(),anyString()); + } + @Test void missingGrantRejectsWithoutAnySideEffects() { + when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.empty()); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } + @Test void inactiveOrRevokedSchoolRejectsEvenWithGrant() throws Exception { + when(outbound.get(anyString(),anyString(),anyMap(),any())).thenReturn(mapper.readTree("{\"appId\":1,\"schoolId\":27,\"activated\":false}")); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } + @Test void unavailableAuthorityRejects() { + when(outbound.get(anyString(),anyString(),anyMap(),any())).thenThrow(new RuntimeException("offline")); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } + @Test void malformedAuthorityResponseRejects() throws Exception { + when(outbound.get(anyString(),anyString(),anyMap(),any())).thenReturn(mapper.readTree("{\"appId\":2,\"schoolId\":27,\"activated\":true}")); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } + @Test void missingSchoolSourceOrGuardRejects() { + assertThatThrownBy(() -> api.apply(null,"app-a",body())).isInstanceOf(BizException.class); + assertThatThrownBy(() -> api.apply(0L,"app-a",body())).isInstanceOf(BizException.class); + assertThatThrownBy(() -> api.apply(27L,null,body())).isInstanceOf(BizException.class); + api.setUsageGrantGuard(null); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } + @Test void sourceLookupUnavailableOrUnknownRejects() throws Exception { + when(apps.getAppByKey("app-a")).thenReturn(mapper.readTree("{\"id\":1}")); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class); + when(apps.getAppByKey("app-a")).thenThrow(new RuntimeException("offline")); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } + @Test void mismatchedGrantSourceRejects() { + FlwUsageGrant g=new FlwUsageGrant();g.setEnabled(1);g.setSourcePrincipalId(B); + when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.of(g)); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } + @Test void v2ExactReceiptSurvivesRevocationButNewCommandDoesNot() { + long id=api.applyV2(27L,"app-a",v2("token-1")).getInstanceId(); + when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.empty()); + assertThat(api.applyV2(27L,"app-a",v2("token-1")).getInstanceId()).isEqualTo(id); + assertThatThrownBy(() -> api.applyV2(27L,"app-a",v2("new-token"))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> api.applyV2(27L,"app-b",v2("token-1"))).isInstanceOf(BizException.class); + } + @Test void v2IndependentSourcesAndConflictRetryUseScopedLookup() { + long a=api.applyV2(27L,"app-a",v2("a-token")).getInstanceId(); + long b=api.applyV2(27L,"app-b",v2("b-token")).getInstanceId();assertThat(a).isNotEqualTo(b); + verify(instances,never()).findBySchIdAndBizTypeAndBizId(anyLong(),anyString(),anyString()); + } + @Test void v1ConflictRecoveryCannotReturnOtherSourcesRow() { + long a=api.apply(27L,"app-a",body()).getInstanceId(); + doAnswer(new org.mockito.stubbing.Answer() {boolean first=true;public FlwInstance answer(org.mockito.invocation.InvocationOnMock c) { + if(first){first=false;throw new DataIntegrityViolationException("race");}FlwInstance i=c.getArgument(0);i.setId(999L);rows.put(i.getSourcePrincipalId(),i);return i; + }}).when(instances).saveAndFlush(any()); + assertThat(api.apply(27L,"app-b",body()).getInstanceId()).isNotEqualTo(a); + } + @Test void otherSchoolOrFlowCannotUseGrant() { + assertThatThrownBy(() -> api.apply(28L,"app-a",body())).isInstanceOf(BizException.class); + OapiModels.ApplyBody r=body();r.setFlowGuid("not-granted"); + assertThatThrownBy(() -> api.apply(27L,"app-a",r)).isInstanceOf(BizException.class);noWrites(); + } + @Test void v2ConflictRetryNeverRecoversAnotherSource() { + long a=api.applyV2(27L,"app-a",v2("a-token")).getInstanceId(); + doAnswer(new org.mockito.stubbing.Answer() {boolean first=true;public FlwInstance answer(org.mockito.invocation.InvocationOnMock c) { + if(first){first=false;throw new DataIntegrityViolationException("race");}FlwInstance i=c.getArgument(0);i.setId(999L);rows.put(i.getSourcePrincipalId(),i);return i; + }}).when(instances).saveAndFlush(any()); + FlowApplyRetryModels.ApplyResultV2 result=api.applyV2(27L,"app-b",v2("b-token")); + assertThat(result.getInstanceId()).isNotEqualTo(a); + assertThat(result.isDuplicated()).isFalse(); + } + @Test void businessReadsAndExplicitInstanceAccessAreSourceScopedEvenWithFormalModeOff() throws Exception { + long a=api.apply(27L,"app-a",body()).getInstanceId(); + long b=api.apply(27L,"app-b",body()).getInstanceId(); + FlowOapiSourceScope scope=new FlowOapiSourceScope(new FlowSourcePrincipalResolver(apps),instances,tasks); + org.springframework.mock.web.MockHttpServletRequest req=new org.springframework.mock.web.MockHttpServletRequest("GET","/oapi/flow/instance"); + req.setAttribute("yh.app.auth","app-b"); + scope.preHandle(req,new org.springframework.mock.web.MockHttpServletResponse(),new Object()); + org.springframework.web.context.request.RequestContextHolder.setRequestAttributes(new org.springframework.web.context.request.ServletRequestAttributes(req)); + try {assertThat(api.instanceByBiz(27L,"type","same-id").getInstanceId()).isEqualTo(b);} + finally {org.springframework.web.context.request.RequestContextHolder.resetRequestAttributes();} + req.setParameter("instanceId",String.valueOf(a)); + assertThatThrownBy(() -> scope.preHandle(req,new org.springframework.mock.web.MockHttpServletResponse(),new Object())).isInstanceOf(BizException.class); + } + @Test void v1UsesExistingCallbackIdentityFieldsWithoutChangingEnvelopeContract() { + api.apply(27L,"app-a",body()); + assertThat(rows.get(A).getApplyRequestToken()).isNotBlank(); + assertThat(rows.get(A).getApplyPayloadHash()).hasSize(64); + assertThat(rows.get(A).getApplyRound()).isEqualTo(1); + } + @Test void legacyWriterCannotDuplicateAnExistingFormalCoordinate() { + FlwInstance formal=new FlwInstance();formal.setId(99L);formal.setSourcePrincipalId(A);formal.setLegacySlot(null); + when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(27L,A,"type","same-id")) + .thenReturn(Optional.of(formal)); + assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java b/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java index b0437ae..f0bf0f5 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java @@ -70,11 +70,11 @@ class FlUsageGrantTest { // ==================== Guard:新发起资格 ==================== @Test - @DisplayName("enforce 关闭:直通且不出站(灰度期存量行为不变)") - void enforceOffPassesThrough() { + @DisplayName("enforce 关闭仍不得绕过授权") + void enforceOffCannotBypassAuthorization() { ReflectionTestUtils.setField(activationClient, "enforce", false); - assertThat(guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isNull(); + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isInstanceOf(BizException.class); verify(grantRepository, never()).findBySchIdAndFlowGuidAndAppId(anyLong(), anyString(), anyInt()); } @@ -195,6 +195,7 @@ class FlUsageGrantTest { when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001)); when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)).thenReturn(Optional.empty()); when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0)); + stubActivation(5001, true); FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin"); @@ -224,6 +225,7 @@ class FlUsageGrantTest { private JsonNode registration(int appId) { Map row = new HashMap<>(); row.put("id", appId); + row.put("sourcePrincipalId", "11111111-1111-1111-1111-111111111111"); row.put("appKey", APP_KEY); row.put("appName", "测试应用"); return mapper.valueToTree(row); @@ -246,6 +248,7 @@ class FlUsageGrantTest { g.setAppId(appId); g.setAppKeySnapshot(APP_KEY); g.setEnabled(enabled); + g.setSourcePrincipalId("11111111-1111-1111-1111-111111111111"); return g; }