diff --git a/deploy/userbase.nacos.yaml b/deploy/userbase.nacos.yaml index 41698d5..f217d6b 100644 --- a/deploy/userbase.nacos.yaml +++ b/deploy/userbase.nacos.yaml @@ -83,12 +83,6 @@ yh: mobi-api-pattern: /mobiapi/* app-auth: enabled: true - # 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务) - app-key: 100616 - app-secret: 1febb9502d2744b6a235d3e4cfde4a2d - # 受信应用(服务端验签用):appKey -> appSecret - trusted-apps: - 100616: 1febb9502d2744b6a235d3e4cfde4a2d ADMIN_TAG_ID: 290 diff --git a/docker/application-prod.yml b/docker/application-prod.yml index 6252aed..b88f43f 100644 --- a/docker/application-prod.yml +++ b/docker/application-prod.yml @@ -69,12 +69,6 @@ yh: mobi-api-pattern: /mobiapi/* app-auth: enabled: true - # 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务) - app-key: 100616 - app-secret: 1febb9502d2744b6a235d3e4cfde4a2d - # 受信应用(服务端验签用):appKey -> appSecret - trusted-apps: - 100616: 1febb9502d2744b6a235d3e4cfde4a2d ADMIN_TAG_ID: 290 diff --git a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java index 738087f..08dcd00 100644 --- a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java +++ b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java @@ -2,6 +2,7 @@ package com.yunhai.userbase.config; import com.yunhai.platform.appauth.AppAuthProperties; import com.yunhai.platform.appauth.AppSecretProvider; +import com.yunhai.platform.appauth.LocalAppIdentityProvider; import com.yunhai.userbase.repository.ApplicationInfoRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,7 +11,9 @@ import org.springframework.context.annotation.Configuration; * 用户中心验签密钥源:UserBase 作为密钥权威源,优先从本库 application_info 解析调用方密钥 * (覆盖全部「已颁发」应用),并以配置式 {@code yh.app-auth.trustedApps} 兜底平台自身默认密钥。 * 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,使开放接口签名校验 - * 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。 + * 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。

+ * 同时注册 UserBase 自身作为出站调用方的身份:固定 appKey=100000,密钥落库,不再依赖 yml + * 配置 app-key/app-secret。 */ @Configuration public class AppSecretConfig { @@ -20,4 +23,9 @@ public class AppSecretConfig { AppAuthProperties properties) { return new UserBaseAppSecretProvider(repository, properties); } + + @Bean + public LocalAppIdentityProvider localAppIdentityProvider(ApplicationInfoRepository repository) { + return new UserBaseLocalAppIdentityProvider(repository); + } } diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java new file mode 100644 index 0000000..b027951 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java @@ -0,0 +1,63 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.appauth.LocalAppIdentityProvider; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.repository.ApplicationInfoRepository; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * UserBase 自身作为「出站签名调用方」的身份提供方。

+ * 约定:
+ * 1) appKey 固定占用 100000(基石应用段),由 application_bootstrap.sql 初始化脚本幂等插入;
+ * 2) appSecret 从 application_info 表按 appKey=100000 读取,带 60s 本地缓存(密钥重设后 60s 内生效)。

+ * 显式覆盖 yh-app-auth-spring-boot-starter 默认的 PropertyLocalAppIdentityProvider(yml 不再需要 + * 配置 yh.app-auth.app-key / app-secret)。 + */ +public class UserBaseLocalAppIdentityProvider implements LocalAppIdentityProvider { + + private static final String USER_CENTER_APP_KEY = "100000"; + private static final int USER_CENTER_APP_KEY_INT = 100000; + private static final long TTL_MS = 60_000L; + + private final ApplicationInfoRepository repository; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + public UserBaseLocalAppIdentityProvider(ApplicationInfoRepository repository) { + this.repository = repository; + } + + @Override + public String getAppKey() { + return USER_CENTER_APP_KEY; + } + + @Override + public String getAppSecret() { + long now = System.currentTimeMillis(); + CacheEntry hit = cache.get(USER_CENTER_APP_KEY); + if (hit != null && now - hit.ts < TTL_MS) { + return hit.secret; + } + String secret = null; + try { + ApplicationInfo info = repository.findByAppKey(USER_CENTER_APP_KEY_INT); + if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) { + secret = info.getAppSecret(); + } + } catch (Exception ignored) { + } + cache.put(USER_CENTER_APP_KEY, new CacheEntry(now, secret)); + return secret; + } + + private static final class CacheEntry { + final long ts; + final String secret; + + CacheEntry(long ts, String secret) { + this.ts = ts; + this.secret = secret; + } + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java b/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java index bb187c9..4abe136 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java @@ -1,17 +1,29 @@ package com.yunhai.userbase.controller.oapi; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.model.groupauthz.UserPermissionAppVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo; +import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.UserCenterService; +import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /open/api/user/...})。

