fix(security): F01 授权门禁不可被配置关闭 + F02 业务坐标按可信来源隔离(含唯一键 SQL)
build / build-push (push) Failing after 1m1s
build / build-push (push) Failing after 1m1s
This commit is contained in:
@@ -339,7 +339,7 @@ CREATE TABLE `flw_instance` (
|
|||||||
`submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
|
`submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
|
||||||
`version` int NOT NULL,
|
`version` int NOT NULL,
|
||||||
PRIMARY KEY (`id`),
|
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`),
|
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_submitter` (`sch_id`,`submitter_id`,`status`),
|
||||||
KEY `idx_flw_inst_status_time` (`sch_id`,`status`,`submit_time`)
|
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):托管模式下业务对象留在业务方,本表只存
|
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
|
||||||
* biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
|
* biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
|
||||||
* (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
|
* (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
|
||||||
* sourcePrincipalId=NULL / legacySlot=1 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。
|
* 旧入口以 sourcePrincipalId + legacySlot=1 实现每来源一业务一行;正式命令 legacySlot=NULL。
|
||||||
*
|
*
|
||||||
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
|
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
|
||||||
* 由 JPA @Version 在 flush 时自增。
|
* 由 JPA @Version 在 flush 时自增。
|
||||||
@@ -26,7 +26,7 @@ import java.time.LocalDateTime;
|
|||||||
@Entity
|
@Entity
|
||||||
@Table(name = "flw_instance",
|
@Table(name = "flw_instance",
|
||||||
uniqueConstraints = {
|
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"})},
|
@UniqueConstraint(name = "uk_flw_instance_round", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "apply_round"})},
|
||||||
indexes = {
|
indexes = {
|
||||||
@Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"),
|
@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 {
|
public class FlwInstance extends BaseEntity {
|
||||||
@Column(name="source_principal_id",length=36,updatable=false)
|
@Column(name="source_principal_id",length=36,updatable=false)
|
||||||
private String sourcePrincipalId;
|
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")
|
@Column(name="legacy_slot")
|
||||||
private Integer legacySlot = 1;
|
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")
|
@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);
|
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")
|
@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);
|
Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
|
||||||
|
|
||||||
|
|||||||
@@ -71,51 +71,44 @@ public class FlowInstanceService {
|
|||||||
* null=存量调用方,指纹核对缺省关)。
|
* null=存量调用方,指纹核对缺省关)。
|
||||||
*/
|
*/
|
||||||
public String payloadHash;
|
public String payloadHash;
|
||||||
|
/** Assigned only by authenticated server-side source resolution; not a request DTO field. */
|
||||||
|
public String sourcePrincipalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ApplyOutcome apply(ApplyCommand cmd) {
|
public ApplyOutcome apply(ApplyCommand cmd) {
|
||||||
FlwInstance existing = instanceRepository
|
FlwInstance existing = findOwned(cmd);
|
||||||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
|
requireSameOwner(existing, cmd);
|
||||||
int status = existing.getStatus();
|
int status = existing.getStatus();
|
||||||
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|
||||||
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
||||||
return new ApplyOutcome(existing.getId(), true);
|
return new ApplyOutcome(existing.getId(), true);
|
||||||
}
|
}
|
||||||
// 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START)
|
|
||||||
restart(existing, cmd);
|
restart(existing, cmd);
|
||||||
return new ApplyOutcome(existing.getId(), true);
|
return new ApplyOutcome(existing.getId(), true);
|
||||||
}
|
}
|
||||||
FlwInstance instance = new FlwInstance();
|
FlwInstance created = createInstance(cmd, java.util.UUID.randomUUID().toString(), 1, cmd.sourcePrincipalId, 1, true);
|
||||||
instance.setSchId(cmd.schId);
|
return new ApplyOutcome(created.getId(), false);
|
||||||
instance.setDefId(cmd.def.getId());
|
}
|
||||||
instance.setDefCode(cmd.def.getCode());
|
|
||||||
instance.setDefVersion(cmd.def.getVersion());
|
private FlwInstance findOwned(ApplyCommand cmd) {
|
||||||
instance.setBizType(cmd.bizType);
|
if (cmd.schId == null || cmd.schId <= 0 || cmd.sourcePrincipalId == null
|
||||||
instance.setBizId(cmd.bizId);
|
|| !cmd.sourcePrincipalId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) {
|
||||||
instance.setBizTitle(cmd.bizTitle);
|
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源或有效申报学校");
|
||||||
instance.setBizUrl(cmd.bizUrl);
|
}
|
||||||
instance.setBizSnapshot(cmd.bizSnapshot);
|
FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||||
instance.setFormDataJson(cmd.formDataJson);
|
cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, 1).orElse(null);
|
||||||
instance.setAssignJson(cmd.assignJson);
|
if (existing == null && instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(
|
||||||
instance.setApplyAppKey(cmd.applyAppKey);
|
cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId).isPresent()) {
|
||||||
instance.setApplyAppCode(cmd.applyAppCode);
|
throw new BizException(ErrorCode.FORBIDDEN, "该业务对象已有正式命令实例,请使用原命令通道");
|
||||||
instance.setSubmitterId(cmd.submitterId);
|
}
|
||||||
instance.setSubmitterName(cmd.submitterName);
|
return existing;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void restart(FlwInstance instance, ApplyCommand cmd) {
|
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
|
@Transactional
|
||||||
public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) {
|
public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) {
|
||||||
FlwInstance existing = instanceRepository
|
FlwInstance existing = findOwned(cmd);
|
||||||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
return settleExisting(existing, cmd, policy);
|
return settleExisting(existing, cmd, policy);
|
||||||
}
|
}
|
||||||
String token = policy == null ? null : trimToNull(policy.requestToken);
|
String token = policy == null ? null : trimToNull(policy.requestToken);
|
||||||
Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt;
|
Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt;
|
||||||
createInstance(cmd, token, round);
|
FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, 1, true);
|
||||||
return new GuardedOutcome(instanceOf(cmd), false, FlowApplyRetryModels.Branch.CREATED, round);
|
return new GuardedOutcome(created.getId(), false, FlowApplyRetryModels.Branch.CREATED, round);
|
||||||
}
|
}
|
||||||
|
|
||||||
private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) {
|
private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) {
|
||||||
@@ -441,14 +433,15 @@ public class FlowInstanceService {
|
|||||||
publishSubmitted(instance);
|
publishSubmitted(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createInstance(ApplyCommand cmd, String token, Integer round) {
|
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) {
|
||||||
createInstance(cmd, token, round, null);
|
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();
|
FlwInstance instance = new FlwInstance();
|
||||||
instance.setSourcePrincipalId(source);
|
instance.setSourcePrincipalId(source);
|
||||||
instance.setLegacySlot(source == null ? 1 : null);
|
instance.setLegacySlot(legacySlot);
|
||||||
instance.setSchId(cmd.schId);
|
instance.setSchId(cmd.schId);
|
||||||
instance.setDefId(cmd.def.getId());
|
instance.setDefId(cmd.def.getId());
|
||||||
instance.setDefCode(cmd.def.getCode());
|
instance.setDefCode(cmd.def.getCode());
|
||||||
@@ -472,31 +465,19 @@ public class FlowInstanceService {
|
|||||||
instanceRepository.saveAndFlush(instance);
|
instanceRepository.saveAndFlush(instance);
|
||||||
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
|
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
|
||||||
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
|
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
|
||||||
if (source == null) {
|
if (start) {
|
||||||
flowEngine.startAdvance(instance, cmd.def);
|
flowEngine.startAdvance(instance, cmd.def);
|
||||||
publishSubmitted(instance);
|
publishSubmitted(instance);
|
||||||
}
|
}
|
||||||
return 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"不能把别的应用
|
* 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用
|
||||||
* 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照,
|
* 相同 bizType 键当自己的结果")。来源必须存在且匹配,不用 appKey 快照替代来源 UUID。
|
||||||
* 不能因为"当年没记"就把存量件判成别人的。
|
|
||||||
*/
|
*/
|
||||||
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
|
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
|
||||||
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null
|
if (cmd.sourcePrincipalId == null || !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
|
||||||
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
|
|
||||||
throw new BizException(ErrorCode.FORBIDDEN,
|
throw new BizException(ErrorCode.FORBIDDEN,
|
||||||
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
|
"该业务键的办件属于另一调用应用(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.apistd.BizException;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
@@ -22,11 +21,9 @@ public class FlowOapiSourceScope implements HandlerInterceptor {
|
|||||||
private final FlowSourcePrincipalResolver resolver;
|
private final FlowSourcePrincipalResolver resolver;
|
||||||
private final FlwInstanceRepository instances;
|
private final FlwInstanceRepository instances;
|
||||||
private final FlwTaskRepository tasks;
|
private final FlwTaskRepository tasks;
|
||||||
@Value("${flow-center.formal-command.enabled:false}") private boolean enabled;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
if (!enabled) return true;
|
|
||||||
String source = resolver.resolve((String) request.getAttribute("yh.app.auth")).id;
|
String source = resolver.resolve((String) request.getAttribute("yh.app.auth")).id;
|
||||||
request.setAttribute(ATTRIBUTE, source);
|
request.setAttribute(ATTRIBUTE, source);
|
||||||
String id = request.getParameter("instanceId");
|
String id = request.getParameter("instanceId");
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ public class FlowSourcePrincipalResolver {
|
|||||||
if (authenticatedAppKey == null || authenticatedAppKey.trim().isEmpty()) {
|
if (authenticatedAppKey == null || authenticatedAppKey.trim().isEmpty()) {
|
||||||
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已认证的调用应用");
|
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已认证的调用应用");
|
||||||
}
|
}
|
||||||
JsonNode app = appClient.getAppByKey(authenticatedAppKey);
|
JsonNode app;
|
||||||
if (app == null || !app.path("id").isIntegralNumber()) {
|
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, "调用应用主体不可解析");
|
throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体不可解析");
|
||||||
}
|
}
|
||||||
String source = app.path("sourcePrincipalId").asText("");
|
String source = app.path("sourcePrincipalId").asText("");
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ public class FlowUsageGrantAdminService {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private OpenApiAppClient appClient;
|
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) {
|
public List<FlwUsageGrant> listByFlow(Long schId, String flowGuid) {
|
||||||
return grantRepository.findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(schId, flowGuid);
|
return grantRepository.findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(schId, flowGuid);
|
||||||
}
|
}
|
||||||
@@ -62,8 +59,7 @@ public class FlowUsageGrantAdminService {
|
|||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
"appKey 未能在 UserBase 解析出稳定应用主体,拒绝授权:" + cleanKey);
|
"appKey 未能在 UserBase 解析出稳定应用主体,拒绝授权:" + cleanKey);
|
||||||
}
|
}
|
||||||
if (activationClient.isEnforce()
|
if (!activationClient.isAppActivatedStrict(appId, schId)) {
|
||||||
&& !activationClient.isAppActivatedAtSchool(appId, schId)) {
|
|
||||||
throw new BizException(ErrorCode.FORBIDDEN,
|
throw new BizException(ErrorCode.FORBIDDEN,
|
||||||
"该应用未在学校 " + schId + " 开通,不能授予流程使用许可");
|
"该应用未在学校 " + schId + " 开通,不能授予流程使用许可");
|
||||||
}
|
}
|
||||||
@@ -76,14 +72,13 @@ public class FlowUsageGrantAdminService {
|
|||||||
grant.setFlowGuid(flowGuid.trim());
|
grant.setFlowGuid(flowGuid.trim());
|
||||||
grant.setAppId(appId);
|
grant.setAppId(appId);
|
||||||
}
|
}
|
||||||
if (formalEnabled) {
|
{
|
||||||
String source = registered.path("sourcePrincipalId").asText("");
|
String source = registered.path("sourcePrincipalId").asText("");
|
||||||
try {
|
try {
|
||||||
if (!java.util.UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException();
|
if (!java.util.UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException();
|
||||||
} catch (IllegalArgumentException invalid) {
|
} catch (IllegalArgumentException invalid) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "UserBase 稳定来源未迁移,不能发放正式许可");
|
throw new BizException(ErrorCode.FORBIDDEN, "UserBase 稳定来源未迁移,不能发放正式许可");
|
||||||
}
|
}
|
||||||
if (!activationClient.isAppActivatedStrict(appId, schId)) throw new BizException(ErrorCode.FORBIDDEN, "学校未开通");
|
|
||||||
grant.setSourcePrincipalId(source);
|
grant.setSourcePrincipalId(source);
|
||||||
}
|
}
|
||||||
grant.setAppKeySnapshot(cleanKey);
|
grant.setAppKeySnapshot(cleanKey);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import org.springframework.stereotype.Service;
|
|||||||
/**
|
/**
|
||||||
* 应用使用许可闸门(2026-10-25 T01):新发起必须持有生效的 (school, flowGuid, appId) 许可。
|
* 应用使用许可闸门(2026-10-25 T01):新发起必须持有生效的 (school, flowGuid, appId) 许可。
|
||||||
*
|
*
|
||||||
* <p>三层独立事实,缺一不可(enforce 开启时):</p>
|
* <p>三层独立事实,缺一不可(所有环境):</p>
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li><b>稳定主体</b>:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用,
|
* <li><b>稳定主体</b>:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用,
|
||||||
* 不是主体;主体解析不可达=fail-closed 拒绝);</li>
|
* 不是主体;主体解析不可达=fail-closed 拒绝);</li>
|
||||||
@@ -37,38 +37,44 @@ public class FlowUsageGrantGuard {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private OpenApiAppClient appClient;
|
private OpenApiAppClient appClient;
|
||||||
|
|
||||||
/** enforce 关闭时直通(灰度开关,与 FC 同款迁移策略);开启后任何一关不过即拒 */
|
/** Mandatory in every profile; the historical enforce flag cannot disable authorization. */
|
||||||
public boolean isEnforce() {
|
public boolean isEnforce() {
|
||||||
return activationClient.isEnforce();
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新发起资格核验:返回稳定主体 appId(供命令快照/审计使用)。
|
* 新发起资格核验:返回稳定主体 appId(供命令快照/审计使用)。
|
||||||
*
|
*
|
||||||
* @throws BizException enforce 开启时:主体解析不可用、无生效许可、未在该校开通、
|
* @throws BizException 所有环境:主体解析不可用、无生效许可、未在该校开通、
|
||||||
* UB 资格核验不可用——四类都拒绝,不猜不降级
|
* UB 资格核验不可用——四类都拒绝,不猜不降级
|
||||||
*/
|
*/
|
||||||
public Integer requireNewApplyGrant(Long schId, String flowGuid, String appKey) {
|
public Integer requireNewApplyGrant(Long schId, String flowGuid, String appKey) {
|
||||||
if (!isEnforce()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (schId == null || flowGuid == null || flowGuid.trim().isEmpty()
|
if (schId == null || flowGuid == null || flowGuid.trim().isEmpty()
|
||||||
|| appKey == null || appKey.trim().isEmpty()) {
|
|| appKey == null || appKey.trim().isEmpty()) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "缺少使用许可判定所需的学校/流程线/调用主体");
|
throw new BizException(ErrorCode.FORBIDDEN, "缺少使用许可判定所需的学校/流程线/调用主体");
|
||||||
}
|
}
|
||||||
Integer appId = resolveAppId(appKey.trim());
|
return requireNewApplyGrant(schId, flowGuid, new FlowSourcePrincipalResolver(appClient).resolve(appKey));
|
||||||
if (appId == null) {
|
}
|
||||||
|
|
||||||
|
/** 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, "调用应用主体解析不可用,已按未授权拒绝");
|
throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析不可用,已按未授权拒绝");
|
||||||
}
|
}
|
||||||
FlwUsageGrant grant = grantRepository.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId)
|
FlwUsageGrant grant = grantRepository.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId)
|
||||||
.orElse(null);
|
.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={}",
|
log.warn("[usage-grant] 拒绝新发起(无生效使用许可):schId={} flowGuid={} appId={} appKey={}",
|
||||||
schId, flowGuid, appId, appKey);
|
schId, flowGuid, appId, source.appKey);
|
||||||
throw new BizException(ErrorCode.FORBIDDEN,
|
throw new BizException(ErrorCode.FORBIDDEN,
|
||||||
"该应用未获此流程线的使用许可(school=" + schId + " flow=" + flowGuid + ")");
|
"该应用未获此流程线的使用许可(school=" + schId + " flow=" + flowGuid + ")");
|
||||||
}
|
}
|
||||||
if (!activationClient.isAppActivatedAtSchool(appId, schId)) {
|
if (!activationClient.isAppActivatedStrict(appId, schId)) {
|
||||||
log.warn("[usage-grant] 拒绝新发起(应用未在该校开通):schId={} appId={}", schId, appId);
|
log.warn("[usage-grant] 拒绝新发起(应用未在该校开通):schId={} appId={}", schId, appId);
|
||||||
throw new BizException(ErrorCode.FORBIDDEN,
|
throw new BizException(ErrorCode.FORBIDDEN,
|
||||||
"该应用未在学校 " + schId + " 开通,不能发起新申报");
|
"该应用未在学校 " + schId + " 开通,不能发起新申报");
|
||||||
@@ -76,21 +82,4 @@ public class FlowUsageGrantGuard {
|
|||||||
return appId;
|
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());
|
coordinate.setBizType(request.getBizType()); coordinate.setBizId(request.getBizId());
|
||||||
coordinates.saveAndFlush(coordinate);
|
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", "旧业务坐标尚未审核迁移");
|
return reject(command, "LEGACY_COORDINATE_NOT_MIGRATED", "旧业务坐标尚未审核迁移");
|
||||||
}
|
}
|
||||||
if (coordinate.getActiveInstanceId() != null) return reject(command, "IN_FLIGHT", "同一业务坐标已有在途实例");
|
if (coordinate.getActiveInstanceId() != null) return reject(command, "IN_FLIGHT", "同一业务坐标已有在途实例");
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ public class OapiFlowService {
|
|||||||
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
|
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
private FlowInstanceService.GuardedOutcome inTransaction(
|
private <T> T inTransaction(
|
||||||
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
|
org.springframework.transaction.support.TransactionCallback<T> action) {
|
||||||
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
|
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +229,6 @@ public class OapiFlowService {
|
|||||||
|
|
||||||
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份,
|
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份,
|
||||||
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
|
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
|
||||||
@Transactional
|
|
||||||
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
|
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
|
||||||
requireLegacyWriter();
|
requireLegacyWriter();
|
||||||
if (schId == null || body.getFlowGuid() == null || body.getBizType() == null
|
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();
|
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||||
// T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报,
|
// T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报,
|
||||||
// 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。
|
// 必须持有效使用许可。恢复感知的分支判定与 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());
|
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
|
||||||
// T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行
|
// T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行
|
||||||
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
||||||
@@ -260,8 +260,15 @@ public class OapiFlowService {
|
|||||||
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
||||||
body.getSubmitterId(), body.getSubmitterName(),
|
body.getSubmitterId(), body.getSubmitterName(),
|
||||||
asFormDataString(body.getFormData()),
|
asFormDataString(body.getFormData()),
|
||||||
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
|
cleanAppKey, source.appCode, assignJson);
|
||||||
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
|
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();
|
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
|
||||||
vo.setInstanceId(outcome.getInstanceId());
|
vo.setInstanceId(outcome.getInstanceId());
|
||||||
vo.setDuplicated(outcome.isDuplicated());
|
vo.setDuplicated(outcome.isDuplicated());
|
||||||
@@ -295,15 +302,9 @@ public class OapiFlowService {
|
|||||||
}
|
}
|
||||||
requireSchoolMember(schId, body.getSubmitterId());
|
requireSchoolMember(schId, body.getSubmitterId());
|
||||||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||||
// T01 使用许可(v2 路径):恢复感知——SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND
|
FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey);
|
||||||
// 四类回执分支不拦(已受理命令的回执不因许可撤销丢失);CREATED/RESTARTED(新申报/新轮)
|
FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||||
// 必须持有效使用许可。分支判定与 FlowInstanceService.applyGuarded 同键同序;
|
schId, source.id, body.getBizType(), body.getBizId(), 1).orElse(null);
|
||||||
// 完整命令协议(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);
|
|
||||||
boolean replay = existing != null && body.getRequestToken() != null
|
boolean replay = existing != null && body.getRequestToken() != null
|
||||||
&& !body.getRequestToken().trim().isEmpty()
|
&& !body.getRequestToken().trim().isEmpty()
|
||||||
&& body.getRequestToken().trim().equals(existing.getApplyRequestToken());
|
&& body.getRequestToken().trim().equals(existing.getApplyRequestToken());
|
||||||
@@ -327,9 +328,15 @@ public class OapiFlowService {
|
|||||||
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
||||||
body.getSubmitterId(), body.getSubmitterName(),
|
body.getSubmitterId(), body.getSubmitterName(),
|
||||||
asFormDataString(body.getFormData()),
|
asFormDataString(body.getFormData()),
|
||||||
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
|
cleanAppKey, source.appCode, assignJson);
|
||||||
|
cmd.sourcePrincipalId = source.id;
|
||||||
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
|
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
|
||||||
cmd.payloadHash = payloadHash(cmd);
|
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(
|
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
|
||||||
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
|
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
|
||||||
body.getExpectedPrevRound());
|
body.getExpectedPrevRound());
|
||||||
@@ -379,37 +386,18 @@ public class OapiFlowService {
|
|||||||
/**
|
/**
|
||||||
* T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
|
* T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
|
||||||
* 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
|
* 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
|
||||||
* 重启即新一轮申报。guard 未装配或未 enforce 时零行为(灰度开关,见 {@link FlowUsageGrantGuard})。
|
* 重启即新一轮申报。每次均强制通过独立的学校开通与流程使用授权校验。
|
||||||
*/
|
*/
|
||||||
private void requireUsageGrantLegacyApply(Long schId, String flowGuid, String appKey,
|
private FlowSourcePrincipalResolver.Principal requireApplySource(Long school, String appKey) {
|
||||||
String bizType, String bizId) {
|
if (school == null || school <= 0 || sourcePrincipalResolver == null) {
|
||||||
if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) {
|
throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校或来源解析服务");
|
||||||
return;
|
|
||||||
}
|
|
||||||
FlwInstance existing = instanceRepository
|
|
||||||
.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null);
|
|
||||||
if (!legacyApplyIsReceiptOnly(existing)) {
|
|
||||||
usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey);
|
|
||||||
}
|
}
|
||||||
|
return sourcePrincipalResolver.resolve(appKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void requireUsageGrant(Long school, String flow, FlowSourcePrincipalResolver.Principal source) {
|
||||||
* T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting}
|
if (usageGrantGuard == null) throw new BizException(ErrorCode.FORBIDDEN, "流程使用授权服务未装配");
|
||||||
* 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly});
|
usageGrantGuard.requireNewApplyGrant(school, flow, source);
|
||||||
* 新申报(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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -497,39 +485,18 @@ public class OapiFlowService {
|
|||||||
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
|
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
|
||||||
FlowInstanceService.ApplyCommand cmd,
|
FlowInstanceService.ApplyCommand cmd,
|
||||||
FlowInstanceService.ApplyRetryPolicy policy) {
|
FlowInstanceService.ApplyRetryPolicy policy) {
|
||||||
FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId(
|
FlowInstanceService.GuardedOutcome outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
|
||||||
schId, cmd.bizType, cmd.bizId).orElse(null);
|
requireNotConflict(outcome);
|
||||||
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();
|
|
||||||
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
|
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
|
||||||
vo.setInstanceId(existing.getId());
|
vo.setInstanceId(outcome.instanceId); vo.setDuplicated(outcome.duplicated);
|
||||||
vo.setDuplicated(true);
|
vo.setBranch(outcome.branch); vo.setApplyRound(outcome.applyRound);
|
||||||
vo.setApplyRound(existing.getApplyRound());
|
instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
|
||||||
vo.setStatus(existing.getStatus());
|
int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
|
||||||
vo.setVersion(existing.getVersion());
|
int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
|
||||||
if (token != null && !token.equals(existing.getApplyRequestToken())) {
|
if (currentRound == outcomeRound && cmd.sourcePrincipalId.equals(current.getSourcePrincipalId())) {
|
||||||
// T04:并发撞键且 token 不符=两份不同命令抢同一业务键——后到者冲突,不伪装幂等成功
|
vo.setStatus(current.getStatus()); vo.setVersion(current.getVersion());
|
||||||
//(旧 CONCURRENT_CREATED 分支只保留给无 token 的存量调用方)。
|
|
||||||
log.warn("[flow-center] 并发建件 token 不符(冲突拒绝):instance={} biz={}/{}",
|
|
||||||
existing.getId(), cmd.bizType, cmd.bizId);
|
|
||||||
throw new BizException(ErrorCode.CUSTOM,
|
|
||||||
"该业务键已被另一并发命令创建(请求标识不符),请按实例查询恢复,不要换 ID 重发");
|
|
||||||
}
|
}
|
||||||
vo.setBranch(token != null ? FlowApplyRetryModels.Branch.SAME_REQUEST
|
});
|
||||||
: FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
|
|
||||||
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}",
|
|
||||||
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch());
|
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,9 +1054,8 @@ public class OapiFlowService {
|
|||||||
|
|
||||||
/** 进度/换图共用的办件定位(schId 收敛在次) */
|
/** 进度/换图共用的办件定位(schId 收敛在次) */
|
||||||
private java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
|
private java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
|
||||||
return formalCommandEnabled
|
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(
|
||||||
? instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(school, FlowOapiSourceScope.current(), type, biz)
|
school, FlowOapiSourceScope.current(), type, biz);
|
||||||
: instanceRepository.findBySchIdAndBizTypeAndBizId(school, type, biz);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private FlwInstance requireInstance(Long schId, Long instanceId) {
|
private FlwInstance requireInstance(Long schId, Long instanceId) {
|
||||||
@@ -1356,13 +1322,17 @@ public class OapiFlowService {
|
|||||||
reg.setAppKey(appKey);
|
reg.setAppKey(appKey);
|
||||||
reg.setFlowGuid(body.getFlowGuid());
|
reg.setFlowGuid(body.getFlowGuid());
|
||||||
}
|
}
|
||||||
if (formalCommandEnabled) {
|
{
|
||||||
FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
|
FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
|
||||||
|
if (formalCommandEnabled) {
|
||||||
com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null);
|
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());
|
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")) {
|
if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
|
throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
requireUsageGrant(schId, body.getFlowGuid(), source);
|
||||||
|
}
|
||||||
reg.setSourcePrincipalId(source.id);
|
reg.setSourcePrincipalId(source.id);
|
||||||
}
|
}
|
||||||
reg.setCallbackUrl(body.getCallbackUrl());
|
reg.setCallbackUrl(body.getCallbackUrl());
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class UserBaseActivationClient {
|
|||||||
private final YschOpenCall openCall;
|
private final YschOpenCall openCall;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 资格核验总开关(T01 灰度):false=不核(存量环境兼容);true=强制核验。
|
* 历史配置保留绑定;任何值均不能关闭学校开通校验。
|
||||||
* 生产开启前须确认 UserBase 已部署 {@code /oapi/app/activated} 且开通数据齐备。
|
* 生产开启前须确认 UserBase 已部署 {@code /oapi/app/activated} 且开通数据齐备。
|
||||||
*/
|
*/
|
||||||
@Value("${yh.flow.school-activation-enforce:false}")
|
@Value("${yh.flow.school-activation-enforce:false}")
|
||||||
@@ -40,7 +40,7 @@ public class UserBaseActivationClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEnforce() {
|
public boolean isEnforce() {
|
||||||
return enforce;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,15 +48,12 @@ public class UserBaseActivationClient {
|
|||||||
* @throws BizException enforce 开启且 UserBase 不可达/返回异常——fail-closed
|
* @throws BizException enforce 开启且 UserBase 不可达/返回异常——fail-closed
|
||||||
*/
|
*/
|
||||||
public boolean isAppActivatedAtSchool(Integer appId, Long schoolId) {
|
public boolean isAppActivatedAtSchool(Integer appId, Long schoolId) {
|
||||||
if (!enforce) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return isAppActivatedStrict(appId, schoolId);
|
return isAppActivatedStrict(appId, schoolId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formal commands never inherit the legacy bypass switch. */
|
/** Formal commands never inherit the legacy bypass switch. */
|
||||||
public boolean isAppActivatedStrict(Integer appId, Long schoolId) {
|
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, "应用学校开通资格核验缺少主体或校域");
|
throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验缺少主体或校域");
|
||||||
}
|
}
|
||||||
if (openCall == null) {
|
if (openCall == null) {
|
||||||
@@ -76,6 +73,11 @@ public class UserBaseActivationClient {
|
|||||||
log.warn("[usage-grant] UserBase 开通资格核验返回异常 appId={} schoolId={}", appId, schoolId);
|
log.warn("[usage-grant] UserBase 开通资格核验返回异常 appId={} schoolId={}", appId, schoolId);
|
||||||
throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试");
|
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 {
|
class FlApplyRecoveryTest {
|
||||||
|
|
||||||
private static final long SCH = 1L;
|
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_TYPE = "svc-hall";
|
||||||
private static final String BIZ_ID = "42";
|
private static final String BIZ_ID = "42";
|
||||||
private static final String FLOW_GUID = "fg-1";
|
private static final String FLOW_GUID = "fg-1";
|
||||||
@@ -95,7 +96,7 @@ class FlApplyRecoveryTest {
|
|||||||
@DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
|
@DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
|
||||||
void sameTokenOnRejectedInstanceDoesNotRestart() {
|
void sameTokenOnRejectedInstanceDoesNotRestart() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
FlowInstanceService.GuardedOutcome outcome =
|
FlowInstanceService.GuardedOutcome outcome =
|
||||||
@@ -112,7 +113,7 @@ class FlApplyRecoveryTest {
|
|||||||
@DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
|
@DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
|
||||||
void sameTokenOnApprovingInstanceIsSameRequest() {
|
void sameTokenOnApprovingInstanceIsSameRequest() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
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 当网络重试)")
|
@DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)")
|
||||||
void retryOnlyNeverRestartsTerminal() {
|
void retryOnlyNeverRestartsTerminal() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
FlowInstanceService.GuardedOutcome outcome =
|
FlowInstanceService.GuardedOutcome outcome =
|
||||||
@@ -141,7 +142,7 @@ class FlApplyRecoveryTest {
|
|||||||
// T04(2026-10-25)改判:带 token 的请求撞在途/通过件且 token 不符 = 另一份命令 →
|
// T04(2026-10-25)改判:带 token 的请求撞在途/通过件且 token 不符 = 另一份命令 →
|
||||||
// CONFLICT(不把他人命令的成功当自己的,审计 P0-2);无 token 的存量调用方保持 IDEMPOTENT。
|
// CONFLICT(不把他人命令的成功当自己的,审计 P0-2);无 token 的存量调用方保持 IDEMPOTENT。
|
||||||
FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
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));
|
.thenReturn(Optional.of(approving));
|
||||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||||
.isEqualTo(FlowApplyRetryModels.Branch.CONFLICT);
|
.isEqualTo(FlowApplyRetryModels.Branch.CONFLICT);
|
||||||
@@ -161,7 +162,7 @@ class FlApplyRecoveryTest {
|
|||||||
void sameTokenDifferentPayloadIsConflict() {
|
void sameTokenDifferentPayloadIsConflict() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||||
existing.setApplyPayloadHash("hash-a");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A);
|
FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A);
|
||||||
@@ -179,7 +180,7 @@ class FlApplyRecoveryTest {
|
|||||||
@DisplayName("expectedPrevRound 命中行轮次:服务端只分配 N+1,不信客户端 attempt(T04)")
|
@DisplayName("expectedPrevRound 命中行轮次:服务端只分配 N+1,不信客户端 attempt(T04)")
|
||||||
void serverAllocatesNextRoundFromExpectedPrev() {
|
void serverAllocatesNextRoundFromExpectedPrev() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
// 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮
|
// 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮
|
||||||
@@ -189,7 +190,7 @@ class FlApplyRecoveryTest {
|
|||||||
assertThat(outcome.applyRound).isEqualTo(3);
|
assertThat(outcome.applyRound).isEqualTo(3);
|
||||||
|
|
||||||
// expectedPrev 与行轮次不符:STALE_ROUND 不重启(restart 会原地改 token,这里换新实例)
|
// 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(
|
.thenReturn(Optional.of(
|
||||||
instance(901L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018")));
|
instance(901L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018")));
|
||||||
assertThat(service.applyGuarded(cmd(TOKEN_B),
|
assertThat(service.applyGuarded(cmd(TOKEN_B),
|
||||||
@@ -204,7 +205,7 @@ class FlApplyRecoveryTest {
|
|||||||
@DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
|
@DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
|
||||||
void higherRoundRestartsPerExistingR4Semantics() {
|
void higherRoundRestartsPerExistingR4Semantics() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
FlowInstanceService.GuardedOutcome outcome =
|
FlowInstanceService.GuardedOutcome outcome =
|
||||||
@@ -224,7 +225,7 @@ class FlApplyRecoveryTest {
|
|||||||
@DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
|
@DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
|
||||||
void staleRoundIsRejectedForRestart() {
|
void staleRoundIsRejectedForRestart() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
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 重投重启不了它")
|
@DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它")
|
||||||
void legacyRowWithoutRoundDefaultsToGuarded() {
|
void legacyRowWithoutRoundDefaultsToGuarded() {
|
||||||
FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
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));
|
.thenReturn(Optional.of(legacy));
|
||||||
|
|
||||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
|
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
|
||||||
@@ -254,7 +255,7 @@ class FlApplyRecoveryTest {
|
|||||||
@DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
|
@DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
|
||||||
void otherApplicationSameBizKeyIsRefused() {
|
void otherApplicationSameBizKeyIsRefused() {
|
||||||
FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020");
|
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));
|
.thenReturn(Optional.of(foreign));
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
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(),
|
FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||||
TOKEN_A, 1, "100018");
|
TOKEN_A, 1, "100018");
|
||||||
otherSubmitter.setSubmitterId(2002);
|
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));
|
.thenReturn(Optional.of(otherSubmitter));
|
||||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("提交者");
|
.isInstanceOf(BizException.class).hasMessageContaining("提交者");
|
||||||
|
|
||||||
FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||||
TOKEN_A, 1, "100018");
|
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));
|
.thenReturn(Optional.of(otherFlow));
|
||||||
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1)));
|
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1)));
|
||||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||||
@@ -286,7 +287,7 @@ class FlApplyRecoveryTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
|
@DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
|
||||||
void createsWhenAbsentAndStampsIdentity() {
|
void createsWhenAbsentAndStampsIdentity() {
|
||||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||||
.thenReturn(Optional.<FlwInstance>empty());
|
.thenReturn(Optional.<FlwInstance>empty());
|
||||||
|
|
||||||
FlowInstanceService.GuardedOutcome outcome =
|
FlowInstanceService.GuardedOutcome outcome =
|
||||||
@@ -307,7 +308,7 @@ class FlApplyRecoveryTest {
|
|||||||
void concurrentUkCollisionIsResolvedOutsideTransaction() {
|
void concurrentUkCollisionIsResolvedOutsideTransaction() {
|
||||||
// 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
|
// 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
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.<FlwInstance>empty())
|
||||||
.thenReturn(Optional.of(existing));
|
.thenReturn(Optional.of(existing));
|
||||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||||
@@ -321,23 +322,24 @@ class FlApplyRecoveryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)")
|
@DisplayName("旧 apply 同来源终态仍按 R4 重启,并记录服务端请求身份")
|
||||||
void legacyApplyPathIsUnchanged() {
|
void legacyApplyRetainsRestartButStampsTrustedIdentity() {
|
||||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
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));
|
.thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null));
|
FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null));
|
||||||
|
|
||||||
assertThat(outcome.isDuplicated()).isTrue();
|
assertThat(outcome.isDuplicated()).isTrue();
|
||||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||||
assertThat(existing.getApplyRequestToken()).isNull();
|
assertThat(existing.getApplyRequestToken()).isNotBlank();
|
||||||
assertThat(existing.getApplyRound()).isNull();
|
assertThat(existing.getApplyRound()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception {
|
void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception {
|
||||||
FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018");
|
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);
|
row.setVersion(3);
|
||||||
when(instanceRepository.findById(900L)).thenReturn(Optional.of(row));
|
when(instanceRepository.findById(900L)).thenReturn(Optional.of(row));
|
||||||
com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of(
|
com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of(
|
||||||
@@ -370,9 +372,11 @@ class FlApplyRecoveryTest {
|
|||||||
|
|
||||||
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
|
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
|
||||||
private FlowInstanceService.ApplyCommand cmd(String token) {
|
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,
|
"张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001,
|
||||||
"张三", "{}", "100018", "svc-hall", null);
|
"张三", "{}", "100018", "svc-hall", null);
|
||||||
|
command.sourcePrincipalId = SOURCE;
|
||||||
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
|
private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
|
||||||
@@ -403,6 +407,7 @@ class FlApplyRecoveryTest {
|
|||||||
instance.setBizId(BIZ_ID);
|
instance.setBizId(BIZ_ID);
|
||||||
instance.setStatus(status);
|
instance.setStatus(status);
|
||||||
instance.setApplyAppKey(appKey);
|
instance.setApplyAppKey(appKey);
|
||||||
|
instance.setSourcePrincipalId("100020".equals(appKey) ? "22222222-2222-2222-2222-222222222222" : SOURCE);
|
||||||
instance.setApplyRequestToken(token);
|
instance.setApplyRequestToken(token);
|
||||||
instance.setApplyRound(round);
|
instance.setApplyRound(round);
|
||||||
instance.setSubmitterId(1001);
|
instance.setSubmitterId(1001);
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ class FlDefLineOfflineTest {
|
|||||||
mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(),
|
mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(),
|
||||||
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
|
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
|
||||||
mock(DiagramTokenService.class), mock(ObjectProvider.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)))
|
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID)))
|
||||||
.thenReturn(Optional.of(def(9, 3, 0)));
|
.thenReturn(Optional.of(def(9, 3, 0)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class FlNewInstancePathTest {
|
|||||||
FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
|
FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
|
||||||
1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
|
1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
|
||||||
null, "100001", "notice", null);
|
null, "100001", "notice", null);
|
||||||
|
command.sourcePrincipalId = "11111111-1111-1111-1111-111111111111";
|
||||||
FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
|
FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
|
||||||
new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
|
new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
|
||||||
assertThat(outcome.duplicated).isFalse();
|
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:新发起资格 ====================
|
// ==================== Guard:新发起资格 ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("enforce 关闭:直通且不出站(灰度期存量行为不变)")
|
@DisplayName("enforce 关闭仍不得绕过授权")
|
||||||
void enforceOffPassesThrough() {
|
void enforceOffCannotBypassAuthorization() {
|
||||||
ReflectionTestUtils.setField(activationClient, "enforce", false);
|
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());
|
verify(grantRepository, never()).findBySchIdAndFlowGuidAndAppId(anyLong(), anyString(), anyInt());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +195,7 @@ class FlUsageGrantTest {
|
|||||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
||||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)).thenReturn(Optional.empty());
|
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)).thenReturn(Optional.empty());
|
||||||
when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0));
|
when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0));
|
||||||
|
stubActivation(5001, true);
|
||||||
|
|
||||||
FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin");
|
FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin");
|
||||||
|
|
||||||
@@ -224,6 +225,7 @@ class FlUsageGrantTest {
|
|||||||
private JsonNode registration(int appId) {
|
private JsonNode registration(int appId) {
|
||||||
Map<String, Object> row = new HashMap<>();
|
Map<String, Object> row = new HashMap<>();
|
||||||
row.put("id", appId);
|
row.put("id", appId);
|
||||||
|
row.put("sourcePrincipalId", "11111111-1111-1111-1111-111111111111");
|
||||||
row.put("appKey", APP_KEY);
|
row.put("appKey", APP_KEY);
|
||||||
row.put("appName", "测试应用");
|
row.put("appName", "测试应用");
|
||||||
return mapper.valueToTree(row);
|
return mapper.valueToTree(row);
|
||||||
@@ -246,6 +248,7 @@ class FlUsageGrantTest {
|
|||||||
g.setAppId(appId);
|
g.setAppId(appId);
|
||||||
g.setAppKeySnapshot(APP_KEY);
|
g.setAppKeySnapshot(APP_KEY);
|
||||||
g.setEnabled(enabled);
|
g.setEnabled(enabled);
|
||||||
|
g.setSourcePrincipalId("11111111-1111-1111-1111-111111111111");
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user