fix(app): 应用列表两处——「全部」视图按 id 正序 + 基石应用不进用户端列表
build / build-push (push) Successful in 1m8s

1) 排序(管理端 GET /AppList):未选应用类型(前端「全部」)统一按 id 正序,最新创建的在最后。
   - SUPER:去掉 @SortDefault(sort="sortNo"),改 categoryId==null ? Sort.by(ASC,"id") : Sort.unsorted();
     类别视图排序改由 Specification 内嵌 orderBy(NULL 沉底 + sortNo + id)——NULL 沉底须 case
     表达式,Sort 表达不了,故该分支必须传 unsorted,否则 Pageable 排序会整体覆盖 spec 的 orderBy。
   - SCHOOL:listSchoolApps 按视图分叉,全部视图走新增 appIdOrder(),选中类型仍走生效排序
     (override sortNo 优先、空回落主表、并列 id 升序),学校拖拽排序管理不回归。
   - 口径:sort_no 是「类型内」序号,跨类型打平无全局意义(服务内注释 + 2026-07-22 记忆)。
2) 基石过滤(oapi GET /oapi/app/list):AppCategoryService.listBoaAppType 补 AppNature.isBaseApp
   过滤,UserBase 自身(appKey<=100000)与 app_nature=BASE(UserHome / 移动门户)不进用户端应用列表。
   根因:findUserAuthorizedAppIds 超管分支返回全量,而该读链全程无基石判定——即
   《消息卡片直批与跳转基址统一方案-20260928》§4.2「一开四关」漏掉的第 6 个触点(现收口为「关」)。
   未动 findUserAuthorizedAppIds(被 /oapi/app/authorized-codes 跳转判权共用,改了会连带把
   「跳到基石应用」判无权限);未动 manage 端口径(仅 isUserbaseSelf,保留基石供重置密钥)。

