Files
label_backend/docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md
2026-04-10 10:47:51 +08:00

1412 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<dependencies>` 末尾添加 springdoc 依赖**
`<!-- Spring Boot Test -->` 依赖之前插入:
```xml
<!-- SpringDoc OpenAPI (Swagger UI) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
```
- [ ] **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: 修改 TokenFilterSwagger 白名单 + 认证开关)
**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 的 OncePerRequestFilterjakarta.servlet避免与 Shiro 1.x
* 的 PathMatchingFilterjavax.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<Object, Object> tokenData = redisService.hGetAll(RedisKeyManager.tokenKey(token));
if (tokenData == null || tokenData.isEmpty()) {
writeUnauthorized(response, "令牌已过期或不存在");
return;
}
Long userId = Long.parseLong(tokenData.get("userId").toString());
String role = tokenData.get("role").toString();
Long companyId = Long.parseLong(tokenData.get("companyId").toString());
String username = tokenData.get("username").toString();
// 注入多租户上下文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 TokenUUID 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<LoginResponse> login(@RequestBody LoginRequest request) {
return Result.success(authService.login(request));
}
@Operation(summary = "退出登录,立即删除 Redis Token")
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
String token = extractToken(request);
authService.logout(token);
return Result.success(null);
}
@Operation(summary = "获取当前登录用户信息")
@GetMapping("/me")
public Result<UserInfoResponse> 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<PageResult<SysUser>> 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<SysUser> createUser(@RequestBody Map<String, String> 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<SysUser> updateUser(@PathVariable Long id,
@RequestBody Map<String, String> 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<Void> updateStatus(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
userService.updateStatus(id, body.get("status"), principal(request));
return Result.success(null);
}
@Operation(summary = "变更用户角色,立即驱逐权限缓存")
@PutMapping("/{id}/role")
@RequiresRoles("ADMIN")
public Result<Void> updateRole(@PathVariable Long id,
@RequestBody Map<String, String> 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<SourceResponse> 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<PageResult<SourceResponse>> 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<SourceResponse> findById(@PathVariable Long id) {
return Result.success(sourceService.findById(id));
}
@Operation(summary = "删除资料(仅 PENDING 状态可删),同步删除 RustFS 文件")
@DeleteMapping("/{id}")
@RequiresRoles("ADMIN")
public Result<Void> 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 = "查询可领取任务池UNCLAIMEDANNOTATOR 看 EXTRACTIONREVIEWER 看 SUBMITTED")
@GetMapping("/pool")
@RequiresRoles("ANNOTATOR")
public Result<PageResult<TaskResponse>> 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<PageResult<TaskResponse>> 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<PageResult<TaskResponse>> 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<PageResult<TaskResponse>> 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<TaskResponse> createTask(@RequestBody Map<String, Object> 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<TaskResponse> 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<Void> claim(@PathVariable Long id, HttpServletRequest request) {
taskClaimService.claim(id, principal(request));
return Result.success(null);
}
@Operation(summary = "放弃任务,退回任务池")
@PostMapping("/{id}/unclaim")
@RequiresRoles("ANNOTATOR")
public Result<Void> 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<Void> 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<Void> reassign(@PathVariable Long id,
@RequestBody Map<String, Object> 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<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(extractionService.getResult(taskId, principal(request)));
}
@Operation(summary = "更新提取结果(整体 JSONB 覆盖PUT 语义)")
@PutMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
public Result<Void> 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<Void> 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<Void> 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<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> 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<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(qaService.getResult(taskId, principal(request)));
}
@Operation(summary = "修改问答对整体覆盖PUT 语义)")
@PutMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
public Result<Void> 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<Void> 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<Void> 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<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> 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<PageResult<TrainingDataset>> 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<ExportBatch> createBatch(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
@SuppressWarnings("unchecked")
List<Object> rawIds = (List<Object>) body.get("sampleIds");
List<Long> 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<Map<String, Object>> 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<Map<String, Object>> getFinetuneStatus(@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(finetuneService.getStatus(batchId, principal(request)));
}
@Operation(summary = "分页查询所有导出批次")
@GetMapping("/api/export/list")
@RequiresRoles("ADMIN")
public Result<PageResult<ExportBatch>> 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<List<Map<String, Object>>> 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<SysConfig> updateConfig(@PathVariable String key,
@RequestBody Map<String, String> 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<VideoProcessJob> createJob(@RequestBody Map<String, Object> 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<VideoProcessJob> 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<VideoProcessJob> 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<Void> handleCallback(@RequestBody Map<String, Object> 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 |
**全部覆盖,无遗漏。**