diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index b082ce9..48fc231 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -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 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 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); diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwApp.java b/src/main/java/com/yunhai/flowcenter/entity/FlwApp.java new file mode 100644 index 0000000..f097b8f --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwApp.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwAppRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwAppRepository.java new file mode 100644 index 0000000..66cc3cf --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwAppRepository.java @@ -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 { + + List findBySchIdOrderBySortNoAscIdAsc(Long schId); + + List 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); +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowAppAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowAppAdminService.java new file mode 100644 index 0000000..253f732 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowAppAdminService.java @@ -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 白名单的同步与开关。 + * + *

源池口径(拍板 D7):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 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 pool = appClient.getSchoolApp(schId.intValue(), null); + Map poolApps = new HashMap<>(); + for (BoaAppType type : pool) { + for (BoaApp app : type.getApps()) { + if (app.getAppKey() != null) { + poolApps.put(String.valueOf(app.getAppKey()), app); + } + } + } + Map local = new HashMap<>(); + for (FlwApp row : appRepository.findBySchIdOrderBySortNoAscIdAsc(schId)) { + local.put(row.getAppKey(), row); + } + int inserted = 0; + int refreshed = 0; + for (Map.Entry 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); + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index 43d7446..b0767c2 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -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()) { diff --git a/src/main/resources/sql/20261010_create_flw_app.sql b/src/main/resources/sql/20261010_create_flw_app.sql new file mode 100644 index 0000000..cd46e07 --- /dev/null +++ b/src/main/resources/sql/20261010_create_flw_app.sql @@ -0,0 +1,15 @@ +-- 2026-10-10 四议题·应用设置:流程对接应用白名单表 +-- ddl-auto=update 会自动建表;本脚本为生产备案(生产建议停自动 DDL 后手工执行) +CREATE TABLE IF NOT EXISTS `flw_app` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL COMMENT '租户键', + `app_key` VARCHAR(64) NOT NULL COMMENT '子应用 appKey(UserBase application_info.app_key 字符串形态)', + `app_name` VARCHAR(128) NOT NULL COMMENT '应用名快照(学校池返回值,含学校别名;同步刷新)', + `enabled` INT NOT NULL DEFAULT 1 COMMENT '1=对接流程(白名单);0=停用(defSave 校验拦截)', + `sort_no` INT NULL COMMENT '展示排序(学校池 orderNumber 快照)', + `create_time` DATETIME(6) NULL, + `update_time` DATETIME(6) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_app_sch_key` (`sch_id`, `app_key`), + KEY `idx_flw_app_sch_enabled` (`sch_id`, `enabled`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '流程对接应用白名单(应用设置)'; diff --git a/src/main/resources/sql/20261010_userbase_menu_107_appsetting.sql b/src/main/resources/sql/20261010_userbase_menu_107_appsetting.sql new file mode 100644 index 0000000..6105cc1 --- /dev/null +++ b/src/main/resources/sql/20261010_userbase_menu_107_appsetting.sql @@ -0,0 +1,56 @@ +-- ===================================================================== +-- flow-center 四议题(2026-10-10):UserBase app_module 变更 +-- ① 新增 107「应用设置」(/admin/app-setting,应用管理 + 科室管理两个 tab) +-- ② 下线 104「办理人映射」(/admin/maps 由 应用设置-科室管理 承载,Part E 上线时执行 ②段) +-- 纪律:AI 不代跑,dev / prod 由人工执行;MySQL 5.7 兼容;幂等可重复执行。 +-- 前置:flow-center 前端(含 AppSetting.vue 路由)已发布,否则菜单点进去 404。 +-- ===================================================================== + +-- 步骤 0:校验身份链(app_module.app_id 存的是 application_info 主键,非 app_key) +SELECT id AS app_id_pk, app_key, name + FROM application_info + WHERE id = 100011 OR app_key = 100019; +-- 期望同一行(id=100011 且 app_key=100019);不同则把 @app_id 改成 app_key=100019 那行的 id。 + +SET @app_id = 100011; +SET @operator = 0; + +-- --------------------------------------------------------------------- +-- ① 新增 107 应用设置(幂等:不存在才插;path 与 routes.ts 逐字一致) +-- --------------------------------------------------------------------- +INSERT INTO app_module + (app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time) +SELECT @app_id, NULL, '应用设置', '107', '/admin/app-setting', NULL, 1, 107, @operator, NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM app_module m WHERE m.app_id = @app_id AND m.custom_id = '107' +); + +-- ①授权(角色路径,@role_id 换成实际选中的启用角色;见 flow-center接入UserBase菜单.sql §3.1 查询) +SET @role_id = 0; -- ← 改这里 +INSERT INTO sys_app_role_module_rel (role_id, module_id, create_user, create_time) +SELECT @role_id, m.id, @operator, NOW() + FROM app_module m + WHERE m.app_id = @app_id AND m.custom_id = '107' + AND NOT EXISTS (SELECT 1 FROM sys_app_role_module_rel r + WHERE r.role_id = @role_id AND r.module_id = m.id); + +-- --------------------------------------------------------------------- +-- ② 下线 104 办理人映射(Part E 科室管理上线时执行;先删授权再删模块行) +-- 如需保留回滚能力,可改为 UPDATE app_module SET sort_no = 9999 隐藏(菜单仍下发)。 +-- --------------------------------------------------------------------- +-- DELETE r FROM sys_app_role_module_rel r +-- JOIN app_module m ON m.id = r.module_id +-- WHERE m.app_id = @app_id AND m.custom_id = '104'; +-- DELETE p FROM sys_group_module_perm p +-- JOIN app_module m ON m.id = p.module_id +-- WHERE m.app_id = @app_id AND m.custom_id = '104'; +-- DELETE m FROM app_module m +-- WHERE m.app_id = @app_id AND m.custom_id = '104'; + +-- --------------------------------------------------------------------- +-- 验收 +-- --------------------------------------------------------------------- +SELECT id, app_id, custom_id, name, path, level, sort_no + FROM app_module + WHERE app_id = @app_id + ORDER BY CAST(custom_id AS UNSIGNED); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowAppAdminServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowAppAdminServiceTest.java new file mode 100644 index 0000000..54413d1 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowAppAdminServiceTest.java @@ -0,0 +1,155 @@ +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 应用设置·应用管理单测(2026-10-10):学校池 diff 同步(新增 enabled=1/改名刷新/池外保留)、 + * UserBase 不可达降级、开关与跨校守卫。口径拍板:D2 只做最优实现(不做存量 appKey 兜底)、 + * D3 clientType=null 不过滤端、D7 学校组单点池。 + */ +class FlowAppAdminServiceTest { + + private static final Long SCH = 990001L; + + private FlwAppRepository appRepository; + private OpenApiAppClient appClient; + private FlowAppAdminService service; + + @BeforeEach + void setUp() { + appRepository = mock(FlwAppRepository.class); + appClient = mock(OpenApiAppClient.class); + service = new FlowAppAdminService(appRepository, appClient); + when(appRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + private BoaAppType type(BoaApp... apps) { + BoaAppType t = new BoaAppType(); + t.setId(2); + t.setName("办公管理"); + t.setApps(Arrays.asList(apps)); + return t; + } + + private BoaApp app(Integer appKey, String name, Integer orderNumber) { + BoaApp a = new BoaApp(); + a.setAppKey(appKey); + a.setName(name); + a.setOrderNumber(orderNumber); + return a; + } + + private FlwApp row(String appKey, String name, int enabled) { + FlwApp r = new FlwApp(); + r.setId((long) appKey.hashCode()); + r.setSchId(SCH); + r.setAppKey(appKey); + r.setAppName(name); + r.setEnabled(enabled); + return r; + } + + @Test + void sync_池内新应用_插入且默认启用() { + when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.singletonList( + type(app(100002, "通知公告", 1)))); + when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)).thenReturn(Collections.emptyList()); + + service.syncFromSchoolPool(SCH); + + ArgumentCaptor cap = ArgumentCaptor.forClass(FlwApp.class); + verify(appRepository, times(1)).save(cap.capture()); + assertEquals("100002", cap.getValue().getAppKey()); + assertEquals("通知公告", cap.getValue().getAppName()); + assertEquals(1, cap.getValue().getEnabled()); + } + + @Test + void sync_改名静默刷新_开关不动_池外行保留() { + FlwApp renamed = row("100002", "通知公告", 1); + FlwApp untouchedEnabled = row("100008", "请假", 0); // 池外且已停用:保留不动 + FlwApp untouchedName = row("100010", "旧名", 1); // 池外:名称也不动 + when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.singletonList( + type(app(100002, "通知公告(校内别名)", 9)))); + when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)) + .thenReturn(Arrays.asList(renamed, untouchedEnabled, untouchedName)); + + service.syncFromSchoolPool(SCH); + + verify(appRepository, times(1)).save(any(FlwApp.class)); + assertEquals("通知公告(校内别名)", renamed.getAppName()); + assertEquals(1, renamed.getEnabled()); + assertEquals("旧名", untouchedName.getAppName()); + assertEquals(0, untouchedEnabled.getEnabled()); + } + + @Test + void list_UserBase不可达_降级返回本地行() { + when(appClient.getSchoolApp(any(), any())).thenThrow(new RuntimeException("connect timeout")); + when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)) + .thenReturn(Collections.singletonList(row("100002", "通知公告", 1))); + + List rows = service.list(SCH); + + assertEquals(1, rows.size()); + assertEquals("100002", rows.get(0).getAppKey()); + } + + @Test + void list_正常链路_先同步再返回() { + when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.emptyList()); + when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)).thenReturn(Collections.emptyList()); + assertEquals(0, service.list(SCH).size()); + verify(appClient, times(1)).getSchoolApp(990001, null); + } + + @Test + void enable_切换与守卫() { + FlwApp r = row("100002", "通知公告", 1); + when(appRepository.findById(r.getId())).thenReturn(java.util.Optional.of(r)); + + service.enable(SCH, r.getId(), false); + assertEquals(0, r.getEnabled()); + + service.enable(SCH, r.getId(), true); + assertEquals(1, r.getEnabled()); + + FlwApp other = row("100002", "x", 1); + other.setSchId(2L); + when(appRepository.findById(other.getId())).thenReturn(java.util.Optional.of(other)); + assertThrows(com.yunhai.platform.apistd.BizException.class, () -> service.enable(SCH, other.getId(), true)); + } + + @Test + void sync_池为空_零写入() { + when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.emptyList()); + when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)).thenReturn(Collections.emptyList()); + + service.syncFromSchoolPool(SCH); + + verify(appRepository, never()).save(any()); + assertTrue(service.list(SCH).isEmpty()); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java index 95e84cf..92e5388 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java @@ -37,18 +37,23 @@ class FlowDefAdminServiceSaveTest { private static final String JSON = "{\"nodes\":[],\"edges\":[]}"; private FlwDefRepository defRepository; + private com.yunhai.flowcenter.repository.FlwAppRepository appRepository; private FlowDefAdminService service; @BeforeEach void setUp() { defRepository = mock(FlwDefRepository.class); + appRepository = mock(com.yunhai.flowcenter.repository.FlwAppRepository.class); service = new FlowDefAdminService(defRepository, mock(FlwAssigneeMapRepository.class), mock(FlwRouterRepository.class), mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), + appRepository, mock(FlowEngine.class), new ObjectMapper()); when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + // 归属应用白名单:非空 appKey 一律放行(校验拦截语义由专门用例覆盖) + when(appRepository.isEnabledApp(any(), any())).thenReturn(true); } private FlwDef def(long id, String flowGuid, String code, int version, int status) { @@ -168,4 +173,23 @@ class FlowDefAdminServiceSaveTest { () -> service.save(SCH, 9L, "work-leave", "请假", JSON, null, null)); assertTrue(e.getMessage().contains("保存冲突")); } + + @Test + void appKey不在启用白名单_保存被拦() { + when(appRepository.isEnabledApp(SCH, "999999")).thenReturn(false); + BizException e = assertThrows(BizException.class, + () -> service.save(SCH, null, "work-leave", "请假", JSON, null, "999999")); + assertTrue(e.getMessage().contains("应用设置")); + } + + @Test + void appKey不传_白名单校验不触发() { + // 升版本继承源线(cleanAppKey=null)不查白名单——存量线升版本不受切换影响 + when(defRepository.findById(9L)).thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")).thenReturn(Optional.empty()); + FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null); + org.mockito.Mockito.verify(appRepository, org.mockito.Mockito.never()).isEnabledApp(any(), any()); + assertNull(saved.getAppKey()); + } } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java index 433369f..5af96ae 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java @@ -483,7 +483,8 @@ class FlowDiagramServiceTest { mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class), mock(FlwInstanceRepository.class), diagramService, mock(com.yunhai.flowcenter.service.DiagramImageService.class), - new ObjectMapper()); + new ObjectMapper(), + mock(com.yunhai.flowcenter.service.FlowAppAdminService.class)); } private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {