release: FL 2.0 候选(C01 记账边界+P2-2 认领协议+D10 变量契约+D11 资格终审+D09 激活代次+T01/T03/T04/T05)

按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。

- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
  CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
  FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
  main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
  6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
This commit is contained in:
chenxing
2026-10-27 12:05:08 +08:00
parent 11e077867b
commit 500a9a2fcb
99 changed files with 7973 additions and 633 deletions
@@ -0,0 +1,133 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwUsageGrant;
import com.yunhai.flowcenter.repository.FlwUsageGrantRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 应用使用授权管理面(2026-10-25 T01):学校×流程线×稳定主体的授予/撤销/查询。
*
* <p>授予时把 appKey 解析成 UserBase 稳定主键 appId 落库(授权对象是主体不是凭据),
* 并按学校开通事实做发放闸(enforce 开启时未开通拒发)。撤销只置 {@code enabled=0}
* (不删行,审计与幂等判据保留);撤权只拦新发起,不回写在途实例与已受理命令。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowUsageGrantAdminService {
private final FlwUsageGrantRepository grantRepository;
private final UserBaseActivationClient activationClient;
@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);
}
public List<FlwUsageGrant> listByApp(Long schId, Integer appId) {
return grantRepository.findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(schId, appId);
}
/**
* 授予/重申(三元键 upsert)。
*
* @throws BizException appKey 不可解析为稳定主体(fail-closed,管理面必须授到真实主体上);
* enforce 开启且应用未在该校开通或核验不可用
*/
@Transactional
public FlwUsageGrant grant(Long schId, String flowGuid, String appKey, String remark, String operator) {
if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty()
|| appKey == null || appKey.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "schId/flowGuid/appKey 必填");
}
String cleanKey = appKey.trim();
JsonNode registered = fetchRegistered(cleanKey);
Integer appId = registered == null ? null : readId(registered);
if (appId == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"appKey 未能在 UserBase 解析出稳定应用主体,拒绝授权:" + cleanKey);
}
if (activationClient.isEnforce()
&& !activationClient.isAppActivatedAtSchool(appId, schId)) {
throw new BizException(ErrorCode.FORBIDDEN,
"该应用未在学校 " + schId + " 开通,不能授予流程使用许可");
}
FlwUsageGrant grant = grantRepository
.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId)
.orElseGet(FlwUsageGrant::new);
boolean isNew = grant.getId() == null;
if (isNew) {
grant.setSchId(schId);
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);
grant.setAppNameSnapshot(registered.path("appName").asText(null));
grant.setEnabled(1);
grant.setRemark(remark);
FlwUsageGrant saved = grantRepository.save(grant);
log.info("[usage-grant] 授予 schId={} flowGuid={} appId={} appKey={} operator={} 新建={}",
schId, flowGuid, appId, cleanKey, operator, isNew);
return saved;
}
/** 撤销(enabled=0,行保留);幂等:对已撤销行重复撤销不报错 */
@Transactional
public FlwUsageGrant revoke(Long schId, String flowGuid, Integer appId, String remark, String operator) {
FlwUsageGrant grant = grantRepository
.findBySchIdAndFlowGuidAndAppId(schId, flowGuid, appId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"使用许可不存在:school=" + schId + " flow=" + flowGuid + " app=" + appId));
grant.setEnabled(0);
if (remark != null && !remark.trim().isEmpty()) {
grant.setRemark(remark.trim());
}
FlwUsageGrant saved = grantRepository.save(grant);
log.info("[usage-grant] 撤销 schId={} flowGuid={} appId={} operator={}(只拦新发起,在途不受影响)",
schId, flowGuid, appId, operator);
return saved;
}
private JsonNode fetchRegistered(String appKey) {
if (appClient == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册通道未装配,拒绝授权");
}
try {
JsonNode node = appClient.getAppByKey(appKey);
return node == null || node.isNull() ? null : node;
} catch (RuntimeException e) {
log.warn("[usage-grant] UserBase 注册解析失败 appKey={}:{}", appKey, e.toString());
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册解析不可用,请稍后重试");
}
}
private Integer readId(JsonNode registered) {
JsonNode idNode = registered.path("id");
return idNode.isNumber() ? idNode.intValue() : null;
}
}