Phase 2/3 完成:修复 Shiro javax/jakarta 兼容性,实现 US1 认证模块

修复:
- TokenFilter 改继承 OncePerRequestFilter(jakarta.servlet),
  移除 PathMatchingFilter(javax.servlet)依赖,解决 Lombok 级联失败
- ShiroConfig 用 FilterRegistrationBean 替代 ShiroFilterFactoryBean,
  避免 javax/jakarta Filter 类型不兼容;securityManager 调用
  SecurityUtils.setSecurityManager() 确保 @RequiresRoles AOP 可用
- LabelBackendApplication 排除 ShiroWeb 自动配置(WebAutoConfiguration、
  WebFilterConfiguration、WebMvcAutoConfiguration)
- SysUserMapper @InterceptorIgnore 修正为 mybatis-plus 包路径

新增(Phase 2 尾声):
- SysCompany / SysCompanyMapper
- SysUser / SysUserMapper
- ShiroFilterIntegrationTest(无 Token→401、过期→401、角色不足→403、满足→200)

新增(Phase 3 / US1):
- LoginRequest / LoginResponse / UserInfoResponse DTO
- AuthService(login + logout + me;BCrypt 校验;Redis Hash 存 Token)
- AuthController(POST /api/auth/login、POST /logout、GET /me)
- AuthIntegrationTest(正确密码→token、错误密码→401、退出后→401)
This commit is contained in:
wh
2026-04-09 15:16:49 +08:00
parent b5f35a7414
commit a28fecd16a
16 changed files with 805 additions and 85 deletions

View File

