diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
index 549a075..c467440 100644
--- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
+++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
@@ -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 查询应用信息(带缓存)。
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。
@@ -73,6 +77,21 @@ public class AppController {
return toVo(applicationInfoService.getByAppKey(appKey));
}
+ /**
+ * 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。
+ * 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用,
+ * 超级管理员返回全部),再映射为 app_code(空值行跳过)。只读,不透出 appSecret。
+ * 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口;
+ * 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。
+ *
+ * @param userId 用户ID(必填)
+ * @return 有权应用的 app_code 集合(可能为空,不为 null)
+ */
+ @GetMapping("authorized-codes")
+ public List authorizedCodes(@RequestParam("userId") Integer userId) {
+ return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId));
+ }
+
private ApplicationInfoVo toVo(ApplicationInfo info) {
if (info == null) {
return null;
diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java
index 2c0b611..242896d 100644
--- a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java
+++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java
@@ -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 数据面,判权由调用方完成)。
+ * 顺序跟随入参集合;app_code 为空的行跳过(存量历史行允许 null);结果按首次出现去重。
+ *
+ * @param ids 应用主键 id 集合
+ * @return app_code 列表(可能为空,不为 null)
+ */
+ List listAppCodesByIds(Collection ids);
+
/**
* 保存/更新应用信息(写后同步更新缓存)。
*/
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 c080064..dd71a6a 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java
@@ -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 listAppCodesByIds(Collection ids) {
+ List codes = new ArrayList<>();
+ if (ids == null || ids.isEmpty()) {
+ return codes;
+ }
+ Set 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 监听器统一执行。
*/
diff --git a/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerAuthorizedCodesTest.java b/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerAuthorizedCodesTest.java
new file mode 100644
index 0000000..a8c1c11
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerAuthorizedCodesTest.java
@@ -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)。
+ * 控制器薄委托:判权来源 {@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 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());
+ }
+}
diff --git a/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java
index e5df073..4921d57 100644
--- a/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java
+++ b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java
@@ -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 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());
+ }
}