From 056cee11cc23ff50af50ad4da3921f9056e4ba92 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 6 Feb 2026 13:11:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20=E5=AE=8C=E5=96=84API?= =?UTF-8?q?=E7=BD=91=E5=85=B3JWT=E8=AE=A4=E8=AF=81=E5=92=8C=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=8E=A7=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现网关侧JWT工具类和权限规则匹配器 - 集成JWT认证流程,支持Bearer Token验证 - 添加基于路径和HTTP方法的权限控制机制 - 配置白名单路由规则,优化认证性能 - 更新前端受保护路由组件,实现权限验证 - 添加403禁止访问页面和权限检查逻辑 - 重构登录页面,集成实际认证API调用 - 实现用户信息获取和权限加载功能 - 优化全局异常处理器中的认证错误状态码 - 集成FastJSON2和JJWT依赖库支持 --- backend/api-gateway/pom.xml | 17 ++ .../gateway/filter/UserContextFilter.java | 108 +++++++++- .../gateway/security/GatewayJwtUtils.java | 65 ++++++ .../security/PermissionRuleMatcher.java | 85 ++++++++ backend/shared/domain-common/pom.xml | 5 + .../application/AuthApplicationService.java | 203 ++++++++++++++++++ .../auth/domain/model/AuthPermissionInfo.java | 21 ++ .../auth/domain/model/AuthRoleInfo.java | 18 ++ .../auth/domain/model/AuthUserAccount.java | 24 +++ .../auth/domain/model/AuthUserSummary.java | 18 ++ .../config/AuthConfiguration.java | 18 ++ .../exception/AuthErrorCode.java | 23 ++ .../persistence/mapper/AuthMapper.java | 39 ++++ .../auth/interfaces/rest/AuthController.java | 82 +++++++ .../rest/dto/AssignUserRolesRequest.java | 14 ++ .../rest/dto/AuthCurrentUserResponse.java | 14 ++ .../rest/dto/AuthLoginResponse.java | 17 ++ .../interfaces/rest/dto/AuthUserView.java | 15 ++ .../rest/dto/AuthUserWithRolesResponse.java | 17 ++ .../interfaces/rest/dto/LoginRequest.java | 13 ++ .../config/GlobalExceptionHandler.java | 17 +- .../src/main/resources/mappers/AuthMapper.xml | 120 +++++++++++ .../datamate/common/security/JwtUtils.java | 29 ++- frontend/src/auth/permissions.ts | 75 +++++++ frontend/src/components/ProtectedRoute.tsx | 37 +++- .../src/pages/Forbidden/ForbiddenPage.tsx | 24 +++ frontend/src/pages/Layout/Sidebar.tsx | 35 ++- frontend/src/pages/Layout/menu.tsx | 5 + frontend/src/pages/Login/LoginPage.tsx | 29 +-- frontend/src/routes/routes.ts | 7 +- frontend/src/store/slices/authSlice.ts | 196 ++++++++++++----- frontend/src/utils/request.ts | 12 +- scripts/db/zz-auth-init.sql | 149 +++++++++++++ 33 files changed, 1462 insertions(+), 89 deletions(-) create mode 100644 backend/api-gateway/src/main/java/com/datamate/gateway/security/GatewayJwtUtils.java create mode 100644 backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/AuthApplicationService.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthPermissionInfo.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthRoleInfo.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserAccount.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserSummary.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/config/AuthConfiguration.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/exception/AuthErrorCode.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/persistence/mapper/AuthMapper.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/AuthController.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AssignUserRolesRequest.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthCurrentUserResponse.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthLoginResponse.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserView.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserWithRolesResponse.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/LoginRequest.java create mode 100644 backend/shared/domain-common/src/main/resources/mappers/AuthMapper.xml create mode 100644 frontend/src/auth/permissions.ts create mode 100644 frontend/src/pages/Forbidden/ForbiddenPage.tsx create mode 100644 scripts/db/zz-auth-init.sql diff --git a/backend/api-gateway/pom.xml b/backend/api-gateway/pom.xml index 067e6af..7253110 100644 --- a/backend/api-gateway/pom.xml +++ b/backend/api-gateway/pom.xml @@ -36,6 +36,23 @@ com.alibaba.fastjson2 fastjson2 + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/filter/UserContextFilter.java b/backend/api-gateway/src/main/java/com/datamate/gateway/filter/UserContextFilter.java index d549a39..a387a37 100644 --- a/backend/api-gateway/src/main/java/com/datamate/gateway/filter/UserContextFilter.java +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/filter/UserContextFilter.java @@ -1,34 +1,124 @@ package com.datamate.gateway.filter; +import com.alibaba.fastjson2.JSONObject; +import com.datamate.gateway.security.GatewayJwtUtils; +import com.datamate.gateway.security.PermissionRuleMatcher; +import io.jsonwebtoken.Claims; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import java.nio.charset.StandardCharsets; +import java.util.List; + /** * 用户信息过滤器 - * */ @Slf4j @Component -public class UserContextFilter implements GlobalFilter { - @Value("${commercial.switch:false}") - private boolean isCommercial; +public class UserContextFilter implements GlobalFilter, Ordered { + private final GatewayJwtUtils gatewayJwtUtils; + private final PermissionRuleMatcher permissionRuleMatcher; + + @Value("${datamate.auth.enabled:true}") + private boolean authEnabled; + + public UserContextFilter(GatewayJwtUtils gatewayJwtUtils, PermissionRuleMatcher permissionRuleMatcher) { + this.gatewayJwtUtils = gatewayJwtUtils; + this.permissionRuleMatcher = permissionRuleMatcher; + } @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - if (!isCommercial) { + if (!authEnabled) { return chain.filter(exchange); } - try { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + HttpMethod method = request.getMethod(); - } catch (Exception e) { - log.error("get current user info error", e); + if (!path.startsWith("/api/")) { return chain.filter(exchange); } - return chain.filter(exchange); + if (HttpMethod.OPTIONS.equals(method)) { + return chain.filter(exchange); + } + if (permissionRuleMatcher.isWhitelisted(path)) { + return chain.filter(exchange); + } + + String token = extractBearerToken(request.getHeaders().getFirst("Authorization")); + if (!StringUtils.hasText(token)) { + return writeError(exchange, HttpStatus.UNAUTHORIZED, "auth.0003", "未登录或登录状态已失效"); + } + + Claims claims; + try { + if (!gatewayJwtUtils.validateToken(token)) { + return writeError(exchange, HttpStatus.UNAUTHORIZED, "auth.0003", "登录状态已失效"); + } + claims = gatewayJwtUtils.getClaimsFromToken(token); + } catch (Exception ex) { + log.warn("JWT校验失败: {}", ex.getMessage()); + return writeError(exchange, HttpStatus.UNAUTHORIZED, "auth.0003", "登录状态已失效"); + } + + String requiredPermission = permissionRuleMatcher.resolveRequiredPermission(method, path); + if (StringUtils.hasText(requiredPermission)) { + List permissionCodes = gatewayJwtUtils.getStringListClaim(claims, "permissions"); + if (!permissionCodes.contains(requiredPermission)) { + return writeError(exchange, HttpStatus.FORBIDDEN, "auth.0006", "权限不足"); + } + } + + String userId = String.valueOf(claims.get("userId")); + String username = claims.getSubject(); + List roles = gatewayJwtUtils.getStringListClaim(claims, "roles"); + + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-User-Id", userId) + .header("X-User-Name", username) + .header("X-User-Roles", String.join(",", roles)) + .build(); + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + @Override + public int getOrder() { + return -200; + } + + private String extractBearerToken(String authorizationHeader) { + if (!StringUtils.hasText(authorizationHeader)) { + return null; + } + if (!authorizationHeader.startsWith("Bearer ")) { + return null; + } + String token = authorizationHeader.substring("Bearer ".length()).trim(); + return token.isEmpty() ? null : token; + } + + private Mono writeError(ServerWebExchange exchange, + HttpStatus status, + String code, + String message) { + exchange.getResponse().setStatusCode(status); + exchange.getResponse().getHeaders().set("Content-Type", "application/json;charset=UTF-8"); + byte[] body = JSONObject.toJSONString(new ErrorBody(code, message, null)) + .getBytes(StandardCharsets.UTF_8); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body))); + } + + private record ErrorBody(String code, String message, Object data) { } } diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/security/GatewayJwtUtils.java b/backend/api-gateway/src/main/java/com/datamate/gateway/security/GatewayJwtUtils.java new file mode 100644 index 0000000..977e844 --- /dev/null +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/security/GatewayJwtUtils.java @@ -0,0 +1,65 @@ +package com.datamate.gateway.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 网关侧JWT工具 + */ +@Component +public class GatewayJwtUtils { + private static final String DEFAULT_SECRET = "datamate-secret-key-for-jwt-token-generation"; + + @Value("${jwt.secret:" + DEFAULT_SECRET + "}") + private String secret; + + public Claims getClaimsFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public boolean validateToken(String token) { + Claims claims = getClaimsFromToken(token); + Date expiration = claims.getExpiration(); + return expiration != null && expiration.after(new Date()); + } + + public List getStringListClaim(Claims claims, String claimName) { + Object claimValue = claims.get(claimName); + if (!(claimValue instanceof Collection values)) { + return Collections.emptyList(); + } + return values.stream() + .map(String::valueOf) + .collect(Collectors.toList()); + } + + private SecretKey getSigningKey() { + String secretValue = StringUtils.hasText(secret) ? secret : DEFAULT_SECRET; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + byte[] keyBytes = digest.digest(secretValue.getBytes(StandardCharsets.UTF_8)); + return Keys.hmacShaKeyFor(keyBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Cannot initialize JWT signing key", e); + } + } +} + diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java b/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java new file mode 100644 index 0000000..aa702b7 --- /dev/null +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java @@ -0,0 +1,85 @@ +package com.datamate.gateway.security; + +import lombok.Getter; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * 权限规则匹配器 + */ +@Component +public class PermissionRuleMatcher { + private static final Set READ_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); + private static final Set WRITE_METHODS = Set.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE); + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final List whiteListPatterns = List.of( + "/api/auth/login", + "/api/auth/login/**" + ); + private final List rules = buildRules(); + + public boolean isWhitelisted(String path) { + return whiteListPatterns.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + public String resolveRequiredPermission(HttpMethod method, String path) { + for (PermissionRule rule : rules) { + if (rule.matches(method, path, pathMatcher)) { + return rule.getPermissionCode(); + } + } + return null; + } + + private List buildRules() { + List permissionRules = new ArrayList<>(); + addModuleRules(permissionRules, "/api/data-management/**", "module:data-management:read", "module:data-management:write"); + addModuleRules(permissionRules, "/api/annotation/**", "module:data-annotation:read", "module:data-annotation:write"); + addModuleRules(permissionRules, "/api/data-collection/**", "module:data-collection:read", "module:data-collection:write"); + addModuleRules(permissionRules, "/api/evaluation/**", "module:data-evaluation:read", "module:data-evaluation:write"); + addModuleRules(permissionRules, "/api/synthesis/**", "module:data-synthesis:read", "module:data-synthesis:write"); + addModuleRules(permissionRules, "/api/knowledge-base/**", "module:knowledge-base:read", "module:knowledge-base:write"); + addModuleRules(permissionRules, "/api/operator-market/**", "module:operator-market:read", "module:operator-market:write"); + addModuleRules(permissionRules, "/api/orchestration/**", "module:orchestration:read", "module:orchestration:write"); + addModuleRules(permissionRules, "/api/content-generation/**", "module:content-generation:use", "module:content-generation:use"); + + permissionRules.add(new PermissionRule(READ_METHODS, "/api/auth/users/**", "system:user:manage")); + permissionRules.add(new PermissionRule(WRITE_METHODS, "/api/auth/users/**", "system:user:manage")); + permissionRules.add(new PermissionRule(READ_METHODS, "/api/auth/roles/**", "system:role:manage")); + permissionRules.add(new PermissionRule(WRITE_METHODS, "/api/auth/roles/**", "system:role:manage")); + permissionRules.add(new PermissionRule(READ_METHODS, "/api/auth/permissions/**", "system:permission:manage")); + permissionRules.add(new PermissionRule(WRITE_METHODS, "/api/auth/permissions/**", "system:permission:manage")); + return permissionRules; + } + + private void addModuleRules(List rules, + String pathPattern, + String readPermissionCode, + String writePermissionCode) { + rules.add(new PermissionRule(READ_METHODS, pathPattern, readPermissionCode)); + rules.add(new PermissionRule(WRITE_METHODS, pathPattern, writePermissionCode)); + } + + @Getter + private static class PermissionRule { + private final Set methods; + private final String pathPattern; + private final String permissionCode; + + private PermissionRule(Set methods, String pathPattern, String permissionCode) { + this.methods = methods; + this.pathPattern = pathPattern; + this.permissionCode = permissionCode; + } + + private boolean matches(HttpMethod method, String path, AntPathMatcher matcher) { + return method != null && methods.contains(method) && matcher.match(pathPattern, path); + } + } +} diff --git a/backend/shared/domain-common/pom.xml b/backend/shared/domain-common/pom.xml index f6b4ee6..f9642cd 100644 --- a/backend/shared/domain-common/pom.xml +++ b/backend/shared/domain-common/pom.xml @@ -17,6 +17,11 @@ DDD领域通用组件 + + com.datamate + security-common + ${project.version} + org.springframework.boot spring-boot-starter diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/AuthApplicationService.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/AuthApplicationService.java new file mode 100644 index 0000000..183dfd3 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/AuthApplicationService.java @@ -0,0 +1,203 @@ +package com.datamate.common.auth.application; + +import com.datamate.common.auth.domain.model.AuthPermissionInfo; +import com.datamate.common.auth.domain.model.AuthRoleInfo; +import com.datamate.common.auth.domain.model.AuthUserAccount; +import com.datamate.common.auth.domain.model.AuthUserSummary; +import com.datamate.common.auth.infrastructure.exception.AuthErrorCode; +import com.datamate.common.auth.infrastructure.persistence.mapper.AuthMapper; +import com.datamate.common.auth.interfaces.rest.dto.AuthCurrentUserResponse; +import com.datamate.common.auth.interfaces.rest.dto.AuthLoginResponse; +import com.datamate.common.auth.interfaces.rest.dto.AuthUserView; +import com.datamate.common.auth.interfaces.rest.dto.AuthUserWithRolesResponse; +import com.datamate.common.infrastructure.exception.BusinessAssert; +import com.datamate.common.security.JwtUtils; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 认证授权应用服务 + */ +@Service +@RequiredArgsConstructor +public class AuthApplicationService { + private static final String TOKEN_TYPE = "Bearer"; + + private final AuthMapper authMapper; + private final JwtUtils jwtUtils; + private final PasswordEncoder passwordEncoder; + + public AuthLoginResponse login(String username, String password) { + AuthUserAccount user = authMapper.findUserByUsername(username); + BusinessAssert.notNull(user, AuthErrorCode.INVALID_CREDENTIALS); + BusinessAssert.isTrue(Boolean.TRUE.equals(user.getEnabled()), AuthErrorCode.ACCOUNT_DISABLED); + BusinessAssert.isTrue(passwordEncoder.matches(password, user.getPasswordHash()), AuthErrorCode.INVALID_CREDENTIALS); + + AuthBundle authBundle = loadAuthBundle(user.getId()); + String token = buildToken(authBundle); + authMapper.updateLastLoginAt(user.getId()); + + return new AuthLoginResponse( + token, + TOKEN_TYPE, + computeExpiresInSeconds(token), + toUserView(authBundle.user()), + authBundle.roleCodes(), + authBundle.permissionCodes() + ); + } + + public AuthCurrentUserResponse getCurrentUser(String token) { + Claims claims = parseClaims(token); + Long userId = parseUserId(claims); + AuthBundle authBundle = loadAuthBundle(userId); + return new AuthCurrentUserResponse( + toUserView(authBundle.user()), + authBundle.roleCodes(), + authBundle.permissionCodes() + ); + } + + public AuthLoginResponse refreshToken(String token) { + Claims claims = parseClaims(token); + Long userId = parseUserId(claims); + AuthBundle authBundle = loadAuthBundle(userId); + String refreshedToken = buildToken(authBundle); + return new AuthLoginResponse( + refreshedToken, + TOKEN_TYPE, + computeExpiresInSeconds(refreshedToken), + toUserView(authBundle.user()), + authBundle.roleCodes(), + authBundle.permissionCodes() + ); + } + + public List listUsersWithRoles() { + List users = authMapper.listUsers(); + List responses = new ArrayList<>(users.size()); + for (AuthUserSummary user : users) { + List roleCodes = authMapper.findRolesByUserId(user.getId()) + .stream() + .map(AuthRoleInfo::getRoleCode) + .filter(Objects::nonNull) + .toList(); + responses.add(new AuthUserWithRolesResponse( + user.getId(), + user.getUsername(), + user.getFullName(), + user.getEmail(), + user.getEnabled(), + roleCodes + )); + } + return responses; + } + + public List listRoles() { + return authMapper.listRoles(); + } + + public List listPermissions() { + return authMapper.listPermissions(); + } + + public void assignUserRoles(Long userId, List roleIds) { + AuthUserAccount user = authMapper.findUserById(userId); + BusinessAssert.notNull(user, AuthErrorCode.USER_NOT_FOUND); + + Set distinctRoleIds = new LinkedHashSet<>(roleIds); + BusinessAssert.notEmpty(distinctRoleIds, AuthErrorCode.ROLE_NOT_FOUND); + + int existingRoleCount = authMapper.countRolesByIds(new ArrayList<>(distinctRoleIds)); + BusinessAssert.isTrue(existingRoleCount == distinctRoleIds.size(), AuthErrorCode.ROLE_NOT_FOUND); + + authMapper.deleteUserRoles(userId); + authMapper.insertUserRoles(userId, new ArrayList<>(distinctRoleIds)); + } + + private String buildToken(AuthBundle authBundle) { + Map claims = Map.of( + "userId", authBundle.user().getId(), + "roles", authBundle.roleCodes(), + "permissions", authBundle.permissionCodes() + ); + return jwtUtils.generateToken(authBundle.user().getUsername(), claims); + } + + private AuthBundle loadAuthBundle(Long userId) { + AuthUserAccount user = authMapper.findUserById(userId); + BusinessAssert.notNull(user, AuthErrorCode.USER_NOT_FOUND); + BusinessAssert.isTrue(Boolean.TRUE.equals(user.getEnabled()), AuthErrorCode.ACCOUNT_DISABLED); + + List roleCodes = authMapper.findRolesByUserId(userId).stream() + .map(AuthRoleInfo::getRoleCode) + .filter(Objects::nonNull) + .toList(); + List permissionCodes = authMapper.findPermissionCodesByUserId(userId).stream() + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + return new AuthBundle(user, roleCodes, permissionCodes); + } + + private Claims parseClaims(String token) { + try { + return jwtUtils.getClaimsFromToken(token); + } catch (JwtException | IllegalArgumentException e) { + throw com.datamate.common.infrastructure.exception.BusinessException.of(AuthErrorCode.TOKEN_INVALID); + } + } + + private Long parseUserId(Claims claims) { + Object userIdObject = claims.get("userId"); + if (userIdObject instanceof Number number) { + return number.longValue(); + } + if (userIdObject instanceof String str) { + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + throw com.datamate.common.infrastructure.exception.BusinessException.of(AuthErrorCode.TOKEN_INVALID); + } + } + throw com.datamate.common.infrastructure.exception.BusinessException.of(AuthErrorCode.TOKEN_INVALID); + } + + private long computeExpiresInSeconds(String token) { + Date expirationDate = jwtUtils.getExpirationDateFromToken(token); + long seconds = Duration.between(new Date().toInstant(), expirationDate.toInstant()).toSeconds(); + return Math.max(seconds, 0L); + } + + private AuthUserView toUserView(AuthUserAccount user) { + return new AuthUserView( + user.getId(), + user.getUsername(), + user.getFullName(), + user.getEmail(), + user.getAvatarUrl(), + user.getOrganization() + ); + } + + private record AuthBundle( + AuthUserAccount user, + List roleCodes, + List permissionCodes + ) { + } +} diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthPermissionInfo.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthPermissionInfo.java new file mode 100644 index 0000000..ebd03cd --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthPermissionInfo.java @@ -0,0 +1,21 @@ +package com.datamate.common.auth.domain.model; + +import lombok.Getter; +import lombok.Setter; + +/** + * 权限信息 + */ +@Getter +@Setter +public class AuthPermissionInfo { + private String id; + private String permissionCode; + private String permissionName; + private String module; + private String action; + private String pathPattern; + private String method; + private Boolean enabled; +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthRoleInfo.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthRoleInfo.java new file mode 100644 index 0000000..adfb544 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthRoleInfo.java @@ -0,0 +1,18 @@ +package com.datamate.common.auth.domain.model; + +import lombok.Getter; +import lombok.Setter; + +/** + * 角色信息 + */ +@Getter +@Setter +public class AuthRoleInfo { + private String id; + private String roleCode; + private String roleName; + private String description; + private Boolean enabled; +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserAccount.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserAccount.java new file mode 100644 index 0000000..c472bde --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserAccount.java @@ -0,0 +1,24 @@ +package com.datamate.common.auth.domain.model; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 认证用户账户 + */ +@Getter +@Setter +public class AuthUserAccount { + private Long id; + private String username; + private String email; + private String passwordHash; + private String fullName; + private String avatarUrl; + private String organization; + private Boolean enabled; + private LocalDateTime lastLoginAt; +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserSummary.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserSummary.java new file mode 100644 index 0000000..3707c3f --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/domain/model/AuthUserSummary.java @@ -0,0 +1,18 @@ +package com.datamate.common.auth.domain.model; + +import lombok.Getter; +import lombok.Setter; + +/** + * 用户摘要 + */ +@Getter +@Setter +public class AuthUserSummary { + private Long id; + private String username; + private String email; + private String fullName; + private Boolean enabled; +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/config/AuthConfiguration.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/config/AuthConfiguration.java new file mode 100644 index 0000000..1930622 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/config/AuthConfiguration.java @@ -0,0 +1,18 @@ +package com.datamate.common.auth.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * 认证模块配置 + */ +@Configuration +public class AuthConfiguration { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/exception/AuthErrorCode.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/exception/AuthErrorCode.java new file mode 100644 index 0000000..929c1ce --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/exception/AuthErrorCode.java @@ -0,0 +1,23 @@ +package com.datamate.common.auth.infrastructure.exception; + +import com.datamate.common.infrastructure.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 认证授权错误码 + */ +@Getter +@AllArgsConstructor +public enum AuthErrorCode implements ErrorCode { + INVALID_CREDENTIALS("auth.0001", "用户名或密码错误"), + ACCOUNT_DISABLED("auth.0002", "账号已被禁用"), + TOKEN_INVALID("auth.0003", "登录状态已失效"), + USER_NOT_FOUND("auth.0004", "用户不存在"), + ROLE_NOT_FOUND("auth.0005", "角色不存在"), + AUTHORIZATION_DENIED("auth.0006", "无权限执行该操作"); + + private final String code; + private final String message; +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/persistence/mapper/AuthMapper.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/persistence/mapper/AuthMapper.java new file mode 100644 index 0000000..590f3f0 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/persistence/mapper/AuthMapper.java @@ -0,0 +1,39 @@ +package com.datamate.common.auth.infrastructure.persistence.mapper; + +import com.datamate.common.auth.domain.model.AuthPermissionInfo; +import com.datamate.common.auth.domain.model.AuthRoleInfo; +import com.datamate.common.auth.domain.model.AuthUserAccount; +import com.datamate.common.auth.domain.model.AuthUserSummary; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 认证授权数据访问 + */ +@Mapper +public interface AuthMapper { + AuthUserAccount findUserByUsername(@Param("username") String username); + + AuthUserAccount findUserById(@Param("userId") Long userId); + + int updateLastLoginAt(@Param("userId") Long userId); + + List findRolesByUserId(@Param("userId") Long userId); + + List findPermissionCodesByUserId(@Param("userId") Long userId); + + List listUsers(); + + List listRoles(); + + List listPermissions(); + + int countRolesByIds(@Param("roleIds") List roleIds); + + int deleteUserRoles(@Param("userId") Long userId); + + int insertUserRoles(@Param("userId") Long userId, @Param("roleIds") List roleIds); +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/AuthController.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/AuthController.java new file mode 100644 index 0000000..ed475a9 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/AuthController.java @@ -0,0 +1,82 @@ +package com.datamate.common.auth.interfaces.rest; + +import com.datamate.common.auth.application.AuthApplicationService; +import com.datamate.common.auth.domain.model.AuthPermissionInfo; +import com.datamate.common.auth.domain.model.AuthRoleInfo; +import com.datamate.common.auth.interfaces.rest.dto.AssignUserRolesRequest; +import com.datamate.common.auth.interfaces.rest.dto.AuthCurrentUserResponse; +import com.datamate.common.auth.interfaces.rest.dto.AuthLoginResponse; +import com.datamate.common.auth.interfaces.rest.dto.AuthUserWithRolesResponse; +import com.datamate.common.auth.interfaces.rest.dto.LoginRequest; +import com.datamate.common.infrastructure.exception.BusinessAssert; +import com.datamate.common.auth.infrastructure.exception.AuthErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 认证授权控制器 + */ +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthApplicationService authApplicationService; + + @PostMapping("/login") + public AuthLoginResponse login(@RequestBody @Valid LoginRequest loginRequest) { + return authApplicationService.login(loginRequest.username(), loginRequest.password()); + } + + @GetMapping("/me") + public AuthCurrentUserResponse me(HttpServletRequest request) { + return authApplicationService.getCurrentUser(extractBearerToken(request.getHeader("Authorization"))); + } + + @PostMapping("/refresh") + public AuthLoginResponse refresh(@RequestHeader("Authorization") String authorization) { + return authApplicationService.refreshToken(extractBearerToken(authorization)); + } + + @GetMapping("/users") + public List listUsers() { + return authApplicationService.listUsersWithRoles(); + } + + @PutMapping("/users/{userId}/roles") + public void assignRoles(@PathVariable("userId") Long userId, + @RequestBody @Valid AssignUserRolesRequest request) { + authApplicationService.assignUserRoles(userId, request.roleIds()); + } + + @GetMapping("/roles") + public List listRoles() { + return authApplicationService.listRoles(); + } + + @GetMapping("/permissions") + public List listPermissions() { + return authApplicationService.listPermissions(); + } + + private String extractBearerToken(String authorizationHeader) { + BusinessAssert.isTrue( + authorizationHeader != null && authorizationHeader.startsWith("Bearer "), + AuthErrorCode.TOKEN_INVALID + ); + String token = authorizationHeader.substring("Bearer ".length()).trim(); + BusinessAssert.isTrue(!token.isEmpty(), AuthErrorCode.TOKEN_INVALID); + return token; + } +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AssignUserRolesRequest.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AssignUserRolesRequest.java new file mode 100644 index 0000000..5d37532 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AssignUserRolesRequest.java @@ -0,0 +1,14 @@ +package com.datamate.common.auth.interfaces.rest.dto; + +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +/** + * 用户角色分配请求 + */ +public record AssignUserRolesRequest( + @NotEmpty(message = "角色列表不能为空") List roleIds +) { +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthCurrentUserResponse.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthCurrentUserResponse.java new file mode 100644 index 0000000..87a78d3 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthCurrentUserResponse.java @@ -0,0 +1,14 @@ +package com.datamate.common.auth.interfaces.rest.dto; + +import java.util.List; + +/** + * 当前用户信息响应 + */ +public record AuthCurrentUserResponse( + AuthUserView user, + List roles, + List permissions +) { +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthLoginResponse.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthLoginResponse.java new file mode 100644 index 0000000..70c4f1d --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthLoginResponse.java @@ -0,0 +1,17 @@ +package com.datamate.common.auth.interfaces.rest.dto; + +import java.util.List; + +/** + * 登录响应 + */ +public record AuthLoginResponse( + String token, + String tokenType, + long expiresInSeconds, + AuthUserView user, + List roles, + List permissions +) { +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserView.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserView.java new file mode 100644 index 0000000..81f9ab5 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserView.java @@ -0,0 +1,15 @@ +package com.datamate.common.auth.interfaces.rest.dto; + +/** + * 登录用户信息 + */ +public record AuthUserView( + Long id, + String username, + String fullName, + String email, + String avatarUrl, + String organization +) { +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserWithRolesResponse.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserWithRolesResponse.java new file mode 100644 index 0000000..a01b53b --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/AuthUserWithRolesResponse.java @@ -0,0 +1,17 @@ +package com.datamate.common.auth.interfaces.rest.dto; + +import java.util.List; + +/** + * 用户与角色响应 + */ +public record AuthUserWithRolesResponse( + Long id, + String username, + String fullName, + String email, + Boolean enabled, + List roleCodes +) { +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/LoginRequest.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/LoginRequest.java new file mode 100644 index 0000000..bde470c --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/interfaces/rest/dto/LoginRequest.java @@ -0,0 +1,13 @@ +package com.datamate.common.auth.interfaces.rest.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 登录请求 + */ +public record LoginRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "密码不能为空") String password +) { +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/GlobalExceptionHandler.java b/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/GlobalExceptionHandler.java index bc49c50..980b51f 100644 --- a/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/GlobalExceptionHandler.java +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ package com.datamate.common.infrastructure.config; import com.datamate.common.infrastructure.common.Response; import com.datamate.common.infrastructure.exception.BusinessException; import com.datamate.common.infrastructure.exception.SystemErrorCode; +import org.springframework.http.HttpStatus; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; @@ -28,7 +29,8 @@ public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusinessException(BusinessException e) { log.warn("BusinessException: code={}, message={}", e.getCode(), e.getMessage(), e); - return ResponseEntity.internalServerError().body(Response.error(e.getErrorCodeEnum())); + HttpStatus status = resolveBusinessStatus(e.getCode()); + return ResponseEntity.status(status).body(Response.error(e.getErrorCodeEnum())); } /** @@ -51,4 +53,17 @@ public class GlobalExceptionHandler { log.error("SystemException: ", e); return ResponseEntity.internalServerError().body(Response.error(SystemErrorCode.SYSTEM_BUSY)); } + + private HttpStatus resolveBusinessStatus(String code) { + if (code == null) { + return HttpStatus.INTERNAL_SERVER_ERROR; + } + if (!code.startsWith("auth.")) { + return HttpStatus.INTERNAL_SERVER_ERROR; + } + if ("auth.0006".equals(code)) { + return HttpStatus.FORBIDDEN; + } + return HttpStatus.UNAUTHORIZED; + } } diff --git a/backend/shared/domain-common/src/main/resources/mappers/AuthMapper.xml b/backend/shared/domain-common/src/main/resources/mappers/AuthMapper.xml new file mode 100644 index 0000000..496685d --- /dev/null +++ b/backend/shared/domain-common/src/main/resources/mappers/AuthMapper.xml @@ -0,0 +1,120 @@ + + + + + + + + + + UPDATE users + SET last_login_at = NOW() + WHERE id = #{userId} + + + + + + + + + + + + + + + + DELETE + FROM t_auth_user_roles + WHERE user_id = #{userId} + + + + INSERT INTO t_auth_user_roles (user_id, role_id) + VALUES + + (#{userId}, #{roleId}) + + + + diff --git a/backend/shared/security-common/src/main/java/com/datamate/common/security/JwtUtils.java b/backend/shared/security-common/src/main/java/com/datamate/common/security/JwtUtils.java index efe4a4b..ece25d0 100644 --- a/backend/shared/security-common/src/main/java/com/datamate/common/security/JwtUtils.java +++ b/backend/shared/security-common/src/main/java/com/datamate/common/security/JwtUtils.java @@ -3,9 +3,13 @@ package com.datamate.common.security; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -15,15 +19,23 @@ import java.util.Map; */ @Component public class JwtUtils { + private static final String DEFAULT_SECRET = "datamate-secret-key-for-jwt-token-generation"; - @Value("${jwt.secret:datamate-secret-key-for-jwt-token-generation}") + @Value("${jwt.secret:" + DEFAULT_SECRET + "}") private String secret; @Value("${jwt.expiration:86400}") // 24小时 private Long expiration; private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(secret.getBytes()); + String secretValue = StringUtils.hasText(secret) ? secret : DEFAULT_SECRET; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + byte[] keyBytes = digest.digest(secretValue.getBytes(StandardCharsets.UTF_8)); + return Keys.hmacShaKeyFor(keyBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Cannot initialize JWT signing key", e); + } } /** @@ -84,7 +96,18 @@ public class JwtUtils { public Boolean validateToken(String token, String username) { try { String tokenUsername = getUsernameFromToken(token); - return (username.equals(tokenUsername) && !isTokenExpired(token)); + return (username.equals(tokenUsername) && validateToken(token)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** + * 仅校验令牌格式与有效期 + */ + public Boolean validateToken(String token) { + try { + return !isTokenExpired(token); } catch (JwtException | IllegalArgumentException e) { return false; } diff --git a/frontend/src/auth/permissions.ts b/frontend/src/auth/permissions.ts new file mode 100644 index 0000000..919349a --- /dev/null +++ b/frontend/src/auth/permissions.ts @@ -0,0 +1,75 @@ +export const PermissionCodes = { + dataManagementRead: "module:data-management:read", + dataManagementWrite: "module:data-management:write", + dataAnnotationRead: "module:data-annotation:read", + dataAnnotationWrite: "module:data-annotation:write", + dataCollectionRead: "module:data-collection:read", + dataCollectionWrite: "module:data-collection:write", + dataEvaluationRead: "module:data-evaluation:read", + dataEvaluationWrite: "module:data-evaluation:write", + dataSynthesisRead: "module:data-synthesis:read", + dataSynthesisWrite: "module:data-synthesis:write", + knowledgeManagementRead: "module:knowledge-management:read", + knowledgeManagementWrite: "module:knowledge-management:write", + knowledgeBaseRead: "module:knowledge-base:read", + knowledgeBaseWrite: "module:knowledge-base:write", + operatorMarketRead: "module:operator-market:read", + operatorMarketWrite: "module:operator-market:write", + orchestrationRead: "module:orchestration:read", + orchestrationWrite: "module:orchestration:write", + contentGenerationUse: "module:content-generation:use", + agentUse: "module:agent:use", + userManage: "system:user:manage", + roleManage: "system:role:manage", + permissionManage: "system:permission:manage", +} as const; + +const routePermissionRules: Array<{ prefix: string; permission: string }> = [ + { prefix: "/data/management", permission: PermissionCodes.dataManagementRead }, + { prefix: "/data/annotation", permission: PermissionCodes.dataAnnotationRead }, + { prefix: "/data/collection", permission: PermissionCodes.dataCollectionRead }, + { prefix: "/data/evaluation", permission: PermissionCodes.dataEvaluationRead }, + { prefix: "/data/synthesis", permission: PermissionCodes.dataSynthesisRead }, + { prefix: "/data/knowledge-management", permission: PermissionCodes.knowledgeManagementRead }, + { prefix: "/data/knowledge-base", permission: PermissionCodes.knowledgeBaseRead }, + { prefix: "/data/operator-market", permission: PermissionCodes.operatorMarketRead }, + { prefix: "/data/orchestration", permission: PermissionCodes.orchestrationRead }, + { prefix: "/data/content-generation", permission: PermissionCodes.contentGenerationUse }, + { prefix: "/chat", permission: PermissionCodes.agentUse }, +]; + +const defaultRouteCandidates: Array<{ path: string; permission: string }> = [ + { path: "/data/management", permission: PermissionCodes.dataManagementRead }, + { path: "/data/annotation", permission: PermissionCodes.dataAnnotationRead }, + { path: "/data/knowledge-management", permission: PermissionCodes.knowledgeManagementRead }, + { path: "/data/knowledge-base", permission: PermissionCodes.knowledgeBaseRead }, + { path: "/chat", permission: PermissionCodes.agentUse }, +]; + +export function hasPermission( + userPermissions: string[] | undefined, + requiredPermission?: string | null +): boolean { + if (!requiredPermission) { + return true; + } + return (userPermissions ?? []).includes(requiredPermission); +} + +export function resolveRequiredPermissionByPath(pathname: string): string | null { + if (pathname === "/403") { + return null; + } + const matchedRule = routePermissionRules.find((rule) => + pathname.startsWith(rule.prefix) + ); + return matchedRule?.permission ?? null; +} + +export function resolveDefaultAuthorizedPath(userPermissions: string[]): string { + const matchedPath = defaultRouteCandidates.find((candidate) => + hasPermission(userPermissions, candidate.permission) + )?.path; + return matchedPath ?? "/403"; +} + diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index d0fdf87..ed4ebd9 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,20 +1,53 @@ import React from 'react'; import { Navigate, useLocation, Outlet } from 'react-router'; -import { useAppSelector } from '@/store/hooks'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { fetchCurrentUser, markInitialized } from '@/store/slices/authSlice'; +import { + hasPermission, + resolveDefaultAuthorizedPath, + resolveRequiredPermissionByPath, +} from '@/auth/permissions'; interface ProtectedRouteProps { children?: React.ReactNode; } const ProtectedRoute: React.FC = ({ children }) => { - const { isAuthenticated } = useAppSelector((state) => state.auth); + const dispatch = useAppDispatch(); + const { isAuthenticated, token, initialized, loading, permissions } = useAppSelector( + (state) => state.auth + ); const location = useLocation(); + const requiredPermission = resolveRequiredPermissionByPath(location.pathname); + + React.useEffect(() => { + if (initialized || loading) { + return; + } + if (!token) { + dispatch(markInitialized()); + return; + } + void dispatch(fetchCurrentUser()); + }, [dispatch, initialized, loading, token]); + + if (!initialized || loading) { + return null; + } if (!isAuthenticated) { // Redirect to the login page, but save the current location they were trying to go to return ; } + if (!hasPermission(permissions, requiredPermission)) { + const fallbackPath = resolveDefaultAuthorizedPath(permissions); + if (location.pathname === fallbackPath) { + return ; + } + return ; + } + return children ? <>{children} : ; }; diff --git a/frontend/src/pages/Forbidden/ForbiddenPage.tsx b/frontend/src/pages/Forbidden/ForbiddenPage.tsx new file mode 100644 index 0000000..671d66e --- /dev/null +++ b/frontend/src/pages/Forbidden/ForbiddenPage.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Button, Result } from "antd"; +import { useNavigate } from "react-router"; + +const ForbiddenPage: React.FC = () => { + const navigate = useNavigate(); + return ( +
+ navigate("/data/management")}> + 返回首页 + + } + /> +
+ ); +}; + +export default ForbiddenPage; + diff --git a/frontend/src/pages/Layout/Sidebar.tsx b/frontend/src/pages/Layout/Sidebar.tsx index f93ceee..57d135c 100644 --- a/frontend/src/pages/Layout/Sidebar.tsx +++ b/frontend/src/pages/Layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { Button, Drawer, Menu, Popover } from "antd"; import { CloseOutlined, @@ -14,6 +14,7 @@ import SettingsPage from "../SettingsPage/SettingsPage"; import { useAppSelector, useAppDispatch } from "@/store/hooks"; import { showSettings, hideSettings } from "@/store/slices/settingsSlice"; import { logout } from "@/store/slices/authSlice"; +import { hasPermission } from "@/auth/permissions"; const isPathMatch = (currentPath: string, targetPath: string) => currentPath === targetPath || currentPath.startsWith(`${targetPath}/`); @@ -25,13 +26,36 @@ const AsiderAndHeaderLayout = () => { const [sidebarOpen, setSidebarOpen] = useState(true); const [taskCenterVisible, setTaskCenterVisible] = useState(false); const settingVisible = useAppSelector((state) => state.settings.visible); + const permissions = useAppSelector((state) => state.auth.permissions); const dispatch = useAppDispatch(); + const visibleMenuItems = useMemo( + () => + menuItems + .map((item) => ({ + ...item, + children: item.children?.filter((subItem) => + hasPermission(permissions, (subItem as { permissionCode?: string }).permissionCode) + ), + })) + .filter((item) => { + const selfVisible = hasPermission( + permissions, + (item as { permissionCode?: string }).permissionCode + ); + if (item.children && item.children.length > 0) { + return selfVisible; + } + return selfVisible; + }), + [permissions] + ); + // Initialize active item based on current pathname const initActiveItem = useCallback(() => { const dataPath = pathname.startsWith("/data/") ? pathname.slice(6) : pathname; - for (let index = 0; index < menuItems.length; index++) { - const element = menuItems[index]; + for (let index = 0; index < visibleMenuItems.length; index++) { + const element = visibleMenuItems[index]; if (element.children) { for (const subItem of element.children) { if (isPathMatch(dataPath, subItem.id)) { @@ -44,7 +68,8 @@ const AsiderAndHeaderLayout = () => { return; } } - }, [pathname]); + setActiveItem(visibleMenuItems[0]?.id ?? ""); + }, [pathname, visibleMenuItems]); useEffect(() => { initActiveItem(); @@ -100,7 +125,7 @@ const AsiderAndHeaderLayout = () => { ({ + items={visibleMenuItems.map((item) => ({ key: item.id, label: item.title, icon: item.icon ? : null, diff --git a/frontend/src/pages/Layout/menu.tsx b/frontend/src/pages/Layout/menu.tsx index 09dccb3..3a1ab4c 100644 --- a/frontend/src/pages/Layout/menu.tsx +++ b/frontend/src/pages/Layout/menu.tsx @@ -13,6 +13,7 @@ import { // Store, // Merge, } from "lucide-react"; +import { PermissionCodes } from "@/auth/permissions"; export const menuItems = [ // { @@ -26,6 +27,7 @@ export const menuItems = [ id: "management", title: "数集管理", icon: FolderOpen, + permissionCode: PermissionCodes.dataManagementRead, description: "创建、导入和管理数据集", color: "bg-blue-500", }, @@ -33,6 +35,7 @@ export const menuItems = [ id: "annotation", title: "数据标注", icon: Tag, + permissionCode: PermissionCodes.dataAnnotationRead, description: "对数据进行标注和标记", color: "bg-green-500", }, @@ -40,6 +43,7 @@ export const menuItems = [ id: "content-generation", title: "内容生成", icon: Sparkles, + permissionCode: PermissionCodes.contentGenerationUse, description: "智能内容生成与创作", color: "bg-purple-500", }, @@ -47,6 +51,7 @@ export const menuItems = [ id: "knowledge-management", title: "知识管理", icon: Shield, + permissionCode: PermissionCodes.knowledgeManagementRead, description: "管理知识集与知识条目", color: "bg-indigo-500", }, diff --git a/frontend/src/pages/Login/LoginPage.tsx b/frontend/src/pages/Login/LoginPage.tsx index 864c3c7..2e45d38 100644 --- a/frontend/src/pages/Login/LoginPage.tsx +++ b/frontend/src/pages/Login/LoginPage.tsx @@ -1,9 +1,9 @@ -import React, { useState } from 'react'; +import React from 'react'; import { useNavigate, useLocation } from 'react-router'; -import { Form, Input, Button, Typography, message, Card } from 'antd'; +import { Form, Input, Button, Typography, message } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { loginLocal } from '@/store/slices/authSlice'; +import { loginUser } from '@/store/slices/authSlice'; const { Title, Text } = Typography; @@ -11,19 +11,20 @@ const LoginPage: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const dispatch = useAppDispatch(); - const { loading, error } = useAppSelector((state) => state.auth); + const { loading } = useAppSelector((state) => state.auth); const [messageApi, contextHolder] = message.useMessage(); const from = location.state?.from?.pathname || '/data'; - const onFinish = (values: any) => { - dispatch(loginLocal(values)); - // The reducer updates state synchronously. - if (values.username === 'admin' && values.password === '123456') { - messageApi.success('登录成功'); - navigate(from, { replace: true }); - } else { - messageApi.error('账号或密码错误'); + const onFinish = async (values: { username: string; password: string }) => { + try { + await dispatch(loginUser(values)).unwrap(); + messageApi.success('登录成功'); + navigate(from, { replace: true }); + } catch (loginError) { + const messageText = + typeof loginError === 'string' ? loginError : '账号或密码错误'; + messageApi.error(messageText); } }; @@ -59,9 +60,9 @@ const LoginPage: React.FC = () => { -
name="login" - initialValues={{ remember: true, username: 'admin', password: '123456' }} + initialValues={{ username: 'admin', password: '123456' }} onFinish={onFinish} layout="vertical" size="large" diff --git a/frontend/src/routes/routes.ts b/frontend/src/routes/routes.ts index 97f15c9..40367f1 100644 --- a/frontend/src/routes/routes.ts +++ b/frontend/src/routes/routes.ts @@ -51,6 +51,7 @@ import Home from "@/pages/Home/Home"; import ContentGenerationPage from "@/pages/ContentGeneration/ContentGenerationPage"; import LoginPage from "@/pages/Login/LoginPage"; import ProtectedRoute from "@/components/ProtectedRoute"; +import ForbiddenPage from "@/pages/Forbidden/ForbiddenPage"; const router = createBrowserRouter([ { @@ -64,6 +65,10 @@ const router = createBrowserRouter([ { Component: ProtectedRoute, children: [ + { + path: "/403", + Component: ForbiddenPage, + }, { path: "/chat", Component: withErrorBoundary(AgentPage), @@ -299,4 +304,4 @@ const router = createBrowserRouter([ } ]); -export default router; \ No newline at end of file +export default router; diff --git a/frontend/src/store/slices/authSlice.ts b/frontend/src/store/slices/authSlice.ts index b772a52..e2c8de3 100644 --- a/frontend/src/store/slices/authSlice.ts +++ b/frontend/src/store/slices/authSlice.ts @@ -1,66 +1,124 @@ -// store/slices/authSlice.js -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { get, post } from "@/utils/request"; -// 异步 thunk -export const loginUser = createAsyncThunk( - 'auth/login', - async (credentials, { rejectWithValue }) => { - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(credentials), - }); +interface AuthUserView { + id: number; + username: string; + fullName?: string; + email?: string; + avatarUrl?: string; + organization?: string; +} - if (!response.ok) { - throw new Error('Login failed'); - } +interface AuthLoginPayload { + token: string; + tokenType: string; + expiresInSeconds: number; + user: AuthUserView; + roles: string[]; + permissions: string[]; +} - const data = await response.json(); - return data; - } catch (error) { - return rejectWithValue(error.message); - } +interface AuthCurrentUserPayload { + user: AuthUserView; + roles: string[]; + permissions: string[]; +} + +interface ApiResponse { + code: string; + message: string; + data: T; +} + +interface AuthState { + user: AuthUserView | null; + token: string | null; + roles: string[]; + permissions: string[]; + isAuthenticated: boolean; + initialized: boolean; + loading: boolean; + error: string | null; +} + +interface LoginCredentials { + username: string; + password: string; +} + +const extractErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + const nestedMessage = (error as { data?: { message?: string } }).data?.message; + return nestedMessage ?? error.message; } -); + return "登录失败,请稍后重试"; +}; + +export const loginUser = createAsyncThunk< + AuthLoginPayload, + LoginCredentials, + { rejectValue: string } +>("auth/login", async (credentials, { rejectWithValue }) => { + try { + const response = (await post("/api/auth/login", credentials)) as ApiResponse; + if (!response?.data?.token) { + return rejectWithValue(response?.message ?? "登录失败"); + } + return response.data; + } catch (error) { + return rejectWithValue(extractErrorMessage(error)); + } +}); + +export const fetchCurrentUser = createAsyncThunk< + AuthCurrentUserPayload, + void, + { rejectValue: string } +>("auth/fetchCurrentUser", async (_, { rejectWithValue }) => { + try { + const response = (await get("/api/auth/me")) as ApiResponse; + if (!response?.data?.user) { + return rejectWithValue(response?.message ?? "用户信息加载失败"); + } + return response.data; + } catch (error) { + return rejectWithValue(extractErrorMessage(error)); + } +}); + +const initialToken = localStorage.getItem("token"); + +const initialState: AuthState = { + user: null, + token: initialToken, + roles: [], + permissions: [], + isAuthenticated: Boolean(initialToken), + initialized: false, + loading: false, + error: null, +}; const authSlice = createSlice({ - name: 'auth', - initialState: { - user: null, - token: localStorage.getItem('token'), - isAuthenticated: !!localStorage.getItem('token'), - loading: false, - error: null, - }, + name: "auth", + initialState, reducers: { logout: (state) => { state.user = null; state.token = null; + state.roles = []; + state.permissions = []; state.isAuthenticated = false; - localStorage.removeItem('token'); + state.error = null; + state.initialized = true; + localStorage.removeItem("token"); }, clearError: (state) => { state.error = null; }, - setToken: (state, action) => { - state.token = action.payload; - localStorage.setItem('token', action.payload); - }, - loginLocal: (state, action) => { - const { username, password } = action.payload; - if (username === 'admin' && password === '123456') { - state.user = { username: 'admin', role: 'admin' }; - state.token = 'mock-token-' + Date.now(); - state.isAuthenticated = true; - localStorage.setItem('token', state.token); - state.error = null; - } else { - state.error = 'Invalid credentials'; - state.isAuthenticated = false; - } + markInitialized: (state) => { + state.initialized = true; }, }, extraReducers: (builder) => { @@ -71,18 +129,52 @@ const authSlice = createSlice({ }) .addCase(loginUser.fulfilled, (state, action) => { state.loading = false; + state.initialized = true; state.user = action.payload.user; state.token = action.payload.token; + state.roles = action.payload.roles ?? []; + state.permissions = action.payload.permissions ?? []; state.isAuthenticated = true; - localStorage.setItem('token', action.payload.token); + state.error = null; + localStorage.setItem("token", action.payload.token); }) .addCase(loginUser.rejected, (state, action) => { state.loading = false; - state.error = action.payload; + state.initialized = true; + state.user = null; + state.roles = []; + state.permissions = []; state.isAuthenticated = false; + state.token = null; + state.error = action.payload ?? "登录失败"; + localStorage.removeItem("token"); + }) + .addCase(fetchCurrentUser.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchCurrentUser.fulfilled, (state, action) => { + state.loading = false; + state.initialized = true; + state.user = action.payload.user; + state.roles = action.payload.roles ?? []; + state.permissions = action.payload.permissions ?? []; + state.isAuthenticated = Boolean(state.token); + state.error = null; + }) + .addCase(fetchCurrentUser.rejected, (state, action) => { + state.loading = false; + state.initialized = true; + state.user = null; + state.roles = []; + state.permissions = []; + state.isAuthenticated = false; + state.token = null; + state.error = action.payload ?? "登录状态已失效"; + localStorage.removeItem("token"); }); }, }); -export const { logout, clearError, setToken, loginLocal } = authSlice.actions; -export default authSlice.reducer; \ No newline at end of file +export const { logout, clearError, markInitialized } = authSlice.actions; +export default authSlice.reducer; diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index 23b7c2b..148c4f7 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -524,8 +524,16 @@ request.addRequestInterceptor((config) => { // 添加默认响应拦截器 - 错误处理 request.addResponseInterceptor((response) => { - // 可以在这里添加全局错误处理逻辑 - // 比如token过期自动跳转登录页等 + if (response.status === 401) { + localStorage.removeItem("token"); + sessionStorage.removeItem("token"); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + if (response.status === 403 && window.location.pathname !== "/403") { + window.location.href = "/403"; + } return response; }); diff --git a/scripts/db/zz-auth-init.sql b/scripts/db/zz-auth-init.sql new file mode 100644 index 0000000..be50058 --- /dev/null +++ b/scripts/db/zz-auth-init.sql @@ -0,0 +1,149 @@ +USE datamate; + +-- ============================================= +-- 认证与授权(RBAC)基础表 +-- 注意:该脚本命名为 zz- 前缀,确保在 users 表初始化后执行 +-- ============================================= + +CREATE TABLE IF NOT EXISTS t_auth_roles +( + id VARCHAR(36) PRIMARY KEY COMMENT '角色ID', + role_code VARCHAR(100) NOT NULL COMMENT '角色编码', + role_name VARCHAR(100) NOT NULL COMMENT '角色名称', + description VARCHAR(255) DEFAULT '' COMMENT '角色描述', + enabled TINYINT DEFAULT 1 COMMENT '是否启用:1-启用,0-禁用', + is_built_in TINYINT DEFAULT 1 COMMENT '是否内置:1-是,0-否', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_auth_role_code (role_code) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='角色表'; + +CREATE TABLE IF NOT EXISTS t_auth_permissions +( + id VARCHAR(36) PRIMARY KEY COMMENT '权限ID', + permission_code VARCHAR(120) NOT NULL COMMENT '权限编码', + permission_name VARCHAR(120) NOT NULL COMMENT '权限名称', + module VARCHAR(100) NOT NULL COMMENT '模块', + action VARCHAR(50) NOT NULL COMMENT '动作', + path_pattern VARCHAR(255) DEFAULT '' COMMENT '路径模式', + method VARCHAR(20) DEFAULT '' COMMENT 'HTTP方法', + enabled TINYINT DEFAULT 1 COMMENT '是否启用:1-启用,0-禁用', + is_built_in TINYINT DEFAULT 1 COMMENT '是否内置:1-是,0-否', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_auth_permission_code (permission_code), + INDEX idx_auth_permission_module_action (module, action) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='权限表'; + +CREATE TABLE IF NOT EXISTS t_auth_role_permissions +( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + role_id VARCHAR(36) NOT NULL COMMENT '角色ID', + permission_id VARCHAR(36) NOT NULL COMMENT '权限ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_auth_role_permission (role_id, permission_id), + INDEX idx_auth_role_permission_role (role_id), + INDEX idx_auth_role_permission_permission (permission_id), + CONSTRAINT fk_auth_rp_role FOREIGN KEY (role_id) REFERENCES t_auth_roles (id) ON DELETE CASCADE, + CONSTRAINT fk_auth_rp_permission FOREIGN KEY (permission_id) REFERENCES t_auth_permissions (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='角色权限关系表'; + +CREATE TABLE IF NOT EXISTS t_auth_user_roles +( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + user_id BIGINT NOT NULL COMMENT '用户ID(users.id)', + role_id VARCHAR(36) NOT NULL COMMENT '角色ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_auth_user_role (user_id, role_id), + INDEX idx_auth_user_role_user (user_id), + INDEX idx_auth_user_role_role (role_id), + CONSTRAINT fk_auth_ur_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_auth_ur_role FOREIGN KEY (role_id) REFERENCES t_auth_roles (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='用户角色关系表'; + +-- ============================================= +-- 角色初始化 +-- ============================================= +INSERT IGNORE INTO t_auth_roles (id, role_code, role_name, description, enabled, is_built_in) +VALUES ('role-admin', 'ROLE_ADMIN', '系统管理员', '拥有平台全部权限', 1, 1), + ('role-data-editor', 'ROLE_DATA_EDITOR', '数据运营', '拥有业务模块读写权限', 1, 1), + ('role-knowledge-user', 'ROLE_KNOWLEDGE_USER', '知识用户', '以知识管理为主的业务权限', 1, 1); + +-- ============================================= +-- 权限初始化(接口级) +-- ============================================= +INSERT IGNORE INTO t_auth_permissions (id, permission_code, permission_name, module, action, path_pattern, method, enabled, is_built_in) +VALUES ('perm-dm-read', 'module:data-management:read', '数据管理读取', 'data-management', 'read', '/api/data-management/**', 'GET', 1, 1), + ('perm-dm-write', 'module:data-management:write', '数据管理写入', 'data-management', 'write', '/api/data-management/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-da-read', 'module:data-annotation:read', '数据标注读取', 'data-annotation', 'read', '/api/annotation/**', 'GET', 1, 1), + ('perm-da-write', 'module:data-annotation:write', '数据标注写入', 'data-annotation', 'write', '/api/annotation/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-dc-read', 'module:data-collection:read', '数据归集读取', 'data-collection', 'read', '/api/data-collection/**', 'GET', 1, 1), + ('perm-dc-write', 'module:data-collection:write', '数据归集写入', 'data-collection', 'write', '/api/data-collection/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-de-read', 'module:data-evaluation:read', '数据评估读取', 'data-evaluation', 'read', '/api/evaluation/**', 'GET', 1, 1), + ('perm-de-write', 'module:data-evaluation:write', '数据评估写入', 'data-evaluation', 'write', '/api/evaluation/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-ds-read', 'module:data-synthesis:read', '数据合成读取', 'data-synthesis', 'read', '/api/synthesis/**', 'GET', 1, 1), + ('perm-ds-write', 'module:data-synthesis:write', '数据合成写入', 'data-synthesis', 'write', '/api/synthesis/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-km-read', 'module:knowledge-management:read', '知识管理读取', 'knowledge-management', 'read', '/api/data-management/knowledge/**', 'GET', 1, 1), + ('perm-km-write', 'module:knowledge-management:write', '知识管理写入', 'knowledge-management', 'write', '/api/data-management/knowledge/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-kb-read', 'module:knowledge-base:read', '知识库读取', 'knowledge-base', 'read', '/api/knowledge-base/**', 'GET', 1, 1), + ('perm-kb-write', 'module:knowledge-base:write', '知识库写入', 'knowledge-base', 'write', '/api/knowledge-base/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-om-read', 'module:operator-market:read', '算子市场读取', 'operator-market', 'read', '/api/operator-market/**', 'GET', 1, 1), + ('perm-om-write', 'module:operator-market:write', '算子市场写入', 'operator-market', 'write', '/api/operator-market/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-orch-read', 'module:orchestration:read', '流程编排读取', 'orchestration', 'read', '/api/orchestration/**', 'GET', 1, 1), + ('perm-orch-write', 'module:orchestration:write', '流程编排写入', 'orchestration', 'write', '/api/orchestration/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-agent-use', 'module:agent:use', '对话助手使用', 'agent', 'use', '/chat/**', 'GET', 1, 1), + ('perm-content-use', 'module:content-generation:use', '内容生成功能使用', 'content-generation', 'use', '/api/content-generation/**', 'POST,PUT,PATCH', 1, 1), + ('perm-user-manage', 'system:user:manage', '用户管理', 'system', 'manage-user', '/api/auth/users/**', 'GET,POST,PUT,PATCH,DELETE', 1, 1), + ('perm-role-manage', 'system:role:manage', '角色管理', 'system', 'manage-role', '/api/auth/roles/**', 'GET,POST,PUT,PATCH,DELETE', 1, 1), + ('perm-perm-manage', 'system:permission:manage', '权限管理', 'system', 'manage-permission', '/api/auth/permissions/**', 'GET,POST,PUT,PATCH,DELETE', 1, 1); + +-- 管理员拥有所有权限 +INSERT IGNORE INTO t_auth_role_permissions (role_id, permission_id) +SELECT 'role-admin', p.id +FROM t_auth_permissions p; + +-- 数据运营拥有业务模块读写权限(不含系统管理) +INSERT IGNORE INTO t_auth_role_permissions (role_id, permission_id) +SELECT 'role-data-editor', p.id +FROM t_auth_permissions p +WHERE p.permission_code IN ( + 'module:data-management:read', 'module:data-management:write', + 'module:data-annotation:read', 'module:data-annotation:write', + 'module:data-collection:read', 'module:data-collection:write', + 'module:data-evaluation:read', 'module:data-evaluation:write', + 'module:data-synthesis:read', 'module:data-synthesis:write', + 'module:knowledge-management:read', 'module:knowledge-management:write', + 'module:knowledge-base:read', 'module:knowledge-base:write', + 'module:operator-market:read', 'module:operator-market:write', + 'module:orchestration:read', 'module:orchestration:write', + 'module:agent:use', 'module:content-generation:use' +); + +-- 知识用户拥有知识相关权限及必要数据读取权限 +INSERT IGNORE INTO t_auth_role_permissions (role_id, permission_id) +SELECT 'role-knowledge-user', p.id +FROM t_auth_permissions p +WHERE p.permission_code IN ( + 'module:data-management:read', + 'module:knowledge-management:read', 'module:knowledge-management:write', + 'module:knowledge-base:read', 'module:knowledge-base:write', + 'module:agent:use' +); + +-- ============================================= +-- 用户角色初始化(绑定到已有 users) +-- ============================================= +INSERT IGNORE INTO t_auth_user_roles (user_id, role_id) +SELECT u.id, 'role-admin' +FROM users u +WHERE u.username = 'admin'; + +INSERT IGNORE INTO t_auth_user_roles (user_id, role_id) +SELECT u.id, 'role-knowledge-user' +FROM users u +WHERE u.username = 'knowledge_user'; + From 6a4c4ae3d76e32a331f58040048860c80209bf67 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 6 Feb 2026 14:58:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20=E4=B8=BA=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8CRAG=E6=9C=8D=E5=8A=A1=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=B5=84=E6=BA=90=E8=AE=BF=E9=97=AE=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在DatasetApplicationService中注入ResourceAccessService并添加所有权验证 - 在KnowledgeSetApplicationService中注入ResourceAccessService并添加所有权验证 - 修改DatasetRepository接口和实现类,增加按创建者过滤的方法 - 修改KnowledgeSetRepository接口和实现类,增加按创建者过滤的方法 - 在RAG索引器服务中添加知识库访问权限检查和作用域过滤 - 更新实体元对象处理器以使用请求用户上下文获取当前用户 - 在前端设置页面添加用户权限管理功能和角色权限控制 - 为Python标注服务增加用户上下文和数据集访问权限验证 --- .../DatasetApplicationService.java | 20 +- .../KnowledgeSetApplicationService.java | 8 +- .../repository/DatasetRepository.java | 6 +- .../repository/KnowledgeSetRepository.java | 2 +- .../impl/DatasetRepositoryImpl.java | 33 +- .../impl/KnowledgeSetRepositoryImpl.java | 5 +- .../application/KnowledgeBaseService.java | 59 +++- .../repository/KnowledgeBaseRepository.java | 6 +- .../domain/repository/RagFileRepository.java | 2 +- .../impl/KnowledgeBaseRepositoryImpl.java | 21 +- .../impl/RagFileRepositoryImpl.java | 5 +- .../application/ResourceAccessService.java | 58 ++++ .../context/RequestUserContext.java | 40 +++ .../context/RequestUserContextHolder.java | 49 +++ .../RequestUserContextInterceptor.java | 53 +++ .../RequestUserContextWebMvcConfigurer.java | 21 ++ .../config/EntityMetaObjectHandler.java | 17 +- .../src/pages/SettingsPage/SettingsPage.tsx | 67 +++- .../SettingsPage/UserPermissionManagement.tsx | 321 ++++++++++++++++++ .../src/pages/SettingsPage/settings.apis.ts | 63 +++- .../app/module/annotation/interface/auto.py | 85 +++-- .../app/module/annotation/interface/editor.py | 25 +- .../module/annotation/interface/project.py | 31 +- .../app/module/annotation/interface/task.py | 17 +- .../app/module/annotation/security.py | 69 ++++ .../app/module/annotation/service/auto.py | 93 +++-- .../app/module/annotation/service/editor.py | 23 +- .../app/module/annotation/service/mapping.py | 22 +- 28 files changed, 1063 insertions(+), 158 deletions(-) create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/ResourceAccessService.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContext.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextHolder.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextInterceptor.java create mode 100644 backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextWebMvcConfigurer.java create mode 100644 frontend/src/pages/SettingsPage/UserPermissionManagement.tsx create mode 100644 runtime/datamate-python/app/module/annotation/security.py diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetApplicationService.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetApplicationService.java index b1174c3..724971b 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetApplicationService.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetApplicationService.java @@ -3,6 +3,7 @@ package com.datamate.datamanagement.application; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.datamate.common.auth.application.ResourceAccessService; import com.datamate.common.domain.utils.ChunksSaver; import com.datamate.common.setting.application.SysParamApplicationService; import com.datamate.datamanagement.interfaces.dto.*; @@ -64,6 +65,7 @@ public class DatasetApplicationService { private final CollectionTaskClient collectionTaskClient; private final DatasetFileApplicationService datasetFileApplicationService; private final SysParamApplicationService sysParamService; + private final ResourceAccessService resourceAccessService; @Value("${datamate.data-management.base-path:/dataset}") private String datasetBasePath; @@ -102,6 +104,7 @@ public class DatasetApplicationService { public Dataset updateDataset(String datasetId, UpdateDatasetRequest updateDatasetRequest) { Dataset dataset = datasetRepository.getById(datasetId); BusinessAssert.notNull(dataset, DataManagementErrorCode.DATASET_NOT_FOUND); + resourceAccessService.assertOwnerAccess(dataset.getCreatedBy()); if (StringUtils.hasText(updateDatasetRequest.getName())) { dataset.setName(updateDatasetRequest.getName()); @@ -151,6 +154,7 @@ public class DatasetApplicationService { public void deleteDataset(String datasetId) { Dataset dataset = datasetRepository.getById(datasetId); BusinessAssert.notNull(dataset, DataManagementErrorCode.DATASET_NOT_FOUND); + resourceAccessService.assertOwnerAccess(dataset.getCreatedBy()); long childCount = datasetRepository.countByParentId(datasetId); BusinessAssert.isTrue(childCount == 0, DataManagementErrorCode.DATASET_HAS_CHILDREN); datasetRepository.removeById(datasetId); @@ -164,6 +168,7 @@ public class DatasetApplicationService { public Dataset getDataset(String datasetId) { Dataset dataset = datasetRepository.getById(datasetId); BusinessAssert.notNull(dataset, DataManagementErrorCode.DATASET_NOT_FOUND); + resourceAccessService.assertOwnerAccess(dataset.getCreatedBy()); List datasetFiles = datasetFileRepository.findAllVisibleByDatasetId(datasetId); dataset.setFiles(datasetFiles); applyVisibleFileCounts(Collections.singletonList(dataset)); @@ -176,7 +181,8 @@ public class DatasetApplicationService { @Transactional(readOnly = true) public PagedResponse getDatasets(DatasetPagingQuery query) { IPage page = new Page<>(query.getPage(), query.getSize()); - page = datasetRepository.findByCriteria(page, query); + String ownerFilterUserId = resourceAccessService.resolveOwnerFilterUserId(); + page = datasetRepository.findByCriteria(page, query, ownerFilterUserId); String datasetPvcName = getDatasetPvcName(); applyVisibleFileCounts(page.getRecords()); List datasetResponses = DatasetConverter.INSTANCE.convertToResponse(page.getRecords()); @@ -189,6 +195,7 @@ public class DatasetApplicationService { BusinessAssert.isTrue(StringUtils.hasText(datasetId), CommonErrorCode.PARAM_ERROR); Dataset dataset = datasetRepository.getById(datasetId); BusinessAssert.notNull(dataset, DataManagementErrorCode.DATASET_NOT_FOUND); + resourceAccessService.assertOwnerAccess(dataset.getCreatedBy()); Set sourceTags = normalizeTagNames(dataset.getTags()); if (sourceTags.isEmpty()) { return Collections.emptyList(); @@ -198,10 +205,12 @@ public class DatasetApplicationService { SIMILAR_DATASET_CANDIDATE_MAX, Math.max(safeLimit * SIMILAR_DATASET_CANDIDATE_FACTOR, safeLimit) ); + String ownerFilterUserId = resourceAccessService.resolveOwnerFilterUserId(); List candidates = datasetRepository.findSimilarByTags( new ArrayList<>(sourceTags), datasetId, - candidateLimit + candidateLimit, + ownerFilterUserId ); if (CollectionUtils.isEmpty(candidates)) { return Collections.emptyList(); @@ -436,6 +445,7 @@ public class DatasetApplicationService { if (dataset == null) { throw new IllegalArgumentException("Dataset not found: " + datasetId); } + resourceAccessService.assertOwnerAccess(dataset.getCreatedBy()); Map statistics = new HashMap<>(); @@ -485,7 +495,11 @@ public class DatasetApplicationService { * 获取所有数据集的汇总统计信息 */ public AllDatasetStatisticsResponse getAllDatasetStatistics() { - return datasetRepository.getAllDatasetStatistics(); + if (resourceAccessService.isAdmin()) { + return datasetRepository.getAllDatasetStatistics(); + } + String currentUserId = resourceAccessService.requireCurrentUserId(); + return datasetRepository.getAllDatasetStatisticsByCreatedBy(currentUserId); } /** diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeSetApplicationService.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeSetApplicationService.java index 259e765..513ca9e 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeSetApplicationService.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeSetApplicationService.java @@ -2,6 +2,7 @@ package com.datamate.datamanagement.application; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.datamate.common.auth.application.ResourceAccessService; import com.datamate.common.infrastructure.exception.BusinessAssert; import com.datamate.common.infrastructure.exception.CommonErrorCode; import com.datamate.common.interfaces.PagedResponse; @@ -40,6 +41,7 @@ import java.util.UUID; public class KnowledgeSetApplicationService { private final KnowledgeSetRepository knowledgeSetRepository; private final TagMapper tagMapper; + private final ResourceAccessService resourceAccessService; public KnowledgeSet createKnowledgeSet(CreateKnowledgeSetRequest request) { BusinessAssert.isTrue(knowledgeSetRepository.findByName(request.getName()) == null, @@ -64,6 +66,7 @@ public class KnowledgeSetApplicationService { public KnowledgeSet updateKnowledgeSet(String setId, UpdateKnowledgeSetRequest request) { KnowledgeSet knowledgeSet = knowledgeSetRepository.getById(setId); BusinessAssert.notNull(knowledgeSet, DataManagementErrorCode.KNOWLEDGE_SET_NOT_FOUND); + resourceAccessService.assertOwnerAccess(knowledgeSet.getCreatedBy()); BusinessAssert.isTrue(!isReadOnlyStatus(knowledgeSet.getStatus()), DataManagementErrorCode.KNOWLEDGE_SET_STATUS_ERROR); @@ -119,6 +122,7 @@ public class KnowledgeSetApplicationService { public void deleteKnowledgeSet(String setId) { KnowledgeSet knowledgeSet = knowledgeSetRepository.getById(setId); BusinessAssert.notNull(knowledgeSet, DataManagementErrorCode.KNOWLEDGE_SET_NOT_FOUND); + resourceAccessService.assertOwnerAccess(knowledgeSet.getCreatedBy()); knowledgeSetRepository.removeById(setId); } @@ -126,13 +130,15 @@ public class KnowledgeSetApplicationService { public KnowledgeSet getKnowledgeSet(String setId) { KnowledgeSet knowledgeSet = knowledgeSetRepository.getById(setId); BusinessAssert.notNull(knowledgeSet, DataManagementErrorCode.KNOWLEDGE_SET_NOT_FOUND); + resourceAccessService.assertOwnerAccess(knowledgeSet.getCreatedBy()); return knowledgeSet; } @Transactional(readOnly = true) public PagedResponse getKnowledgeSets(KnowledgeSetPagingQuery query) { IPage page = new Page<>(query.getPage(), query.getSize()); - page = knowledgeSetRepository.findByCriteria(page, query); + String ownerFilterUserId = resourceAccessService.resolveOwnerFilterUserId(); + page = knowledgeSetRepository.findByCriteria(page, query, ownerFilterUserId); List responses = KnowledgeConverter.INSTANCE.convertSetResponses(page.getRecords()); return PagedResponse.of(responses, page.getCurrent(), page.getTotal(), page.getPages()); } diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/DatasetRepository.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/DatasetRepository.java index a2f218f..64bc4cc 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/DatasetRepository.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/DatasetRepository.java @@ -25,9 +25,11 @@ public interface DatasetRepository extends IRepository { AllDatasetStatisticsResponse getAllDatasetStatistics(); - IPage findByCriteria(IPage page, DatasetPagingQuery query); + AllDatasetStatisticsResponse getAllDatasetStatisticsByCreatedBy(String createdBy); + + IPage findByCriteria(IPage page, DatasetPagingQuery query, String createdBy); long countByParentId(String parentDatasetId); - List findSimilarByTags(List tagNames, String excludedDatasetId, int limit); + List findSimilarByTags(List tagNames, String excludedDatasetId, int limit, String createdBy); } diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/KnowledgeSetRepository.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/KnowledgeSetRepository.java index 7d36db4..4de8314 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/KnowledgeSetRepository.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/KnowledgeSetRepository.java @@ -11,5 +11,5 @@ import com.datamate.datamanagement.interfaces.dto.KnowledgeSetPagingQuery; public interface KnowledgeSetRepository extends IRepository { KnowledgeSet findByName(String name); - IPage findByCriteria(IPage page, KnowledgeSetPagingQuery query); + IPage findByCriteria(IPage page, KnowledgeSetPagingQuery query, String createdBy); } diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/DatasetRepositoryImpl.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/DatasetRepositoryImpl.java index aec9203..02af21c 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/DatasetRepositoryImpl.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/DatasetRepositoryImpl.java @@ -51,10 +51,34 @@ public class DatasetRepositoryImpl extends CrudRepository findByCriteria(IPage page, DatasetPagingQuery query) { + public AllDatasetStatisticsResponse getAllDatasetStatisticsByCreatedBy(String createdBy) { + List datasets = lambdaQuery() + .eq(Dataset::getCreatedBy, createdBy) + .list(); + long totalFiles = datasets.stream() + .map(Dataset::getFileCount) + .filter(java.util.Objects::nonNull) + .mapToLong(Long::longValue) + .sum(); + long totalSize = datasets.stream() + .map(Dataset::getSizeBytes) + .filter(java.util.Objects::nonNull) + .mapToLong(Long::longValue) + .sum(); + AllDatasetStatisticsResponse response = new AllDatasetStatisticsResponse(); + response.setTotalDatasets(datasets.size()); + response.setTotalFiles(totalFiles); + response.setTotalSize(totalSize); + return response; + } + + + @Override + public IPage findByCriteria(IPage page, DatasetPagingQuery query, String createdBy) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(query.getType() != null, Dataset::getDatasetType, query.getType()) - .eq(query.getStatus() != null, Dataset::getStatus, query.getStatus()); + .eq(query.getStatus() != null, Dataset::getStatus, query.getStatus()) + .eq(StringUtils.isNotBlank(createdBy), Dataset::getCreatedBy, createdBy); if (query.getParentDatasetId() != null) { if (StringUtils.isBlank(query.getParentDatasetId())) { @@ -92,7 +116,7 @@ public class DatasetRepositoryImpl extends CrudRepository findSimilarByTags(List tagNames, String excludedDatasetId, int limit) { + public List findSimilarByTags(List tagNames, String excludedDatasetId, int limit, String createdBy) { if (limit <= 0 || tagNames == null || tagNames.isEmpty()) { return Collections.emptyList(); } @@ -109,6 +133,9 @@ public class DatasetRepositoryImpl extends CrudRepository 0"); wrapper.and(condition -> { boolean hasCondition = false; diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeSetRepositoryImpl.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeSetRepositoryImpl.java index 896e61c..fa37e43 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeSetRepositoryImpl.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeSetRepositoryImpl.java @@ -25,7 +25,7 @@ public class KnowledgeSetRepositoryImpl extends CrudRepository findByCriteria(IPage page, KnowledgeSetPagingQuery query) { + public IPage findByCriteria(IPage page, KnowledgeSetPagingQuery query, String createdBy) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(query.getStatus() != null, KnowledgeSet::getStatus, query.getStatus()) .eq(StringUtils.isNotBlank(query.getDomain()), KnowledgeSet::getDomain, query.getDomain()) @@ -34,7 +34,8 @@ public class KnowledgeSetRepositoryImpl extends CrudRepository w.like(KnowledgeSet::getName, query.getKeyword()) diff --git a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/application/KnowledgeBaseService.java b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/application/KnowledgeBaseService.java index 2372e61..54e317b 100644 --- a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/application/KnowledgeBaseService.java +++ b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/application/KnowledgeBaseService.java @@ -2,8 +2,11 @@ package com.datamate.rag.indexer.application; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.datamate.common.auth.application.ResourceAccessService; +import com.datamate.common.infrastructure.exception.BusinessAssert; import com.datamate.common.infrastructure.exception.BusinessException; import com.datamate.common.infrastructure.exception.KnowledgeBaseErrorCode; +import com.datamate.common.infrastructure.exception.SystemErrorCode; import com.datamate.common.interfaces.PagedResponse; import com.datamate.common.interfaces.PagingQuery; import com.datamate.common.setting.domain.entity.ModelConfig; @@ -55,6 +58,7 @@ public class KnowledgeBaseService { private final ApplicationEventPublisher eventPublisher; private final ModelConfigRepository modelConfigRepository; private final MilvusService milvusService; + private final ResourceAccessService resourceAccessService; /** * 创建知识库 @@ -77,8 +81,7 @@ public class KnowledgeBaseService { */ @Transactional(rollbackFor = Exception.class) public void update(String knowledgeBaseId, KnowledgeBaseUpdateReq request) { - KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(knowledgeBaseId)) - .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + KnowledgeBase knowledgeBase = getKnowledgeBaseWithAccessCheck(knowledgeBaseId); if (StringUtils.hasText(request.getName()) && !knowledgeBase.getName().equals(request.getName())) { milvusService.getMilvusClient().renameCollection(RenameCollectionReq.builder() .collectionName(knowledgeBase.getName()) @@ -98,16 +101,14 @@ public class KnowledgeBaseService { */ @Transactional(rollbackFor = Exception.class) public void delete(String knowledgeBaseId) { - KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(knowledgeBaseId)) - .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + KnowledgeBase knowledgeBase = getKnowledgeBaseWithAccessCheck(knowledgeBaseId); knowledgeBaseRepository.removeById(knowledgeBaseId); ragFileRepository.removeByKnowledgeBaseId(knowledgeBaseId); milvusService.getMilvusClient().dropCollection(DropCollectionReq.builder().collectionName(knowledgeBase.getName()).build()); } public KnowledgeBaseResp getById(String knowledgeBaseId) { - KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(knowledgeBaseId)) - .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + KnowledgeBase knowledgeBase = getKnowledgeBaseWithAccessCheck(knowledgeBaseId); KnowledgeBaseResp resp = getKnowledgeBaseResp(knowledgeBase); resp.setEmbedding(modelConfigRepository.getById(knowledgeBase.getEmbeddingModel())); resp.setChat(modelConfigRepository.getById(knowledgeBase.getChatModel())); @@ -133,7 +134,8 @@ public class KnowledgeBaseService { public PagedResponse list(KnowledgeBaseQueryReq request) { IPage page = new Page<>(request.getPage(), request.getSize()); - page = knowledgeBaseRepository.page(page, request); + String ownerFilterUserId = resourceAccessService.resolveOwnerFilterUserId(); + page = knowledgeBaseRepository.page(page, request, ownerFilterUserId); // 将 KnowledgeBase 转换为 KnowledgeBaseResp,并计算 fileCount 和 chunkCount List respList = page.getRecords().stream().map(this::getKnowledgeBaseResp).toList(); @@ -143,8 +145,7 @@ public class KnowledgeBaseService { @Transactional(rollbackFor = Exception.class) public void addFiles(AddFilesReq request) { - KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(request.getKnowledgeBaseId())) - .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + KnowledgeBase knowledgeBase = getKnowledgeBaseWithAccessCheck(request.getKnowledgeBaseId()); List ragFiles = request.getFiles().stream().map(fileInfo -> { RagFile ragFile = new RagFile(); ragFile.setKnowledgeBaseId(knowledgeBase.getId()); @@ -170,6 +171,7 @@ public class KnowledgeBaseService { } public PagedResponse listFiles(String knowledgeBaseId, RagFileReq request) { + getKnowledgeBaseWithAccessCheck(knowledgeBaseId); IPage page = new Page<>(request.getPage(), request.getSize()); request.setKnowledgeBaseId(knowledgeBaseId); page = ragFileRepository.page(page, request); @@ -177,8 +179,13 @@ public class KnowledgeBaseService { } public PagedResponse searchFiles(KnowledgeBaseFileSearchReq request) { + boolean admin = resourceAccessService.isAdmin(); + List scopedKnowledgeBaseIds = resolveSearchScopeKnowledgeBaseIds(request, admin); + if (!admin && scopedKnowledgeBaseIds.isEmpty()) { + return PagedResponse.of(Collections.emptyList(), request.getPage(), 0L, 0); + } IPage page = new Page<>(request.getPage(), request.getSize()); - page = ragFileRepository.searchPage(page, request); + page = ragFileRepository.searchPage(page, request, scopedKnowledgeBaseIds); List records = page.getRecords(); if (records.isEmpty()) { return PagedResponse.of(Collections.emptyList(), page.getCurrent(), page.getTotal(), page.getPages()); @@ -213,8 +220,7 @@ public class KnowledgeBaseService { @Transactional(rollbackFor = Exception.class) public void deleteFiles(String knowledgeBaseId, DeleteFilesReq request) { - KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(knowledgeBaseId)) - .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + KnowledgeBase knowledgeBase = getKnowledgeBaseWithAccessCheck(knowledgeBaseId); ragFileRepository.removeByIds(request.getIds()); milvusService.getMilvusClient().delete(DeleteReq.builder() .collectionName(knowledgeBase.getName()) @@ -223,8 +229,7 @@ public class KnowledgeBaseService { } public PagedResponse getChunks(String knowledgeBaseId, String ragFileId, PagingQuery pagingQuery) { - KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(knowledgeBaseId)) - .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + KnowledgeBase knowledgeBase = getKnowledgeBaseWithAccessCheck(knowledgeBaseId); QueryResp results = milvusService.getMilvusClient().query(QueryReq.builder() .collectionName(knowledgeBase.getName()) .filter("metadata[\"rag_file_id\"] == \"" + ragFileId + "\"") @@ -259,8 +264,7 @@ public class KnowledgeBaseService { * @return 检索结果 */ public List retrieve(RetrieveReq request) { - KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(request.getKnowledgeBaseIds().getFirst())) - .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + KnowledgeBase knowledgeBase = getKnowledgeBaseWithAccessCheck(request.getKnowledgeBaseIds().getFirst()); ModelConfig modelConfig = modelConfigRepository.getById(knowledgeBase.getEmbeddingModel()); EmbeddingModel embeddingModel = ModelClient.invokeEmbeddingModel(modelConfig); Embedding embedding = embeddingModel.embed(request.getQuery()).content(); @@ -273,4 +277,27 @@ public class KnowledgeBaseService { }); return searchResults; } + + private KnowledgeBase getKnowledgeBaseWithAccessCheck(String knowledgeBaseId) { + KnowledgeBase knowledgeBase = Optional.ofNullable(knowledgeBaseRepository.getById(knowledgeBaseId)) + .orElseThrow(() -> BusinessException.of(KnowledgeBaseErrorCode.KNOWLEDGE_BASE_NOT_FOUND)); + resourceAccessService.assertOwnerAccess(knowledgeBase.getCreatedBy()); + return knowledgeBase; + } + + private List resolveSearchScopeKnowledgeBaseIds(KnowledgeBaseFileSearchReq request, boolean admin) { + if (admin) { + return Collections.emptyList(); + } + String currentUserId = resourceAccessService.requireCurrentUserId(); + List ownedKnowledgeBaseIds = knowledgeBaseRepository.listIdsByCreatedBy(currentUserId); + if (!StringUtils.hasText(request.getKnowledgeBaseId())) { + return ownedKnowledgeBaseIds; + } + BusinessAssert.isTrue( + ownedKnowledgeBaseIds.contains(request.getKnowledgeBaseId()), + SystemErrorCode.INSUFFICIENT_PERMISSIONS + ); + return Collections.singletonList(request.getKnowledgeBaseId()); + } } diff --git a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/KnowledgeBaseRepository.java b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/KnowledgeBaseRepository.java index 273abc9..b09599c 100644 --- a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/KnowledgeBaseRepository.java +++ b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/KnowledgeBaseRepository.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.repository.IRepository; import com.datamate.rag.indexer.domain.model.KnowledgeBase; import com.datamate.rag.indexer.interfaces.dto.KnowledgeBaseQueryReq; +import java.util.List; + /** * 知识库仓储接口 * @@ -19,5 +21,7 @@ public interface KnowledgeBaseRepository extends IRepository { * @param request 查询请求 * @return 知识库分页结果 */ - IPage page(IPage page, KnowledgeBaseQueryReq request); + IPage page(IPage page, KnowledgeBaseQueryReq request, String createdBy); + + List listIdsByCreatedBy(String createdBy); } diff --git a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/RagFileRepository.java b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/RagFileRepository.java index 61b86dd..839026f 100644 --- a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/RagFileRepository.java +++ b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/domain/repository/RagFileRepository.java @@ -23,5 +23,5 @@ public interface RagFileRepository extends IRepository { IPage page(IPage page, RagFileReq request); - IPage searchPage(IPage page, KnowledgeBaseFileSearchReq request); + IPage searchPage(IPage page, KnowledgeBaseFileSearchReq request, List knowledgeBaseIds); } diff --git a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/KnowledgeBaseRepositoryImpl.java b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/KnowledgeBaseRepositoryImpl.java index c186bac..69216f8 100644 --- a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/KnowledgeBaseRepositoryImpl.java +++ b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/KnowledgeBaseRepositoryImpl.java @@ -10,6 +10,9 @@ import com.datamate.rag.indexer.interfaces.dto.KnowledgeBaseQueryReq; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; +import java.util.Collections; +import java.util.List; + /** * 知识库仓储实现类 * @@ -20,12 +23,28 @@ import org.springframework.util.StringUtils; public class KnowledgeBaseRepositoryImpl extends CrudRepository implements KnowledgeBaseRepository { @Override - public IPage page(IPage page, KnowledgeBaseQueryReq request) { + public IPage page(IPage page, KnowledgeBaseQueryReq request, String createdBy) { return this.page(page, new LambdaQueryWrapper() .like(StringUtils.hasText(request.getName()), KnowledgeBase::getName, request.getName()) .like(StringUtils.hasText(request.getDescription()), KnowledgeBase::getDescription, request.getDescription()) .like(StringUtils.hasText(request.getCreatedBy()), KnowledgeBase::getCreatedBy, request.getCreatedBy()) .like(StringUtils.hasText(request.getUpdatedBy()), KnowledgeBase::getUpdatedBy, request.getUpdatedBy()) + .eq(StringUtils.hasText(createdBy), KnowledgeBase::getCreatedBy, createdBy) .orderByDesc(KnowledgeBase::getCreatedAt)); } + + @Override + public List listIdsByCreatedBy(String createdBy) { + if (!StringUtils.hasText(createdBy)) { + return Collections.emptyList(); + } + return lambdaQuery() + .select(KnowledgeBase::getId) + .eq(KnowledgeBase::getCreatedBy, createdBy) + .list() + .stream() + .map(KnowledgeBase::getId) + .filter(StringUtils::hasText) + .toList(); + } } diff --git a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/RagFileRepositoryImpl.java b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/RagFileRepositoryImpl.java index 14b4dd8..ff3f98f 100644 --- a/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/RagFileRepositoryImpl.java +++ b/backend/services/rag-indexer-service/src/main/java/com/datamate/rag/indexer/infrastructure/persistence/impl/RagFileRepositoryImpl.java @@ -52,9 +52,12 @@ public class RagFileRepositoryImpl extends CrudRepository searchPage(IPage page, KnowledgeBaseFileSearchReq request) { + public IPage searchPage(IPage page, KnowledgeBaseFileSearchReq request, List knowledgeBaseIds) { return lambdaQuery() .eq(StringUtils.hasText(request.getKnowledgeBaseId()), RagFile::getKnowledgeBaseId, request.getKnowledgeBaseId()) + .in(!StringUtils.hasText(request.getKnowledgeBaseId()) && knowledgeBaseIds != null && !knowledgeBaseIds.isEmpty(), + RagFile::getKnowledgeBaseId, + knowledgeBaseIds) .like(StringUtils.hasText(request.getFileName()), RagFile::getFileName, request.getFileName()) .likeRight(StringUtils.hasText(request.getRelativePath()), RagFile::getRelativePath, normalizeRelativePath(request.getRelativePath())) .page(page); diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/ResourceAccessService.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/ResourceAccessService.java new file mode 100644 index 0000000..71d7f71 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/application/ResourceAccessService.java @@ -0,0 +1,58 @@ +package com.datamate.common.auth.application; + +import com.datamate.common.auth.infrastructure.context.RequestUserContextHolder; +import com.datamate.common.infrastructure.exception.BusinessAssert; +import com.datamate.common.infrastructure.exception.SystemErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Objects; + +/** + * 资源访问控制服务(基于请求用户上下文) + */ +@Service +public class ResourceAccessService { + public static final String ADMIN_ROLE_CODE = "ROLE_ADMIN"; + + public boolean isAdmin() { + return RequestUserContextHolder.hasRole(ADMIN_ROLE_CODE); + } + + public String getCurrentUserId() { + return RequestUserContextHolder.getCurrentUserId(); + } + + public String requireCurrentUserId() { + String currentUserId = getCurrentUserId(); + BusinessAssert.isTrue(StringUtils.hasText(currentUserId), SystemErrorCode.INSUFFICIENT_PERMISSIONS); + return currentUserId; + } + + /** + * 资源列表查询的 owner 过滤: + * - 管理员返回 null(不过滤) + * - 非管理员返回当前用户ID + */ + public String resolveOwnerFilterUserId() { + if (isAdmin()) { + return null; + } + return requireCurrentUserId(); + } + + /** + * 校验当前用户是否可访问 owner 资源 + */ + public void assertOwnerAccess(String ownerUserId) { + if (isAdmin()) { + return; + } + String currentUserId = requireCurrentUserId(); + BusinessAssert.isTrue( + StringUtils.hasText(ownerUserId) && Objects.equals(ownerUserId, currentUserId), + SystemErrorCode.INSUFFICIENT_PERMISSIONS + ); + } +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContext.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContext.java new file mode 100644 index 0000000..096a02e --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContext.java @@ -0,0 +1,40 @@ +package com.datamate.common.auth.infrastructure.context; + +import lombok.Getter; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * 请求级用户上下文 + */ +@Getter +public class RequestUserContext { + private final String userId; + private final String username; + private final List roles; + + private RequestUserContext(String userId, String username, List roles) { + this.userId = userId; + this.username = username; + this.roles = roles == null ? Collections.emptyList() : List.copyOf(roles); + } + + public static RequestUserContext of(String userId, String username, List roles) { + return new RequestUserContext(userId, username, roles); + } + + public static RequestUserContext empty() { + return new RequestUserContext(null, null, Collections.emptyList()); + } + + public boolean hasRole(String roleCode) { + if (!StringUtils.hasText(roleCode)) { + return false; + } + return roles.stream().anyMatch(role -> StringUtils.hasText(role) && Objects.equals(role.trim(), roleCode)); + } +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextHolder.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextHolder.java new file mode 100644 index 0000000..2276553 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextHolder.java @@ -0,0 +1,49 @@ +package com.datamate.common.auth.infrastructure.context; + +import org.springframework.core.NamedThreadLocal; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.List; + +/** + * 请求级用户上下文持有器 + */ +public final class RequestUserContextHolder { + private static final ThreadLocal USER_CONTEXT_HOLDER = + new NamedThreadLocal<>("datamate-request-user-context"); + + private RequestUserContextHolder() { + } + + public static void set(RequestUserContext context) { + USER_CONTEXT_HOLDER.set(context == null ? RequestUserContext.empty() : context); + } + + public static RequestUserContext get() { + RequestUserContext context = USER_CONTEXT_HOLDER.get(); + return context == null ? RequestUserContext.empty() : context; + } + + public static String getCurrentUserId() { + return get().getUserId(); + } + + public static List getCurrentRoles() { + List roles = get().getRoles(); + return roles == null ? Collections.emptyList() : roles; + } + + public static boolean hasRole(String roleCode) { + if (!StringUtils.hasText(roleCode)) { + return false; + } + return getCurrentRoles().stream() + .anyMatch(role -> StringUtils.hasText(role) && roleCode.equalsIgnoreCase(role.trim())); + } + + public static void clear() { + USER_CONTEXT_HOLDER.remove(); + } +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextInterceptor.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextInterceptor.java new file mode 100644 index 0000000..aff3996 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextInterceptor.java @@ -0,0 +1,53 @@ +package com.datamate.common.auth.infrastructure.context; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * 从网关透传请求头中提取用户上下文 + */ +@Component +public class RequestUserContextInterceptor implements HandlerInterceptor { + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_USER_NAME = "X-User-Name"; + private static final String HEADER_USER_ROLES = "X-User-Roles"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String userId = normalizeValue(request.getHeader(HEADER_USER_ID)); + String username = normalizeValue(request.getHeader(HEADER_USER_NAME)); + List roleCodes = parseRoleCodes(request.getHeader(HEADER_USER_ROLES)); + RequestUserContextHolder.set(RequestUserContext.of(userId, username, roleCodes)); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + RequestUserContextHolder.clear(); + } + + private String normalizeValue(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private List parseRoleCodes(String roleHeader) { + if (!StringUtils.hasText(roleHeader)) { + return Collections.emptyList(); + } + return Arrays.stream(roleHeader.split(",")) + .map(String::trim) + .filter(StringUtils::hasText) + .toList(); + } +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextWebMvcConfigurer.java b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextWebMvcConfigurer.java new file mode 100644 index 0000000..eeea311 --- /dev/null +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/auth/infrastructure/context/RequestUserContextWebMvcConfigurer.java @@ -0,0 +1,21 @@ +package com.datamate.common.auth.infrastructure.context; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 请求用户上下文拦截器注册 + */ +@Configuration +@RequiredArgsConstructor +public class RequestUserContextWebMvcConfigurer implements WebMvcConfigurer { + private final RequestUserContextInterceptor requestUserContextInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(requestUserContextInterceptor).addPathPatterns("/**"); + } +} + diff --git a/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/EntityMetaObjectHandler.java b/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/EntityMetaObjectHandler.java index 47b2ed8..bf244a5 100644 --- a/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/EntityMetaObjectHandler.java +++ b/backend/shared/domain-common/src/main/java/com/datamate/common/infrastructure/config/EntityMetaObjectHandler.java @@ -1,9 +1,11 @@ package com.datamate.common.infrastructure.config; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.datamate.common.auth.infrastructure.context.RequestUserContextHolder; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.reflection.MetaObject; import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; @@ -44,17 +46,10 @@ public class EntityMetaObjectHandler implements MetaObjectHandler { * 获取当前用户(需要根据你的安全框架实现) */ private String getCurrentUser() { - // todo 这里需要根据你的安全框架实现,例如Spring Security、Shiro等 - // 示例:返回默认用户或从SecurityContext获取 - try { - // 如果是Spring Security - // return SecurityContextHolder.getContext().getAuthentication().getName(); - - // 临时返回默认值,请根据实际情况修改 - return "system"; - } catch (Exception e) { - log.error("Error getting current user", e); - return "unknown"; + String currentUserId = RequestUserContextHolder.getCurrentUserId(); + if (StringUtils.hasText(currentUserId)) { + return currentUserId; } + return "system"; } } diff --git a/frontend/src/pages/SettingsPage/SettingsPage.tsx b/frontend/src/pages/SettingsPage/SettingsPage.tsx index 84805b4..3888394 100644 --- a/frontend/src/pages/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage/SettingsPage.tsx @@ -1,12 +1,51 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Menu } from "antd"; -import { SettingOutlined } from "@ant-design/icons"; +import { SettingOutlined, TeamOutlined } from "@ant-design/icons"; import { Component } from "lucide-react"; import SystemConfig from "./SystemConfig"; import ModelAccess from "./ModelAccess"; +import UserPermissionManagement from "./UserPermissionManagement"; +import { useAppSelector } from "@/store/hooks"; +import { hasPermission, PermissionCodes } from "@/auth/permissions"; export default function SettingsPage() { - const [activeTab, setActiveTab] = useState("model-access"); + const permissions = useAppSelector((state) => state.auth.permissions); + const canManageUsers = hasPermission(permissions, PermissionCodes.userManage); + const canViewRoles = hasPermission(permissions, PermissionCodes.roleManage); + const canViewPermissions = hasPermission( + permissions, + PermissionCodes.permissionManage + ); + const tabs = useMemo(() => { + const nextTabs = [ + { + key: "model-access", + icon: , + label: "模型接入", + }, + { + key: "system-config", + icon: , + label: "参数配置", + }, + ]; + if (canManageUsers || canViewRoles || canViewPermissions) { + nextTabs.push({ + key: "user-permission", + icon: , + label: "用户与权限", + }); + } + return nextTabs; + }, [canManageUsers, canViewPermissions, canViewRoles]); + const [activeTab, setActiveTab] = useState(tabs[0]?.key ?? "model-access"); + + useEffect(() => { + const hasActiveTab = tabs.some((tab) => tab.key === activeTab); + if (!hasActiveTab && tabs.length > 0) { + setActiveTab(tabs[0].key); + } + }, [activeTab, tabs]); return (
@@ -18,21 +57,10 @@ export default function SettingsPage() {
, - label: "模型接入", - }, - { - key: "system-config", - icon: , - label: "参数配置", - }, - ]} + items={tabs} selectedKeys={[activeTab]} onClick={({ key }) => { - setActiveTab(key); + setActiveTab(String(key)); }} />
@@ -41,6 +69,13 @@ export default function SettingsPage() { {/* 内容区域,根据 activeTab 渲染不同的组件 */} {activeTab === "system-config" && } {activeTab === "model-access" && } + {activeTab === "user-permission" && ( + + )}
); diff --git a/frontend/src/pages/SettingsPage/UserPermissionManagement.tsx b/frontend/src/pages/SettingsPage/UserPermissionManagement.tsx new file mode 100644 index 0000000..d9d9a79 --- /dev/null +++ b/frontend/src/pages/SettingsPage/UserPermissionManagement.tsx @@ -0,0 +1,321 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Button, + Card, + Empty, + message, + Modal, + Select, + Space, + Table, + Tag, + Typography, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + assignUserRolesUsingPut, + listAuthPermissionsUsingGet, + listAuthRolesUsingGet, + listAuthUsersUsingGet, +} from "./settings.apis"; +import type { + AuthPermissionInfo, + AuthRoleInfo, + AuthUserWithRoles, +} from "./settings.apis"; + +interface ApiResponse { + code: string; + message: string; + data: T; +} + +interface UserPermissionManagementProps { + canManageUsers: boolean; + canViewRoles: boolean; + canViewPermissions: boolean; +} + +export default function UserPermissionManagement({ + canManageUsers, + canViewRoles, + canViewPermissions, +}: UserPermissionManagementProps) { + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState([]); + const [editingUser, setEditingUser] = useState(null); + const [selectedRoleCodes, setSelectedRoleCodes] = useState([]); + const [submitting, setSubmitting] = useState(false); + + const canShowAnything = canManageUsers || canViewRoles || canViewPermissions; + const canAssignRoles = canManageUsers && roles.length > 0; + + const roleNameMap = useMemo( + () => new Map(roles.map((role) => [role.roleCode, role.roleName || role.roleCode])), + [roles] + ); + const roleCodeToIdMap = useMemo( + () => new Map(roles.map((role) => [role.roleCode, role.id])), + [roles] + ); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const requestTasks: Array> = []; + if (canManageUsers || canViewRoles || canViewPermissions) { + requestTasks.push(listAuthUsersUsingGet()); + } + if (canManageUsers || canViewRoles) { + requestTasks.push(listAuthRolesUsingGet()); + } + if (canViewPermissions) { + requestTasks.push(listAuthPermissionsUsingGet()); + } + const responses = await Promise.all(requestTasks); + let index = 0; + if (canManageUsers || canViewRoles || canViewPermissions) { + const userResponse = responses[index++] as ApiResponse; + setUsers(userResponse?.data ?? []); + } + if (canManageUsers || canViewRoles) { + const roleResponse = responses[index++] as ApiResponse; + setRoles(roleResponse?.data ?? []); + } else { + setRoles([]); + } + if (canViewPermissions) { + const permissionResponse = responses[index++] as ApiResponse; + setPermissions(permissionResponse?.data ?? []); + } else { + setPermissions([]); + } + } catch (error) { + message.error("加载用户权限信息失败"); + console.error("加载用户权限信息失败:", error); + } finally { + setLoading(false); + } + }, [canManageUsers, canViewPermissions, canViewRoles]); + + useEffect(() => { + if (!canShowAnything) { + return; + } + void loadData(); + }, [canShowAnything, loadData]); + + const userColumns: ColumnsType = [ + { + title: "用户名", + dataIndex: "username", + key: "username", + width: 180, + }, + { + title: "姓名", + dataIndex: "fullName", + key: "fullName", + width: 180, + render: (value?: string) => value || "-", + }, + { + title: "邮箱", + dataIndex: "email", + key: "email", + render: (value?: string) => value || "-", + }, + { + title: "状态", + dataIndex: "enabled", + key: "enabled", + width: 120, + render: (enabled?: boolean) => + enabled ? 启用 : 禁用, + }, + { + title: "角色", + dataIndex: "roleCodes", + key: "roleCodes", + render: (roleCodes: string[]) => ( + + {(roleCodes ?? []).map((roleCode) => ( + {roleNameMap.get(roleCode) || roleCode} + ))} + + ), + }, + { + title: "操作", + key: "actions", + width: 120, + render: (_, record) => ( + + ), + }, + ]; + + const roleColumns: ColumnsType = [ + { title: "角色编码", dataIndex: "roleCode", key: "roleCode", width: 220 }, + { title: "角色名称", dataIndex: "roleName", key: "roleName", width: 180 }, + { + title: "状态", + dataIndex: "enabled", + key: "enabled", + width: 120, + render: (enabled?: boolean) => + enabled ? 启用 : 禁用, + }, + { + title: "描述", + dataIndex: "description", + key: "description", + render: (value?: string) => value || "-", + }, + ]; + + const permissionColumns: ColumnsType = [ + { + title: "权限编码", + dataIndex: "permissionCode", + key: "permissionCode", + width: 260, + }, + { + title: "权限名称", + dataIndex: "permissionName", + key: "permissionName", + width: 200, + }, + { + title: "模块", + dataIndex: "module", + key: "module", + width: 140, + render: (value?: string) => value || "-", + }, + { + title: "动作", + dataIndex: "action", + key: "action", + width: 120, + render: (value?: string) => value || "-", + }, + { + title: "接口", + key: "api", + render: (_, record) => + record.pathPattern ? `${record.method || "ALL"} ${record.pathPattern}` : "-", + }, + ]; + + const handleAssignRoles = async () => { + if (!editingUser) { + return; + } + if (selectedRoleCodes.length === 0) { + message.warning("请至少选择一个角色"); + return; + } + const roleIds = selectedRoleCodes + .map((roleCode) => roleCodeToIdMap.get(roleCode)) + .filter((roleId): roleId is string => Boolean(roleId)); + if (roleIds.length !== selectedRoleCodes.length) { + message.error("角色映射失败,请刷新后重试"); + return; + } + setSubmitting(true); + try { + await assignUserRolesUsingPut(editingUser.id, roleIds); + message.success("角色分配成功"); + setEditingUser(null); + setSelectedRoleCodes([]); + await loadData(); + } catch (error) { + message.error("角色分配失败"); + console.error("角色分配失败:", error); + } finally { + setSubmitting(false); + } + }; + + if (!canShowAnything) { + return ; + } + + return ( + + + + + {canViewRoles && ( + +
+ + )} + {canViewPermissions && ( + +
+ + )} + { + void handleAssignRoles(); + }} + onCancel={() => { + setEditingUser(null); + setSelectedRoleCodes([]); + }} + > + {roles.length === 0 ? ( + 暂无可分配角色 + ) : ( +