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

50 KiB
Raw Blame History

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 --> 依赖之前插入:

        <!-- 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: 块):

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: 编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS,无编译错误。

  • Step 4: Commit
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

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: 编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS

  • Step 3: Commit
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

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: 编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS

  • Step 3: Commit
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

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
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
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
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
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: 编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS

  • Step 7: Commit
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

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
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: 编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS

  • Step 4: Commit
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

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
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: 编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS

  • Step 4: Commit
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

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
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: 编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS

  • Step 4: Commit
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

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
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
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: 最终编译验证
cd label_backend
mvn compile -q

预期:BUILD SUCCESS

  • Step 5: 最终打包验证
cd label_backend
mvn package -DskipTests -q

预期:BUILD SUCCESStarget/ 下生成 .zip.tar.gz

  • Step 6: Commit
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

全部覆盖,无遗漏。