diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java index fbd2e42..75bc3aa 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -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 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(); diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java index d931846..03041bf 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java @@ -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 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; diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index dd71a6a..c5fab57 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -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(); } }; diff --git a/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java index e0df1d9..7bc70bf 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java @@ -194,6 +194,15 @@ public class SchoolAppOverrideService { return vo; } + /** + * 「全部」视图(未选应用类型)排序:按 id 正序 —— 创建顺序,最新创建的在最后。 + * 与 SUPER 分支同口径(AppSettingsController:「全部」= Sort.by(ASC,"id")): + * sort_no 是「类型内」序号,跨类型打平无全局意义,故「全部」不参与生效排序。 + */ + private Comparator appIdOrder() { + return Comparator.comparing(ApplicationInfoVo::getId, Comparator.nullsLast(Integer::compareTo)); + } + /** 生效排序:override sortNo 优先,空沿用主表(空视为999),并列按 id 升序 */ private Comparator effectiveAppOrder(Map overrides) { return (a, b) -> { @@ -243,9 +252,12 @@ public class SchoolAppOverrideService { filtered.add(app); } Map overrides = appOverridesFor(schools); + // 「全部」视图(未选应用类型)按 id 正序——与 SUPER 分支同口径(AppSettingsController: + // 全部 = Sort.by(ASC,"id"))。sort_no 是「类型内」序号,跨类型打平无全局意义, + // 故「全部」不参与生效排序;选中类型仍走 override/主表生效排序(拖拽结果可见)。 List vos = filtered.stream() .map(app -> mergeAppVo(app, overrides)) - .sorted(effectiveAppOrder(overrides)) + .sorted(categoryId == null ? appIdOrder() : effectiveAppOrder(overrides)) .collect(Collectors.toList()); return pageInMemory(vos, pageable); } diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceBaseAppFilterTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceBaseAppFilterTest.java new file mode 100644 index 0000000..933cb1f --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceBaseAppFilterTest.java @@ -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 修复)。
+ * 契约:用户端应用列表(门户 /apps)不出现基石应用 —— + *
    + *
  • UserBase 自身(appKey ≤ 100000)被排除(超管分支 findUserAuthorizedAppIds 返回全量时的兜底);
  • + *
  • app_nature=BASE(UserHome / 移动门户)被排除;
  • + *
  • BUSINESS 应用保留;
  • + *
  • 无基石行时输出与改动前逐项等价(不误伤)。
  • + *
+ */ +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 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 result = appCategoryService.listBoaAppType(1, null, null); + + assertEquals(1, result.size()); + List 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 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; + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/SchoolAppOverrideServiceListSchoolAppsTest.java b/src/test/java/com/yunhai/userbase/service/impl/SchoolAppOverrideServiceListSchoolAppsTest.java new file mode 100644 index 0000000..e6ea0b0 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SchoolAppOverrideServiceListSchoolAppsTest.java @@ -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 修复)。
+ * 契约(学校管理员应用列表): + *
    + *
  • 「全部」视图(categoryId=null)→ 按 id 正序(与 SUPER 分支 Sort.by(ASC,"id") 同口径), + * 最新创建的在最后,主表/覆盖 sort_no 均不影响;
  • + *
  • 选中应用类型 → 仍按生效排序(override sortNo 优先,空回落主表,并列 id 升序),拖拽结果不回归。
  • + *
+ */ +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 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 ids = idsOf(service.listSchoolApps(new ApplicationInfoQuery(), pageable, scope, null)); + assertEquals(Arrays.asList(1, 2, 3), ids); + } + + /** 选中应用类型:仍按主表生效排序(sortNo 升序) */ + @Test + void categoryView_orderedByEffectiveSortNo() { + List 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 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 ids = idsOf(service.listSchoolApps(query, pageable, scope, null)); + assertEquals(Collections.singletonList(3), ids); + } + + // ===================== 辅助 ===================== + + private static List idsOf(Page 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; + } +}