From 876a23a123736553708aa10992602c5d75118b65 Mon Sep 17 00:00:00 2001 From: let5sne Date: Thu, 16 Jul 2026 10:26:28 +0800 Subject: [PATCH] feat(knowledge): add application and principal space grants --- backend/ruoyi-modules/ruoyi-aihr/pom.xml | 5 + .../knowledge/domain/AihrKnowledgeAppDto.java | 37 ++++ .../domain/AihrKnowledgePrincipal.java | 18 ++ .../service/AihrKnowledgeAccessService.java | 135 ++++++++++++ .../service/AihrKnowledgeAppService.java | 205 ++++++++++++++++++ .../AihrKnowledgePrincipalResolver.java | 77 +++++++ .../AihrKnowledgeAccessServiceTest.java | 35 +++ .../AihrKnowledgeAppServiceTest.java | 38 ++++ .../AihrKnowledgePrincipalResolverTest.java | 57 +++++ 9 files changed, 607 insertions(+) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeAppDto.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgePrincipal.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java diff --git a/backend/ruoyi-modules/ruoyi-aihr/pom.xml b/backend/ruoyi-modules/ruoyi-aihr/pom.xml index a5cf11e1..c65c4edf 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/pom.xml +++ b/backend/ruoyi-modules/ruoyi-aihr/pom.xml @@ -31,6 +31,11 @@ ruoyi-common-web + + org.dromara + ruoyi-common-redis + + org.dromara ruoyi-system diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeAppDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeAppDto.java new file mode 100644 index 00000000..c5ff7b04 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeAppDto.java @@ -0,0 +1,37 @@ +package org.dromara.aihr.knowledge.domain; + +import java.time.LocalDateTime; +import java.util.List; + +public final class AihrKnowledgeAppDto { + + private AihrKnowledgeAppDto() { + } + + public record AuthenticatedApp( + Long id, + String tenantId, + String appCode, + String appName, + String authType, + Integer rateLimitPerMinute, + LocalDateTime expiresTime + ) { + } + + public record AppResponse( + Long id, + String appCode, + String appName, + String authType, + String internalClientKey, + String status, + Integer rateLimitPerMinute, + LocalDateTime expiresTime, + List spaceCodes + ) { + } + + public record TokenIssuedResponse(String appCode, String plainToken, LocalDateTime expiresTime) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgePrincipal.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgePrincipal.java new file mode 100644 index 00000000..27b0689c --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgePrincipal.java @@ -0,0 +1,18 @@ +package org.dromara.aihr.knowledge.domain; + +import java.util.Set; + +public record AihrKnowledgePrincipal( + String tenantId, + Long userId, + String userType, + String extPartyId, + Set roles, + Set projectCodes, + String clientKey +) { + public AihrKnowledgePrincipal { + roles = roles == null ? Set.of() : Set.copyOf(roles); + projectCodes = projectCodes == null ? Set.of() : Set.copyOf(projectCodes); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java new file mode 100644 index 00000000..778fa390 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java @@ -0,0 +1,135 @@ +package org.dromara.aihr.knowledge.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class AihrKnowledgeAccessService { + + private final JdbcTemplate jdbcTemplate; + + public Set resolveInternalSpaceIds(AihrKnowledgePrincipal principal, AuthenticatedApp app, + List requestedCodes, String permission) { + requireSameTenant(principal.tenantId(), app.tenantId()); + Set appSpaces = appSpaces(app); + Set principalSpaces = principalSpaces(principal, permission); + Set requestedSpaces = requestedSpaceIds(principal.tenantId(), requestedCodes); + return intersect(appSpaces, principalSpaces, requestedSpaces); + } + + public Set resolveExternalSpaceIds(AuthenticatedApp app, List requestedCodes) { + Set appSpaces = appSpaces(app); + Set requestedSpaces = requestedSpaceIds(app.tenantId(), requestedCodes); + return narrow(appSpaces, requestedSpaces); + } + + public static Set intersect(Set appSpaces, Set principalSpaces, Set requestedSpaces) { + Set effective = new LinkedHashSet<>(appSpaces); + effective.retainAll(principalSpaces); + return narrow(effective, requestedSpaces); + } + + private static Set narrow(Set effective, Set requested) { + if (effective.isEmpty()) { + throw forbidden("当前应用和账号没有共同可访问的知识空间"); + } + if (requested == null || requested.isEmpty()) { + return Set.copyOf(effective); + } + if (!effective.containsAll(requested)) { + throw forbidden("请求包含未授权的知识空间"); + } + return Set.copyOf(requested); + } + + private Set appSpaces(AuthenticatedApp app) { + return new LinkedHashSet<>(jdbcTemplate.queryForList(""" + select s.knowledge_id + from aihr_knowledge_app_space s + join aihr_knowledge_info k on k.id = s.knowledge_id and k.tenant_id = s.tenant_id + where s.tenant_id = ? and s.app_id = ? and k.status = 'ACTIVE' + """, Long.class, app.tenantId(), app.id())); + } + + private Set principalSpaces(AihrKnowledgePrincipal principal, String permission) { + List args = new ArrayList<>(); + args.add(principal.tenantId()); + args.add("MANAGE".equalsIgnoreCase(permission) ? "MANAGE" : "READ"); + args.add(principal.userId() == null ? "" : String.valueOf(principal.userId())); + String roleClause = ""; + if (!principal.roles().isEmpty()) { + roleClause = " or (g.principal_type = 'ROLE' and g.principal_value in (" + placeholders(principal.roles()) + "))"; + args.addAll(principal.roles()); + } + String permissionClause = "MANAGE".equalsIgnoreCase(permission) + ? "g.permission = ?" + : "g.permission in (?, 'MANAGE')"; + String sql = """ + select distinct g.knowledge_id + from aihr_knowledge_space_grant g + join aihr_knowledge_info k on k.id = g.knowledge_id and k.tenant_id = g.tenant_id + where g.tenant_id = ? and %s and g.status = 'ACTIVE' and k.status = 'ACTIVE' + and ((g.principal_type = 'USER' and g.principal_value = ?)%s) + """.formatted(permissionClause, roleClause); + return new LinkedHashSet<>(jdbcTemplate.queryForList(sql, Long.class, args.toArray())); + } + + private Set requestedSpaceIds(String tenantId, List requestedCodes) { + Set codes = cleanCodes(requestedCodes); + if (codes.isEmpty()) { + return Set.of(); + } + List args = new ArrayList<>(); + args.add(tenantId); + args.addAll(codes); + List ids = jdbcTemplate.queryForList(""" + select id from aihr_knowledge_info + where tenant_id = ? and status = 'ACTIVE' and code in (%s) + """.formatted(placeholders(codes)), Long.class, args.toArray()); + if (ids.size() != codes.size()) { + throw forbidden("请求包含不存在、停用或其他租户的知识空间"); + } + return new LinkedHashSet<>(ids); + } + + private static Set cleanCodes(List values) { + Set result = new LinkedHashSet<>(); + if (values != null) { + values.stream().filter(AihrKnowledgeAccessService::hasText).map(String::trim).forEach(result::add); + } + if (result.size() > 20) { + throw new ServiceException("单次最多选择 20 个知识空间", HttpStatus.BAD_REQUEST); + } + return result; + } + + private static String placeholders(Collection values) { + return String.join(",", java.util.Collections.nCopies(values.size(), "?")); + } + + private static void requireSameTenant(String principalTenant, String appTenant) { + if (!hasText(principalTenant) || !principalTenant.equals(appTenant)) { + throw forbidden("调用应用不属于当前租户"); + } + } + + private static ServiceException forbidden(String message) { + return new ServiceException(message, HttpStatus.FORBIDDEN); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java new file mode 100644 index 00000000..18104a8f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java @@ -0,0 +1,205 @@ +package org.dromara.aihr.knowledge.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.TokenIssuedResponse; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.redis.utils.RedisUtils; +import org.redisson.api.RateType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class AihrKnowledgeAppService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final Pattern APP_CODE = Pattern.compile("[a-z0-9_]{3,64}"); + private static final int TOKEN_RANDOM_CHARS = 43; + + private final JdbcTemplate jdbcTemplate; + + public AuthenticatedApp requireSessionApp(String tenantId, String clientKey) { + if (!hasText(tenantId) || !hasText(clientKey)) { + throw forbidden("当前登录端未配置知识问答应用"); + } + List rows = jdbcTemplate.query(""" + select id, tenant_id, app_code, app_name, auth_type, token_hash, status, + rate_limit_per_minute, expires_time + from aihr_knowledge_app + where tenant_id = ? and auth_type = 'SESSION' and internal_client_key = ? + limit 1 + """, (rs, rowNum) -> new AppRow( + rs.getLong("id"), rs.getString("tenant_id"), rs.getString("app_code"), + rs.getString("app_name"), rs.getString("auth_type"), rs.getString("token_hash"), + rs.getString("status"), rs.getInt("rate_limit_per_minute"), + rs.getTimestamp("expires_time") == null ? null : rs.getTimestamp("expires_time").toLocalDateTime() + ), tenantId, clientKey.trim()); + if (rows.isEmpty() || !active(rows.get(0))) { + throw forbidden("当前登录端未启用知识问答应用"); + } + return rows.get(0).authenticated(); + } + + public AuthenticatedApp authenticateBearer(String authorization) { + String token = bearerToken(authorization); + String code; + try { + code = appCode(token); + } catch (IllegalArgumentException ex) { + throw unauthorized(); + } + List rows = jdbcTemplate.query(""" + select id, tenant_id, app_code, app_name, auth_type, token_hash, status, + rate_limit_per_minute, expires_time + from aihr_knowledge_app + where app_code = ? and auth_type = 'API_TOKEN' + limit 1 + """, (rs, rowNum) -> new AppRow( + rs.getLong("id"), rs.getString("tenant_id"), rs.getString("app_code"), + rs.getString("app_name"), rs.getString("auth_type"), rs.getString("token_hash"), + rs.getString("status"), rs.getInt("rate_limit_per_minute"), + rs.getTimestamp("expires_time") == null ? null : rs.getTimestamp("expires_time").toLocalDateTime() + ), code); + if (rows.isEmpty() || !active(rows.get(0)) || !hashMatches(token, rows.get(0).tokenHash())) { + throw unauthorized(); + } + AppRow row = rows.get(0); + enforceRateLimit(row.authenticated()); + jdbcTemplate.update("update aihr_knowledge_app set last_used_time = now() where id = ? and tenant_id = ?", + row.id(), row.tenantId()); + return row.authenticated(); + } + + public TokenIssuedResponse rotateToken(String tenantId, Long appId, LocalDateTime expiresTime) { + List codes = jdbcTemplate.queryForList(""" + select app_code from aihr_knowledge_app + where tenant_id = ? and id = ? and auth_type = 'API_TOKEN' + """, String.class, tenantId, appId); + if (codes.isEmpty()) { + throw new ServiceException("外部调用应用不存在", HttpStatus.NOT_FOUND); + } + String token = generateToken(codes.get(0)); + jdbcTemplate.update(""" + update aihr_knowledge_app + set token_hash = ?, expires_time = ?, update_time = now() + where tenant_id = ? and id = ? and auth_type = 'API_TOKEN' + """, sha256(token), expiresTime, tenantId, appId); + return new TokenIssuedResponse(codes.get(0), token, expiresTime); + } + + public void enforceRateLimit(AuthenticatedApp app) { + int rate = Math.max(1, app.rateLimitPerMinute() == null ? 60 : app.rateLimitPerMinute()); + try { + if (RedisUtils.rateLimiter("knowledge:app:" + app.id(), RateType.OVERALL, rate, 60) < 0) { + throw new ServiceException("调用过于频繁,请稍后重试", 429); + } + } catch (ServiceException ex) { + throw ex; + } catch (RuntimeException ex) { + throw new ServiceException("知识问答限流服务暂不可用", 503); + } + } + + public static String generateToken(String appCode) { + String code = normalizeCode(appCode); + byte[] random = new byte[32]; + SECURE_RANDOM.nextBytes(random); + return "ak_" + code + "_" + Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + public static String appCode(String token) { + if (token == null || !token.startsWith("ak_")) { + throw new IllegalArgumentException("invalid app token"); + } + int split = token.length() - TOKEN_RANDOM_CHARS - 1; + if (split <= 3 || split == token.length() - 1) { + throw new IllegalArgumentException("invalid app token"); + } + if (token.charAt(split) != '_') { + throw new IllegalArgumentException("invalid app token"); + } + return normalizeCode(token.substring(3, split)); + } + + public static String sha256(String value) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 unavailable", ex); + } + } + + public static boolean hashMatches(String token, String expectedHash) { + if (!hasText(token) || !hasText(expectedHash)) { + return false; + } + return MessageDigest.isEqual( + sha256(token).getBytes(StandardCharsets.US_ASCII), + expectedHash.getBytes(StandardCharsets.US_ASCII)); + } + + private static String bearerToken(String authorization) { + if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { + throw unauthorized(); + } + String token = authorization.substring(7).trim(); + if (token.isBlank()) { + throw unauthorized(); + } + return token; + } + + private static String normalizeCode(String value) { + String code = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + if (!APP_CODE.matcher(code).matches()) { + throw new IllegalArgumentException("appCode 仅支持 3-64 位小写字母、数字和下划线"); + } + return code; + } + + private static boolean active(AppRow row) { + return "ACTIVE".equals(row.status()) + && (row.expiresTime() == null || row.expiresTime().isAfter(LocalDateTime.now())); + } + + private static ServiceException unauthorized() { + return new ServiceException("调用应用认证失败", HttpStatus.UNAUTHORIZED); + } + + private static ServiceException forbidden(String message) { + return new ServiceException(message, HttpStatus.FORBIDDEN); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private record AppRow( + Long id, + String tenantId, + String appCode, + String appName, + String authType, + String tokenHash, + String status, + Integer rateLimit, + LocalDateTime expiresTime + ) { + private AuthenticatedApp authenticated() { + return new AuthenticatedApp(id, tenantId, appCode, appName, authType, rateLimit, expiresTime); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java new file mode 100644 index 00000000..c4535ce6 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java @@ -0,0 +1,77 @@ +package org.dromara.aihr.knowledge.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.service.AihrOrgSyncService; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrKnowledgePrincipalResolver { + + private final AihrOrgSyncService orgSyncService; + + public AihrKnowledgePrincipal current() { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser == null || loginUser.getTenantId() == null || loginUser.getUserId() == null) { + throw new ServiceException("登录身份缺失,请重新登录", 401); + } + return from(loginUser); + } + + AihrKnowledgePrincipal from(LoginUser loginUser) { + Set roles = new LinkedHashSet<>(); + Set projectCodes = new LinkedHashSet<>(); + String extPartyId = ""; + if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { + roles.add("employee"); + extPartyId = trim(loginUser.getUsername()); + List rows = orgRows(extPartyId); + if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) { + roles.add("supervisor"); + } + rows.stream().map(OrgPersonRow::projectCode).filter(AihrKnowledgePrincipalResolver::hasText) + .forEach(projectCodes::add); + } else if (loginUser.getRolePermission() != null) { + roles.addAll(loginUser.getRolePermission()); + } + return new AihrKnowledgePrincipal( + loginUser.getTenantId(), loginUser.getUserId(), loginUser.getUserType(), extPartyId, + roles, projectCodes, trim(loginUser.getClientKey())); + } + + private List orgRows(String extPartyId) { + if (extPartyId.isBlank()) { + return List.of(); + } + try { + return orgSyncService.snapshot(extPartyId, null, null, "active", 1, 500, 500).rows(); + } catch (RuntimeException ex) { + log.warn("knowledge principal org lookup failed, keeping employee-only scope"); + return List.of(); + } + } + + private static boolean isSupervisor(OrgPersonRow row) { + return "主管".equals(row.positionLevel()) || "项目经理".equals(row.positionLevel()); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java new file mode 100644 index 00000000..85e54cab --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java @@ -0,0 +1,35 @@ +package org.dromara.aihr.knowledge; + +import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Tag("dev") +class AihrKnowledgeAccessServiceTest { + + @Test + void appAndPrincipalGrantsAreIntersected() { + assertEquals(Set.of(2L), AihrKnowledgeAccessService.intersect( + Set.of(1L, 2L), Set.of(2L, 3L), Set.of())); + } + + @Test + void requestedSpacesCanOnlyNarrowEffectiveScope() { + assertEquals(Set.of(2L), AihrKnowledgeAccessService.intersect( + Set.of(1L, 2L), Set.of(2L, 3L), Set.of(2L))); + assertThrows(ServiceException.class, () -> AihrKnowledgeAccessService.intersect( + Set.of(1L, 2L), Set.of(2L, 3L), Set.of(2L, 3L))); + } + + @Test + void emptyEffectiveScopeIsRejected() { + assertThrows(ServiceException.class, () -> AihrKnowledgeAccessService.intersect( + Set.of(1L), Set.of(2L), Set.of())); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java new file mode 100644 index 00000000..e1481305 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java @@ -0,0 +1,38 @@ +package org.dromara.aihr.knowledge; + +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrKnowledgeAppServiceTest { + + @Test + void generatedTokenContainsAppCodeAndPersistsOnlySha256() { + String token = AihrKnowledgeAppService.generateToken("mt_card_miniapp"); + String hash = AihrKnowledgeAppService.sha256(token); + + assertTrue(token.startsWith("ak_mt_card_miniapp_")); + assertEquals("mt_card_miniapp", AihrKnowledgeAppService.appCode(token)); + assertEquals(64, hash.length()); + assertFalse(hash.contains("mt_card_miniapp")); + assertTrue(AihrKnowledgeAppService.hashMatches(token, hash)); + assertFalse(AihrKnowledgeAppService.hashMatches(token + "x", hash)); + } + + @Test + void tokenUsesAtLeastThirtyTwoRandomBytes() { + String token = AihrKnowledgeAppService.generateToken("demo"); + String randomPart = token.substring("ak_demo_".length()); + assertTrue(java.util.Base64.getUrlDecoder().decode(randomPart).length >= 32); + assertFalse(java.util.Arrays.equals( + token.getBytes(StandardCharsets.UTF_8), + AihrKnowledgeAppService.generateToken("demo").getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java new file mode 100644 index 00000000..18a5034f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java @@ -0,0 +1,57 @@ +package org.dromara.aihr.knowledge.service; + +import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; +import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse; +import org.dromara.aihr.service.AihrOrgSyncService; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.enums.UserType; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrKnowledgePrincipalResolverTest { + + @Test + void appSupervisorRoleAndProjectsComeFromOrgSnapshot() { + AihrOrgSyncService orgService = mock(AihrOrgSyncService.class); + when(orgService.snapshot(eq("party-1"), any(), any(), eq("active"), eq(1), eq(500), eq(500))) + .thenReturn(new OrgSnapshotResponse(1, 1, 0, 1, 0, 1, 500, List.of(), List.of(), List.of( + new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16") + ))); + LoginUser login = login(UserType.APP_USER.getUserType(), "party-1", Set.of()); + + var principal = new AihrKnowledgePrincipalResolver(orgService).from(login); + + assertEquals(Set.of("employee", "supervisor"), principal.roles()); + assertEquals(Set.of("P01"), principal.projectCodes()); + } + + @Test + void sysUserRolesComeFromLoginUser() { + LoginUser login = login(UserType.SYS_USER.getUserType(), "admin", Set.of("hr_operator")); + var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class)).from(login); + assertTrue(principal.roles().contains("hr_operator")); + assertTrue(principal.projectCodes().isEmpty()); + } + + private static LoginUser login(String userType, String username, Set roles) { + LoginUser login = new LoginUser(); + login.setTenantId("000000"); + login.setUserId(1L); + login.setUserType(userType); + login.setUsername(username); + login.setRolePermission(roles); + login.setClientKey("app"); + return login; + } +}