# Swagger + Shiro 认证开关 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 为所有 REST 接口添加 springdoc-openapi Swagger 文档注解,并在 application.yml 中提供 Shiro 认证鉴权开关以方便测试。 **Architecture:** 通过在 `pom.xml` 引入 `springdoc-openapi-starter-webmvc-ui`,新建 `OpenApiConfig` 配置 Bearer Token 安全方案;修改 `TokenFilter` 支持 Swagger 路径白名单和 `shiro.auth.enabled=false` 时注入 mock Principal;在所有 Controller 上添加 `@Tag`/`@Operation`,核心 DTO 添加 `@Schema`。 **Tech Stack:** Spring Boot 3.2.5、Apache Shiro 1.13、springdoc-openapi-starter-webmvc-ui 2.5.0、Lombok、io.swagger.v3.oas.annotations --- ## 文件变更清单 | 操作 | 文件路径 | |------|----------| | 修改 | `pom.xml` | | 修改 | `src/main/resources/application.yml` | | 修改 | `src/main/java/com/label/common/shiro/TokenFilter.java` | | 新建 | `src/main/java/com/label/common/config/OpenApiConfig.java` | | 修改 | `src/main/java/com/label/module/user/dto/LoginRequest.java` | | 修改 | `src/main/java/com/label/module/user/dto/LoginResponse.java` | | 修改 | `src/main/java/com/label/module/user/dto/UserInfoResponse.java` | | 修改 | `src/main/java/com/label/module/task/dto/TaskResponse.java` | | 修改 | `src/main/java/com/label/module/source/dto/SourceResponse.java` | | 修改 | `src/main/java/com/label/module/user/controller/AuthController.java` | | 修改 | `src/main/java/com/label/module/user/controller/UserController.java` | | 修改 | `src/main/java/com/label/module/source/controller/SourceController.java` | | 修改 | `src/main/java/com/label/module/task/controller/TaskController.java` | | 修改 | `src/main/java/com/label/module/annotation/controller/ExtractionController.java` | | 修改 | `src/main/java/com/label/module/annotation/controller/QaController.java` | | 修改 | `src/main/java/com/label/module/export/controller/ExportController.java` | | 修改 | `src/main/java/com/label/module/config/controller/SysConfigController.java` | | 修改 | `src/main/java/com/label/module/video/controller/VideoController.java` | --- ## Task 1: 依赖与配置(pom.xml + application.yml) **Files:** - Modify: `pom.xml` - Modify: `src/main/resources/application.yml` - [ ] **Step 1: 在 pom.xml `` 末尾添加 springdoc 依赖** 在 `` 依赖之前插入: ```xml org.springdoc springdoc-openapi-starter-webmvc-ui 2.5.0 ``` - [ ] **Step 2: 在 application.yml 末尾追加 springdoc 和 shiro.auth 配置** 在 `logging:` 块之前插入以下内容(保留原 `logging:` 块): ```yaml springdoc: api-docs: enabled: true path: /v3/api-docs swagger-ui: enabled: true path: /swagger-ui.html shiro: auth: enabled: true mock-company-id: 1 mock-user-id: 1 mock-role: ADMIN mock-username: mock ``` - [ ] **Step 3: 编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS`,无编译错误。 - [ ] **Step 4: Commit** ```bash cd label_backend git add pom.xml src/main/resources/application.yml git commit -m "feat: add springdoc-openapi dependency and swagger/shiro-auth config" ``` --- ## Task 2: 修改 TokenFilter(Swagger 白名单 + 认证开关) **Files:** - Modify: `src/main/java/com/label/common/shiro/TokenFilter.java` - [ ] **Step 1: 用以下完整内容替换 TokenFilter.java** ```java package com.label.common.shiro; import com.fasterxml.jackson.databind.ObjectMapper; import com.label.common.context.CompanyContext; import com.label.common.redis.RedisKeyManager; import com.label.common.redis.RedisService; import com.label.common.result.Result; 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; 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 java.io.IOException; import java.util.Map; /** * JWT-style Bearer Token 过滤器。 * 继承 Spring 的 OncePerRequestFilter(jakarta.servlet),避免与 Shiro 1.x * 的 PathMatchingFilter(javax.servlet)产生命名空间冲突。 * * 过滤逻辑: * - 跳过非 /api/ 路径、/api/auth/login、Swagger UI 路径(公开端点) * - shiro.auth.enabled=false 时:注入 mock Principal,跳过 Redis 校验(测试模式) * - 解析 "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; /** * 公开端点跳过过滤: * - 非 /api/ 前缀路径 * - 登录接口 * - AI 服务内部回调 * - Swagger UI / OpenAPI 文档路径 */ @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"); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 认证开关关闭时:注入固定测试上下文,跳过 Redis 校验 if (!authEnabled) { TokenPrincipal mockPrincipal = new TokenPrincipal( mockUserId, mockRole, mockCompanyId, mockUsername, "mock-token"); CompanyContext.set(mockCompanyId); SecurityUtils.getSubject().login(new BearerToken("mock-token", mockPrincipal)); request.setAttribute("__token_principal__", mockPrincipal); filterChain.doFilter(request, response); return; } String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { writeUnauthorized(response, "缺少或无效的认证令牌"); return; } String token = authHeader.substring(7).trim(); Map 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(); // 注入多租户上下文(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); 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))); } } ``` - [ ] **Step 2: 编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS` - [ ] **Step 3: Commit** ```bash cd label_backend git add src/main/java/com/label/common/shiro/TokenFilter.java git commit -m "feat: add swagger path whitelist and shiro.auth.enabled toggle to TokenFilter" ``` --- ## Task 3: 新建 OpenApiConfig **Files:** - Create: `src/main/java/com/label/common/config/OpenApiConfig.java` - [ ] **Step 1: 创建 OpenApiConfig.java** ```java package com.label.common.config; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * SpringDoc OpenAPI 全局配置:API 基本信息 + Bearer Token 安全方案。 */ @Configuration public class OpenApiConfig { @Bean public OpenAPI openAPI() { return new OpenAPI() .info(new Info() .title("Label Backend API") .version("1.0.0") .description("知识图谱智能标注平台后端接口文档")) .addSecurityItem(new SecurityRequirement().addList("BearerAuth")) .components(new Components() .addSecuritySchemes("BearerAuth", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("UUID") .description("登录后返回的 Token,格式:Bearer {uuid}"))); } } ``` - [ ] **Step 2: 编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS` - [ ] **Step 3: Commit** ```bash cd label_backend git add src/main/java/com/label/common/config/OpenApiConfig.java git commit -m "feat: add OpenApiConfig with Bearer Token security scheme" ``` --- ## Task 4: 核心 DTO 添加 @Schema 注解 **Files:** - Modify: `src/main/java/com/label/module/user/dto/LoginRequest.java` - Modify: `src/main/java/com/label/module/user/dto/LoginResponse.java` - Modify: `src/main/java/com/label/module/user/dto/UserInfoResponse.java` - Modify: `src/main/java/com/label/module/task/dto/TaskResponse.java` - Modify: `src/main/java/com/label/module/source/dto/SourceResponse.java` - [ ] **Step 1: 替换 LoginRequest.java** ```java package com.label.module.user.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; /** * 登录请求体。 */ @Data @Schema(description = "登录请求") public class LoginRequest { @Schema(description = "公司代码(英文简写),用于确定租户", example = "acme") private String companyCode; @Schema(description = "登录用户名", example = "admin") private String username; @Schema(description = "明文密码(传输层应使用 HTTPS 保护)", example = "password123") private String password; } ``` - [ ] **Step 2: 替换 LoginResponse.java** ```java package com.label.module.user.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; /** * 登录成功响应体。 */ @Data @AllArgsConstructor @Schema(description = "登录响应") public class LoginResponse { @Schema(description = "Bearer Token(UUID v4),后续请求放入 Authorization 头") private String token; @Schema(description = "用户主键") private Long userId; @Schema(description = "登录用户名") private String username; @Schema(description = "角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN") private String role; @Schema(description = "Token 有效期(秒)") private Long expiresIn; } ``` - [ ] **Step 3: 替换 UserInfoResponse.java** ```java package com.label.module.user.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; /** * GET /api/auth/me 响应体,包含当前登录用户的详细信息。 */ @Data @AllArgsConstructor @Schema(description = "当前登录用户信息") public class UserInfoResponse { @Schema(description = "用户主键") private Long id; @Schema(description = "用户名") private String username; @Schema(description = "真实姓名") private String realName; @Schema(description = "角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN") private String role; @Schema(description = "所属公司 ID") private Long companyId; @Schema(description = "所属公司名称") private String companyName; } ``` - [ ] **Step 4: 替换 TaskResponse.java** ```java package com.label.module.task.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import java.time.LocalDateTime; /** * 任务接口统一响应体(任务池、我的任务、任务详情均复用)。 */ @Data @Builder @Schema(description = "标注任务响应") public class TaskResponse { @Schema(description = "任务主键") private Long id; @Schema(description = "关联资料 ID") private Long sourceId; @Schema(description = "任务阶段:EXTRACTION / QA_GENERATION") private String taskType; @Schema(description = "任务状态:UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED") private String status; @Schema(description = "领取人用户 ID") private Long claimedBy; @Schema(description = "领取时间") private LocalDateTime claimedAt; @Schema(description = "提交时间") private LocalDateTime submittedAt; @Schema(description = "完成时间") private LocalDateTime completedAt; @Schema(description = "驳回原因(REJECTED 状态时非空)") private String rejectReason; @Schema(description = "创建时间") private LocalDateTime createdAt; } ``` - [ ] **Step 5: 替换 SourceResponse.java** ```java package com.label.module.source.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import java.time.LocalDateTime; /** * 资料接口统一响应体(上传、列表、详情均复用此类)。 * 各端点按需填充字段,未填充字段序列化时因 jackson non_null 配置自动省略。 */ @Data @Builder @Schema(description = "原始资料响应") public class SourceResponse { @Schema(description = "资料主键") private Long id; @Schema(description = "原始文件名") private String fileName; @Schema(description = "资料类型:TEXT / IMAGE / VIDEO") private String dataType; @Schema(description = "文件大小(字节)") private Long fileSize; @Schema(description = "状态:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED") private String status; @Schema(description = "上传用户 ID(列表端点返回)") private Long uploaderId; @Schema(description = "15 分钟预签名下载链接(详情端点返回)") private String presignedUrl; @Schema(description = "父资料 ID(视频帧/文本片段;详情端点返回)") private Long parentSourceId; @Schema(description = "创建时间") private LocalDateTime createdAt; } ``` - [ ] **Step 6: 编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS` - [ ] **Step 7: Commit** ```bash cd label_backend git add src/main/java/com/label/module/user/dto/ \ src/main/java/com/label/module/task/dto/ \ src/main/java/com/label/module/source/dto/ git commit -m "feat: add @Schema annotations to core DTOs" ``` --- ## Task 5: AuthController + UserController 添加 Swagger 注解 **Files:** - Modify: `src/main/java/com/label/module/user/controller/AuthController.java` - Modify: `src/main/java/com/label/module/user/controller/UserController.java` - [ ] **Step 1: 替换 AuthController.java** ```java 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 io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; /** * 认证接口:登录、退出、获取当前用户。 */ @Tag(name = "认证管理", description = "登录、退出、获取当前用户信息") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final AuthService authService; @Operation(summary = "用户登录,返回 Bearer Token") @PostMapping("/login") public Result login(@RequestBody LoginRequest request) { return Result.success(authService.login(request)); } @Operation(summary = "退出登录,立即删除 Redis Token") @PostMapping("/logout") public Result logout(HttpServletRequest request) { String token = extractToken(request); authService.logout(token); return Result.success(null); } @Operation(summary = "获取当前登录用户信息") @GetMapping("/me") public Result me(HttpServletRequest request) { TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); return Result.success(authService.me(principal)); } private String extractToken(HttpServletRequest request) { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7).trim(); } return null; } } ``` - [ ] **Step 2: 替换 UserController.java** ```java package com.label.module.user.controller; import com.label.common.result.PageResult; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.user.entity.SysUser; import com.label.module.user.service.UserService; 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; /** * 用户管理接口(5 个端点,全部 ADMIN 权限)。 */ @Tag(name = "用户管理", description = "用户 CRUD、状态与角色变更(ADMIN 专属)") @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @Operation(summary = "分页查询用户列表") @GetMapping @RequiresRoles("ADMIN") public Result> listUsers( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, HttpServletRequest request) { return Result.success(userService.listUsers(page, pageSize, principal(request))); } @Operation(summary = "创建用户") @PostMapping @RequiresRoles("ADMIN") public Result createUser(@RequestBody Map body, HttpServletRequest request) { return Result.success(userService.createUser( body.get("username"), body.get("password"), body.get("realName"), body.get("role"), principal(request))); } @Operation(summary = "更新用户基本信息(realName / password)") @PutMapping("/{id}") @RequiresRoles("ADMIN") public Result updateUser(@PathVariable Long id, @RequestBody Map body, HttpServletRequest request) { return Result.success(userService.updateUser( id, body.get("realName"), body.get("password"), principal(request))); } @Operation(summary = "变更用户状态(ACTIVE / DISABLED)") @PutMapping("/{id}/status") @RequiresRoles("ADMIN") public Result updateStatus(@PathVariable Long id, @RequestBody Map body, HttpServletRequest request) { userService.updateStatus(id, body.get("status"), principal(request)); return Result.success(null); } @Operation(summary = "变更用户角色,立即驱逐权限缓存") @PutMapping("/{id}/role") @RequiresRoles("ADMIN") public Result updateRole(@PathVariable Long id, @RequestBody Map body, HttpServletRequest request) { userService.updateRole(id, body.get("role"), principal(request)); return Result.success(null); } private TokenPrincipal principal(HttpServletRequest request) { return (TokenPrincipal) request.getAttribute("__token_principal__"); } } ``` - [ ] **Step 3: 编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS` - [ ] **Step 4: Commit** ```bash cd label_backend git add src/main/java/com/label/module/user/controller/ git commit -m "feat: add @Tag/@Operation to AuthController and UserController" ``` --- ## Task 6: SourceController + TaskController 添加 Swagger 注解 **Files:** - Modify: `src/main/java/com/label/module/source/controller/SourceController.java` - Modify: `src/main/java/com/label/module/task/controller/TaskController.java` - [ ] **Step 1: 替换 SourceController.java** ```java package com.label.module.source.controller; import com.label.common.result.PageResult; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.source.dto.SourceResponse; import com.label.module.source.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; /** * 原始资料管理接口。 */ @Tag(name = "资料管理", description = "文件上传至 RustFS、资料元数据管理") @RestController @RequestMapping("/api/source") @RequiredArgsConstructor public class SourceController { private final SourceService sourceService; @Operation(summary = "上传文件(multipart/form-data),返回资料摘要") @PostMapping("/upload") @RequiresRoles("UPLOADER") @ResponseStatus(HttpStatus.CREATED) public Result upload( @RequestParam("file") MultipartFile file, @RequestParam("dataType") String dataType, HttpServletRequest request) { TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); return Result.success(sourceService.upload(file, dataType, principal)); } @Operation(summary = "分页查询资料列表(UPLOADER 只见自己,ADMIN 见全部)") @GetMapping("/list") @RequiresRoles("UPLOADER") public Result> list( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String dataType, @RequestParam(required = false) String status, HttpServletRequest request) { TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); return Result.success(sourceService.list(page, pageSize, dataType, status, principal)); } @Operation(summary = "查询资料详情(含 15 分钟预签名下载链接)") @GetMapping("/{id}") @RequiresRoles("UPLOADER") public Result findById(@PathVariable Long id) { return Result.success(sourceService.findById(id)); } @Operation(summary = "删除资料(仅 PENDING 状态可删),同步删除 RustFS 文件") @DeleteMapping("/{id}") @RequiresRoles("ADMIN") public Result delete(@PathVariable Long id, HttpServletRequest request) { TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); sourceService.delete(id, principal.getCompanyId()); return Result.success(null); } } ``` - [ ] **Step 2: 替换 TaskController.java** ```java package com.label.module.task.controller; import com.label.common.result.PageResult; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.task.dto.TaskResponse; import com.label.module.task.service.TaskClaimService; import com.label.module.task.service.TaskService; 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; /** * 任务管理接口(10 个端点)。 */ @Tag(name = "任务管理", description = "任务创建、领取、放弃、审批、强制转移") @RestController @RequestMapping("/api/tasks") @RequiredArgsConstructor public class TaskController { private final TaskService taskService; private final TaskClaimService taskClaimService; @Operation(summary = "查询可领取任务池(UNCLAIMED,ANNOTATOR 看 EXTRACTION,REVIEWER 看 SUBMITTED)") @GetMapping("/pool") @RequiresRoles("ANNOTATOR") public Result> getPool( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, HttpServletRequest request) { return Result.success(taskService.getPool(page, pageSize, principal(request))); } @Operation(summary = "查询我的任务(IN_PROGRESS / SUBMITTED / REJECTED)") @GetMapping("/mine") @RequiresRoles("ANNOTATOR") public Result> getMine( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String status, HttpServletRequest request) { return Result.success(taskService.getMine(page, pageSize, status, principal(request))); } @Operation(summary = "查询待审批队列(REVIEWER 专属,status=SUBMITTED)") @GetMapping("/pending-review") @RequiresRoles("REVIEWER") public Result> getPendingReview( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String taskType) { return Result.success(taskService.getPendingReview(page, pageSize, taskType)); } @Operation(summary = "查询全部任务(ADMIN,支持状态/类型过滤)") @GetMapping @RequiresRoles("ADMIN") public Result> getAll( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String status, @RequestParam(required = false) String taskType) { return Result.success(taskService.getAll(page, pageSize, status, taskType)); } @Operation(summary = "为指定资料创建标注任务(ADMIN)") @PostMapping @RequiresRoles("ADMIN") public Result createTask(@RequestBody Map body, HttpServletRequest request) { Long sourceId = Long.parseLong(body.get("sourceId").toString()); String taskType = body.get("taskType").toString(); TokenPrincipal principal = principal(request); return Result.success(taskService.toPublicResponse( taskService.createTask(sourceId, taskType, principal.getCompanyId()))); } @Operation(summary = "查询任务详情") @GetMapping("/{id}") @RequiresRoles("ANNOTATOR") public Result getById(@PathVariable Long id) { return Result.success(taskService.toPublicResponse(taskService.getById(id))); } @Operation(summary = "领取任务(Redis SET NX + DB 乐观锁双重保障)") @PostMapping("/{id}/claim") @RequiresRoles("ANNOTATOR") public Result claim(@PathVariable Long id, HttpServletRequest request) { taskClaimService.claim(id, principal(request)); return Result.success(null); } @Operation(summary = "放弃任务,退回任务池") @PostMapping("/{id}/unclaim") @RequiresRoles("ANNOTATOR") public Result unclaim(@PathVariable Long id, HttpServletRequest request) { taskClaimService.unclaim(id, principal(request)); return Result.success(null); } @Operation(summary = "重领被驳回的任务(task.status=REJECTED 且 claimedBy=当前用户)") @PostMapping("/{id}/reclaim") @RequiresRoles("ANNOTATOR") public Result reclaim(@PathVariable Long id, HttpServletRequest request) { taskClaimService.reclaim(id, principal(request)); return Result.success(null); } @Operation(summary = "ADMIN 强制转移任务归属") @PutMapping("/{id}/reassign") @RequiresRoles("ADMIN") public Result reassign(@PathVariable Long id, @RequestBody Map body, HttpServletRequest request) { Long targetUserId = Long.parseLong(body.get("userId").toString()); taskService.reassign(id, targetUserId, principal(request)); return Result.success(null); } private TokenPrincipal principal(HttpServletRequest request) { return (TokenPrincipal) request.getAttribute("__token_principal__"); } } ``` - [ ] **Step 3: 编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS` - [ ] **Step 4: Commit** ```bash cd label_backend git add src/main/java/com/label/module/source/controller/ \ src/main/java/com/label/module/task/controller/ git commit -m "feat: add @Tag/@Operation to SourceController and TaskController" ``` --- ## Task 7: ExtractionController + QaController 添加 Swagger 注解 **Files:** - Modify: `src/main/java/com/label/module/annotation/controller/ExtractionController.java` - Modify: `src/main/java/com/label/module/annotation/controller/QaController.java` - [ ] **Step 1: 替换 ExtractionController.java** ```java package com.label.module.annotation.controller; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.annotation.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; /** * 提取阶段标注工作台接口(5 个端点)。 */ @Tag(name = "标注工作台", description = "EXTRACTION 阶段:AI 预标注、结果编辑、提交、审批") @RestController @RequestMapping("/api/extraction") @RequiredArgsConstructor public class ExtractionController { private final ExtractionService extractionService; @Operation(summary = "获取当前提取结果(含 AI 预标注数据)") @GetMapping("/{taskId}") @RequiresRoles("ANNOTATOR") public Result> getResult(@PathVariable Long taskId, HttpServletRequest request) { return Result.success(extractionService.getResult(taskId, principal(request))); } @Operation(summary = "更新提取结果(整体 JSONB 覆盖,PUT 语义)") @PutMapping("/{taskId}") @RequiresRoles("ANNOTATOR") public Result updateResult(@PathVariable Long taskId, @RequestBody String resultJson, HttpServletRequest request) { extractionService.updateResult(taskId, resultJson, principal(request)); return Result.success(null); } @Operation(summary = "提交提取结果,进入审批队列") @PostMapping("/{taskId}/submit") @RequiresRoles("ANNOTATOR") public Result submit(@PathVariable Long taskId, HttpServletRequest request) { extractionService.submit(taskId, principal(request)); return Result.success(null); } @Operation(summary = "审批通过(REVIEWER),自动触发 QA 任务创建") @PostMapping("/{taskId}/approve") @RequiresRoles("REVIEWER") public Result approve(@PathVariable Long taskId, HttpServletRequest request) { extractionService.approve(taskId, principal(request)); return Result.success(null); } @Operation(summary = "驳回提取结果(REVIEWER),标注员可重领后修改") @PostMapping("/{taskId}/reject") @RequiresRoles("REVIEWER") public Result reject(@PathVariable Long taskId, @RequestBody Map body, HttpServletRequest request) { String reason = body != null ? body.get("reason") : null; extractionService.reject(taskId, reason, principal(request)); return Result.success(null); } private TokenPrincipal principal(HttpServletRequest request) { return (TokenPrincipal) request.getAttribute("__token_principal__"); } } ``` - [ ] **Step 2: 替换 QaController.java** ```java package com.label.module.annotation.controller; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.annotation.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; /** * 问答生成阶段标注工作台接口(5 个端点)。 */ @Tag(name = "问答生成", description = "QA_GENERATION 阶段:查看候选问答对、修改、提交、审批") @RestController @RequestMapping("/api/qa") @RequiredArgsConstructor public class QaController { private final QaService qaService; @Operation(summary = "获取候选问答对列表") @GetMapping("/{taskId}") @RequiresRoles("ANNOTATOR") public Result> getResult(@PathVariable Long taskId, HttpServletRequest request) { return Result.success(qaService.getResult(taskId, principal(request))); } @Operation(summary = "修改问答对(整体覆盖,PUT 语义)") @PutMapping("/{taskId}") @RequiresRoles("ANNOTATOR") public Result updateResult(@PathVariable Long taskId, @RequestBody String body, HttpServletRequest request) { qaService.updateResult(taskId, body, principal(request)); return Result.success(null); } @Operation(summary = "提交问答对,进入审批队列") @PostMapping("/{taskId}/submit") @RequiresRoles("ANNOTATOR") public Result submit(@PathVariable Long taskId, HttpServletRequest request) { qaService.submit(taskId, principal(request)); return Result.success(null); } @Operation(summary = "审批通过(REVIEWER),写入 training_dataset,流水线完成") @PostMapping("/{taskId}/approve") @RequiresRoles("REVIEWER") public Result approve(@PathVariable Long taskId, HttpServletRequest request) { qaService.approve(taskId, principal(request)); return Result.success(null); } @Operation(summary = "驳回问答对(REVIEWER),删除候选记录,标注员重新生成") @PostMapping("/{taskId}/reject") @RequiresRoles("REVIEWER") public Result reject(@PathVariable Long taskId, @RequestBody Map body, HttpServletRequest request) { String reason = body != null ? body.get("reason") : null; qaService.reject(taskId, reason, principal(request)); return Result.success(null); } private TokenPrincipal principal(HttpServletRequest request) { return (TokenPrincipal) request.getAttribute("__token_principal__"); } } ``` - [ ] **Step 3: 编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS` - [ ] **Step 4: Commit** ```bash cd label_backend git add src/main/java/com/label/module/annotation/controller/ git commit -m "feat: add @Tag/@Operation to ExtractionController and QaController" ``` --- ## Task 8: ExportController + SysConfigController + VideoController 添加 Swagger 注解 **Files:** - Modify: `src/main/java/com/label/module/export/controller/ExportController.java` - Modify: `src/main/java/com/label/module/config/controller/SysConfigController.java` - Modify: `src/main/java/com/label/module/video/controller/VideoController.java` - [ ] **Step 1: 替换 ExportController.java** ```java package com.label.module.export.controller; import com.label.common.result.PageResult; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.annotation.entity.TrainingDataset; import com.label.module.export.entity.ExportBatch; import com.label.module.export.service.ExportService; import com.label.module.export.service.FinetuneService; 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 java.util.List; import java.util.Map; /** * 训练数据导出与微调接口(5 个端点,全部 ADMIN 权限)。 */ @Tag(name = "导出管理", description = "训练样本查询、JSONL 批次导出、GLM 微调任务管理") @RestController @RequiredArgsConstructor public class ExportController { private final ExportService exportService; private final FinetuneService finetuneService; @Operation(summary = "分页查询已审批可导出的训练样本") @GetMapping("/api/training/samples") @RequiresRoles("ADMIN") public Result> listSamples( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String sampleType, @RequestParam(required = false) Boolean exported, HttpServletRequest request) { return Result.success(exportService.listSamples(page, pageSize, sampleType, exported, principal(request))); } @Operation(summary = "创建导出批次,合并样本为 JSONL 并上传 RustFS") @PostMapping("/api/export/batch") @RequiresRoles("ADMIN") @ResponseStatus(HttpStatus.CREATED) public Result createBatch(@RequestBody Map body, HttpServletRequest request) { @SuppressWarnings("unchecked") List rawIds = (List) body.get("sampleIds"); List sampleIds = rawIds.stream() .map(id -> Long.parseLong(id.toString())) .toList(); return Result.success(exportService.createBatch(sampleIds, principal(request))); } @Operation(summary = "向 GLM 工厂提交微调任务") @PostMapping("/api/export/{batchId}/finetune") @RequiresRoles("ADMIN") public Result> triggerFinetune(@PathVariable Long batchId, HttpServletRequest request) { return Result.success(finetuneService.trigger(batchId, principal(request))); } @Operation(summary = "查询微调任务状态") @GetMapping("/api/export/{batchId}/status") @RequiresRoles("ADMIN") public Result> getFinetuneStatus(@PathVariable Long batchId, HttpServletRequest request) { return Result.success(finetuneService.getStatus(batchId, principal(request))); } @Operation(summary = "分页查询所有导出批次") @GetMapping("/api/export/list") @RequiresRoles("ADMIN") public Result> listBatches( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, HttpServletRequest request) { return Result.success(exportService.listBatches(page, pageSize, principal(request))); } private TokenPrincipal principal(HttpServletRequest request) { return (TokenPrincipal) request.getAttribute("__token_principal__"); } } ``` - [ ] **Step 2: 替换 SysConfigController.java** ```java package com.label.module.config.controller; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.config.entity.SysConfig; import com.label.module.config.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; import java.util.Map; /** * 系统配置接口(2 个端点,均需 ADMIN 权限)。 */ @Tag(name = "系统配置", description = "Prompt 模板、模型参数等全局/公司级配置管理") @RestController @RequiredArgsConstructor public class SysConfigController { private final SysConfigService sysConfigService; @Operation(summary = "查询合并后的配置列表(公司专属 + 全局默认)") @GetMapping("/api/config") @RequiresRoles("ADMIN") public Result>> listConfig(HttpServletRequest request) { TokenPrincipal principal = principal(request); return Result.success(sysConfigService.list(principal.getCompanyId())); } @Operation(summary = "UPSERT 公司专属配置项") @PutMapping("/api/config/{key}") @RequiresRoles("ADMIN") public Result updateConfig(@PathVariable String key, @RequestBody Map body, HttpServletRequest request) { String value = body.get("value"); String description = body.get("description"); TokenPrincipal principal = principal(request); return Result.success( sysConfigService.update(key, value, description, principal.getCompanyId())); } private TokenPrincipal principal(HttpServletRequest request) { return (TokenPrincipal) request.getAttribute("__token_principal__"); } } ``` - [ ] **Step 3: 替换 VideoController.java** ```java package com.label.module.video.controller; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; import com.label.module.video.entity.VideoProcessJob; import com.label.module.video.service.VideoProcessService; import io.swagger.v3.oas.annotations.Operation; 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.*; import java.util.Map; /** * 视频处理接口(4 个端点)。 */ @Tag(name = "视频处理", description = "触发视频抽帧/转文本、查询任务状态、AI 回调接收") @Slf4j @RestController @RequiredArgsConstructor public class VideoController { private final VideoProcessService videoProcessService; @Value("${video.callback-secret:}") private String callbackSecret; @Operation(summary = "触发视频处理任务(FRAME_EXTRACT 或 VIDEO_TO_TEXT)") @PostMapping("/api/video/process") @RequiresRoles("ADMIN") public Result createJob(@RequestBody Map body, HttpServletRequest request) { Object sourceIdVal = body.get("sourceId"); Object jobTypeVal = body.get("jobType"); if (sourceIdVal == null || jobTypeVal == null) { return Result.failure("INVALID_PARAMS", "sourceId 和 jobType 不能为空"); } Long sourceId = Long.parseLong(sourceIdVal.toString()); String jobType = jobTypeVal.toString(); String params = body.containsKey("params") ? body.get("params").toString() : null; TokenPrincipal principal = principal(request); return Result.success( videoProcessService.createJob(sourceId, jobType, params, principal.getCompanyId())); } @Operation(summary = "查询视频处理任务状态") @GetMapping("/api/video/jobs/{jobId}") @RequiresRoles("ADMIN") public Result getJob(@PathVariable Long jobId, HttpServletRequest request) { return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId())); } @Operation(summary = "管理员重置失败的视频处理任务(FAILED → PENDING)") @PostMapping("/api/video/jobs/{jobId}/reset") @RequiresRoles("ADMIN") public Result resetJob(@PathVariable Long jobId, HttpServletRequest request) { return Result.success(videoProcessService.reset(jobId, principal(request).getCompanyId())); } @Operation(summary = "AI 服务回调接口(无需 Bearer Token,内部调用)") @PostMapping("/api/video/callback") public Result handleCallback(@RequestBody Map body, HttpServletRequest request) { if (callbackSecret != null && !callbackSecret.isBlank()) { String provided = request.getHeader("X-Callback-Secret"); if (!callbackSecret.equals(provided)) { return Result.failure("UNAUTHORIZED", "回调密钥无效"); } } Long jobId = Long.parseLong(body.get("jobId").toString()); String status = (String) body.get("status"); String outputPath = body.containsKey("outputPath") ? (String) body.get("outputPath") : null; String errorMessage = body.containsKey("errorMessage") ? (String) body.get("errorMessage") : null; log.info("视频处理回调:jobId={}, status={}", jobId, status); videoProcessService.handleCallback(jobId, status, outputPath, errorMessage); return Result.success(null); } private TokenPrincipal principal(HttpServletRequest request) { return (TokenPrincipal) request.getAttribute("__token_principal__"); } } ``` - [ ] **Step 4: 最终编译验证** ```bash cd label_backend mvn compile -q ``` 预期:`BUILD SUCCESS` - [ ] **Step 5: 最终打包验证** ```bash cd label_backend mvn package -DskipTests -q ``` 预期:`BUILD SUCCESS`,`target/` 下生成 `.zip` 和 `.tar.gz` - [ ] **Step 6: Commit** ```bash cd label_backend git add src/main/java/com/label/module/export/controller/ \ src/main/java/com/label/module/config/controller/ \ src/main/java/com/label/module/video/controller/ git commit -m "feat: add @Tag/@Operation to ExportController, SysConfigController, VideoController" ``` --- ## Self-Review **Spec coverage 核对:** | 规格要求 | 对应 Task | |----------|-----------| | pom.xml 新增 springdoc 依赖 | Task 1 | | application.yml 配置 springdoc | Task 1 | | application.yml 配置 shiro.auth 开关 | Task 1 | | TokenFilter swagger 路径白名单 | Task 2 | | TokenFilter shiro.auth.enabled=false 分支 | Task 2 | | OpenApiConfig Bearer SecurityScheme | Task 3 | | 5 个核心 DTO @Schema | Task 4 | | AuthController + UserController @Tag/@Operation | Task 5 | | SourceController + TaskController @Tag/@Operation | Task 6 | | ExtractionController + QaController @Tag/@Operation | Task 7 | | ExportController + SysConfigController + VideoController @Tag/@Operation | Task 8 | **全部覆盖,无遗漏。**