1412 lines
50 KiB
Markdown
1412 lines
50 KiB
Markdown
# 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: 修改 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<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 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<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 = "查询可领取任务池(UNCLAIMED,ANNOTATOR 看 EXTRACTION,REVIEWER 看 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 |
|
||
|
||
**全部覆盖,无遗漏。**
|