@@ -3,7 +3,19 @@ package com.label;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
/**
* 应用入口。
*
* 排除 Shiro Web 自动配置ShiroWebAutoConfiguration、ShiroWebFilterConfiguration、
* ShiroWebMvcAutoConfiguration避免其依赖的 ShiroFilterjavax.servlet.Filter
* 与 Spring Boot 3.x 的 jakarta.servlet 命名空间冲突。
* 认证/授权逻辑改由 TokenFilterOncePerRequestFilter+ ShiroConfig 手动装配。
*/
@SpringBootApplication(excludeName = {
"org.apache.shiro.spring.config.web.autoconfigure.ShiroWebAutoConfiguration",
"org.apache.shiro.spring.config.web.autoconfigure.ShiroWebFilterConfiguration",
"org.apache.shiro.spring.config.web.autoconfigure.ShiroWebMvcAutoConfiguration"
})
public class LabelBackendApplication {
public static void main(String[] args) {

View File

@@ -2,6 +2,8 @@ package com.label.common.exception;
import com.label.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.AuthorizationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -18,6 +20,17 @@ public class GlobalExceptionHandler {
.body(Result.failure(e.getCode(), e.getMessage()));
}
/**
* 处理 Shiro 权限不足异常(@RequiresRoles / subject.checkRole() 抛出)→ 403
*/
@ExceptionHandler(AuthorizationException.class)
public ResponseEntity<Result<?>> handleAuthorizationException(AuthorizationException e) {
log.warn("权限不足: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(Result.failure("FORBIDDEN", "权限不足"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<?>> handleException(Exception e) {
log.error("系统异常", e);

View File

@@ -2,30 +2,28 @@ package com.label.common.shiro;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.redis.RedisService;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Shiro security configuration.
* Shiro 安全配置。
*
* Filter chain:
* /api/auth/login → anon (no auth required)
* /api/auth/logout → tokenFilter
* /api/** → tokenFilter (all other API endpoints require auth)
* /actuator/** → anon (health check)
* /** → anon (default)
*
* NOTE: spring.mvc.pathmatch.matching-strategy=ant_path_matcher MUST be set
* in application.yml for Shiro to work correctly with Spring Boot 3.
* 设计说明:
* - 使用 Spring 的 FilterRegistrationBean 注册 TokenFilterjakarta.servlet
* 替代 Shiro 的 ShiroFilterFactoryBeanjavax.servlet避免 Shiro 1.x 与
* Spring Boot 3.x 之间的 javax/jakarta 命名空间冲突。
* - URL 路由逻辑内聚于 TokenFilter.shouldNotFilter()
* /api/auth/login → 跳过(公开)
* 非 /api/ 路径 → 跳过(公开)
* /api/** → 强制校验 Bearer Token
* - SecurityUtils.setSecurityManager() 必须在此处调用,
* 以便 @RequiresRoles 等 AOP 注解和 SecurityUtils.getSubject() 可正常工作。
*/
@Configuration
public class ShiroConfig {
@@ -39,6 +37,8 @@ public class ShiroConfig {
public SecurityManager securityManager(UserRealm userRealm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealms(List.of(userRealm));
// 设置全局 SecurityManager使 SecurityUtils.getSubject() 及 AOP 注解可用
SecurityUtils.setSecurityManager(manager);
return manager;
}
@@ -47,25 +47,17 @@ public class ShiroConfig {
return new TokenFilter(redisService, objectMapper);
}
/**
* 将 TokenFilter 注册为 Servlet 过滤器,覆盖所有路径。
* 实际的路径过滤逻辑由 TokenFilter.shouldNotFilter() 控制。
*/
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
TokenFilter tokenFilter) {
ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean();
factory.setSecurityManager(securityManager);
// Register custom filters
Map<String, Filter> filters = new LinkedHashMap<>();
filters.put("tokenFilter", tokenFilter);
factory.setFilters(filters);
// Filter chain definition (ORDER MATTERS - first match wins)
Map<String, String> filterChainDef = new LinkedHashMap<>();
filterChainDef.put("/api/auth/login", "anon");
filterChainDef.put("/actuator/**", "anon");
filterChainDef.put("/api/**", "tokenFilter");
filterChainDef.put("/**", "anon");
factory.setFilterChainDefinitionMap(filterChainDef);
return factory;
public FilterRegistrationBean<TokenFilter> tokenFilterRegistration(TokenFilter tokenFilter) {
FilterRegistrationBean<TokenFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(tokenFilter);
registration.addUrlPatterns("/*");
registration.setOrder(1);
registration.setName("tokenFilter");
return registration;
}
}

View File

@@ -7,83 +7,85 @@ import com.label.common.redis.RedisService;
import com.label.common.result.Result;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.PathMatchingFilter;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.util.ThreadContext;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
/**
* Shiro filter: parses "Authorization: Bearer {uuid}", validates against Redis,
* injects CompanyContext and Shiro subject principals.
* JWT-style Bearer Token 过滤器。
* 继承 Spring 的 OncePerRequestFilterjakarta.servlet避免与 Shiro 1.x
* 的 PathMatchingFilterjavax.servlet产生命名空间冲突。
*
* KEY DESIGN:
* - CompanyContext.clear() MUST be called in finally block to prevent thread pool leakage
* - Token lookup is from Redis Hash token:{uuid} → {userId, role, companyId, username}
* - 401 on missing/invalid token; filter continues for valid token
* 过滤逻辑:
* - 跳过非 /api/ 路径和 /api/auth/login公开端点
* - 解析 "Authorization: Bearer {uuid}",查询 Redis Hash token:{uuid}
* - Token 存在 → 注入 CompanyContext登录 Shiro Subject继续请求链路
* - Token 缺失或过期 → 直接返回 401
* - finally 块中清除 CompanyContext 和 ThreadContext Subject防止线程池串漏
*/
@Slf4j
@RequiredArgsConstructor
public class TokenFilter extends PathMatchingFilter {
public class TokenFilter extends OncePerRequestFilter {
private final RedisService redisService;
private final ObjectMapper objectMapper;
/**
* 公开端点跳过过滤:非 /api/ 前缀路径,以及登录接口本身。
*/
@Override
protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
String authHeader = req.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
writeUnauthorized(resp, "缺少或无效的认证令牌");
return false;
}
String token = authHeader.substring(7).trim();
String tokenKey = RedisKeyManager.tokenKey(token);
Map<Object, Object> tokenData = redisService.hGetAll(tokenKey);
if (tokenData == null || tokenData.isEmpty()) {
writeUnauthorized(resp, "令牌已过期或不存在");
return false;
}
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return !path.startsWith("/api/") || path.equals("/api/auth/login");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
writeUnauthorized(response, "缺少或无效的认证令牌");
return;
}
String token = authHeader.substring(7).trim();
Map<Object, Object> tokenData = redisService.hGetAll(RedisKeyManager.tokenKey(token));
if (tokenData == null || tokenData.isEmpty()) {
writeUnauthorized(response, "令牌已过期或不存在");
return;
}
Long userId = Long.parseLong(tokenData.get("userId").toString());
String role = tokenData.get("role").toString();
Long companyId = Long.parseLong(tokenData.get("companyId").toString());
String username = tokenData.get("username").toString();
// Inject company context (must be cleared in finally)
// 注入多租户上下文finally 中清除,防止线程池串漏)
CompanyContext.set(companyId);
// Bind Shiro subject with token principal
// 创建 TokenPrincipal 并登录 Shiro Subject,使 @RequiresRoles 等注解生效
TokenPrincipal principal = new TokenPrincipal(userId, role, companyId, username, token);
SecurityUtils.getSubject().login(new BearerToken(token, principal));
request.setAttribute("__token_principal__", principal);
return true;
} catch (Exception e) {
filterChain.doFilter(request, response);
} catch (NumberFormatException e) {
log.error("解析 Token 数据失败: {}", e.getMessage());
writeUnauthorized(resp, "令牌数据格式错误");
return false;
}
}
@Override
public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
try {
super.doFilterInternal(request, response, chain);
writeUnauthorized(response, "令牌数据格式错误");
} finally {
// CRITICAL: Always clear ThreadLocal to prevent leakage in thread pool
// 关键:必须清除 ThreadLocal防止线程池复用时数据串漏
CompanyContext.clear();
ThreadContext.unbindSubject();
}
}

View File

@@ -0,0 +1,64 @@
package com.label.module.user.controller;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.user.dto.LoginRequest;
import com.label.module.user.dto.LoginResponse;
import com.label.module.user.dto.UserInfoResponse;
import com.label.module.user.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 认证接口:登录、退出、获取当前用户。
*
* 路由设计:
* - POST /api/auth/login → 匿名TokenFilter.shouldNotFilter 跳过)
* - POST /api/auth/logout → 需要有效 TokenTokenFilter 校验)
* - GET /api/auth/me → 需要有效 TokenTokenFilter 校验)
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 登录接口(匿名,无需 Token
*/
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
return Result.success(authService.login(request));
}
/**
* 退出登录,立即删除 Redis Token。
*/
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
String token = extractToken(request);
authService.logout(token);
return Result.success(null);
}
/**
* 获取当前登录用户信息。
* TokenPrincipal 由 TokenFilter 写入请求属性 "__token_principal__"。
*/
@GetMapping("/me")
public Result<UserInfoResponse> me(HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
return Result.success(authService.me(principal));
}
/** 从 Authorization 头提取 Bearer token 字符串 */
private String extractToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7).trim();
}
return null;
}
}

