去掉shiro框架

This commit is contained in:
wh
2026-04-14 16:33:34 +08:00
parent 158873d5ae
commit a30b648d30
44 changed files with 868 additions and 859 deletions

View File

@@ -1,4 +1,3 @@
package com.label;
import org.springframework.boot.SpringApplication;
@@ -6,18 +5,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 应用入口。
*
* 排除 Shiro Web 自动配置ShiroWebAutoConfiguration、ShiroWebFilterConfiguration、
* ShiroWebMvcAutoConfiguration避免其依赖的 ShiroFilterjavax.servlet.Filter
* Spring Boot 3. 的 jakarta.servlet 命名空间冲突。 认证/ 授权逻辑改由
* TokenFilterOncePerRequestFilter+ ShiroConfig 手动装配。
*/
// (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" })
@SpringBootApplication
public class LabelBackendApplication {

View File

@@ -0,0 +1,11 @@
package com.label.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireAuth {
}

View File

@@ -0,0 +1,13 @@
package com.label.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@RequireAuth
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String value();
}

View File

@@ -1,12 +1,10 @@
package com.label.common.shiro;
package com.label.common.auth;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.io.Serializable;
/**
* Shiro principal carrying the authenticated user's session data.
*/
@Getter
@AllArgsConstructor
public class TokenPrincipal implements Serializable {

View File

@@ -0,0 +1,23 @@
package com.label.common.context;
import com.label.common.auth.TokenPrincipal;
public final class UserContext {
private static final ThreadLocal<TokenPrincipal> PRINCIPAL = new ThreadLocal<>();
public static void set(TokenPrincipal principal) {
PRINCIPAL.set(principal);
}
public static TokenPrincipal get() {
return PRINCIPAL.get();
}
public static void clear() {
PRINCIPAL.remove();
}
private UserContext() {
throw new UnsupportedOperationException("Utility class");
}
}

View File

@@ -2,8 +2,6 @@ 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;
@@ -16,26 +14,15 @@ public class GlobalExceptionHandler {
public ResponseEntity<Result<?>> handleBusinessException(BusinessException e) {
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return ResponseEntity
.status(e.getHttpStatus())
.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", "权限不足"));
.status(e.getHttpStatus())
.body(Result.failure(e.getCode(), e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<?>> handleException(Exception e) {
log.error("系统异常", e);
return ResponseEntity
.internalServerError()
.body(Result.failure("INTERNAL_ERROR", "系统内部错误"));
.internalServerError()
.body(Result.failure("INTERNAL_ERROR", "系统内部错误"));
}
}

View File

@@ -1,26 +0,0 @@
package com.label.common.shiro;
import org.apache.shiro.authc.AuthenticationToken;
/**
* Shiro AuthenticationToken wrapper for Bearer token strings.
*/
public class BearerToken implements AuthenticationToken {
private final String token;
private final TokenPrincipal principal;
public BearerToken(String token, TokenPrincipal principal) {
this.token = token;
this.principal = principal;
}
@Override
public Object getPrincipal() {
return principal;
}
@Override
public Object getCredentials() {
return token;
}
}

View File

@@ -1,139 +0,0 @@
package com.label.common.shiro;
import java.io.IOException;
import java.util.Map;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.util.ThreadContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.context.CompanyContext;
import com.label.common.result.Result;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* JWT-style Bearer Token 过滤器。
* 继承 Spring 的 OncePerRequestFilterjakarta.servlet避免与 Shiro 1.x
* 的 PathMatchingFilterjavax.servlet产生命名空间冲突。
*
* 过滤逻辑:
* - 跳过非 /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 OncePerRequestFilter {
private final RedisService redisService;
private final ObjectMapper objectMapper;
@Value("${shiro.auth.enabled:true}")
private boolean authEnabled;
@Value("${shiro.auth.mock-company-id:1}")
private Long mockCompanyId;
@Value("${shiro.auth.mock-user-id:1}")
private Long mockUserId;
@Value("${shiro.auth.mock-role:ADMIN}")
private String mockRole;
@Value("${shiro.auth.mock-username:mock}")
private String mockUsername;
@Value("${token.ttl-seconds:7200}")
private long tokenTtlSeconds;
/**
* 公开端点跳过过滤:非 /api/ 前缀路径,以及登录接口本身。
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return !path.startsWith("/api/")
|| path.equals("/api/auth/login")
|| path.equals("/api/video/callback")
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs"); // AI 服务内部回调,不走用户 Token 认证
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
if (!authEnabled) {
TokenPrincipal principal = new TokenPrincipal(
mockUserId, mockRole, mockCompanyId, mockUsername, "mock-token");
CompanyContext.set(mockCompanyId);
SecurityUtils.getSubject().login(new BearerToken("mock-token", principal));
request.setAttribute("__token_principal__", principal);
filterChain.doFilter(request, response);
return;
}
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.toLowerCase().startsWith("bearer ")) {
writeUnauthorized(response, "缺少或无效的认证令牌");
return;
}
String[] parts = authHeader.split("\\s+");
if (parts.length != 2 || !"Bearer".equalsIgnoreCase(parts[0])) {
writeUnauthorized(response, "无效的认证格式");
return;
}
String token = parts[1];
// String token = authHeader.substring(7).trim();
Map<Object, Object> tokenData = redisService.hGetAll(RedisUtil.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();
// 注入多租户上下文finally 中清除,防止线程池串漏)
CompanyContext.set(companyId);
// 创建 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);
redisService.expire(RedisUtil.tokenKey(token), tokenTtlSeconds);
redisService.expire(RedisUtil.userSessionsKey(userId), tokenTtlSeconds);
filterChain.doFilter(request, response);
} catch (Exception e) {
log.error("解析 Token 数据失败: {}", e.getMessage());
writeUnauthorized(response, "令牌数据格式错误");
} finally {
// 关键:必须清除 ThreadLocal防止线程池复用时数据串漏
CompanyContext.clear();
ThreadContext.unbindSubject();
}
}
private void writeUnauthorized(HttpServletResponse resp, String message) throws IOException {
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resp.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
resp.getWriter().write(objectMapper.writeValueAsString(Result.failure("UNAUTHORIZED", message)));
}
}

View File

@@ -1,88 +0,0 @@
package com.label.common.shiro;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
/**
* Shiro Realm for role-based authorization using token-based authentication.
*
* Role hierarchy (addInheritedRoles):
* ADMIN ⊃ REVIEWER ⊃ ANNOTATOR ⊃ UPLOADER
*
* Permission lookup order:
* 1. Redis user:perm:{userId} (TTL 5 min)
* 2. If miss: use role from TokenPrincipal
*/
@Slf4j
@RequiredArgsConstructor
public class UserRealm extends AuthorizingRealm {
private static final long PERM_CACHE_TTL = 300L; // 5 minutes
private final RedisService redisService;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof BearerToken;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// Token validation is done in TokenFilter; this realm only handles authorization
// For authentication, we trust the token that was validated by TokenFilter
return new SimpleAuthenticationInfo(token.getPrincipal(), token.getCredentials(), getName());
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
TokenPrincipal principal = (TokenPrincipal) principals.getPrimaryPrincipal();
if (principal == null) {
return new SimpleAuthorizationInfo();
}
String role = getRoleFromCacheOrPrincipal(principal);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.addRole(role);
addInheritedRoles(info, role);
return info;
}
private String getRoleFromCacheOrPrincipal(TokenPrincipal principal) {
String permKey = RedisUtil.userPermKey(principal.getUserId());
String cachedRole = redisService.get(permKey);
if (cachedRole != null && !cachedRole.isEmpty()) {
return cachedRole;
}
// Cache miss: use role from token, then refresh cache
String role = principal.getRole();
redisService.set(permKey, role, PERM_CACHE_TTL);
return role;
}
/**
* ADMIN inherits all roles: ADMIN ⊃ REVIEWER ⊃ ANNOTATOR ⊃ UPLOADER
*/
private void addInheritedRoles(SimpleAuthorizationInfo info, String role) {
switch (role) {
case "ADMIN":
info.addRole("REVIEWER");
// fall through
case "REVIEWER":
info.addRole("ANNOTATOR");
// fall through
case "ANNOTATOR":
info.addRole("UPLOADER");
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,20 @@
package com.label.config;
import com.label.interceptor.AuthInterceptor;
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 AuthConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**");
}
}

View File

@@ -1,66 +0,0 @@
package com.label.config;
import java.util.List;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.SecurityManager;
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 com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.shiro.TokenFilter;
import com.label.common.shiro.UserRealm;
import com.label.service.RedisService;
/**
* Shiro 安全配置。
*
* 设计说明:
* - 使用 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 {
@Bean
public UserRealm userRealm(RedisService redisService) {
return new UserRealm(redisService);
}
@Bean
public SecurityManager securityManager(UserRealm userRealm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealms(List.of(userRealm));
// 设置全局 SecurityManager使 SecurityUtils.getSubject() 及 AOP 注解可用
SecurityUtils.setSecurityManager(manager);
return manager;
}
@Bean
public TokenFilter tokenFilter(RedisService redisService, ObjectMapper objectMapper) {
return new TokenFilter(redisService, objectMapper);
}
/**
* 将 TokenFilter 注册为 Servlet 过滤器,覆盖所有路径。
* 实际的路径过滤逻辑由 TokenFilter.shouldNotFilter() 控制。
*/
@Bean
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

@@ -1,7 +1,8 @@
package com.label.controller;
import com.label.annotation.RequireAuth;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
@@ -16,9 +17,9 @@ import org.springframework.web.bind.annotation.*;
* 认证接口:登录、退出、获取当前用户。
*
* 路由设计:
* - POST /api/auth/login → 匿名(TokenFilter.shouldNotFilter 跳过)
* - POST /api/auth/logout → 需要有效 TokenTokenFilter 校验)
* - GET /api/auth/me → 需要有效 TokenTokenFilter 校验)
* - POST /api/auth/login → 匿名(AuthInterceptor 跳过)
* - POST /api/auth/logout → 需要有效 TokenAuthInterceptor 校验)
* - GET /api/auth/me → 需要有效 TokenAuthInterceptor 校验)
*/
@Tag(name = "认证管理", description = "登录、退出和当前用户信息")
@RestController
@@ -42,6 +43,7 @@ public class AuthController {
*/
@Operation(summary = "退出登录并立即失效当前 Token")
@PostMapping("/logout")
@RequireAuth
public Result<Void> logout(HttpServletRequest request) {
String token = extractToken(request);
authService.logout(token);
@@ -50,10 +52,11 @@ public class AuthController {
/**
* 获取当前登录用户信息。
* TokenPrincipal 由 TokenFilter 写入请求属性 "__token_principal__"。
* TokenPrincipal 由 AuthInterceptor 写入请求属性 "__token_principal__"。
*/
@Operation(summary = "获取当前登录用户信息")
@GetMapping("/me")
@RequireAuth
public Result<UserInfoResponse> me(HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
return Result.success(authService.me(principal));

View File

@@ -0,0 +1,73 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.entity.SysCompany;
import com.label.service.CompanyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Tag(name = "公司管理", description = "租户公司增删改查")
@RestController
@RequestMapping("/api/companies")
@RequiredArgsConstructor
public class CompanyController {
private final CompanyService companyService;
@Operation(summary = "分页查询公司列表")
@GetMapping
@RequireRole("ADMIN")
public Result<PageResult<SysCompany>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String status) {
return Result.success(companyService.list(page, pageSize, status));
}
@Operation(summary = "创建公司")
@PostMapping
@RequireRole("ADMIN")
@ResponseStatus(HttpStatus.CREATED)
public Result<SysCompany> create(@RequestBody Map<String, String> body) {
return Result.success(companyService.create(body.get("companyName"), body.get("companyCode")));
}
@Operation(summary = "更新公司信息")
@PutMapping("/{id}")
@RequireRole("ADMIN")
public Result<SysCompany> update(@PathVariable Long id, @RequestBody Map<String, String> body) {
return Result.success(companyService.update(id, body.get("companyName"), body.get("companyCode")));
}
@Operation(summary = "更新公司状态")
@PutMapping("/{id}/status")
@RequireRole("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
companyService.updateStatus(id, body.get("status"));
return Result.success(null);
}
@Operation(summary = "删除公司")
@DeleteMapping("/{id}")
@RequireRole("ADMIN")
public Result<Void> delete(@PathVariable Long id) {
companyService.delete(id);
return Result.success(null);
}
}

View File

@@ -1,8 +1,9 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.TrainingDataset;
import com.label.entity.ExportBatch;
import com.label.service.ExportService;
@@ -11,7 +12,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@@ -32,7 +32,7 @@ public class ExportController {
/** GET /api/training/samples — 分页查询已审批可导出样本 */
@Operation(summary = "分页查询可导出训练样本")
@GetMapping("/api/training/samples")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<TrainingDataset>> listSamples(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -45,7 +45,7 @@ public class ExportController {
/** POST /api/export/batch — 创建导出批次 */
@Operation(summary = "创建导出批次")
@PostMapping("/api/export/batch")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
@ResponseStatus(HttpStatus.CREATED)
public Result<ExportBatch> createBatch(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
@@ -60,7 +60,7 @@ public class ExportController {
/** POST /api/export/{batchId}/finetune — 提交微调任务 */
@Operation(summary = "提交微调任务")
@PostMapping("/api/export/{batchId}/finetune")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Map<String, Object>> triggerFinetune(@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(finetuneService.trigger(batchId, principal(request)));
@@ -69,7 +69,7 @@ public class ExportController {
/** GET /api/export/{batchId}/status — 查询微调状态 */
@Operation(summary = "查询微调状态")
@GetMapping("/api/export/{batchId}/status")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Map<String, Object>> getFinetuneStatus(@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(finetuneService.getStatus(batchId, principal(request)));
@@ -78,7 +78,7 @@ public class ExportController {
/** GET /api/export/list — 分页查询导出批次列表 */
@Operation(summary = "分页查询导出批次")
@GetMapping("/api/export/list")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<ExportBatch>> listBatches(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,

View File

@@ -1,13 +1,13 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.service.ExtractionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@@ -26,7 +26,7 @@ public class ExtractionController {
/** GET /api/extraction/{taskId} — 获取当前标注结果 */
@Operation(summary = "获取提取标注结果")
@GetMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(extractionService.getResult(taskId, principal(request)));
@@ -35,7 +35,7 @@ public class ExtractionController {
/** PUT /api/extraction/{taskId} — 更新标注结果(整体覆盖) */
@Operation(summary = "更新提取标注结果")
@PutMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> updateResult(@PathVariable Long taskId,
@RequestBody String resultJson,
HttpServletRequest request) {
@@ -46,7 +46,7 @@ public class ExtractionController {
/** POST /api/extraction/{taskId}/submit — 提交标注结果 */
@Operation(summary = "提交提取标注结果")
@PostMapping("/{taskId}/submit")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> submit(@PathVariable Long taskId,
HttpServletRequest request) {
extractionService.submit(taskId, principal(request));
@@ -56,7 +56,7 @@ public class ExtractionController {
/** POST /api/extraction/{taskId}/approve — 审批通过REVIEWER */
@Operation(summary = "审批通过提取结果")
@PostMapping("/{taskId}/approve")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> approve(@PathVariable Long taskId,
HttpServletRequest request) {
extractionService.approve(taskId, principal(request));
@@ -66,7 +66,7 @@ public class ExtractionController {
/** POST /api/extraction/{taskId}/reject — 驳回REVIEWER */
@Operation(summary = "驳回提取结果")
@PostMapping("/{taskId}/reject")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,13 +1,13 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.service.QaService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@@ -26,7 +26,7 @@ public class QaController {
/** GET /api/qa/{taskId} — 获取候选问答对 */
@Operation(summary = "获取候选问答对")
@GetMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(qaService.getResult(taskId, principal(request)));
@@ -35,7 +35,7 @@ public class QaController {
/** PUT /api/qa/{taskId} — 整体覆盖问答对 */
@Operation(summary = "更新候选问答对")
@PutMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> updateResult(@PathVariable Long taskId,
@RequestBody String body,
HttpServletRequest request) {
@@ -46,7 +46,7 @@ public class QaController {
/** POST /api/qa/{taskId}/submit — 提交问答对 */
@Operation(summary = "提交问答对")
@PostMapping("/{taskId}/submit")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> submit(@PathVariable Long taskId,
HttpServletRequest request) {
qaService.submit(taskId, principal(request));
@@ -56,7 +56,7 @@ public class QaController {
/** POST /api/qa/{taskId}/approve — 审批通过REVIEWER */
@Operation(summary = "审批通过问答对")
@PostMapping("/{taskId}/approve")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> approve(@PathVariable Long taskId,
HttpServletRequest request) {
qaService.approve(taskId, principal(request));
@@ -66,7 +66,7 @@ public class QaController {
/** POST /api/qa/{taskId}/reject — 驳回REVIEWER */
@Operation(summary = "驳回答案对")
@PostMapping("/{taskId}/reject")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,15 +1,15 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.SourceResponse;
import com.label.service.SourceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -35,7 +35,7 @@ public class SourceController {
*/
@Operation(summary = "上传原始资料", description = "dataType: text,image, video")
@PostMapping("/upload")
@RequiresRoles("UPLOADER")
@RequireRole("UPLOADER")
@ResponseStatus(HttpStatus.CREATED)
public Result<SourceResponse> upload(
@RequestParam("file") MultipartFile file,
@@ -51,7 +51,7 @@ public class SourceController {
*/
@Operation(summary = "分页查询资料列表")
@GetMapping("/list")
@RequiresRoles("UPLOADER")
@RequireRole("UPLOADER")
public Result<PageResult<SourceResponse>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -67,7 +67,7 @@ public class SourceController {
*/
@Operation(summary = "查询资料详情")
@GetMapping("/{id}")
@RequiresRoles("UPLOADER")
@RequireRole("UPLOADER")
public Result<SourceResponse> findById(@PathVariable Long id) {
return Result.success(sourceService.findById(id));
}
@@ -78,7 +78,7 @@ public class SourceController {
*/
@Operation(summary = "删除资料")
@DeleteMapping("/{id}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
sourceService.delete(id, principal.getCompanyId());

View File

@@ -1,14 +1,14 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.SysConfig;
import com.label.service.SysConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -36,7 +36,7 @@ public class SysConfigController {
*/
@Operation(summary = "查询合并后的系统配置")
@GetMapping("/api/config")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<List<Map<String, Object>>> listConfig(HttpServletRequest request) {
TokenPrincipal principal = principal(request);
return Result.success(sysConfigService.list(principal.getCompanyId()));
@@ -49,7 +49,7 @@ public class SysConfigController {
*/
@Operation(summary = "更新或创建公司专属配置")
@PutMapping("/api/config/{key}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<SysConfig> updateConfig(@PathVariable String key,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,8 +1,9 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.TaskResponse;
import com.label.service.TaskClaimService;
import com.label.service.TaskService;
@@ -10,7 +11,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@@ -30,7 +30,7 @@ public class TaskController {
/** GET /api/tasks/pool — 查询可领取任务池(角色感知) */
@Operation(summary = "查询可领取任务池")
@GetMapping("/pool")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<PageResult<TaskResponse>> getPool(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -41,7 +41,7 @@ public class TaskController {
/** GET /api/tasks/mine — 查询我的任务 */
@Operation(summary = "查询我的任务")
@GetMapping("/mine")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<PageResult<TaskResponse>> getMine(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -53,7 +53,7 @@ public class TaskController {
/** GET /api/tasks/pending-review — 待审批队列REVIEWER 专属) */
@Operation(summary = "查询待审批任务")
@GetMapping("/pending-review")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<PageResult<TaskResponse>> getPendingReview(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -64,7 +64,7 @@ public class TaskController {
/** GET /api/tasks — 查询全部任务ADMIN */
@Operation(summary = "管理员查询全部任务")
@GetMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<TaskResponse>> getAll(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -76,7 +76,7 @@ public class TaskController {
/** POST /api/tasks — 创建任务ADMIN */
@Operation(summary = "管理员创建任务")
@PostMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<TaskResponse> createTask(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
Long sourceId = Long.parseLong(body.get("sourceId").toString());
@@ -89,7 +89,7 @@ public class TaskController {
/** GET /api/tasks/{id} — 查询任务详情 */
@Operation(summary = "查询任务详情")
@GetMapping("/{id}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<TaskResponse> getById(@PathVariable Long id) {
return Result.success(taskService.toPublicResponse(taskService.getById(id)));
}
@@ -97,7 +97,7 @@ public class TaskController {
/** POST /api/tasks/{id}/claim — 领取任务 */
@Operation(summary = "领取任务")
@PostMapping("/{id}/claim")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> claim(@PathVariable Long id, HttpServletRequest request) {
taskClaimService.claim(id, principal(request));
return Result.success(null);
@@ -106,7 +106,7 @@ public class TaskController {
/** POST /api/tasks/{id}/unclaim — 放弃任务 */
@Operation(summary = "放弃任务")
@PostMapping("/{id}/unclaim")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> unclaim(@PathVariable Long id, HttpServletRequest request) {
taskClaimService.unclaim(id, principal(request));
return Result.success(null);
@@ -115,7 +115,7 @@ public class TaskController {
/** POST /api/tasks/{id}/reclaim — 重领被驳回的任务 */
@Operation(summary = "重领被驳回的任务")
@PostMapping("/{id}/reclaim")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> reclaim(@PathVariable Long id, HttpServletRequest request) {
taskClaimService.reclaim(id, principal(request));
return Result.success(null);
@@ -124,7 +124,7 @@ public class TaskController {
/** PUT /api/tasks/{id}/reassign — ADMIN 强制指派 */
@Operation(summary = "管理员强制指派任务")
@PutMapping("/{id}/reassign")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> reassign(@PathVariable Long id,
@RequestBody Map<String, Object> body,
HttpServletRequest request) {

View File

@@ -2,7 +2,6 @@ package com.label.controller;
import java.util.Map;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -12,9 +11,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.SysUser;
import com.label.service.UserService;
@@ -37,7 +37,7 @@ public class UserController {
/** GET /api/users — 分页查询用户列表 */
@Operation(summary = "分页查询用户列表")
@GetMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<SysUser>> listUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -48,7 +48,7 @@ public class UserController {
/** POST /api/users — 创建用户 */
@Operation(summary = "创建用户")
@PostMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<SysUser> createUser(@RequestBody Map<String, String> body,
HttpServletRequest request) {
return Result.success(userService.createUser(
@@ -62,7 +62,7 @@ public class UserController {
/** PUT /api/users/{id} — 更新用户基本信息 */
@Operation(summary = "更新用户基本信息")
@PutMapping("/{id}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<SysUser> updateUser(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
@@ -76,7 +76,7 @@ public class UserController {
/** PUT /api/users/{id}/status — 变更用户状态 */
@Operation(summary = "变更用户状态", description = "statusACTIVE、DISABLED")
@PutMapping("/{id}/status")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
@@ -87,7 +87,7 @@ public class UserController {
/** PUT /api/users/{id}/role — 变更用户角色 */
@Operation(summary = "变更用户角色", description = "roleADMIN、UPLOADER、VIEWER")
@PutMapping("/{id}/role")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> updateRole(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,7 +1,8 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.VideoProcessJob;
import com.label.service.VideoProcessService;
import io.swagger.v3.oas.annotations.Operation;
@@ -9,7 +10,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
@@ -21,7 +21,7 @@ import java.util.Map;
* POST /api/video/process — 触发视频处理ADMIN
* GET /api/video/jobs/{jobId} — 查询任务状态ADMIN
* POST /api/video/jobs/{jobId}/reset — 重置失败任务ADMIN
* POST /api/video/callback — AI 回调接口(无需认证,已在 TokenFilter 中排除)
* POST /api/video/callback — AI 回调接口(无需认证,已在 AuthInterceptor 中排除)
*/
@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调")
@Slf4j
@@ -37,7 +37,7 @@ public class VideoController {
/** POST /api/video/process — 触发视频处理任务 */
@Operation(summary = "触发视频处理任务")
@PostMapping("/api/video/process")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<VideoProcessJob> createJob(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
Object sourceIdVal = body.get("sourceId");
@@ -57,7 +57,7 @@ public class VideoController {
/** GET /api/video/jobs/{jobId} — 查询视频处理任务 */
@Operation(summary = "查询视频处理任务状态")
@GetMapping("/api/video/jobs/{jobId}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<VideoProcessJob> getJob(@PathVariable Long jobId,
HttpServletRequest request) {
return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId()));
@@ -66,7 +66,7 @@ public class VideoController {
/** POST /api/video/jobs/{jobId}/reset — 管理员重置失败任务 */
@Operation(summary = "重置失败的视频处理任务")
@PostMapping("/api/video/jobs/{jobId}/reset")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<VideoProcessJob> resetJob(@PathVariable Long jobId,
HttpServletRequest request) {
return Result.success(videoProcessService.reset(jobId, principal(request).getCompanyId()));
@@ -75,7 +75,7 @@ public class VideoController {
/**
* POST /api/video/callback — AI 服务回调(无需 Bearer Token
*
* 此端点已在 TokenFilter.shouldNotFilter() 中排除认证,
* 此端点已在 AuthInterceptor 中排除认证,
* 由 AI 服务直接调用,携带 jobId、status、outputPath 等参数。
*
* Body 示例:

View File

@@ -0,0 +1,182 @@
package com.label.interceptor;
import java.io.IOException;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.context.CompanyContext;
import com.label.common.context.UserContext;
import com.label.common.result.Result;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final RedisService redisService;
private final ObjectMapper objectMapper;
@Value("${auth.enabled:true}")
private boolean authEnabled;
@Value("${auth.mock-company-id:1}")
private Long mockCompanyId;
@Value("${auth.mock-user-id:1}")
private Long mockUserId;
@Value("${auth.mock-role:ADMIN}")
private String mockRole;
@Value("${auth.mock-username:mock}")
private String mockUsername;
@Value("${token.ttl-seconds:7200}")
private long tokenTtlSeconds;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String path = requestPath(request);
if (isPublicPath(path)) {
return true;
}
TokenPrincipal principal = authEnabled
? resolvePrincipal(request, response)
: new TokenPrincipal(mockUserId, mockRole, mockCompanyId, mockUsername, "mock-token");
if (principal == null) {
return false;
}
bindPrincipal(request, principal);
RequireRole requiredRole = requiredRole(handler);
if (requiredRole != null && !hasRole(principal.getRole(), requiredRole.value())) {
writeFailure(response, HttpServletResponse.SC_FORBIDDEN, "FORBIDDEN", "权限不足");
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear();
CompanyContext.clear();
}
private TokenPrincipal resolvePrincipal(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.toLowerCase().startsWith("bearer ")) {
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "缺少或无效的认证令牌");
return null;
}
String[] parts = authHeader.split("\\s+");
if (parts.length != 2 || !"Bearer".equalsIgnoreCase(parts[0])) {
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "无效的认证格式");
return null;
}
String token = parts[1];
Map<Object, Object> tokenData = redisService.hGetAll(RedisUtil.tokenKey(token));
if (tokenData == null || tokenData.isEmpty()) {
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "令牌已过期或不存在");
return null;
}
try {
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();
redisService.expire(RedisUtil.tokenKey(token), tokenTtlSeconds);
redisService.expire(RedisUtil.userSessionsKey(userId), tokenTtlSeconds);
return new TokenPrincipal(userId, role, companyId, username, token);
} catch (Exception e) {
log.warn("解析 Token 数据失败: {}", e.getMessage());
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "令牌数据格式错误");
return null;
}
}
private void bindPrincipal(HttpServletRequest request, TokenPrincipal principal) {
CompanyContext.set(principal.getCompanyId());
UserContext.set(principal);
request.setAttribute("__token_principal__", principal);
}
private RequireRole requiredRole(Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return null;
}
RequireRole methodRole = AnnotatedElementUtils.findMergedAnnotation(
handlerMethod.getMethod(), RequireRole.class);
if (methodRole != null) {
return methodRole;
}
return AnnotatedElementUtils.findMergedAnnotation(
handlerMethod.getBeanType(), RequireRole.class);
}
private boolean hasRole(String actualRole, String requiredRole) {
return roleLevel(actualRole) >= roleLevel(requiredRole);
}
private int roleLevel(String role) {
return switch (role) {
case "ADMIN" -> 4;
case "REVIEWER" -> 3;
case "ANNOTATOR" -> 2;
case "UPLOADER" -> 1;
default -> 0;
};
}
private boolean isPublicPath(String path) {
return !path.startsWith("/api/")
|| path.equals("/api/auth/login")
|| path.equals("/api/video/callback")
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs");
}
private String requestPath(HttpServletRequest request) {
String path = request.getServletPath();
if (path == null || path.isBlank()) {
path = request.getRequestURI();
}
return path != null ? path : "";
}
private void writeFailure(HttpServletResponse response, int status, String code, String message)
throws IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(Result.failure(code, message)));
}
}

View File

@@ -1,18 +1,9 @@
package com.label.listener;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.context.CompanyContext;
import com.label.entity.TrainingDataset;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.service.TaskClaimService;
import com.label.service.TaskService;
import com.label.event.ExtractionApprovedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
@@ -20,9 +11,18 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.context.CompanyContext;
import com.label.entity.SourceData;
import com.label.entity.TrainingDataset;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.SourceDataMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.service.TaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 提取审批通过后的异步处理器。
@@ -89,7 +89,8 @@ public class ExtractionApprovedEventListener {
? aiServiceClient.genImageQa(req)
: aiServiceClient.genTextQa(req);
qaPairs = response != null && response.getQaPairs() != null
? response.getQaPairs() : Collections.emptyList();
? response.getQaPairs()
: Collections.emptyList();
} catch (Exception e) {
log.warn("AI 问答生成失败taskId={}{},将使用空问答对", event.getTaskId(), e.getMessage());
qaPairs = Collections.emptyList();

View File

@@ -31,4 +31,8 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
@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);
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT COUNT(1) FROM sys_user WHERE company_id = #{companyId}")
Long countByCompanyId(@Param("companyId") Long companyId);
}

View File

@@ -1,7 +1,7 @@
package com.label.service;
import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
@@ -117,7 +117,7 @@ public class AuthService {
/**
* 获取当前登录用户详情(含 realName、companyName
*
* @param principal TokenFilter 注入的当前用户主体
* @param principal AuthInterceptor 注入的当前用户主体
* @return 用户信息响应体
*/
public UserInfoResponse me(TokenPrincipal principal) {

View File

@@ -0,0 +1,122 @@
package com.label.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.entity.SysCompany;
import com.label.mapper.SysCompanyMapper;
import com.label.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class CompanyService {
private final SysCompanyMapper companyMapper;
private final SysUserMapper userMapper;
public PageResult<SysCompany> list(int page, int pageSize, String status) {
pageSize = Math.min(pageSize, 100);
LambdaQueryWrapper<SysCompany> wrapper = new LambdaQueryWrapper<SysCompany>()
.orderByDesc(SysCompany::getCreatedAt);
if (status != null && !status.isBlank()) {
wrapper.eq(SysCompany::getStatus, status);
}
Page<SysCompany> result = companyMapper.selectPage(new Page<>(page, pageSize), wrapper);
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
}
@Transactional
public SysCompany create(String companyName, String companyCode) {
String normalizedName = requireText(companyName, "公司名称不能为空");
String normalizedCode = normalizeCode(companyCode);
ensureUniqueCode(null, normalizedCode);
ensureUniqueName(null, normalizedName);
SysCompany company = new SysCompany();
company.setCompanyName(normalizedName);
company.setCompanyCode(normalizedCode);
company.setStatus("ACTIVE");
companyMapper.insert(company);
log.info("公司已创建: id={}, code={}", company.getId(), normalizedCode);
return company;
}
@Transactional
public SysCompany update(Long companyId, String companyName, String companyCode) {
SysCompany company = getExistingCompany(companyId);
String normalizedName = requireText(companyName, "公司名称不能为空");
String normalizedCode = normalizeCode(companyCode);
ensureUniqueCode(companyId, normalizedCode);
ensureUniqueName(companyId, normalizedName);
company.setCompanyName(normalizedName);
company.setCompanyCode(normalizedCode);
companyMapper.updateById(company);
return company;
}
@Transactional
public void updateStatus(Long companyId, String status) {
SysCompany company = getExistingCompany(companyId);
if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) {
throw new BusinessException("INVALID_COMPANY_STATUS", "公司状态不合法", HttpStatus.BAD_REQUEST);
}
company.setStatus(status);
companyMapper.updateById(company);
}
@Transactional
public void delete(Long companyId) {
getExistingCompany(companyId);
Long userCount = userMapper.countByCompanyId(companyId);
if (userCount != null && userCount > 0) {
throw new BusinessException("COMPANY_HAS_USERS", "公司下仍存在用户,无法删除", HttpStatus.CONFLICT);
}
companyMapper.deleteById(companyId);
}
private SysCompany getExistingCompany(Long companyId) {
SysCompany company = companyMapper.selectById(companyId);
if (company == null) {
throw new BusinessException("NOT_FOUND", "公司不存在: " + companyId, HttpStatus.NOT_FOUND);
}
return company;
}
private void ensureUniqueCode(Long companyId, String companyCode) {
SysCompany existing = companyMapper.selectByCompanyCode(companyCode);
if (existing != null && !existing.getId().equals(companyId)) {
throw new BusinessException("DUPLICATE_COMPANY_CODE", "公司代码已存在", HttpStatus.CONFLICT);
}
}
private void ensureUniqueName(Long companyId, String companyName) {
SysCompany existing = companyMapper.selectOne(new LambdaQueryWrapper<SysCompany>()
.eq(SysCompany::getCompanyName, companyName)
.last("LIMIT 1"));
if (existing != null && !existing.getId().equals(companyId)) {
throw new BusinessException("DUPLICATE_COMPANY_NAME", "公司名称已存在", HttpStatus.CONFLICT);
}
}
private String requireText(String text, String message) {
if (text == null || text.isBlank()) {
throw new BusinessException("INVALID_COMPANY_FIELD", message, HttpStatus.BAD_REQUEST);
}
return text.trim();
}
private String normalizeCode(String companyCode) {
return requireText(companyCode, "公司代码不能为空").toUpperCase();
}
}

View File

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.common.storage.RustFsClient;
import com.label.entity.TrainingDataset;
import com.label.mapper.TrainingDatasetMapper;

View File

@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.AnnotationResult;

View File

@@ -2,7 +2,7 @@ package com.label.service;
import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.entity.ExportBatch;
import com.label.mapper.ExportBatchMapper;
import lombok.RequiredArgsConstructor;

View File

@@ -3,7 +3,7 @@ package com.label.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.TrainingDataset;

View File

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.common.storage.RustFsClient;
import com.label.dto.SourceResponse;
import com.label.entity.SourceData;

View File

@@ -2,7 +2,7 @@ package com.label.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.AnnotationTask;

View File

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.dto.TaskResponse;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;

View File

@@ -12,7 +12,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.TokenPrincipal;
import com.label.entity.SysUser;
import com.label.mapper.SysUserMapper;
import com.label.util.RedisUtil;

View File

@@ -5,9 +5,9 @@ spring:
application:
name: label-backend
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://39.107.112.174:5432/labeldb}
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/labeldb}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:postgres!Pw}
password: ${SPRING_DATASOURCE_PASSWORD:}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
@@ -16,9 +16,9 @@ spring:
data:
redis:
host: ${SPRING_DATA_REDIS_HOST:39.107.112.174}
host: ${SPRING_DATA_REDIS_HOST:localhost}
port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:jsti@2024}
password: ${SPRING_DATA_REDIS_PASSWORD:}
timeout: 5000ms
lettuce:
pool:
@@ -33,7 +33,7 @@ spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher # Shiro 与 Spring Boot 3 兼容性需要
matching-strategy: ant_path_matcher
springdoc:
api-docs:
@@ -45,7 +45,7 @@ springdoc:
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.label.module
type-aliases-package: com.label.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
@@ -54,31 +54,29 @@ mybatis-plus:
id-type: auto
rustfs:
endpoint: ${RUSTFS_ENDPOINT:http://39.107.112.174:9000}
endpoint: ${RUSTFS_ENDPOINT:http://localhost:9000}
access-key: ${RUSTFS_ACCESS_KEY:admin}
secret-key: ${RUSTFS_SECRET_KEY:your_strong_password}
secret-key: ${RUSTFS_SECRET_KEY:local-secret-key}
region: us-east-1
ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000}
timeout: 30000 # milliseconds
timeout: 30000
shiro:
auth:
enabled: false
mock-company-id: 1
mock-user-id: 1
mock-role: ADMIN
mock-username: mock
auth:
enabled: true
mock-company-id: 1
mock-user-id: 1
mock-role: ADMIN
mock-username: mock
token:
ttl-seconds: 7200 # Token 默认有效期(秒),与 sys_config token_ttl_seconds 保持一致
ttl-seconds: 7200
video:
callback-secret: ${VIDEO_CALLBACK_SECRET:} # AI 服务回调共享密钥,为空时跳过校验(开发环境)
callback-secret: ${VIDEO_CALLBACK_SECRET:}
logging:
level:
com.label: INFO
org.apache.shiro: INFO
com.baomidou.mybatisplus: INFO