测试:AppCategoryServiceBaseAppFilterTest(3 用例)+ SchoolAppOverrideServiceListSchoolAppsTest(5 用例)
This commit is contained in:
chenxing
2026-10-09 12:03:49 +08:00
parent 90c08a5f14
commit 705cbde7cf
6 changed files with 346 additions and 3 deletions
@@ -30,7 +30,6 @@ import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -195,8 +194,14 @@ public class AppSettingsController {
@GetMapping("/AppList")
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
// 排序必须带唯一兜底键:同键行 MySQL 不保证稳定序,加删一行即整组洗牌(应用列表乱序的根因)。
// 全部(未选类别)= id 升序——sort_no 是「类型内」序号,跨类型打平无全局意义;
// 选中类别 = 未排序(NULL)沉底 + sortNo 升序 + id 兜底——由 Specification 内嵌 orderBy 实现
// (NULL 沉底须 case 表达式,Sort 只走属性解析表达不了;此处传 unsorted,否则 Pageable 排序会覆盖 spec 的 orderBy)
Sort sort = query.getCategoryId() == null
? Sort.by(Sort.Direction.ASC, "id")
: Sort.unsorted();
if (isSchoolLevel()) {
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
AccessScope scope = accessScopeService.resolveCurrentScope();
@@ -7,6 +7,7 @@ import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.constant.AppNature;
import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysUserGroupRel;
@@ -220,6 +221,14 @@ public class AppCategoryService implements IAppCategoryService {
type.setName(category.getName());
List<BoaApp> boaApps = new ArrayList<>();
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
// 基石应用过滤(口径单点 = AppNature.isBaseApp,与管理端 ①②③⑤ 同侧):
// UserBase 自身 / app_nature=BASE(UserHome、移动门户)不参与用户端应用列表,
// 否则超管(findUserAuthorizedAppIds 返回全量)会把基石带进门户 /apps。
// 与 manage 端列表(ApplicationInfoService.select,仅同步 isUserbaseSelf 语义、
// 保留基石供重置密钥)口径不同,勿合并。
if (AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
continue;
}
// 用户权限过滤
if (!authorizedAppIds.contains(app.getId())) {
continue;
@@ -294,6 +294,15 @@ public class ApplicationInfoService implements IApplicationInfoService {
predicates.add(cb.equal(root.get("categoryId"), query.getCategoryId()));
}
}
// 类别视图的排序只能内嵌在 spec(NULL 沉底须 case 表达式,Pageable 的 Sort 表达不了)。
// 生效前提:控制器对该分支传 Sort.unsorted(),否则 Pageable 排序会在此之后 cq.orderBy 整体覆盖;
// count 查询不受影响(Spring Data 会剥掉 spec 带入的 orderBy)
if (query != null && query.getCategoryId() != null) {
cq.orderBy(
cb.asc(cb.selectCase().when(cb.isNull(root.get("sortNo")), 1).otherwise(0)),
cb.asc(root.get("sortNo")),
cb.asc(root.get("id")));
}
return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction();
}
};
@@ -194,6 +194,15 @@ public class SchoolAppOverrideService {
return vo;
}
/**
* 「全部」视图(未选应用类型)排序:按 id 正序 —— 创建顺序,最新创建的在最后。
* 与 SUPER 分支同口径(AppSettingsController:「全部」= Sort.by(ASC,"id")):
* sort_no 是「类型内」序号,跨类型打平无全局意义,故「全部」不参与生效排序。
*/
private Comparator<ApplicationInfoVo> appIdOrder() {
return Comparator.comparing(ApplicationInfoVo::getId, Comparator.nullsLast(Integer::compareTo));
}
/** 生效排序:override sortNo 优先,空沿用主表(空视为999),并列按 id 升序 */
private Comparator<ApplicationInfoVo> effectiveAppOrder(Map<Integer, SysSchoolAppOverride> overrides) {
return (a, b) -> {
@@ -243,9 +252,12 @@ public class SchoolAppOverrideService {
filtered.add(app);
}
Map<Integer, SysSchoolAppOverride> overrides = appOverridesFor(schools);
// 「全部」视图(未选应用类型)按 id 正序——与 SUPER 分支同口径(AppSettingsController:
// 全部 = Sort.by(ASC,"id"))。sort_no 是「类型内」序号,跨类型打平无全局意义,
// 故「全部」不参与生效排序;选中类型仍走 override/主表生效排序(拖拽结果可见)。
List<ApplicationInfoVo> vos = filtered.stream()
.map(app -> mergeAppVo(app, overrides))
.sorted(effectiveAppOrder(overrides))
.sorted(categoryId == null ? appIdOrder() : effectiveAppOrder(overrides))
.collect(Collectors.toList());
return pageInMemory(vos, pageable);
}
@@ -0,0 +1,141 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.repository.AppCategoryRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.when;
/**
* AppCategoryService.listBoaAppType 基石应用过滤单元测试(2026-10-08 修复)。<br>
* 契约:用户端应用列表(门户 /apps)不出现基石应用 ——
* <ul>
* <li>UserBase 自身(appKey &le; 100000)被排除(超管分支 findUserAuthorizedAppIds 返回全量时的兜底);</li>
* <li>app_nature=BASE(UserHome / 移动门户)被排除;</li>
* <li>BUSINESS 应用保留;</li>
* <li>无基石行时输出与改动前逐项等价(不误伤)。</li>
* </ul>
*/
class AppCategoryServiceBaseAppFilterTest {
@InjectMocks
private AppCategoryService appCategoryService;
@Mock
private AppCategoryRepository appCategoryRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private IGroupAuthzService groupAuthzService;
@Mock
private SchoolAppOverrideService schoolAppOverrideService;
@Mock
private IdentityService identityService;
@Mock
private MainGroupResolver mainGroupResolver;
@Mock
private ISysGroupService sysGroupService;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
AppCategory category = new AppCategory();
category.setId(10);
category.setName("办公管理");
category.setSortNo(1);
when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category));
// 无学校语境(平台账号):identity 无、主归属无 → applyAlias 不合并,直接看构建结果
when(identityService.getCurrent(1)).thenReturn(null);
when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
// 合并口透传第一参数(本测试只断言过滤后的构建结果)
when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
.thenAnswer(inv -> inv.getArgument(0));
}
/** 基石三类:UserBase 自身(appKey=100000)/ app_nature=BASE 均排除,BUSINESS 保留 */
@Test
void baseApps_excluded_businessKept() {
List<ApplicationInfo> apps = Arrays.asList(
app(1, 100000, null, "用户中心"),
app(2, 100001, "BASE", "用户之家"),
app(3, 100002, "BUSINESS", "通知公告"));
when(applicationInfoRepository.findAll()).thenReturn(apps);
// 超管分支:授权集合为全量(基石也在内)——正是 bug1 的触发条件
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Arrays.asList(1, 2, 3));
List<BoaAppType> result = appCategoryService.listBoaAppType(1, null, null);
assertEquals(1, result.size());
List<BoaApp> kept = result.get(0).getApps();
assertEquals(1, kept.size());
assertEquals(Integer.valueOf(3), kept.get(0).getId());
assertEquals("通知公告", kept.get(0).getName());
}
/** 无基石行时过滤逻辑零影响:全部 BUSINESS 应用按原顺序保留 */
@Test
void noBaseApps_allKeptUnchanged() {
when(applicationInfoRepository.findAll()).thenReturn(Arrays.asList(
app(3, 100002, "BUSINESS", "通知公告"),
app(4, null, null, "老应用")));
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Arrays.asList(3, 4));
List<BoaApp> kept = appCategoryService.listBoaAppType(1, null, null).get(0).getApps();
assertEquals(2, kept.size());
assertEquals(Integer.valueOf(3), kept.get(0).getId());
assertEquals(Integer.valueOf(4), kept.get(1).getId());
}
/** 无权限 / 无客户端命中的应用原本就被挡(回归:基石过滤不改变这两条既有口径) */
@Test
void unauthorizedBaseApp_stillNoneKept() {
when(applicationInfoRepository.findAll())
.thenReturn(new ArrayList<>(Collections.singletonList(app(2, 100001, "BASE", "用户之家"))));
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Collections.singletonList(2));
assertEquals(0, appCategoryService.listBoaAppType(1, null, null).get(0).getApps().size());
}
private static ApplicationInfo app(Integer id, Integer appKey, String appNature, String name) {
ApplicationInfo app = new ApplicationInfo();
app.setId(id);
app.setAppKey(appKey);
app.setAppNature(appNature);
app.setName(name);
app.setCategoryId(10);
app.setSortNo(id);
return app;
}
}
@@ -0,0 +1,167 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysGroupAppRel;
import com.yunhai.userbase.entity.SysSchoolAppOverride;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.repository.AppCategoryRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysSchoolAppOverrideRepository;
import com.yunhai.userbase.repository.SysSchoolCategoryOverrideRepository;
import com.yunhai.userbase.service.resolve.AccessScope;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyIterable;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
/**
* SchoolAppOverrideService.listSchoolApps 视图分叉排序单元测试(2026-10-08 修复)。<br>
* 契约(学校管理员应用列表):
* <ul>
* <li>「全部」视图(categoryId=null)→ 按 id 正序(与 SUPER 分支 Sort.by(ASC,"id") 同口径),
* 最新创建的在最后,主表/覆盖 sort_no 均不影响;</li>
* <li>选中应用类型 → 仍按生效排序(override sortNo 优先,空回落主表,并列 id 升序),拖拽结果不回归。</li>
* </ul>
*/
class SchoolAppOverrideServiceListSchoolAppsTest {
private static final Integer SCHOOL_ID = 7;
private static final Integer CAT_A = 10;
@InjectMocks
private SchoolAppOverrideService service;
@Mock
private SysSchoolAppOverrideRepository appOverrideRepository;
@Mock
private SysSchoolCategoryOverrideRepository categoryOverrideRepository;
@Mock
private SysGroupAppRelRepository sysGroupAppRelRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private AppCategoryRepository appCategoryRepository;
@Mock
private SysGroupRepository sysGroupRepository;
private final AccessScope scope = AccessScope.school(Collections.singletonList(SCHOOL_ID));
private final Pageable pageable = PageRequest.of(0, 20);
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
when(appOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.emptyList());
when(sysGroupAppRelRepository.findByGroupIdIn(anyList()))
.thenReturn(Arrays.asList(rel(1), rel(2), rel(3)));
// 主表 sortNo 与 id 反序(id 升序 = 3 的 sortNo 最小),两种视图结果必然不同,便于断言分叉生效
// 注意 findAllById 的入参是 openedAppIds 产出的 Set,须用 anyIterable 匹配(anyList 不匹配 Set)
when(applicationInfoRepository.findAllById(anyIterable())).thenReturn(Arrays.asList(
app(1, CAT_A, "应用一", 3),
app(2, CAT_A, "应用二", 2),
app(3, CAT_A, "应用三", 1)));
}
/** 「全部」视图:按 id 正序,主表 sortNo 不参与 */
@Test
void allView_orderedByIdAsc() {
List<Integer> ids = idsOf(service.listSchoolApps(new ApplicationInfoQuery(), pageable, scope, null));
assertEquals(Arrays.asList(1, 2, 3), ids);
}
/** 「全部」视图:有覆盖 sort_no 也不参与(sticky 排序不影响全部视图) */
@Test
void allView_ignoresOverrideSortNo() {
when(appOverrideRepository.findBySchoolIdIn(anyList()))
.thenReturn(new ArrayList<>(Collections.singletonList(override(2, 0))));
List<Integer> ids = idsOf(service.listSchoolApps(new ApplicationInfoQuery(), pageable, scope, null));
assertEquals(Arrays.asList(1, 2, 3), ids);
}
/** 选中应用类型:仍按主表生效排序(sortNo 升序) */
@Test
void categoryView_orderedByEffectiveSortNo() {
List<Integer> ids = idsOf(service.listSchoolApps(queryOfCategory(CAT_A), pageable, scope, null));
assertEquals(Arrays.asList(3, 2, 1), ids);
}
/** 选中应用类型:override sort_no 优先于主表(排序管理结果不回归) */
@Test
void categoryView_overrideSortNoWins() {
when(appOverrideRepository.findBySchoolIdIn(anyList()))
.thenReturn(new ArrayList<>(Collections.singletonList(override(2, 0))));
List<Integer> ids = idsOf(service.listSchoolApps(queryOfCategory(CAT_A), pageable, scope, null));
assertEquals(Arrays.asList(2, 3, 1), ids);
}
/** 关键词过滤仍生效,且「全部」视图内按 id 正序 */
@Test
void allView_keywordFilterKept() {
ApplicationInfoQuery query = new ApplicationInfoQuery();
query.setName("应用三");
List<Integer> ids = idsOf(service.listSchoolApps(query, pageable, scope, null));
assertEquals(Collections.singletonList(3), ids);
}
// ===================== 辅助 =====================
private static List<Integer> idsOf(Page<ApplicationInfoVo> page) {
return page.getContent().stream().map(ApplicationInfoVo::getId).collect(Collectors.toList());
}
private static ApplicationInfoQuery queryOfCategory(Integer categoryId) {
ApplicationInfoQuery query = new ApplicationInfoQuery();
query.setCategoryId(categoryId);
return query;
}
private static SysGroupAppRel rel(Integer appId) {
SysGroupAppRel rel = new SysGroupAppRel();
rel.setGroupId(SCHOOL_ID);
rel.setAppId(appId);
return rel;
}
private static SysSchoolAppOverride override(Integer appId, Integer sortNo) {
SysSchoolAppOverride o = new SysSchoolAppOverride();
o.setSchoolId(SCHOOL_ID);
o.setAppId(appId);
o.setSortNo(sortNo);
return o;
}
private static ApplicationInfo app(Integer id, Integer categoryId, String name, Integer sortNo) {
ApplicationInfo app = new ApplicationInfo();
app.setId(id);
app.setCategoryId(categoryId);
app.setName(name);
app.setSortNo(sortNo);
return app;
}
}