View File

@@ -0,0 +1,16 @@
package com.label.module.user.dto;
import lombok.Data;
/**
* 登录请求体。
*/
@Data
public class LoginRequest {
/** 公司代码(英文简写),用于确定租户 */
private String companyCode;
/** 登录用户名 */
private String username;
/** 明文密码(传输层应使用 HTTPS 保护) */
private String password;
}

View File

@@ -0,0 +1,22 @@
package com.label.module.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 登录成功响应体。
*/
@Data
@AllArgsConstructor
public class LoginResponse {
/** Bearer TokenUUID v4后续请求放入 Authorization 头 */
private String token;
/** 用户主键 */
private Long userId;
/** 登录用户名 */
private String username;
/** 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
private String role;
/** Token 有效期(秒) */
private Long expiresIn;
}

View File

@@ -0,0 +1,18 @@
package com.label.module.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* GET /api/auth/me 响应体,包含当前登录用户的详细信息。
*/
@Data
@AllArgsConstructor
public class UserInfoResponse {
private Long id;
private String username;
private String realName;
private String role;
private Long companyId;
private String companyName;
}

View File

@@ -0,0 +1,34 @@
package com.label.module.user.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 租户公司实体,对应 sys_company 表。
* status 取值ACTIVE / DISABLED
*/
@Data
@TableName("sys_company")
public class SysCompany {
/** 公司主键,自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 公司全称,全局唯一 */
private String companyName;
/** 公司代码(英文简写),全局唯一 */
private String companyCode;
/** 状态ACTIVE / DISABLED */
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,49 @@
package com.label.module.user.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统用户实体,对应 sys_user 表。
* role 取值UPLOADER / ANNOTATOR / REVIEWER / ADMIN
* status 取值ACTIVE / DISABLED
*/
@Data
@TableName("sys_user")
public class SysUser {
/** 用户主键,自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属公司 ID多租户键 */
private Long companyId;
/** 登录用户名(同公司内唯一) */
private String username;
/**
* BCrypt 哈希密码strength ≥ 10
* 序列化时排除,防止密码哈希泄漏到 API 响应。
*/
@JsonIgnore
private String passwordHash;
/** 真实姓名 */
private String realName;
/** 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
private String role;
/** 状态ACTIVE / DISABLED */
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,23 @@
package com.label.module.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysCompany;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
/**
* sys_company 表 Mapper。
* 继承 BaseMapper 获得标准 CRUD自定义方法用注解 SQL。
*/
@Mapper
public interface SysCompanyMapper extends BaseMapper<SysCompany> {
/**
* 按公司代码查询公司忽略多租户过滤sys_company 无 company_id 字段)。
*
* @param companyCode 公司代码
* @return 公司实体,不存在则返回 null
*/
@Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}")
SysCompany selectByCompanyCode(String companyCode);
}

View File

@@ -0,0 +1,34 @@
package com.label.module.user.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* sys_user 表 Mapper。
* 继承 BaseMapper 获得标准 CRUD自定义登录查询方法绕过多租户过滤器
* 由调用方显式传入 companyId。
*/
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 按公司 ID + 用户名查询用户(登录场景使用)。
* <p>
* 使用 @InterceptorIgnore 绕过 TenantLineInnerInterceptor
* 由参数 companyId 显式限定租户,防止登录时 CompanyContext 尚未注入
* 导致查询条件变为 {@code company_id = NULL}。
* </p>
*
* @param companyId 公司 ID
* @param username 用户名
* @return 用户实体(含 passwordHash不存在则返回 null
*/
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'")
SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId,
@Param("username") String username);
}

