Compare commits

..

18 Commits

Author SHA1 Message Date
wh
bf0b00ed08 提取功能改为异步实现,添加ai辅助提取状态 2026-04-17 01:20:27 +08:00
wh
ccbcfd2c74 添加上传文件大小限制500M 2026-04-15 23:22:14 +08:00
wh
4708aa0f28 不追踪设计文档 2026-04-15 18:25:07 +08:00
wh
5a24ebd49b 修改yaml 2026-04-15 16:41:27 +08:00
wh
3ce2deb0a6 Merge branch 'main' of https://fun-md.com/whfh/label_backend 2026-04-15 16:39:04 +08:00
wh
13945b239e 添加前缀 2026-04-15 16:38:50 +08:00
wh
eb22998b28 修改配置文件端口 2026-04-15 16:26:09 +08:00
wh
f6ba09521a 提交swagger 对象接口补充 2026-04-15 15:28:11 +08:00
wh
73a13fd16d docs: plan swagger dto annotation rollout 2026-04-15 14:25:23 +08:00
wh
00032dd491 docs: add swagger dto annotation constraints 2026-04-15 14:18:32 +08:00
zjw
c65fdbab5b Merge branch 'main' of https://fun-md.com/whfh/label_backend
# Conflicts:
#	src/test/java/com/label/blackbox/AbstractBlackBoxTest.java
#	src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java
#	src/test/java/com/label/integration/AuthIntegrationTest.java
#	src/test/java/com/label/integration/ExportIntegrationTest.java
#	src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java
#	src/test/java/com/label/integration/MultiTenantIsolationTest.java
#	src/test/java/com/label/integration/QaApprovalIntegrationTest.java
#	src/test/java/com/label/integration/SourceIntegrationTest.java
#	src/test/java/com/label/integration/SysConfigIntegrationTest.java
#	src/test/java/com/label/integration/TaskClaimConcurrencyTest.java
#	src/test/java/com/label/integration/UserManagementIntegrationTest.java
#	src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java
#	src/test/java/com/label/unit/AuthInterceptorTest.java
2026-04-15 10:48:11 +08:00
zjw
9fd8971732 统一接口前缀 2026-04-15 10:46:57 +08:00
wh
b65b1c6ee0 Merge branch 'main' of https://fun-md.com/whfh/label_backend 2026-04-15 10:44:30 +08:00
wh
d9486a8c75 ignore文件提交 2026-04-15 10:43:34 +08:00
wh
8d9e7cb027 撤销测试用例提交 2026-04-15 10:43:12 +08:00
zjw
5d5308cf57 打包简化,dockerfile简化 2026-04-15 10:09:53 +08:00
wh
e30b288894 修改readme 2026-04-15 00:24:27 +08:00
wh
325ea3b486 修改打包部署文件 2026-04-15 00:16:25 +08:00
83 changed files with 1328 additions and 4374 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ target/
*.ear
docs/
specs/
src/test/
CLAUDE.md
# ==========================================
# 2. IDE 配置文件

View File

@@ -1,27 +1,9 @@
# 构建阶段Maven + JDK 17 编译,生成薄 jar 及依赖
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
FROM registry.bjzgzp.com:4433/library/eclipse-temurin:21-jdk-ubi10-minimal
WORKDIR /app
# 优先复制 pom.xml 利用 Docker 层缓存(依赖不变时跳过 go-offline
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY ./label-backend-1.0.0-SNAPSHOT.jar /app/label-backend-1.0.0-SNAPSHOT.jar
COPY src ./src
RUN mvn clean package -DskipTests -q
EXPOSE 18082
# 运行阶段:仅含 JRE 的精简镜像
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# 复制部署结构bin/ libs/ etc/
COPY --from=builder /app/scripts/start.sh bin/start.sh
COPY --from=builder /app/target/libs/ libs/
COPY --from=builder /app/src/main/resources/application.yml etc/application.yml
COPY --from=builder /app/src/main/resources/logback.xml etc/logback.xml
RUN mkdir -p logs && chmod +x bin/start.sh
EXPOSE 8080
# start.sh 检测到 /.dockerenv 后以 exec 前台方式运行
ENTRYPOINT ["bin/start.sh"]
ENTRYPOINT ["java", "-Djava.net.preferIPv4Stack=true", "-jar", "/app/label-backend-1.0.0-SNAPSHOT.jar"]

View File

