fix(security): F01 授权门禁不可被配置关闭 + F02 业务坐标按可信来源隔离(含唯一键 SQL)
build / build-push (push) Failing after 1m1s

This commit is contained in:
chenxing
2026-10-28 11:46:20 +08:00
parent 302216a07c
commit 8a1294aef2
17 changed files with 366 additions and 211 deletions
+1 -1
View File
@@ -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`)
@@ -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。
*
* <p>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;
@@ -39,7 +39,10 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
@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<FlwInstance> pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable);
/** biz uk 幂等主键 */
Optional<FlwInstance> 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<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
@@ -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 + ")");
}
@@ -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");
@@ -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("");
@@ -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<FlwUsageGrant> 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);
@@ -14,7 +14,7 @@ import org.springframework.stereotype.Service;
/**
* 应用使用许可闸门(2026-10-25 T01):新发起必须持有生效的 (school, flowGuid, appId) 许可。
*
* <p>三层独立事实,缺一不可(enforce 开启时):</p>
* <p>三层独立事实,缺一不可(所有环境):</p>
* <ol>
* <li><b>稳定主体</b>:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用,
* 不是主体;主体解析不可达=fail-closed 拒绝);</li>
@@ -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;
}
}
}
@@ -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", "同一业务坐标已有在途实例");
@@ -145,8 +145,8 @@ public class OapiFlowService {
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
}
private FlowInstanceService.GuardedOutcome inTransaction(
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
private <T> T inTransaction(
org.springframework.transaction.support.TransactionCallback<T> 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<FlwInstance> 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);
}
@@ -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();
}
}
@@ -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.
@@ -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.<FlwInstance>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.<FlwInstance>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);
@@ -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)));
}
@@ -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();
@@ -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<String,FlwInstance> 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<String,String> 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<FlwInstance>() {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<FlwInstance>() {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();
}
}
@@ -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<String, Object> 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;
}