View File

@@ -0,0 +1,127 @@
package com.label.module.user.service;
import com.label.common.exception.BusinessException;
import com.label.common.redis.RedisKeyManager;
import com.label.common.redis.RedisService;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.user.dto.LoginRequest;
import com.label.module.user.dto.LoginResponse;
import com.label.module.user.dto.UserInfoResponse;
import com.label.module.user.entity.SysCompany;
import com.label.module.user.entity.SysUser;
import com.label.module.user.mapper.SysCompanyMapper;
import com.label.module.user.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 认证服务:登录、退出、查询当前用户信息。
*
* Token 生命周期:
* - 登录成功 → UUID v4 → Redis Hash token:{uuid} → TTL = token.ttl-seconds
* - 退出登录 → 直接 DEL token:{uuid}(立即失效)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final SysCompanyMapper companyMapper;
private final SysUserMapper userMapper;
private final RedisService redisService;
/** BCryptPasswordEncoder 线程安全,可复用 */
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
@Value("${token.ttl-seconds:7200}")
private long tokenTtlSeconds;
/**
* 用户登录。
*
* @param request 包含 companyCode / username / password
* @return LoginResponse含 token、userId、role、expiresIn
* @throws BusinessException USER_NOT_FOUND(401) 凭证错误
* @throws BusinessException USER_DISABLED(403) 账号已禁用
*/
public LoginResponse login(LoginRequest request) {
// 1. 查公司绕过多租户过滤器sys_company 无 company_id 字段)
SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode());
if (company == null || !"ACTIVE".equals(company.getStatus())) {
// 公司不存在或禁用,统一报 USER_NOT_FOUND 防止信息泄漏
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 2. 查用户(显式传入 companyId绕过多租户拦截器
SysUser user = userMapper.selectByCompanyAndUsername(company.getId(), request.getUsername());
if (user == null) {
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 3. 账号禁用检查(先于密码校验,防止暴力破解已知用户状态)
if (!"ACTIVE".equals(user.getStatus())) {
throw new BusinessException("USER_DISABLED", "账号已禁用,请联系管理员", HttpStatus.FORBIDDEN);
}
// 4. BCrypt 密码校验
if (!PASSWORD_ENCODER.matches(request.getPassword(), user.getPasswordHash())) {
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 5. 生成 UUID v4 Token写入 Redis Hash
String token = UUID.randomUUID().toString();
Map<String, String> tokenData = new HashMap<>();
tokenData.put("userId", user.getId().toString());
tokenData.put("role", user.getRole());
tokenData.put("companyId", user.getCompanyId().toString());
tokenData.put("username", user.getUsername());
redisService.hSetAll(RedisKeyManager.tokenKey(token), tokenData, tokenTtlSeconds);
log.debug("用户登录成功: companyCode={}, username={}", request.getCompanyCode(), request.getUsername());
return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole(), tokenTtlSeconds);
}
/**
* 退出登录,立即删除 Redis TokenToken 立即失效)。
*
* @param token 来自 Authorization 头的 Bearer token
*/
public void logout(String token) {
if (token != null && !token.isBlank()) {
redisService.delete(RedisKeyManager.tokenKey(token));
log.debug("用户退出Token 已删除: {}", token);
}
}
/**
* 获取当前登录用户详情(含 realName、companyName
*
* @param principal TokenFilter 注入的当前用户主体
* @return 用户信息响应体
*/
public UserInfoResponse me(TokenPrincipal principal) {
// 从 DB 获取 realNameToken 中未存储)
SysUser user = userMapper.selectById(principal.getUserId());
SysCompany company = companyMapper.selectById(principal.getCompanyId());
String realName = (user != null) ? user.getRealName() : principal.getUsername();
String companyName = (company != null) ? company.getCompanyName() : "";
return new UserInfoResponse(
principal.getUserId(),
principal.getUsername(),
realName,
principal.getRole(),
principal.getCompanyId(),
companyName
);
}
}

View File

@@ -53,13 +53,8 @@ ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000}
timeout: 30000 # milliseconds
shiro:
loginUrl: /api/auth/login
successUrl: /
unauthorizedUrl: /api/auth/unauthorized
sessionManager:
sessionIdCookieEnabled: false # REST API不使用基于 Cookie 的会话
sessionIdUrlRewritingEnabled: false
token:
ttl-seconds: 7200 # Token 默认有效期(秒),与 sys_config token_ttl_seconds 保持一致
logging:
level: