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
@@ -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<FlwApp> 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<FlwApp> 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());
}
}
@@ -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());
}
}
@@ -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) {