feat(app): 新增 /oapi/app/authorized-codes 判权端点(消息跳转收口 C1)——findUserAuthorizedAppIds 判权 + listAppCodesByIds 映射 app_code(空值跳过/首现去重);数据面与控制器用例补齐

This commit is contained in:
chenxing
2026-09-28 16:52:37 +08:00
parent fe19954d8b
commit 7e222f19b8
5 changed files with 149 additions and 0 deletions
@@ -8,6 +8,7 @@ import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -33,6 +34,9 @@ public class AppController {
@Autowired
private IApplicationInfoService applicationInfoService;
@Autowired
private IGroupAuthzService groupAuthzService;
/**
* 按主键 ID 查询应用信息(带缓存)。<br>
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
@@ -73,6 +77,21 @@ public class AppController {
return toVo(applicationInfoService.getByAppKey(appKey));
}
/**
* 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。<br>
* 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用,
* 超级管理员返回全部),再映射为 app_code(空值行跳过)。只读,不透出 appSecret。<br>
* 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口;
* 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。
*
* @param userId 用户ID(必填)
* @return 有权应用的 app_code 集合(可能为空,不为 null)
*/
@GetMapping("authorized-codes")
public List<String> authorizedCodes(@RequestParam("userId") Integer userId) {
return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId));
}
private ApplicationInfoVo toVo(ApplicationInfo info) {
if (info == null) {
return null;
@@ -11,6 +11,7 @@ import com.yunhai.userbase.model.application.PushSettingVo;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List;
/**
@@ -45,6 +46,15 @@ public interface IApplicationInfoService {
*/
ApplicationInfo getByAppKey(Integer appKey);
/**
* 按应用 id 集合取技术标识 app_code 列表(/oapi/app/authorized-codes 数据面,判权由调用方完成)。<br>
* 顺序跟随入参集合;app_code 为空的行跳过(存量历史行允许 null);结果按首次出现去重。
*
* @param ids 应用主键 id 集合
* @return app_code 列表(可能为空,不为 null)
*/
List<String> listAppCodesByIds(Collection<Integer> ids);
/**
* 保存/更新应用信息(写后同步更新缓存)。
*/
@@ -37,8 +37,11 @@ import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@Service
@@ -120,6 +123,22 @@ public class ApplicationInfoService implements IApplicationInfoService {
return id == null ? null : getById(id);
}
@Override
public List<String> listAppCodesByIds(Collection<Integer> ids) {
List<String> codes = new ArrayList<>();
if (ids == null || ids.isEmpty()) {
return codes;
}
Set<String> seen = new HashSet<>();
for (ApplicationInfo info : applicationInfoRepository.findAllById(ids)) {
String code = info.getAppCode();
if (StringUtils.isNotBlank(code) && seen.add(code)) {
codes.add(code);
}
}
return codes;
}
/**
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
*/
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* /oapi/app/authorized-codes 判权端点单测(消息跳转收口 C1)。<br>
* 控制器薄委托:判权来源 {@code IGroupAuthzService.findUserAuthorizedAppIds}(超管返回全部 /
* 普通用户返回子集 / 空权限返回空),数据面 {@code listAppCodesByIds} 映射 app_code
* (空值跳过/去重语义见 ApplicationInfoLookupTest)。
*/
@DisplayName("AppController.authorizedCodes - 有权 app_code 集合")
class AppControllerAuthorizedCodesTest {
@InjectMocks
private AppController controller;
@Mock
private IGroupAuthzService groupAuthzService;
@Mock
private IApplicationInfoService applicationInfoService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void 超管_返回全部应用code() {
when(groupAuthzService.findUserAuthorizedAppIds(10006)).thenReturn(Arrays.asList(100011, 100013, 100015));
when(applicationInfoService.listAppCodesByIds(Arrays.asList(100011, 100013, 100015)))
.thenReturn(Arrays.asList("flow-center", "notice", "userhome"));
List<String> codes = controller.authorizedCodes(10006);
assertEquals(Arrays.asList("flow-center", "notice", "userhome"), codes);
verify(applicationInfoService).listAppCodesByIds(Arrays.asList(100011, 100013, 100015));
}
@Test
void 普通用户_仅返回有权子集() {
when(groupAuthzService.findUserAuthorizedAppIds(201)).thenReturn(Collections.singletonList(100013));
when(applicationInfoService.listAppCodesByIds(Collections.singletonList(100013)))
.thenReturn(Collections.singletonList("notice"));
assertEquals(Collections.singletonList("notice"), controller.authorizedCodes(201));
}
@Test
void 空权限_返回空集合() {
when(groupAuthzService.findUserAuthorizedAppIds(202)).thenReturn(Collections.emptyList());
when(applicationInfoService.listAppCodesByIds(Collections.emptyList()))
.thenReturn(Collections.emptyList());
assertEquals(Collections.emptyList(), controller.authorizedCodes(202));
verify(groupAuthzService).findUserAuthorizedAppIds(202);
verify(applicationInfoService).listAppCodesByIds(Collections.emptyList());
}
}
@@ -108,4 +108,31 @@ class ApplicationInfoLookupTest {
assertNull(existing.getAppNature(), "性质同样不得被 null 覆盖");
verify(applicationInfoRepository, never()).existsByAppCodeAndIdNot(any(), any());
}
@Test
void listAppCodesByIds_空appCode行跳过_按首现去重() {
ApplicationInfo center = flowCenter();
ApplicationInfo legacy = new ApplicationInfo();
legacy.setId(100012);
legacy.setAppCode(null);
ApplicationInfo notice = new ApplicationInfo();
notice.setId(100013);
notice.setAppCode("notice");
when(applicationInfoRepository.findAllById(any())).thenReturn(
java.util.Arrays.asList(center, legacy, notice, center));
java.util.List<String> codes = applicationInfoService.listAppCodesByIds(
java.util.Arrays.asList(100011, 100012, 100013));
assertEquals(java.util.Arrays.asList("flow-center", "notice"), codes,
"null app_code 跳过(存量历史行),重复去重");
}
@Test
void listAppCodesByIds_空入参返回空不出站() {
assertEquals(java.util.Collections.emptyList(), applicationInfoService.listAppCodesByIds(null));
assertEquals(java.util.Collections.emptyList(), applicationInfoService.listAppCodesByIds(
java.util.Collections.emptyList()));
verify(applicationInfoRepository, never()).findAllById(any());
}
}