@@ -308,9 +308,7 @@ mvn clean package -DskipTests
```
构建产物:
- `target/libs/`
- 薄 jar 与运行时依赖
...
- `target/label-backend-1.0.0-SNAPSHOT.zip`
- `target/label-backend-1.0.0-SNAPSHOT.tar.gz`
@@ -387,13 +385,6 @@ docker build -t label-backend:latest .
## 开发规范
项目实现以以下文档为准:
- 总体设计
- [2026-04-09-label-backend-design.md](d:/workspace/label/label_backend/docs/superpowers/specs/2026-04-09-label-backend-design.md)
- 目录扁平化设计
- [2026-04-14-label-backend-directory-flattening-design.md](d:/workspace/label/label_backend/docs/superpowers/specs/2026-04-14-label-backend-directory-flattening-design.md)
当前约束摘要:
- 统一扁平目录结构,避免再次引入按业务域分层的旧目录
@@ -401,4 +392,10 @@ docker build -t label-backend:latest .
- Service 统一放在 `service/`,不拆 `service/impl`
- 业务规则优先放在 ServiceController 只负责 HTTP 协议层
- 新增接口需同步补齐 Swagger 注解与测试
- 所有对外接口参数必须在 Swagger 中明确体现名称、类型和含义
- 固定结构请求体禁止继续使用匿名 `Map<String, Object>``Map<String, String>`,必须定义 DTO 并补齐 `@Schema` 字段说明
- 固定结构响应应优先使用明确 DTO或至少为 Swagger 暴露对象补齐字段级 `@Schema` 注解
- 路径参数、查询参数、请求体、分页包装和通用返回体都必须维护可读的 OpenAPI 文档说明
- 需要保持历史兼容的原始 JSON 字符串请求体可以继续使用 `String`,但必须在 Swagger `@RequestBody` 中说明完整 JSON body 的提交方式和兼容原因
- 修改 Controller 参数、请求 DTO、响应 DTO 或对外实体后,必须运行 `mvn -Dtest=OpenApiAnnotationTest test`,确保 Swagger 参数名称、类型和含义没有回退
- 目录、配置、打包方式变化后README、设计文档和部署说明必须同步更新

View File

@@ -24,8 +24,8 @@
<directory>src/main/resources</directory>
<outputDirectory>etc</outputDirectory>
<includes>
<include>application.yml</include>
<include>logback.xml</include>
<include>*.yml</include>
<include>*.xml</include>
</includes>
</fileSet>

83
pom.xml
View File

@@ -3,18 +3,13 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/>
</parent>
<groupId>com.label</groupId>
<artifactId>label-backend</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<spring.boot.version>3.1.5</spring.boot.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<postgrescp.version>42.2.24</postgrescp.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
@@ -23,6 +18,13 @@
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- AWS SDK v2 BOM -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
@@ -46,6 +48,12 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Spring Boot Actuator (health check endpoint) -->
<dependency>
@@ -132,65 +140,32 @@
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<excludes>
<exclude>sql/**</exclude>
</excludes>
</resource>
</resources>
<plugins>
<!-- 薄 jar仅打包编译后的 class输出到 target/libs/ -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<outputDirectory>${project.build.directory}/libs</outputDirectory>
<archive>
<manifest>
<mainClass>com.label.LabelBackendApplication</mainClass>
<addClasspath>false</addClasspath>
</manifest>
</archive>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- 将所有运行时依赖复制到 target/libs/ -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
<goal>repackage</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/libs</outputDirectory>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
<!-- 组装分发包zip + tar.gz -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<id>create-distribution</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<descriptors>
<descriptor>assembly/distribution.xml</descriptor>
</descriptors>
<finalName>${project.artifactId}-${project.version}</finalName>
<appendAssemblyId>false</appendAssemblyId>
</configuration>
</execution>
</executions>
</plugin>

View File

@@ -1,29 +1,25 @@
#!/bin/bash
# label-backend 启动脚本
# - Docker 环境(检测 /.dockerenvexec 前台运行,保持容器进程存活
# - 裸机 / VMnohup 后台运行,日志追加至 logs/startup.log
set -e
# 1. 获取脚本所在目录的绝对路径
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASEDIR=$(cd "$(dirname "$0")/.." && pwd)
LIBDIR="$BASEDIR/libs"
CONFDIR="$BASEDIR/etc"
LOGDIR="$BASEDIR/logs"
# 2. 获取项目根目录 (假设 bin 在根目录下)
APP_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
mkdir -p "$LOGDIR"
# 3. 【关键步骤】切换到项目根目录
# 这样相对路径 "logs" 就会指向 $APP_HOME/logs
cd "$APP_HOME"
JVM_OPTS="${JVM_OPTS:--Xms512m -Xmx1024m}"
MAIN_CLASS="com.label.LabelBackendApplication"
JAVA_ARGS="$JVM_OPTS \
-Dspring.config.location=file:$CONFDIR/application.yml \
-Dlogging.config=file:$CONFDIR/logback.xml \
-cp $LIBDIR/*"
# 4. 确保 logs 目录存在
mkdir -p logs
if [ -f /.dockerenv ]; then
# Docker 容器exec 替换当前进程PID=1 接管信号
exec java $JAVA_ARGS $MAIN_CLASS
else
# 裸机 / VMnohup 后台运行
nohup java $JAVA_ARGS $MAIN_CLASS >> "$LOGDIR/startup.log" 2>&1 &
echo "label-backend started, PID=$!"
fi
# 5. 定义其他变量
JAR_FILE="$APP_HOME/libs/label-backend-1.0.0-SNAPSHOT.jar"
# 6. 启动应用
nohup java -Xms512m -Xmx512m \
-jar "$JAR_FILE" \
> /dev/null 2>&1 &
# 如果希望保留控制台日志备份,可以重定向到 $APP_HOME/logs/console.log
echo "Application started. Logs at: $APP_HOME/logs/"

View File

@@ -1,5 +1,6 @@
package com.label.common.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
@@ -36,87 +37,190 @@ public class AiServiceClient {
@Data
@Builder
public static class ExtractionRequest {
private Long sourceId;
public static class TextExtractRequest {
@JsonProperty("file_path")
private String filePath;
private String bucket;
@JsonProperty("file_name")
private String fileName;
private String model;
private String prompt;
@JsonProperty("prompt_template")
private String promptTemplate;
}
@Data
@Builder
public static class ImageExtractRequest {
@JsonProperty("file_path")
private String filePath;
@JsonProperty("task_id")
private Long taskId;
private String model;
@JsonProperty("prompt_template")
private String promptTemplate;
}
@Data
public static class ExtractionResponse {
private List<Map<String, Object>> items; // triple/quadruple items
private String rawOutput;
}
@Data
@Builder
public static class VideoProcessRequest {
private Long sourceId;
public static class ExtractFramesRequest {
@JsonProperty("file_path")
private String filePath;
private String bucket;
private Map<String, Object> params; // frameInterval, mode etc.
@JsonProperty("source_id")
private Long sourceId;
@JsonProperty("job_id")
private Long jobId;
private String mode;
@JsonProperty("frame_interval")
private Integer frameInterval;
}
@Data
@Builder
public static class VideoToTextRequest {
@JsonProperty("file_path")
private String filePath;
@JsonProperty("source_id")
private Long sourceId;
@JsonProperty("job_id")
private Long jobId;
@JsonProperty("start_sec")
private Double startSec;
@JsonProperty("end_sec")
private Double endSec;
private String model;
@JsonProperty("prompt_template")
private String promptTemplate;
}
@Data
public static class TextQaItem {
private String subject;
private String predicate;
private String object;
@JsonProperty("source_snippet")
private String sourceSnippet;
}
@Data
@Builder
public static class GenTextQaRequest {
private List<TextQaItem> items;
private String model;
@JsonProperty("prompt_template")
private String promptTemplate;
}
@Data
public static class ImageQaItem {
private String subject;
private String predicate;
private String object;
private String qualifier;
@JsonProperty("cropped_image_path")
private String croppedImagePath;
}
@Data
@Builder
public static class GenImageQaRequest {
private List<ImageQaItem> items;
private String model;
@JsonProperty("prompt_template")
private String promptTemplate;
}
@Data
public static class QaGenResponse {
private List<Map<String, Object>> qaPairs;
private List<Map<String, Object>> pairs;
}
@Data
@Builder
public static class FinetuneRequest {
private String datasetPath; // RustFS path to JSONL file
private String model;
private Long batchId;
public static class FinetuneStartRequest {
@JsonProperty("jsonl_url")
private String jsonlUrl;
@JsonProperty("base_model")
private String baseModel;
private Map<String, Object> hyperparams;
}
@Data
public static class FinetuneResponse {
public static class FinetuneStartResponse {
@JsonProperty("job_id")
private String jobId;
private String status;
}
@Data
public static class FinetuneStatusResponse {
@JsonProperty("job_id")
private String jobId;
private String status; // PENDING/RUNNING/COMPLETED/FAILED
private Integer progress; // 0-100
@JsonProperty("error_message")
private String errorMessage;
}
// The 8 endpoints:
public ExtractionResponse extractText(ExtractionRequest request) {
return restTemplate.postForObject("/extract/text", request, ExtractionResponse.class);
public ExtractionResponse extractText(TextExtractRequest request) {
return restTemplate.postForObject("/api/v1/text/extract", request, ExtractionResponse.class);
}
public ExtractionResponse extractImage(ExtractionRequest request) {
return restTemplate.postForObject("/extract/image", request, ExtractionResponse.class);
public ExtractionResponse extractImage(ImageExtractRequest request) {
return restTemplate.postForObject("/api/v1/image/extract", request, ExtractionResponse.class);
}
public void extractFrames(VideoProcessRequest request) {
restTemplate.postForLocation("/video/extract-frames", request);
public void extractFrames(ExtractFramesRequest request) {
restTemplate.postForLocation("/api/v1/video/extract-frames", request);
}
public void videoToText(VideoProcessRequest request) {
restTemplate.postForLocation("/video/to-text", request);
public void videoToText(VideoToTextRequest request) {
restTemplate.postForLocation("/api/v1/video/to-text", request);
}
public QaGenResponse genTextQa(ExtractionRequest request) {
return restTemplate.postForObject("/qa/gen-text", request, QaGenResponse.class);
public QaGenResponse genTextQa(GenTextQaRequest request) {
return restTemplate.postForObject("/api/v1/qa/gen-text", request, QaGenResponse.class);
}
public QaGenResponse genImageQa(ExtractionRequest request) {
return restTemplate.postForObject("/qa/gen-image", request, QaGenResponse.class);
public QaGenResponse genImageQa(GenImageQaRequest request) {
return restTemplate.postForObject("/api/v1/qa/gen-image", request, QaGenResponse.class);
}
public FinetuneResponse startFinetune(FinetuneRequest request) {
return restTemplate.postForObject("/finetune/start", request, FinetuneResponse.class);
public FinetuneStartResponse startFinetune(FinetuneStartRequest request) {
return restTemplate.postForObject("/api/v1/finetune/start", request, FinetuneStartResponse.class);
}
public FinetuneStatusResponse getFinetuneStatus(String jobId) {
return restTemplate.getForObject("/finetune/status/{jobId}", FinetuneStatusResponse.class, jobId);
return restTemplate.getForObject("/api/v1/finetune/status/{jobId}", FinetuneStatusResponse.class, jobId);
}
}

View File

@@ -1,14 +1,23 @@
package com.label.common.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "分页响应")
public class PageResult<T> {
@Schema(description = "当前页数据列表")
private List<T> items;
@Schema(description = "总条数", example = "123")
private long total;
@Schema(description = "页码(从 1 开始)", example = "1")
private int page;
@Schema(description = "每页条数", example = "20")
private int pageSize;
public static <T> PageResult<T> of(List<T> items, long total, int page, int pageSize) {

View File

@@ -1,11 +1,18 @@
package com.label.common.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "通用响应包装")
public class Result<T> {
@Schema(description = "业务状态码", example = "SUCCESS")
private String code;
@Schema(description = "响应数据")
private T data;
@Schema(description = "提示信息", example = "操作成功")
private String message;
public static <T> Result<T> success(T data) {

View File

@@ -1,14 +0,0 @@
package com.label.common.statemachine;
import java.util.Map;
import java.util.Set;
public enum DatasetStatus {
PENDING_REVIEW, APPROVED, REJECTED;
public static final Map<DatasetStatus, Set<DatasetStatus>> TRANSITIONS = Map.of(
PENDING_REVIEW, Set.of(APPROVED, REJECTED),
REJECTED, Set.of(PENDING_REVIEW) // 重新提交审核
// APPROVED: terminal state
);
}

View File

@@ -1,20 +0,0 @@
package com.label.common.statemachine;
import java.util.Map;
import java.util.Set;
public enum VideoJobStatus {
PENDING, RUNNING, SUCCESS, FAILED, RETRYING;
/**
* Automatic state machine transitions.
* Note: FAILED → PENDING is a manual ADMIN operation, handled separately in VideoProcessService.reset().
*/
public static final Map<VideoJobStatus, Set<VideoJobStatus>> TRANSITIONS = Map.of(
PENDING, Set.of(RUNNING),
RUNNING, Set.of(SUCCESS, FAILED, RETRYING),
RETRYING, Set.of(RUNNING, FAILED)
// SUCCESS: terminal state
// FAILED → PENDING: manual ADMIN reset, NOT in this automatic transitions map
);
}

View File

@@ -3,10 +3,10 @@ package com.label.common.statemachine;
import java.util.Map;
import java.util.Set;
public enum SourceStatus {
public enum VideoSourceStatus {
PENDING, PREPROCESSING, EXTRACTING, QA_REVIEW, APPROVED;
public static final Map<SourceStatus, Set<SourceStatus>> TRANSITIONS = Map.of(
public static final Map<VideoSourceStatus, Set<VideoSourceStatus>> TRANSITIONS = Map.of(
PENDING, Set.of(EXTRACTING, PREPROCESSING),
PREPROCESSING, Set.of(PENDING),
EXTRACTING, Set.of(QA_REVIEW),

View File

@@ -0,0 +1,26 @@
package com.label.config;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("aiTaskExecutor")
public Executor aiTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("ai-annotate-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

View File

@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.*;
*/
@Tag(name = "认证管理", description = "登录、退出和当前用户信息")
@RestController
@RequestMapping("/api/auth")
@RequestMapping("/label/api/auth")
@RequiredArgsConstructor
public class AuthController {
@@ -34,7 +34,11 @@ public class AuthController {
*/
@Operation(summary = "用户登录,返回 Bearer Token")
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
public Result<LoginResponse> login(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "用户登录请求体",
required = true)
@RequestBody LoginRequest request) {
return Result.success(authService.login(request));
}

View File

@@ -3,9 +3,13 @@ package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.dto.CompanyCreateRequest;
import com.label.dto.CompanyStatusUpdateRequest;
import com.label.dto.CompanyUpdateRequest;
import com.label.entity.SysCompany;
import com.label.service.CompanyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@@ -20,11 +24,9 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Tag(name = "公司管理", description = "租户公司增删改查")
@RestController
@RequestMapping("/api/companies")
@RequestMapping("/label/api/companies")
@RequiredArgsConstructor
public class CompanyController {
@@ -34,8 +36,11 @@ public class CompanyController {
@GetMapping
@RequireRole("ADMIN")
public Result<PageResult<SysCompany>> list(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
@Parameter(description = "公司状态过滤可选值ACTIVE、DISABLED", example = "ACTIVE")
@RequestParam(required = false) String status) {
return Result.success(companyService.list(page, pageSize, status));
}
@@ -44,29 +49,47 @@ public class CompanyController {
@PostMapping
@RequireRole("ADMIN")
@ResponseStatus(HttpStatus.CREATED)
public Result<SysCompany> create(@RequestBody Map<String, String> body) {
return Result.success(companyService.create(body.get("companyName"), body.get("companyCode")));
public Result<SysCompany> create(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建公司请求体",
required = true)
@RequestBody CompanyCreateRequest body) {
return Result.success(companyService.create(body.getCompanyName(), body.getCompanyCode()));
}
@Operation(summary = "更新公司信息")
@PutMapping("/{id}")
@RequireRole("ADMIN")
public Result<SysCompany> update(@PathVariable Long id, @RequestBody Map<String, String> body) {
return Result.success(companyService.update(id, body.get("companyName"), body.get("companyCode")));
public Result<SysCompany> update(
@Parameter(description = "公司 ID", example = "100")
@PathVariable Long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "更新公司信息请求体",
required = true)
@RequestBody CompanyUpdateRequest body) {
return Result.success(companyService.update(id, body.getCompanyName(), body.getCompanyCode()));
}
@Operation(summary = "更新公司状态")
@PutMapping("/{id}/status")
@RequireRole("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
companyService.updateStatus(id, body.get("status"));
public Result<Void> updateStatus(
@Parameter(description = "公司 ID", example = "100")
@PathVariable Long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "更新公司状态请求体",
required = true)
@RequestBody CompanyStatusUpdateRequest body) {
companyService.updateStatus(id, body.getStatus());
return Result.success(null);
}
@Operation(summary = "删除公司")
@DeleteMapping("/{id}")
@RequireRole("ADMIN")
public Result<Void> delete(@PathVariable Long id) {
public Result<Void> delete(
@Parameter(description = "公司 ID", example = "100")
@PathVariable Long id) {
companyService.delete(id);
return Result.success(null);
}

View File

@@ -4,11 +4,14 @@ import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.dto.ExportBatchCreateRequest;
import com.label.dto.FinetuneJobResponse;
import com.label.entity.TrainingDataset;
import com.label.entity.ExportBatch;
import com.label.service.ExportService;
import com.label.service.FinetuneService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -23,6 +26,7 @@ import java.util.Map;
*/
@Tag(name = "导出管理", description = "训练样本查询、导出批次和微调任务")
@RestController
@RequestMapping("/label")
@RequiredArgsConstructor
public class ExportController {
@@ -34,9 +38,13 @@ public class ExportController {
@GetMapping("/api/training/samples")
@RequireRole("ADMIN")
public Result<PageResult<TrainingDataset>> listSamples(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
@Parameter(description = "样本类型过滤可选值EXTRACTION、QA_GENERATION", example = "EXTRACTION")
@RequestParam(required = false) String sampleType,
@Parameter(description = "是否已导出过滤", example = "false")
@RequestParam(required = false) Boolean exported,
HttpServletRequest request) {
return Result.success(exportService.listSamples(page, pageSize, sampleType, exported, principal(request)));
@@ -47,32 +55,35 @@ public class ExportController {
@PostMapping("/api/export/batch")
@RequireRole("ADMIN")
@ResponseStatus(HttpStatus.CREATED)
public Result<ExportBatch> createBatch(@RequestBody Map<String, Object> body,
public Result<ExportBatch> createBatch(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建训练数据导出批次请求体",
required = true)
@RequestBody ExportBatchCreateRequest 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)));
return Result.success(exportService.createBatch(body.getSampleIds(), principal(request)));
}
/** POST /api/export/{batchId}/finetune — 提交微调任务 */
@Operation(summary = "提交微调任务")
@PostMapping("/api/export/{batchId}/finetune")
@RequireRole("ADMIN")
public Result<Map<String, Object>> triggerFinetune(@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(finetuneService.trigger(batchId, principal(request)));
public Result<FinetuneJobResponse> triggerFinetune(
@Parameter(description = "导出批次 ID", example = "501")
@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(toFinetuneJobResponse(finetuneService.trigger(batchId, principal(request))));
}
/** GET /api/export/{batchId}/status — 查询微调状态 */
@Operation(summary = "查询微调状态")
@GetMapping("/api/export/{batchId}/status")
@RequireRole("ADMIN")
public Result<Map<String, Object>> getFinetuneStatus(@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(finetuneService.getStatus(batchId, principal(request)));
public Result<FinetuneJobResponse> getFinetuneStatus(
@Parameter(description = "导出批次 ID", example = "501")
@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(toFinetuneJobResponse(finetuneService.getStatus(batchId, principal(request))));
}
/** GET /api/export/list — 分页查询导出批次列表 */
@@ -80,12 +91,36 @@ public class ExportController {
@GetMapping("/api/export/list")
@RequireRole("ADMIN")
public Result<PageResult<ExportBatch>> listBatches(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
HttpServletRequest request) {
return Result.success(exportService.listBatches(page, pageSize, principal(request)));
}
private FinetuneJobResponse toFinetuneJobResponse(Map<String, Object> values) {
FinetuneJobResponse response = new FinetuneJobResponse();
response.setBatchId(asLong(values.get("batchId")));
response.setGlmJobId(asString(values.get("glmJobId")));
response.setFinetuneStatus(asString(values.get("finetuneStatus")));
response.setProgress(asInteger(values.get("progress")));
response.setErrorMessage(asString(values.get("errorMessage")));
return response;
}
private Long asLong(Object value) {
return value == null ? null : Long.parseLong(value.toString());
}
private Integer asInteger(Object value) {
return value == null ? null : Integer.parseInt(value.toString());
}
private String asString(Object value) {
return value == null ? null : value.toString();
}
private TokenPrincipal principal(HttpServletRequest request) {
return (TokenPrincipal) request.getAttribute("__token_principal__");
}

View File

@@ -1,34 +1,56 @@
package com.label.controller;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.dto.RejectRequest;
import com.label.service.ExtractionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 提取阶段标注工作台接口5 个端点)。
*/
@Tag(name = "提取标注", description = "提取阶段的查看、编辑、提交和审批")
@RestController
@RequestMapping("/api/extraction")
@RequestMapping("/label/api/extraction")
@RequiredArgsConstructor
public class ExtractionController {
private final ExtractionService extractionService;
/** POST /api/extraction/{taskId}/ai-annotate — AI 辅助预标注 */
@Operation(summary = "AI 辅助预标注", description = "调用 AI 服务自动生成预标注结果,可重复调用")
@PostMapping("/{taskId}/ai-annotate")
@RequireRole("ANNOTATOR")
public Result<Void> aiPreAnnotate(
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
HttpServletRequest request) {
extractionService.aiPreAnnotate(taskId, principal(request));
return Result.success(null);
}
/** GET /api/extraction/{taskId} — 获取当前标注结果 */
@Operation(summary = "获取提取标注结果")
@GetMapping("/{taskId}")
@RequireRole("ANNOTATOR")
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
public Result<Map<String, Object>> getResult(
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(extractionService.getResult(taskId, principal(request)));
}
@@ -36,9 +58,10 @@ public class ExtractionController {
@Operation(summary = "更新提取标注结果")
@PutMapping("/{taskId}")
@RequireRole("ANNOTATOR")
public Result<Void> updateResult(@PathVariable Long taskId,
@RequestBody String resultJson,
HttpServletRequest request) {
public Result<Void> updateResult(
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "完整提取标注结果 JSON 字符串,保持原始 JSON body 直接提交", required = true) @RequestBody String resultJson,
HttpServletRequest request) {
extractionService.updateResult(taskId, resultJson, principal(request));
return Result.success(null);
}
@@ -47,8 +70,9 @@ public class ExtractionController {
@Operation(summary = "提交提取标注结果")
@PostMapping("/{taskId}/submit")
@RequireRole("ANNOTATOR")
public Result<Void> submit(@PathVariable Long taskId,
HttpServletRequest request) {
public Result<Void> submit(
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
HttpServletRequest request) {
extractionService.submit(taskId, principal(request));
return Result.success(null);
}
@@ -57,8 +81,9 @@ public class ExtractionController {
@Operation(summary = "审批通过提取结果")
@PostMapping("/{taskId}/approve")
@RequireRole("REVIEWER")
public Result<Void> approve(@PathVariable Long taskId,
HttpServletRequest request) {
public Result<Void> approve(
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
HttpServletRequest request) {
extractionService.approve(taskId, principal(request));
return Result.success(null);
}
@@ -67,10 +92,11 @@ public class ExtractionController {
@Operation(summary = "驳回提取结果")
@PostMapping("/{taskId}/reject")
@RequireRole("REVIEWER")
public Result<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
String reason = body != null ? body.get("reason") : null;
public Result<Void> reject(
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "驳回提取结果请求体", required = true) @RequestBody RejectRequest body,
HttpServletRequest request) {
String reason = body != null ? body.getReason() : null;
extractionService.reject(taskId, reason, principal(request));
return Result.success(null);
}

View File

@@ -3,8 +3,10 @@ package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.dto.RejectRequest;
import com.label.service.QaService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -17,7 +19,7 @@ import java.util.Map;
*/
@Tag(name = "问答生成", description = "问答生成阶段的查看、编辑、提交和审批")
@RestController
@RequestMapping("/api/qa")
@RequestMapping("/label/api/qa")
@RequiredArgsConstructor
public class QaController {
@@ -27,8 +29,10 @@ public class QaController {
@Operation(summary = "获取候选问答对")
@GetMapping("/{taskId}")
@RequireRole("ANNOTATOR")
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
public Result<Map<String, Object>> getResult(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(qaService.getResult(taskId, principal(request)));
}
@@ -36,8 +40,13 @@ public class QaController {
@Operation(summary = "更新候选问答对")
@PutMapping("/{taskId}")
@RequireRole("ANNOTATOR")
public Result<Void> updateResult(@PathVariable Long taskId,
@RequestBody String body,
public Result<Void> updateResult(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "完整问答标注结果 JSON 字符串,保持原始 JSON body 直接提交",
required = true)
@RequestBody String body,
HttpServletRequest request) {
qaService.updateResult(taskId, body, principal(request));
return Result.success(null);
@@ -47,8 +56,10 @@ public class QaController {
@Operation(summary = "提交问答对")
@PostMapping("/{taskId}/submit")
@RequireRole("ANNOTATOR")
public Result<Void> submit(@PathVariable Long taskId,
HttpServletRequest request) {
public Result<Void> submit(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long taskId,
HttpServletRequest request) {
qaService.submit(taskId, principal(request));
return Result.success(null);
}
@@ -57,8 +68,10 @@ public class QaController {
@Operation(summary = "审批通过问答对")
@PostMapping("/{taskId}/approve")
@RequireRole("REVIEWER")
public Result<Void> approve(@PathVariable Long taskId,
HttpServletRequest request) {
public Result<Void> approve(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long taskId,
HttpServletRequest request) {
qaService.approve(taskId, principal(request));
return Result.success(null);
}
@@ -67,10 +80,15 @@ public class QaController {
@Operation(summary = "驳回答案对")
@PostMapping("/{taskId}/reject")
@RequireRole("REVIEWER")
public Result<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> body,
public Result<Void> reject(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "驳回问答结果请求体",
required = true)
@RequestBody RejectRequest body,
HttpServletRequest request) {
String reason = body != null ? body.get("reason") : null;
String reason = body != null ? body.getReason() : null;
qaService.reject(taskId, reason, principal(request));
return Result.success(null);
}

View File

@@ -7,6 +7,7 @@ import com.label.common.result.Result;
import com.label.dto.SourceResponse;
import com.label.service.SourceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -23,7 +24,7 @@ import org.springframework.web.multipart.MultipartFile;
*/
@Tag(name = "资料管理", description = "原始资料上传、查询和删除")
@RestController
@RequestMapping("/api/source")
@RequestMapping("/label/api/source")
@RequiredArgsConstructor
public class SourceController {
@@ -38,7 +39,9 @@ public class SourceController {
@RequireRole("UPLOADER")
@ResponseStatus(HttpStatus.CREATED)
public Result<SourceResponse> upload(
@Parameter(description = "上传文件,支持文本、图片、视频", required = true)
@RequestParam("file") MultipartFile file,
@Parameter(description = "资料类型可选值text、image、video", example = "text", required = true)
@RequestParam("dataType") String dataType,
HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
@@ -53,9 +56,13 @@ public class SourceController {
@GetMapping("/list")
@RequireRole("UPLOADER")
public Result<PageResult<SourceResponse>> list(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
@Parameter(description = "资料类型过滤可选值text、image、video", example = "text")
@RequestParam(required = false) String dataType,
@Parameter(description = "资料状态过滤", example = "PENDING")
@RequestParam(required = false) String status,
HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
@@ -68,7 +75,9 @@ public class SourceController {
@Operation(summary = "查询资料详情")
@GetMapping("/{id}")
@RequireRole("UPLOADER")
public Result<SourceResponse> findById(@PathVariable Long id) {
public Result<SourceResponse> findById(
@Parameter(description = "资料 ID", example = "1001")
@PathVariable Long id) {
return Result.success(sourceService.findById(id));
}
@@ -79,7 +88,10 @@ public class SourceController {
@Operation(summary = "删除资料")
@DeleteMapping("/{id}")
@RequireRole("ADMIN")
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
public Result<Void> delete(
@Parameter(description = "资料 ID", example = "1001")
@PathVariable Long id,
HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
sourceService.delete(id, principal.getCompanyId());
return Result.success(null);

View File

@@ -3,9 +3,12 @@ package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.dto.SysConfigItemResponse;
import com.label.dto.SysConfigUpdateRequest;
import com.label.entity.SysConfig;
import com.label.service.SysConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -22,6 +25,7 @@ import java.util.Map;
*/
@Tag(name = "系统配置", description = "全局和公司级系统配置管理")
@RestController
@RequestMapping("/label")
@RequiredArgsConstructor
public class SysConfigController {
@@ -37,9 +41,11 @@ public class SysConfigController {
@Operation(summary = "查询合并后的系统配置")
@GetMapping("/api/config")
@RequireRole("ADMIN")
public Result<List<Map<String, Object>>> listConfig(HttpServletRequest request) {
public Result<List<SysConfigItemResponse>> listConfig(HttpServletRequest request) {
TokenPrincipal principal = principal(request);
return Result.success(sysConfigService.list(principal.getCompanyId()));
return Result.success(sysConfigService.list(principal.getCompanyId()).stream()
.map(this::toConfigItemResponse)
.toList());
}
/**
@@ -50,14 +56,36 @@ public class SysConfigController {
@Operation(summary = "更新或创建公司专属配置")
@PutMapping("/api/config/{key}")
@RequireRole("ADMIN")
public Result<SysConfig> updateConfig(@PathVariable String key,
@RequestBody Map<String, String> body,
public Result<SysConfig> updateConfig(
@Parameter(description = "系统配置键可选值token_ttl_seconds、model_default、video_frame_interval", example = "model_default")
@PathVariable String key,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "系统配置更新请求体",
required = true)
@RequestBody SysConfigUpdateRequest 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()));
sysConfigService.update(key, body.getValue(), body.getDescription(), principal.getCompanyId()));
}
private SysConfigItemResponse toConfigItemResponse(Map<String, Object> item) {
SysConfigItemResponse response = new SysConfigItemResponse();
response.setId(asLong(item.get("id")));
response.setConfigKey(asString(item.get("configKey")));
response.setConfigValue(asString(item.get("configValue")));
response.setDescription(asString(item.get("description")));
response.setScope(asString(item.get("scope")));
response.setCompanyId(asLong(item.get("companyId")));
return response;
}
private Long asLong(Object value) {
return value == null ? null : Long.parseLong(value.toString());
}
private String asString(Object value) {
return value == null ? null : value.toString();
}
private TokenPrincipal principal(HttpServletRequest request) {

View File

@@ -4,23 +4,24 @@ import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.dto.CreateTaskRequest;
import com.label.dto.TaskReassignRequest;
import com.label.dto.TaskResponse;
import com.label.service.TaskClaimService;
import com.label.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 任务管理接口10 个端点)。
*/
@Tag(name = "任务管理", description = "任务池、我的任务、审批队列和管理操作")
@RestController
@RequestMapping("/api/tasks")
@RequestMapping("/label/api/tasks")
@RequiredArgsConstructor
public class TaskController {
@@ -32,7 +33,9 @@ public class TaskController {
@GetMapping("/pool")
@RequireRole("ANNOTATOR")
public Result<PageResult<TaskResponse>> getPool(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
HttpServletRequest request) {
return Result.success(taskService.getPool(page, pageSize, principal(request)));
@@ -43,8 +46,11 @@ public class TaskController {
@GetMapping("/mine")
@RequireRole("ANNOTATOR")
public Result<PageResult<TaskResponse>> getMine(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
@Parameter(description = "任务状态过滤可选值UNCLAIMED、IN_PROGRESS、SUBMITTED、APPROVED、REJECTED", example = "IN_PROGRESS")
@RequestParam(required = false) String status,
HttpServletRequest request) {
return Result.success(taskService.getMine(page, pageSize, status, principal(request)));
@@ -55,8 +61,11 @@ public class TaskController {
@GetMapping("/pending-review")
@RequireRole("REVIEWER")
public Result<PageResult<TaskResponse>> getPendingReview(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
@Parameter(description = "任务类型过滤可选值EXTRACTION、QA_GENERATION", example = "EXTRACTION")
@RequestParam(required = false) String taskType) {
return Result.success(taskService.getPendingReview(page, pageSize, taskType));
}
@@ -64,11 +73,15 @@ public class TaskController {
/** GET /api/tasks — 查询全部任务ADMIN */
@Operation(summary = "管理员查询全部任务")
@GetMapping
@RequireRole("ADMIN")
@RequireRole("ANNOTATOR")
public Result<PageResult<TaskResponse>> getAll(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
@Parameter(description = "任务状态过滤可选值UNCLAIMED、IN_PROGRESS、SUBMITTED、APPROVED、REJECTED", example = "SUBMITTED")
@RequestParam(required = false) String status,
@Parameter(description = "任务类型过滤可选值EXTRACTION、QA_GENERATION", example = "QA_GENERATION")
@RequestParam(required = false) String taskType) {
return Result.success(taskService.getAll(page, pageSize, status, taskType));
}
@@ -77,20 +90,24 @@ public class TaskController {
@Operation(summary = "管理员创建任务")
@PostMapping
@RequireRole("ADMIN")
public Result<TaskResponse> createTask(@RequestBody Map<String, Object> body,
public Result<TaskResponse> createTask(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建标注任务请求体",
required = true)
@RequestBody CreateTaskRequest 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())));
taskService.createTask(body.getSourceId(), body.getTaskType(), principal.getCompanyId())));
}
/** GET /api/tasks/{id} — 查询任务详情 */
@Operation(summary = "查询任务详情")
@GetMapping("/{id}")
@RequireRole("ANNOTATOR")
public Result<TaskResponse> getById(@PathVariable Long id) {
public Result<TaskResponse> getById(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long id) {
return Result.success(taskService.toPublicResponse(taskService.getById(id)));
}
@@ -98,7 +115,10 @@ public class TaskController {
@Operation(summary = "领取任务")
@PostMapping("/{id}/claim")
@RequireRole("ANNOTATOR")
public Result<Void> claim(@PathVariable Long id, HttpServletRequest request) {
public Result<Void> claim(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long id,
HttpServletRequest request) {
taskClaimService.claim(id, principal(request));
return Result.success(null);
}
@@ -107,7 +127,10 @@ public class TaskController {
@Operation(summary = "放弃任务")
@PostMapping("/{id}/unclaim")
@RequireRole("ANNOTATOR")
public Result<Void> unclaim(@PathVariable Long id, HttpServletRequest request) {
public Result<Void> unclaim(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long id,
HttpServletRequest request) {
taskClaimService.unclaim(id, principal(request));
return Result.success(null);
}
@@ -116,7 +139,10 @@ public class TaskController {
@Operation(summary = "重领被驳回的任务")
@PostMapping("/{id}/reclaim")
@RequireRole("ANNOTATOR")
public Result<Void> reclaim(@PathVariable Long id, HttpServletRequest request) {
public Result<Void> reclaim(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long id,
HttpServletRequest request) {
taskClaimService.reclaim(id, principal(request));
return Result.success(null);
}
@@ -125,11 +151,15 @@ public class TaskController {
@Operation(summary = "管理员强制指派任务")
@PutMapping("/{id}/reassign")
@RequireRole("ADMIN")
public Result<Void> reassign(@PathVariable Long id,
@RequestBody Map<String, Object> body,
public Result<Void> reassign(
@Parameter(description = "任务 ID", example = "1001")
@PathVariable Long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "管理员强制改派任务请求体",
required = true)
@RequestBody TaskReassignRequest body,
HttpServletRequest request) {
Long targetUserId = Long.parseLong(body.get("userId").toString());
taskService.reassign(id, targetUserId, principal(request));
taskService.reassign(id, body.getUserId(), principal(request));
return Result.success(null);
}

View File

@@ -1,7 +1,5 @@
package com.label.controller;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -15,10 +13,15 @@ import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.dto.UserCreateRequest;
import com.label.dto.UserRoleUpdateRequest;
import com.label.dto.UserStatusUpdateRequest;
import com.label.dto.UserUpdateRequest;
import com.label.entity.SysUser;
import com.label.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -28,7 +31,7 @@ import lombok.RequiredArgsConstructor;
*/
@Tag(name = "用户管理", description = "管理员维护公司用户")
@RestController
@RequestMapping("/api/users")
@RequestMapping("/label/api/users")
@RequiredArgsConstructor
public class UserController {
@@ -39,7 +42,9 @@ public class UserController {
@GetMapping
@RequireRole("ADMIN")
public Result<PageResult<SysUser>> listUsers(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数", example = "20")
@RequestParam(defaultValue = "20") int pageSize,
HttpServletRequest request) {
return Result.success(userService.listUsers(page, pageSize, principal(request)));
@@ -49,13 +54,17 @@ public class UserController {
@Operation(summary = "创建用户")
@PostMapping
@RequireRole("ADMIN")
public Result<SysUser> createUser(@RequestBody Map<String, String> body,
public Result<SysUser> createUser(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建用户请求体",
required = true)
@RequestBody UserCreateRequest body,
HttpServletRequest request) {
return Result.success(userService.createUser(
body.get("username"),
body.get("password"),
body.get("realName"),
body.get("role"),
body.getUsername(),
body.getPassword(),
body.getRealName(),
body.getRole(),
principal(request)));
}
@@ -63,13 +72,18 @@ public class UserController {
@Operation(summary = "更新用户基本信息")
@PutMapping("/{id}")
@RequireRole("ADMIN")
public Result<SysUser> updateUser(@PathVariable Long id,
@RequestBody Map<String, String> body,
public Result<SysUser> updateUser(
@Parameter(description = "用户 ID", example = "2001")
@PathVariable Long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "更新用户基本信息请求体",
required = true)
@RequestBody UserUpdateRequest body,
HttpServletRequest request) {
return Result.success(userService.updateUser(
id,
body.get("realName"),
body.get("password"),
body.getRealName(),
body.getPassword(),
principal(request)));
}
@@ -77,10 +91,15 @@ public class UserController {
@Operation(summary = "变更用户状态", description = "statusACTIVE、DISABLED")
@PutMapping("/{id}/status")
@RequireRole("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id,
@RequestBody Map<String, String> body,
public Result<Void> updateStatus(
@Parameter(description = "用户 ID", example = "2001")
@PathVariable Long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "更新用户状态请求体",
required = true)
@RequestBody UserStatusUpdateRequest body,
HttpServletRequest request) {
userService.updateStatus(id, body.get("status"), principal(request));
userService.updateStatus(id, body.getStatus(), principal(request));
return Result.success(null);
}
@@ -88,10 +107,15 @@ public class UserController {
@Operation(summary = "变更用户角色", description = "roleADMIN、UPLOADER、VIEWER")
@PutMapping("/{id}/role")
@RequireRole("ADMIN")
public Result<Void> updateRole(@PathVariable Long id,
@RequestBody Map<String, String> body,
public Result<Void> updateRole(
@Parameter(description = "用户 ID", example = "2001")
@PathVariable Long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "更新用户角色请求体",
required = true)
@RequestBody UserRoleUpdateRequest body,
HttpServletRequest request) {
userService.updateRole(id, body.get("role"), principal(request));
userService.updateRole(id, body.getRole(), principal(request));
return Result.success(null);
}

View File

@@ -3,9 +3,12 @@ package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.dto.VideoProcessCallbackRequest;
import com.label.dto.VideoProcessCreateRequest;
import com.label.entity.VideoProcessJob;
import com.label.service.VideoProcessService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -13,8 +16,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 视频处理接口4 个端点)。
*
@@ -26,6 +27,7 @@ import java.util.Map;
@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调")
@Slf4j
@RestController
@RequestMapping("/label")
@RequiredArgsConstructor
public class VideoController {
@@ -38,16 +40,18 @@ public class VideoController {
@Operation(summary = "触发视频处理任务")
@PostMapping("/api/video/process")
@RequireRole("ADMIN")
public Result<VideoProcessJob> createJob(@RequestBody Map<String, Object> body,
public Result<VideoProcessJob> createJob(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建视频处理任务请求体",
required = true)
@RequestBody VideoProcessCreateRequest body,
HttpServletRequest request) {
Object sourceIdVal = body.get("sourceId");
Object jobTypeVal = body.get("jobType");
if (sourceIdVal == null || jobTypeVal == null) {
Long sourceId = body.getSourceId();
String jobType = body.getJobType();
if (sourceId == null || jobType == 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;
String params = body.getParams();
TokenPrincipal principal = principal(request);
return Result.success(
@@ -58,8 +62,10 @@ public class VideoController {
@Operation(summary = "查询视频处理任务状态")
@GetMapping("/api/video/jobs/{jobId}")
@RequireRole("ADMIN")
public Result<VideoProcessJob> getJob(@PathVariable Long jobId,
HttpServletRequest request) {
public Result<VideoProcessJob> getJob(
@Parameter(description = "视频处理任务 ID", example = "9001")
@PathVariable Long jobId,
HttpServletRequest request) {
return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId()));
}
@@ -67,8 +73,10 @@ public class VideoController {
@Operation(summary = "重置失败的视频处理任务")
@PostMapping("/api/video/jobs/{jobId}/reset")
@RequireRole("ADMIN")
public Result<VideoProcessJob> resetJob(@PathVariable Long jobId,
HttpServletRequest request) {
public Result<VideoProcessJob> resetJob(
@Parameter(description = "视频处理任务 ID", example = "9001")
@PathVariable Long jobId,
HttpServletRequest request) {
return Result.success(videoProcessService.reset(jobId, principal(request).getCompanyId()));
}
@@ -84,7 +92,11 @@ public class VideoController {
*/
@Operation(summary = "接收 AI 服务视频处理回调")
@PostMapping("/api/video/callback")
public Result<Void> handleCallback(@RequestBody Map<String, Object> body,
public Result<Void> handleCallback(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "AI 服务视频处理回调请求体",
required = true)
@RequestBody VideoProcessCallbackRequest body,
HttpServletRequest request) {
// 共享密钥校验(配置了 VIDEO_CALLBACK_SECRET 时强制校验)
if (callbackSecret != null && !callbackSecret.isBlank()) {
@@ -94,10 +106,10 @@ public class VideoController {
}
}
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;
Long jobId = body.getJobId();
String status = body.getStatus();
String outputPath = body.getOutputPath();
String errorMessage = body.getErrorMessage();
log.info("视频处理回调jobId={}, status={}", jobId, status);
videoProcessService.handleCallback(jobId, status, outputPath, errorMessage);

View File

@@ -0,0 +1,14 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "创建公司请求")
public class CompanyCreateRequest {
@Schema(description = "公司名称", example = "示例科技")
private String companyName;
@Schema(description = "公司代码(英文简写)", example = "DEMO")
private String companyCode;
}

View File

@@ -0,0 +1,11 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "公司状态变更请求")
public class CompanyStatusUpdateRequest {
@Schema(description = "公司状态可选值ACTIVE / DISABLED", example = "ACTIVE")
private String status;
}

View File

@@ -0,0 +1,14 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "更新公司请求")
public class CompanyUpdateRequest {
@Schema(description = "公司名称", example = "示例科技(升级版)")
private String companyName;
@Schema(description = "公司代码(英文简写)", example = "DEMO")
private String companyCode;
}

View File

@@ -0,0 +1,14 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "创建任务请求")
public class CreateTaskRequest {
@Schema(description = "资料 ID", example = "1001")
private Long sourceId;
@Schema(description = "任务类型可选值EXTRACTION / QA_GENERATION", example = "EXTRACTION")
private String taskType;
}

View File

@@ -0,0 +1,13 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Map;
@Data
@Schema(description = "动态 JSON 响应")
public class DynamicJsonResponse {
@Schema(description = "动态 JSON 内容", example = "{\"label\":\"cat\",\"score\":0.98}")
private Map<String, Object> content;
}

View File

@@ -0,0 +1,13 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "创建导出批次请求")
public class ExportBatchCreateRequest {
@Schema(description = "样本 ID 列表", example = "[101, 102, 103]")
private List<Long> sampleIds;
}

View File

@@ -0,0 +1,23 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "微调任务响应")
public class FinetuneJobResponse {
@Schema(description = "导出批次 ID", example = "501")
private Long batchId;
@Schema(description = "GLM 微调任务 ID", example = "glm-ft-001")
private String glmJobId;
@Schema(description = "微调状态", example = "RUNNING")
private String finetuneStatus;
@Schema(description = "进度百分比", example = "35")
private Integer progress;
@Schema(description = "错误信息", example = "")
private String errorMessage;
}

View File

@@ -15,10 +15,10 @@ public class LoginResponse {
@Schema(description = "Bearer Token", example = "550e8400-e29b-41d4-a716-446655440000")
private String token;
/** 用户主键 */
@Schema(description = "用户主键")
@Schema(description = "用户主键", example = "1")
private Long userId;
/** 登录用户名 */
@Schema(description = "登录用户名")
@Schema(description = "登录用户名", example = "admin")
private String username;
/** 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
@Schema(description = "角色", example = "ADMIN")

View File

@@ -0,0 +1,11 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "驳回请求")
public class RejectRequest {
@Schema(description = "驳回原因", example = "标注结果缺少关键字段")
private String reason;
}

View File

@@ -14,25 +14,25 @@ import java.time.LocalDateTime;
@Builder
@Schema(description = "原始资料响应")
public class SourceResponse {
@Schema(description = "资料主键")
@Schema(description = "资料主键", example = "2001")
private Long id;
@Schema(description = "文件名")
@Schema(description = "文件名", example = "demo.txt")
private String fileName;
@Schema(description = "资料类型", example = "TEXT")
private String dataType;
@Schema(description = "文件大小(字节)")
@Schema(description = "文件大小(字节)", example = "1024")
private Long fileSize;
@Schema(description = "资料状态", example = "PENDING")
private String status;
/** 上传用户 ID列表端点返回 */
@Schema(description = "上传用户 ID")
@Schema(description = "上传用户 ID", example = "1")
private Long uploaderId;
/** 15 分钟预签名下载链接(详情端点返回) */
@Schema(description = "预签名下载链接")
@Schema(description = "预签名下载链接", example = "https://example.com/presigned-url")
private String presignedUrl;
/** 父资料 ID视频帧 / 文本片段;详情端点返回) */
@Schema(description = "父资料 ID")
@Schema(description = "父资料 ID", example = "1001")
private Long parentSourceId;
@Schema(description = "创建时间")
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,26 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "系统配置项响应")
public class SysConfigItemResponse {
@Schema(description = "配置主键", example = "1")
private Long id;
@Schema(description = "配置键", example = "model_default")
private String configKey;
@Schema(description = "配置值", example = "glm-4-flash")
private String configValue;
@Schema(description = "配置说明", example = "默认文本模型")
private String description;
@Schema(description = "配置来源作用域可选值COMPANY、GLOBAL", example = "COMPANY")
private String scope;
@Schema(description = "所属公司 IDGLOBAL 配置为空COMPANY 配置为当前公司 ID", example = "100")
private Long companyId;
}

View File

@@ -0,0 +1,14 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "系统配置更新请求")
public class SysConfigUpdateRequest {
@Schema(description = "配置值", example = "https://api.example.com")
private String value;
@Schema(description = "配置说明", example = "AI 服务基础地址")
private String description;
}

View File

@@ -0,0 +1,11 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "任务改派请求")
public class TaskReassignRequest {
@Schema(description = "目标用户 ID", example = "2001")
private Long userId;
}

View File

@@ -13,26 +13,28 @@ import java.time.LocalDateTime;
@Builder
@Schema(description = "标注任务响应")
public class TaskResponse {
@Schema(description = "任务主键")
@Schema(description = "任务主键", example = "1001")
private Long id;
@Schema(description = "关联资料 ID")
@Schema(description = "关联资料 ID", example = "2001")
private Long sourceId;
/** 任务类型(对应 taskType 字段EXTRACTION / QA_GENERATION */
@Schema(description = "任务类型", example = "EXTRACTION")
private String taskType;
@Schema(description = "任务状态", example = "UNCLAIMED")
private String status;
@Schema(description = "领取人用户 ID")
@Schema(description = "领取人用户 ID", example = "1")
private Long claimedBy;
@Schema(description = "领取时间")
@Schema(description = "AI 预标注状态PENDING/PROCESSING/COMPLETED/FAILED", example = "COMPLETED")
private String aiStatus;
@Schema(description = "领取时间", example = "2026-04-15T12:34:56")
private LocalDateTime claimedAt;
@Schema(description = "提交时间")
@Schema(description = "提交时间", example = "2026-04-15T12:34:56")
private LocalDateTime submittedAt;
@Schema(description = "完成时间")
@Schema(description = "完成时间", example = "2026-04-15T12:34:56")
private LocalDateTime completedAt;
/** 驳回原因REJECTED 状态时非空) */
@Schema(description = "驳回原因")
private String rejectReason;
@Schema(description = "创建时间")
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,20 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "创建用户请求")
public class UserCreateRequest {
@Schema(description = "登录用户名", example = "reviewer01")
private String username;
@Schema(description = "明文密码", example = "Pass@123")
private String password;
@Schema(description = "真实姓名", example = "张三")
private String realName;
@Schema(description = "角色可选值ADMIN / REVIEWER / ANNOTATOR / UPLOADER", example = "REVIEWER")
private String role;
}

View File

@@ -11,16 +11,16 @@ import lombok.Data;
@AllArgsConstructor
@Schema(description = "当前登录用户信息")
public class UserInfoResponse {
@Schema(description = "用户主键")
@Schema(description = "用户主键", example = "1")
private Long id;
@Schema(description = "用户名")
@Schema(description = "用户名", example = "admin")
private String username;
@Schema(description = "真实姓名")
@Schema(description = "真实姓名", example = "张三")
private String realName;
@Schema(description = "角色", example = "ADMIN")
private String role;
@Schema(description = "所属公司 ID")
@Schema(description = "所属公司 ID", example = "1")
private Long companyId;
@Schema(description = "所属公司名称")
@Schema(description = "所属公司名称", example = "示例科技有限公司")
private String companyName;
}

View File

@@ -0,0 +1,11 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "用户角色变更请求")
public class UserRoleUpdateRequest {
@Schema(description = "用户角色可选值ADMIN / REVIEWER / ANNOTATOR / UPLOADER", example = "ANNOTATOR")
private String role;
}

View File

@@ -0,0 +1,11 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "用户状态变更请求")
public class UserStatusUpdateRequest {
@Schema(description = "用户状态可选值ACTIVE / DISABLED", example = "DISABLED")
private String status;
}

View File

@@ -0,0 +1,14 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "更新用户请求")
public class UserUpdateRequest {
@Schema(description = "真实姓名", example = "李四")
private String realName;
@Schema(description = "新密码,可为空或 null 表示保持不变", example = "")
private String password;
}

View File

@@ -0,0 +1,20 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "视频处理回调请求")
public class VideoProcessCallbackRequest {
@Schema(description = "视频处理任务 ID", example = "9001")
private Long jobId;
@Schema(description = "处理状态", example = "SUCCESS")
private String status;
@Schema(description = "输出文件路径", example = "/data/output/video-9001.json")
private String outputPath;
@Schema(description = "失败时的错误信息", example = "ffmpeg error")
private String errorMessage;
}

View File

@@ -0,0 +1,17 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "创建视频处理任务请求")
public class VideoProcessCreateRequest {
@Schema(description = "资料 ID", example = "3001")
private Long sourceId;
@Schema(description = "处理任务类型可选值FRAME_EXTRACT、VIDEO_TO_TEXT", example = "FRAME_EXTRACT")
private String jobType;
@Schema(description = "任务参数 JSON 字符串", example = "{\"frameInterval\":5}")
private String params;
}

View File

@@ -44,7 +44,7 @@ public class AnnotationTask {
/** 完成时间APPROVED 时设置) */
private LocalDateTime completedAt;
/** 是否最终结果APPROVED 且无需再审)*/
/** 是否最终结果APPROVED 且无需再审) */
private Boolean isFinal;
/** 使用的 AI 模型名称 */
@@ -53,6 +53,9 @@ public class AnnotationTask {
/** 驳回原因 */
private String rejectReason;
/** AI 预标注状态PENDING / PROCESSING / COMPLETED / FAILED */
private String aiStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -3,6 +3,7 @@ package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@@ -15,30 +16,40 @@ import java.util.UUID;
*/
@Data
@TableName("export_batch")
@Schema(description = "导出批次")
public class ExportBatch {
@TableId(type = IdType.AUTO)
@Schema(description = "导出批次主键", example = "1")
private Long id;
/** 所属公司(多租户键) */
@Schema(description = "所属公司 ID", example = "1")
private Long companyId;
/** 批次唯一标识UUIDDB 默认 gen_random_uuid() */
@Schema(description = "批次 UUID", example = "550e8400-e29b-41d4-a716-446655440000")
private UUID batchUuid;
/** 本批次样本数量 */
@Schema(description = "样本数量", example = "1000")
private Integer sampleCount;
/** 导出 JSONL 的 RustFS 路径 */
@Schema(description = "数据集文件路径JSONL", example = "datasets/export/2026-04-15/batch.jsonl")
private String datasetFilePath;
/** GLM fine-tune 任务 ID提交微调后填写 */
@Schema(description = "GLM 微调任务 ID", example = "glm-job-123456")
private String glmJobId;
/** 微调任务状态NOT_STARTED / RUNNING / COMPLETED / FAILED */
@Schema(description = "微调任务状态", example = "NOT_STARTED")
private String finetuneStatus;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -3,6 +3,7 @@ package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@@ -13,22 +14,29 @@ import java.time.LocalDateTime;
*/
@Data
@TableName("sys_company")
@Schema(description = "租户公司")
public class SysCompany {
/** 公司主键,自增 */
@TableId(type = IdType.AUTO)
@Schema(description = "公司主键", example = "1")
private Long id;
/** 公司全称,全局唯一 */
@Schema(description = "公司全称", example = "示例科技有限公司")
private String companyName;
/** 公司代码(英文简写),全局唯一 */
@Schema(description = "公司代码(英文简写)", example = "DEMO")
private String companyCode;
/** 状态ACTIVE / DISABLED */
@Schema(description = "状态", example = "ACTIVE")
private String status;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -3,6 +3,7 @@ package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@@ -15,27 +16,35 @@ import java.time.LocalDateTime;
*/
@Data
@TableName("sys_config")
@Schema(description = "系统配置")
public class SysConfig {
@TableId(type = IdType.AUTO)
@Schema(description = "配置主键", example = "1")
private Long id;
/**
* 所属公司 IDNULL = 全局默认配置;非 NULL = 租户专属配置)。
* 注意:不能用 @TableField(exist = false) 排除,必须保留以支持 company_id IS NULL 查询。
*/
@Schema(description = "所属公司 IDNULL 表示全局默认配置)", example = "1")
private Long companyId;
/** 配置键 */
@Schema(description = "配置键", example = "STORAGE_BUCKET")
private String configKey;
/** 配置值 */
@Schema(description = "配置值", example = "label-bucket")
private String configValue;
/** 配置说明 */
@Schema(description = "配置说明", example = "对象存储桶名称")
private String description;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@@ -15,16 +16,20 @@ import java.time.LocalDateTime;
*/
@Data
@TableName("sys_user")
@Schema(description = "系统用户")
public class SysUser {
/** 用户主键,自增 */
@TableId(type = IdType.AUTO)
@Schema(description = "用户主键", example = "1")
private Long id;
/** 所属公司 ID多租户键 */
@Schema(description = "所属公司 ID", example = "1")
private Long companyId;
/** 登录用户名(同公司内唯一) */
@Schema(description = "登录用户名", example = "admin")
private String username;
/**
@@ -32,18 +37,24 @@ public class SysUser {
* 序列化时排除,防止密码哈希泄漏到 API 响应。
*/
@JsonIgnore
@Schema(description = "密码哈希(不会在响应中返回)")
private String passwordHash;
/** 真实姓名 */
@Schema(description = "真实姓名", example = "张三")
private String realName;
/** 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
@Schema(description = "角色", example = "ADMIN")
private String role;
/** 状态ACTIVE / DISABLED */
@Schema(description = "状态", example = "ACTIVE")
private String status;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -3,6 +3,7 @@ package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@@ -15,32 +16,44 @@ import java.time.LocalDateTime;
*/
@Data
@TableName("training_dataset")
@Schema(description = "训练数据集样本")
public class TrainingDataset {
@TableId(type = IdType.AUTO)
@Schema(description = "样本主键", example = "1")
private Long id;
/** 所属公司(多租户键) */
@Schema(description = "所属公司 ID", example = "1")
private Long companyId;
@Schema(description = "关联任务 ID", example = "1001")
private Long taskId;
@Schema(description = "关联资料 ID", example = "2001")
private Long sourceId;
/** 样本类型TEXT / IMAGE / VIDEO_FRAME */
@Schema(description = "样本类型", example = "TEXT")
private String sampleType;
/** GLM fine-tune 格式的 JSON 字符串JSONB */
@Schema(description = "GLM 微调格式 JSON", example = "{\"messages\":[{\"role\":\"user\",\"content\":\"...\"},{\"role\":\"assistant\",\"content\":\"...\"}]}")
private String glmFormatJson;
/** 状态PENDING_REVIEW / APPROVED / REJECTED */
@Schema(description = "状态", example = "APPROVED")
private String status;
@Schema(description = "导出批次 ID", example = "3001")
private Long exportBatchId;
@Schema(description = "导出时间", example = "2026-04-15T12:34:56")
private LocalDateTime exportedAt;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -3,6 +3,7 @@ package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@@ -15,43 +16,58 @@ import java.time.LocalDateTime;
*/
@Data
@TableName("video_process_job")
@Schema(description = "视频处理任务")
public class VideoProcessJob {
@TableId(type = IdType.AUTO)
@Schema(description = "任务主键", example = "1")
private Long id;
/** 所属公司(多租户键) */
@Schema(description = "所属公司 ID", example = "1")
private Long companyId;
/** 关联资料 ID */
@Schema(description = "关联资料 ID", example = "2001")
private Long sourceId;
/** 任务类型FRAME_EXTRACT / VIDEO_TO_TEXT */
@Schema(description = "任务类型", example = "FRAME_EXTRACT")
private String jobType;
/** 任务状态PENDING / RUNNING / SUCCESS / FAILED / RETRYING */
@Schema(description = "任务状态", example = "PENDING")
private String status;
/** 任务参数JSONB例如 {"frameInterval": 30} */
@Schema(description = "任务参数JSON", example = "{\"frameInterval\":30}")
private String params;
/** AI 处理输出路径(成功后填写) */
@Schema(description = "输出路径", example = "outputs/video/2026-04-15/result.json")
private String outputPath;
/** 已重试次数 */
@Schema(description = "已重试次数", example = "0")
private Integer retryCount;
/** 最大重试次数(默认 3 */
@Schema(description = "最大重试次数", example = "3")
private Integer maxRetries;
/** 错误信息 */
@Schema(description = "错误信息")
private String errorMessage;
@Schema(description = "开始时间", example = "2026-04-15T12:34:56")
private LocalDateTime startedAt;
@Schema(description = "完成时间", example = "2026-04-15T12:34:56")
private LocalDateTime completedAt;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -28,6 +28,9 @@ import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private static final String API_PREFIX = "/label";
private static final String API_ROOT = API_PREFIX + "/api/";
private final RedisService redisService;
private final ObjectMapper objectMapper;
@@ -155,9 +158,9 @@ public class AuthInterceptor implements HandlerInterceptor {
}
private boolean isPublicPath(String path) {
return !path.startsWith("/api/")
|| path.equals("/api/auth/login")
|| path.equals("/api/video/callback")
return !path.startsWith(API_ROOT)
|| path.equals(API_PREFIX + "/api/auth/login")
|| path.equals(API_PREFIX + "/api/video/callback")
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs");
}

View File

@@ -1,43 +1,28 @@
package com.label.listener;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.context.CompanyContext;
import com.label.entity.AnnotationResult;
import com.label.entity.SourceData;
import com.label.entity.TrainingDataset;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.SourceDataMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.service.TaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.context.CompanyContext;
import com.label.entity.SourceData;
import com.label.entity.TrainingDataset;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.SourceDataMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.service.TaskService;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 提取审批通过后的异步处理器。
*
* 设计约束(关键):
* - @TransactionalEventListener(AFTER_COMMIT):确保在审批事务提交后才触发 AI 调用
* - @Transactional(REQUIRES_NEW):在独立新事务中写 DB与审批事务完全隔离
* - 异常不会回滚审批事务(已提交),但会在日志中记录
*
* 处理流程:
* 1. 调用 AI 生成候选问答对Text/Image 走不同端点)
* 2. 写入 training_datasetstatus=PENDING_REVIEW
* 3. 创建 QA_GENERATION 任务status=UNCLAIMED
* 4. 更新 source_data 状态为 QA_REVIEW
*/
@Slf4j
@Component
@RequiredArgsConstructor
@@ -47,23 +32,19 @@ public class ExtractionApprovedEventListener {
private final SourceDataMapper sourceDataMapper;
private final TaskService taskService;
private final AiServiceClient aiServiceClient;
private final AnnotationResultMapper annotationResultMapper;
private final ObjectMapper objectMapper;
@Value("${rustfs.bucket:label-source-data}")
private String bucket;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onExtractionApproved(ExtractionApprovedEvent event) {
log.info("处理提取审批通过事件: taskId={}, sourceId={}", event.getTaskId(), event.getSourceId());
// 设置多租户上下文(新事务中 ThreadLocal 已清除)
CompanyContext.set(event.getCompanyId());
try {
processEvent(event);
} catch (Exception e) {
log.error("处理审批通过事件失败taskId={}{}", event.getTaskId(), e.getMessage(), e);
// 不向上抛出,审批操作已提交,此处失败不回滚审批
log.error("处理审批通过事件失败(taskId={}): {}", event.getTaskId(), e.getMessage(), e);
} finally {
CompanyContext.clear();
}
@@ -76,57 +57,79 @@ public class ExtractionApprovedEventListener {
return;
}
// 1. 调用 AI 生成候选问答对
AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder()
.sourceId(source.getId())
.filePath(source.getFilePath())
.bucket(bucket)
.build();
List<Map<String, Object>> qaPairs;
try {
AiServiceClient.QaGenResponse response = "IMAGE".equals(source.getDataType())
? aiServiceClient.genImageQa(req)
: aiServiceClient.genTextQa(req);
qaPairs = response != null && response.getQaPairs() != null
? response.getQaPairs()
? aiServiceClient.genImageQa(buildImageQaRequest(event.getTaskId()))
: aiServiceClient.genTextQa(buildTextQaRequest(event.getTaskId()));
qaPairs = response != null && response.getPairs() != null
? response.getPairs()
: Collections.emptyList();
} catch (Exception e) {
log.warn("AI 问答生成失败taskId={}{},将使用空问答对", event.getTaskId(), e.getMessage());
log.warn("AI 问答生成失败(taskId={}): {},将使用空问答对", event.getTaskId(), e.getMessage());
qaPairs = Collections.emptyList();
}
// 2. 写入 training_datasetPENDING_REVIEW
String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT";
String glmJson = buildGlmJson(qaPairs);
TrainingDataset dataset = new TrainingDataset();
dataset.setCompanyId(event.getCompanyId());
dataset.setTaskId(event.getTaskId());
dataset.setSourceId(event.getSourceId());
dataset.setSampleType(sampleType);
dataset.setGlmFormatJson(glmJson);
dataset.setGlmFormatJson(buildGlmJson(qaPairs));
dataset.setStatus("PENDING_REVIEW");
datasetMapper.insert(dataset);
// 3. 创建 QA_GENERATION 任务UNCLAIMED
taskService.createTask(event.getSourceId(), "QA_GENERATION", event.getCompanyId());
// 4. 更新 source_data 状态为 QA_REVIEW
sourceDataMapper.updateStatus(event.getSourceId(), "QA_REVIEW", event.getCompanyId());
log.info("审批通过后续处理完成: taskId={}, 新 QA 任务已创建", event.getTaskId());
log.info("审批通过后续处理完成: taskId={}", event.getTaskId());
}
/**
* 将 AI 生成的问答对列表转换为 GLM fine-tune 格式 JSON。
*/
private String buildGlmJson(List<Map<String, Object>> qaPairs) {
try {
return objectMapper.writeValueAsString(Map.of("conversations", qaPairs));
} catch (Exception e) {
log.error("构建 GLM JSON 失败", e);
log.error("构建微调 JSON 失败", e);
return "{\"conversations\":[]}";
}
}
private AiServiceClient.GenTextQaRequest buildTextQaRequest(Long taskId) {
List<AiServiceClient.TextQaItem> items = readAnnotationItems(taskId).stream()
.map(item -> objectMapper.convertValue(item, AiServiceClient.TextQaItem.class))
.toList();
return AiServiceClient.GenTextQaRequest.builder()
.items(items)
.build();
}
private AiServiceClient.GenImageQaRequest buildImageQaRequest(Long taskId) {
List<AiServiceClient.ImageQaItem> items = readAnnotationItems(taskId).stream()
.map(item -> objectMapper.convertValue(item, AiServiceClient.ImageQaItem.class))
.toList();
return AiServiceClient.GenImageQaRequest.builder()
.items(items)
.build();
}
private List<Map<String, Object>> readAnnotationItems(Long taskId) {
AnnotationResult result = annotationResultMapper.selectByTaskId(taskId);
if (result == null || result.getResultJson() == null || result.getResultJson().isBlank()) {
return Collections.emptyList();
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> parsed = objectMapper.readValue(result.getResultJson(), Map.class);
Object items = parsed.get("items");
if (items instanceof List<?>) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> typedItems = (List<Map<String, Object>>) items;
return typedItems;
}
} catch (Exception e) {
log.warn("解析提取结果失败taskId={},将使用空 items: {}", taskId, e.getMessage());
}
return Collections.emptyList();
}
}

View File

@@ -22,8 +22,8 @@ public interface AnnotationResultMapper extends BaseMapper<AnnotationResult> {
"SET result_json = #{resultJson}::jsonb, updated_at = NOW() " +
"WHERE task_id = #{taskId} AND company_id = #{companyId}")
int updateResultJson(@Param("taskId") Long taskId,
@Param("resultJson") String resultJson,
@Param("companyId") Long companyId);
@Param("resultJson") String resultJson,
@Param("companyId") Long companyId);
/**
* 按任务 ID 查询标注结果。
@@ -33,4 +33,9 @@ public interface AnnotationResultMapper extends BaseMapper<AnnotationResult> {
*/
@Select("SELECT * FROM annotation_result WHERE task_id = #{taskId}")
AnnotationResult selectByTaskId(@Param("taskId") Long taskId);
@Insert("INSERT INTO annotation_result (task_id, company_id, result_json, created_at, updated_at) " +
"VALUES (#{taskId}, #{companyId}, #{resultJson}::jsonb, NOW(), NOW())")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int insertWithJsonb(AnnotationResult result);
}

View File

@@ -0,0 +1,143 @@
package com.label.service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.context.CompanyContext;
import com.label.entity.AnnotationResult;
import com.label.entity.AnnotationTask;
import com.label.entity.SourceData;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.AnnotationTaskMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAnnotationAsyncService {
private final AnnotationTaskMapper taskMapper;
private final ObjectMapper objectMapper;
private final AnnotationResultMapper resultMapper;
private final AiServiceClient aiServiceClient;
@Async("aiTaskExecutor")
public void processAnnotation(Long taskId, Long companyId, SourceData source) {
CompanyContext.set(companyId);
log.info("开始异步执行 AI 预标注任务ID: {}", taskId);
String dataType = source.getDataType().toUpperCase();
AiServiceClient.ExtractionResponse aiResponse = null;
int maxRetries = 2;
Exception lastException = null;
String finalStatus = "FAILED";
try {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
if ("IMAGE".equals(dataType)) {
AiServiceClient.ImageExtractRequest req = AiServiceClient.ImageExtractRequest.builder()
.filePath(source.getFilePath())
.taskId(taskId)
.build();
aiResponse = aiServiceClient.extractImage(req);
} else {
AiServiceClient.TextExtractRequest req = AiServiceClient.TextExtractRequest.builder()
.filePath(source.getFilePath())
.fileName(source.getFileName())
.build();
aiResponse = aiServiceClient.extractText(req);
}
if (aiResponse != null) {
log.info("AI 预标注成功任务ID: {}, 尝试次数: {}", taskId, attempt);
break;
}
} catch (Exception e) {
lastException = e;
log.warn("AI 预标注调用失败(任务 {}),第 {} 次尝试:{}", taskId, attempt, e.getMessage());
if (attempt < maxRetries) {
try {
Thread.sleep(1000L * attempt);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
List<?> items = Collections.emptyList();
if (aiResponse != null && aiResponse.getItems() != null) {
items = aiResponse.getItems();
}
writeOrUpdateResult(taskId, companyId, items);
finalStatus = "COMPLETED";
} catch (Exception e) {
lastException = e;
log.error("AI 预标注处理过程中发生未知异常任务ID: {}", taskId, e);
finalStatus = "FAILED";
} finally {
try {
AnnotationTask updateEntity = new AnnotationTask();
updateEntity.setId(taskId);
updateEntity.setAiStatus(finalStatus);
if ("FAILED".equals(finalStatus)) {
String reason = lastException != null ? lastException.getMessage() : "AI处理失败";
if (reason != null && reason.length() > 500) {
reason = reason.substring(0, 500);
}
updateEntity.setRejectReason(reason);
}
int rows = taskMapper.updateById(updateEntity);
log.info("异步 AI 预标注结束任务ID: {}, 最终状态: {}, row {}", taskId, finalStatus, rows);
} catch (Exception updateEx) {
log.error("更新任务 AI 状态失败任务ID: {}", taskId, updateEx);
} finally {
CompanyContext.clear();
}
}
}
private void writeOrUpdateResult(Long taskId, Long companyId, List<?> items) {
try {
String json = objectMapper
.writeValueAsString(Map.of("items", items != null ? items : Collections.emptyList()));
int updated = resultMapper.updateResultJson(taskId, json, companyId);
if (updated == 0) {
try {
AnnotationResult result = new AnnotationResult();
result.setTaskId(taskId);
result.setCompanyId(companyId);
result.setResultJson(json);
resultMapper.insertWithJsonb(result);
log.info("新建AI预标注结果任务ID: {}", taskId);
} catch (Exception insertEx) {
if (insertEx.getMessage() != null && insertEx.getMessage().contains("duplicate key")) {
log.warn("检测到并发插入冲突转为更新模式任务ID: {}", taskId);
resultMapper.updateResultJson(taskId, json, companyId);
} else {
throw insertEx;
}
}
} else {
log.info("更新AI预标注结果任务ID: {}", taskId);
}
} catch (Exception e) {
log.error("写入 AI 预标注结果失败, taskId={}", taskId, e);
throw new RuntimeException("RESULT_WRITE_FAILED: " + e.getMessage(), e);
}
}
}

View File

@@ -1,33 +1,30 @@
package com.label.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.auth.TokenPrincipal;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.AnnotationResult;
import com.label.entity.TrainingDataset;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;
import com.label.service.TaskClaimService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Map;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.auth.TokenPrincipal;
import com.label.common.exception.BusinessException;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.AnnotationResult;
import com.label.entity.AnnotationTask;
import com.label.entity.SourceData;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.AnnotationTaskMapper;
import com.label.mapper.SourceDataMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 提取阶段标注服务AI 预标注、更新结果、提交、审批、驳回。
@@ -43,12 +40,13 @@ public class ExtractionService {
private final AnnotationTaskMapper taskMapper;
private final AnnotationResultMapper resultMapper;
private final TrainingDatasetMapper datasetMapper;
// private final TrainingDatasetMapper datasetMapper;
private final SourceDataMapper sourceDataMapper;
private final TaskClaimService taskClaimService;
private final AiServiceClient aiServiceClient;
// private final AiServiceClient aiServiceClient;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
private final AiAnnotationAsyncService aiAnnotationAsyncService; // 注入异步服务
@Value("${rustfs.bucket:label-source-data}")
private String bucket;
@@ -67,32 +65,30 @@ public class ExtractionService {
throw new BusinessException("NOT_FOUND", "关联资料不存在", HttpStatus.NOT_FOUND);
}
// 调用 AI 服务(在事务外,避免长时间持有 DB 连接)
AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder()
.sourceId(source.getId())
.filePath(source.getFilePath())
.bucket(bucket)
.build();
AiServiceClient.ExtractionResponse aiResponse;
try {
if ("IMAGE".equals(source.getDataType())) {
aiResponse = aiServiceClient.extractImage(req);
} else {
aiResponse = aiServiceClient.extractText(req);
}
} catch (Exception e) {
log.warn("AI 预标注调用失败(任务 {}{}", taskId, e.getMessage());
// AI 失败不阻塞流程,写入空结果
aiResponse = new AiServiceClient.ExtractionResponse();
aiResponse.setItems(Collections.emptyList());
if (source.getFilePath() == null || source.getFilePath().isEmpty()) {
throw new BusinessException("INVALID_SOURCE", "源文件路径不能为空", HttpStatus.BAD_REQUEST);
}
// 将 AI 结果写入 annotation_resultUPSERT 语义)
writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems());
}
if (source.getDataType() == null || source.getDataType().isEmpty()) {
throw new BusinessException("INVALID_SOURCE", "数据类型不能为空", HttpStatus.BAD_REQUEST);
}
// ------------------------------------------------------------------ 更新结果 --
String dataType = source.getDataType().toUpperCase();
if (!"IMAGE".equals(dataType) && !"TEXT".equals(dataType)) {
log.warn("不支持的数据类型: {}, 任务ID: {}", dataType, taskId);
throw new BusinessException("UNSUPPORTED_TYPE",
"不支持的数据类型: " + dataType, HttpStatus.BAD_REQUEST);
}
// 更新任务状态为 PROCESSING
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
.eq(AnnotationTask::getId, taskId)
.set(AnnotationTask::getAiStatus, "PROCESSING"));
// 触发异步任务
aiAnnotationAsyncService.processAnnotation(taskId, principal.getCompanyId(), source);
// executeAiAnnotationAsync(taskId, principal.getCompanyId(), source);
}
/**
* 人工更新标注结果整体覆盖PUT 语义)。
@@ -237,8 +233,7 @@ public class ExtractionService {
"sourceType", source != null ? source.getDataType() : "",
"sourceFilePath", source != null && source.getFilePath() != null ? source.getFilePath() : "",
"isFinal", task.getIsFinal() != null && task.getIsFinal(),
"resultJson", result != null ? result.getResultJson() : "[]"
);
"resultJson", result != null ? result.getResultJson() : "[]");
}
// ------------------------------------------------------------------ 私有工具 --
@@ -253,20 +248,4 @@ public class ExtractionService {
}
return task;
}
private void writeOrUpdateResult(Long taskId, Long companyId, java.util.List<?> items) {
try {
String json = objectMapper.writeValueAsString(Map.of("items", items != null ? items : Collections.emptyList()));
int updated = resultMapper.updateResultJson(taskId, json, companyId);
if (updated == 0) {
AnnotationResult result = new AnnotationResult();
result.setTaskId(taskId);
result.setCompanyId(companyId);
result.setResultJson(json);
resultMapper.insert(result);
}
} catch (Exception e) {
log.error("写入 AI 预标注结果失败: taskId={}", taskId, e);
}
}
}

View File

@@ -1,74 +1,73 @@
package com.label.service;
import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.auth.TokenPrincipal;
import com.label.common.exception.BusinessException;
import com.label.common.storage.RustFsClient;
import com.label.entity.ExportBatch;
import com.label.mapper.ExportBatchMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
/**
* GLM 微调服务:提交任务、查询状态。
*
* 注意trigger() 包含 AI HTTP 调用,不在 @Transactional 注解下。
* 仅在 DB 写入时开启事务updateFinetuneInfo
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FinetuneService {
private static final String FINETUNE_BUCKET = "finetune-export";
private static final int PRESIGNED_URL_MINUTES = 60;
private final ExportBatchMapper exportBatchMapper;
private final ExportService exportService;
private final AiServiceClient aiServiceClient;
private final RustFsClient rustFsClient;
// ------------------------------------------------------------------ 提交微调 --
private String finetuneBaseModel = "qwen3-14b";
/**
* 向 GLM AI 服务提交微调任务。
*
* T074 设计AI 调用不在 @Transactional 内执行,避免持有 DB 连接期间发起 HTTP 请求。
* DB 写入updateFinetuneInfo是单条 UPDATE不需要显式事务自动提交
* 如果 AI 调用成功但 DB 写入失败,下次查询状态仍可通过 AI 服务的 jobId 重建状态。
*
* @param batchId 批次 ID
* @param principal 当前用户
* @return 包含 glmJobId 和 finetuneStatus 的 Map
*/
public Map<String, Object> trigger(Long batchId, TokenPrincipal principal) {
ExportBatch batch = exportService.getById(batchId, principal);
if (!"NOT_STARTED".equals(batch.getFinetuneStatus())) {
throw new BusinessException("FINETUNE_ALREADY_STARTED",
"微调任务已提交,当前状态: " + batch.getFinetuneStatus(), HttpStatus.CONFLICT);
throw new BusinessException(
"FINETUNE_ALREADY_STARTED",
"微调任务已提交,当前状态 " + batch.getFinetuneStatus(),
HttpStatus.CONFLICT
);
}
// 调用 AI 服务(无事务,不持有 DB 连接)
AiServiceClient.FinetuneRequest req = AiServiceClient.FinetuneRequest.builder()
.datasetPath(batch.getDatasetFilePath())
.model("glm-4")
.batchId(batchId)
String jsonlUrl = rustFsClient.getPresignedUrl(
FINETUNE_BUCKET,
batch.getDatasetFilePath(),
PRESIGNED_URL_MINUTES
);
AiServiceClient.FinetuneStartRequest req = AiServiceClient.FinetuneStartRequest.builder()
.jsonlUrl(jsonlUrl)
.baseModel(finetuneBaseModel)
.hyperparams(Map.of())
.build();
AiServiceClient.FinetuneResponse response;
AiServiceClient.FinetuneStartResponse response;
try {
response = aiServiceClient.startFinetune(req);
} catch (Exception e) {
throw new BusinessException("FINETUNE_TRIGGER_FAILED",
"提交微调任务失败: " + e.getMessage(), HttpStatus.SERVICE_UNAVAILABLE);
throw new BusinessException(
"FINETUNE_TRIGGER_FAILED",
"提交微调任务失败: " + e.getMessage(),
HttpStatus.SERVICE_UNAVAILABLE
);
}
// AI 调用成功后更新批次记录(单条 UPDATE自动提交
exportBatchMapper.updateFinetuneInfo(batchId,
response.getJobId(), "RUNNING", principal.getCompanyId());
log.info("微调任务已提交: batchId={}, glmJobId={}", batchId, response.getJobId());
exportBatchMapper.updateFinetuneInfo(
batchId,
response.getJobId(),
"RUNNING",
principal.getCompanyId()
);
return Map.of(
"glmJobId", response.getJobId(),
@@ -76,15 +75,6 @@ public class FinetuneService {
);
}
// ------------------------------------------------------------------ 查询状态 --
/**
* 查询微调任务实时状态(向 AI 服务查询)。
*
* @param batchId 批次 ID
* @param principal 当前用户
* @return 状态 Map
*/
public Map<String, Object> getStatus(Long batchId, TokenPrincipal principal) {
ExportBatch batch = exportService.getById(batchId, principal);
@@ -98,13 +88,11 @@ public class FinetuneService {
);
}
// 向 AI 服务实时查询
AiServiceClient.FinetuneStatusResponse statusResp;
try {
statusResp = aiServiceClient.getFinetuneStatus(batch.getGlmJobId());
} catch (Exception e) {
log.warn("查询微调状态失败batchId={}{}", batchId, e.getMessage());
// 查询失败时返回 DB 中的缓存状态
log.warn("查询微调状态失败(batchId={}): {}", batchId, e.getMessage());
return Map.of(
"batchId", batchId,
"glmJobId", batch.getGlmJobId(),

View File

@@ -190,6 +190,7 @@ public class TaskService {
.sourceId(task.getSourceId())
.taskType(task.getTaskType())
.status(task.getStatus())
.aiStatus(task.getAiStatus())
.claimedBy(task.getClaimedBy())
.claimedAt(task.getClaimedAt())
.submittedAt(task.getSubmittedAt())

View File

@@ -1,17 +1,18 @@
package com.label.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.statemachine.SourceStatus;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.VideoSourceStatus;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.VideoProcessJob;
import com.label.mapper.SourceDataMapper;
import com.label.mapper.VideoProcessJobMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,20 +22,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime;
import java.util.Map;
/**
* 视频处理服务:创建任务、处理回调、管理员重置。
*
* 状态流转:
* - 创建时source_data → PREPROCESSINGjob → PENDING
* - 回调成功job → SUCCESSsource_data → PENDING进入提取队列
* - 回调失败可重试job → RETRYINGretryCount++,重新触发 AI
* - 回调失败超出上限job → FAILEDsource_data → PENDING
* - 管理员重置job → PENDING可手动重新触发
*
* T074 设计说明:
* AI 调用通过 TransactionSynchronizationManager.registerSynchronization().afterCommit()
* 延迟到事务提交后执行,避免在持有 DB 连接期间进行 HTTP 调用。
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -43,44 +30,27 @@ public class VideoProcessService {
private final VideoProcessJobMapper jobMapper;
private final SourceDataMapper sourceDataMapper;
private final AiServiceClient aiServiceClient;
private final ObjectMapper objectMapper;
@Value("${rustfs.bucket:label-source-data}")
private String bucket;
// ------------------------------------------------------------------ 创建任务 --
/**
* 创建视频处理任务并在事务提交后触发 AI 服务。
*
* DB 写入source_data→PREPROCESSING + 插入 job在 @Transactional 内完成;
* AI 触发通过 afterCommit() 在事务提交后执行,不占用 DB 连接。
*
* @param sourceId 资料 ID
* @param jobType 任务类型FRAME_EXTRACT / VIDEO_TO_TEXT
* @param params JSON 参数(如 {"frameInterval": 30}
* @param companyId 租户 ID
* @return 新建的 VideoProcessJob
*/
@Transactional
public VideoProcessJob createJob(Long sourceId, String jobType,
String params, Long companyId) {
public VideoProcessJob createJob(Long sourceId, String jobType, String params, Long companyId) {
SourceData source = sourceDataMapper.selectById(sourceId);
if (source == null || !companyId.equals(source.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "资料不存在: " + sourceId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "资料不存在 " + sourceId, HttpStatus.NOT_FOUND);
}
validateJobType(jobType);
// source_data → PREPROCESSING
StateValidator.assertTransition(
SourceStatus.TRANSITIONS,
SourceStatus.valueOf(source.getStatus()), SourceStatus.PREPROCESSING);
VideoSourceStatus.TRANSITIONS,
VideoSourceStatus.valueOf(source.getStatus()),
VideoSourceStatus.PREPROCESSING
);
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
.eq(SourceData::getId, sourceId)
.set(SourceData::getStatus, "PREPROCESSING")
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
// 插入 PENDING 任务
VideoProcessJob job = new VideoProcessJob();
job.setCompanyId(companyId);
job.setSourceId(sourceId);
@@ -91,48 +61,32 @@ public class VideoProcessService {
job.setMaxRetries(3);
jobMapper.insert(job);
// 事务提交后触发 AI不在事务内不占用 DB 连接)
final Long jobId = job.getId();
final Long jobId = job.getId();
final String filePath = source.getFilePath();
final String finalJobType = jobType;
final String finalParams = job.getParams();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
triggerAi(jobId, sourceId, filePath, finalJobType);
triggerAi(jobId, sourceId, filePath, finalJobType, finalParams);
}
});
log.info("视频处理任务已创建AI 将在事务提交后触发): jobId={}, sourceId={}", jobId, sourceId);
log.info("视频处理任务已创建: jobId={}, sourceId={}", jobId, sourceId);
return job;
}
// ------------------------------------------------------------------ 处理回调 --
/**
* 处理 AI 服务异步回调POST /api/video/callback无需用户 Token
*
* 幂等:若 job 已为 SUCCESS直接返回防止重复处理。
* 重试触发同样延迟到事务提交后afterCommit不在事务内执行。
*
* @param jobId 任务 ID
* @param callbackStatus AI 回调状态SUCCESS / FAILED
* @param outputPath 成功时的输出路径(可选)
* @param errorMessage 失败时的错误信息(可选)
*/
@Transactional
public void handleCallback(Long jobId, String callbackStatus,
String outputPath, String errorMessage) {
// video_process_job 在 IGNORED_TABLES 中(回调无 CompanyContext此处显式校验
public void handleCallback(Long jobId, String callbackStatus, String outputPath, String errorMessage) {
VideoProcessJob job = jobMapper.selectById(jobId);
if (job == null || job.getCompanyId() == null) {
log.warn("视频处理回调job 不存在jobId={}", jobId);
log.warn("视频处理回调job 不存在: jobId={}", jobId);
return;
}
// 幂等:已成功则忽略重复回调
if ("SUCCESS".equals(job.getStatus())) {
log.info("视频处理回调幂等jobId={} 已为 SUCCESS跳过", jobId);
log.info("视频处理回调幂等跳过: jobId={}", jobId);
return;
}
@@ -143,28 +97,19 @@ public class VideoProcessService {
}
}
// ------------------------------------------------------------------ 管理员重置 --
/**
* 管理员手动重置失败任务FAILED → PENDING
*
* 仅允许 FAILED 状态的任务重置,重置后 retryCount 清零,
* 管理员可随后重新调用 createJob 触发处理。
*
* @param jobId 任务 ID
* @param companyId 租户 ID
*/
@Transactional
public VideoProcessJob reset(Long jobId, Long companyId) {
VideoProcessJob job = jobMapper.selectById(jobId);
if (job == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "视频处理任务不存在: " + jobId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "视频处理任务不存在 " + jobId, HttpStatus.NOT_FOUND);
}
if (!"FAILED".equals(job.getStatus())) {
throw new BusinessException("INVALID_TRANSITION",
"只有 FAILED 状态的任务可以重置,当前状态: " + job.getStatus(),
HttpStatus.BAD_REQUEST);
throw new BusinessException(
"INVALID_TRANSITION",
"只有 FAILED 状态的任务可以重置,当前状态 " + job.getStatus(),
HttpStatus.BAD_REQUEST
);
}
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
@@ -176,24 +121,18 @@ public class VideoProcessService {
job.setStatus("PENDING");
job.setRetryCount(0);
log.info("视频处理任务已重置: jobId={}", jobId);
return job;
}
// ------------------------------------------------------------------ 查询 --
public VideoProcessJob getJob(Long jobId, Long companyId) {
VideoProcessJob job = jobMapper.selectById(jobId);
if (job == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "视频处理任务不存在: " + jobId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "视频处理任务不存在 " + jobId, HttpStatus.NOT_FOUND);
}
return job;
}
// ------------------------------------------------------------------ 私有方法 --
private void handleSuccess(VideoProcessJob job, String outputPath) {
// job → SUCCESS
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
.eq(VideoProcessJob::getId, job.getId())
.set(VideoProcessJob::getStatus, "SUCCESS")
@@ -201,13 +140,10 @@ public class VideoProcessService {
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
// source_data PREPROCESSING → PENDING进入提取队列
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
.eq(SourceData::getId, job.getSourceId())
.set(SourceData::getStatus, "PENDING")
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
log.info("视频处理成功jobId={}, sourceId={}", job.getId(), job.getSourceId());
}
private void handleFailure(VideoProcessJob job, String errorMessage) {
@@ -215,7 +151,6 @@ public class VideoProcessService {
int maxRetries = job.getMaxRetries() != null ? job.getMaxRetries() : 3;
if (newRetryCount < maxRetries) {
// 仍有重试次数job → RETRYING事务提交后重新触发 AI
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
.eq(VideoProcessJob::getId, job.getId())
.set(VideoProcessJob::getStatus, "RETRYING")
@@ -223,26 +158,22 @@ public class VideoProcessService {
.set(VideoProcessJob::getErrorMessage, errorMessage)
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
log.warn("视频处理失败,开始第 {} 次重试jobId={}, error={}",
newRetryCount, job.getId(), errorMessage);
// 重试 AI 触发延迟到事务提交后
SourceData source = sourceDataMapper.selectById(job.getSourceId());
if (source != null) {
final Long jobId = job.getId();
final Long sourceId = job.getSourceId();
final Long jobId = job.getId();
final Long sourceId = job.getSourceId();
final String filePath = source.getFilePath();
final String jobType = job.getJobType();
final String jobType = job.getJobType();
final String params = job.getParams();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
triggerAi(jobId, sourceId, filePath, jobType);
triggerAi(jobId, sourceId, filePath, jobType, params);
}
});
}
} else {
// 超出最大重试次数job → FAILEDsource_data → PENDING
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
.eq(VideoProcessJob::getId, job.getId())
.set(VideoProcessJob::getStatus, "FAILED")
@@ -251,40 +182,87 @@ public class VideoProcessService {
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
// source_data PREPROCESSING → PENDING管理员可重新处理
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
.eq(SourceData::getId, job.getSourceId())
.set(SourceData::getStatus, "PENDING")
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
log.error("视频处理永久失败jobId={}, sourceId={}, error={}",
job.getId(), job.getSourceId(), errorMessage);
}
}
private void triggerAi(Long jobId, Long sourceId, String filePath, String jobType) {
AiServiceClient.VideoProcessRequest req = AiServiceClient.VideoProcessRequest.builder()
.sourceId(sourceId)
.filePath(filePath)
.bucket(bucket)
.params(Map.of("jobId", jobId, "jobType", jobType))
.build();
private void triggerAi(Long jobId, Long sourceId, String filePath, String jobType, String paramsJson) {
Map<String, Object> params = parseParams(paramsJson);
try {
if ("FRAME_EXTRACT".equals(jobType)) {
aiServiceClient.extractFrames(req);
aiServiceClient.extractFrames(AiServiceClient.ExtractFramesRequest.builder()
.filePath(filePath)
.sourceId(sourceId)
.jobId(jobId)
.mode(stringParam(params, "mode", "interval"))
.frameInterval(intParam(params, "frameInterval", 30))
.build());
} else {
aiServiceClient.videoToText(req);
aiServiceClient.videoToText(AiServiceClient.VideoToTextRequest.builder()
.filePath(filePath)
.sourceId(sourceId)
.jobId(jobId)
.startSec(doubleParam(params, "startSec", 0.0))
.endSec(doubleParam(params, "endSec", 120.0))
.model(stringParam(params, "model", null))
.promptTemplate(stringParam(params, "promptTemplate", null))
.build());
}
log.info("AI 触发成功: jobId={}", jobId);
log.info("AI 视频任务已触发: jobId={}", jobId);
} catch (Exception e) {
log.error("触发视频处理 AI 失败jobId={}{}job 保持当前状态,需管理员手动重置", jobId, e.getMessage());
log.error("触发视频处理 AI 失败(jobId={}): {}", jobId, e.getMessage());
}
}
private Map<String, Object> parseParams(String paramsJson) {
if (paramsJson == null || paramsJson.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(paramsJson, new TypeReference<>() {});
} catch (Exception e) {
log.warn("解析视频处理参数失败,将使用默认值: {}", e.getMessage());
return Map.of();
}
}
private String stringParam(Map<String, Object> params, String key, String defaultValue) {
Object value = params.get(key);
return value == null ? defaultValue : String.valueOf(value);
}
private Integer intParam(Map<String, Object> params, String key, Integer defaultValue) {
Object value = params.get(key);
if (value instanceof Number number) {
return number.intValue();
}
if (value instanceof String text && !text.isBlank()) {
return Integer.parseInt(text);
}
return defaultValue;
}
private Double doubleParam(Map<String, Object> params, String key, Double defaultValue) {
Object value = params.get(key);
if (value instanceof Number number) {
return number.doubleValue();
}
if (value instanceof String text && !text.isBlank()) {
return Double.parseDouble(text);
}
return defaultValue;
}
private void validateJobType(String jobType) {
if (!"FRAME_EXTRACT".equals(jobType) && !"VIDEO_TO_TEXT".equals(jobType)) {
throw new BusinessException("INVALID_JOB_TYPE",
"任务类型不合法,应为 FRAME_EXTRACT 或 VIDEO_TO_TEXT", HttpStatus.BAD_REQUEST);
throw new BusinessException(
"INVALID_JOB_TYPE",
"任务类型不合法,应为 FRAME_EXTRACT 或 VIDEO_TO_TEXT",
HttpStatus.BAD_REQUEST
);
}
}
}

View File

@@ -1,9 +1,13 @@
server:
port: 8080
port: 18082
spring:
application:
name: label-backend
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://39.107.112.174:5432/labeldb}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
@@ -60,11 +64,12 @@ rustfs:
region: us-east-1
ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000}
timeout: 30000
base-url: ${AI_SERVICE_BASE_URL:http://172.28.77.215:18000}
#base-url: ${AI_SERVICE_BASE_URL:http://127.0.0.1:18000}
timeout: 300000
auth:
enabled: false
enabled: true
mock-company-id: 1
mock-user-id: 1
mock-role: ADMIN

View File

@@ -87,6 +87,7 @@ CREATE TABLE IF NOT EXISTS annotation_task (
completed_at TIMESTAMP,
is_final BOOLEAN NOT NULL DEFAULT FALSE, -- true 即 APPROVED 且无需再审
ai_model VARCHAR(50),
ai_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
reject_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
@@ -313,7 +314,7 @@ INSERT INTO sys_config (company_id, config_key, config_value, description)
VALUES
(NULL, 'token_ttl_seconds', '7200',
'会话凭证有效期(秒)'),
(NULL, 'model_default', 'glm-4',
(NULL, 'model_default', 'qwen-plus',
'AI 辅助默认模型'),
(NULL, 'video_frame_interval', '30',
'视频帧提取间隔(帧数)'),

View File

@@ -1,87 +0,0 @@
package com.label;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
/**
* Base class for all integration tests.
*
* Starts real PostgreSQL 16 and Redis 7 containers (shared across test class instances).
* Executes sql/init.sql to initialize schema and seed data.
*
* DESIGN:
* - @Container with static fields → containers are shared across test methods (faster)
* - @DynamicPropertySource → overrides datasource/redis properties at runtime
* - @BeforeEach cleanData() → truncates business tables (not sys_company/sys_user) between tests
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class AbstractIntegrationTest {
@LocalServerPort
protected int port;
@Autowired
protected JdbcTemplate jdbcTemplate;
@SuppressWarnings("resource")
@Container
protected static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("label_db")
.withUsername("label")
.withPassword("label_password")
.withCopyFileToContainer(
MountableFile.forClasspathResource("db/init.sql"),
"/docker-entrypoint-initdb.d/init.sql");
@SuppressWarnings("resource")
@Container
protected static final GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
.withCommand("redis-server", "--requirepass", "test_redis_password");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.data.redis.password", () -> "test_redis_password");
}
/**
* Clean only business data between tests to keep schema intact.
* Keep sys_company and sys_user since init.sql seeds them.
*/
@BeforeEach
void cleanData() {
jdbcTemplate.execute("TRUNCATE TABLE video_process_job, annotation_task_history, " +
"sys_operation_log, sys_config, export_batch, training_dataset, " +
"annotation_result, annotation_task, source_data RESTART IDENTITY CASCADE");
// Re-insert global sys_config entries that were truncated
jdbcTemplate.execute("INSERT INTO sys_config (company_id, config_key, config_value) VALUES " +
"(NULL, 'token_ttl_seconds', '7200'), " +
"(NULL, 'model_default', 'glm-4'), " +
"(NULL, 'video_frame_interval', '30') " +
"ON CONFLICT DO NOTHING");
}
/** Helper: get base URL for REST calls */
protected String baseUrl(String path) {
return "http://localhost:" + port + path;
}
}

View File

@@ -1,12 +0,0 @@
package com.label;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class LabelBackendApplicationTests {
@Test
void contextLoads() {
}
}

View File

@@ -1,413 +0,0 @@
package com.label.blackbox;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInfo;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.io.ByteArrayResource;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
abstract class AbstractBlackBoxTest {
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
private static final Properties APPLICATION = loadApplicationProperties();
protected final TestRestTemplate restTemplate = new TestRestTemplate();
protected final ObjectMapper objectMapper = new ObjectMapper();
protected JdbcTemplate jdbcTemplate;
protected String baseUrl;
protected String callbackSecret;
protected String runId;
protected Long companyId;
protected String companyCode;
protected String companyName;
protected TestUser adminUser;
protected TestUser reviewerUser;
protected TestUser annotatorUser;
protected TestUser annotator2User;
protected TestUser uploaderUser;
protected String adminToken;
protected String reviewerToken;
protected String annotatorToken;
protected String annotator2Token;
protected String uploaderToken;
protected boolean roleAwareAuthEnabled;
@BeforeEach
void setUpBlackBox(TestInfo testInfo) {
this.jdbcTemplate = new JdbcTemplate(createDataSource());
this.baseUrl = resolveBaseUrl();
this.callbackSecret = resolved("video.callback-secret", "");
this.runId = buildRunId(testInfo);
assertBackendReachable();
createIsolatedCompanyAndUsers();
issueTokens();
detectRuntimeAuthMode();
}
@AfterEach
void cleanUpBlackBox() {
if (companyId == null) {
return;
}
jdbcTemplate.update("DELETE FROM annotation_task_history WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM sys_operation_log WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM video_process_job WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM training_dataset WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM annotation_result WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM export_batch WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM annotation_task WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM source_data WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM sys_config WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM sys_user WHERE company_id = ?", companyId);
jdbcTemplate.update("DELETE FROM sys_company WHERE id = ?", companyId);
}
protected void requireRoleAwareAuth() {
org.junit.jupiter.api.Assumptions.assumeTrue(
roleAwareAuthEnabled,
"当前运行中的 backend 未启用真实多角色认证,跳过依赖角色/租户隔离的黑盒用例");
}
protected ResponseEntity<String> getRaw(String path) {
return restTemplate.getForEntity(url(path), String.class);
}
protected ResponseEntity<Map> get(String path, String token) {
return exchange(path, HttpMethod.GET, null, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> delete(String path, String token) {
return exchange(path, HttpMethod.DELETE, null, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> postJson(String path, Object body, String token) {
return exchange(path, HttpMethod.POST, body, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> putJson(String path, Object body, String token) {
return exchange(path, HttpMethod.PUT, body, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> upload(String path, String token, String filename, String dataType, byte[] bytes) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
if (token != null && !token.isBlank()) {
headers.setBearerAuth(token);
}
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("dataType", dataType);
body.add("file", new ByteArrayResource(bytes) {
@Override
public String getFilename() {
return filename;
}
});
return restTemplate.exchange(url(path), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class);
}
protected ResponseEntity<Map> postVideoCallback(Map<String, Object> body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (callbackSecret != null && !callbackSecret.isBlank()) {
headers.set("X-Callback-Secret", callbackSecret);
}
return restTemplate.exchange(url("/api/video/callback"), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class);
}
protected String login(String targetCompanyCode, String username, String password) {
Map<String, Object> body = Map.of(
"companyCode", targetCompanyCode,
"username", username,
"password", password
);
ResponseEntity<Map> response = postJson("/api/auth/login", body, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
return String.valueOf(data.get("token"));
}
protected Long uploadTextSource(String token) {
ResponseEntity<Map> response = upload(
"/api/source/upload",
token,
"bb-" + runId + ".txt",
"TEXT",
("hello-blackbox-" + runId).getBytes(StandardCharsets.UTF_8));
assertSuccess(response, HttpStatus.CREATED);
return dataId(response);
}
protected Long uploadVideoSource(String token) {
ResponseEntity<Map> response = upload(
"/api/source/upload",
token,
"bb-" + runId + ".mp4",
"VIDEO",
("fake-video-" + runId).getBytes(StandardCharsets.UTF_8));
assertSuccess(response, HttpStatus.CREATED);
return dataId(response);
}
protected Long createTask(Long sourceId, String taskType) {
ResponseEntity<Map> response = postJson("/api/tasks", Map.of(
"sourceId", sourceId,
"taskType", taskType
), adminToken);
assertSuccess(response, HttpStatus.OK);
return dataId(response);
}
protected Long latestTaskId(Long sourceId, String taskType) {
return jdbcTemplate.queryForObject(
"SELECT id FROM annotation_task WHERE company_id = ? AND source_id = ? AND task_type = ? " +
"ORDER BY id DESC LIMIT 1",
Long.class,
companyId, sourceId, taskType);
}
protected Long latestApprovedDatasetId(Long sourceId) {
return jdbcTemplate.queryForObject(
"SELECT id FROM training_dataset WHERE company_id = ? AND source_id = ? AND status = 'APPROVED' " +
"ORDER BY id DESC LIMIT 1",
Long.class,
companyId, sourceId);
}
protected Long insertFailedVideoJob(Long sourceId) {
return jdbcTemplate.queryForObject(
"INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " +
"VALUES (?, ?, 'FRAME_EXTRACT', 'FAILED', '{}'::jsonb, 3, 3) RETURNING id",
Long.class,
companyId, sourceId);
}
protected Long insertPendingVideoJob(Long sourceId) {
return jdbcTemplate.queryForObject(
"INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " +
"VALUES (?, ?, 'FRAME_EXTRACT', 'PENDING', '{}'::jsonb, 0, 3) RETURNING id",
Long.class,
companyId, sourceId);
}
protected void assertSuccess(ResponseEntity<Map> response, HttpStatus expectedStatus) {
assertThat(response.getStatusCode()).isEqualTo(expectedStatus);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().get("code")).isEqualTo("SUCCESS");
}
protected Long dataId(ResponseEntity<Map> response) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
return ((Number) data.get("id")).longValue();
}
protected boolean responseContainsId(ResponseEntity<Map> response, Long id) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
return items.stream().anyMatch(item -> id.equals(((Number) item.get("id")).longValue()));
}
protected Long claimedByOfTask(Long taskId) {
return jdbcTemplate.queryForObject(
"SELECT claimed_by FROM annotation_task WHERE id = ?",
Long.class,
taskId);
}
protected String url(String path) {
return baseUrl + path;
}
private String resolveBaseUrl() {
String override = System.getProperty("blackbox.base-url");
if (override == null || override.isBlank()) {
override = System.getenv("BLACKBOX_BASE_URL");
}
if (override != null && !override.isBlank()) {
return override.endsWith("/") ? override.substring(0, override.length() - 1) : override;
}
return "http://127.0.0.1:" + resolved("server.port", "8080");
}
private ResponseEntity<Map> exchange(String path,
HttpMethod method,
Object body,
String token,
MediaType contentType) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(contentType);
if (token != null && !token.isBlank()) {
headers.setBearerAuth(token);
}
return restTemplate.exchange(url(path), method, new HttpEntity<>(body, headers), Map.class);
}
private void assertBackendReachable() {
try {
ResponseEntity<String> response = getRaw("/v3/api-docs");
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
} catch (Exception ex) {
fail("无法连接运行中的 backend请确认服务已按 application.yml 配置启动: " + ex.getMessage());
}
}
private void createIsolatedCompanyAndUsers() {
this.companyCode = ("BB" + runId).toUpperCase(Locale.ROOT);
this.companyName = "黑盒测试-" + runId;
this.companyId = jdbcTemplate.queryForObject(
"INSERT INTO sys_company (company_name, company_code, status) VALUES (?, ?, 'ACTIVE') RETURNING id",
Long.class,
companyName, companyCode);
this.adminUser = insertUser("admin", "ADMIN");
this.reviewerUser = insertUser("reviewer", "REVIEWER");
this.annotatorUser = insertUser("annotator", "ANNOTATOR");
this.annotator2User = insertUser("annotator2", "ANNOTATOR");
this.uploaderUser = insertUser("uploader", "UPLOADER");
}
private TestUser insertUser(String namePrefix, String role) {
String username = (namePrefix + "_" + runId).toLowerCase(Locale.ROOT);
String password = "Bb@" + runId;
Long userId = jdbcTemplate.queryForObject(
"INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) " +
"VALUES (?, ?, ?, ?, ?, 'ACTIVE') RETURNING id",
Long.class,
companyId,
username,
PASSWORD_ENCODER.encode(password),
"黑盒-" + namePrefix,
role);
return new TestUser(userId, username, password, role);
}
private void issueTokens() {
this.adminToken = login(companyCode, adminUser.username(), adminUser.password());
this.reviewerToken = login(companyCode, reviewerUser.username(), reviewerUser.password());
this.annotatorToken = login(companyCode, annotatorUser.username(), annotatorUser.password());
this.annotator2Token = login(companyCode, annotator2User.username(), annotator2User.password());
this.uploaderToken = login(companyCode, uploaderUser.username(), uploaderUser.password());
}
private void detectRuntimeAuthMode() {
try {
ResponseEntity<Map> adminMe = get("/api/auth/me", adminToken);
ResponseEntity<Map> reviewerMe = get("/api/auth/me", reviewerToken);
if (!adminMe.getStatusCode().is2xxSuccessful() || !reviewerMe.getStatusCode().is2xxSuccessful()) {
this.roleAwareAuthEnabled = false;
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> adminData = (Map<String, Object>) Objects.requireNonNull(adminMe.getBody()).get("data");
@SuppressWarnings("unchecked")
Map<String, Object> reviewerData = (Map<String, Object>) Objects.requireNonNull(reviewerMe.getBody()).get("data");
this.roleAwareAuthEnabled =
adminUser.username().equals(adminData.get("username"))
&& reviewerUser.username().equals(reviewerData.get("username"))
&& "ADMIN".equals(adminData.get("role"))
&& "REVIEWER".equals(reviewerData.get("role"))
&& companyId.equals(((Number) adminData.get("companyId")).longValue())
&& companyId.equals(((Number) reviewerData.get("companyId")).longValue());
} catch (Exception ex) {
this.roleAwareAuthEnabled = false;
}
}
private DataSource createDataSource() {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName(resolved("spring.datasource.driver-class-name", "org.postgresql.Driver"));
dataSource.setUrl(resolved("spring.datasource.url", ""));
dataSource.setUsername(resolved("spring.datasource.username", ""));
dataSource.setPassword(resolved("spring.datasource.password", ""));
return dataSource;
}
private String resolved(String key, String fallback) {
String raw = APPLICATION.getProperty(key);
if (raw == null || raw.isBlank()) {
return fallback;
}
return resolvePlaceholder(raw, fallback);
}
private static Properties loadApplicationProperties() {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
yaml.setResources(new ClassPathResource("application.yml"));
Properties properties = yaml.getObject();
if (properties == null) {
throw new IllegalStateException("无法加载 application.yml");
}
return properties;
}
private static String resolvePlaceholder(String raw, String fallback) {
if (!raw.startsWith("${") || !raw.endsWith("}")) {
return raw;
}
String inner = raw.substring(2, raw.length() - 1);
int splitIndex = inner.indexOf(':');
if (splitIndex < 0) {
String envValue = System.getenv(inner);
return envValue != null ? envValue : fallback;
}
String envKey = inner.substring(0, splitIndex);
String defaultValue = inner.substring(splitIndex + 1);
String envValue = System.getenv(envKey);
return envValue != null ? envValue : defaultValue;
}
private static String buildRunId(TestInfo testInfo) {
String methodName = testInfo.getTestMethod().map(method -> method.getName()).orElse("case");
String normalized = methodName.replaceAll("[^a-zA-Z0-9]", "").toLowerCase(Locale.ROOT);
if (normalized.length() > 10) {
normalized = normalized.substring(0, 10);
}
return normalized + Long.toHexString(Instant.now().toEpochMilli()).substring(5) + UUID.randomUUID().toString().substring(0, 4);
}
protected record TestUser(Long id, String username, String password, String role) {
}
}

View File

@@ -1,337 +0,0 @@
package com.label.blackbox;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class SwaggerLiveBlackBoxTest extends AbstractBlackBoxTest {
@Test
@DisplayName("公共接口与认证接口在真实运行环境下可访问")
void publicAndAuthEndpoints_shouldWork() {
ResponseEntity<String> openApi = getRaw("/v3/api-docs");
assertThat(openApi.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(openApi.getBody()).contains("/api/auth/login");
ResponseEntity<String> swaggerUi = getRaw("/swagger-ui.html");
assertThat(swaggerUi.getStatusCode().is2xxSuccessful() || swaggerUi.getStatusCode().is3xxRedirection()).isTrue();
ResponseEntity<Map> login = postJson("/api/auth/login", Map.of(
"companyCode", companyCode,
"username", adminUser.username(),
"password", adminUser.password()
), null);
assertSuccess(login, HttpStatus.OK);
if (!roleAwareAuthEnabled) {
return;
}
ResponseEntity<Map> me = get("/api/auth/me", adminToken);
assertSuccess(me, HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> meData = (Map<String, Object>) me.getBody().get("data");
assertThat(meData.get("username")).isEqualTo(adminUser.username());
assertThat(meData.get("role")).isEqualTo("ADMIN");
ResponseEntity<Map> logout = postJson("/api/auth/logout", null, adminToken);
assertSuccess(logout, HttpStatus.OK);
ResponseEntity<Map> meAfterLogout = get("/api/auth/me", adminToken);
assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("公司管理与用户管理接口在真实运行环境下可覆盖")
void companyAndUserEndpoints_shouldWork() {
requireRoleAwareAuth();
ResponseEntity<Map> companyList = get("/api/companies?page=1&pageSize=20", adminToken);
assertSuccess(companyList, HttpStatus.OK);
String extraCompanyCode = ("EXT" + runId).toUpperCase();
String extraCompanyName = "扩展公司-" + runId;
ResponseEntity<Map> createCompany = postJson("/api/companies", Map.of(
"companyName", extraCompanyName,
"companyCode", extraCompanyCode
), adminToken);
assertSuccess(createCompany, HttpStatus.CREATED);
Long extraCompanyId = dataId(createCompany);
ResponseEntity<Map> updateCompany = putJson("/api/companies/" + extraCompanyId, Map.of(
"companyName", extraCompanyName + "-改",
"companyCode", extraCompanyCode
), adminToken);
assertSuccess(updateCompany, HttpStatus.OK);
ResponseEntity<Map> companyStatus = putJson("/api/companies/" + extraCompanyId + "/status",
Map.of("status", "DISABLED"), adminToken);
assertSuccess(companyStatus, HttpStatus.OK);
ResponseEntity<Map> deleteCompany = delete("/api/companies/" + extraCompanyId, adminToken);
assertSuccess(deleteCompany, HttpStatus.OK);
ResponseEntity<Map> userList = get("/api/users?page=1&pageSize=20", adminToken);
assertSuccess(userList, HttpStatus.OK);
String username = "bb_user_" + UUID.randomUUID().toString().substring(0, 8);
String password = "BbUser@123";
ResponseEntity<Map> createUser = postJson("/api/users", Map.of(
"username", username,
"password", password,
"realName", "黑盒用户",
"role", "ANNOTATOR"
), adminToken);
assertSuccess(createUser, HttpStatus.OK);
Long userId = dataId(createUser);
ResponseEntity<Map> updateUser = putJson("/api/users/" + userId, Map.of(
"realName", "黑盒用户-改",
"password", "BbUser@456"
), adminToken);
assertSuccess(updateUser, HttpStatus.OK);
String userToken = login(companyCode, username, "BbUser@456");
ResponseEntity<Map> beforeRoleChange = get("/api/tasks/pending-review", userToken);
assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
ResponseEntity<Map> updateRole = putJson("/api/users/" + userId + "/role",
Map.of("role", "REVIEWER"), adminToken);
assertSuccess(updateRole, HttpStatus.OK);
ResponseEntity<Map> afterRoleChange = get("/api/tasks/pending-review", userToken);
assertThat(afterRoleChange.getStatusCode()).isEqualTo(HttpStatus.OK);
ResponseEntity<Map> updateStatus = putJson("/api/users/" + userId + "/status",
Map.of("status", "DISABLED"), adminToken);
assertSuccess(updateStatus, HttpStatus.OK);
ResponseEntity<Map> meAfterDisable = get("/api/auth/me", userToken);
assertThat(meAfterDisable.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("资料与任务管理接口在真实运行环境下可覆盖")
void sourceAndTaskEndpoints_shouldWork() {
requireRoleAwareAuth();
Long disposableSourceId = uploadTextSource(uploaderToken);
ResponseEntity<Map> deleteDisposable = delete("/api/source/" + disposableSourceId, adminToken);
assertSuccess(deleteDisposable, HttpStatus.OK);
Long sourceId = uploadTextSource(uploaderToken);
ResponseEntity<Map> uploaderList = get("/api/source/list?page=1&pageSize=20", uploaderToken);
assertSuccess(uploaderList, HttpStatus.OK);
assertThat(responseContainsId(uploaderList, sourceId)).isTrue();
ResponseEntity<Map> adminList = get("/api/source/list?page=1&pageSize=20", adminToken);
assertSuccess(adminList, HttpStatus.OK);
assertThat(responseContainsId(adminList, sourceId)).isTrue();
ResponseEntity<Map> sourceDetail = get("/api/source/" + sourceId, adminToken);
assertSuccess(sourceDetail, HttpStatus.OK);
Long taskId = createTask(sourceId, "EXTRACTION");
ResponseEntity<Map> pool = get("/api/tasks/pool?page=1&pageSize=20", annotatorToken);
assertSuccess(pool, HttpStatus.OK);
assertThat(responseContainsId(pool, taskId)).isTrue();
ResponseEntity<Map> allTasks = get("/api/tasks?page=1&pageSize=20&taskType=EXTRACTION", adminToken);
assertSuccess(allTasks, HttpStatus.OK);
assertThat(responseContainsId(allTasks, taskId)).isTrue();
ResponseEntity<Map> taskDetail = get("/api/tasks/" + taskId, annotatorToken);
assertSuccess(taskDetail, HttpStatus.OK);
ResponseEntity<Map> claim = postJson("/api/tasks/" + taskId + "/claim", null, annotatorToken);
assertSuccess(claim, HttpStatus.OK);
ResponseEntity<Map> mine = get("/api/tasks/mine?page=1&pageSize=20", annotatorToken);
assertSuccess(mine, HttpStatus.OK);
assertThat(responseContainsId(mine, taskId)).isTrue();
ResponseEntity<Map> unclaim = postJson("/api/tasks/" + taskId + "/unclaim", null, annotatorToken);
assertSuccess(unclaim, HttpStatus.OK);
Long sourceId2 = uploadTextSource(uploaderToken);
Long taskId2 = createTask(sourceId2, "EXTRACTION");
ResponseEntity<Map> claimTask2 = postJson("/api/tasks/" + taskId2 + "/claim", null, annotatorToken);
assertSuccess(claimTask2, HttpStatus.OK);
ResponseEntity<Map> reassign = putJson("/api/tasks/" + taskId2 + "/reassign",
Map.of("userId", annotator2User.id()), adminToken);
assertSuccess(reassign, HttpStatus.OK);
assertThat(claimedByOfTask(taskId2)).isEqualTo(annotator2User.id());
}
@Test
@DisplayName("提取、问答、配置与导出接口在真实运行环境下可覆盖")
void extractionQaConfigAndExportEndpoints_shouldWork() {
requireRoleAwareAuth();
ResponseEntity<Map> listConfig = get("/api/config", adminToken);
assertSuccess(listConfig, HttpStatus.OK);
ResponseEntity<Map> updateConfig = putJson("/api/config/model_default",
Map.of("value", "glm-4-blackbox-" + runId, "description", "黑盒测试默认模型"),
adminToken);
assertSuccess(updateConfig, HttpStatus.OK);
Long sourceId = uploadTextSource(uploaderToken);
Long extractionTaskId = createTask(sourceId, "EXTRACTION");
assertSuccess(postJson("/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
ResponseEntity<Map> extractionGet = get("/api/extraction/" + extractionTaskId, annotatorToken);
assertSuccess(extractionGet, HttpStatus.OK);
ResponseEntity<Map> extractionPut = putJson("/api/extraction/" + extractionTaskId,
"{\"items\":[{\"label\":\"entity\",\"text\":\"北京\"}]}",
annotatorToken);
assertSuccess(extractionPut, HttpStatus.OK);
ResponseEntity<Map> extractionSubmit = postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken);
assertSuccess(extractionSubmit, HttpStatus.OK);
ResponseEntity<Map> pendingExtraction = get("/api/tasks/pending-review?page=1&pageSize=20&taskType=EXTRACTION", reviewerToken);
assertSuccess(pendingExtraction, HttpStatus.OK);
assertThat(responseContainsId(pendingExtraction, extractionTaskId)).isTrue();
ResponseEntity<Map> extractionReject = postJson("/api/extraction/" + extractionTaskId + "/reject",
Map.of("reason", "黑盒驳回一次"), reviewerToken);
assertSuccess(extractionReject, HttpStatus.OK);
ResponseEntity<Map> reclaim = postJson("/api/tasks/" + extractionTaskId + "/reclaim", null, annotatorToken);
assertSuccess(reclaim, HttpStatus.OK);
assertSuccess(putJson("/api/extraction/" + extractionTaskId,
"{\"items\":[{\"label\":\"entity\",\"text\":\"上海\"}]}",
annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK);
ResponseEntity<Map> extractionApprove = postJson("/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken);
assertSuccess(extractionApprove, HttpStatus.OK);
Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION");
assertThat(qaTaskId).isNotNull();
assertSuccess(postJson("/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
ResponseEntity<Map> qaGet = get("/api/qa/" + qaTaskId, annotatorToken);
assertSuccess(qaGet, HttpStatus.OK);
ResponseEntity<Map> qaPut = putJson("/api/qa/" + qaTaskId,
Map.of("items", List.of(Map.of("question", "北京在哪里", "answer", "中国"))),
annotatorToken);
assertSuccess(qaPut, HttpStatus.OK);
ResponseEntity<Map> qaSubmit = postJson("/api/qa/" + qaTaskId + "/submit", null, annotatorToken);
assertSuccess(qaSubmit, HttpStatus.OK);
ResponseEntity<Map> pendingQa = get("/api/tasks/pending-review?page=1&pageSize=20&taskType=QA_GENERATION", reviewerToken);
assertSuccess(pendingQa, HttpStatus.OK);
assertThat(responseContainsId(pendingQa, qaTaskId)).isTrue();
ResponseEntity<Map> qaApprove = postJson("/api/qa/" + qaTaskId + "/approve", null, reviewerToken);
assertSuccess(qaApprove, HttpStatus.OK);
ResponseEntity<Map> samples = get("/api/training/samples?page=1&pageSize=20&sampleType=TEXT", adminToken);
assertSuccess(samples, HttpStatus.OK);
Long datasetId = latestApprovedDatasetId(sourceId);
ResponseEntity<Map> createBatch = postJson("/api/export/batch",
Map.of("sampleIds", List.of(datasetId)), adminToken);
assertSuccess(createBatch, HttpStatus.CREATED);
Long batchId = dataId(createBatch);
ResponseEntity<Map> exportList = get("/api/export/list?page=1&pageSize=20", adminToken);
assertSuccess(exportList, HttpStatus.OK);
assertThat(responseContainsId(exportList, batchId)).isTrue();
ResponseEntity<Map> exportStatus = get("/api/export/" + batchId + "/status", adminToken);
assertSuccess(exportStatus, HttpStatus.OK);
// 第二条链路覆盖 QA reject
Long sourceId2 = uploadTextSource(uploaderToken);
Long extractionTaskId2 = createTask(sourceId2, "EXTRACTION");
assertSuccess(postJson("/api/tasks/" + extractionTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/extraction/" + extractionTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/extraction/" + extractionTaskId2 + "/approve", null, reviewerToken), HttpStatus.OK);
Long qaTaskId2 = latestTaskId(sourceId2, "QA_GENERATION");
assertSuccess(postJson("/api/tasks/" + qaTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/qa/" + qaTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK);
ResponseEntity<Map> qaReject = postJson("/api/qa/" + qaTaskId2 + "/reject",
Map.of("reason", "黑盒问答驳回"), reviewerToken);
assertSuccess(qaReject, HttpStatus.OK);
}
@Test
@DisplayName("视频处理与微调接口在显式开启重链路模式时可覆盖")
void videoAndFinetuneEndpoints_shouldWorkWhenHeavyModeEnabled() {
requireRoleAwareAuth();
assumeTrue(Boolean.getBoolean("blackbox.heavy.enabled"),
"未开启 -Dblackbox.heavy.enabled=true跳过视频处理与微调重链路黑盒用例");
Long sourceId = uploadTextSource(uploaderToken);
Long extractionTaskId = createTask(sourceId, "EXTRACTION");
assertSuccess(postJson("/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken), HttpStatus.OK);
Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION");
assertSuccess(postJson("/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/qa/" + qaTaskId + "/submit", null, annotatorToken), HttpStatus.OK);
assertSuccess(postJson("/api/qa/" + qaTaskId + "/approve", null, reviewerToken), HttpStatus.OK);
Long datasetId = latestApprovedDatasetId(sourceId);
ResponseEntity<Map> createBatch = postJson("/api/export/batch",
Map.of("sampleIds", List.of(datasetId)), adminToken);
assertSuccess(createBatch, HttpStatus.CREATED);
Long batchId = dataId(createBatch);
ResponseEntity<Map> finetune = postJson("/api/export/" + batchId + "/finetune", null, adminToken);
assertSuccess(finetune, HttpStatus.OK);
Long videoSourceId = uploadVideoSource(uploaderToken);
ResponseEntity<Map> createVideoJob = postJson("/api/video/process",
Map.of("sourceId", videoSourceId, "jobType", "FRAME_EXTRACT", "params", "{\"frameInterval\":30}"),
adminToken);
assertSuccess(createVideoJob, HttpStatus.OK);
Long jobId = dataId(createVideoJob);
ResponseEntity<Map> getVideoJob = get("/api/video/jobs/" + jobId, adminToken);
assertSuccess(getVideoJob, HttpStatus.OK);
Long failedJobId = insertFailedVideoJob(videoSourceId);
ResponseEntity<Map> resetJob = postJson("/api/video/jobs/" + failedJobId + "/reset", null, adminToken);
assertSuccess(resetJob, HttpStatus.OK);
Long callbackJobId = insertPendingVideoJob(videoSourceId);
ResponseEntity<Map> callbackSuccess1 = postVideoCallback(Map.of(
"jobId", callbackJobId,
"status", "SUCCESS",
"outputPath", "processed/" + runId + "/frames.zip"
));
assertSuccess(callbackSuccess1, HttpStatus.OK);
ResponseEntity<Map> callbackSuccess2 = postVideoCallback(Map.of(
"jobId", callbackJobId,
"status", "SUCCESS",
"outputPath", "processed/" + runId + "/frames.zip"
));
assertSuccess(callbackSuccess2, HttpStatus.OK);
}
}

View File

@@ -1,160 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.common.result.Result;
import com.label.dto.LoginRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 认证流程集成测试US1
*
* 测试场景:
* 1. 正确密码登录 → 返回 token
* 2. 错误密码登录 → 401
* 3. 不存在的公司代码 → 401
* 4. 有效 Token 访问 /api/auth/me → 200返回用户信息
* 5. 主动退出后,原 Token 访问 /api/auth/me → 401
*
* 测试数据来自 init.sql 种子DEMO 公司 / admin / admin123
*/
public class AuthIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
// ------------------------------------------------------------------ 登录测试 --
@Test
@DisplayName("正确密码登录 → 返回 token")
void login_withCorrectCredentials_returnsToken() {
ResponseEntity<Map> response = doLogin("DEMO", "admin", "admin123");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<?, ?> body = response.getBody();
assertThat(body).isNotNull();
assertThat(body.get("code")).isEqualTo("SUCCESS");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) body.get("data");
assertThat(data.get("token")).isNotNull().isInstanceOf(String.class);
assertThat((String) data.get("token")).isNotBlank();
assertThat(data.get("role")).isEqualTo("ADMIN");
}
@Test
@DisplayName("错误密码登录 → 401 Unauthorized")
void login_withWrongPassword_returns401() {
ResponseEntity<Map> response = doLogin("DEMO", "admin", "wrong_password");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("不存在的公司代码 → 401 Unauthorized")
void login_withUnknownCompany_returns401() {
ResponseEntity<Map> response = doLogin("NONEXIST", "admin", "admin123");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ /me 测试 --
@Test
@DisplayName("有效 Token 访问 /api/auth/me → 200返回用户信息")
void me_withValidToken_returns200WithUserInfo() {
String token = loginAndGetToken("DEMO", "admin", "admin123");
assertThat(token).isNotBlank();
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(token),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data.get("username")).isEqualTo("admin");
assertThat(data.get("role")).isEqualTo("ADMIN");
assertThat(data.get("companyId")).isNotNull();
}
@Test
@DisplayName("无 Token 访问 /api/auth/me → 401")
void me_withNoToken_returns401() {
ResponseEntity<String> response = restTemplate.getForEntity(
baseUrl("/api/auth/me"), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ 退出测试 --
@Test
@DisplayName("主动退出后,原 Token 访问 /api/auth/me → 401")
void logout_thenMe_returns401() {
String token = loginAndGetToken("DEMO", "admin", "admin123");
assertThat(token).isNotBlank();
// 确认登录有效
ResponseEntity<Map> meResponse = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(token),
Map.class);
assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 退出
ResponseEntity<Map> logoutResponse = restTemplate.exchange(
baseUrl("/api/auth/logout"),
HttpMethod.POST,
bearerRequest(token),
Map.class);
assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 退出后再访问 /me → 401
ResponseEntity<Map> meAfterLogout = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(token),
Map.class);
assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ 工具方法 --
/** 发起登录请求,返回原始 ResponseEntity */
private ResponseEntity<Map> doLogin(String companyCode, String username, String password) {
LoginRequest req = new LoginRequest();
req.setCompanyCode(companyCode);
req.setUsername(username);
req.setPassword(password);
return restTemplate.postForEntity(baseUrl("/api/auth/login"), req, Map.class);
}
/** 登录并提取 token 字符串;失败时返回 null */
private String loginAndGetToken(String companyCode, String username, String password) {
ResponseEntity<Map> response = doLogin(companyCode, username, password);
if (!response.getStatusCode().is2xxSuccessful()) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
return (String) data.get("token");
}
/** 构造带 Bearer Token 的请求实体(无 body */
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,175 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 训练数据导出集成测试US6
*
* 测试场景:
* 1. 包含非 APPROVED 样本时返回 400 INVALID_SAMPLES
* 2. sampleIds 为空时返回 400 EMPTY_SAMPLES
* 3. 非 ADMIN 访问 → 403 Forbidden
*
* 注意:实际上传 RustFS 需要 MinIO 容器支持,此处仅测试可验证的业务逻辑。
* 文件存在性验证需启动 MinIO 容器(超出当前测试范围)。
*/
public class ExportIntegrationTest extends AbstractIntegrationTest {
private static final String ADMIN_TOKEN = "test-admin-token-export";
private static final String ANNOTATOR_TOKEN = "test-annotator-token-export";
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RedisService redisService;
private Long sourceId;
private Long approvedDatasetId;
private Long pendingDatasetId;
@BeforeEach
void setupTokensAndData() {
Long companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
Long userId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'admin'", Long.class);
// 伪造 Redis Token
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
Map.of("userId", userId.toString(), "role", "ADMIN",
"companyId", companyId.toString(), "username", "admin"),
3600L);
redisService.hSetAll(RedisUtil.tokenKey(ANNOTATOR_TOKEN),
Map.of("userId", "3", "role", "ANNOTATOR",
"companyId", companyId.toString(), "username", "annotator01"),
3600L);
// 插入 source_data
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (" + companyId + ", " + userId + ", 'TEXT', " +
"'test/export-test/file.txt', 'file.txt', 100, 'test-bucket', 'APPROVED')");
sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
// 插入 EXTRACTION 任务(已 APPROVED用于关联 training_dataset
jdbcTemplate.execute(
"INSERT INTO annotation_task (company_id, source_id, task_type, status, is_final) " +
"VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'APPROVED', true)");
Long taskId = jdbcTemplate.queryForObject(
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
// 插入 APPROVED training_dataset
jdbcTemplate.execute(
"INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " +
"glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId +
", 'TEXT', '{\"conversations\":[{\"question\":\"Q1\",\"answer\":\"A1\"}]}'::jsonb, " +
"'APPROVED')");
approvedDatasetId = jdbcTemplate.queryForObject(
"SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class);
// 插入 PENDING_REVIEW training_dataset用于测试校验失败
jdbcTemplate.execute(
"INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " +
"glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId +
", 'TEXT', '{\"conversations\":[]}'::jsonb, 'PENDING_REVIEW')");
pendingDatasetId = jdbcTemplate.queryForObject(
"SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class);
}
@AfterEach
void cleanupTokens() {
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
redisService.delete(RedisUtil.tokenKey(ANNOTATOR_TOKEN));
}
// ------------------------------------------------------------------ 权限测试 --
@Test
@DisplayName("非 ADMIN 访问导出接口 → 403 Forbidden")
void createBatch_byAnnotator_returns403() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + ANNOTATOR_TOKEN);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> req = new HttpEntity<>(
Map.of("sampleIds", List.of(approvedDatasetId)), headers);
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/export/batch"), HttpMethod.POST, req, Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
// ------------------------------------------------------------------ 样本校验测试 --
@Test
@DisplayName("sampleIds 为空 → 400 EMPTY_SAMPLES")
void createBatch_withEmptyIds_returns400() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> req = new HttpEntity<>(
Map.of("sampleIds", List.of()), headers);
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/export/batch"), HttpMethod.POST, req, Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("code")).isEqualTo("EMPTY_SAMPLES");
}
@Test
@DisplayName("包含非 APPROVED 样本 → 400 INVALID_SAMPLES")
void createBatch_withNonApprovedSample_returns400() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
headers.setContentType(MediaType.APPLICATION_JSON);
// 混合 APPROVED + PENDING_REVIEW
HttpEntity<Map<String, Object>> req = new HttpEntity<>(
Map.of("sampleIds", List.of(approvedDatasetId, pendingDatasetId)), headers);
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/export/batch"), HttpMethod.POST, req, Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("code")).isEqualTo("INVALID_SAMPLES");
}
@Test
@DisplayName("查询已审批样本列表 → 200包含 APPROVED 样本")
void listSamples_adminOnly_returns200() {
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/training/samples"),
HttpMethod.GET,
bearerRequest(ADMIN_TOKEN),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(((Number) data.get("total")).longValue()).isGreaterThanOrEqualTo(1L);
}
// ------------------------------------------------------------------ 工具方法 --
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,217 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.dto.LoginRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 提取阶段审批集成测试US4
*
* 测试场景:
* 1. 审批通过 → QA_GENERATION 任务自动创建source_data 状态更新为 QA_REVIEW
* 2. 审批人与提交人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN
* 3. 驳回后标注员可重领任务并再次提交
*/
public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
private Long sourceId;
private Long taskId;
private Long annotatorUserId;
private Long reviewerUserId;
@BeforeEach
void setup() {
// 获取种子用户 IDinit.sql 中已插入)
annotatorUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class);
reviewerUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class);
Long companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
// 插入测试 source_data
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (" + companyId + ", " + annotatorUserId + ", 'TEXT', " +
"'test/approval-test/file.txt', 'file.txt', 100, 'test-bucket', 'PENDING')");
sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
// 插入 UNCLAIMED EXTRACTION 任务
jdbcTemplate.execute(
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
"VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')");
taskId = jdbcTemplate.queryForObject(
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
}
// ------------------------------------------------------------------ 测试 1: 审批通过 → QA 任务自动创建 --
@Test
@DisplayName("审批通过后QA_GENERATION 任务自动创建source_data 状态变为 QA_REVIEW")
void approveTask_thenQaTaskAndSourceStatusUpdated() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 1. 标注员领取任务
ResponseEntity<Map> claimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 2. 标注员提交标注
ResponseEntity<Map> submitResp = restTemplate.exchange(
baseUrl("/api/extraction/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 3. 审核员审批通过
// 注ExtractionApprovedEventListener(@TransactionalEventListener AFTER_COMMIT)
// 在同一线程中同步执行HTTP 响应返回前已完成后续处理
ResponseEntity<Map> approveResp = restTemplate.exchange(
baseUrl("/api/extraction/" + taskId + "/approve"),
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:原任务状态变为 APPROVEDis_final=true
Map<String, Object> taskRow = jdbcTemplate.queryForMap(
"SELECT status, is_final FROM annotation_task WHERE id = ?", taskId);
assertThat(taskRow.get("status")).isEqualTo("APPROVED");
assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE);
// 验证QA_GENERATION 任务已自动创建UNCLAIMED 状态)
Integer qaTaskCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM annotation_task " +
"WHERE source_id = ? AND task_type = 'QA_GENERATION' AND status = 'UNCLAIMED'",
Integer.class, sourceId);
assertThat(qaTaskCount).as("QA_GENERATION 任务应已创建").isEqualTo(1);
// 验证source_data 状态已更新为 QA_REVIEW
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("source_data 状态应为 QA_REVIEW").isEqualTo("QA_REVIEW");
// 验证training_dataset 已以 PENDING_REVIEW 状态创建
Integer datasetCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM training_dataset " +
"WHERE source_id = ? AND status = 'PENDING_REVIEW'",
Integer.class, sourceId);
assertThat(datasetCount).as("training_dataset 应已创建").isEqualTo(1);
}
// ------------------------------------------------------------------ 测试 2: 自审返回 403 --
@Test
@DisplayName("审批人与任务领取人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN")
void approveOwnSubmission_returnsForbidden() {
// 直接将任务置为 SUBMITTED 并设 claimed_by = reviewer01模拟自审场景
jdbcTemplate.execute(
"UPDATE annotation_task " +
"SET status = 'SUBMITTED', claimed_by = " + reviewerUserId +
", claimed_at = NOW(), submitted_at = NOW() " +
"WHERE id = " + taskId);
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
ResponseEntity<Map> resp = restTemplate.exchange(
baseUrl("/api/extraction/" + taskId + "/approve"),
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
// 验证任务状态未变
String status = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(status).isEqualTo("SUBMITTED");
}
// ------------------------------------------------------------------ 测试 3: 驳回 → 重领 → 再提交 --
@Test
@DisplayName("驳回后标注员可重领任务并再次提交,任务状态恢复为 SUBMITTED")
void rejectThenReclaimAndResubmit_succeeds() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 1. 标注员领取并提交
restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
restTemplate.exchange(baseUrl("/api/extraction/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
// 2. 审核员驳回(驳回原因必填)
HttpHeaders rejectHeaders = new HttpHeaders();
rejectHeaders.set("Authorization", "Bearer " + reviewerToken);
rejectHeaders.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> rejectReq = new HttpEntity<>(
Map.of("reason", "实体识别有误,请重新标注"), rejectHeaders);
ResponseEntity<Map> rejectResp = restTemplate.exchange(
baseUrl("/api/extraction/" + taskId + "/reject"),
HttpMethod.POST, rejectReq, Map.class);
assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:任务状态变为 REJECTED
String statusAfterReject = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(statusAfterReject).isEqualTo("REJECTED");
// 3. 标注员重领任务REJECTED → IN_PROGRESS
ResponseEntity<Map> reclaimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/reclaim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:任务状态恢复为 IN_PROGRESS
String statusAfterReclaim = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(statusAfterReclaim).isEqualTo("IN_PROGRESS");
// 4. 标注员再次提交IN_PROGRESS → SUBMITTED
ResponseEntity<Map> resubmitResp = restTemplate.exchange(
baseUrl("/api/extraction/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:任务状态变为 SUBMITTED
String finalStatus = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(finalStatus).isEqualTo("SUBMITTED");
}
// ------------------------------------------------------------------ 工具方法 --
private String loginAndGetToken(String companyCode, String username, String password) {
LoginRequest req = new LoginRequest();
req.setCompanyCode(companyCode);
req.setUsername(username);
req.setPassword(password);
ResponseEntity<Map> response = restTemplate.postForEntity(
baseUrl("/api/auth/login"), req, Map.class);
if (!response.getStatusCode().is2xxSuccessful()) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
return (String) data.get("token");
}
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,199 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 多租户隔离集成测试Phase 10 / T070
*
* 测试场景:
* 1. 公司 A 的 ADMIN 查询资料列表 → 只能看到公司 A 的资料,看不到公司 B 的
* 2. 公司 B 的 ADMIN 查询任务 → 只能看到公司 B 的任务,看不到公司 A 的
* 3. 公司 A 的 sys_config 配置不影响公司 B配置隔离
*/
public class MultiTenantIsolationTest extends AbstractIntegrationTest {
private static final String TOKEN_A = "test-admin-token-company-a";
private static final String TOKEN_B = "test-admin-token-company-b";
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RedisService redisService;
private Long companyAId; // DEMO 公司(已在 init.sql 中创建)
private Long companyBId; // 测试用第二家公司
private Long adminAId; // DEMO 公司 admin
private Long adminBId; // 第二家公司 admin
@BeforeEach
void setupCompaniesAndTokens() {
// 公司 A使用 init.sql 中的 DEMO 公司
companyAId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
adminAId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'admin' AND company_id = ?",
Long.class, companyAId);
// 公司 B在测试中创建第二家公司
jdbcTemplate.execute(
"INSERT INTO sys_company (company_name, company_code, status) " +
"VALUES ('测试公司B', 'TESTB', 'ACTIVE') ON CONFLICT DO NOTHING");
companyBId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'TESTB'", Long.class);
// 为公司 B 创建 admin 用户
jdbcTemplate.execute(
"INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) " +
"VALUES (" + companyBId + ", 'admin_b', " +
"'$2a$10$B8iR5z43URiNPm.eut3JvufIPBuvGx5ZZmqyUqE1A1WdbZppX5bmi', " +
"'B公司管理员', 'ADMIN', 'ACTIVE') ON CONFLICT (company_id, username) DO NOTHING");
adminBId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'admin_b' AND company_id = ?",
Long.class, companyBId);
// 伪造 Redis Token
redisService.hSetAll(RedisUtil.tokenKey(TOKEN_A),
Map.of("userId", adminAId.toString(), "role", "ADMIN",
"companyId", companyAId.toString(), "username", "admin"),
3600L);
redisService.hSetAll(RedisUtil.tokenKey(TOKEN_B),
Map.of("userId", adminBId.toString(), "role", "ADMIN",
"companyId", companyBId.toString(), "username", "admin_b"),
3600L);
// 公司 A 插入两条 source_data
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (" + companyAId + ", " + adminAId + ", 'TEXT', " +
"'company-a/file1.txt', 'file1.txt', 100, 'label-source-data', 'PENDING'), " +
"(" + companyAId + ", " + adminAId + ", 'TEXT', " +
"'company-a/file2.txt', 'file2.txt', 200, 'label-source-data', 'PENDING')");
// 公司 B 插入一条 source_data
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (" + companyBId + ", " + adminBId + ", 'TEXT', " +
"'company-b/file1.txt', 'file1.txt', 300, 'label-source-data', 'PENDING')");
}
@AfterEach
void cleanupTokensAndCompanyB() {
redisService.delete(RedisUtil.tokenKey(TOKEN_A));
redisService.delete(RedisUtil.tokenKey(TOKEN_B));
// 清理公司 B 的数据sys_company 不在 cleanData TRUNCATE 范围内)
jdbcTemplate.execute("DELETE FROM sys_user WHERE username = 'admin_b'");
jdbcTemplate.execute("DELETE FROM sys_company WHERE company_code = 'TESTB'");
}
// ------------------------------------------------------------------ 测试 1: 资料列表隔离 --
@Test
@DisplayName("公司 A 只能查看本公司资料,看不到公司 B 的资料")
void sourceList_companyA_cannotSeeCompanyBData() {
ResponseEntity<Map> resp = restTemplate.exchange(
baseUrl("/api/source/list?page=1&pageSize=50"),
HttpMethod.GET,
bearerRequest(TOKEN_A),
Map.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) resp.getBody().get("data");
assertThat(((Number) data.get("total")).longValue())
.as("公司 A 应只看到自己的 2 条资料")
.isEqualTo(2L);
@SuppressWarnings("unchecked")
List<Map<String, Object>> records = (List<Map<String, Object>>) data.get("records");
records.forEach(r ->
assertThat(((Number) r.get("companyId")).longValue())
.as("每条资料的 companyId 应为公司 A 的 ID")
.isEqualTo(companyAId));
}
@Test
@DisplayName("公司 B 只能查看本公司资料,看不到公司 A 的资料")
void sourceList_companyB_cannotSeeCompanyAData() {
ResponseEntity<Map> resp = restTemplate.exchange(
baseUrl("/api/source/list?page=1&pageSize=50"),
HttpMethod.GET,
bearerRequest(TOKEN_B),
Map.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) resp.getBody().get("data");
assertThat(((Number) data.get("total")).longValue())
.as("公司 B 应只看到自己的 1 条资料")
.isEqualTo(1L);
}
// ------------------------------------------------------------------ 测试 2: 配置隔离 --
@Test
@DisplayName("公司 A 设置专属配置,公司 B 仍使用全局默认")
void sysConfig_companyA_doesNotAffectCompanyB() {
// 公司 A 设置专属 model_default
HttpHeaders headersA = new HttpHeaders();
headersA.set("Authorization", "Bearer " + TOKEN_A);
headersA.setContentType(MediaType.APPLICATION_JSON);
restTemplate.exchange(
baseUrl("/api/config/model_default"),
HttpMethod.PUT,
new HttpEntity<>(Map.of("value", "glm-4-plus"), headersA),
Map.class);
// 公司 B 查询配置列表
ResponseEntity<Map> respB = restTemplate.exchange(
baseUrl("/api/config"),
HttpMethod.GET,
bearerRequest(TOKEN_B),
Map.class);
assertThat(respB.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
List<Map<String, Object>> configsB = (List<Map<String, Object>>) respB.getBody().get("data");
Map<String, Object> modelCfgB = configsB.stream()
.filter(c -> "model_default".equals(c.get("configKey")))
.findFirst()
.orElse(null);
if (modelCfgB != null) {
// 公司 B 未设置专属,应使用全局默认 glm-4scope=GLOBAL
assertThat(modelCfgB.get("scope"))
.as("公司 B 应使用全局默认配置scope=GLOBAL")
.isEqualTo("GLOBAL");
assertThat(modelCfgB.get("configValue"))
.as("公司 B model_default 应为全局默认 glm-4不受公司 A 设置影响")
.isEqualTo("glm-4");
}
}
// ------------------------------------------------------------------ 工具方法 --
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,196 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.dto.LoginRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* QA 问答生成阶段审批集成测试US5
*
* 测试场景:
* 1. QA 审批通过 → training_dataset.status = APPROVEDsource_data.status = APPROVED
* 2. QA 驳回 → 候选问答对被删除,标注员可重领
*/
public class QaApprovalIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
private Long sourceId;
private Long taskId;
private Long datasetId;
private Long annotatorUserId;
private Long reviewerUserId;
@BeforeEach
void setup() {
annotatorUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class);
reviewerUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class);
Long companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
// 插入 source_dataQA_REVIEW 状态,模拟提取审批已完成)
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (" + companyId + ", " + annotatorUserId + ", 'TEXT', " +
"'test/qa-test/file.txt', 'file.txt', 100, 'test-bucket', 'QA_REVIEW')");
sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
// 插入 QA_GENERATION 任务UNCLAIMED 状态,模拟提取审批通过后自动创建的 QA 任务)
jdbcTemplate.execute(
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
"VALUES (" + companyId + ", " + sourceId + ", 'QA_GENERATION', 'UNCLAIMED')");
taskId = jdbcTemplate.queryForObject(
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
// 插入候选问答对(模拟 ExtractionApprovedEventListener 创建)
jdbcTemplate.execute(
"INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " +
"glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId +
", 'TEXT', '{\"conversations\":[{\"question\":\"北京是哪个国家的首都?\",\"answer\":\"中国\"}]}'::jsonb, " +
"'PENDING_REVIEW')");
datasetId = jdbcTemplate.queryForObject(
"SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class);
}
// ------------------------------------------------------------------ 测试 1: 审批通过 → 终态 --
@Test
@DisplayName("QA 审批通过 → training_dataset.status=APPROVEDsource_data.status=APPROVED")
void approveQaTask_thenDatasetAndSourceApproved() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 注意QA 任务 claim 端点为 POST /api/tasks/{id}/claimANNOTATOR 角色)
// 但 TaskController.getPool 只给 ANNOTATOR 显示 EXTRACTION/UNCLAIMED
// QA 任务由 ANNOTATOR 直接领取(不经过任务池)
ResponseEntity<Map> claimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 提交 QA 结果
ResponseEntity<Map> submitResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 审批通过
ResponseEntity<Map> approveResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/approve"),
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证training_dataset → APPROVED
String datasetStatus = jdbcTemplate.queryForObject(
"SELECT status FROM training_dataset WHERE id = ?", String.class, datasetId);
assertThat(datasetStatus).as("training_dataset 状态应为 APPROVED").isEqualTo("APPROVED");
// 验证annotation_task → APPROVEDis_final=true
Map<String, Object> taskRow = jdbcTemplate.queryForMap(
"SELECT status, is_final FROM annotation_task WHERE id = ?", taskId);
assertThat(taskRow.get("status")).isEqualTo("APPROVED");
assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE);
// 验证source_data → APPROVED整条流水线完成
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("source_data 状态应为 APPROVED流水线终态").isEqualTo("APPROVED");
}
// ------------------------------------------------------------------ 测试 2: 驳回 → 候选记录删除 → 可重领 --
@Test
@DisplayName("QA 驳回 → 候选问答对被删除,标注员可重领并再次提交")
void rejectQaTask_thenDatasetDeletedAndReclaimable() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 领取并提交
restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
restTemplate.exchange(baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
// 驳回(驳回原因必填)
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + reviewerToken);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> rejectReq = new HttpEntity<>(
Map.of("reason", "问题描述不准确,请修改"), headers);
ResponseEntity<Map> rejectResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/reject"),
HttpMethod.POST, rejectReq, Map.class);
assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:任务状态变为 REJECTED
String statusAfterReject = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(statusAfterReject).isEqualTo("REJECTED");
// 验证:候选问答对已被删除
Integer datasetCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM training_dataset WHERE task_id = ?",
Integer.class, taskId);
assertThat(datasetCount).as("驳回后候选问答对应被删除").isEqualTo(0);
// 验证source_data 保持 QA_REVIEW不变
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("驳回后 source_data 应保持 QA_REVIEW").isEqualTo("QA_REVIEW");
// 标注员重领任务
ResponseEntity<Map> reclaimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/reclaim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 再次提交
ResponseEntity<Map> resubmitResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:任务状态变为 SUBMITTED
String finalStatus = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(finalStatus).isEqualTo("SUBMITTED");
}
// ------------------------------------------------------------------ 工具方法 --
private String loginAndGetToken(String companyCode, String username, String password) {
LoginRequest req = new LoginRequest();
req.setCompanyCode(companyCode);
req.setUsername(username);
req.setPassword(password);
ResponseEntity<Map> response = restTemplate.postForEntity(
baseUrl("/api/auth/login"), req, Map.class);
if (!response.getStatusCode().is2xxSuccessful()) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
return (String) data.get("token");
}
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,167 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 原始资料管理集成测试US2
*
* 测试场景:
* 1. UPLOADER 上传文本 → 列表仅返回自己的资料
* 2. ADMIN 查看列表 → 返回全公司资料
* 3. 上传视频 → status = PENDING视频预处理由 Phase 9 处理)
* 4. 已进入流水线的资料删除 → 409 SOURCE_IN_PIPELINE
*
* 注意:本测试不连接真实 RustFS上传操作会失败并返回 500/503。
* 测试仅验证可访问的业务逻辑(权限、状态机)。
* 如需覆盖文件上传,需在测试环境配置 Mock RustFsClient 或启动 MinIO 容器。
*/
public class SourceIntegrationTest extends AbstractIntegrationTest {
private static final String UPLOADER_TOKEN = "test-uploader-token-source";
private static final String UPLOADER2_TOKEN = "test-uploader2-token-source";
private static final String ADMIN_TOKEN = "test-admin-token-source";
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RedisService redisService;
@BeforeEach
void setupTokens() {
// uploader01 token (userId=4 from init.sql seed)
redisService.hSetAll(RedisUtil.tokenKey(UPLOADER_TOKEN),
Map.of("userId", "4", "role", "UPLOADER", "companyId", "1", "username", "uploader01"),
3600L);
// admin token (userId=1 from init.sql seed)
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
Map.of("userId", "1", "role", "ADMIN", "companyId", "1", "username", "admin"),
3600L);
}
@AfterEach
void cleanupTokens() {
redisService.delete(RedisUtil.tokenKey(UPLOADER_TOKEN));
redisService.delete(RedisUtil.tokenKey(UPLOADER2_TOKEN));
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
}
// ------------------------------------------------------------------ 权限测试 --
@Test
@DisplayName("无 Token 访问上传接口 → 401")
void upload_withoutToken_returns401() {
ResponseEntity<String> response = restTemplate.postForEntity(
baseUrl("/api/source/upload"), null, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("UPLOADER 访问列表接口(无数据)→ 200items 为空")
void list_uploaderWithNoData_returnsEmptyList() {
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/source/list"),
HttpMethod.GET,
bearerRequest(UPLOADER_TOKEN),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data.get("items")).isInstanceOf(List.class);
assertThat(((List<?>) data.get("items"))).isEmpty();
assertThat(((Number) data.get("total")).longValue()).isEqualTo(0L);
}
@Test
@DisplayName("ADMIN 访问列表接口(无数据)→ 200items 为空")
void list_adminWithNoData_returnsEmptyList() {
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/source/list"),
HttpMethod.GET,
bearerRequest(ADMIN_TOKEN),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(((List<?>) data.get("items"))).isEmpty();
}
@Test
@DisplayName("删除不存在的资料 → 404")
void delete_nonExistentSource_returns404() {
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/source/9999"),
HttpMethod.DELETE,
bearerRequest(ADMIN_TOKEN),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@DisplayName("非 ADMIN 删除资料 → 403 Forbidden")
void delete_byUploader_returns403() {
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/source/9999"),
HttpMethod.DELETE,
bearerRequest(UPLOADER_TOKEN),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("ADMIN 删除已进入流水线的资料 → 409 SOURCE_IN_PIPELINE")
void delete_sourceInPipeline_returns409() {
// 直接向 DB 插入一条 EXTRACTING 状态的资料(模拟已进入流水线)
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (1, 1, 'TEXT', 'test/path/file.txt', 'file.txt', 100, 'test-bucket', 'EXTRACTING')");
Long sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data WHERE status='EXTRACTING' LIMIT 1", Long.class);
assertThat(sourceId).isNotNull();
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/source/" + sourceId),
HttpMethod.DELETE,
bearerRequest(ADMIN_TOKEN),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
@SuppressWarnings("unchecked")
Map<String, Object> body = response.getBody();
assertThat(body.get("code")).isEqualTo("SOURCE_IN_PIPELINE");
}
// ------------------------------------------------------------------ 工具方法 --
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,184 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 系统配置集成测试US8
*
* 测试场景:
* 1. 公司专属配置覆盖全局默认
* 2. 未设置公司专属时,回退至全局默认
* 3. 未知配置键 → 400 UNKNOWN_CONFIG_KEY
*/
public class SysConfigIntegrationTest extends AbstractIntegrationTest {
private static final String ADMIN_TOKEN = "test-admin-token-config";
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RedisService redisService;
private Long companyId;
private Long adminUserId;
@BeforeEach
void setupToken() {
companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
adminUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'admin'", Long.class);
// 伪造 Redis Token
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
Map.of("userId", adminUserId.toString(), "role", "ADMIN",
"companyId", companyId.toString(), "username", "admin"),
3600L);
}
@AfterEach
void cleanupTokens() {
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
}
// ------------------------------------------------------------------ 测试 1: 公司配置覆盖全局 --
@Test
@DisplayName("公司专属配置优先于全局默认scope=COMPANY 覆盖 scope=GLOBAL")
void companyConfig_overridesGlobalDefault() {
// 设置公司专属配置(覆盖全局 model_default
updateConfig("model_default", "glm-4-plus", "公司专属模型");
// 查询配置列表
ResponseEntity<Map> listResp = restTemplate.exchange(
baseUrl("/api/config"),
HttpMethod.GET,
bearerRequest(ADMIN_TOKEN),
Map.class);
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
List<Map<String, Object>> configs = (List<Map<String, Object>>) listResp.getBody().get("data");
assertThat(configs).isNotEmpty();
// 找到 model_default 配置
Map<String, Object> modelConfig = configs.stream()
.filter(c -> "model_default".equals(c.get("configKey")))
.findFirst()
.orElseThrow(() -> new AssertionError("model_default 配置不存在"));
// 应返回公司专属配置值scope=COMPANY
assertThat(modelConfig.get("configValue"))
.as("公司专属配置应覆盖全局默认")
.isEqualTo("glm-4-plus");
assertThat(modelConfig.get("scope"))
.as("scope 应标记为 COMPANY")
.isEqualTo("COMPANY");
}
// ------------------------------------------------------------------ 测试 2: 回退全局默认 --
@Test
@DisplayName("未设置公司专属配置时返回全局默认值scope=GLOBAL")
void globalConfig_usedWhenNoCompanyOverride() {
// 不设置公司专属,直接查询列表
ResponseEntity<Map> listResp = restTemplate.exchange(
baseUrl("/api/config"),
HttpMethod.GET,
bearerRequest(ADMIN_TOKEN),
Map.class);
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
List<Map<String, Object>> configs = (List<Map<String, Object>>) listResp.getBody().get("data");
// 至少包含 AbstractIntegrationTest.cleanData() 中插入的全局配置
assertThat(configs).isNotEmpty();
// 所有配置都应有 scope 字段
configs.forEach(cfg ->
assertThat(cfg.containsKey("scope")).as("每条配置应含 scope 字段").isTrue());
// token_ttl_seconds 全局默认应为 7200
Map<String, Object> ttlConfig = configs.stream()
.filter(c -> "token_ttl_seconds".equals(c.get("configKey")))
.findFirst()
.orElse(null);
if (ttlConfig != null) {
assertThat(ttlConfig.get("configValue")).isEqualTo("7200");
assertThat(ttlConfig.get("scope")).isEqualTo("GLOBAL");
}
}
// ------------------------------------------------------------------ 测试 3: 未知配置键 --
@Test
@DisplayName("更新未知配置键 → 400 UNKNOWN_CONFIG_KEY")
void updateUnknownKey_returns400() {
ResponseEntity<Map> resp = updateConfig("unknown_key_xyz", "someValue", null);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(resp.getBody().get("code")).isEqualTo("UNKNOWN_CONFIG_KEY");
}
// ------------------------------------------------------------------ 测试 4: UPSERT 同键两次 --
@Test
@DisplayName("同一配置键两次 PUT → 第二次更新而非重复插入")
void updateSameKey_twice_upserts() {
updateConfig("video_frame_interval", "60", "帧间隔 60s");
updateConfig("video_frame_interval", "120", "帧间隔 120s");
// 数据库中公司专属 video_frame_interval 应只有一条记录
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_config WHERE company_id = ? AND config_key = 'video_frame_interval'",
Integer.class, companyId);
assertThat(count).as("UPSERT同键应只有一条公司专属记录").isEqualTo(1);
// 值应为最后一次 PUT 的值
String value = jdbcTemplate.queryForObject(
"SELECT config_value FROM sys_config WHERE company_id = ? AND config_key = 'video_frame_interval'",
String.class, companyId);
assertThat(value).isEqualTo("120");
}
// ------------------------------------------------------------------ 工具方法 --
private ResponseEntity<Map> updateConfig(String key, String value, String description) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = description != null
? Map.of("value", value, "description", description)
: Map.of("value", value);
return restTemplate.exchange(
baseUrl("/api/config/" + key),
HttpMethod.PUT,
new HttpEntity<>(body, headers),
Map.class);
}
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,136 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 任务领取并发安全集成测试US3
*
* 测试场景10 个线程同时争抢同一 UNCLAIMED 任务。
* 期望结果:
* - 恰好 1 人成功200 OK
* - 其余 9 人收到 TASK_CLAIMED (409)
* - DB 中 claimed_by 唯一(只有一个用户 ID
*
* 此测试需要 10 个不同的 userId使用同一 DB 用户账号但不同的 Token。
*/
public class TaskClaimConcurrencyTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RedisService redisService;
private Long taskId;
private final List<String> tokens = new ArrayList<>();
@BeforeEach
void setup() {
// 创建测试任务(直接向 DB 插入一条 UNCLAIMED 任务)
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (1, 1, 'TEXT', 'test/path/file.txt', 'file.txt', 100, 'test-bucket', 'PENDING')");
Long sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
jdbcTemplate.execute(
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
"VALUES (1, " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')");
taskId = jdbcTemplate.queryForObject(
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
// 创建 10 个 Annotator Token模拟不同用户
for (int i = 1; i <= 10; i++) {
String token = "concurrency-test-token-" + i;
tokens.add(token);
// 所有 Token 使用 userId=3annotator01这在真实场景不会发生
// 但在测试中用于验证并发锁机制redis key 基于 taskId不是 userId
redisService.hSetAll(RedisUtil.tokenKey(token),
Map.of("userId", String.valueOf(i + 100), // 假设 userId > 100 不存在,但不影响锁逻辑
"role", "ANNOTATOR", "companyId", "1", "username", "annotator" + i),
3600L);
}
}
@AfterEach
void cleanup() {
tokens.forEach(token -> redisService.delete(RedisUtil.tokenKey(token)));
if (taskId != null) {
redisService.delete(RedisUtil.taskClaimKey(taskId));
}
}
@Test
@DisplayName("10 线程并发抢同一任务:恰好 1 人成功,其余 9 人收到 409 TASK_CLAIMED")
void concurrentClaim_onlyOneSucceeds() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(10);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger conflictCount = new AtomicInteger(0);
for (int i = 0; i < 10; i++) {
final String token = tokens.get(i);
executor.submit(() -> {
try {
startLatch.await(); // 等待起跑信号,最大化并发
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
HttpEntity<Void> request = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, request, Map.class);
if (response.getStatusCode() == HttpStatus.OK) {
successCount.incrementAndGet();
} else if (response.getStatusCode() == HttpStatus.CONFLICT) {
conflictCount.incrementAndGet();
}
} catch (Exception e) {
conflictCount.incrementAndGet(); // 异常也算失败
} finally {
doneLatch.countDown();
}
});
}
startLatch.countDown(); // 同时放行所有线程
doneLatch.await(30, TimeUnit.SECONDS);
executor.shutdown();
// 恰好 1 人成功
assertThat(successCount.get()).isEqualTo(1);
// 其余 9 人失败409 或异常)
assertThat(conflictCount.get()).isEqualTo(9);
// DB 中 claimed_by 有且仅有一个值
String claimedByStr = jdbcTemplate.queryForObject(
"SELECT claimed_by::text FROM annotation_task WHERE id = ?",
String.class, taskId);
assertThat(claimedByStr).isNotNull();
// DB 中状态为 IN_PROGRESS
String status = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(status).isEqualTo("IN_PROGRESS");
}
}

View File

@@ -1,177 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.dto.LoginRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 用户管理集成测试US7
*
* 测试场景:
* 1. 变更角色后权限下一次请求立即生效(无需重新登录)
* 2. 禁用账号后现有 Token 下一次请求立即返回 401
*/
public class UserManagementIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
private String adminToken;
@BeforeEach
void setup() {
adminToken = loginAndGetToken("DEMO", "admin", "admin123");
assertThat(adminToken).isNotBlank();
}
// ------------------------------------------------------------------ 测试 1: 角色变更立即生效 --
@Test
@DisplayName("创建用户为 ANNOTATOR变更为 REVIEWER 后同一 Token 立即可访问审批接口")
void updateRole_takesEffectImmediately() {
String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8);
// 1. 创建 ANNOTATOR 用户
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + adminToken);
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<Map> createResp = restTemplate.exchange(
baseUrl("/api/users"),
HttpMethod.POST,
new HttpEntity<>(Map.of(
"username", uniqueUsername,
"password", "test1234",
"realName", "测试用户",
"role", "ANNOTATOR"
), headers),
Map.class);
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> userData = (Map<String, Object>) createResp.getBody().get("data");
Long newUserId = ((Number) userData.get("id")).longValue();
// 2. 新用户登录获取 Token
String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234");
assertThat(userToken).isNotBlank();
// 3. 验证ANNOTATOR 无法访问待审批队列REVIEWER 专属)→ 403
ResponseEntity<Map> beforeRoleChange = restTemplate.exchange(
baseUrl("/api/tasks/pending-review"),
HttpMethod.GET,
bearerRequest(userToken),
Map.class);
assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
// 4. ADMIN 变更角色为 REVIEWER
ResponseEntity<Map> roleResp = restTemplate.exchange(
baseUrl("/api/users/" + newUserId + "/role"),
HttpMethod.PUT,
new HttpEntity<>(Map.of("role", "REVIEWER"), headers),
Map.class);
assertThat(roleResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 5. 验证:同一 Token 下次请求立即具有 REVIEWER 权限 → 200
ResponseEntity<Map> afterRoleChange = restTemplate.exchange(
baseUrl("/api/tasks/pending-review"),
HttpMethod.GET,
bearerRequest(userToken),
Map.class);
assertThat(afterRoleChange.getStatusCode())
.as("角色变更后同一 Token 应立即具有 REVIEWER 权限")
.isEqualTo(HttpStatus.OK);
}
// ------------------------------------------------------------------ 测试 2: 禁用账号 Token 立即失效 --
@Test
@DisplayName("禁用账号后,现有 Token 下一次请求立即返回 401")
void disableAccount_tokenInvalidatedImmediately() {
String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8);
// 1. 创建用户
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + adminToken);
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<Map> createResp = restTemplate.exchange(
baseUrl("/api/users"),
HttpMethod.POST,
new HttpEntity<>(Map.of(
"username", uniqueUsername,
"password", "test1234",
"realName", "测试用户",
"role", "ANNOTATOR"
), headers),
Map.class);
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> userData = (Map<String, Object>) createResp.getBody().get("data");
Long newUserId = ((Number) userData.get("id")).longValue();
// 2. 新用户登录,获取 Token
String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234");
assertThat(userToken).isNotBlank();
// 3. 验证 Token 有效
ResponseEntity<Map> meResp = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(userToken),
Map.class);
assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 4. ADMIN 禁用账号
ResponseEntity<Map> disableResp = restTemplate.exchange(
baseUrl("/api/users/" + newUserId + "/status"),
HttpMethod.PUT,
new HttpEntity<>(Map.of("status", "DISABLED"), headers),
Map.class);
assertThat(disableResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 5. 验证:禁用后,现有 Token 立即失效 → 401
ResponseEntity<Map> meAfterDisable = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(userToken),
Map.class);
assertThat(meAfterDisable.getStatusCode())
.as("禁用账号后现有 Token 应立即失效")
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ 工具方法 --
private String loginAndGetToken(String companyCode, String username, String password) {
LoginRequest req = new LoginRequest();
req.setCompanyCode(companyCode);
req.setUsername(username);
req.setPassword(password);
ResponseEntity<Map> response = restTemplate.postForEntity(
baseUrl("/api/auth/login"), req, Map.class);
if (!response.getStatusCode().is2xxSuccessful()) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
return (String) data.get("token");
}
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -1,183 +0,0 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 视频处理回调幂等与重试集成测试US8
*
* 测试场景:
* 1. 同一 jobId 收到两次 SUCCESS 回调annotation_taskEXTRACTION仅创建一次
* 2. 超出最大重试次数 → job.status = FAILEDsource_data.status = PENDING
*/
public class VideoCallbackIdempotencyTest extends AbstractIntegrationTest {
private static final String ADMIN_TOKEN = "test-admin-token-video";
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RedisService redisService;
private Long companyId;
private Long adminUserId;
private Long sourceId;
private Long jobId;
@BeforeEach
void setupTokenAndData() {
companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
adminUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'admin'", Long.class);
// 伪造 Redis Token
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
Map.of("userId", adminUserId.toString(), "role", "ADMIN",
"companyId", companyId.toString(), "username", "admin"),
3600L);
// 插入 source_dataPREPROCESSING 状态,模拟视频处理中)
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (" + companyId + ", " + adminUserId + ", 'VIDEO', " +
"'videos/test.mp4', 'test.mp4', 10240, 'label-source-data', 'PREPROCESSING')");
sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
// 插入 PENDING 视频处理任务
jdbcTemplate.execute(
"INSERT INTO video_process_job (company_id, source_id, job_type, status, " +
"params, retry_count, max_retries) " +
"VALUES (" + companyId + ", " + sourceId + ", 'FRAME_EXTRACT', 'PENDING', " +
"'{}'::jsonb, 0, 3)");
jobId = jdbcTemplate.queryForObject(
"SELECT id FROM video_process_job ORDER BY id DESC LIMIT 1", Long.class);
}
@AfterEach
void cleanupTokens() {
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
}
// ------------------------------------------------------------------ 测试 1: 幂等性 --
@Test
@DisplayName("同一 jobId 发送两次 SUCCESS 回调source_data 仅更新一次status=PENDING")
void successCallback_idempotent_sourceUpdatedOnce() {
// 第一次 SUCCESS 回调
ResponseEntity<Map> resp1 = sendCallback(jobId, "SUCCESS", "processed/frames.zip", null);
assertThat(resp1.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证第一次回调后状态
String jobStatus1 = jdbcTemplate.queryForObject(
"SELECT status FROM video_process_job WHERE id = ?", String.class, jobId);
assertThat(jobStatus1).isEqualTo("SUCCESS");
String sourceStatus1 = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus1).isEqualTo("PENDING");
// 第二次 SUCCESS 回调(幂等:应直接返回,不重复处理)
ResponseEntity<Map> resp2 = sendCallback(jobId, "SUCCESS", "processed/frames.zip", null);
assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);
// 状态仍为 SUCCESS + PENDING未被改变
String jobStatus2 = jdbcTemplate.queryForObject(
"SELECT status FROM video_process_job WHERE id = ?", String.class, jobId);
assertThat(jobStatus2).as("幂等:第二次回调不应改变 job 状态").isEqualTo("SUCCESS");
String sourceStatus2 = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus2).as("幂等:第二次回调不应改变 source_data 状态").isEqualTo("PENDING");
}
// ------------------------------------------------------------------ 测试 2: 超出重试上限 → FAILED --
@Test
@DisplayName("超出最大重试次数后 → job.status=FAILEDsource_data.status=PENDING")
void failedCallback_exceedsMaxRetries_jobBecomesFailedAndSourceReverts() {
// 将 retry_count 设为 max_retries-1再失败一次就超限
jdbcTemplate.execute(
"UPDATE video_process_job SET retry_count = 2, max_retries = 3, " +
"status = 'RETRYING' WHERE id = " + jobId);
// 发送最后一次 FAILED 回调retry_count 变为 3 = max_retries → 超限)
ResponseEntity<Map> resp = sendCallback(jobId, "FAILED", null, "ffmpeg 处理超时");
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证 job → FAILED
Map<String, Object> jobRow = jdbcTemplate.queryForMap(
"SELECT status, retry_count, error_message FROM video_process_job WHERE id = ?", jobId);
assertThat(jobRow.get("status")).as("超出重试上限后 job 应为 FAILED").isEqualTo("FAILED");
assertThat(((Number) jobRow.get("retry_count")).intValue()).isEqualTo(3);
assertThat(jobRow.get("error_message")).isEqualTo("ffmpeg 处理超时");
// 验证 source_data → PENDING管理员可重新处理
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("超出重试上限后 source_data 应回退为 PENDING").isEqualTo("PENDING");
}
// ------------------------------------------------------------------ 测试 3: 管理员重置 --
@Test
@DisplayName("管理员重置 FAILED 任务 → job.status=PENDINGretryCount=0")
void resetFailedJob_succeeds() {
// 先将任务置为 FAILED 状态
jdbcTemplate.execute(
"UPDATE video_process_job SET status = 'FAILED', retry_count = 3 WHERE id = " + jobId);
// 重置
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
ResponseEntity<Map> resp = restTemplate.exchange(
baseUrl("/api/video/jobs/" + jobId + "/reset"),
HttpMethod.POST,
new HttpEntity<>(headers),
Map.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证
Map<String, Object> jobRow = jdbcTemplate.queryForMap(
"SELECT status, retry_count FROM video_process_job WHERE id = ?", jobId);
assertThat(jobRow.get("status")).isEqualTo("PENDING");
assertThat(((Number) jobRow.get("retry_count")).intValue()).isEqualTo(0);
}
// ------------------------------------------------------------------ 工具方法 --
private ResponseEntity<Map> sendCallback(Long jobId, String status,
String outputPath, String errorMessage) {
Map<String, Object> body;
if ("SUCCESS".equals(status)) {
body = Map.of("jobId", jobId, "status", status, "outputPath", outputPath);
} else {
body = Map.of("jobId", jobId, "status", status, "errorMessage",
errorMessage != null ? errorMessage : "");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return restTemplate.exchange(
baseUrl("/api/video/callback"),
HttpMethod.POST,
new HttpEntity<>(body, headers),
Map.class);
}
}

View File

@@ -1,55 +0,0 @@
package com.label.unit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("应用配置单元测试")
class ApplicationConfigTest {
@Test
@DisplayName("application.yml 提供 Swagger 和 auth 测试开关配置")
void applicationYaml_containsSwaggerAndAuthToggle() throws Exception {
PropertySource<?> source = new YamlPropertySourceLoader()
.load("application", new ClassPathResource("application.yml"))
.get(0);
assertThat(source.getProperty("springdoc.api-docs.enabled")).isEqualTo(true);
assertThat(source.getProperty("springdoc.api-docs.path")).isEqualTo("/v3/api-docs");
assertThat(source.getProperty("springdoc.swagger-ui.enabled")).isEqualTo(true);
assertThat(source.getProperty("springdoc.swagger-ui.path")).isEqualTo("/swagger-ui.html");
assertThat(source.getProperty("auth.enabled")).isEqualTo(true);
assertThat(source.getProperty("auth.mock-company-id")).isEqualTo(1);
assertThat(source.getProperty("auth.mock-user-id")).isEqualTo(1);
assertThat(source.getProperty("auth.mock-role")).isEqualTo("ADMIN");
assertThat(source.getProperty("logging.level.com.label")).isEqualTo("INFO");
}
@Test
@DisplayName("application.yml 默认值不指向公网服务或携带真实默认密码")
void applicationYaml_doesNotShipPublicInfrastructureDefaults() throws Exception {
String yaml = new ClassPathResource("application.yml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml).doesNotContain("39.107.112.174");
assertThat(yaml).doesNotContain("postgres!Pw");
assertThat(yaml).doesNotContain("jsti@2024");
}
@Test
@DisplayName("logback.xml 启用 60 MB 滚动文件日志")
void logback_enablesRollingFileAppender() throws Exception {
String xml = new ClassPathResource("logback.xml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(xml).contains("<maxFileSize>60MB</maxFileSize>");
assertThat(xml).contains("<appender-ref ref=\"FILE\"/>");
assertThat(xml).doesNotContain("<!-- <appender-ref ref=\"FILE\"/> -->");
}
}

View File

@@ -1,154 +0,0 @@
package com.label.unit;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.annotation.RequireAuth;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.context.CompanyContext;
import com.label.interceptor.AuthInterceptor;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.method.HandlerMethod;
import java.lang.reflect.Method;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("自定义认证鉴权拦截器测试")
class AuthInterceptorTest {
private final RedisService redisService = mock(RedisService.class);
private final AuthInterceptor interceptor = new AuthInterceptor(redisService, new ObjectMapper());
@AfterEach
void tearDown() {
CompanyContext.clear();
}
@Test
@DisplayName("有效 Token 会注入 Principal、租户上下文并刷新 TTL")
void validTokenInjectsPrincipalAndRefreshesTtl() throws Exception {
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
ReflectionTestUtils.setField(interceptor, "tokenTtlSeconds", 7200L);
when(redisService.hGetAll(RedisUtil.tokenKey("valid-token"))).thenReturn(Map.of(
"userId", "10",
"role", "ADMIN",
"companyId", "20",
"username", "admin"
));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/admin");
request.addHeader("Authorization", "Bearer valid-token");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed = interceptor.preHandle(request, response, handler("adminOnly"));
assertThat(proceed).isTrue();
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
assertThat(principal.getUserId()).isEqualTo(10L);
assertThat(principal.getRole()).isEqualTo("ADMIN");
assertThat(CompanyContext.get()).isEqualTo(20L);
verify(redisService).expire(RedisUtil.tokenKey("valid-token"), 7200L);
verify(redisService).expire(RedisUtil.userSessionsKey(10L), 7200L);
}
@Test
@DisplayName("角色继承规则允许 ADMIN 访问 REVIEWER 接口")
void adminRoleInheritsReviewerRole() throws Exception {
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
when(redisService.hGetAll(RedisUtil.tokenKey("admin-token"))).thenReturn(Map.of(
"userId", "1",
"role", "ADMIN",
"companyId", "1",
"username", "admin"
));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/reviewer");
request.addHeader("Authorization", "Bearer admin-token");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("reviewerOnly"))).isTrue();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
}
@Test
@DisplayName("角色不足时返回 403")
void insufficientRoleReturnsForbidden() throws Exception {
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
when(redisService.hGetAll(RedisUtil.tokenKey("annotator-token"))).thenReturn(Map.of(
"userId", "2",
"role", "ANNOTATOR",
"companyId", "1",
"username", "annotator"
));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/reviewer");
request.addHeader("Authorization", "Bearer annotator-token");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("reviewerOnly"))).isFalse();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
@Test
@DisplayName("缺少 Token 时返回 401")
void missingTokenReturnsUnauthorized() throws Exception {
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/admin");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("adminOnly"))).isFalse();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
verify(redisService, never()).hGetAll(org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("UserContext 已被移除,避免重复维护用户 ThreadLocal")
void userContextClassShouldBeRemoved() {
assertThatThrownBy(() -> Class.forName("com.label.common.context.UserContext"))
.isInstanceOf(ClassNotFoundException.class);
}
@Test
@DisplayName("请求完成后清理公司 ThreadLocal")
void afterCompletionClearsCompanyContext() throws Exception {
CompanyContext.set(20L);
interceptor.afterCompletion(new MockHttpServletRequest(), new MockHttpServletResponse(),
handler("adminOnly"), null);
assertThat(CompanyContext.get()).isEqualTo(-1L);
}
private static HandlerMethod handler(String methodName) throws NoSuchMethodException {
Method method = TestController.class.getDeclaredMethod(methodName);
return new HandlerMethod(new TestController(), method);
}
private static class TestController {
@RequireRole("ADMIN")
void adminOnly() {
}
@RequireRole("REVIEWER")
void reviewerOnly() {
}
@RequireAuth
void authenticatedOnly() {
}
}
}

View File

@@ -1,73 +0,0 @@
package com.label.unit;
import com.label.common.exception.BusinessException;
import com.label.entity.SysCompany;
import com.label.mapper.SysCompanyMapper;
import com.label.mapper.SysUserMapper;
import com.label.service.CompanyService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("公司管理服务测试")
class CompanyServiceTest {
private final SysCompanyMapper companyMapper = mock(SysCompanyMapper.class);
private final SysUserMapper userMapper = mock(SysUserMapper.class);
private final CompanyService companyService = new CompanyService(companyMapper, userMapper);
@Test
@DisplayName("创建公司时写入 ACTIVE 状态并保存公司代码")
void createCompanyInsertsActiveCompany() {
SysCompany company = companyService.create("测试公司", "TEST");
assertThat(company.getCompanyName()).isEqualTo("测试公司");
assertThat(company.getCompanyCode()).isEqualTo("TEST");
assertThat(company.getStatus()).isEqualTo("ACTIVE");
verify(companyMapper).insert(any(SysCompany.class));
}
@Test
@DisplayName("创建公司时拒绝重复公司代码")
void createCompanyRejectsDuplicateCode() {
SysCompany existing = new SysCompany();
existing.setId(1L);
when(companyMapper.selectByCompanyCode("DEMO")).thenReturn(existing);
assertThatThrownBy(() -> companyService.create("演示公司", "DEMO"))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("公司代码已存在");
}
@Test
@DisplayName("禁用公司时只允许 ACTIVE 或 DISABLED")
void updateStatusRejectsInvalidStatus() {
SysCompany existing = new SysCompany();
existing.setId(1L);
existing.setStatus("ACTIVE");
when(companyMapper.selectById(1L)).thenReturn(existing);
assertThatThrownBy(() -> companyService.updateStatus(1L, "DELETED"))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("公司状态不合法");
}
@Test
@DisplayName("删除公司时若仍有关联用户则拒绝删除")
void deleteRejectsCompanyWithUsers() {
SysCompany existing = new SysCompany();
existing.setId(1L);
when(companyMapper.selectById(1L)).thenReturn(existing);
when(userMapper.countByCompanyId(1L)).thenReturn(2L);
assertThatThrownBy(() -> companyService.delete(1L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("公司下仍存在用户");
}
}

View File

@@ -1,100 +0,0 @@
package com.label.unit;
import com.label.controller.AuthController;
import com.label.controller.CompanyController;
import com.label.controller.ExportController;
import com.label.controller.ExtractionController;
import com.label.controller.QaController;
import com.label.controller.SourceController;
import com.label.controller.SysConfigController;
import com.label.controller.TaskController;
import com.label.controller.UserController;
import com.label.controller.VideoController;
import com.label.dto.SourceResponse;
import com.label.dto.TaskResponse;
import com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("OpenAPI 注解覆盖测试")
class OpenApiAnnotationTest {
private static final List<Class<?>> CONTROLLERS = List.of(
AuthController.class,
CompanyController.class,
UserController.class,
SourceController.class,
TaskController.class,
ExtractionController.class,
QaController.class,
ExportController.class,
SysConfigController.class,
VideoController.class
);
private static final List<Class<?>> DTOS = List.of(
LoginRequest.class,
LoginResponse.class,
UserInfoResponse.class,
TaskResponse.class,
SourceResponse.class
);
@Test
@DisplayName("所有 REST Controller 都声明 @Tag")
void allControllersHaveTag() {
assertThat(CONTROLLERS)
.allSatisfy(controller ->
assertThat(controller.getAnnotation(Tag.class))
.as(controller.getSimpleName() + " should have @Tag")
.isNotNull());
}
@Test
@DisplayName("所有 REST endpoint 方法都声明 @Operation")
void allEndpointMethodsHaveOperation() {
for (Class<?> controller : CONTROLLERS) {
Arrays.stream(controller.getDeclaredMethods())
.filter(method -> !Modifier.isPrivate(method.getModifiers()))
.filter(OpenApiAnnotationTest::isEndpointMethod)
.forEach(method -> assertThat(method.getAnnotation(Operation.class))
.as(controller.getSimpleName() + "." + method.getName() + " should have @Operation")
.isNotNull());
}
}
@Test
@DisplayName("核心 DTO 都声明 @Schema")
void coreDtosHaveSchema() {
assertThat(DTOS)
.allSatisfy(dto ->
assertThat(dto.getAnnotation(Schema.class))
.as(dto.getSimpleName() + " should have @Schema")
.isNotNull());
}
private static boolean isEndpointMethod(Method method) {
return method.isAnnotationPresent(GetMapping.class)
|| method.isAnnotationPresent(PostMapping.class)
|| method.isAnnotationPresent(PutMapping.class)
|| method.isAnnotationPresent(DeleteMapping.class)
|| method.isAnnotationPresent(RequestMapping.class);
}
}

View File

@@ -1,135 +0,0 @@
package com.label.unit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("标准目录扁平化迁移守卫测试")
class PackageStructureMigrationTest {
@Test
@DisplayName("基础设施类已迁移到目标目录")
void infrastructureTypesMoved() {
assertClassExists("com.label.annotation.OperationLog");
assertClassExists("com.label.aspect.AuditAspect");
assertClassExists("com.label.config.MybatisPlusConfig");
assertClassExists("com.label.config.OpenApiConfig");
assertClassExists("com.label.config.RedisConfig");
assertClassExists("com.label.event.ExtractionApprovedEvent");
assertClassExists("com.label.listener.ExtractionApprovedEventListener");
assertClassMissing("com.label.common.aop.OperationLog");
assertClassMissing("com.label.common.aop.AuditAspect");
assertClassMissing("com.label.common.config.MybatisPlusConfig");
assertClassMissing("com.label.common.config.OpenApiConfig");
assertClassMissing("com.label.common.config.RedisConfig");
assertClassMissing("com.label.module.annotation.event.ExtractionApprovedEvent");
assertClassMissing("com.label.module.annotation.service.ExtractionApprovedEventListener");
}
@Test
@DisplayName("DTO、实体、Mapper 已迁移到扁平数据层")
void dataTypesMoved() {
for (String fqcn : java.util.List.of(
"com.label.dto.LoginRequest",
"com.label.dto.LoginResponse",
"com.label.dto.UserInfoResponse",
"com.label.dto.TaskResponse",
"com.label.dto.SourceResponse",
"com.label.entity.AnnotationResult",
"com.label.entity.TrainingDataset",
"com.label.entity.SysConfig",
"com.label.entity.ExportBatch",
"com.label.entity.SourceData",
"com.label.entity.AnnotationTask",
"com.label.entity.AnnotationTaskHistory",
"com.label.entity.SysCompany",
"com.label.entity.SysUser",
"com.label.entity.VideoProcessJob",
"com.label.mapper.AnnotationResultMapper",
"com.label.mapper.TrainingDatasetMapper",
"com.label.mapper.SysConfigMapper",
"com.label.mapper.ExportBatchMapper",
"com.label.mapper.SourceDataMapper",
"com.label.mapper.AnnotationTaskMapper",
"com.label.mapper.TaskHistoryMapper",
"com.label.mapper.SysCompanyMapper",
"com.label.mapper.SysUserMapper",
"com.label.mapper.VideoProcessJobMapper")) {
assertClassExists(fqcn);
}
}
@Test
@DisplayName("服务类已迁移到扁平 service 目录")
void serviceTypesMoved() {
for (String fqcn : java.util.List.of(
"com.label.service.ExtractionService",
"com.label.service.QaService",
"com.label.service.SysConfigService",
"com.label.service.ExportService",
"com.label.service.FinetuneService",
"com.label.service.SourceService",
"com.label.service.TaskClaimService",
"com.label.service.TaskService",
"com.label.service.AuthService",
"com.label.service.UserService",
"com.label.service.VideoProcessService")) {
assertClassExists(fqcn);
}
}
@Test
@DisplayName("控制器类已迁移到扁平 controller 目录")
void controllerTypesMoved() {
for (String fqcn : java.util.List.of(
"com.label.controller.AuthController",
"com.label.controller.UserController",
"com.label.controller.SourceController",
"com.label.controller.TaskController",
"com.label.controller.ExtractionController",
"com.label.controller.QaController",
"com.label.controller.ExportController",
"com.label.controller.SysConfigController",
"com.label.controller.VideoController")) {
assertClassExists(fqcn);
}
}
@Test
@DisplayName("源码中不再引用 legacy module 与 common 目录包名")
void sourceTreeHasNoLegacyPackageReferences() throws Exception {
java.nio.file.Path self = java.nio.file.Path.of(
"src", "test", "java", "com", "label", "unit", "PackageStructureMigrationTest.java");
try (java.util.stream.Stream<java.nio.file.Path> paths = java.nio.file.Files.walk(java.nio.file.Path.of("src"))) {
java.util.List<String> violations = paths
.filter(path -> path.toString().endsWith(".java"))
.filter(path -> !path.normalize().endsWith(self))
.map(path -> {
try {
String text = java.nio.file.Files.readString(path);
boolean legacy = text.contains("com.label.module.")
|| text.contains("com.label.common.aop")
|| text.contains("com.label.common.config");
return legacy ? path.toString() : null;
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.filter(java.util.Objects::nonNull)
.toList();
org.assertj.core.api.Assertions.assertThat(violations).isEmpty();
}
}
private static void assertClassExists(String fqcn) {
assertThatCode(() -> Class.forName(fqcn)).doesNotThrowAnyException();
}
private static void assertClassMissing(String fqcn) {
assertThatThrownBy(() -> Class.forName(fqcn)).isInstanceOf(ClassNotFoundException.class);
}
}

View File

@@ -1,265 +0,0 @@
package com.label.unit;
import com.label.common.exception.BusinessException;
import com.label.common.statemachine.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/**
* Unit tests for all state machine enums and StateValidator.
* No Spring context needed - pure unit tests.
*/
@DisplayName("状态机单元测试")
class StateMachineTest {
// ===== SourceStatus =====
@Nested
@DisplayName("SourceStatus 状态机")
class SourceStatusTest {
@Test
@DisplayName("合法转换PENDING → EXTRACTING文本/图片直接提取)")
void pendingToExtracting() {
assertThatCode(() ->
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.EXTRACTING)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换PENDING → PREPROCESSING视频上传")
void pendingToPreprocessing() {
assertThatCode(() ->
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.PREPROCESSING)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换PREPROCESSING → PENDING视频预处理完成")
void preprocessingToPending() {
assertThatCode(() ->
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PREPROCESSING, SourceStatus.PENDING)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换EXTRACTING → QA_REVIEW提取审批通过")
void extractingToQaReview() {
assertThatCode(() ->
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.EXTRACTING, SourceStatus.QA_REVIEW)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换QA_REVIEW → APPROVEDQA 审批通过)")
void qaReviewToApproved() {
assertThatCode(() ->
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.QA_REVIEW, SourceStatus.APPROVED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("非法转换APPROVED → PENDING 抛出异常")
void approvedToPendingFails() {
assertThatThrownBy(() ->
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.APPROVED, SourceStatus.PENDING)
).isInstanceOf(BusinessException.class)
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
}
@Test
@DisplayName("非法转换PENDING → APPROVED跳过中间状态抛出异常")
void pendingToApprovedFails() {
assertThatThrownBy(() ->
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.APPROVED)
).isInstanceOf(BusinessException.class)
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
}
}
// ===== TaskStatus =====
@Nested
@DisplayName("TaskStatus 状态机")
class TaskStatusTest {
@Test
@DisplayName("合法转换UNCLAIMED → IN_PROGRESS领取")
void unclaimedToInProgress() {
assertThatCode(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.UNCLAIMED, TaskStatus.IN_PROGRESS)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换IN_PROGRESS → SUBMITTED提交")
void inProgressToSubmitted() {
assertThatCode(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.SUBMITTED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换IN_PROGRESS → UNCLAIMED放弃")
void inProgressToUnclaimed() {
assertThatCode(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.UNCLAIMED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换IN_PROGRESS → IN_PROGRESSADMIN 强制转移,持有人变更)")
void inProgressToInProgress() {
assertThatCode(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.IN_PROGRESS)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换SUBMITTED → APPROVED审批通过")
void submittedToApproved() {
assertThatCode(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.SUBMITTED, TaskStatus.APPROVED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换SUBMITTED → REJECTED审批驳回")
void submittedToRejected() {
assertThatCode(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.SUBMITTED, TaskStatus.REJECTED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换REJECTED → IN_PROGRESS标注员重领")
void rejectedToInProgress() {
assertThatCode(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.REJECTED, TaskStatus.IN_PROGRESS)
).doesNotThrowAnyException();
}
@Test
@DisplayName("非法转换APPROVED → IN_PROGRESS 抛出异常")
void approvedToInProgressFails() {
assertThatThrownBy(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.APPROVED, TaskStatus.IN_PROGRESS)
).isInstanceOf(BusinessException.class)
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
}
@Test
@DisplayName("非法转换UNCLAIMED → SUBMITTED跳过 IN_PROGRESS抛出异常")
void unclaimedToSubmittedFails() {
assertThatThrownBy(() ->
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.UNCLAIMED, TaskStatus.SUBMITTED)
).isInstanceOf(BusinessException.class)
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
}
}
// ===== DatasetStatus =====
@Nested
@DisplayName("DatasetStatus 状态机")
class DatasetStatusTest {
@Test
@DisplayName("合法转换PENDING_REVIEW → APPROVED")
void pendingReviewToApproved() {
assertThatCode(() ->
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.PENDING_REVIEW, DatasetStatus.APPROVED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换PENDING_REVIEW → REJECTED")
void pendingReviewToRejected() {
assertThatCode(() ->
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.PENDING_REVIEW, DatasetStatus.REJECTED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换REJECTED → PENDING_REVIEW重新提交")
void rejectedToPendingReview() {
assertThatCode(() ->
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.REJECTED, DatasetStatus.PENDING_REVIEW)
).doesNotThrowAnyException();
}
@Test
@DisplayName("非法转换APPROVED → REJECTED 抛出异常")
void approvedToRejectedFails() {
assertThatThrownBy(() ->
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.APPROVED, DatasetStatus.REJECTED)
).isInstanceOf(BusinessException.class)
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
}
}
// ===== VideoJobStatus =====
@Nested
@DisplayName("VideoJobStatus 状态机")
class VideoJobStatusTest {
@Test
@DisplayName("合法转换PENDING → RUNNING")
void pendingToRunning() {
assertThatCode(() ->
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.PENDING, VideoJobStatus.RUNNING)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换RUNNING → SUCCESS")
void runningToSuccess() {
assertThatCode(() ->
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.SUCCESS)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换RUNNING → RETRYING失败且未超重试次数")
void runningToRetrying() {
assertThatCode(() ->
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.RETRYING)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换RUNNING → FAILED失败且超过最大重试")
void runningToFailed() {
assertThatCode(() ->
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.FAILED)
).doesNotThrowAnyException();
}
@Test
@DisplayName("合法转换RETRYING → RUNNINGAI 重试)")
void retryingToRunning() {
assertThatCode(() ->
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RETRYING, VideoJobStatus.RUNNING)
).doesNotThrowAnyException();
}
@Test
@DisplayName("非法转换FAILED → PENDING 不在状态机内ADMIN 手动触发,不走 StateValidator")
void failedToPendingNotInStateMachine() {
// FAILED → PENDING is intentionally NOT in TRANSITIONS (ADMIN manual reset via special API)
assertThatThrownBy(() ->
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.FAILED, VideoJobStatus.PENDING)
).isInstanceOf(BusinessException.class)
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
}
@Test
@DisplayName("非法转换SUCCESS → RUNNING 抛出异常")
void successToRunningFails() {
assertThatThrownBy(() ->
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.SUCCESS, VideoJobStatus.RUNNING)
).isInstanceOf(BusinessException.class)
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
}
}
}

View File

@@ -1,332 +0,0 @@
-- label_backend init.sql
-- PostgreSQL 14+
-- 按依赖顺序建全部 11 张表:
-- sys_company → sys_user → source_data → annotation_task → annotation_result
-- → training_dataset → export_batch → sys_config → sys_operation_log
-- → annotation_task_history → video_process_job
-- 含所有索引及初始配置数据
-- ============================================================
-- 扩展
-- ============================================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ============================================================
-- 1. sys_company租户
-- ============================================================
CREATE TABLE IF NOT EXISTS sys_company (
id BIGSERIAL PRIMARY KEY,
company_name VARCHAR(100) NOT NULL,
company_code VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uk_sys_company_name UNIQUE (company_name),
CONSTRAINT uk_sys_company_code UNIQUE (company_code)
);
-- ============================================================
-- 2. sys_user用户
-- ============================================================
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES sys_company(id),
username VARCHAR(50) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- BCrypt, strength >= 10
real_name VARCHAR(50),
role VARCHAR(20) NOT NULL, -- UPLOADER / ANNOTATOR / REVIEWER / ADMIN
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uk_sys_user_company_username UNIQUE (company_id, username)
);
CREATE INDEX IF NOT EXISTS idx_sys_user_company_id
ON sys_user (company_id);
-- ============================================================
-- 3. source_data原始资料
-- ============================================================
CREATE TABLE IF NOT EXISTS source_data (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES sys_company(id),
uploader_id BIGINT REFERENCES sys_user(id),
data_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO
file_path VARCHAR(500) NOT NULL, -- RustFS object path
file_name VARCHAR(255) NOT NULL,
file_size BIGINT,
bucket_name VARCHAR(100) NOT NULL,
parent_source_id BIGINT REFERENCES source_data(id), -- 视频帧 / 文本片段
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
-- PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED
reject_reason TEXT, -- 保留字段(当前无 REJECTED 状态)
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_source_data_company_id
ON source_data (company_id);
CREATE INDEX IF NOT EXISTS idx_source_data_company_status
ON source_data (company_id, status);
CREATE INDEX IF NOT EXISTS idx_source_data_parent_source_id
ON source_data (parent_source_id);
-- ============================================================
-- 4. annotation_task标注任务
-- ============================================================
CREATE TABLE IF NOT EXISTS annotation_task (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES sys_company(id),
source_id BIGINT NOT NULL REFERENCES source_data(id),
task_type VARCHAR(30) NOT NULL, -- EXTRACTION / QA_GENERATION
status VARCHAR(20) NOT NULL DEFAULT 'UNCLAIMED',
-- UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED
claimed_by BIGINT REFERENCES sys_user(id),
claimed_at TIMESTAMP,
submitted_at TIMESTAMP,
completed_at TIMESTAMP,
is_final BOOLEAN NOT NULL DEFAULT FALSE, -- true 即 APPROVED 且无需再审
ai_model VARCHAR(50),
reject_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_annotation_task_company_status
ON annotation_task (company_id, status);
CREATE INDEX IF NOT EXISTS idx_annotation_task_source_id
ON annotation_task (source_id);
CREATE INDEX IF NOT EXISTS idx_annotation_task_claimed_by
ON annotation_task (claimed_by);
-- ============================================================
-- 5. annotation_result标注结果JSONB
-- ============================================================
CREATE TABLE IF NOT EXISTS annotation_result (
id BIGSERIAL NOT NULL,
task_id BIGINT NOT NULL REFERENCES annotation_task(id),
company_id BIGINT NOT NULL REFERENCES sys_company(id),
result_json JSONB NOT NULL DEFAULT '[]'::jsonb, -- 整体替换语义
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT pk_annotation_result PRIMARY KEY (id),
CONSTRAINT uk_annotation_result_task_id UNIQUE (task_id)
);
CREATE INDEX IF NOT EXISTS idx_annotation_result_task_id
ON annotation_result (task_id);
CREATE INDEX IF NOT EXISTS idx_annotation_result_company_id
ON annotation_result (company_id);
-- ============================================================
-- 6. training_dataset训练数据集
-- export_batch_id FK 在 export_batch 建完后补加
-- ============================================================
CREATE TABLE IF NOT EXISTS training_dataset (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES sys_company(id),
task_id BIGINT NOT NULL REFERENCES annotation_task(id),
source_id BIGINT NOT NULL REFERENCES source_data(id),
sample_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO_FRAME
glm_format_json JSONB NOT NULL, -- GLM fine-tune 格式
status VARCHAR(20) NOT NULL DEFAULT 'PENDING_REVIEW',
-- PENDING_REVIEW / APPROVED / REJECTED
export_batch_id BIGINT, -- 导出后填写FK 在下方补加
exported_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_training_dataset_company_status
ON training_dataset (company_id, status);
CREATE INDEX IF NOT EXISTS idx_training_dataset_task_id
ON training_dataset (task_id);
-- ============================================================
-- 7. export_batch导出批次
-- ============================================================
CREATE TABLE IF NOT EXISTS export_batch (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES sys_company(id),
batch_uuid UUID NOT NULL DEFAULT gen_random_uuid(),
sample_count INT NOT NULL DEFAULT 0,
dataset_file_path VARCHAR(500), -- 导出 JSONL 的 RustFS 路径
glm_job_id VARCHAR(100), -- GLM fine-tune 任务 ID
finetune_status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED',
-- NOT_STARTED / RUNNING / COMPLETED / FAILED
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_export_batch_company_id
ON export_batch (company_id);
-- 补加 training_dataset.export_batch_id FK
ALTER TABLE training_dataset
ADD CONSTRAINT fk_training_dataset_export_batch
FOREIGN KEY (export_batch_id) REFERENCES export_batch(id)
NOT VALID; -- 允许已有 NULL 行,不强制回溯校验
-- ============================================================
-- 8. sys_config系统配置
-- ============================================================
CREATE TABLE IF NOT EXISTS sys_config (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT REFERENCES sys_company(id), -- NULL = 全局默认
config_key VARCHAR(100) NOT NULL,
config_value TEXT NOT NULL,
description VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 公司级配置唯一索引
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_company_key
ON sys_config (company_id, config_key)
WHERE company_id IS NOT NULL;
-- 全局配置唯一索引
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_global_key
ON sys_config (config_key)
WHERE company_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_sys_config_company_key
ON sys_config (company_id, config_key);
-- ============================================================
-- 9. sys_operation_log操作日志仅追加
-- ============================================================
CREATE TABLE IF NOT EXISTS sys_operation_log (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES sys_company(id),
operator_id BIGINT REFERENCES sys_user(id),
operation_type VARCHAR(50) NOT NULL, -- 例如 EXTRACTION_APPROVE / USER_LOGIN
target_id BIGINT,
target_type VARCHAR(50),
detail JSONB,
result VARCHAR(10), -- SUCCESS / FAILURE
error_message TEXT,
operated_at TIMESTAMP NOT NULL DEFAULT NOW()
-- 无 updated_at仅追加表永不更新
);
CREATE INDEX IF NOT EXISTS idx_sys_operation_log_company_operated_at
ON sys_operation_log (company_id, operated_at);
CREATE INDEX IF NOT EXISTS idx_sys_operation_log_operator_id
ON sys_operation_log (operator_id);
-- ============================================================
-- 10. annotation_task_history任务状态历史仅追加
-- ============================================================
CREATE TABLE IF NOT EXISTS annotation_task_history (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES annotation_task(id),
company_id BIGINT NOT NULL REFERENCES sys_company(id),
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
operator_id BIGINT REFERENCES sys_user(id),
operator_role VARCHAR(20),
comment TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
-- 无 updated_at仅追加表永不更新
);
CREATE INDEX IF NOT EXISTS idx_annotation_task_history_task_id
ON annotation_task_history (task_id);
CREATE INDEX IF NOT EXISTS idx_annotation_task_history_company_id
ON annotation_task_history (company_id);
-- ============================================================
-- 11. video_process_job视频处理作业
-- ============================================================
CREATE TABLE IF NOT EXISTS video_process_job (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES sys_company(id),
source_id BIGINT NOT NULL REFERENCES source_data(id),
job_type VARCHAR(30) NOT NULL, -- FRAME_EXTRACT / VIDEO_TO_TEXT
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
-- PENDING / RUNNING / SUCCESS / FAILED / RETRYING
params JSONB, -- 例如 {"frameInterval": 30, "mode": "FRAME"}
output_path VARCHAR(500), -- 完成后的 RustFS 输出路径
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
error_message TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_video_process_job_company_id
ON video_process_job (company_id);
CREATE INDEX IF NOT EXISTS idx_video_process_job_source_id
ON video_process_job (source_id);
CREATE INDEX IF NOT EXISTS idx_video_process_job_status
ON video_process_job (status);
-- ============================================================
-- 初始数据
-- ============================================================
-- 1. 演示公司
INSERT INTO sys_company (company_name, company_code, status)
VALUES ('演示公司', 'DEMO', 'ACTIVE')
ON CONFLICT DO NOTHING;
-- 2. 初始用户BCrypt strength=10
-- admin / admin123
-- reviewer01/ review123
-- annotator01/annot123
-- uploader01 / upload123
INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status)
SELECT
c.id,
u.username,
u.password_hash,
u.real_name,
u.role,
'ACTIVE'
FROM sys_company c
CROSS JOIN (VALUES
('admin',
'$2a$10$B8iR5z43URiNPm.eut3JvufIPBuvGx5ZZmqyUqE1A1WdbZppX5bmi',
'管理员',
'ADMIN'),
('reviewer01',
'$2a$10$euOJZRfUtYNW7WHpfW1Ciee5b3rjkYFe3yQHT/uCQWrYVc0XQcukm',
'审核员01',
'REVIEWER'),
('annotator01',
'$2a$10$8UKwHPNASauKMTrqosR0Reg1X1gkFzFlGa/HBwNLXUELaj4e/zcqu',
'标注员01',
'ANNOTATOR'),
('uploader01',
'$2a$10$o2d7jsT31vyxIJHUo50mUefoZLLvGqft97zaL9OQCjRxn9ie1H/1O',
'上传员01',
'UPLOADER')
) AS u(username, password_hash, real_name, role)
WHERE c.company_code = 'DEMO'
ON CONFLICT (company_id, username) DO NOTHING;
-- 3. 全局系统配置
INSERT INTO sys_config (company_id, config_key, config_value, description)
VALUES
(NULL, 'token_ttl_seconds', '7200',
'会话凭证有效期(秒)'),
(NULL, 'model_default', 'glm-4',
'AI 辅助默认模型'),
(NULL, 'video_frame_interval', '30',
'视频帧提取间隔(帧数)'),
(NULL, 'prompt_extract_text',
'请提取以下文本中的主语-谓语-宾语三元组以JSON数组格式返回每个元素包含subject、predicate、object、sourceText、startOffset、endOffset字段。',
'文本三元组提取 Prompt 模板'),
(NULL, 'prompt_extract_image',
'请提取图片中的实体关系四元组以JSON数组格式返回每个元素包含subject、relation、object、modifier、confidence字段。',
'图片四元组提取 Prompt 模板'),
(NULL, 'prompt_qa_gen_text',
'根据以下文本三元组生成高质量问答对以JSON数组格式返回每个元素包含question、answer、difficulty字段。',
'文本问答生成 Prompt 模板'),
(NULL, 'prompt_qa_gen_image',
'根据以下图片四元组生成高质量问答对以JSON数组格式返回每个元素包含question、answer、imageRef、difficulty字段。',
'图片问答生成 Prompt 模板')
ON CONFLICT DO NOTHING;