* 路径与基础平台 {@code DefaultUserCenterClient} 硬编码的契约严格一致: - * {@code GET /open/api/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}。
+ * {@code GET /open/api/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}、 + * {@code /open/api/user/modules?userId=&schId=}。
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求, * 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。 */ @@ -22,6 +34,9 @@ public class OpenBaseController { @Autowired private UserCenterService userCenterService; + @Autowired + private IGroupAuthzService groupAuthzService; + @GetMapping("byId") public BoaUser byId(@RequestParam("id") Integer id) { return userCenterService.getUserInfoWithDeptById(id); @@ -42,4 +57,78 @@ public class OpenBaseController { } return userCenterService.getUserInfoWithDeptByPhone(phone); } + + /** + * 查询用户在指定学校下的已授权模块列表。
+ * 若 {@code schId} 为 null 时,自动取用户的主学校;若仍无主学校信息,退化为取用户主归属组(groups 第 0 个)。 + * + * @param userId 用户ID(必填) + * @param schId 学校ID/组ID(可选;为 null 时按上述规则推断) + * @return 扁平化的已授权模块列表(含基础平台统一 BoaModule 模型);无权限时返回空列表 + */ + @GetMapping("modules") + public List modules(@RequestParam("userId") Integer userId, + @RequestParam(value = "schId", required = false) Integer schId) { + if (userId == null) { + return Collections.emptyList(); + } + Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId); + if (effectiveGroupId == null) { + return Collections.emptyList(); + } + List tree = groupAuthzService.findUserPermissionTree(effectiveGroupId, userId); + if (tree == null || tree.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (UserPermissionCategoryVo category : tree) { + if (category.getApps() == null) continue; + for (UserPermissionAppVo appVo : category.getApps()) { + String icon = appVo.getPcIcon(); + collectModules(appVo.getModules(), icon, result); + } + } + return result; + } + + /** + * 递归收集已授权模块树到扁平列表,并把应用 PC 图标作为模块的 icon 兜底(BoaModule.icon)。 + */ + private void collectModules(List modules, + String appIcon, + List collector) { + if (modules == null || modules.isEmpty()) { + return; + } + for (UserPermissionModuleVo m : modules) { + BoaModule bm = new BoaModule(); + BeanUtils.copyProperties(m, bm); + bm.setIcon(appIcon); + collector.add(bm); + collectModules(m.getChildren(), appIcon, collector); + } + } + + /** + * 按「schId 为空时」解析出用户主学校/主组作为模块查询的上下文组ID。 + */ + private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { + if (schId != null) { + return schId; + } + BoaUser boa = userCenterService.getUserInfoWithDeptById(userId); + if (boa == null) { + return null; + } + BoaGroup school = boa.getSchool(); + if (school != null && school.getId() != null) { + return school.getId(); + } + List groups = boa.getGroups(); + if (groups != null && !groups.isEmpty()) { + BoaGroup main = groups.get(0); + return main == null ? null : main.getId(); + } + return null; + } } diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java index c1cef04..8bde07a 100644 --- a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java @@ -17,7 +17,8 @@ import java.util.List; * @description */ public interface ApplicationInfoRepository extends JpaRepository, JpaSpecificationExecutor { - Integer countByCategoryId(Integer id); + @Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000") + Integer countByCategoryId(@Param("categoryId") Integer id); boolean existsByName(String name); @@ -54,21 +55,23 @@ public interface ApplicationInfoRepository extends JpaRepository * 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。
+ * 排除用户中心自身的基石应用(appKey=100000)。 * * @param categoryId 应用类型ID * @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底 */ @Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " + - "FROM ApplicationInfo a WHERE a.categoryId = :categoryId ORDER BY a.sortNo ASC, a.id ASC") + "FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 ORDER BY a.sortNo ASC, a.id ASC") List findSortVoByCategoryId(@Param("categoryId") Integer categoryId); /** * 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。
+ * 排除用户中心自身的基石应用(appKey=100000)。 * * @return 全部应用的授权投影行 */ @Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " + - "FROM ApplicationInfo a ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC") + "FROM ApplicationInfo a WHERE a.appKey > 100000 ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC") List findAllAuthzRows(); } 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 807a483..51ad550 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -191,6 +191,7 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override public Predicate toPredicate(Root root, CriteriaQuery cq, CriteriaBuilder cb) { List predicates = new ArrayList(); + predicates.add(cb.greaterThan(root.get("appKey"), 100000)); if (query != null) { if (StringUtils.isNotEmpty(query.getName())) { String keyword = query.getName(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef71b75..f56c5c1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,80 +1,73 @@ server: - port: 8060 + port: 8060 spring: - mvc: - date-format: yyyy-MM-dd HH:mm:ss - servlet: - multipart: - max-file-size: 50MB # 单个文件大小 - max-request-size: 200MB # 单次请求总大小(多文件/表单整体上限,避免单文件没超但整体被拒) - jackson: - time-zone: GMT+8 - date-format: yyyy-MM-dd HH:mm:ss - redis: - host: 127.0.0.1 - port: 6379 - database: 0 - password: 123456 - # 连接/命令超时:让后台探活(不可达时)快速失败,避免阻塞定时器线程;不影响正常请求 - timeout: 500ms - data: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + servlet: + multipart: + max-file-size: 50MB # 单个文件大小 + max-request-size: 200MB # 单次请求总大小(多文件/表单整体上限,避免单文件没超但整体被拒) + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss redis: - repositories: - enabled: false - http: - encoding: - force: true - charset: utf-8 - enabled: true + host: 127.0.0.1 + port: 6379 + database: 0 + password: 123456 + # 连接/命令超时:让后台探活(不可达时)快速失败,避免阻塞定时器线程;不影响正常请求 + timeout: 500ms + data: + redis: + repositories: + enabled: false + http: + encoding: + force: true + charset: utf-8 + enabled: true - datasource: - url: jdbc:mysql://localhost:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai - username: root - password: 123456 - driver-class-name: com.mysql.cj.jdbc.Driver - min-idle: 30 - max-active: 30 - #jpa - jpa: - database: MySQL - show-sql: true - hibernate: - # 生产环境设置成 none,避免程序运行时自动更新数据库结构 - ddl-auto: update - database-platform: org.hibernate.dialect.MySQL5InnoDBDialect - properties: - hibernate.order_by.default_null_ordering: last - hibernate: - dialect: org.hibernate.dialect.MySQL5InnoDBDialect - open-in-view: true - - #本地关闭rabbit - rabbitmq: - listener: - direct: - auto-startup: false - simple: - auto-startup: false + datasource: + url: jdbc:mysql://localhost:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 30 + max-active: 30 + #jpa + jpa: + database: MySQL + show-sql: true + hibernate: + # 生产环境设置成 none,避免程序运行时自动更新数据库结构 + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + hibernate: + dialect: org.hibernate.dialect.MySQL5InnoDBDialect + open-in-view: true + #本地关闭rabbit + rabbitmq: + listener: + direct: + auto-startup: false + simple: + auto-startup: false # 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) yh: - sso: - server-url-prefix: http://sso.yunhaixingtong.com/cas/ - client-host-url: http://localhost:8060 - allow-headers: all - allow-origins: all - url-pattern: /api/* - mobi-api-pattern: /mobiapi/* - app-auth: - enabled: true - # 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务) - app-key: 100616 - app-secret: 1febb9502d2744b6a235d3e4cfde4a2d - # 受信应用(服务端验签用):appKey -> appSecret - trusted-apps: - 100616: 1febb9502d2744b6a235d3e4cfde4a2d + sso: + server-url-prefix: http://sso.yunhaixingtong.com/cas/ + client-host-url: http://localhost:8060 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true ADMIN_TAG_ID: 290 diff --git a/src/main/resources/sql/application_bootstrap.sql b/src/main/resources/sql/application_bootstrap.sql new file mode 100644 index 0000000..c85223c --- /dev/null +++ b/src/main/resources/sql/application_bootstrap.sql @@ -0,0 +1,37 @@ +-- ============================================================ +-- 用户中心自身应用身份初始化(appKey=100000,后续业务应用从 100001 起) +-- ------------------------------------------------------------ +-- 适用数据库:MySQL 5.7 / 8.0 +-- 运行方式: +-- mysql --default-character-set=utf8mb4 -u -p < application_bootstrap.sql +-- 前置:表结构由 Hibernate(ddl-auto=update)创建,请先启动一次应用生成 +-- application_info 表后再执行本脚本;如已执行请忽略(幂等)。 +-- 说明: +-- 1. 本脚本按 app_key=100000 做幂等判断,重复执行不会新增重复记录。 +-- 2. 用户中心作为「应用平台基石/默认调用方」,自身也需要一个合法的应用身份, +-- 用于对外发起 yh-app-auth 签名的应用签名请求;因此固定占用 appKey=100000。 +-- 3. appSecret 每次执行脚本若不存在时会重新生成一个 UUID(32 位十六进制); +-- 已存在记录不会被修改,生产上如需重置请手动 UPDATE 或在应用管理界面点「重设密钥」。 +-- 4. 业务应用新增时 ApplicationInfoService 会按 MAX(app_key)+1 分配: +-- 因此本脚本写入 100000 后,下一条业务应用 appKey=100001,与预期一致。 +-- 5. AUTO_INCREMENT=100001 仅设置 application_info 表的主键 id(非 appKey), +-- 使后续新建行的主键 id 也从 100001 起,与 appKey 段对齐,便于排查。 +-- ============================================================ + +SET NAMES utf8mb4; + +-- 1) 插入用户中心自身身份(appKey=100000),已存在则跳过 +INSERT INTO application_info (app_key, name, app_secret, app_desc, create_user, create_time, update_user, update_time) +SELECT 100000, + '用户中心平台', + REPLACE(UUID(), '-', ''), + '用户中心自身应用身份(基石应用,不对外展示于应用管理列表)', + 0, + NOW(), + 0, + NOW() +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM application_info WHERE app_key = 100000); + +-- 2) 调整主键 id 自增起点,使其与 appKey 段对齐(100001 起) +ALTER TABLE application_info AUTO_INCREMENT = 100001;