feat(app): 应用设置·应用管理——flw_app 白名单 + 学校池同步 + defSave 归属应用校验

- flw_app(sch,app_key uk):学校池(UserBase list-by-school,学校组单点已开通、clientType=null 不过滤端)
  diff 同步——新增即 enabled=1、改名静默刷新、池外保留;UserBase 不可达降级本地行
- /admin/app/list(触发同步)/ /admin/app/enable;defSave 显式 appKey 须 ∈ 启用集合(不传不拦,升版本继承不受影响)
- SQL 备案:flw_app 建表 + UserBase 菜单 107 新增/104 下线(人工执行)
- 单测 +8;E2E 13/13(local-debug/four-topics-c-e2e.py)
- 关联 SDK 修复:BoaApp.createTime @JsonFormat 对齐 UserBase 时间契约(BasePlatform 单独 commit,Nexus -73)
This commit is contained in:
chenxing
2026-10-10 19:17:38 +08:00
parent 92619508f0
commit d6b7a68ec3
10 changed files with 454 additions and 1 deletions
@@ -50,6 +50,7 @@ public class FlowAdminController {
private final FlowDiagramService diagramService;
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService;
// ==================== 流程定义 ====================
@@ -66,6 +67,27 @@ public class FlowAdminController {
return defAdminService.distinctAppKeys(adminGuard.requireAdmin(user, null));
}
// ==================== 应用设置(2026-10-10 四议题) ====================
/**
* 应用管理列表:本校"可对接流程"白名单(flw_app)。附带一次学校池静默同步
* (UserBase list-by-school;不可达降级本地行)。开通过程在 UserBase 管理端做。
*/
@GetMapping("/app/list")
public List<com.yunhai.flowcenter.entity.FlwApp> appList(BoaUser user) {
return appAdminService.list(adminGuard.requireAdmin(user, null));
}
/** 开关某应用是否对接流程(停用后 defSave 归属应用校验拦截新引用) */
@PostMapping("/app/enable")
public void appEnable(BoaUser user,
@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
appAdminService.enable(schId,
body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))),
Boolean.parseBoolean(String.valueOf(body.get("enabled"))));
}
@GetMapping("/def/get")
public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) {
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
@@ -0,0 +1,45 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 流程对接应用(2026-10-10 四议题·应用设置):本校"哪些应用可对接流程"的白名单。
* 行来源=应用设置页从学校池(UserBase /oapi/app/list-by-school,学校组单点已开通)同步,
* 同步即 enabled=1;管理员可停用(停用后 defSave 的归属应用校验拦截新引用)。
* app_name 是学校池快照(含学校别名),每次同步静默刷新。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_app",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_app_sch_key", columnNames = {"sch_id", "app_key"}),
indexes = @Index(name = "idx_flw_app_sch_enabled", columnList = "sch_id, enabled"))
public class FlwApp extends BaseEntity {
/** 租户键 */
@Column(name = "sch_id", nullable = false)
private Long schId;
/** 子应用 appKey(UserBase application_info.app_key 的字符串形态,与 flw_def.app_key 同口径) */
@Column(name = "app_key", nullable = false, length = 64)
private String appKey;
/** 应用名快照(学校池返回值,含学校别名;同步时刷新) */
@Column(name = "app_name", nullable = false, length = 128)
private String appName;
/** 1=对接流程(白名单内);0=停用(defSave 校验拦截) */
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
/** 展示排序(学校池 orderNumber 快照,可空=0) */
@Column(name = "sort_no")
private Integer sortNo;
}
@@ -0,0 +1,19 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwApp;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface FlwAppRepository extends JpaRepository<FlwApp, Long> {
List<FlwApp> findBySchIdOrderBySortNoAscIdAsc(Long schId);
List<FlwApp> findBySchIdAndEnabledOrderBySortNoAscIdAsc(Long schId, Integer enabled);
/** defSave 归属应用白名单校验:本校启用集合是否包含该 appKey */
@Query("select count(a) > 0 from FlwApp a where a.schId = :schId and a.appKey = :appKey and a.enabled = 1")
boolean isEnabledApp(@Param("schId") Long schId, @Param("appKey") String appKey);
}
@@ -0,0 +1,109 @@
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwApp;
import com.yunhai.flowcenter.repository.FlwAppRepository;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 应用设置·应用管理(2026-10-10 四议题):flw_app 白名单的同步与开关。
*
* <p><b>源池口径(拍板 D7)</b>:UserBase {@code /oapi/app/list-by-school}——学校组单点已开通
* (sys_group_app_rel,不并子组/角色模块),clientType 传 null 不过滤端(拍板 D3,避免漏纯移动端应用)。
* 同步即入白名单 enabled=1(拍板 D2:只做最优实现,不做存量 appKey 兜底——不在学校池的应用
* 进不了白名单,defSave 引用会被拦,属拍板接受的后果);池内应用名变化静默刷新(学校别名);
* 学校池已移除的本地行保留(enabled 交管理员处置)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowAppAdminService {
private final FlwAppRepository appRepository;
private final OpenApiAppClient appClient;
/**
* 列表(管理端应用设置页)。附带一次静默学校池同步:UserBase 不可达/学校未开通任何应用时
* 降级只返回本地行(首次使用前 flw_app 为空表是正常态,管理员可先在 UserBase 侧开通)。
*/
public List<FlwApp> list(Long schId) {
try {
syncFromSchoolPool(schId);
} catch (Exception e) {
log.warn("[flow-center] 学校池同步失败,降级返回本地应用行:sch={} {}", schId, e.toString());
}
return appRepository.findBySchIdOrderBySortNoAscIdAsc(schId);
}
/** 开关某应用是否对接流程(停用后 defSave 归属应用校验拦截新引用;存量定义不受影响) */
@Transactional
public FlwApp enable(Long schId, Long id, boolean enabled) {
FlwApp app = appRepository.findById(id)
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "应用行不存在:" + id));
if (!app.getSchId().equals(schId)) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "应用行不属于该学校");
}
app.setEnabled(enabled ? 1 : 0);
return appRepository.save(app);
}
/**
* 学校池 → flw_app diff 同步(新增 enabled=1 / 改名静默刷新 / 池外保留)。
* 幂等:按 (sch_id, app_key) 唯一键 upsert。
*/
@Transactional
public void syncFromSchoolPool(Long schId) {
List<BoaAppType> pool = appClient.getSchoolApp(schId.intValue(), null);
Map<String, BoaApp> poolApps = new HashMap<>();
for (BoaAppType type : pool) {
for (BoaApp app : type.getApps()) {
if (app.getAppKey() != null) {
poolApps.put(String.valueOf(app.getAppKey()), app);
}
}
}
Map<String, FlwApp> local = new HashMap<>();
for (FlwApp row : appRepository.findBySchIdOrderBySortNoAscIdAsc(schId)) {
local.put(row.getAppKey(), row);
}
int inserted = 0;
int refreshed = 0;
for (Map.Entry<String, BoaApp> e : poolApps.entrySet()) {
BoaApp poolApp = e.getValue();
FlwApp row = local.get(e.getKey());
if (row == null) {
row = new FlwApp();
row.setSchId(schId);
row.setAppKey(e.getKey());
row.setAppName(poolApp.getName() == null || poolApp.getName().isEmpty()
? e.getKey() : poolApp.getName());
row.setEnabled(1);
row.setSortNo(poolApp.getOrderNumber() == null ? 0 : poolApp.getOrderNumber());
appRepository.save(row);
inserted++;
} else if (poolApp.getName() != null && !poolApp.getName().isEmpty()
&& !poolApp.getName().equals(row.getAppName())) {
// 学校别名变化:静默刷新名称快照(开关不动)
row.setAppName(poolApp.getName());
row.setSortNo(poolApp.getOrderNumber() == null ? row.getSortNo() : poolApp.getOrderNumber());
appRepository.save(row);
refreshed++;
}
}
if (inserted > 0 || refreshed > 0) {
log.info("[flow-center] 学校池同步:sch={} 池内 {} 应用,新增 {} 行,刷新名 {} 行",
schId, poolApps.size(), inserted, refreshed);
}
}
}
@@ -36,6 +36,7 @@ public class FlowDefAdminService {
private final FlwAssigneeMapRepository mapRepository;
private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository;
private final com.yunhai.flowcenter.repository.FlwCallbackRegRepository regRepository;
private final com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
private final FlowEngine flowEngine;
private final ObjectMapper objectMapper;
@@ -79,6 +80,12 @@ public class FlowDefAdminService {
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid, String appKey) {
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
// 归属应用白名单(2026-10-10 应用设置):显式传入非空时必须 ∈ flw_app(sch, enabled=1);
// 不传(升版本继承源线)不拦。不在学校池/未开启的应用由此拦下(拍板 D2:不做存量兜底)
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
}
// JSON 先解析再落库(语法错误不进库)
FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson);
if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) {