Files
label_backend/docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md

1412 lines
50 KiB
Markdown
Raw Normal View History

2026-04-10 10:47:51 +08:00
# 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 |
**全部覆盖,无遗漏。**