Compare commits
54 Commits
2c2aa116d6
...
63ed9e6771
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ed9e6771 | ||
|
|
7b8bf21e51 | ||
|
|
21f3a92f7d | ||
|
|
3f3c355d4e | ||
| 29b62b6ca0 | |||
|
|
f4a8592c92 | ||
|
|
c7201b03e1 | ||
|
|
e8235eeec5 | ||
|
|
5d74578aa3 | ||
|
|
ef8b75a03e | ||
|
|
7172861e67 | ||
|
|
a489e2b204 | ||
|
|
c3308e069d | ||
|
|
b8d9aec4ca | ||
|
|
5103dac16c | ||
|
|
c2a254cba4 | ||
|
|
d231180bff | ||
|
|
3f0dee0826 | ||
|
|
8eb3c77abd | ||
|
|
b7d6cbc1e2 | ||
|
|
7b25064593 | ||
|
|
ff3b38ab2e | ||
|
|
011a731f4b | ||
|
|
0fa3981a85 | ||
|
|
a14c3f5559 | ||
|
|
f6c3b0b4c6 | ||
|
|
49666d1579 | ||
|
|
6d972511ff | ||
|
|
927e4f1cf3 | ||
|
|
7f12fc520a | ||
|
|
a28fecd16a | ||
|
|
b5f35a7414 | ||
|
|
4a002bd84e | ||
|
|
0cd99aa22c | ||
|
|
556f7b9672 | ||
|
|
8fb730d281 | ||
|
|
3d1790ad64 | ||
|
|
42fb748949 | ||
|
|
52d5dd9c24 | ||
|
|
ae55e87e2c | ||
|
|
94cb27e95f | ||
|
|
0e2b1e291b | ||
|
|
3da0e49b38 | ||
|
|
600a8b8669 | ||
|
|
672fe888c9 | ||
|
|
bc33194b6e | ||
|
|
fba3701cb9 | ||
|
|
3b99b1d8c3 | ||
|
|
4054a1133b | ||
|
|
0891ae188d | ||
|
|
ba3b7389f0 | ||
|
|
badffd8bca | ||
|
|
6e0677e06a | ||
|
|
e382995718 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.claude
|
||||||
|
specs
|
||||||
|
docs
|
||||||
|
target
|
||||||
|
*.md
|
||||||
|
.gitignore
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# ==========================================
|
||||||
|
# 1. Maven/Java 构建产物 (一键忽略整个目录)
|
||||||
|
# ==========================================
|
||||||
|
target/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. IDE 配置文件
|
||||||
|
# ==========================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.agents/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. 项目特定工具目录 (根据你的文件列表)
|
||||||
|
# ==========================================
|
||||||
|
# 忽略 Specifiy 工具生成的所有配置和脚本
|
||||||
|
.specify/
|
||||||
|
|
||||||
|
# 忽略 Claude 本地设置和技能文件
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. 操作系统文件
|
||||||
|
# ==========================================
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 构建阶段:Maven + JDK 17 编译,生成薄 jar 及依赖
|
||||||
|
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 优先复制 pom.xml 利用 Docker 层缓存(依赖不变时跳过 go-offline)
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn dependency:go-offline -q
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn clean package -DskipTests -q
|
||||||
|
|
||||||
|
# 运行阶段:仅含 JRE 的精简镜像
|
||||||
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制部署结构:bin/ libs/ etc/
|
||||||
|
COPY --from=builder /app/src/main/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"]
|
||||||
96
docker-compose.yml
Normal file
96
docker-compose.yml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: label_db
|
||||||
|
POSTGRES_USER: label
|
||||||
|
POSTGRES_PASSWORD: label_password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U label -d label_db"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: redis-server --requirepass redis_password
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "redis_password", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# RustFS is an S3-compatible object storage service.
|
||||||
|
# Using MinIO as a drop-in S3 API substitute for development/testing.
|
||||||
|
# Replace with the actual RustFS image in production environments.
|
||||||
|
rustfs:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- rustfs_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/label_db
|
||||||
|
SPRING_DATASOURCE_USERNAME: label
|
||||||
|
SPRING_DATASOURCE_PASSWORD: label_password
|
||||||
|
SPRING_DATA_REDIS_HOST: redis
|
||||||
|
SPRING_DATA_REDIS_PORT: 6379
|
||||||
|
SPRING_DATA_REDIS_PASSWORD: redis_password
|
||||||
|
RUSTFS_ENDPOINT: http://rustfs:9000
|
||||||
|
RUSTFS_ACCESS_KEY: minioadmin
|
||||||
|
RUSTFS_SECRET_KEY: minioadmin
|
||||||
|
AI_SERVICE_BASE_URL: http://ai-service:8000
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
rustfs:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/actuator/health 2>/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# Placeholder AI service — replace with the actual FastAPI image in production.
|
||||||
|
ai-service:
|
||||||
|
image: python:3.11-slim
|
||||||
|
command: ["python3", "-m", "http.server", "8000"]
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
# Placeholder frontend — replace with the actual Nginx + static build in production.
|
||||||
|
frontend:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
rustfs_data:
|
||||||
517
docs/superpowers/plans/2026-04-09-deploy-optimization.md
Normal file
517
docs/superpowers/plans/2026-04-09-deploy-optimization.md
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
# Deploy Optimization Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 将 label_backend 从 Spring Boot fat JAR 方式改造为薄 jar + Maven Assembly 分发包,并统一日志级别为 INFO。
|
||||||
|
|
||||||
|
**Architecture:** 移除 `spring-boot-maven-plugin`,改用 `maven-jar-plugin`(薄 jar)+ `maven-dependency-plugin`(复制依赖到 `target/libs/`)+ `maven-assembly-plugin`(组装 zip/tar.gz)。新增 `start.sh`(Docker/VM 双模式启动)和 `logback.xml`(60 MB 滚动)。Dockerfile 改为多阶段构建,从 `target/libs/` 和 `src/main/resources/` 复制构建产物。
|
||||||
|
|
||||||
|
**Tech Stack:** Maven 3.9, JDK 17, Spring Boot 3.2.5, maven-assembly-plugin 3.x, logback 1.4.x (Spring Boot 管理版本)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 操作 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 新建 | `src/main/resources/logback.xml` | INFO 级,60 MB 滚动日志配置 |
|
||||||
|
| 新建 | `src/main/scripts/start.sh` | Docker/VM 双模式启动脚本 |
|
||||||
|
| 新建 | `src/main/assembly/distribution.xml` | Assembly 描述符 |
|
||||||
|
| 新建 | `src/main/assembly/empty-logs/.gitkeep` | logs/ 目录占位(Assembly 引用) |
|
||||||
|
| 修改 | `pom.xml` | 替换 spring-boot-maven-plugin |
|
||||||
|
| 修改 | `Dockerfile` | 多阶段构建,复制 etc/ + libs/ |
|
||||||
|
| 批量修改 | 11 个 Service 类 | `log.debug` → `log.info`(21 处) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 创建 logback.xml
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/logback.xml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 logback.xml 文件**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration scan="true" scanPeriod="60 seconds">
|
||||||
|
|
||||||
|
<property name="LOG_PATH" value="${LOG_PATH:-logs}"/>
|
||||||
|
<property name="APP_NAME" value="label-backend"/>
|
||||||
|
<property name="LOG_PATTERN"
|
||||||
|
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>
|
||||||
|
|
||||||
|
<!-- 控制台输出(Docker 日志采集依赖 stdout) -->
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder charset="UTF-8">
|
||||||
|
<pattern>${LOG_PATTERN}</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 滚动文件:60 MB / 个,按日分组,保留 30 天,总上限 3 GB -->
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_PATH}/${APP_NAME}.log</file>
|
||||||
|
<encoder charset="UTF-8">
|
||||||
|
<pattern>${LOG_PATTERN}</pattern>
|
||||||
|
</encoder>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<maxFileSize>60MB</maxFileSize>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>3GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="FILE"/>
|
||||||
|
</root>
|
||||||
|
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证文件存在**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls src/main/resources/logback.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:`src/main/resources/logback.xml`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/logback.xml
|
||||||
|
git commit -m "feat(deploy): 添加 logback.xml(INFO 级,60 MB 滚动)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 创建 start.sh 启动脚本
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/scripts/start.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建目录并写入 start.sh**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p src/main/scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
文件内容 `src/main/scripts/start.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# label-backend 启动脚本
|
||||||
|
# - Docker 环境(检测 /.dockerenv):exec 前台运行,保持容器进程存活
|
||||||
|
# - 裸机 / VM:nohup 后台运行,日志追加至 logs/startup.log
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BASEDIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
LIBDIR="$BASEDIR/libs"
|
||||||
|
CONFDIR="$BASEDIR/etc"
|
||||||
|
LOGDIR="$BASEDIR/logs"
|
||||||
|
|
||||||
|
mkdir -p "$LOGDIR"
|
||||||
|
|
||||||
|
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/*"
|
||||||
|
|
||||||
|
if [ -f /.dockerenv ]; then
|
||||||
|
# Docker 容器:exec 替换当前进程,PID=1 接管信号
|
||||||
|
exec java $JAVA_ARGS $MAIN_CLASS
|
||||||
|
else
|
||||||
|
# 裸机 / VM:nohup 后台运行
|
||||||
|
nohup java $JAVA_ARGS $MAIN_CLASS >> "$LOGDIR/startup.log" 2>&1 &
|
||||||
|
echo "label-backend started, PID=$!"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证文件存在**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls src/main/scripts/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:`src/main/scripts/start.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/scripts/start.sh
|
||||||
|
git commit -m "feat(deploy): 添加 start.sh(Docker exec / VM nohup 双模式)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 创建 Assembly 描述符和目录占位
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/assembly/distribution.xml`
|
||||||
|
- Create: `src/main/assembly/empty-logs/.gitkeep`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 assembly 目录结构**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p src/main/assembly/empty-logs
|
||||||
|
touch src/main/assembly/empty-logs/.gitkeep
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 创建 distribution.xml**
|
||||||
|
|
||||||
|
文件内容 `src/main/assembly/distribution.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0
|
||||||
|
https://maven.apache.org/xsd/assembly-2.1.0.xsd">
|
||||||
|
<id>dist</id>
|
||||||
|
<formats>
|
||||||
|
<format>zip</format>
|
||||||
|
<format>tar.gz</format>
|
||||||
|
</formats>
|
||||||
|
<includeBaseDirectory>true</includeBaseDirectory>
|
||||||
|
|
||||||
|
<!-- bin/start.sh(0755 可执行) -->
|
||||||
|
<files>
|
||||||
|
<file>
|
||||||
|
<source>src/main/scripts/start.sh</source>
|
||||||
|
<outputDirectory>bin</outputDirectory>
|
||||||
|
<fileMode>0755</fileMode>
|
||||||
|
</file>
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<fileSets>
|
||||||
|
<!-- etc/:application.yml + logback.xml -->
|
||||||
|
<fileSet>
|
||||||
|
<directory>src/main/resources</directory>
|
||||||
|
<outputDirectory>etc</outputDirectory>
|
||||||
|
<includes>
|
||||||
|
<include>application.yml</include>
|
||||||
|
<include>logback.xml</include>
|
||||||
|
</includes>
|
||||||
|
</fileSet>
|
||||||
|
|
||||||
|
<!-- libs/:薄 jar + 所有运行时依赖 -->
|
||||||
|
<fileSet>
|
||||||
|
<directory>${project.build.directory}/libs</directory>
|
||||||
|
<outputDirectory>libs</outputDirectory>
|
||||||
|
<includes>
|
||||||
|
<include>**/*.jar</include>
|
||||||
|
</includes>
|
||||||
|
</fileSet>
|
||||||
|
|
||||||
|
<!-- logs/ 空目录占位 -->
|
||||||
|
<fileSet>
|
||||||
|
<directory>src/main/assembly/empty-logs</directory>
|
||||||
|
<outputDirectory>logs</outputDirectory>
|
||||||
|
</fileSet>
|
||||||
|
</fileSets>
|
||||||
|
|
||||||
|
</assembly>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证文件存在**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls src/main/assembly/distribution.xml src/main/assembly/empty-logs/.gitkeep
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:两个文件路径均显示
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/assembly/
|
||||||
|
git commit -m "feat(deploy): 添加 Assembly 描述符 distribution.xml"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 更新 pom.xml(替换 spring-boot-maven-plugin)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pom.xml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 替换 `<build><plugins>` 段落**
|
||||||
|
|
||||||
|
将 `pom.xml` 的 `<build>` 段(当前仅含 spring-boot-maven-plugin)替换为:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
|
||||||
|
<!-- 薄 jar:仅打包编译后的 class,输出到 target/libs/ -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<outputDirectory>${project.build.directory}/libs</outputDirectory>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>com.label.LabelBackendApplication</mainClass>
|
||||||
|
<addClasspath>false</addClasspath>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- 将所有运行时依赖复制到 target/libs/ -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-dependency-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>copy-dependencies</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>copy-dependencies</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>src/main/assembly/distribution.xml</descriptor>
|
||||||
|
</descriptors>
|
||||||
|
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||||
|
<appendAssemblyId>false</appendAssemblyId>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
```
|
||||||
|
|
||||||
|
即用上述内容完整替换 `pom.xml` 中现有的 `<build>...</build>` 块(原内容为含 spring-boot-maven-plugin 的单插件配置)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证语法并试构建**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn validate -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:无错误(只做 pom.xml 解析校验,不实际编译)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pom.xml
|
||||||
|
git commit -m "feat(deploy): pom.xml 替换 fat JAR → 薄 jar + maven-dependency + maven-assembly"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 更新 Dockerfile
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Dockerfile`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 替换 Dockerfile 全文**
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# 构建阶段:Maven + JDK 17 编译,生成薄 jar 及依赖
|
||||||
|
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 优先复制 pom.xml 利用 Docker 层缓存(依赖不变时跳过 go-offline)
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn dependency:go-offline -q
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn clean package -DskipTests -q
|
||||||
|
|
||||||
|
# 运行阶段:仅含 JRE 的精简镜像
|
||||||
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制部署结构:bin/ libs/ etc/
|
||||||
|
COPY --from=builder /app/src/main/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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证 Dockerfile 语法**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --no-cache --dry-run . 2>/dev/null || echo "docker not available; syntax check skipped"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:无语法错误(或 docker 不可用时跳过)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Dockerfile
|
||||||
|
git commit -m "feat(deploy): Dockerfile 改为多阶段构建(薄 jar + start.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 批量替换 log.debug → log.info(11 文件,21 处)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/com/label/module/video/service/VideoProcessService.java` (5 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/source/service/SourceService.java` (2 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/user/service/UserService.java` (3 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/user/service/AuthService.java` (2 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/config/service/SysConfigService.java` (2 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/annotation/service/ExtractionApprovedEventListener.java` (2 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/task/service/TaskService.java` (1 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/annotation/service/QaService.java` (1 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/export/service/FinetuneService.java` (1 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/task/service/TaskClaimService.java` (1 处)
|
||||||
|
- Modify: `src/main/java/com/label/module/export/service/ExportService.java` (1 处)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 批量替换**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find src/main/java -name "*.java" \
|
||||||
|
-exec sed -i 's/log\.debug(/log.info(/g' {} +
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证替换完整,无 log.debug 残留**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -r "log\.debug" src/main/java && echo "FAIL: 仍有 log.debug 残留" || echo "PASS: 无 log.debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:`PASS: 无 log.debug`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证替换数量正确(应有 21 处 log.info 新增)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --stat src/main/java | tail -1
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:显示 11 个文件,21 处改动(`21 insertions(+), 21 deletions(-)`)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/
|
||||||
|
git commit -m "refactor(log): log.debug 全量替换为 log.info(11 文件,21 处)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 全量构建验证
|
||||||
|
|
||||||
|
**Files:** 只读操作,不修改文件
|
||||||
|
|
||||||
|
- [ ] **Step 1: 执行完整构建(跳过测试)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:`BUILD SUCCESS`,无 ERROR 输出
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证 target/libs/ 目录存在且包含 jar**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls target/libs/*.jar | head -5
|
||||||
|
ls target/libs/label-backend-1.0.0-SNAPSHOT.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:显示薄 jar 及多个依赖 jar
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证分发包已生成**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls target/label-backend-1.0.0-SNAPSHOT.zip
|
||||||
|
ls target/label-backend-1.0.0-SNAPSHOT.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:两个文件均存在
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证分发包内部结构**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 unzip 检查 zip 内容(Windows Git Bash 或 Linux 均可用)
|
||||||
|
unzip -l target/label-backend-1.0.0-SNAPSHOT.zip | grep -E "bin/|etc/|libs/|logs/"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出包含:
|
||||||
|
```
|
||||||
|
label-backend-1.0.0-SNAPSHOT/bin/start.sh
|
||||||
|
label-backend-1.0.0-SNAPSHOT/etc/application.yml
|
||||||
|
label-backend-1.0.0-SNAPSHOT/etc/logback.xml
|
||||||
|
label-backend-1.0.0-SNAPSHOT/libs/label-backend-1.0.0-SNAPSHOT.jar
|
||||||
|
label-backend-1.0.0-SNAPSHOT/logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 验证 logback.xml 已包含在 etc/ 内**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip -l target/label-backend-1.0.0-SNAPSHOT.zip | grep logback
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:`label-backend-1.0.0-SNAPSHOT/etc/logback.xml`
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit 构建验证记录(如有必要则修正问题后提交)**
|
||||||
|
|
||||||
|
若 Step 1-5 全部通过,无需额外提交。若发现问题(如缺少文件、路径错误),修正对应 Task 后重新执行本 Task。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自检
|
||||||
|
|
||||||
|
### Spec 覆盖检查
|
||||||
|
|
||||||
|
| deploy.md 需求 | 对应任务 |
|
||||||
|
|---------------|---------|
|
||||||
|
| 1. 分发包结构 bin/etc/libs/logs | Task 3 (distribution.xml) + Task 7 验证 |
|
||||||
|
| 2. start.sh nohup 后台启动 | Task 2 |
|
||||||
|
| 3. logback.xml INFO + 60 MB 滚动 | Task 1 |
|
||||||
|
| 4. logback.xml + application.yml 在 etc/ | Task 3 (distribution.xml fileSets) |
|
||||||
|
| 5. maven-jar-plugin + maven-dependency-plugin | Task 4 |
|
||||||
|
| 6. Maven Assembly Plugin → zip/tar.gz | Task 3 + Task 4 |
|
||||||
|
| 7. Dockerfile 复制 etc/ + 调用 start.sh | Task 5 |
|
||||||
|
| 8. log.debug → log.info | Task 6 |
|
||||||
|
|
||||||
|
所有 8 条需求均有对应任务。✅
|
||||||
|
|
||||||
|
### 类型一致性
|
||||||
|
|
||||||
|
- `MAIN_CLASS="com.label.LabelBackendApplication"` — 与 `src/main/java/com/label/LabelBackendApplication.java` 一致 ✅
|
||||||
|
- `maven-jar-plugin` 输出路径 `target/libs/` — 与 Dockerfile `COPY --from=builder /app/target/libs/` 一致 ✅
|
||||||
|
- `distribution.xml` `<directory>${project.build.directory}/libs</directory>` — 与 `maven-dependency-plugin` 的 `outputDirectory` 一致 ✅
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
|
||||||
|
| Review | Trigger | Why | Runs | Status | Findings |
|
||||||
|
|--------|---------|-----|------|--------|----------|
|
||||||
|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — |
|
||||||
|
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — |
|
||||||
|
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — |
|
||||||
|
| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |
|
||||||
|
|
||||||
|
**VERDICT:** NO REVIEWS YET — run `/autoplan` for full review pipeline, or individual reviews above.
|
||||||
1411
docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md
Normal file
1411
docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md
Normal file
File diff suppressed because it is too large
Load Diff
1943
docs/superpowers/specs/2026-04-09-label-backend-design.md
Normal file
1943
docs/superpowers/specs/2026-04-09-label-backend-design.md
Normal file
File diff suppressed because it is too large
Load Diff
240
pom.xml
Normal file
240
pom.xml
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
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.2.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.label</groupId>
|
||||||
|
<artifactId>label-backend</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<!-- AWS SDK v2 BOM -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>bom</artifactId>
|
||||||
|
<version>2.26.31</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Testcontainers BOM -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-bom</artifactId>
|
||||||
|
<version>1.20.1</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Actuator (health check endpoint) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Data Redis (Lettuce) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot AOP -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-aop</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PostgreSQL JDBC Driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MyBatis Plus -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
<version>3.5.10</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MyBatis Plus JSqlParser (required for TenantLineInnerInterceptor in 3.5.7+) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||||
|
<version>3.5.10</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.5.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Apache Shiro -->
|
||||||
|
<!-- <dependency>
|
||||||
|
<groupId>org.apache.shiro</groupId>
|
||||||
|
<artifactId>shiro-spring-boot-web-starter</artifactId>
|
||||||
|
<version>2.1.0</version>
|
||||||
|
</dependency> -->
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.shiro</groupId>
|
||||||
|
<artifactId>shiro-core</artifactId>
|
||||||
|
<classifier>jakarta</classifier>
|
||||||
|
<version>2.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.shiro</groupId>
|
||||||
|
<artifactId>shiro-web</artifactId>
|
||||||
|
<classifier>jakarta</classifier>
|
||||||
|
<version>2.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.shiro</groupId>
|
||||||
|
<artifactId>shiro-spring</artifactId>
|
||||||
|
<classifier>jakarta</classifier>
|
||||||
|
<version>2.0.0</version>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>org.apache.shiro</groupId>
|
||||||
|
<artifactId>shiro-web</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- AWS SDK v2 - S3 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>s3</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- AWS SDK v2 - STS -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>sts</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Security Crypto (BCrypt only, no web filter chain) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testcontainers - PostgreSQL -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testcontainers - JUnit Jupiter -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
|
||||||
|
<!-- 薄 jar:仅打包编译后的 class,输出到 target/libs/ -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<outputDirectory>${project.build.directory}/libs</outputDirectory>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>com.label.LabelBackendApplication</mainClass>
|
||||||
|
<addClasspath>false</addClasspath>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- 将所有运行时依赖复制到 target/libs/ -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-dependency-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>copy-dependencies</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>copy-dependencies</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>src/main/assembly/distribution.xml</descriptor>
|
||||||
|
</descriptors>
|
||||||
|
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||||
|
<appendAssemblyId>false</appendAssemblyId>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
34
specs/001-label-backend-spec/checklists/requirements.md
Normal file
34
specs/001-label-backend-spec/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 规格质量检查清单:label_backend 知识图谱智能标注平台
|
||||||
|
|
||||||
|
**用途**: 在进入规划阶段前验证规格说明的完整性和质量
|
||||||
|
**创建日期**: 2026-04-09
|
||||||
|
**功能**: [查看规格说明](../spec.md)
|
||||||
|
|
||||||
|
## 内容质量
|
||||||
|
|
||||||
|
- [x] 无实现细节(无编程语言、框架、API 引用)
|
||||||
|
- [x] 聚焦用户价值和业务需求
|
||||||
|
- [x] 面向非技术干系人编写
|
||||||
|
- [x] 所有必填章节均已完成
|
||||||
|
|
||||||
|
## 需求完整性
|
||||||
|
|
||||||
|
- [x] 无 [NEEDS CLARIFICATION] 标记残留
|
||||||
|
- [x] 需求可测试且无歧义
|
||||||
|
- [x] 成功标准可度量
|
||||||
|
- [x] 成功标准与技术无关(无实现细节)
|
||||||
|
- [x] 所有验收场景均已定义
|
||||||
|
- [x] 已识别边界情况
|
||||||
|
- [x] 范围边界清晰
|
||||||
|
- [x] 已识别依赖和假设
|
||||||
|
|
||||||
|
## 功能就绪性
|
||||||
|
|
||||||
|
- [x] 所有功能性需求均有明确验收标准
|
||||||
|
- [x] 用户场景覆盖主流程(认证、上传、标注、审批、导出)
|
||||||
|
- [x] 功能满足成功标准中定义的可度量结果
|
||||||
|
- [x] 无实现细节渗入规格说明
|
||||||
|
|
||||||
|
## 备注
|
||||||
|
|
||||||
|
所有检查项均通过。规格说明已就绪,可进行 `/speckit.plan` 规划阶段。
|
||||||
148
specs/001-label-backend-spec/contracts/auth.md
Normal file
148
specs/001-label-backend-spec/contracts/auth.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# API 契约:认证与用户管理
|
||||||
|
|
||||||
|
**统一响应格式**:
|
||||||
|
- 成功:`{"code": "SUCCESS", "data": {...}}`
|
||||||
|
- 成功(无数据):`{"code": "SUCCESS", "data": null}`
|
||||||
|
- 失败:`{"code": "ERROR_CODE", "message": "描述"}`
|
||||||
|
- 分页成功:`{"code": "SUCCESS", "data": {"items": [...], "total": 100, "page": 1, "pageSize": 20}}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/auth/login
|
||||||
|
|
||||||
|
**权限**: 匿名
|
||||||
|
**描述**: 用户登录,返回会话凭证
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"companyCode": "COMPANY_A",
|
||||||
|
"username": "zhangsan",
|
||||||
|
"password": "plaintext_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"token": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"userId": 1,
|
||||||
|
"username": "zhangsan",
|
||||||
|
"role": "ANNOTATOR",
|
||||||
|
"expiresIn": 7200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败响应**:
|
||||||
|
- `401` `USER_NOT_FOUND`: 用户名或密码错误(不区分哪个错误,防止枚举)
|
||||||
|
- `403` `USER_DISABLED`: 账号已禁用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/auth/logout
|
||||||
|
|
||||||
|
**权限**: 已登录(Bearer Token)
|
||||||
|
**描述**: 退出登录,立即删除 Redis 会话
|
||||||
|
|
||||||
|
**请求头**: `Authorization: Bearer {token}`
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/auth/me
|
||||||
|
|
||||||
|
**权限**: 已登录
|
||||||
|
**描述**: 获取当前用户信息
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "zhangsan",
|
||||||
|
"realName": "张三",
|
||||||
|
"role": "ANNOTATOR",
|
||||||
|
"companyId": 10,
|
||||||
|
"companyName": "测试公司"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/users
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 分页查询本公司用户列表
|
||||||
|
|
||||||
|
**查询参数**: `page`(默认 1)、`pageSize`(默认 20,最大 100)、`role`(可选过滤)、`status`(可选过滤)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{"id": 1, "username": "zhangsan", "realName": "张三", "role": "ANNOTATOR", "status": "ACTIVE"}
|
||||||
|
],
|
||||||
|
"total": 50,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/users
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 创建用户
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "lisi",
|
||||||
|
"password": "initial_password",
|
||||||
|
"realName": "李四",
|
||||||
|
"role": "ANNOTATOR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应** `201`: `{"code": "SUCCESS", "data": {"id": 2, "username": "lisi", ...}}`
|
||||||
|
**失败**: `409` `USERNAME_EXISTS`: 用户名已存在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/users/{id}
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 更新用户基本信息
|
||||||
|
|
||||||
|
**请求体**: `{"realName": "新姓名"}`
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/users/{id}/status
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 启用或禁用账号,立即驱逐权限缓存
|
||||||
|
|
||||||
|
**请求体**: `{"status": "DISABLED"}`
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/users/{id}/role
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 变更用户角色,立即驱逐权限缓存
|
||||||
|
|
||||||
|
**请求体**: `{"role": "REVIEWER"}`
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**: `400` `INVALID_ROLE`: 角色值不合法
|
||||||
53
specs/001-label-backend-spec/contracts/config.md
Normal file
53
specs/001-label-backend-spec/contracts/config.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# API 契约:系统配置
|
||||||
|
|
||||||
|
*所有接口需要 ADMIN 权限*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/config
|
||||||
|
|
||||||
|
**描述**: 获取所有配置项(公司级配置 + 全局默认配置合并,公司级优先)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"configKey": "prompt_extract_text",
|
||||||
|
"configValue": "请提取以下文本中的主语-谓语-宾语三元组...",
|
||||||
|
"description": "文本三元组提取 Prompt 模板",
|
||||||
|
"scope": "GLOBAL",
|
||||||
|
"updatedAt": "2026-04-09T00:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configKey": "model_default",
|
||||||
|
"configValue": "glm-4-turbo",
|
||||||
|
"description": "默认 AI 辅助模型",
|
||||||
|
"scope": "COMPANY",
|
||||||
|
"updatedAt": "2026-04-09T09:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scope` 字段:`GLOBAL`(来自全局默认)、`COMPANY`(来自公司级覆盖)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/config/{key}
|
||||||
|
|
||||||
|
**描述**: 更新单项配置(若公司级配置不存在则创建;若存在则覆盖)
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"configValue": "glm-4-turbo",
|
||||||
|
"description": "升级到 GLM-4-Turbo 模型"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**: `400` `UNKNOWN_CONFIG_KEY`: 未知的配置键(防止拼写错误创建无效配置)
|
||||||
113
specs/001-label-backend-spec/contracts/export.md
Normal file
113
specs/001-label-backend-spec/contracts/export.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# API 契约:训练数据导出与微调
|
||||||
|
|
||||||
|
*所有接口需要 ADMIN 权限*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/training/samples
|
||||||
|
|
||||||
|
**描述**: 分页查询已审批、可导出的训练样本
|
||||||
|
|
||||||
|
**查询参数**: `page`、`pageSize`、`sampleType`(TEXT / IMAGE / VIDEO_FRAME,可选)、`exported`(true/false,可选)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"sampleType": "TEXT",
|
||||||
|
"status": "APPROVED",
|
||||||
|
"exportBatchId": null,
|
||||||
|
"sourceId": 50,
|
||||||
|
"createdAt": "2026-04-09T12:00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 500,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/export/batch
|
||||||
|
|
||||||
|
**描述**: 创建导出批次,合并选定样本为 JSONL 并上传 RustFS
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sampleIds": [1001, 1002, 1003]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应** `201`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"id": 10,
|
||||||
|
"batchUuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"sampleCount": 3,
|
||||||
|
"datasetFilePath": "export/550e8400.jsonl",
|
||||||
|
"finetuneStatus": "NOT_STARTED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败**:
|
||||||
|
- `400` `INVALID_SAMPLES`: 部分样本不处于 APPROVED 状态
|
||||||
|
- `400` `EMPTY_SAMPLES`: sampleIds 为空
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/export/{batchId}/finetune
|
||||||
|
|
||||||
|
**描述**: 向 GLM AI 服务提交微调任务
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"glmJobId": "glm-finetune-abc123",
|
||||||
|
"finetuneStatus": "RUNNING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败**: `409` `FINETUNE_ALREADY_STARTED`: 微调任务已提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/export/{batchId}/status
|
||||||
|
|
||||||
|
**描述**: 查询微调任务状态(向 AI 服务实时查询)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"batchId": 10,
|
||||||
|
"glmJobId": "glm-finetune-abc123",
|
||||||
|
"finetuneStatus": "RUNNING",
|
||||||
|
"progress": 45,
|
||||||
|
"errorMessage": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/export/list
|
||||||
|
|
||||||
|
**描述**: 分页查询所有导出批次
|
||||||
|
|
||||||
|
**查询参数**: `page`、`pageSize`
|
||||||
|
|
||||||
|
**响应** `200`: 批次列表(含 finetuneStatus、sampleCount、createdAt 等字段)
|
||||||
97
specs/001-label-backend-spec/contracts/extraction.md
Normal file
97
specs/001-label-backend-spec/contracts/extraction.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# API 契约:提取阶段标注工作台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/extraction/{taskId}
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR(且为任务持有者)
|
||||||
|
**描述**: 获取当前提取结果(含 AI 预标注候选,供人工编辑)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"taskId": 101,
|
||||||
|
"sourceType": "TEXT",
|
||||||
|
"sourceFilePath": "text/202604/50.txt",
|
||||||
|
"isFinal": false,
|
||||||
|
"resultJson": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"subject": "北京",
|
||||||
|
"predicate": "是...首都",
|
||||||
|
"object": "中国",
|
||||||
|
"sourceText": "北京是中国的首都",
|
||||||
|
"startOffset": 0,
|
||||||
|
"endOffset": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/extraction/{taskId}
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR(且为任务持有者)
|
||||||
|
**描述**: 更新提取结果(**整体 JSONB 覆盖,PUT 语义,禁止局部 PATCH**)
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"subject": "北京",
|
||||||
|
"predicate": "是...首都",
|
||||||
|
"object": "中国",
|
||||||
|
"sourceText": "北京是中国的首都",
|
||||||
|
"startOffset": 0,
|
||||||
|
"endOffset": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**: `400` `INVALID_JSON`: 提交的 JSON 格式不合法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/extraction/{taskId}/submit
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR(且为任务持有者)
|
||||||
|
**描述**: 提交提取结果,任务状态 IN_PROGRESS → SUBMITTED,进入审批队列
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**: `409` `INVALID_STATE`: 任务当前状态不允许提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/extraction/{taskId}/approve
|
||||||
|
|
||||||
|
**权限**: REVIEWER
|
||||||
|
**描述**: 审批通过。**两阶段操作**:
|
||||||
|
1. 同步(同一事务):`annotation_result.is_final = true`,任务状态 SUBMITTED → APPROVED,写任务历史
|
||||||
|
2. 异步(事务提交后):AI 生成候选问答对 → 写 training_dataset → 创建 QA_GENERATION 任务 → source_data 状态推进
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**:
|
||||||
|
- `403` `SELF_REVIEW_FORBIDDEN`: 不允许审批自己提交的任务
|
||||||
|
- `409` `INVALID_STATE`: 任务状态不为 SUBMITTED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/extraction/{taskId}/reject
|
||||||
|
|
||||||
|
**权限**: REVIEWER
|
||||||
|
**描述**: 驳回提取结果,任务状态 SUBMITTED → REJECTED,标注员可重领
|
||||||
|
|
||||||
|
**请求体**: `{"reason": "三元组边界不准确,请重新标注"}`
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**:
|
||||||
|
- `403` `SELF_REVIEW_FORBIDDEN`: 不允许驳回自己提交的任务
|
||||||
|
- `409` `INVALID_STATE`: 任务状态不为 SUBMITTED
|
||||||
|
- `400` `REASON_REQUIRED`: 驳回原因不能为空
|
||||||
83
specs/001-label-backend-spec/contracts/qa.md
Normal file
83
specs/001-label-backend-spec/contracts/qa.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# API 契约:问答生成阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/qa/{taskId}
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR(且为任务持有者)
|
||||||
|
**描述**: 获取候选问答对列表(由提取阶段审批触发 AI 生成)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"taskId": 202,
|
||||||
|
"sourceType": "TEXT",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"question": "北京是哪个国家的首都?",
|
||||||
|
"answer": "中国",
|
||||||
|
"status": "PENDING_REVIEW"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/qa/{taskId}
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR(且为任务持有者)
|
||||||
|
**描述**: 修改问答对(**整体覆盖,PUT 语义**,每次提交包含完整 items 数组)
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"question": "北京是哪个国家的首都?",
|
||||||
|
"answer": "中国。北京自1949年起成为中华人民共和国的首都。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/qa/{taskId}/submit
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR(且为任务持有者)
|
||||||
|
**描述**: 提交问答对,任务状态 IN_PROGRESS → SUBMITTED
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**: `409` `INVALID_STATE`: 任务当前状态不允许提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/qa/{taskId}/approve
|
||||||
|
|
||||||
|
**权限**: REVIEWER
|
||||||
|
**描述**: 审批通过。同一事务中:先校验任务 → training_dataset 状态 → 任务状态 SUBMITTED → APPROVED → source_data 状态 → 写任务历史
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**:
|
||||||
|
- `403` `SELF_REVIEW_FORBIDDEN`: 不允许审批自己提交的任务
|
||||||
|
- `409` `INVALID_STATE`: 任务状态不为 SUBMITTED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/qa/{taskId}/reject
|
||||||
|
|
||||||
|
**权限**: REVIEWER
|
||||||
|
**描述**: 驳回问答对,删除候选记录,任务状态 SUBMITTED → REJECTED
|
||||||
|
|
||||||
|
**请求体**: `{"reason": "问题描述不准确,请修改"}`
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**:
|
||||||
|
- `403` `SELF_REVIEW_FORBIDDEN`: 不允许驳回自己提交的任务
|
||||||
|
- `400` `REASON_REQUIRED`: 驳回原因不能为空
|
||||||
96
specs/001-label-backend-spec/contracts/source.md
Normal file
96
specs/001-label-backend-spec/contracts/source.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# API 契约:资料管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/source/upload
|
||||||
|
|
||||||
|
**权限**: UPLOADER
|
||||||
|
**描述**: 上传文件,创建 source_data 记录,文件字节流写入 RustFS
|
||||||
|
|
||||||
|
**请求**: `multipart/form-data`,字段:`file`(必填)、`dataType`(TEXT / IMAGE / VIDEO)
|
||||||
|
|
||||||
|
**响应** `201`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"id": 50,
|
||||||
|
"fileName": "document.txt",
|
||||||
|
"dataType": "TEXT",
|
||||||
|
"fileSize": 204800,
|
||||||
|
"status": "PENDING",
|
||||||
|
"createdAt": "2026-04-09T10:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败**:
|
||||||
|
- `400` `INVALID_TYPE`: 不支持的资料类型
|
||||||
|
- `400` `FILE_EMPTY`: 文件为空
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/source/list
|
||||||
|
|
||||||
|
**权限**: UPLOADER
|
||||||
|
**描述**: 分页查询资料列表。UPLOADER 只见自己上传的资料;ADMIN 见本公司全部资料
|
||||||
|
|
||||||
|
**查询参数**: `page`(默认 1)、`pageSize`(默认 20)、`dataType`(可选)、`status`(可选)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"fileName": "document.txt",
|
||||||
|
"dataType": "TEXT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"uploaderId": 1,
|
||||||
|
"createdAt": "2026-04-09T10:00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 120,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/source/{id}
|
||||||
|
|
||||||
|
**权限**: UPLOADER
|
||||||
|
**描述**: 查看资料详情,含 RustFS 预签名临时下载链接(有效期 15 分钟)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"id": 50,
|
||||||
|
"dataType": "TEXT",
|
||||||
|
"fileName": "document.txt",
|
||||||
|
"fileSize": 204800,
|
||||||
|
"status": "EXTRACTING",
|
||||||
|
"presignedUrl": "https://rustfs.example.com/...",
|
||||||
|
"parentSourceId": null,
|
||||||
|
"createdAt": "2026-04-09T10:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /api/source/{id}
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 删除资料(同时删除 RustFS 文件及元数据)
|
||||||
|
|
||||||
|
**前置条件**: 资料状态为 PENDING(不允许删除已进入流水线的资料)
|
||||||
|
|
||||||
|
**响应** `204`: 无响应体
|
||||||
|
**失败**: `409` `SOURCE_IN_PIPELINE`: 资料已进入标注流程,不可删除
|
||||||
150
specs/001-label-backend-spec/contracts/tasks.md
Normal file
150
specs/001-label-backend-spec/contracts/tasks.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# API 契约:任务管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/tasks/pool
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR
|
||||||
|
**描述**: 查看可领取任务池。角色过滤规则:
|
||||||
|
- ANNOTATOR:仅返回 EXTRACTION 阶段、status=UNCLAIMED 的任务
|
||||||
|
- REVIEWER/ADMIN:仅返回 SUBMITTED 状态(待审批队列)的任务
|
||||||
|
|
||||||
|
**查询参数**: `page`(默认 1)、`pageSize`(默认 20)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"sourceId": 50,
|
||||||
|
"sourceType": "TEXT",
|
||||||
|
"phase": "EXTRACTION",
|
||||||
|
"status": "UNCLAIMED",
|
||||||
|
"createdAt": "2026-04-09T10:00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 30,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/tasks/pending-review
|
||||||
|
|
||||||
|
**权限**: REVIEWER
|
||||||
|
**描述**: REVIEWER 专属审批入口,查看 status=SUBMITTED 的任务列表
|
||||||
|
|
||||||
|
**查询参数**: `page`、`pageSize`、`phase`(可选,EXTRACTION / QA_GENERATION)
|
||||||
|
|
||||||
|
**响应**: 同 `/api/tasks/pool` 结构
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/tasks/{id}/claim
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR
|
||||||
|
**描述**: 领取任务(双重并发保障:Redis SET NX + DB 乐观约束)
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**:
|
||||||
|
- `409` `TASK_CLAIMED`: 任务已被他人领取
|
||||||
|
- `404` `TASK_NOT_FOUND`: 任务不存在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/tasks/{id}/unclaim
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR(且为任务持有者)
|
||||||
|
**描述**: 放弃任务,退回任务池(status: IN_PROGRESS → UNCLAIMED)
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**: `403` `NOT_TASK_OWNER`: 非任务持有者
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/tasks/mine
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR
|
||||||
|
**描述**: 查询当前用户领取的任务(含 IN_PROGRESS、SUBMITTED、REJECTED 三种状态)
|
||||||
|
|
||||||
|
**查询参数**: `page`、`pageSize`、`status`(可选过滤)
|
||||||
|
|
||||||
|
**响应**: 同任务列表结构,含 `rejectReason` 字段(REJECTED 状态时非空)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/tasks/{id}/reclaim
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR
|
||||||
|
**描述**: 重领被驳回的任务(status 必须为 REJECTED 且 claimedBy = 当前用户,流转 REJECTED → IN_PROGRESS)
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**:
|
||||||
|
- `403` `NOT_TASK_OWNER`: 非原持有者
|
||||||
|
- `409` `INVALID_STATE`: 任务状态不为 REJECTED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/tasks/{id}
|
||||||
|
|
||||||
|
**权限**: ANNOTATOR
|
||||||
|
**描述**: 查看任务详情(含驳回原因、历史记录摘要)
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"id": 101,
|
||||||
|
"sourceId": 50,
|
||||||
|
"phase": "EXTRACTION",
|
||||||
|
"status": "IN_PROGRESS",
|
||||||
|
"claimedBy": 1,
|
||||||
|
"claimedAt": "2026-04-09T10:05:00",
|
||||||
|
"rejectReason": null,
|
||||||
|
"historyCount": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/tasks
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 查询全部任务(支持过滤,分页)
|
||||||
|
|
||||||
|
**查询参数**: `page`、`pageSize`、`phase`、`status`、`claimedBy`、`sourceId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/tasks/{id}/reassign
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 强制转移任务归属(status 保持 IN_PROGRESS,仅 claimedBy 变更)
|
||||||
|
|
||||||
|
**请求体**: `{"newOwnerId": 5, "reason": "原持有者长期未操作"}`
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/tasks
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 为指定资料创建 EXTRACTION 任务
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sourceId": 50,
|
||||||
|
"taskType": "AI_ASSISTED",
|
||||||
|
"aiModel": "glm-4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**响应** `201`: `{"code": "SUCCESS", "data": {"id": 101, ...}}`
|
||||||
87
specs/001-label-backend-spec/contracts/video.md
Normal file
87
specs/001-label-backend-spec/contracts/video.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# API 契约:视频处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/video/process
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 为已上传的视频资料创建异步处理任务
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sourceId": 50,
|
||||||
|
"jobType": "FRAME_EXTRACT",
|
||||||
|
"params": {
|
||||||
|
"frameInterval": 30,
|
||||||
|
"mode": "FRAME"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
jobType 可选值:`FRAME_EXTRACT`(帧提取)、`VIDEO_TO_TEXT`(片段转文字)
|
||||||
|
|
||||||
|
**响应** `201`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"jobId": 200,
|
||||||
|
"sourceId": 50,
|
||||||
|
"jobType": "FRAME_EXTRACT",
|
||||||
|
"status": "PENDING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/video/jobs/{jobId}
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 查询视频处理任务状态
|
||||||
|
|
||||||
|
**响应** `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"id": 200,
|
||||||
|
"status": "RUNNING",
|
||||||
|
"processedUnits": 15,
|
||||||
|
"totalUnits": 50,
|
||||||
|
"retryCount": 0,
|
||||||
|
"errorMessage": null,
|
||||||
|
"startedAt": "2026-04-09T10:05:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/video/jobs/{jobId}/reset
|
||||||
|
|
||||||
|
**权限**: ADMIN
|
||||||
|
**描述**: 手动重置 FAILED 状态的任务为 PENDING,允许重新触发(FAILED → PENDING 不在自动状态机中)
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
|
**失败**: `409` `INVALID_STATE`: 任务状态不为 FAILED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/video/callback(内部接口)
|
||||||
|
|
||||||
|
**权限**: AI 服务内部调用(IP 白名单 / 服务密钥)
|
||||||
|
**描述**: AI 服务回调,通知视频处理结果(幂等:重复成功回调静默忽略)
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": 200,
|
||||||
|
"success": true,
|
||||||
|
"outputPath": "frames/50/",
|
||||||
|
"errorMessage": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应** `200`: `{"code": "SUCCESS", "data": null}`
|
||||||
355
specs/001-label-backend-spec/data-model.md
Normal file
355
specs/001-label-backend-spec/data-model.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# 数据模型:label_backend
|
||||||
|
|
||||||
|
**日期**: 2026-04-09
|
||||||
|
**分支**: `001-label-backend-spec`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实体关系概览
|
||||||
|
|
||||||
|
```
|
||||||
|
sys_company ─┬─ sys_user (company_id FK)
|
||||||
|
├─ source_data (company_id FK)
|
||||||
|
│ └─ source_data (parent_source_id 自引用,视频溯源链)
|
||||||
|
├─ annotation_task (company_id FK)
|
||||||
|
│ ├─ annotation_result (task_id FK)
|
||||||
|
│ └─ annotation_task_history (task_id FK)
|
||||||
|
├─ training_dataset (company_id FK)
|
||||||
|
├─ export_batch (company_id FK)
|
||||||
|
├─ sys_config (company_id FK,可为 NULL 表示全局默认)
|
||||||
|
├─ sys_operation_log (company_id FK)
|
||||||
|
└─ video_process_job (company_id FK)
|
||||||
|
```
|
||||||
|
|
||||||
|
**多租户规则**:除 `sys_company` 本身外,所有业务表均包含 `company_id NOT NULL`。查询时由 `TenantLineInnerInterceptor` 自动注入 `WHERE company_id = ?`。唯一例外:`sys_config` 允许 `company_id = NULL` 表示全局默认配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实体详情
|
||||||
|
|
||||||
|
### 1. sys_company — 公司(租户)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | 自增主键 |
|
||||||
|
| company_name | VARCHAR(100) | NOT NULL UNIQUE | 公司名称 |
|
||||||
|
| company_code | VARCHAR(50) | NOT NULL UNIQUE | 公司编码 |
|
||||||
|
| status | VARCHAR(10) | NOT NULL DEFAULT 'ACTIVE' | ACTIVE / DISABLED |
|
||||||
|
| created_at | TIMESTAMP | NOT NULL DEFAULT NOW() | |
|
||||||
|
| updated_at | TIMESTAMP | NOT NULL DEFAULT NOW() | |
|
||||||
|
|
||||||
|
**状态**: 无状态机(仅 ACTIVE/DISABLED 标志)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. sys_user — 用户
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | 租户隔离键 |
|
||||||
|
| username | VARCHAR(50) | NOT NULL | 同公司内唯一 |
|
||||||
|
| password_hash | VARCHAR(255) | NOT NULL | BCrypt 强度≥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 / updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**约束**: `UNIQUE(company_id, username)`
|
||||||
|
**索引**: `(company_id)`
|
||||||
|
**角色继承**: ADMIN ⊃ REVIEWER ⊃ ANNOTATOR ⊃ UPLOADER(由 Shiro Realm 的 addInheritedRoles() 实现)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. source_data — 原始资料
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | |
|
||||||
|
| uploader_id | BIGINT | FK→sys_user | |
|
||||||
|
| data_type | VARCHAR(20) | NOT NULL | TEXT / IMAGE / VIDEO |
|
||||||
|
| file_path | VARCHAR(500) | NOT NULL | RustFS 对象路径 |
|
||||||
|
| file_name | VARCHAR(255) | NOT NULL | 原始文件名 |
|
||||||
|
| file_size | BIGINT | — | 字节数 |
|
||||||
|
| bucket_name | VARCHAR(100) | NOT NULL | RustFS 桶名 |
|
||||||
|
| parent_source_id | BIGINT | FK→source_data | 视频片段转文本时指向原视频 |
|
||||||
|
| status | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | 见状态机 |
|
||||||
|
| reject_reason | TEXT | — | 保留字段(当前无 REJECTED 状态) |
|
||||||
|
| created_at / updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**: `(company_id)`、`(company_id, status)`、`(parent_source_id)`
|
||||||
|
|
||||||
|
**状态机**:
|
||||||
|
```
|
||||||
|
PENDING → EXTRACTING(直接上传的文本/图片)
|
||||||
|
PENDING → PREPROCESSING(视频上传后)
|
||||||
|
PREPROCESSING → PENDING(视频预处理完成后进入标注流程)
|
||||||
|
EXTRACTING → QA_REVIEW(提取任务审批通过后)
|
||||||
|
QA_REVIEW → APPROVED(QA 任务审批通过后,整条流水线完成)
|
||||||
|
```
|
||||||
|
|
||||||
|
*注:source_data 无 REJECTED 状态。QA 阶段驳回作用于 annotation_task(→REJECTED),source_data 保持 QA_REVIEW 不变。*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. annotation_task — 标注任务
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | |
|
||||||
|
| source_id | BIGINT | NOT NULL FK→source_data | |
|
||||||
|
| phase | VARCHAR(20) | NOT NULL | EXTRACTION / QA_GENERATION |
|
||||||
|
| task_type | VARCHAR(20) | NOT NULL | AI_ASSISTED / MANUAL |
|
||||||
|
| ai_model | VARCHAR(50) | — | 使用的 AI 模型 |
|
||||||
|
| video_unit_type | VARCHAR(20) | — | FRAME(视频帧模式)/ NULL |
|
||||||
|
| video_unit_info | JSONB | — | `{frame_index, time_sec, frame_path}` |
|
||||||
|
| claimed_by | BIGINT | FK→sys_user | 当前持有者 |
|
||||||
|
| claimed_at | TIMESTAMP | — | |
|
||||||
|
| status | VARCHAR(20) | NOT NULL DEFAULT 'UNCLAIMED' | 见状态机 |
|
||||||
|
| reject_reason | TEXT | — | 驳回原因 |
|
||||||
|
| submitted_at | TIMESTAMP | — | |
|
||||||
|
| completed_at | TIMESTAMP | — | |
|
||||||
|
| created_at / updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**: `(company_id)`、`(company_id, phase, status)`(任务池查询)、`(claimed_by, status)`(我的任务)
|
||||||
|
|
||||||
|
**状态机**:
|
||||||
|
```
|
||||||
|
UNCLAIMED → IN_PROGRESS(领取)
|
||||||
|
IN_PROGRESS → SUBMITTED(提交)
|
||||||
|
IN_PROGRESS → UNCLAIMED(放弃)
|
||||||
|
IN_PROGRESS → IN_PROGRESS(ADMIN 强制转移,持有人变更,状态不变)
|
||||||
|
SUBMITTED → APPROVED(审批通过)
|
||||||
|
SUBMITTED → REJECTED(审批驳回)
|
||||||
|
REJECTED → IN_PROGRESS(标注员重领)
|
||||||
|
```
|
||||||
|
|
||||||
|
**并发控制**: 领取时双重保障:① Redis `SET NX task:claim:{taskId}` TTL 30s;② DB `UPDATE ... WHERE status='UNCLAIMED'` 影响行数为 0 时返回错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. annotation_result — 标注结果(提取阶段)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | |
|
||||||
|
| task_id | BIGINT | NOT NULL FK→annotation_task | |
|
||||||
|
| result_json | JSONB | NOT NULL | 整体覆盖,禁止局部 PATCH |
|
||||||
|
| is_final | BOOLEAN | NOT NULL DEFAULT FALSE | 审批通过后置 TRUE |
|
||||||
|
| submitted_by | BIGINT | FK→sys_user | |
|
||||||
|
| created_at / updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**result_json 结构**(文本三元组示例):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"subject": "北京",
|
||||||
|
"predicate": "是...首都",
|
||||||
|
"object": "中国",
|
||||||
|
"source_text": "北京是中国的首都",
|
||||||
|
"start_offset": 0,
|
||||||
|
"end_offset": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**result_json 结构**(图片四元组示例):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"subject": "猫",
|
||||||
|
"relation": "坐在",
|
||||||
|
"object": "椅子",
|
||||||
|
"modifier": "白色的",
|
||||||
|
"bbox": [100, 200, 300, 400],
|
||||||
|
"crop_path": "crops/123/0.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**索引**: `(task_id)`、`(company_id, is_final)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. training_dataset — 训练样本
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | |
|
||||||
|
| task_id | BIGINT | NOT NULL FK→annotation_task | |
|
||||||
|
| source_id | BIGINT | NOT NULL FK→source_data | |
|
||||||
|
| extraction_result_id | BIGINT | NOT NULL FK→annotation_result | |
|
||||||
|
| sample_type | VARCHAR(20) | NOT NULL | TEXT / IMAGE / VIDEO_FRAME |
|
||||||
|
| glm_format_json | JSONB | NOT NULL | GLM 微调格式 |
|
||||||
|
| export_batch_id | VARCHAR(50) | — | NULL 表示未导出 |
|
||||||
|
| status | VARCHAR(20) | NOT NULL DEFAULT 'PENDING_REVIEW' | 见状态机 |
|
||||||
|
| reject_reason | TEXT | — | |
|
||||||
|
| reviewed_by | BIGINT | FK→sys_user | |
|
||||||
|
| exported_at | TIMESTAMP | — | |
|
||||||
|
| created_at / updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**状态机**:
|
||||||
|
```
|
||||||
|
PENDING_REVIEW → APPROVED(QA 审批通过)
|
||||||
|
PENDING_REVIEW → REJECTED(QA 审批驳回)
|
||||||
|
REJECTED → PENDING_REVIEW(标注员修改后重提)
|
||||||
|
```
|
||||||
|
|
||||||
|
**glm_format_json 结构**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversations": [
|
||||||
|
{"role": "user", "content": "..."},
|
||||||
|
{"role": "assistant", "content": "..."}
|
||||||
|
],
|
||||||
|
"source_type": "TEXT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**索引**: `(company_id)`、`(company_id, status)`、`(export_batch_id)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. export_batch — 导出批次
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | |
|
||||||
|
| batch_uuid | VARCHAR(50) | NOT NULL UNIQUE | 批次标识符 |
|
||||||
|
| dataset_file_path | VARCHAR(500) | — | RustFS JSONL 路径 |
|
||||||
|
| sample_count | INT | NOT NULL DEFAULT 0 | |
|
||||||
|
| glm_job_id | VARCHAR(100) | — | 微调任务 ID |
|
||||||
|
| finetune_status | VARCHAR(20) | NOT NULL DEFAULT 'NOT_STARTED' | 见状态 |
|
||||||
|
| error_message | TEXT | — | |
|
||||||
|
| created_by | BIGINT | FK→sys_user | |
|
||||||
|
| created_at / updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**finetune_status 值**: NOT_STARTED / RUNNING / SUCCESS / FAILED
|
||||||
|
|
||||||
|
**索引**: `(company_id)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. sys_config — 系统配置
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | FK→sys_company,可 NULL | NULL = 全局默认配置 |
|
||||||
|
| config_key | VARCHAR(100) | NOT NULL | |
|
||||||
|
| config_value | TEXT | NOT NULL | |
|
||||||
|
| description | TEXT | — | |
|
||||||
|
| updated_by | BIGINT | FK→sys_user | |
|
||||||
|
| updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**约束**: `UNIQUE(company_id, config_key)`
|
||||||
|
**查询规则**: 先按 `(companyId, configKey)` 查;未命中则按 `(NULL, configKey)` 查全局默认。
|
||||||
|
|
||||||
|
**预置全局配置键**:
|
||||||
|
- `prompt_extract_text`、`prompt_extract_image`、`prompt_video_to_text`
|
||||||
|
- `prompt_qa_gen_text`、`prompt_qa_gen_image`
|
||||||
|
- `model_default`(默认:`glm-4`)
|
||||||
|
- `video_frame_interval`(默认:`30`)
|
||||||
|
- `token_ttl_seconds`(默认:`7200`)
|
||||||
|
- `glm_api_base_url`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. sys_operation_log — 操作审计日志
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | FK→sys_company | |
|
||||||
|
| operator_id | BIGINT | FK→sys_user | 登录失败时可为 NULL |
|
||||||
|
| operator_name | VARCHAR(50) | NOT NULL | **操作时用户名快照**(不随改名变化) |
|
||||||
|
| operation_type | VARCHAR(50) | NOT NULL | 见枚举列表 |
|
||||||
|
| target_type | VARCHAR(30) | — | |
|
||||||
|
| target_id | BIGINT | — | |
|
||||||
|
| detail | JSONB | — | 补充信息 |
|
||||||
|
| ip_address | VARCHAR(50) | — | |
|
||||||
|
| result | VARCHAR(10) | NOT NULL | SUCCESS / FAIL |
|
||||||
|
| error_message | TEXT | — | |
|
||||||
|
| created_at | TIMESTAMP | NOT NULL DEFAULT NOW() | 分区键 |
|
||||||
|
|
||||||
|
**只追加**:应用层禁止 UPDATE/DELETE,建议 DB 层添加触发器强制执行
|
||||||
|
**分区**:按 `created_at` Range 分区,以月为单位(`sys_operation_log_YYYY_MM`)
|
||||||
|
|
||||||
|
**operation_type 枚举**:
|
||||||
|
`USER_LOGIN`、`USER_LOGOUT`、`USER_CREATE`、`USER_UPDATE`、`USER_DISABLE`、`USER_ROLE_CHANGE`、`SOURCE_UPLOAD`、`SOURCE_DELETE`、`TASK_CREATE`、`TASK_CLAIM`、`TASK_UNCLAIM`、`TASK_SUBMIT`、`EXTRACTION_APPROVE`、`EXTRACTION_REJECT`、`QA_APPROVE`、`QA_REJECT`、`TASK_REASSIGN`、`EXPORT_CREATE`、`FINETUNE_START`、`CONFIG_UPDATE`、`VIDEO_JOB_RESET`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. annotation_task_history — 任务流转历史
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | |
|
||||||
|
| task_id | BIGINT | NOT NULL FK→annotation_task | |
|
||||||
|
| from_status | VARCHAR(20) | — | 任务初建时为 NULL |
|
||||||
|
| to_status | VARCHAR(20) | NOT NULL | |
|
||||||
|
| operator_id | BIGINT | NOT NULL FK→sys_user | |
|
||||||
|
| operator_role | VARCHAR(20) | NOT NULL | **操作时角色快照** |
|
||||||
|
| note | TEXT | — | 驳回原因、转移说明等 |
|
||||||
|
| created_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**只追加**:每次 annotation_task.status 变更时同步插入,与业务操作在同一事务中
|
||||||
|
**索引**: `(task_id)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. video_process_job — 视频异步处理任务
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | BIGSERIAL | PK | |
|
||||||
|
| company_id | BIGINT | NOT NULL FK→sys_company | |
|
||||||
|
| source_id | BIGINT | NOT NULL FK→source_data | |
|
||||||
|
| job_type | VARCHAR(20) | NOT NULL | FRAME_EXTRACT / VIDEO_TO_TEXT |
|
||||||
|
| status | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | 见状态机 |
|
||||||
|
| params | JSONB | NOT NULL | 处理参数 |
|
||||||
|
| total_units | INT | — | 总帧数/片段数 |
|
||||||
|
| processed_units | INT | NOT NULL DEFAULT 0 | |
|
||||||
|
| output_path | VARCHAR(500) | — | |
|
||||||
|
| retry_count | INT | NOT NULL DEFAULT 0 | |
|
||||||
|
| max_retries | INT | NOT NULL DEFAULT 3 | |
|
||||||
|
| error_message | TEXT | — | |
|
||||||
|
| started_at / completed_at | TIMESTAMP | — | |
|
||||||
|
| created_at / updated_at | TIMESTAMP | NOT NULL | |
|
||||||
|
|
||||||
|
**状态机**:
|
||||||
|
```
|
||||||
|
PENDING → RUNNING
|
||||||
|
RUNNING → SUCCESS(处理成功)
|
||||||
|
RUNNING → RETRYING(失败且 retry_count < max_retries)
|
||||||
|
RUNNING → FAILED(失败且 retry_count >= max_retries)
|
||||||
|
RETRYING → RUNNING(AI 服务自动重试)
|
||||||
|
RETRYING → FAILED(超过最大重试次数)
|
||||||
|
```
|
||||||
|
*FAILED → PENDING:由 ADMIN 手动触发接口,不在状态机自动流转中*
|
||||||
|
|
||||||
|
**幂等规则**: 回调时若 `status == SUCCESS` 则静默忽略,不执行任何 DB 写入
|
||||||
|
|
||||||
|
**索引**: `(source_id)`、`(status)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis 数据结构
|
||||||
|
|
||||||
|
| Key 模式 | 类型 | TTL | 内容 |
|
||||||
|
|---------|------|-----|------|
|
||||||
|
| `token:{uuid}` | Hash | 2h(滑动) | `{userId, role, companyId, username}` |
|
||||||
|
| `user:perm:{userId}` | String | 5min | 用户角色字符串 |
|
||||||
|
| `task:claim:{taskId}` | String | 30s | 持有者 userId |
|
||||||
|
|
||||||
|
*禁止在上述三类命名空间之外自造 Key 用于认证、权限或锁目的。*
|
||||||
137
specs/001-label-backend-spec/plan.md
Normal file
137
specs/001-label-backend-spec/plan.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 实施计划:label_backend 知识图谱智能标注平台
|
||||||
|
|
||||||
|
**分支**: `001-label-backend-spec` | **日期**: 2026-04-09 | **规格说明**: [spec.md](spec.md)
|
||||||
|
**输入**: 功能规格说明 `/specs/001-label-backend-spec/spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
构建面向多租户的知识图谱智能标注平台后端服务,驱动**文本线**(三元组提取 → 问答对生成 → 训练样本)和**图片线**(四元组提取 → 问答对生成 → 训练样本)两条流水线。视频作为预处理入口异步汇入两条流水线。系统基于 Spring Boot 3 + Apache Shiro + MyBatis Plus + PostgreSQL + Redis + RustFS 构建,通过 HTTP 调用 Python FastAPI AI 服务完成 AI 辅助标注和问答生成能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术上下文
|
||||||
|
|
||||||
|
**语言/版本**: Java 17(LTS)
|
||||||
|
**主要依赖**: Spring Boot ≥ 3.0.x、Apache Shiro ≥ 1.13.x、MyBatis Plus ≥ 3.5.x、Spring Data Redis
|
||||||
|
**存储**: PostgreSQL ≥ 14(主库)、Redis ≥ 6.x(会话/权限缓存/分布式锁)、RustFS(S3 兼容对象存储)
|
||||||
|
**测试**: JUnit 5 + Testcontainers(真实 PostgreSQL + Redis 实例)、Spring Boot Test
|
||||||
|
**目标平台**: Linux 服务器,Docker Compose 容器化部署
|
||||||
|
**项目类型**: Web Service(REST API)
|
||||||
|
**性能目标**: 任务领取并发下有且仅有一人成功;权限变更延迟 < 1 秒生效
|
||||||
|
**约束**: 禁止 JWT;禁止 Spring Security;禁止文件字节流存入数据库;AI HTTP 调用禁止在 @Transactional 内同步执行;所有列表接口强制分页
|
||||||
|
**规模**: 多租户(多公司),每公司独立数据空间;11 张核心业务表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 宪章合规检查
|
||||||
|
|
||||||
|
*门控:Phase 0 研究前必须通过。Phase 1 设计后重检。*
|
||||||
|
|
||||||
|
| # | 宪章原则 | 状态 | 说明 |
|
||||||
|
|---|---------|------|------|
|
||||||
|
| 1 | 环境约束(JDK 17、SB 3、Shiro、MyBatis Plus) | ✅ 通过 | pom.xml 中版本约束与宪章完全对齐;无 Spring Security 引入 |
|
||||||
|
| 2 | 多租户数据隔离(company_id + ThreadLocal) | ✅ 通过 | TenantLineInnerInterceptor 自动注入;CompanyContext 在 finally 块清理 |
|
||||||
|
| 3 | BCrypt 密码 + UUID Token + 禁 JWT | ✅ 通过 | AuthService 使用 BCrypt ≥ 10;UUID v4 Token 存 Redis;无 JWT 库 |
|
||||||
|
| 4 | 分级 RBAC + 权限注解 + 角色变更驱逐缓存 | ✅ 通过 | @RequiresRoles 声明权限;updateRole() 立即删 user:perm:{userId} |
|
||||||
|
| 5 | 双流水线 + 级联触发 + parent_source_id 溯源 | ✅ 通过 | 仅文本线/图片线;审批通过用 @TransactionalEventListener 触发 QA |
|
||||||
|
| 6 | 状态机完整性(StateValidator) | ✅ 通过 | 所有状态变更经 StateValidator.assertTransition();禁止绕过 Mapper 直写 |
|
||||||
|
| 7 | 任务争抢双重保障(Redis SET NX + DB 乐观锁) | ✅ 通过 | task:claim:{taskId} TTL 30s + WHERE status='UNCLAIMED' |
|
||||||
|
| 8 | 异步视频处理幂等 + 重试上限 + FAILED 手动重置 | ✅ 通过 | SUCCESS 回调静默忽略;retry_count ≥ max_retries → FAILED |
|
||||||
|
| 9 | 只追加审计日志 + AOP 切面 + 审计失败不回滚业务 | ✅ 通过 | @OperationLog AOP;sys_operation_log 无 UPDATE/DELETE;异常仅 error 日志 |
|
||||||
|
| 10 | RESTful URL + 统一响应格式 + 强制分页 | ✅ 通过 | Result<T> 包装;无动词路径;PageResult<T> 分页 |
|
||||||
|
| 11 | YAGNI:业务在 Service,Controller 只处理 HTTP | ✅ 通过 | 分层明确;无预测性抽象层 |
|
||||||
|
|
||||||
|
**门控结果:全部通过,可进入 Phase 0。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
### 规格说明文档(本功能)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/001-label-backend-spec/
|
||||||
|
├── plan.md # 本文件(/speckit.plan 输出)
|
||||||
|
├── research.md # Phase 0 输出
|
||||||
|
├── data-model.md # Phase 1 输出
|
||||||
|
├── quickstart.md # Phase 1 输出
|
||||||
|
├── contracts/ # Phase 1 输出(REST API 契约)
|
||||||
|
│ ├── auth.md
|
||||||
|
│ ├── source.md
|
||||||
|
│ ├── tasks.md
|
||||||
|
│ ├── extraction.md
|
||||||
|
│ ├── qa.md
|
||||||
|
│ ├── export.md
|
||||||
|
│ ├── config.md
|
||||||
|
│ └── video.md
|
||||||
|
└── tasks.md # Phase 2 输出(/speckit.tasks 命令创建,非本命令)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 源代码(仓库根目录)
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
└── main/
|
||||||
|
└── java/com/label/
|
||||||
|
├── LabelBackendApplication.java
|
||||||
|
├── common/
|
||||||
|
│ ├── result/ # Result<T>、ResultCode、PageResult<T>
|
||||||
|
│ ├── exception/ # BusinessException、GlobalExceptionHandler
|
||||||
|
│ ├── context/ # CompanyContext(ThreadLocal)
|
||||||
|
│ ├── shiro/ # TokenFilter、UserRealm、ShiroConfig
|
||||||
|
│ ├── redis/ # RedisKeyManager、RedisService
|
||||||
|
│ ├── aop/ # AuditAspect、@OperationLog 注解
|
||||||
|
│ ├── storage/ # RustFsClient(S3 兼容封装)
|
||||||
|
│ ├── ai/ # AiServiceClient(RestClient 封装 8 个端点)
|
||||||
|
│ └── statemachine/ # StateValidator、各状态枚举
|
||||||
|
└── module/
|
||||||
|
├── user/ # AuthController、UserController、AuthService、UserService
|
||||||
|
├── source/ # SourceController、SourceService
|
||||||
|
├── task/ # TaskController、TaskService、TaskClaimService
|
||||||
|
├── annotation/ # ExtractionController、QaController、ExtractionService、QaService
|
||||||
|
├── export/ # ExportController、ExportService、FinetuneService
|
||||||
|
├── config/ # SysConfigController、SysConfigService
|
||||||
|
└── video/ # VideoController、VideoProcessService
|
||||||
|
|
||||||
|
src/
|
||||||
|
└── test/
|
||||||
|
└── java/com/label/
|
||||||
|
├── integration/ # Testcontainers(真实 PG + Redis)集成测试
|
||||||
|
│ ├── AuthIntegrationTest.java
|
||||||
|
│ ├── TaskClaimConcurrencyTest.java
|
||||||
|
│ ├── VideoCallbackIdempotencyTest.java
|
||||||
|
│ ├── MultiTenantIsolationTest.java
|
||||||
|
│ └── ShiroFilterIntegrationTest.java
|
||||||
|
└── unit/ # 纯单元测试(状态机、业务逻辑)
|
||||||
|
└── StateMachineTest.java
|
||||||
|
|
||||||
|
sql/
|
||||||
|
└── init.sql # 全部 DDL(11 张表,按依赖顺序执行)
|
||||||
|
|
||||||
|
docker-compose.yml # postgres、redis、rustfs、backend、ai-service、frontend
|
||||||
|
Dockerfile # eclipse-temurin:17-jre-alpine
|
||||||
|
pom.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**结构决策**:单一后端服务(Web Service),无前端代码。标准 Maven 项目布局,源代码在 `src/main/java/com/label/`,测试在 `src/test/java/com/label/`,按 `common/` + `module/` 两层分包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 复杂度追踪
|
||||||
|
|
||||||
|
> 宪章检查无违规,本节留空。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
|
||||||
|
| Review | Trigger | Why | Runs | Status | Findings |
|
||||||
|
|--------|---------|-----|------|--------|----------|
|
||||||
|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — |
|
||||||
|
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — |
|
||||||
|
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — |
|
||||||
|
| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |
|
||||||
|
|
||||||
|
**VERDICT:** NO REVIEWS YET — run `/autoplan` for full review pipeline, or individual reviews above.
|
||||||
179
specs/001-label-backend-spec/quickstart.md
Normal file
179
specs/001-label-backend-spec/quickstart.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 快速启动指南:label_backend
|
||||||
|
|
||||||
|
**日期**: 2026-04-09
|
||||||
|
**分支**: `001-label-backend-spec`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- Docker Desktop ≥ 4.x(含 Docker Compose v2)
|
||||||
|
- JDK 17(本地开发时)
|
||||||
|
- Maven ≥ 3.8(本地开发时)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、使用 Docker Compose 启动完整环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone <repo-url>
|
||||||
|
cd label_backend
|
||||||
|
|
||||||
|
# 启动所有服务(PostgreSQL + Redis + RustFS + AI Service + Backend + Frontend)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 查看后端启动日志
|
||||||
|
docker compose logs -f backend
|
||||||
|
|
||||||
|
# 检查健康状态
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
**服务端口**:
|
||||||
|
| 服务 | 端口 |
|
||||||
|
|------|------|
|
||||||
|
| 前端(Nginx) | http://localhost:80 |
|
||||||
|
| 后端 REST API | http://localhost:8080 |
|
||||||
|
| AI 服务(FastAPI) | http://localhost:8000 |
|
||||||
|
| PostgreSQL | localhost:5432 |
|
||||||
|
| Redis | localhost:6379 |
|
||||||
|
| RustFS S3 API | http://localhost:9000 |
|
||||||
|
| RustFS Web 控制台 | http://localhost:9001 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、初始化数据库
|
||||||
|
|
||||||
|
数据库 DDL 通过 `./sql/init.sql` 在 PostgreSQL 容器启动时自动执行(`docker-entrypoint-initdb.d`)。
|
||||||
|
|
||||||
|
若需手动执行:
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres psql -U label -d label_db -f /docker-entrypoint-initdb.d/init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**初始账号**(由 `init.sql` 中的 INSERT 语句创建):
|
||||||
|
| 用户名 | 密码 | 角色 | 公司 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| admin | admin123 | ADMIN | 演示公司 |
|
||||||
|
| reviewer01 | review123 | REVIEWER | 演示公司 |
|
||||||
|
| annotator01 | annot123 | ANNOTATOR | 演示公司 |
|
||||||
|
| uploader01 | upload123 | UPLOADER | 演示公司 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、本地开发模式(不使用 Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动依赖服务(仅 PostgreSQL + Redis + RustFS,不启动后端)
|
||||||
|
docker compose up -d postgres redis rustfs
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/label_db
|
||||||
|
export SPRING_DATASOURCE_USERNAME=label
|
||||||
|
export SPRING_DATASOURCE_PASSWORD=label_password
|
||||||
|
export SPRING_REDIS_HOST=localhost
|
||||||
|
export SPRING_REDIS_PORT=6379
|
||||||
|
export SPRING_REDIS_PASSWORD=redis_password
|
||||||
|
export RUSTFS_ENDPOINT=http://localhost:9000
|
||||||
|
export RUSTFS_ACCESS_KEY=minioadmin
|
||||||
|
export RUSTFS_SECRET_KEY=minioadmin
|
||||||
|
export AI_SERVICE_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# 编译并启动
|
||||||
|
mvn clean spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、验证安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 登录(获取 Token)
|
||||||
|
curl -X POST http://localhost:8080/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"companyCode":"DEMO","username":"admin","password":"admin123"}'
|
||||||
|
|
||||||
|
# 期望响应:{"code":"SUCCESS","data":{"token":"...","role":"ADMIN",...}}
|
||||||
|
|
||||||
|
# 2. 使用 Token 访问受保护接口(将 {TOKEN} 替换为上一步返回的 token)
|
||||||
|
curl http://localhost:8080/api/auth/me \
|
||||||
|
-H "Authorization: Bearer {TOKEN}"
|
||||||
|
|
||||||
|
# 期望响应:{"code":"SUCCESS","data":{"username":"admin","role":"ADMIN",...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有测试(Testcontainers 会自动启动真实 PG + Redis 容器)
|
||||||
|
mvn test
|
||||||
|
|
||||||
|
# 运行特定测试(并发任务领取)
|
||||||
|
mvn test -Dtest=TaskClaimConcurrencyTest
|
||||||
|
|
||||||
|
# 运行集成测试套件
|
||||||
|
mvn test -Dtest=*IntegrationTest
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: Testcontainers 需要本地 Docker 可用。首次运行会拉取 PostgreSQL 和 Redis 镜像(约 200MB)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、关键配置项说明
|
||||||
|
|
||||||
|
配置文件位于 `src/main/resources/application.yml`。以下配置项可在运行时通过 `PUT /api/config/{key}` 接口(ADMIN 权限)动态调整,无需重启服务:
|
||||||
|
|
||||||
|
| 配置键 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `token_ttl_seconds` | 会话凭证有效期(秒) | 7200(2小时) |
|
||||||
|
| `model_default` | AI 辅助默认模型 | glm-4 |
|
||||||
|
| `video_frame_interval` | 视频帧提取间隔(帧数) | 30 |
|
||||||
|
| `prompt_extract_text` | 文本三元组提取 Prompt | 见 init.sql |
|
||||||
|
| `prompt_extract_image` | 图片四元组提取 Prompt | 见 init.sql |
|
||||||
|
| `prompt_qa_gen_text` | 文本问答生成 Prompt | 见 init.sql |
|
||||||
|
| `prompt_qa_gen_image` | 图片问答生成 Prompt | 见 init.sql |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、标注流水线快速验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="your-admin-token"
|
||||||
|
|
||||||
|
# 步骤 1:上传文本资料
|
||||||
|
curl -X POST http://localhost:8080/api/source/upload \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F "file=@sample.txt" -F "dataType=TEXT"
|
||||||
|
|
||||||
|
# 步骤 2:为资料创建提取任务(sourceId 从上一步响应中获取)
|
||||||
|
curl -X POST http://localhost:8080/api/tasks \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"sourceId": 1, "taskType": "AI_ASSISTED", "aiModel": "glm-4"}'
|
||||||
|
|
||||||
|
# 步骤 3:标注员领取任务(使用 annotator01 的 Token)
|
||||||
|
ANNOTATOR_TOKEN="annotator-token"
|
||||||
|
curl -X POST http://localhost:8080/api/tasks/1/claim \
|
||||||
|
-H "Authorization: Bearer $ANNOTATOR_TOKEN"
|
||||||
|
|
||||||
|
# 步骤 4:获取 AI 预标注结果
|
||||||
|
curl http://localhost:8080/api/extraction/1 \
|
||||||
|
-H "Authorization: Bearer $ANNOTATOR_TOKEN"
|
||||||
|
|
||||||
|
# 步骤 5:提交标注结果
|
||||||
|
curl -X PUT http://localhost:8080/api/extraction/1 \
|
||||||
|
-H "Authorization: Bearer $ANNOTATOR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"items":[{"subject":"北京","predicate":"是首都","object":"中国","sourceText":"北京是中国的首都","startOffset":0,"endOffset":8}]}'
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8080/api/extraction/1/submit \
|
||||||
|
-H "Authorization: Bearer $ANNOTATOR_TOKEN"
|
||||||
|
|
||||||
|
# 步骤 6:审批员审批通过(使用 reviewer01 的 Token)
|
||||||
|
REVIEWER_TOKEN="reviewer-token"
|
||||||
|
curl -X POST http://localhost:8080/api/extraction/1/approve \
|
||||||
|
-H "Authorization: Bearer $REVIEWER_TOKEN"
|
||||||
|
```
|
||||||
150
specs/001-label-backend-spec/research.md
Normal file
150
specs/001-label-backend-spec/research.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Phase 0 研究报告:label_backend
|
||||||
|
|
||||||
|
**日期**: 2026-04-09
|
||||||
|
**分支**: `001-label-backend-spec`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术决策汇总
|
||||||
|
|
||||||
|
所有技术选型均由宪章强制约束,无需评估备选方案。本报告记录关键设计决策的理由,供后续实施参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 1:认证机制
|
||||||
|
|
||||||
|
**决策**: UUID v4 Token 存储于 Redis,滑动过期,禁止 JWT
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- JWT 自包含令牌无法按需吊销,无法满足"管理员禁用账号立即生效"的安全要求
|
||||||
|
- UUID Token 在 Redis 中可精确控制生命周期:退出登录或禁用账号时同步删除 Key,下一次请求立即失效
|
||||||
|
- 滑动过期(每次有效请求重置 TTL)确保活跃用户不被意外踢出
|
||||||
|
|
||||||
|
**备选方案放弃理由**:
|
||||||
|
- JWT:无法即时吊销,存在安全窗口
|
||||||
|
- Session Cookie:在无状态 REST API 架构中不适用
|
||||||
|
- OAuth2:过度设计,当前场景无第三方授权需求
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 2:多租户隔离机制
|
||||||
|
|
||||||
|
**决策**: MyBatis Plus `TenantLineInnerInterceptor` + `ThreadLocal CompanyContext`
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- `TenantLineInnerInterceptor` 在 SQL 拦截器层自动在每条查询的 WHERE 子句中注入 `company_id`,覆盖范围广且无需逐方法手动添加条件
|
||||||
|
- ThreadLocal 存储当前请求的 `companyId`,由 Shiro TokenFilter 在解析 Token 时从 Redis 会话数据注入,确保 companyId 来自服务端权威来源而非客户端参数
|
||||||
|
- `finally` 块强制清理 ThreadLocal,防止线程池复用时数据串漏
|
||||||
|
|
||||||
|
**备选方案放弃理由**:
|
||||||
|
- 行级安全(RLS):PostgreSQL 原生支持,但与 MyBatis Plus 集成复杂,且宪章已指定 ThreadLocal 方案
|
||||||
|
- 逐方法手动添加 WHERE:容易遗漏,维护成本高
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 3:任务并发领取控制
|
||||||
|
|
||||||
|
**决策**: Redis `SET NX`(分布式锁)+ 数据库乐观约束(`WHERE status = 'UNCLAIMED'`)双重保障
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 单纯使用数据库乐观锁在高并发下存在写放大问题(大量 UPDATE 竞争)
|
||||||
|
- 单纯使用 Redis 锁若锁过期后 DB 写入失败可能导致数据不一致
|
||||||
|
- 双重保障:Redis 锁(TTL 30s)快速拦截大部分并发请求,减少数据库压力;DB 乐观约束作为最终一致性兜底
|
||||||
|
|
||||||
|
**Key 命名**: `task:claim:{taskId}`(TTL 30s,与宪章 Redis Key 规范一致)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 4:审批触发 QA 任务的异步解耦
|
||||||
|
|
||||||
|
**决策**: Spring `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Transactional(REQUIRES_NEW)`
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 提取阶段审批通过后需调用 AI HTTP 生成候选问答对,该 HTTP 调用延迟不确定(秒级到分钟级)
|
||||||
|
- 若在 `@Transactional` 内同步调用,数据库连接被长时间占用,且 AI 失败会错误地回滚已完成的审批操作
|
||||||
|
- `AFTER_COMMIT` 保证业务审批先提交再触发事件,避免事务回滚导致的幽灵任务
|
||||||
|
- `REQUIRES_NEW` 为 QA 生成开启独立事务,AI 失败仅影响 QA 任务创建,不影响审批结果
|
||||||
|
|
||||||
|
**事件流**: `approve()` → publish `ExtractionApprovedEvent` → 事务提交 → `onExtractionApproved()` 异步执行(AI 调用 + 创建 QA 任务)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 5:标注结果存储语义
|
||||||
|
|
||||||
|
**决策**: JSONB 整体覆盖(PUT 语义),禁止局部 PATCH
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 三元组/四元组条目具有强关联性(主语-谓语-宾语作为整体,或主体-关系-客体-修饰词作为整体),局部更新易导致不一致
|
||||||
|
- 整体替换简化服务端逻辑,前端每次提交完整 items 数组,服务端直接执行 UPDATE `result_json = ?`
|
||||||
|
- 避免局部追加导致的索引层数据不一致(如删除某条目后残留旧数据)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 6:审计日志事务边界
|
||||||
|
|
||||||
|
**决策**: 审计日志写入不要求与业务操作在同一事务,AOP `finally` 块中独立写入
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 审计写入失败不应回滚业务操作(用户的标注/审批结果比审计日志更重要)
|
||||||
|
- `@Around` 通知在业务方法执行完成(commit 或 rollback)后捕获最终 `result`,可记录准确的成功/失败状态
|
||||||
|
- 审计失败仅 error 级别日志 + 告警,不影响用户体验
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 7:视频预处理幂等回调
|
||||||
|
|
||||||
|
**决策**: 回调处理时检查 `video_process_job.status`,已为 `SUCCESS` 则静默忽略
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- AI 服务可能因网络抖动对同一 jobId 发起多次成功回调
|
||||||
|
- 幂等检查确保第一次成功回调创建标注任务,后续重复回调无任何副作用
|
||||||
|
- 检查粒度:`status == SUCCESS` 即返回,不进行任何 DB 写入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 8:对象存储路径规范
|
||||||
|
|
||||||
|
**决策**: RustFS(S3 兼容),文件字节流禁止入库,路径按资源类型分桶分目录
|
||||||
|
|
||||||
|
**路径规范**:
|
||||||
|
|
||||||
|
| 资源 | 桶 | 路径格式 |
|
||||||
|
|------|-----|---------|
|
||||||
|
| 文本文件 | `source-data` | `text/{yyyyMM}/{source_id}.txt` |
|
||||||
|
| 图片 | `source-data` | `image/{yyyyMM}/{source_id}.jpg` |
|
||||||
|
| 视频 | `source-data` | `video/{yyyyMM}/{source_id}.mp4` |
|
||||||
|
| 视频帧 | `source-data` | `frames/{source_id}/{frame_index}.jpg` |
|
||||||
|
| 视频转文本 | `source-data` | `video-text/{parent_source_id}/{timestamp}.txt` |
|
||||||
|
| bbox 裁剪图 | `source-data` | `crops/{task_id}/{item_index}.jpg` |
|
||||||
|
| 导出 JSONL | `finetune-export` | `export/{batchUuid}.jsonl` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策 9:测试策略
|
||||||
|
|
||||||
|
**决策**: 集成测试使用 Testcontainers(真实 PG + Redis),不允许 Mock 数据库
|
||||||
|
|
||||||
|
**必须覆盖的测试场景**:
|
||||||
|
|
||||||
|
1. **并发任务领取**:10 线程同时争抢同一任务,验证恰好 1 人成功(Redis + DB 双重锁)
|
||||||
|
2. **视频回调幂等**:同一 jobId 两次成功回调,验证只创建 1 个 annotation_task
|
||||||
|
3. **状态机越界拒绝**:非法状态转换(如 APPROVED → IN_PROGRESS)抛出 BusinessException
|
||||||
|
4. **多租户隔离**:公司 A 身份访问公司 B 资源,验证被拒绝
|
||||||
|
5. **Shiro 过滤器链**:无 Token → 401;Token 有效但角色不足 → 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 无需澄清事项汇总
|
||||||
|
|
||||||
|
| 项目 | 状态 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| 认证方案 | ✅ 已确定(UUID Token) | 宪章原则三 |
|
||||||
|
| 数据库选型 | ✅ 已确定(PostgreSQL) | 宪章原则一 |
|
||||||
|
| ORM | ✅ 已确定(MyBatis Plus) | 宪章原则一 |
|
||||||
|
| 缓存/锁 | ✅ 已确定(Redis) | 宪章原则一 |
|
||||||
|
| 对象存储 | ✅ 已确定(RustFS S3) | 宪章原则一 |
|
||||||
|
| AI 集成方式 | ✅ 已确定(HTTP RestClient) | 宪章原则一 |
|
||||||
|
| 多租户隔离 | ✅ 已确定(ThreadLocal + Interceptor) | 宪章原则二 |
|
||||||
|
| 并发控制 | ✅ 已确定(双重锁) | 宪章原则七 |
|
||||||
|
| 审批事务边界 | ✅ 已确定(@TransactionalEventListener) | 宪章原则五 |
|
||||||
|
| 测试策略 | ✅ 已确定(Testcontainers) | 宪章开发工作流 |
|
||||||
273
specs/001-label-backend-spec/spec.md
Normal file
273
specs/001-label-backend-spec/spec.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# 功能规格说明:label_backend 知识图谱智能标注平台
|
||||||
|
|
||||||
|
**功能分支**: `001-label-backend-spec`
|
||||||
|
**创建日期**: 2026-04-09
|
||||||
|
**状态**: 草稿
|
||||||
|
**输入**: 根据文档 docs/superpowers/specs/2026-04-09-label-backend-design.md 生成需求规格文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户场景与测试 *(必填)*
|
||||||
|
|
||||||
|
### 用户故事 1 - 用户登录与身份认证 (优先级: P1)
|
||||||
|
|
||||||
|
公司员工使用用户名和密码登录平台,获取会话凭证后访问受权限保护的功能。会话在持续活跃时保持有效,用户主动退出或管理员禁用账号后会话立即失效。
|
||||||
|
|
||||||
|
**优先级理由**: 认证是所有其他功能的前提,无法登录则所有功能均不可用。
|
||||||
|
|
||||||
|
**独立测试**: 可独立通过以下方式测试:用正确凭证登录,携带返回凭证请求受保护接口,验证正常访问;携带错误凭证或过期凭证,验证被拒绝。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 用户持有效用户名和密码,**当** 提交登录请求,**则** 系统返回会话凭证,且该凭证可用于后续请求
|
||||||
|
2. **给定** 用户已登录并持有效凭证,**当** 发起正常业务请求,**则** 会话有效期自动延长
|
||||||
|
3. **给定** 用户主动退出登录,**当** 使用旧凭证访问任意受保护接口,**则** 系统立即拒绝,返回未授权响应
|
||||||
|
4. **给定** 管理员禁用某用户账号,**当** 被禁用用户使用现有凭证访问接口,**则** 系统立即拒绝,不设任何宽限期
|
||||||
|
5. **给定** 用户使用错误密码,**当** 提交登录请求,**则** 系统返回认证失败,不泄露用户是否存在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 用户故事 2 - 原始资料上传 (优先级: P1)
|
||||||
|
|
||||||
|
上传员将文本文件、图片或视频上传至平台,系统存储文件并记录元数据,视频文件额外触发异步预处理流程(帧提取或转文字)。
|
||||||
|
|
||||||
|
**优先级理由**: 资料上传是整条标注流水线的起点,没有资料则无法产生任何标注任务。
|
||||||
|
|
||||||
|
**独立测试**: 可独立测试:上传一个文本文件或图片,验证系统成功接收并记录;上传视频,验证系统创建预处理任务并开始异步处理。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 上传员已登录,**当** 上传一个文本文件,**则** 系统保存文件并创建资料记录,状态为"待提取"
|
||||||
|
2. **给定** 上传员已登录,**当** 上传一张图片,**则** 系统保存图片并创建资料记录,状态为"待提取"
|
||||||
|
3. **给定** 上传员已登录,**当** 上传一个视频文件,**则** 系统保存视频,创建预处理任务,资料状态变为"预处理中"
|
||||||
|
4. **给定** 视频预处理成功完成,**当** AI 服务回调成功,**则** 每帧(帧模式)或每段转译文本(片段模式)均作为独立资料进入标注队列,原视频状态变为"已完成"
|
||||||
|
5. **给定** 视频预处理因 AI 服务故障失败且已达最大重试次数,**当** 回调失败,**则** 任务标记为失败,管理员可查阅错误信息并手动重新触发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 用户故事 3 - 提取阶段标注(EXTRACTION) (优先级: P1)
|
||||||
|
|
||||||
|
标注员从任务池中领取一个提取任务,借助 AI 辅助预标注对文本资料完成三元组标注或对图片资料完成四元组标注,提交后由审批员审核。
|
||||||
|
|
||||||
|
**优先级理由**: 提取阶段是双流水线的第一个生产阶段,直接产出结构化知识。
|
||||||
|
|
||||||
|
**独立测试**: 可独立测试:标注员领取任务,修改 AI 预标注结果,提交后验证任务进入"待审批"状态;同一任务被多人同时尝试领取,验证只有一人成功。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 存在未被领取的提取任务,**当** 标注员请求领取,**则** 任务归属到该标注员,状态变为"进行中"
|
||||||
|
2. **给定** 同一任务被 10 名标注员同时争抢,**当** 所有人同时发起领取请求,**则** 恰好一名标注员领取成功,其余人收到"任务已被他人领取"响应
|
||||||
|
3. **给定** 标注员已领取任务,**当** 请求 AI 辅助预标注,**则** 系统调用 AI 服务返回结构化候选结果(不直接提交,供人工编辑)
|
||||||
|
4. **给定** 标注员完成人工编辑,**当** 提交标注结果,**则** 任务状态变为"已提交",进入审批队列
|
||||||
|
5. **给定** 标注员领取任务后决定放弃,**当** 放弃任务,**则** 任务回到任务池,可被其他标注员重新领取
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 用户故事 4 - 提取阶段审批 (优先级: P1)
|
||||||
|
|
||||||
|
审批员查看提交的提取标注结果,选择通过或驳回。审批通过后系统自动创建问答生成任务;驳回时需填写驳回原因,标注员可重新领取该任务修改后再次提交。
|
||||||
|
|
||||||
|
**优先级理由**: 审批控制标注质量,是推进流水线到下一阶段的门控节点。
|
||||||
|
|
||||||
|
**独立测试**: 可独立测试:审批通过一个提取任务,验证系统自动创建 QA 生成任务;驳回一个任务,验证标注员可重领并修改。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 审批员进入待审批队列,**当** 查看列表,**则** 只看到状态为"已提交"的任务
|
||||||
|
2. **给定** 审批员查看某提取任务的标注结果,**当** 点击通过,**则** 标注结果标记为最终版,系统自动创建对应的问答生成任务并置于任务池中
|
||||||
|
3. **给定** 审批员本人提交了某提取任务,**当** 该审批员尝试审批自己提交的任务,**则** 系统拒绝,提示不允许自审
|
||||||
|
4. **给定** 审批员认为标注结果不合格,**当** 附带驳回原因并驳回,**则** 任务状态变为"已驳回",标注员可在我的任务列表中看到该任务及原因
|
||||||
|
5. **给定** 标注员查看被驳回的任务,**当** 重新领取并修改后提交,**则** 任务重新进入审批队列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 用户故事 5 - 问答生成阶段标注与审批(QA_GENERATION) (优先级: P2)
|
||||||
|
|
||||||
|
标注员领取问答生成任务,在 AI 候选问答对基础上完成人工编辑,提交后由审批员审批。审批通过即写入训练样本库;驳回则退回标注员修改。
|
||||||
|
|
||||||
|
**优先级理由**: QA 阶段是流水线的最后生产阶段,直接决定训练样本质量。
|
||||||
|
|
||||||
|
**独立测试**: 可独立测试:领取 QA 任务,修改候选问答对并提交;审批员通过后,验证训练样本库中出现对应记录。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 存在由提取阶段审批通过自动创建的问答生成任务,**当** 标注员进入任务池,**则** 可以看到并领取该任务
|
||||||
|
2. **给定** 标注员已领取问答生成任务,**当** 整体提交修改后的问答对列表,**则** 任务进入审批队列(每次提交均为完整列表替换,不允许部分追加)
|
||||||
|
3. **给定** 审批员通过问答生成任务,**当** 审批完成,**则** 对应训练样本状态变为"已审批",整条资料流水线标记为完成
|
||||||
|
4. **给定** 审批员驳回问答生成任务,**当** 驳回完成,**则** 候选问答对记录被清除,标注员可重领任务重新生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 用户故事 6 - 训练数据导出与微调提交 (优先级: P2)
|
||||||
|
|
||||||
|
管理员从已审批的训练样本中选择一批次,导出为 GLM 微调格式的 JSONL 文件,并可选择一键提交至 GLM 微调服务。
|
||||||
|
|
||||||
|
**优先级理由**: 导出是将标注成果转化为 AI 训练价值的最终步骤。
|
||||||
|
|
||||||
|
**独立测试**: 可独立测试:选择若干已审批样本创建导出批次,验证生成 JSONL 文件;将批次提交微调服务,验证可查询到微调任务状态。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 管理员查看样本库,**当** 筛选已审批样本,**则** 只返回状态为"已审批"的样本(分页,不可无界查询)
|
||||||
|
2. **给定** 管理员选择若干已审批样本,**当** 创建导出批次,**则** 系统生成 JSONL 文件并存储,返回批次标识;若任意样本不处于已审批状态则整批失败
|
||||||
|
3. **给定** 导出批次已创建,**当** 管理员提交微调任务,**则** 系统向 AI 服务发起微调请求,记录微调任务标识,状态变为"进行中"
|
||||||
|
4. **给定** 微调任务已提交,**当** 管理员查询状态,**则** 返回最新的微调进度信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 用户故事 7 - 用户与权限管理 (优先级: P2)
|
||||||
|
|
||||||
|
管理员管理本公司用户,包括创建用户、分配角色、启用/禁用账号。角色变更和账号禁用在保存后立即生效,无延迟窗口。
|
||||||
|
|
||||||
|
**优先级理由**: 人员和权限管理是平台运营的基础管控能力。
|
||||||
|
|
||||||
|
**独立测试**: 可独立测试:创建一个标注员角色用户,验证该用户可以领取任务但无法执行审批;将其角色升为审批员,立即验证可以审批;禁用该用户,验证其现有会话立即失效。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 管理员创建一个新用户并分配角色,**当** 新用户登录,**则** 该用户拥有该角色对应的权限(高级角色自动包含低级角色权限)
|
||||||
|
2. **给定** 管理员将用户角色从标注员升为审批员,**当** 角色变更保存后,**则** 该用户无需重新登录即可使用审批功能
|
||||||
|
3. **给定** 管理员禁用某用户账号,**当** 被禁用用户下次发起请求,**则** 系统立即返回拒绝响应,不设过渡期
|
||||||
|
4. **给定** 管理员查询用户列表,**当** 获取结果,**则** 仅返回本公司用户,不可看到其他公司用户数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 用户故事 8 - 系统配置管理 (优先级: P3)
|
||||||
|
|
||||||
|
管理员维护 AI Prompt 模板、模型参数、Token 有效期等系统配置项,支持公司级配置覆盖全局默认值。
|
||||||
|
|
||||||
|
**优先级理由**: 配置管理是运营支撑能力,可在系统运行后按需调整,不影响核心标注流程。
|
||||||
|
|
||||||
|
**独立测试**: 可独立测试:修改某公司的 Prompt 模板配置,验证该公司后续标注使用新模板,其他公司仍使用全局默认值。
|
||||||
|
|
||||||
|
**验收场景**:
|
||||||
|
|
||||||
|
1. **给定** 管理员查看配置列表,**当** 获取结果,**则** 同时展示本公司专属配置和全局默认配置,公司专属配置对同一 Key 优先
|
||||||
|
2. **给定** 管理员更新某配置项,**当** 保存成功,**则** 后续相关操作立即使用新配置值
|
||||||
|
3. **给定** 某配置项仅有全局默认值无公司级覆盖,**当** 系统查询该配置,**则** 返回全局默认值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 边界情况
|
||||||
|
|
||||||
|
- 标注员领取任务后长时间未操作——管理员可强制转移任务给其他标注员(状态保持"进行中",持有人变更)
|
||||||
|
- 视频预处理回调因网络抖动发生重复投递——系统对同一任务的重复成功回调静默忽略,不重复创建标注任务
|
||||||
|
- 某租户上传量极大时的无界查询——所有列表接口强制分页,无法绕过分页限制获取全量数据
|
||||||
|
- 审批员同时兼任标注员角色时尝试自审——系统按提交者身份校验,自审请求被拒绝
|
||||||
|
- 跨公司数据访问尝试——每次数据查询自动注入当前用户所属公司标识,无法通过参数篡改访问其他公司数据
|
||||||
|
- 操作日志写入失败——审计写入失败不影响业务操作,仅记录错误并触发告警
|
||||||
|
- 同一账号在多设备登录——每次登录生成独立会话凭证,互不影响;退出某设备仅使该设备凭证失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求说明 *(必填)*
|
||||||
|
|
||||||
|
### 功能性需求
|
||||||
|
|
||||||
|
**认证与会话**
|
||||||
|
|
||||||
|
- **FR-001**: 系统必须支持基于用户名和密码的登录认证,验证通过后返回会话凭证
|
||||||
|
- **FR-002**: 系统必须在每次有效请求时自动延长会话有效期(滑动过期)
|
||||||
|
- **FR-003**: 系统必须支持主动退出登录,退出后凭证立即失效
|
||||||
|
- **FR-004**: 系统必须在管理员禁用账号后立即使该账号所有有效凭证失效,不设任何宽限期
|
||||||
|
- **FR-005**: 系统必须拒绝无凭证或过期凭证的请求,返回未授权响应
|
||||||
|
|
||||||
|
**访问控制**
|
||||||
|
|
||||||
|
- **FR-006**: 系统必须实现四级角色体系:上传员 ⊂ 标注员 ⊂ 审批员 ⊂ 管理员,高级角色自动继承低级角色权限
|
||||||
|
- **FR-007**: 系统必须在接口层声明每个接口所需的最低角色,角色不足时拒绝访问
|
||||||
|
- **FR-008**: 系统必须在角色变更保存后立即生效,无需等待会话自然过期
|
||||||
|
|
||||||
|
**多租户数据隔离**
|
||||||
|
|
||||||
|
- **FR-009**: 系统必须保证每个公司的数据完全隔离,任何查询均只返回当前用户所属公司的数据
|
||||||
|
- **FR-010**: 系统必须禁止调用方通过请求参数指定公司标识来访问其他公司数据;公司标识必须从服务端会话中获取
|
||||||
|
- **FR-011**: 全局系统配置对所有公司可见,公司级配置对同一配置项优先覆盖全局值
|
||||||
|
|
||||||
|
**资料管理**
|
||||||
|
|
||||||
|
- **FR-012**: 系统必须支持文本、图片、视频三种原始资料的上传,文件内容存储至对象存储服务,数据库只保存元数据和存储路径
|
||||||
|
- **FR-013**: 视频上传后必须触发异步预处理任务,不阻塞上传响应
|
||||||
|
- **FR-014**: 系统必须支持视频帧提取模式(每帧作为独立图片进入图片标注流水线)和视频片段转文本模式(派生文本资料进入文本标注流水线)
|
||||||
|
- **FR-015**: 视频片段转文本产生的派生资料必须记录对原始视频资料的引用,可追溯来源
|
||||||
|
|
||||||
|
**任务管理**
|
||||||
|
|
||||||
|
- **FR-016**: 系统必须支持并发安全的任务领取机制,确保同一任务不会被两名标注员同时持有
|
||||||
|
- **FR-017**: 系统必须支持任务放弃(退回任务池)和管理员强制转移任务归属
|
||||||
|
- **FR-018**: 每次任务状态变更必须记录历史快照(含操作人、操作时间、驳回原因等),不可修改或删除历史记录
|
||||||
|
- **FR-019**: 所有任务列表接口必须强制分页,不允许无界查询
|
||||||
|
|
||||||
|
**提取阶段标注工作台**
|
||||||
|
|
||||||
|
- **FR-020**: 系统必须调用 AI 服务生成候选提取结果供标注员参考编辑,不直接写入最终结果
|
||||||
|
- **FR-021**: 标注员提交的提取结果以整体替换方式存储,禁止局部追加修改
|
||||||
|
- **FR-022**: 审批员审批通过时,系统必须在同一操作中将提取结果标记为最终版并自动创建问答生成任务,该级联操作不得由前端发起独立请求触发
|
||||||
|
- **FR-023**: 系统必须拒绝提交者本人审批或驳回自己提交的任务(禁止自审)
|
||||||
|
- **FR-024**: 审批驳回时,标注员必须可以看到被驳回任务及驳回原因,并可重新领取修改后再次提交
|
||||||
|
|
||||||
|
**问答生成阶段**
|
||||||
|
|
||||||
|
- **FR-025**: 问答生成任务的标注结果采用整体替换,每次提交包含完整问答对列表
|
||||||
|
- **FR-026**: 问答生成阶段审批通过时,对应训练样本必须写入训练样本库,资料状态标记为"已完成"
|
||||||
|
- **FR-027**: 问答生成阶段审批驳回时,候选问答对记录必须被清除,标注员可重领任务重新生成
|
||||||
|
|
||||||
|
**训练数据导出**
|
||||||
|
|
||||||
|
- **FR-028**: 系统必须支持将已审批的训练样本批量导出为 GLM 微调格式,每条样本一行
|
||||||
|
- **FR-029**: 导出时若任意选定样本不处于已审批状态,整批导出请求必须失败
|
||||||
|
- **FR-030**: 系统必须支持将导出批次提交至外部 AI 微调服务,并可追踪微调任务进度
|
||||||
|
|
||||||
|
**审计日志**
|
||||||
|
|
||||||
|
- **FR-031**: 系统必须对所有状态变更操作自动记录审计日志,包含操作人姓名快照、操作类型、结果、IP 地址等信息
|
||||||
|
- **FR-032**: 审计日志只追加不修改,禁止对审计记录执行更新或删除
|
||||||
|
- **FR-033**: 审计日志写入失败不得导致业务操作失败或回滚
|
||||||
|
|
||||||
|
**视频异步处理**
|
||||||
|
|
||||||
|
- **FR-034**: 视频预处理任务必须支持自动重试,达到最大重试次数后置为失败状态,需管理员手动重新触发
|
||||||
|
- **FR-035**: AI 服务对同一视频处理任务的重复成功回调必须被幂等处理,不得重复创建标注任务
|
||||||
|
|
||||||
|
### 核心实体
|
||||||
|
|
||||||
|
- **公司(Company)**: 多租户根节点,每个公司拥有独立的用户、资料和任务数据空间
|
||||||
|
- **用户(User)**: 属于某公司,拥有角色(上传员/标注员/审批员/管理员),通过会话凭证访问系统
|
||||||
|
- **原始资料(SourceData)**: 待标注的文件(文本/图片/视频),拥有状态流转(待处理→提取中→QA审核中→已完成);视频派生资料通过父资料引用保留溯源链
|
||||||
|
- **标注任务(AnnotationTask)**: 标注工作单元,分提取阶段和问答生成阶段,拥有领取、提交、审批、驳回完整生命周期
|
||||||
|
- **标注结果(AnnotationResult)**: 提取阶段的结构化输出(三元组或四元组),以整体 JSON 存储
|
||||||
|
- **训练样本(TrainingDataset)**: 经审批的问答对,GLM 微调格式,待导出
|
||||||
|
- **导出批次(ExportBatch)**: 一批训练样本的导出记录,关联外部微调任务标识
|
||||||
|
- **视频处理任务(VideoProcessJob)**: 视频预处理的异步任务跟踪,包含重试计数和最终输出路径
|
||||||
|
- **系统配置(SysConfig)**: 配置键值对,分全局默认和公司级两层,公司级优先
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 成功标准 *(必填)*
|
||||||
|
|
||||||
|
### 可度量结果
|
||||||
|
|
||||||
|
- **SC-001**: 同一标注任务被多人同时争抢时,有且仅有一人领取成功,其余人立即收到明确的"已被领取"响应,成功率 100%,无数据竞争导致的双重持有
|
||||||
|
- **SC-002**: 管理员禁用账号或变更角色后,该账号的权限变更在下一次请求时立即生效(延迟小于 1 秒)
|
||||||
|
- **SC-003**: 提取阶段审批通过时,问答生成任务在同一次操作中自动出现在任务池,无需任何人工干预步骤
|
||||||
|
- **SC-004**: 视频预处理回调的重复投递(同一任务多次成功回调)不产生重复标注任务,幂等处理成功率 100%
|
||||||
|
- **SC-005**: 跨公司数据访问尝试 100% 被系统拒绝,无任何数据泄露至非所属租户
|
||||||
|
- **SC-006**: 审计日志对所有状态变更操作的覆盖率达到 100%,审计写入失败不影响业务成功率
|
||||||
|
- **SC-007**: 所有列表接口在数据量增长时保持稳定响应,用户无法绕过分页限制一次性获取不受限制数量的记录
|
||||||
|
- **SC-008**: 标注员完成一次任务领取→标注→提交的完整操作流程(不含 AI 辅助预标注等待时间)可在 5 分钟内完成
|
||||||
|
- **SC-009**: 从资料上传到训练样本进入样本库的完整流水线(含两次人工标注和两次审批)中,每个节点的操作人、时间、结果均可查询追溯
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 假设与前提
|
||||||
|
|
||||||
|
- 系统服务于多个公司,每家公司的用户、资料和标注数据完全独立,不存在跨公司协作场景
|
||||||
|
- 每位用户在同一时刻只属于一家公司,不存在用户跨公司兼职的场景
|
||||||
|
- 视频预处理(帧提取、转文字)由外部 AI 服务异步完成,后端只负责触发和回调处理
|
||||||
|
- 微调结果的质量评估不在本平台范围内,平台只负责提交微调任务并查询状态
|
||||||
|
- 前端应用已独立开发,本规格仅覆盖后端 API 能力
|
||||||
|
- 所有文件二进制内容存储在兼容 S3 协议的对象存储服务中,不存入关系型数据库
|
||||||
|
- 生产环境使用容器化部署,后端服务、数据库、缓存、对象存储均为独立容器
|
||||||
|
- AI 服务通过 HTTP 提供结构化的提取和问答生成能力,后端不内嵌 AI 模型
|
||||||
|
- 标注流水线中一条资料同一时间只有一个活跃的提取任务或问答生成任务,不支持并行多版本标注
|
||||||
|
- 审计日志的长期归档(超过月分区范围)由数据库运维团队负责,不在本系统范围内
|
||||||
310
specs/001-label-backend-spec/tasks.md
Normal file
310
specs/001-label-backend-spec/tasks.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# 任务清单:label_backend 知识图谱智能标注平台
|
||||||
|
|
||||||
|
**输入**: `/specs/001-label-backend-spec/` 全部设计文档
|
||||||
|
**前置条件**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/ ✅ | quickstart.md ✅
|
||||||
|
|
||||||
|
## 格式说明
|
||||||
|
|
||||||
|
- **[P]**: 可并行执行(不同文件,无未完成任务的依赖)
|
||||||
|
- **[USn]**: 对应 spec.md 中的用户故事编号
|
||||||
|
- 每条任务包含精确的文件路径
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 项目初始化
|
||||||
|
|
||||||
|
**目标**: 创建 Maven 项目骨架、基础配置和 Docker 环境
|
||||||
|
|
||||||
|
- [ ] T001 创建 Maven 项目骨架(`com.label` GroupId,`label-backend` ArtifactId,Java 17 编译目标)
|
||||||
|
- [ ] T002 配置 `pom.xml`(Spring Boot 3、Apache Shiro 1.13.x、MyBatis Plus 3.5.x、Spring Data Redis、AWS S3 SDK v2、Testcontainers、Lombok)
|
||||||
|
- [ ] T003 [P] 创建 `sql/init.sql`(按依赖顺序建全部 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;含所有索引和初始配置数据)
|
||||||
|
- [ ] T004 [P] 创建 `docker-compose.yml`(postgres、redis、rustfs、backend、ai-service、frontend 六个服务,含健康检查)和后端 `Dockerfile`(eclipse-temurin:17-jre-alpine)
|
||||||
|
- [ ] T005 创建 `src/main/resources/application.yml`(数据源、Redis、RustFS、AI 服务 base-url、Shiro 相关配置项)
|
||||||
|
|
||||||
|
**检查点**: Maven 编译通过(`mvn compile`),Docker Compose `up -d` 全部服务健康
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 公共基础设施(阻塞性前置条件)
|
||||||
|
|
||||||
|
**目标**: 所有业务模块依赖的公共组件。**必须全部完成后用户故事阶段才能开始**
|
||||||
|
|
||||||
|
**⚠️ 重要**: 此阶段未完成前任何用户故事均不可开始实现
|
||||||
|
|
||||||
|
- [ ] T006 创建 `Result<T>`、`ResultCode`、`PageResult<T>` — `src/main/java/com/label/common/result/`(统一响应格式:`{"code":"SUCCESS","data":{...}}`)
|
||||||
|
- [ ] T007 [P] 创建 `BusinessException`(含 `code`、`message`、`httpStatus`)和 `GlobalExceptionHandler`(`@RestControllerAdvice`)— `src/main/java/com/label/common/exception/`
|
||||||
|
- [ ] T008 [P] 创建 `CompanyContext`(ThreadLocal,含 `set/get/clear` 三个方法,clear 必须在 finally 块调用)— `src/main/java/com/label/common/context/CompanyContext.java`
|
||||||
|
- [ ] T009 创建 `RedisKeyManager`(三个静态方法:`tokenKey`、`userPermKey`、`taskClaimKey`)和 `RedisService` — `src/main/java/com/label/common/redis/`
|
||||||
|
- [ ] T010 创建 MyBatis Plus 配置类 `MybatisPlusConfig`,注册 `TenantLineInnerInterceptor`(从 `CompanyContext` 获取 `companyId` 自动注入 WHERE 子句;`sys_company`、`sys_config` 加入忽略表列表)— `src/main/java/com/label/common/config/MybatisPlusConfig.java`
|
||||||
|
- [ ] T011 创建 `StateValidator`(`assertTransition` 泛型方法,违规时抛出 `BusinessException("INVALID_STATE_TRANSITION",...)`)— `src/main/java/com/label/common/statemachine/StateValidator.java`
|
||||||
|
- [ ] T012 [P] 创建 `SourceStatus` 枚举(PENDING/PREPROCESSING/EXTRACTING/QA_REVIEW/APPROVED,含 TRANSITIONS Map)— `src/main/java/com/label/common/statemachine/SourceStatus.java`
|
||||||
|
- [ ] T013 [P] 创建 `TaskStatus` 枚举(UNCLAIMED/IN_PROGRESS/SUBMITTED/APPROVED/REJECTED,含 TRANSITIONS Map,含 IN_PROGRESS→IN_PROGRESS 用于 ADMIN 强制转移)— `src/main/java/com/label/common/statemachine/TaskStatus.java`
|
||||||
|
- [ ] T014 [P] 创建 `DatasetStatus` 枚举(PENDING_REVIEW/APPROVED/REJECTED,含 TRANSITIONS Map)— `src/main/java/com/label/common/statemachine/DatasetStatus.java`
|
||||||
|
- [ ] T015 [P] 创建 `VideoJobStatus` 枚举(PENDING/RUNNING/SUCCESS/FAILED/RETRYING,含 TRANSITIONS Map,注释说明 FAILED→PENDING 由 ADMIN 手动触发)— `src/main/java/com/label/common/statemachine/VideoJobStatus.java`
|
||||||
|
- [ ] T016 创建 `@OperationLog` 注解(`type` 和 `targetType` 两个属性,`@Around` 级别)— `src/main/java/com/label/common/aop/OperationLog.java`
|
||||||
|
- [ ] T017 创建 `AuditAspect`(`@Around("@annotation(operationLog)")`,在 finally 块以独立操作写入 `sys_operation_log`;审计写入失败只记录 error 日志,禁止抛出异常回滚业务)— `src/main/java/com/label/common/aop/AuditAspect.java`
|
||||||
|
- [ ] T018 [P] 创建 `RustFsClient`(AWS S3 SDK v2 封装,endpoint 指向 RustFS;实现 `upload`、`download`、`delete`、`getPresignedUrl`)— `src/main/java/com/label/common/storage/RustFsClient.java`
|
||||||
|
- [ ] T019 [P] 创建 `AiServiceClient`(`RestClient` 封装,8 个端点:`extractText`、`extractImage`、`extractFrames`、`videoToText`、`genTextQa`、`genImageQa`、`startFinetune`、`getFinetuneStatus`)— `src/main/java/com/label/common/ai/AiServiceClient.java`
|
||||||
|
- [ ] T020 创建 Shiro 三件套:`TokenFilter`(解析 `Authorization: Bearer {uuid}`,查 Redis `token:{uuid}`,注入 `CompanyContext`,请求结束 finally 清理 ThreadLocal)、`UserRealm`(先查 Redis `user:perm:{userId}` TTL 5min,未命中查 PG;含 `addInheritedRoles`)、`ShiroConfig`(过滤器链:`/api/auth/login` → `anon`,`/api/**` → `tokenFilter`)— `src/main/java/com/label/common/shiro/`
|
||||||
|
- [ ] T021 创建 `AbstractIntegrationTest`(Testcontainers,启动真实 PostgreSQL + Redis 容器,执行 sql/init.sql,注入测试用的公司和用户数据)— `src/test/java/com/label/AbstractIntegrationTest.java`
|
||||||
|
- [ ] T022 集成测试:`ShiroFilterIntegrationTest`(无 Token → 401;有效 Token 但角色不足 → 403;有效 Token 且角色满足 → 200)— `src/test/java/com/label/integration/ShiroFilterIntegrationTest.java`
|
||||||
|
- [ ] T023 单元测试:`StateMachineTest`(验证所有枚举的合法转换通过;非法转换抛出 `BusinessException("INVALID_STATE_TRANSITION")`)— `src/test/java/com/label/unit/StateMachineTest.java`
|
||||||
|
|
||||||
|
**检查点**: 基础设施就绪,所有 Phase 3+ 的用户故事可并行开始
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: 用户故事 1 — 用户登录与身份认证(优先级: P1)🎯 MVP
|
||||||
|
|
||||||
|
**目标**: 用户可以用用户名和密码登录,获得会话凭证,使用凭证访问受保护接口,退出后凭证立即失效
|
||||||
|
|
||||||
|
**独立测试**: 登录 → 获取 Token → 访问 `/api/auth/me` 返回用户信息 → 退出 → 再次访问返回 401
|
||||||
|
|
||||||
|
- [ ] T024 [P] [US1] 创建 `SysCompany` 实体(MyBatis Plus `@TableName`)和 `SysCompanyMapper` — `src/main/java/com/label/module/user/entity/SysCompany.java` + `mapper/SysCompanyMapper.java`
|
||||||
|
- [ ] T025 [P] [US1] 创建 `SysUser` 实体(`passwordHash` 字段加 `@JsonIgnore`)和 `SysUserMapper`(含 `selectByCompanyAndUsername` 方法)— `src/main/java/com/label/module/user/entity/SysUser.java` + `mapper/SysUserMapper.java`
|
||||||
|
- [ ] T026 [US1] 实现 `AuthService`:`login()`(BCrypt 校验密码 → UUID v4 Token → Redis Hash 存储 userId/role/companyId/username → 设置 TTL = `token_ttl_seconds` 配置值);`logout()`(删除 Redis Token Key)— `src/main/java/com/label/module/user/service/AuthService.java`
|
||||||
|
- [ ] T027 [US1] 实现 `AuthController`:`POST /api/auth/login`(`anon`,调用 `AuthService.login()`)、`POST /api/auth/logout`(已登录)、`GET /api/auth/me`(返回当前用户信息);所有响应用 `Result<T>` 包装 — `src/main/java/com/label/module/user/controller/AuthController.java`
|
||||||
|
- [ ] T028 [US1] 集成测试:正确密码登录返回 Token;Token 有效时 `/api/auth/me` 返回 200;主动退出后再访问返回 401;错误密码登录返回 401 — `src/test/java/com/label/integration/AuthIntegrationTest.java`
|
||||||
|
|
||||||
|
**检查点**: US1 独立可测试 — 登录/退出流程完整可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: 用户故事 2 — 原始资料上传(优先级: P1)
|
||||||
|
|
||||||
|
**目标**: 上传员可以上传文本/图片/视频,查询自己的资料列表;管理员可查看全公司资料
|
||||||
|
|
||||||
|
**独立测试**: 上传文本文件 → 列表查到 → 详情含预签名 URL → 管理员可删除
|
||||||
|
|
||||||
|
- [ ] T029 [P] [US2] 创建 `SourceData` 实体(含 `parentSourceId` 自引用字段)和 `SourceDataMapper`(含 `updateStatus` 方法)— `src/main/java/com/label/module/source/entity/SourceData.java` + `mapper/SourceDataMapper.java`
|
||||||
|
- [ ] T030 [US2] 实现 `SourceService`:`upload()`(先 insert 获取 ID → 构造路径 → 上传 RustFS → 更新 filePath);`list()`(UPLOADER 按 `uploaderId` 过滤,ADMIN 不过滤,强制分页);`findById()`(含 15 分钟预签名 URL);`delete()`(仅 PENDING 状态可删,同步删 RustFS 文件)— `src/main/java/com/label/module/source/service/SourceService.java`
|
||||||
|
- [ ] T031 [US2] 实现 `SourceController`(`POST /api/source/upload`、`GET /api/source/list`、`GET /api/source/{id}`、`DELETE /api/source/{id}`;`@RequiresRoles` 注解声明权限;所有响应 `Result<T>` 包装)— `src/main/java/com/label/module/source/controller/SourceController.java`
|
||||||
|
- [ ] T032 [US2] 集成测试:UPLOADER 上传文本/图片 → 列表仅返回自己的资料;ADMIN 查看列表返回全部;上传视频 → source_data 状态为 PENDING(视频预处理 Phase 9 覆盖);已进入流水线的资料删除返回 409 — `src/test/java/com/label/integration/SourceIntegrationTest.java`
|
||||||
|
|
||||||
|
**检查点**: US2 独立可测试 — 上传/查询/删除流程完整可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: 用户故事 3+4 — 提取阶段标注与审批(优先级: P1)
|
||||||
|
|
||||||
|
**目标**: 标注员可以领取任务(并发安全)、AI 辅助预标注、编辑并提交;审批员可以通过(自动触发 QA 任务)或驳回(标注员可重领)
|
||||||
|
|
||||||
|
**独立测试**: 创建任务 → 标注员领取 → AI 预标注 → 提交 → 审批通过 → QA 任务自动出现在任务池
|
||||||
|
|
||||||
|
### 实体与数据层
|
||||||
|
|
||||||
|
- [ ] T033 [P] [US3] 创建 `AnnotationTask` 实体 + `AnnotationTaskMapper`(含 `claimTask(taskId, userId, companyId)` 方法,SQL:`UPDATE ... SET status='IN_PROGRESS', claimed_by=?, claimed_at=NOW() WHERE id=? AND status='UNCLAIMED' AND company_id=?`,返回影响行数)— `src/main/java/com/label/module/task/entity/AnnotationTask.java` + `mapper/AnnotationTaskMapper.java`
|
||||||
|
- [ ] T034 [P] [US3] 创建 `AnnotationTaskHistory` 实体 + `TaskHistoryMapper` — `src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java` + `mapper/TaskHistoryMapper.java`
|
||||||
|
- [ ] T035 [P] [US3] 创建 `AnnotationResult` 实体 + `AnnotationResultMapper`(含 `updateResultJson` 整体覆盖方法和 `selectByTaskId` 方法)— `src/main/java/com/label/module/annotation/entity/AnnotationResult.java` + `mapper/AnnotationResultMapper.java`
|
||||||
|
|
||||||
|
### 任务管理服务与控制器
|
||||||
|
|
||||||
|
- [ ] T036 [US3] 实现 `TaskClaimService.claim()`(① Redis `SET NX task:claim:{taskId}` TTL 30s,失败抛 `TASK_CLAIMED`;② DB `claimTask()` 影响行数为 0 时抛 `TASK_CLAIMED`;③ `insertHistory(UNCLAIMED→IN_PROGRESS)`)和 `unclaim()`(StateValidator + 清 Redis 锁 + 历史)和 `reclaim()`(校验 REJECTED + claimedBy = 当前用户 + REJECTED→IN_PROGRESS + 历史)— `src/main/java/com/label/module/task/service/TaskClaimService.java`
|
||||||
|
- [ ] T037 [US3] 实现 `TaskService`(`createTask`、`getPool`(按角色过滤:ANNOTATOR→UNCLAIMED/EXTRACTION;REVIEWER→SUBMITTED)、`getMine`(含 IN_PROGRESS/SUBMITTED/REJECTED)、`getPendingReview`(SUBMITTED,分页)、`getById`、`reassign`(ADMIN,仅更新 claimedBy + 历史))— `src/main/java/com/label/module/task/service/TaskService.java`
|
||||||
|
- [ ] T038 [US3] 实现 `TaskController`(10 个端点:`POST /api/tasks`、`GET /api/tasks/pool`、`POST /api/tasks/{id}/claim`、`POST /api/tasks/{id}/unclaim`、`GET /api/tasks/mine`、`POST /api/tasks/{id}/reclaim`、`GET /api/tasks/pending-review`、`GET /api/tasks/{id}`、`GET /api/tasks`、`PUT /api/tasks/{id}/reassign`)— `src/main/java/com/label/module/task/controller/TaskController.java`
|
||||||
|
|
||||||
|
### 提取标注服务与控制器
|
||||||
|
|
||||||
|
- [ ] T039 [US3] 实现 `ExtractionService.aiPreAnnotate()`(调用 `AiServiceClient.extractText/extractImage`,写入 `annotation_result`)和 `updateResult()`(整体覆盖 `result_json`,校验 JSON 格式)— `src/main/java/com/label/module/annotation/service/ExtractionService.java`
|
||||||
|
- [ ] T040 [US3] 实现 `ExtractionService.submit()`(`@Transactional`:IN_PROGRESS→SUBMITTED + `submitted_at` + insertHistory)— `src/main/java/com/label/module/annotation/service/ExtractionService.java`
|
||||||
|
- [ ] T041 [US4] 创建 `ExtractionApprovedEvent`(携带 `taskId`、`sourceId`、`sourceType`、`companyId`)— `src/main/java/com/label/module/annotation/event/ExtractionApprovedEvent.java`
|
||||||
|
- [ ] T042 [US4] 实现 `ExtractionService.approve()`(`@Transactional`:① 自审校验;② `is_final=true`;③ SUBMITTED→APPROVED + `completedAt` + 历史;④ `publishEvent(ExtractionApprovedEvent)`;AI 调用禁止在此事务内执行)— `src/main/java/com/label/module/annotation/service/ExtractionService.java`
|
||||||
|
- [ ] T043 [US4] 实现 `ExtractionApprovedEventListener`(`@TransactionalEventListener(AFTER_COMMIT)` + `@Transactional(REQUIRES_NEW)`:调用 AI 生成候选问答对 → 写 `training_dataset`(PENDING_REVIEW)→ 创建 QA_GENERATION 任务(UNCLAIMED)→ `source_data` 状态→ QA_REVIEW)— `src/main/java/com/label/module/annotation/service/ExtractionApprovedEventListener.java`
|
||||||
|
- [ ] T044 [US4] 实现 `ExtractionService.reject()`(`@Transactional`:① 自审校验;② StateValidator;③ SUBMITTED→REJECTED + 历史)— `src/main/java/com/label/module/annotation/service/ExtractionService.java`
|
||||||
|
- [ ] T045 [US4] 实现 `ExtractionController`(5 个端点:`GET /api/extraction/{taskId}`、`PUT /api/extraction/{taskId}`、`POST /api/extraction/{taskId}/submit`、`POST /api/extraction/{taskId}/approve`、`POST /api/extraction/{taskId}/reject`)— `src/main/java/com/label/module/annotation/controller/ExtractionController.java`
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
|
||||||
|
- [ ] T046 [US3] 并发集成测试:10 个线程同时争抢同一 UNCLAIMED 任务,验证恰好 1 人成功、其余均收到 `TASK_CLAIMED` 错误、DB 中 `claimed_by` 唯一 — `src/test/java/com/label/integration/TaskClaimConcurrencyTest.java`
|
||||||
|
- [ ] T047 [US4] 集成测试:审批通过 → QA 任务自动出现在任务池;自审返回 `SELF_REVIEW_FORBIDDEN` 403;驳回后标注员可重领并再次提交 — `src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java`
|
||||||
|
|
||||||
|
**检查点**: US3+US4 独立可测试 — 完整提取流水线(领取→标注→提交→审批→QA任务自动创建)可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: 用户故事 5 — 问答生成阶段标注与审批(优先级: P2)
|
||||||
|
|
||||||
|
**目标**: 标注员领取 QA 任务、修改候选问答对并提交;审批员通过后训练样本入库,整条流水线完成
|
||||||
|
|
||||||
|
**独立测试**: 领取 QA 任务 → 修改问答对 → 提交 → 审批通过 → training_dataset 状态 APPROVED,source_data 状态 APPROVED
|
||||||
|
|
||||||
|
- [ ] T048 [P] [US5] 创建 `TrainingDataset` 实体 + `TrainingDatasetMapper`(含 `approveByTaskId`、`deleteByTaskId` 方法)— `src/main/java/com/label/module/annotation/entity/TrainingDataset.java` + `mapper/TrainingDatasetMapper.java`
|
||||||
|
- [ ] T049 [US5] 实现 `QaService.updateResult()`(整体覆盖问答对 JSONB)和 `submit()`(`@Transactional`:IN_PROGRESS→SUBMITTED + 历史)— `src/main/java/com/label/module/annotation/service/QaService.java`
|
||||||
|
- [ ] T050 [US5] 实现 `QaService.approve()`(`@Transactional`:① `validateAndGetTask` 先于一切 DB 写入;② 自审校验;③ `training_dataset` → APPROVED;④ `annotation_task` → APPROVED + 历史;⑤ `source_data` → APPROVED)— `src/main/java/com/label/module/annotation/service/QaService.java`
|
||||||
|
- [ ] T051 [US5] 实现 `QaService.reject()`(`@Transactional`:① 自审校验;② `deleteByTaskId` 清除候选问答对;③ SUBMITTED→REJECTED + 历史;④ `source_data` 保持 QA_REVIEW 不变)— `src/main/java/com/label/module/annotation/service/QaService.java`
|
||||||
|
- [ ] T052 [US5] 实现 `QaController`(5 个端点:`GET /api/qa/{taskId}`、`PUT /api/qa/{taskId}`、`POST /api/qa/{taskId}/submit`、`POST /api/qa/{taskId}/approve`、`POST /api/qa/{taskId}/reject`)— `src/main/java/com/label/module/annotation/controller/QaController.java`
|
||||||
|
- [ ] T053 [US5] 集成测试:QA 审批通过 → `training_dataset.status = APPROVED`,`source_data.status = APPROVED`;QA 驳回 → 候选记录被删除,标注员可重领 — `src/test/java/com/label/integration/QaApprovalIntegrationTest.java`
|
||||||
|
|
||||||
|
**检查点**: US5 独立可测试 — 完整 QA 流水线可用,training_dataset 产出验证通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: 用户故事 6 — 训练数据导出与微调提交(优先级: P2)
|
||||||
|
|
||||||
|
**目标**: 管理员将已审批样本批量导出为 JSONL,并可提交 GLM 微调任务
|
||||||
|
|
||||||
|
**独立测试**: 选取已审批样本 → 创建批次 → RustFS 中存在 JSONL 文件 → 提交微调 → 可查询状态
|
||||||
|
|
||||||
|
- [ ] T054 [P] [US6] 创建 `ExportBatch` 实体 + `ExportBatchMapper` — `src/main/java/com/label/module/export/entity/ExportBatch.java` + `mapper/ExportBatchMapper.java`
|
||||||
|
- [ ] T055 [US6] 实现 `ExportService.createBatch()`(`@Transactional`:① 校验全部样本为 APPROVED;② 生成 JSONL(每行一个 `glm_format_json`);③ 上传 RustFS `finetune-export/export/{batchUuid}.jsonl`;④ 批量更新 `export_batch_id`/`exported_at`;⑤ 插入 `export_batch` 记录)— `src/main/java/com/label/module/export/service/ExportService.java`
|
||||||
|
- [ ] T056 [US6] 实现 `FinetuneService`:`trigger()`(调用 `AiServiceClient.startFinetune()`,更新 `glm_job_id` 和 `finetune_status = RUNNING`)和 `getStatus()`(调用 `AiServiceClient.getFinetuneStatus()`)— `src/main/java/com/label/module/export/service/FinetuneService.java`
|
||||||
|
- [ ] T057 [US6] 实现 `ExportController`(`GET /api/training/samples`、`POST /api/export/batch`、`POST /api/export/{batchId}/finetune`、`GET /api/export/{batchId}/status`、`GET /api/export/list`;全部 `@RequiresRoles("ADMIN")`)— `src/main/java/com/label/module/export/controller/ExportController.java`
|
||||||
|
- [ ] T058 [US6] 集成测试:成功创建批次后 JSONL 文件存在于 RustFS;包含非 APPROVED 样本时返回 `INVALID_SAMPLES` 400 — `src/test/java/com/label/integration/ExportIntegrationTest.java`
|
||||||
|
|
||||||
|
**检查点**: US6 独立可测试 — 导出批次创建和微调提交流程可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: 用户故事 7 — 用户与权限管理(优先级: P2)
|
||||||
|
|
||||||
|
**目标**: 管理员可以创建用户、变更角色(立即生效)、禁用账号(立即失效)
|
||||||
|
|
||||||
|
**独立测试**: 创建标注员用户 → 验证其能领取任务 → 升为审批员 → 验证立即可以审批 → 禁用账号 → 已有 Token 立即失效
|
||||||
|
|
||||||
|
- [ ] T059 [US7] 实现 `UserService`:`createUser()`(BCrypt 哈希密码,强度因子 ≥ 10);`updateUser()`;`updateRole()`(DB 写入后立即 `redisTemplate.delete(userPermKey(userId))`);`updateStatus()`(禁用时删 Redis Token + 权限缓存)— `src/main/java/com/label/module/user/service/UserService.java`
|
||||||
|
- [ ] T060 [US7] 实现 `UserController`(`GET /api/users`、`POST /api/users`、`PUT /api/users/{id}`、`PUT /api/users/{id}/status`、`PUT /api/users/{id}/role`;全部 `@RequiresRoles("ADMIN")`)— `src/main/java/com/label/module/user/controller/UserController.java`
|
||||||
|
- [ ] T061 [US7] 集成测试:变更角色后权限下一次请求立即生效(无需重新登录);禁用账号后现有 Token 下一次请求立即返回 401 — `src/test/java/com/label/integration/UserManagementIntegrationTest.java`
|
||||||
|
|
||||||
|
**检查点**: US7 独立可测试 — 用户管理和即时权限变更可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: 用户故事 8 — 视频处理与系统配置(优先级: P3)
|
||||||
|
|
||||||
|
**目标**: 上传视频后触发异步预处理(帧提取/转文字);AI 回调幂等处理;管理员可配置 Prompt 模板等系统参数
|
||||||
|
|
||||||
|
**独立测试(视频)**: 上传视频 → 创建处理任务 → 模拟成功回调 → annotation_task 出现在任务池;重复成功回调 → 任务数量不增加
|
||||||
|
**独立测试(配置)**: 为公司设置专属 Prompt → 验证该公司使用新值;其他公司使用全局默认
|
||||||
|
|
||||||
|
- [ ] T062 [P] [US8] 创建 `VideoProcessJob` 实体 + `VideoProcessJobMapper` — `src/main/java/com/label/module/video/entity/VideoProcessJob.java` + `mapper/VideoProcessJobMapper.java`
|
||||||
|
- [ ] T063 [P] [US8] 创建 `SysConfig` 实体 + `SysConfigMapper`(含 `selectByCompanyAndKey(companyId, configKey)` 方法,支持 `companyId IS NULL` 查询)— `src/main/java/com/label/module/config/entity/SysConfig.java` + `mapper/SysConfigMapper.java`
|
||||||
|
- [ ] T064 [US8] 实现 `VideoProcessService`:`createJob()`(`@Transactional`:`source_data.status → PREPROCESSING` + 插入 job + 触发 AI 异步调用);`handleCallback()`(`@Transactional`:幂等检查 status==SUCCESS 则 return;成功 → SUCCESS + `source_data.status → PENDING`;失败 → 按 retry_count 决定 RETRYING 或 FAILED);`reset()`(FAILED → PENDING)— `src/main/java/com/label/module/video/service/VideoProcessService.java`
|
||||||
|
- [ ] T065 [US8] 实现 `VideoController`(`POST /api/video/process`、`GET /api/video/jobs/{jobId}`、`POST /api/video/jobs/{jobId}/reset`、`POST /api/video/callback`(内部接口,IP 白名单或服务密钥保护))— `src/main/java/com/label/module/video/controller/VideoController.java`
|
||||||
|
- [ ] T066 [US8] 实现 `SysConfigService.get(configKey)`(先按 `(companyId, key)` 查;未命中按 `(NULL, key)` 查全局默认)和 `update(key, value)`(UPSERT:公司级配置不存在则创建,存在则覆盖)— `src/main/java/com/label/module/config/service/SysConfigService.java`
|
||||||
|
- [ ] T067 [US8] 实现 `SysConfigController`(`GET /api/config`(合并公司级 + 全局,标注 scope)、`PUT /api/config/{key}`;均 `@RequiresRoles("ADMIN")`)— `src/main/java/com/label/module/config/controller/SysConfigController.java`
|
||||||
|
- [ ] T068 [US8] 集成测试:同一 jobId 两次成功回调,`annotation_task` 记录数为 1(幂等);达最大重试次数后 status = FAILED — `src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java`
|
||||||
|
- [ ] T069 [US8] 集成测试:公司级配置覆盖同 Key 的全局默认;其他公司读取全局默认 — `src/test/java/com/label/integration/SysConfigIntegrationTest.java`
|
||||||
|
|
||||||
|
**检查点**: US8 独立可测试 — 视频处理幂等和配置管理可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: 收尾与横切关注点
|
||||||
|
|
||||||
|
**目标**: 多租户隔离验证、整体合规检查、快速启动验证
|
||||||
|
|
||||||
|
- [ ] T070 集成测试:`MultiTenantIsolationTest`(公司 A 身份查询公司 B 的资料/任务 → 返回空列表或 404,不泄露数据)— `src/test/java/com/label/integration/MultiTenantIsolationTest.java`
|
||||||
|
- [ ] T071 [P] 代码审查:检查所有 Controller 方法返回值均为 `Result<T>` 或 `Result<PageResult<T>>`,无裸 POJO 或裸 List 返回
|
||||||
|
- [ ] T072 [P] 代码审查:检查所有列表查询方法均含分页参数(`page`/`pageSize`),无 `selectAll()` 或不分页的查询
|
||||||
|
- [ ] T073 [P] 代码审查:检查 `sys_operation_log` 相关代码,确认应用层零处 UPDATE 或 DELETE
|
||||||
|
- [ ] T074 [P] 代码审查:检查所有 `@Transactional` 方法内无 `AiServiceClient` 的同步 HTTP 调用(审批触发 AI 必须通过 `@TransactionalEventListener`)
|
||||||
|
- [ ] T075 运行 `quickstart.md` 端到端验证:`docker compose up -d` → 登录 → 上传文件 → 创建任务 → 领取 → 提交 → 审批通过 → 确认 QA 任务出现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖关系与执行顺序
|
||||||
|
|
||||||
|
### 阶段依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1(初始化)
|
||||||
|
↓
|
||||||
|
Phase 2(基础设施)[全部完成后解锁所有用户故事]
|
||||||
|
↓
|
||||||
|
Phase 3(US1 认证) ← 可与 Phase 4/5/6/7/8/9 并行
|
||||||
|
Phase 4(US2 上传) ← 依赖 Phase 2,独立于其他用户故事
|
||||||
|
Phase 5(US3+4 提取) ← 依赖 Phase 2(上传已有资料的集成测试依赖 US2)
|
||||||
|
Phase 6(US5 QA) ← 依赖 Phase 5 完成(QA 任务由提取审批自动创建)
|
||||||
|
Phase 7(US6 导出) ← 依赖 Phase 6 完成(需要 APPROVED 的 training_dataset)
|
||||||
|
Phase 8(US7 用户管理) ← 依赖 Phase 3(UserService 在 AuthService 基础上扩展)
|
||||||
|
Phase 9(US8 视频+配置) ← 依赖 Phase 2,其余独立
|
||||||
|
↓
|
||||||
|
Phase 10(收尾)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户故事间依赖
|
||||||
|
|
||||||
|
- **US1(认证)**: 仅依赖 Phase 2,完全独立
|
||||||
|
- **US2(上传)**: 仅依赖 Phase 2,完全独立
|
||||||
|
- **US3+4(提取)**: 依赖 Phase 2;集成测试中使用已上传资料需 US2
|
||||||
|
- **US5(QA)**: 依赖 US3+4(QA 任务来源于提取阶段审批通过的级联触发)
|
||||||
|
- **US6(导出)**: 依赖 US5(需要 APPROVED 状态的 training_dataset)
|
||||||
|
- **US7(用户管理)**: 依赖 US1(UserService 扩展 AuthService 的用户实体)
|
||||||
|
- **US8(视频+配置)**: 仅依赖 Phase 2
|
||||||
|
|
||||||
|
### 阶段内并行机会
|
||||||
|
|
||||||
|
- Phase 2:T007-T010、T012-T015、T018-T019 均可并行(独立文件)
|
||||||
|
- Phase 3:T024、T025 可并行(独立文件)
|
||||||
|
- Phase 5:T033、T034、T035 可并行(独立文件)
|
||||||
|
- Phase 9:T062、T063 可并行(独立文件)
|
||||||
|
- Phase 10:T071-T074 全部可并行(仅代码审查,无文件修改)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并行执行示例
|
||||||
|
|
||||||
|
### Phase 2 基础设施并行
|
||||||
|
|
||||||
|
```
|
||||||
|
同时启动:
|
||||||
|
任务: "创建 BusinessException、GlobalExceptionHandler — common/exception/" [T007]
|
||||||
|
任务: "创建 CompanyContext(ThreadLocal)— common/context/" [T008]
|
||||||
|
任务: "创建 RustFsClient — common/storage/" [T018]
|
||||||
|
任务: "创建 AiServiceClient — common/ai/" [T019]
|
||||||
|
任务: "创建 SourceStatus 枚举" [T012]
|
||||||
|
任务: "创建 TaskStatus 枚举" [T013]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5 提取阶段并行
|
||||||
|
|
||||||
|
```
|
||||||
|
同时启动(实体/Mapper):
|
||||||
|
任务: "创建 AnnotationTask 实体 + Mapper" [T033]
|
||||||
|
任务: "创建 AnnotationTaskHistory 实体 + Mapper" [T034]
|
||||||
|
任务: "创建 AnnotationResult 实体 + Mapper" [T035]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施策略
|
||||||
|
|
||||||
|
### MVP 优先(仅用户故事 1)
|
||||||
|
|
||||||
|
1. 完成 Phase 1(初始化)
|
||||||
|
2. 完成 Phase 2(基础设施)— **关键,阻塞所有故事**
|
||||||
|
3. 完成 Phase 3(US1 认证)
|
||||||
|
4. **停止并验证**: 登录/退出/权限校验全流程可用
|
||||||
|
5. 可以独立部署演示认证功能
|
||||||
|
|
||||||
|
### 增量交付
|
||||||
|
|
||||||
|
1. Phase 1 + Phase 2 → 基础就绪
|
||||||
|
2. Phase 3(US1)→ 验证 → 演示(MVP)
|
||||||
|
3. Phase 4(US2)→ 验证 → 演示(上传功能)
|
||||||
|
4. Phase 5(US3+4)→ 验证 → 演示(标注流程)
|
||||||
|
5. Phase 6(US5)→ 验证 → 演示(完整双阶段流水线)
|
||||||
|
6. Phase 7(US6)→ 验证 → 演示(训练数据产出)
|
||||||
|
7. Phase 8+9 → 验证 → 演示(完整平台)
|
||||||
|
8. Phase 10 → 收尾
|
||||||
|
|
||||||
|
### 多人协作策略
|
||||||
|
|
||||||
|
Phase 2 完成后:
|
||||||
|
- 开发者 A:Phase 3(US1 认证)+ Phase 8(US7 用户管理)
|
||||||
|
- 开发者 B:Phase 4(US2 上传)+ Phase 5(US3+4 提取)
|
||||||
|
- 开发者 C:Phase 9(US8 视频+配置)
|
||||||
|
|
||||||
|
Phase 5 完成后:
|
||||||
|
- 开发者 A/B 合力:Phase 6(US5 QA)→ Phase 7(US6 导出)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- `[P]` 任务 = 不同文件,无依赖,可并行
|
||||||
|
- `[USn]` 标签将任务映射到具体用户故事,便于追踪
|
||||||
|
- 每个用户故事应独立可完成和可测试
|
||||||
|
- 每完成一个阶段后提交 git commit
|
||||||
|
- 在每个检查点停下来独立验证该用户故事
|
||||||
|
- 避免:模糊任务、同文件并发冲突、破坏独立性的跨故事依赖
|
||||||
332
sql/init.sql
Normal file
332
sql/init.sql
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
-- 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;
|
||||||
48
src/main/assembly/distribution.xml
Normal file
48
src/main/assembly/distribution.xml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0
|
||||||
|
https://maven.apache.org/xsd/assembly-2.1.0.xsd">
|
||||||
|
<id>dist</id>
|
||||||
|
<formats>
|
||||||
|
<format>zip</format>
|
||||||
|
<format>tar.gz</format>
|
||||||
|
</formats>
|
||||||
|
<includeBaseDirectory>true</includeBaseDirectory>
|
||||||
|
|
||||||
|
<!-- bin/start.sh(0755 可执行) -->
|
||||||
|
<files>
|
||||||
|
<file>
|
||||||
|
<source>src/main/scripts/start.sh</source>
|
||||||
|
<outputDirectory>bin</outputDirectory>
|
||||||
|
<fileMode>0755</fileMode>
|
||||||
|
</file>
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<fileSets>
|
||||||
|
<!-- etc/:application.yml + logback.xml -->
|
||||||
|
<fileSet>
|
||||||
|
<directory>src/main/resources</directory>
|
||||||
|
<outputDirectory>etc</outputDirectory>
|
||||||
|
<includes>
|
||||||
|
<include>application.yml</include>
|
||||||
|
<include>logback.xml</include>
|
||||||
|
</includes>
|
||||||
|
</fileSet>
|
||||||
|
|
||||||
|
<!-- libs/:薄 jar + 所有运行时依赖 -->
|
||||||
|
<fileSet>
|
||||||
|
<directory>${project.build.directory}/libs</directory>
|
||||||
|
<outputDirectory>libs</outputDirectory>
|
||||||
|
<includes>
|
||||||
|
<include>**/*.jar</include>
|
||||||
|
</includes>
|
||||||
|
</fileSet>
|
||||||
|
|
||||||
|
<!-- logs/ 空目录占位 -->
|
||||||
|
<fileSet>
|
||||||
|
<directory>src/main/assembly/empty-logs</directory>
|
||||||
|
<outputDirectory>logs</outputDirectory>
|
||||||
|
</fileSet>
|
||||||
|
</fileSets>
|
||||||
|
|
||||||
|
</assembly>
|
||||||
0
src/main/assembly/empty-logs/.gitkeep
Normal file
0
src/main/assembly/empty-logs/.gitkeep
Normal file
27
src/main/java/com/label/LabelBackendApplication.java
Normal file
27
src/main/java/com/label/LabelBackendApplication.java
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
package com.label;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用入口。
|
||||||
|
*
|
||||||
|
* 排除 Shiro Web 自动配置(ShiroWebAutoConfiguration、ShiroWebFilterConfiguration、
|
||||||
|
* ShiroWebMvcAutoConfiguration),避免其依赖的 ShiroFilter(javax.servlet.Filter) 与
|
||||||
|
* Spring Boot 3. 的 jakarta.servlet 命名空间冲突。 认证/ 授权逻辑改由
|
||||||
|
* TokenFilter(OncePerRequestFilter)+ ShiroConfig 手动装配。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// (excludeName = {
|
||||||
|
|
||||||
|
// "org.apache.shiro.spring.config.web.autoconfigure.ShiroWebAutoConfiguration",
|
||||||
|
// "org.apache.shiro.spring.config.web.autoconfigure.ShiroWebFilterConfiguration",
|
||||||
|
// "org.apache.shiro.spring.config.web.autoconfigure.ShiroWebMvcAutoConfiguration" })
|
||||||
|
@SpringBootApplication
|
||||||
|
public class LabelBackendApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(LabelBackendApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/main/java/com/label/common/ai/AiServiceClient.java
Normal file
116
src/main/java/com/label/common/ai/AiServiceClient.java
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package com.label.common.ai;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AiServiceClient {
|
||||||
|
|
||||||
|
@Value("${ai-service.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
@Value("${ai-service.timeout:30000}")
|
||||||
|
private int timeoutMs;
|
||||||
|
|
||||||
|
private RestClient restClient;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
restClient = RestClient.builder().baseUrl(baseUrl).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTO classes
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class ExtractionRequest {
|
||||||
|
private Long sourceId;
|
||||||
|
private String filePath;
|
||||||
|
private String bucket;
|
||||||
|
private String model;
|
||||||
|
private String prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
private String filePath;
|
||||||
|
private String bucket;
|
||||||
|
private Map<String, Object> params; // frameInterval, mode etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class QaGenResponse {
|
||||||
|
private List<Map<String, Object>> qaPairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class FinetuneRequest {
|
||||||
|
private String datasetPath; // RustFS path to JSONL file
|
||||||
|
private String model;
|
||||||
|
private Long batchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class FinetuneResponse {
|
||||||
|
private String jobId;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class FinetuneStatusResponse {
|
||||||
|
private String jobId;
|
||||||
|
private String status; // PENDING/RUNNING/COMPLETED/FAILED
|
||||||
|
private Integer progress; // 0-100
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 8 endpoints:
|
||||||
|
|
||||||
|
public ExtractionResponse extractText(ExtractionRequest request) {
|
||||||
|
return restClient.post().uri("/extract/text").body(request).retrieve().body(ExtractionResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExtractionResponse extractImage(ExtractionRequest request) {
|
||||||
|
return restClient.post().uri("/extract/image").body(request).retrieve().body(ExtractionResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void extractFrames(VideoProcessRequest request) {
|
||||||
|
restClient.post().uri("/video/extract-frames").body(request).retrieve().toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void videoToText(VideoProcessRequest request) {
|
||||||
|
restClient.post().uri("/video/to-text").body(request).retrieve().toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
public QaGenResponse genTextQa(ExtractionRequest request) {
|
||||||
|
return restClient.post().uri("/qa/gen-text").body(request).retrieve().body(QaGenResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public QaGenResponse genImageQa(ExtractionRequest request) {
|
||||||
|
return restClient.post().uri("/qa/gen-image").body(request).retrieve().body(QaGenResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinetuneResponse startFinetune(FinetuneRequest request) {
|
||||||
|
return restClient.post().uri("/finetune/start").body(request).retrieve().body(FinetuneResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinetuneStatusResponse getFinetuneStatus(String jobId) {
|
||||||
|
return restClient.get().uri("/finetune/status/{jobId}", jobId).retrieve().body(FinetuneStatusResponse.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/main/java/com/label/common/aop/AuditAspect.java
Normal file
75
src/main/java/com/label/common/aop/AuditAspect.java
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package com.label.common.aop;
|
||||||
|
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AOP aspect for audit logging.
|
||||||
|
*
|
||||||
|
* KEY DESIGN DECISIONS:
|
||||||
|
* 1. Uses JdbcTemplate directly (not MyBatis Mapper) to bypass TenantLineInnerInterceptor
|
||||||
|
* — operation logs need to capture company_id explicitly, not via thread-local injection
|
||||||
|
* 2. Written in finally block — audit log is written regardless of business method success/failure
|
||||||
|
* 3. Audit failures are logged as ERROR but NEVER rethrown — business transactions must not be
|
||||||
|
* affected by audit failures
|
||||||
|
* 4. Captures result of business method to log SUCCESS or FAILURE
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditAspect {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Around("@annotation(operationLog)")
|
||||||
|
public Object audit(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable {
|
||||||
|
Long companyId = CompanyContext.get();
|
||||||
|
// operator_id can be obtained from SecurityContext or ThreadLocal in the future
|
||||||
|
// For now, use null as a safe default when not available
|
||||||
|
Long operatorId = null;
|
||||||
|
|
||||||
|
String result = "SUCCESS";
|
||||||
|
String errorMessage = null;
|
||||||
|
Object returnValue = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
returnValue = joinPoint.proceed();
|
||||||
|
} catch (Throwable e) {
|
||||||
|
result = "FAILURE";
|
||||||
|
errorMessage = e.getMessage();
|
||||||
|
throw e; // Always rethrow business exceptions
|
||||||
|
} finally {
|
||||||
|
// Write audit log in finally block — runs regardless of success or failure
|
||||||
|
// CRITICAL: Never throw from here — would swallow the original exception
|
||||||
|
try {
|
||||||
|
writeAuditLog(companyId, operatorId, operationLog.type(),
|
||||||
|
operationLog.targetType(), result, errorMessage);
|
||||||
|
} catch (Exception auditEx) {
|
||||||
|
// Audit failure must NOT affect business transaction
|
||||||
|
log.error("审计日志写入失败: type={}, error={}",
|
||||||
|
operationLog.type(), auditEx.getMessage(), auditEx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeAuditLog(Long companyId, Long operatorId, String operationType,
|
||||||
|
String targetType, String result, String errorMessage) {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO sys_operation_log
|
||||||
|
(company_id, operator_id, operation_type, target_type, result, error_message, operated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
""";
|
||||||
|
jdbcTemplate.update(sql, companyId, operatorId, operationType,
|
||||||
|
targetType.isEmpty() ? null : targetType,
|
||||||
|
result, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/main/java/com/label/common/aop/OperationLog.java
Normal file
18
src/main/java/com/label/common/aop/OperationLog.java
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.label.common.aop;
|
||||||
|
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a method for audit logging.
|
||||||
|
* The AuditAspect intercepts this annotation and writes to sys_operation_log.
|
||||||
|
*/
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
public @interface OperationLog {
|
||||||
|
/** Operation type, e.g., "EXTRACTION_APPROVE", "USER_LOGIN", "TASK_CLAIM" */
|
||||||
|
String type();
|
||||||
|
|
||||||
|
/** Target entity type, e.g., "annotation_task", "sys_user" */
|
||||||
|
String targetType() default "";
|
||||||
|
}
|
||||||
58
src/main/java/com/label/common/config/MybatisPlusConfig.java
Normal file
58
src/main/java/com/label/common/config/MybatisPlusConfig.java
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package com.label.common.config;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
|
import net.sf.jsqlparser.expression.LongValue;
|
||||||
|
import net.sf.jsqlparser.expression.NullValue;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class MybatisPlusConfig {
|
||||||
|
|
||||||
|
// Tables that do NOT need tenant isolation (either global or tenant root tables)
|
||||||
|
private static final List<String> IGNORED_TABLES = Arrays.asList(
|
||||||
|
"sys_company", // the tenant root table itself
|
||||||
|
"sys_config", // has company_id=NULL for global defaults; service handles this manually
|
||||||
|
"video_process_job" // accessed by unauthenticated callback endpoint; service validates companyId manually
|
||||||
|
);
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
|
||||||
|
// 1. Tenant isolation - auto-injects WHERE company_id = ?
|
||||||
|
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||||
|
@Override
|
||||||
|
public Expression getTenantId() {
|
||||||
|
Long companyId = CompanyContext.get();
|
||||||
|
if (companyId == null) {
|
||||||
|
return new NullValue();
|
||||||
|
}
|
||||||
|
return new LongValue(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getTenantIdColumn() {
|
||||||
|
return "company_id";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean ignoreTable(String tableName) {
|
||||||
|
return IGNORED_TABLES.contains(tableName);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Pagination interceptor (required for MyBatis Plus Page queries)
|
||||||
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||||
|
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/label/common/config/OpenApiConfig.java
Normal file
33
src/main/java/com/label/common/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.label.common.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpringDoc OpenAPI 全局配置:API 基本信息 + Bearer Token 安全方案。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("Label Backend API")
|
||||||
|
.version("1.0.0")
|
||||||
|
.description("知识图谱智能标注平台后端接口文档"))
|
||||||
|
.addSecurityItem(new SecurityRequirement().addList("BearerAuth"))
|
||||||
|
.components(new Components()
|
||||||
|
.addSecuritySchemes("BearerAuth",
|
||||||
|
new SecurityScheme()
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.scheme("bearer")
|
||||||
|
.bearerFormat("UUID")
|
||||||
|
.description("登录后返回的 Token,格式:Bearer {uuid}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/main/java/com/label/common/config/RedisConfig.java
Normal file
24
src/main/java/com/label/common/config/RedisConfig.java
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.label.common.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(connectionFactory);
|
||||||
|
StringRedisSerializer serializer = new StringRedisSerializer();
|
||||||
|
template.setKeySerializer(serializer);
|
||||||
|
template.setValueSerializer(serializer);
|
||||||
|
template.setHashKeySerializer(serializer);
|
||||||
|
template.setHashValueSerializer(serializer);
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/main/java/com/label/common/context/CompanyContext.java
Normal file
24
src/main/java/com/label/common/context/CompanyContext.java
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.label.common.context;
|
||||||
|
|
||||||
|
public class CompanyContext {
|
||||||
|
private static final ThreadLocal<Long> COMPANY_ID = new ThreadLocal<>().withInitial(() -> -1L);
|
||||||
|
|
||||||
|
public static void set(Long companyId) {
|
||||||
|
COMPANY_ID.set(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long get() {
|
||||||
|
if (COMPANY_ID.get() == null) {
|
||||||
|
throw new IllegalStateException("Company ID not set");
|
||||||
|
}
|
||||||
|
return COMPANY_ID.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
COMPANY_ID.remove(); // Use remove() not set(null) to prevent memory leaks
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompanyContext() { // Prevent instantiation
|
||||||
|
throw new UnsupportedOperationException("Utility class");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.label.common.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
private final String code;
|
||||||
|
private final HttpStatus httpStatus;
|
||||||
|
|
||||||
|
public BusinessException(String code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.httpStatus = HttpStatus.BAD_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(String code, String message, HttpStatus httpStatus) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.label.common.exception;
|
||||||
|
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.shiro.authz.AuthorizationException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ResponseEntity<Result<?>> handleBusinessException(BusinessException e) {
|
||||||
|
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
|
||||||
|
return ResponseEntity
|
||||||
|
.status(e.getHttpStatus())
|
||||||
|
.body(Result.failure(e.getCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 Shiro 权限不足异常(@RequiresRoles / subject.checkRole() 抛出)→ 403
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AuthorizationException.class)
|
||||||
|
public ResponseEntity<Result<?>> handleAuthorizationException(AuthorizationException e) {
|
||||||
|
log.warn("权限不足: {}", e.getMessage());
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(Result.failure("FORBIDDEN", "权限不足"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Result<?>> handleException(Exception e) {
|
||||||
|
log.error("系统异常", e);
|
||||||
|
return ResponseEntity
|
||||||
|
.internalServerError()
|
||||||
|
.body(Result.failure("INTERNAL_ERROR", "系统内部错误"));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/main/java/com/label/common/redis/RedisKeyManager.java
Normal file
30
src/main/java/com/label/common/redis/RedisKeyManager.java
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package com.label.common.redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized Redis key naming conventions.
|
||||||
|
* All keys follow the pattern: prefix:{id}
|
||||||
|
*/
|
||||||
|
public final class RedisKeyManager {
|
||||||
|
|
||||||
|
private RedisKeyManager() {}
|
||||||
|
|
||||||
|
/** Session token key: token:{uuid} */
|
||||||
|
public static String tokenKey(String uuid) {
|
||||||
|
return "token:" + uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User permission cache key: user:perm:{userId} */
|
||||||
|
public static String userPermKey(Long userId) {
|
||||||
|
return "user:perm:" + userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Task claim distributed lock key: task:claim:{taskId} */
|
||||||
|
public static String taskClaimKey(Long taskId) {
|
||||||
|
return "task:claim:" + taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User active sessions set key: user:sessions:{userId} */
|
||||||
|
public static String userSessionsKey(Long userId) {
|
||||||
|
return "user:sessions:" + userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/main/java/com/label/common/redis/RedisService.java
Normal file
84
src/main/java/com/label/common/redis/RedisService.java
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package com.label.common.redis;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RedisService {
|
||||||
|
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
|
||||||
|
// String operations
|
||||||
|
|
||||||
|
public void set(String key, String value, long ttlSeconds) {
|
||||||
|
redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String get(String key) {
|
||||||
|
return redisTemplate.opsForValue().get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String key) {
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean exists(String key) {
|
||||||
|
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set if absent (NX). Returns true if key was set (lock acquired). */
|
||||||
|
public boolean setIfAbsent(String key, String value, long ttlSeconds) {
|
||||||
|
Boolean result = redisTemplate.opsForValue()
|
||||||
|
.setIfAbsent(key, value, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
return Boolean.TRUE.equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh TTL on an existing key (sliding expiration). */
|
||||||
|
public void expire(String key, long ttlSeconds) {
|
||||||
|
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash operations (for token storage: token:{uuid} → Hash)
|
||||||
|
|
||||||
|
public void hSetAll(String key, Map<String, String> entries, long ttlSeconds) {
|
||||||
|
redisTemplate.opsForHash().putAll(key, entries);
|
||||||
|
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Object, Object> hGetAll(String key) {
|
||||||
|
return redisTemplate.opsForHash().entries(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String hGet(String key, String field) {
|
||||||
|
Object val = redisTemplate.opsForHash().get(key, field);
|
||||||
|
return val != null ? val.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 Hash 中的单个字段(不改变其他字段和 TTL)。 */
|
||||||
|
public void hPut(String key, String field, String value) {
|
||||||
|
redisTemplate.opsForHash().put(key, field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set operations(用于用户会话跟踪)
|
||||||
|
|
||||||
|
/** 向 Set 添加成员。 */
|
||||||
|
public void sAdd(String key, String member) {
|
||||||
|
redisTemplate.opsForSet().add(key, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Set 移除成员。 */
|
||||||
|
public void sRemove(String key, String member) {
|
||||||
|
redisTemplate.opsForSet().remove(key, (Object) member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 Set 全部成员;Set 不存在时返回空集合。 */
|
||||||
|
public java.util.Set<String> sMembers(String key) {
|
||||||
|
java.util.Set<String> members = redisTemplate.opsForSet().members(key);
|
||||||
|
return members != null ? members : java.util.Collections.emptySet();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main/java/com/label/common/result/PageResult.java
Normal file
22
src/main/java/com/label/common/result/PageResult.java
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package com.label.common.result;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PageResult<T> {
|
||||||
|
private List<T> items;
|
||||||
|
private long total;
|
||||||
|
private int page;
|
||||||
|
private int pageSize;
|
||||||
|
|
||||||
|
public static <T> PageResult<T> of(List<T> items, long total, int page, int pageSize) {
|
||||||
|
PageResult<T> pageResult = new PageResult<>();
|
||||||
|
pageResult.setItems(items);
|
||||||
|
pageResult.setTotal(total);
|
||||||
|
pageResult.setPage(page);
|
||||||
|
pageResult.setPageSize(pageSize);
|
||||||
|
return pageResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/main/java/com/label/common/result/Result.java
Normal file
37
src/main/java/com/label/common/result/Result.java
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package com.label.common.result;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class Result<T> {
|
||||||
|
private String code;
|
||||||
|
private T data;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
public static <T> Result<T> success(T data) {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(ResultCode.SUCCESS.name());
|
||||||
|
result.setData(data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> success() {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(ResultCode.SUCCESS.name());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> failure(ResultCode code, String message) {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(code.name());
|
||||||
|
result.setMessage(message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> failure(String code, String message) {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(code);
|
||||||
|
result.setMessage(message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main/java/com/label/common/result/ResultCode.java
Normal file
19
src/main/java/com/label/common/result/ResultCode.java
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package com.label.common.result;
|
||||||
|
|
||||||
|
public enum ResultCode {
|
||||||
|
SUCCESS,
|
||||||
|
FAILURE,
|
||||||
|
UNAUTHORIZED, // 401 - no valid token
|
||||||
|
FORBIDDEN, // 403 - insufficient role
|
||||||
|
NOT_FOUND, // 404
|
||||||
|
CONFLICT, // 409
|
||||||
|
INVALID_STATE, // 409 state machine violation
|
||||||
|
TASK_CLAIMED, // 409 task already claimed
|
||||||
|
SELF_REVIEW_FORBIDDEN, // 403 self-review prevention
|
||||||
|
UNKNOWN_CONFIG_KEY, // 400 unknown config key
|
||||||
|
INVALID_SAMPLES, // 400 invalid export samples
|
||||||
|
EMPTY_SAMPLES, // 400 empty sample list
|
||||||
|
FINETUNE_ALREADY_STARTED, // 409 fine-tune already started
|
||||||
|
INVALID_STATE_TRANSITION, // 409 invalid state machine transition
|
||||||
|
INTERNAL_ERROR // 500
|
||||||
|
}
|
||||||
26
src/main/java/com/label/common/shiro/BearerToken.java
Normal file
26
src/main/java/com/label/common/shiro/BearerToken.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.label.common.shiro;
|
||||||
|
|
||||||
|
import org.apache.shiro.authc.AuthenticationToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shiro AuthenticationToken wrapper for Bearer token strings.
|
||||||
|
*/
|
||||||
|
public class BearerToken implements AuthenticationToken {
|
||||||
|
private final String token;
|
||||||
|
private final TokenPrincipal principal;
|
||||||
|
|
||||||
|
public BearerToken(String token, TokenPrincipal principal) {
|
||||||
|
this.token = token;
|
||||||
|
this.principal = principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getPrincipal() {
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getCredentials() {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/main/java/com/label/common/shiro/ShiroConfig.java
Normal file
64
src/main/java/com/label/common/shiro/ShiroConfig.java
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package com.label.common.shiro;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.apache.shiro.SecurityUtils;
|
||||||
|
import org.apache.shiro.mgt.SecurityManager;
|
||||||
|
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.redis.RedisService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shiro 安全配置。
|
||||||
|
*
|
||||||
|
* 设计说明:
|
||||||
|
* - 使用 Spring 的 FilterRegistrationBean 注册 TokenFilter(jakarta.servlet),
|
||||||
|
* 替代 Shiro 的 ShiroFilterFactoryBean(javax.servlet),避免 Shiro 1.x 与
|
||||||
|
* Spring Boot 3.x 之间的 javax/jakarta 命名空间冲突。
|
||||||
|
* - URL 路由逻辑内聚于 TokenFilter.shouldNotFilter():
|
||||||
|
* /api/auth/login → 跳过(公开)
|
||||||
|
* 非 /api/ 路径 → 跳过(公开)
|
||||||
|
* /api/** → 强制校验 Bearer Token
|
||||||
|
* - SecurityUtils.setSecurityManager() 必须在此处调用,
|
||||||
|
* 以便 @RequiresRoles 等 AOP 注解和 SecurityUtils.getSubject() 可正常工作。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ShiroConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public UserRealm userRealm(RedisService redisService) {
|
||||||
|
return new UserRealm(redisService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityManager securityManager(UserRealm userRealm) {
|
||||||
|
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
|
||||||
|
manager.setRealms(List.of(userRealm));
|
||||||
|
// 设置全局 SecurityManager,使 SecurityUtils.getSubject() 及 AOP 注解可用
|
||||||
|
SecurityUtils.setSecurityManager(manager);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TokenFilter tokenFilter(RedisService redisService, ObjectMapper objectMapper) {
|
||||||
|
return new TokenFilter(redisService, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 TokenFilter 注册为 Servlet 过滤器,覆盖所有路径。
|
||||||
|
* 实际的路径过滤逻辑由 TokenFilter.shouldNotFilter() 控制。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<TokenFilter> tokenFilterRegistration(TokenFilter tokenFilter) {
|
||||||
|
FilterRegistrationBean<TokenFilter> registration = new FilterRegistrationBean<>();
|
||||||
|
registration.setFilter(tokenFilter);
|
||||||
|
registration.addUrlPatterns("/*");
|
||||||
|
registration.setOrder(1);
|
||||||
|
registration.setName("tokenFilter");
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/main/java/com/label/common/shiro/TokenFilter.java
Normal file
139
src/main/java/com/label/common/shiro/TokenFilter.java
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package com.label.common.shiro;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.apache.shiro.SecurityUtils;
|
||||||
|
import org.apache.shiro.util.ThreadContext;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import com.label.common.redis.RedisKeyManager;
|
||||||
|
import com.label.common.redis.RedisService;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT-style Bearer Token 过滤器。
|
||||||
|
* 继承 Spring 的 OncePerRequestFilter(jakarta.servlet),避免与 Shiro 1.x
|
||||||
|
* 的 PathMatchingFilter(javax.servlet)产生命名空间冲突。
|
||||||
|
*
|
||||||
|
* 过滤逻辑:
|
||||||
|
* - 跳过非 /api/ 路径和 /api/auth/login(公开端点)
|
||||||
|
* - 解析 "Authorization: Bearer {uuid}",查询 Redis Hash token:{uuid}
|
||||||
|
* - Token 存在 → 注入 CompanyContext,登录 Shiro Subject,继续请求链路
|
||||||
|
* - Token 缺失或过期 → 直接返回 401
|
||||||
|
* - finally 块中清除 CompanyContext 和 ThreadContext Subject,防止线程池串漏
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TokenFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final RedisService redisService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${shiro.auth.enabled:true}")
|
||||||
|
private boolean authEnabled;
|
||||||
|
|
||||||
|
@Value("${shiro.auth.mock-company-id:1}")
|
||||||
|
private Long mockCompanyId;
|
||||||
|
|
||||||
|
@Value("${shiro.auth.mock-user-id:1}")
|
||||||
|
private Long mockUserId;
|
||||||
|
|
||||||
|
@Value("${shiro.auth.mock-role:ADMIN}")
|
||||||
|
private String mockRole;
|
||||||
|
|
||||||
|
@Value("${shiro.auth.mock-username:mock}")
|
||||||
|
private String mockUsername;
|
||||||
|
|
||||||
|
@Value("${token.ttl-seconds:7200}")
|
||||||
|
private long tokenTtlSeconds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开端点跳过过滤:非 /api/ 前缀路径,以及登录接口本身。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String path = request.getServletPath();
|
||||||
|
return !path.startsWith("/api/")
|
||||||
|
|| path.equals("/api/auth/login")
|
||||||
|
|| path.equals("/api/video/callback")
|
||||||
|
|| path.startsWith("/swagger-ui")
|
||||||
|
|| path.startsWith("/v3/api-docs"); // AI 服务内部回调,不走用户 Token 认证
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
try {
|
||||||
|
if (!authEnabled) {
|
||||||
|
TokenPrincipal principal = new TokenPrincipal(
|
||||||
|
mockUserId, mockRole, mockCompanyId, mockUsername, "mock-token");
|
||||||
|
CompanyContext.set(mockCompanyId);
|
||||||
|
SecurityUtils.getSubject().login(new BearerToken("mock-token", principal));
|
||||||
|
request.setAttribute("__token_principal__", principal);
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
if (authHeader == null || !authHeader.toLowerCase().startsWith("bearer ")) {
|
||||||
|
writeUnauthorized(response, "缺少或无效的认证令牌");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String[] parts = authHeader.split("\\s+");
|
||||||
|
if (parts.length != 2 || !"Bearer".equalsIgnoreCase(parts[0])) {
|
||||||
|
writeUnauthorized(response, "无效的认证格式");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String token = parts[1];
|
||||||
|
// String token = authHeader.substring(7).trim();
|
||||||
|
Map<Object, Object> tokenData = redisService.hGetAll(RedisKeyManager.tokenKey(token));
|
||||||
|
|
||||||
|
if (tokenData == null || tokenData.isEmpty()) {
|
||||||
|
writeUnauthorized(response, "令牌已过期或不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = Long.parseLong(tokenData.get("userId").toString());
|
||||||
|
String role = tokenData.get("role").toString();
|
||||||
|
Long companyId = Long.parseLong(tokenData.get("companyId").toString());
|
||||||
|
String username = tokenData.get("username").toString();
|
||||||
|
|
||||||
|
// 注入多租户上下文(finally 中清除,防止线程池串漏)
|
||||||
|
CompanyContext.set(companyId);
|
||||||
|
|
||||||
|
// 创建 TokenPrincipal 并登录 Shiro Subject,使 @RequiresRoles 等注解生效
|
||||||
|
TokenPrincipal principal = new TokenPrincipal(userId, role, companyId, username, token);
|
||||||
|
SecurityUtils.getSubject().login(new BearerToken(token, principal));
|
||||||
|
request.setAttribute("__token_principal__", principal);
|
||||||
|
redisService.expire(RedisKeyManager.tokenKey(token), tokenTtlSeconds);
|
||||||
|
redisService.expire(RedisKeyManager.userSessionsKey(userId), tokenTtlSeconds);
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析 Token 数据失败: {}", e.getMessage());
|
||||||
|
writeUnauthorized(response, "令牌数据格式错误");
|
||||||
|
} finally {
|
||||||
|
// 关键:必须清除 ThreadLocal,防止线程池复用时数据串漏
|
||||||
|
CompanyContext.clear();
|
||||||
|
ThreadContext.unbindSubject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeUnauthorized(HttpServletResponse resp, String message) throws IOException {
|
||||||
|
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
resp.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
|
||||||
|
resp.getWriter().write(objectMapper.writeValueAsString(Result.failure("UNAUTHORIZED", message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/main/java/com/label/common/shiro/TokenPrincipal.java
Normal file
18
src/main/java/com/label/common/shiro/TokenPrincipal.java
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.label.common.shiro;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shiro principal carrying the authenticated user's session data.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TokenPrincipal implements Serializable {
|
||||||
|
private final Long userId;
|
||||||
|
private final String role;
|
||||||
|
private final Long companyId;
|
||||||
|
private final String username;
|
||||||
|
private final String token;
|
||||||
|
}
|
||||||
87
src/main/java/com/label/common/shiro/UserRealm.java
Normal file
87
src/main/java/com/label/common/shiro/UserRealm.java
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package com.label.common.shiro;
|
||||||
|
|
||||||
|
import com.label.common.redis.RedisKeyManager;
|
||||||
|
import com.label.common.redis.RedisService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.shiro.authc.*;
|
||||||
|
import org.apache.shiro.authz.AuthorizationInfo;
|
||||||
|
import org.apache.shiro.authz.SimpleAuthorizationInfo;
|
||||||
|
import org.apache.shiro.realm.AuthorizingRealm;
|
||||||
|
import org.apache.shiro.subject.PrincipalCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shiro Realm for role-based authorization using token-based authentication.
|
||||||
|
*
|
||||||
|
* Role hierarchy (addInheritedRoles):
|
||||||
|
* ADMIN ⊃ REVIEWER ⊃ ANNOTATOR ⊃ UPLOADER
|
||||||
|
*
|
||||||
|
* Permission lookup order:
|
||||||
|
* 1. Redis user:perm:{userId} (TTL 5 min)
|
||||||
|
* 2. If miss: use role from TokenPrincipal
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserRealm extends AuthorizingRealm {
|
||||||
|
|
||||||
|
private static final long PERM_CACHE_TTL = 300L; // 5 minutes
|
||||||
|
|
||||||
|
private final RedisService redisService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(AuthenticationToken token) {
|
||||||
|
return token instanceof BearerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
|
||||||
|
// Token validation is done in TokenFilter; this realm only handles authorization
|
||||||
|
// For authentication, we trust the token that was validated by TokenFilter
|
||||||
|
return new SimpleAuthenticationInfo(token.getPrincipal(), token.getCredentials(), getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) principals.getPrimaryPrincipal();
|
||||||
|
if (principal == null) {
|
||||||
|
return new SimpleAuthorizationInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
String role = getRoleFromCacheOrPrincipal(principal);
|
||||||
|
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
|
||||||
|
info.addRole(role);
|
||||||
|
addInheritedRoles(info, role);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getRoleFromCacheOrPrincipal(TokenPrincipal principal) {
|
||||||
|
String permKey = RedisKeyManager.userPermKey(principal.getUserId());
|
||||||
|
String cachedRole = redisService.get(permKey);
|
||||||
|
if (cachedRole != null && !cachedRole.isEmpty()) {
|
||||||
|
return cachedRole;
|
||||||
|
}
|
||||||
|
// Cache miss: use role from token, then refresh cache
|
||||||
|
String role = principal.getRole();
|
||||||
|
redisService.set(permKey, role, PERM_CACHE_TTL);
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADMIN inherits all roles: ADMIN ⊃ REVIEWER ⊃ ANNOTATOR ⊃ UPLOADER
|
||||||
|
*/
|
||||||
|
private void addInheritedRoles(SimpleAuthorizationInfo info, String role) {
|
||||||
|
switch (role) {
|
||||||
|
case "ADMIN":
|
||||||
|
info.addRole("REVIEWER");
|
||||||
|
// fall through
|
||||||
|
case "REVIEWER":
|
||||||
|
info.addRole("ANNOTATOR");
|
||||||
|
// fall through
|
||||||
|
case "ANNOTATOR":
|
||||||
|
info.addRole("UPLOADER");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.label.common.statemachine;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public enum SourceStatus {
|
||||||
|
PENDING, PREPROCESSING, EXTRACTING, QA_REVIEW, APPROVED;
|
||||||
|
|
||||||
|
public static final Map<SourceStatus, Set<SourceStatus>> TRANSITIONS = Map.of(
|
||||||
|
PENDING, Set.of(EXTRACTING, PREPROCESSING),
|
||||||
|
PREPROCESSING, Set.of(PENDING),
|
||||||
|
EXTRACTING, Set.of(QA_REVIEW),
|
||||||
|
QA_REVIEW, Set.of(APPROVED)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.label.common.statemachine;
|
||||||
|
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic state machine validator.
|
||||||
|
* Validates state transitions against a predefined transitions map.
|
||||||
|
*/
|
||||||
|
public final class StateValidator {
|
||||||
|
|
||||||
|
private StateValidator() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a state transition from {@code current} to {@code next} is valid.
|
||||||
|
*
|
||||||
|
* @param transitions the allowed transitions map
|
||||||
|
* @param current the current state
|
||||||
|
* @param next the desired next state
|
||||||
|
* @param <S> the state type (enum)
|
||||||
|
* @throws BusinessException with code INVALID_STATE_TRANSITION if transition not allowed
|
||||||
|
*/
|
||||||
|
public static <S> void assertTransition(Map<S, Set<S>> transitions, S current, S next) {
|
||||||
|
Set<S> allowed = transitions.get(current);
|
||||||
|
if (allowed == null || !allowed.contains(next)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"INVALID_STATE_TRANSITION",
|
||||||
|
String.format("不允许的状态转换: %s → %s", current, next),
|
||||||
|
HttpStatus.CONFLICT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/java/com/label/common/statemachine/TaskStatus.java
Normal file
16
src/main/java/com/label/common/statemachine/TaskStatus.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package com.label.common.statemachine;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public enum TaskStatus {
|
||||||
|
UNCLAIMED, IN_PROGRESS, SUBMITTED, APPROVED, REJECTED;
|
||||||
|
|
||||||
|
public static final Map<TaskStatus, Set<TaskStatus>> TRANSITIONS = Map.of(
|
||||||
|
UNCLAIMED, Set.of(IN_PROGRESS),
|
||||||
|
IN_PROGRESS, Set.of(SUBMITTED, UNCLAIMED, IN_PROGRESS), // IN_PROGRESS->IN_PROGRESS for ADMIN reassign
|
||||||
|
SUBMITTED, Set.of(APPROVED, REJECTED),
|
||||||
|
REJECTED, Set.of(IN_PROGRESS)
|
||||||
|
// APPROVED: terminal state, no outgoing transitions
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/main/java/com/label/common/storage/RustFsClient.java
Normal file
124
src/main/java/com/label/common/storage/RustFsClient.java
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package com.label.common.storage;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||||
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.regions.Region;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
|
||||||
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RustFsClient {
|
||||||
|
|
||||||
|
@Value("${rustfs.endpoint}")
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
@Value("${rustfs.access-key}")
|
||||||
|
private String accessKey;
|
||||||
|
|
||||||
|
@Value("${rustfs.secret-key}")
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
private S3Client s3Client;
|
||||||
|
private S3Presigner presigner;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
var credentials = StaticCredentialsProvider.create(
|
||||||
|
AwsBasicCredentials.create(accessKey, secretKey));
|
||||||
|
|
||||||
|
s3Client = S3Client.builder()
|
||||||
|
.endpointOverride(URI.create(endpoint))
|
||||||
|
.credentialsProvider(credentials)
|
||||||
|
.region(Region.US_EAST_1)
|
||||||
|
.forcePathStyle(true) // Required for MinIO/RustFS
|
||||||
|
.build();
|
||||||
|
|
||||||
|
presigner = S3Presigner.builder()
|
||||||
|
.endpointOverride(URI.create(endpoint))
|
||||||
|
.credentialsProvider(credentials)
|
||||||
|
.region(Region.US_EAST_1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file to RustFS.
|
||||||
|
* @param bucket bucket name
|
||||||
|
* @param key object key (path)
|
||||||
|
* @param inputStream file content
|
||||||
|
* @param contentLength file size in bytes
|
||||||
|
* @param contentType MIME type
|
||||||
|
*/
|
||||||
|
public void upload(String bucket, String key, InputStream inputStream,
|
||||||
|
long contentLength, String contentType) {
|
||||||
|
// Ensure bucket exists
|
||||||
|
ensureBucketExists(bucket);
|
||||||
|
|
||||||
|
s3Client.putObject(
|
||||||
|
PutObjectRequest.builder()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.contentType(contentType)
|
||||||
|
.contentLength(contentLength)
|
||||||
|
.build(),
|
||||||
|
RequestBody.fromInputStream(inputStream, contentLength)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file from RustFS.
|
||||||
|
*/
|
||||||
|
public InputStream download(String bucket, String key) {
|
||||||
|
return s3Client.getObject(
|
||||||
|
GetObjectRequest.builder().bucket(bucket).key(key).build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete file from RustFS.
|
||||||
|
*/
|
||||||
|
public void delete(String bucket, String key) {
|
||||||
|
s3Client.deleteObject(
|
||||||
|
DeleteObjectRequest.builder().bucket(bucket).key(key).build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a presigned URL for temporary read access.
|
||||||
|
* @param expirationMinutes URL validity in minutes
|
||||||
|
*/
|
||||||
|
public String getPresignedUrl(String bucket, String key, int expirationMinutes) {
|
||||||
|
var presignRequest = GetObjectPresignRequest.builder()
|
||||||
|
.signatureDuration(Duration.ofMinutes(expirationMinutes))
|
||||||
|
.getObjectRequest(GetObjectRequest.builder().bucket(bucket).key(key).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return presigner.presignGetObject(presignRequest).url().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureBucketExists(String bucket) {
|
||||||
|
try {
|
||||||
|
s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build());
|
||||||
|
} catch (NoSuchBucketException e) {
|
||||||
|
s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||||
|
log.info("Created bucket: {}", bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.label.module.annotation.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.annotation.service.ExtractionService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取阶段标注工作台接口(5 个端点)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "提取标注", description = "提取阶段的查看、编辑、提交和审批")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/extraction")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExtractionController {
|
||||||
|
|
||||||
|
private final ExtractionService extractionService;
|
||||||
|
|
||||||
|
/** GET /api/extraction/{taskId} — 获取当前标注结果 */
|
||||||
|
@Operation(summary = "获取提取标注结果")
|
||||||
|
@GetMapping("/{taskId}")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(extractionService.getResult(taskId, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/extraction/{taskId} — 更新标注结果(整体覆盖) */
|
||||||
|
@Operation(summary = "更新提取标注结果")
|
||||||
|
@PutMapping("/{taskId}")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Void> updateResult(@PathVariable Long taskId,
|
||||||
|
@RequestBody String resultJson,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
extractionService.updateResult(taskId, resultJson, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/extraction/{taskId}/submit — 提交标注结果 */
|
||||||
|
@Operation(summary = "提交提取标注结果")
|
||||||
|
@PostMapping("/{taskId}/submit")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Void> submit(@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
extractionService.submit(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/extraction/{taskId}/approve — 审批通过(REVIEWER) */
|
||||||
|
@Operation(summary = "审批通过提取结果")
|
||||||
|
@PostMapping("/{taskId}/approve")
|
||||||
|
@RequiresRoles("REVIEWER")
|
||||||
|
public Result<Void> approve(@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
extractionService.approve(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/extraction/{taskId}/reject — 驳回(REVIEWER) */
|
||||||
|
@Operation(summary = "驳回提取结果")
|
||||||
|
@PostMapping("/{taskId}/reject")
|
||||||
|
@RequiresRoles("REVIEWER")
|
||||||
|
public Result<Void> reject(@PathVariable Long taskId,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String reason = body != null ? body.get("reason") : null;
|
||||||
|
extractionService.reject(taskId, reason, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.label.module.annotation.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.annotation.service.QaService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 问答生成阶段标注工作台接口(5 个端点)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "问答生成", description = "问答生成阶段的查看、编辑、提交和审批")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/qa")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class QaController {
|
||||||
|
|
||||||
|
private final QaService qaService;
|
||||||
|
|
||||||
|
/** GET /api/qa/{taskId} — 获取候选问答对 */
|
||||||
|
@Operation(summary = "获取候选问答对")
|
||||||
|
@GetMapping("/{taskId}")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(qaService.getResult(taskId, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/qa/{taskId} — 整体覆盖问答对 */
|
||||||
|
@Operation(summary = "更新候选问答对")
|
||||||
|
@PutMapping("/{taskId}")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Void> updateResult(@PathVariable Long taskId,
|
||||||
|
@RequestBody String body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
qaService.updateResult(taskId, body, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/qa/{taskId}/submit — 提交问答对 */
|
||||||
|
@Operation(summary = "提交问答对")
|
||||||
|
@PostMapping("/{taskId}/submit")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Void> submit(@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
qaService.submit(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/qa/{taskId}/approve — 审批通过(REVIEWER) */
|
||||||
|
@Operation(summary = "审批通过问答对")
|
||||||
|
@PostMapping("/{taskId}/approve")
|
||||||
|
@RequiresRoles("REVIEWER")
|
||||||
|
public Result<Void> approve(@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
qaService.approve(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/qa/{taskId}/reject — 驳回(REVIEWER) */
|
||||||
|
@Operation(summary = "驳回答案对")
|
||||||
|
@PostMapping("/{taskId}/reject")
|
||||||
|
@RequiresRoles("REVIEWER")
|
||||||
|
public Result<Void> reject(@PathVariable Long taskId,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String reason = body != null ? body.get("reason") : null;
|
||||||
|
qaService.reject(taskId, reason, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.label.module.annotation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注结果实体,对应 annotation_result 表。
|
||||||
|
* resultJson 存储 JSONB 格式的标注内容(整体替换语义)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("annotation_result")
|
||||||
|
public class AnnotationResult {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 标注结果 JSON(JSONB,整体覆盖) */
|
||||||
|
private String resultJson;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.label.module.annotation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 训练数据集实体,对应 training_dataset 表。
|
||||||
|
*
|
||||||
|
* status 取值:PENDING_REVIEW / APPROVED / REJECTED
|
||||||
|
* sampleType 取值:TEXT / IMAGE / VIDEO_FRAME
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("training_dataset")
|
||||||
|
public class TrainingDataset {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
/** 样本类型:TEXT / IMAGE / VIDEO_FRAME */
|
||||||
|
private String sampleType;
|
||||||
|
|
||||||
|
/** GLM fine-tune 格式的 JSON 字符串(JSONB) */
|
||||||
|
private String glmFormatJson;
|
||||||
|
|
||||||
|
/** 状态:PENDING_REVIEW / APPROVED / REJECTED */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private Long exportBatchId;
|
||||||
|
|
||||||
|
private LocalDateTime exportedAt;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.label.module.annotation.event;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取任务审批通过事件。
|
||||||
|
* 由 ExtractionService.approve() 在事务提交前发布(@TransactionalEventListener 在 AFTER_COMMIT 处理)。
|
||||||
|
*
|
||||||
|
* 设计约束:AI 调用禁止在审批事务内执行,必须通过此事件解耦。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class ExtractionApprovedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private final Long taskId;
|
||||||
|
private final Long sourceId;
|
||||||
|
/** 资料类型:TEXT / IMAGE,决定调用哪个 AI 生成接口 */
|
||||||
|
private final String sourceType;
|
||||||
|
private final Long companyId;
|
||||||
|
private final Long reviewerId;
|
||||||
|
|
||||||
|
public ExtractionApprovedEvent(Object source, Long taskId, Long sourceId,
|
||||||
|
String sourceType, Long companyId, Long reviewerId) {
|
||||||
|
super(source);
|
||||||
|
this.taskId = taskId;
|
||||||
|
this.sourceId = sourceId;
|
||||||
|
this.sourceType = sourceType;
|
||||||
|
this.companyId = companyId;
|
||||||
|
this.reviewerId = reviewerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.label.module.annotation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.annotation.entity.AnnotationResult;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* annotation_result 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AnnotationResultMapper extends BaseMapper<AnnotationResult> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整体覆盖标注结果 JSON(JSONB 字段)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param resultJson 新的 JSON 字符串(整体替换)
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE annotation_result " +
|
||||||
|
"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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务 ID 查询标注结果。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @return 标注结果(不存在则返回 null)
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM annotation_result WHERE task_id = #{taskId}")
|
||||||
|
AnnotationResult selectByTaskId(@Param("taskId") Long taskId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.label.module.annotation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.annotation.entity.TrainingDataset;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* training_dataset 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface TrainingDatasetMapper extends BaseMapper<TrainingDataset> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务 ID 将训练样本状态改为 APPROVED。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE training_dataset SET status = 'APPROVED', updated_at = NOW() " +
|
||||||
|
"WHERE task_id = #{taskId} AND company_id = #{companyId}")
|
||||||
|
int approveByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务 ID 删除训练样本(驳回时清除候选数据)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Delete("DELETE FROM training_dataset WHERE task_id = #{taskId} AND company_id = #{companyId}")
|
||||||
|
int deleteByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.label.module.annotation.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.ai.AiServiceClient;
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import com.label.module.annotation.entity.TrainingDataset;
|
||||||
|
import com.label.module.annotation.event.ExtractionApprovedEvent;
|
||||||
|
import com.label.module.annotation.mapper.AnnotationResultMapper;
|
||||||
|
import com.label.module.annotation.mapper.TrainingDatasetMapper;
|
||||||
|
import com.label.module.source.entity.SourceData;
|
||||||
|
import com.label.module.source.mapper.SourceDataMapper;
|
||||||
|
import com.label.module.task.service.TaskClaimService;
|
||||||
|
import com.label.module.task.service.TaskService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
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 java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取审批通过后的异步处理器。
|
||||||
|
*
|
||||||
|
* 设计约束(关键):
|
||||||
|
* - @TransactionalEventListener(AFTER_COMMIT):确保在审批事务提交后才触发 AI 调用
|
||||||
|
* - @Transactional(REQUIRES_NEW):在独立新事务中写 DB,与审批事务完全隔离
|
||||||
|
* - 异常不会回滚审批事务(已提交),但会在日志中记录
|
||||||
|
*
|
||||||
|
* 处理流程:
|
||||||
|
* 1. 调用 AI 生成候选问答对(Text/Image 走不同端点)
|
||||||
|
* 2. 写入 training_dataset(status=PENDING_REVIEW)
|
||||||
|
* 3. 创建 QA_GENERATION 任务(status=UNCLAIMED)
|
||||||
|
* 4. 更新 source_data 状态为 QA_REVIEW
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExtractionApprovedEventListener {
|
||||||
|
|
||||||
|
private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final TaskService taskService;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
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);
|
||||||
|
// 不向上抛出,审批操作已提交,此处失败不回滚审批
|
||||||
|
} finally {
|
||||||
|
CompanyContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processEvent(ExtractionApprovedEvent event) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(event.getSourceId());
|
||||||
|
if (source == null) {
|
||||||
|
log.warn("资料不存在,跳过后续处理: sourceId={}", event.getSourceId());
|
||||||
|
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() : Collections.emptyList();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("AI 问答生成失败(taskId={}):{},将使用空问答对", event.getTaskId(), e.getMessage());
|
||||||
|
qaPairs = Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 写入 training_dataset(PENDING_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.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 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);
|
||||||
|
return "{\"conversations\":[]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package com.label.module.annotation.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.shiro.TokenPrincipal;
|
||||||
|
import com.label.common.statemachine.StateValidator;
|
||||||
|
import com.label.common.statemachine.TaskStatus;
|
||||||
|
import com.label.module.annotation.entity.AnnotationResult;
|
||||||
|
import com.label.module.annotation.entity.TrainingDataset;
|
||||||
|
import com.label.module.annotation.event.ExtractionApprovedEvent;
|
||||||
|
import com.label.module.annotation.mapper.AnnotationResultMapper;
|
||||||
|
import com.label.module.annotation.mapper.TrainingDatasetMapper;
|
||||||
|
import com.label.module.source.entity.SourceData;
|
||||||
|
import com.label.module.source.mapper.SourceDataMapper;
|
||||||
|
import com.label.module.task.entity.AnnotationTask;
|
||||||
|
import com.label.module.task.mapper.AnnotationTaskMapper;
|
||||||
|
import com.label.module.task.service.TaskClaimService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取阶段标注服务:AI 预标注、更新结果、提交、审批、驳回。
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - approve() 内禁止直接调用 AI,通过 ExtractionApprovedEvent 解耦(AFTER_COMMIT)
|
||||||
|
* - 所有写操作包裹在 @Transactional 中,确保任务状态和历史的一致性
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExtractionService {
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final AnnotationResultMapper resultMapper;
|
||||||
|
private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${rustfs.bucket:label-source-data}")
|
||||||
|
private String bucket;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ AI 预标注 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 辅助预标注:调用 AI 服务,将结果写入 annotation_result。
|
||||||
|
* 注:此方法在 @Transactional 外调用(AI 调用不应在事务内),由控制器直接调用。
|
||||||
|
*/
|
||||||
|
public void aiPreAnnotate(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
if (source == null) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 AI 结果写入 annotation_result(UPSERT 语义)
|
||||||
|
writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 更新结果 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人工更新标注结果(整体覆盖,PUT 语义)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param resultJson 新的标注结果 JSON 字符串
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateResult(Long taskId, String resultJson, TokenPrincipal principal) {
|
||||||
|
validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 校验 JSON 格式
|
||||||
|
try {
|
||||||
|
objectMapper.readTree(resultJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("INVALID_JSON", "标注结果 JSON 格式不合法", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
int updated = resultMapper.updateResultJson(taskId, resultJson, principal.getCompanyId());
|
||||||
|
if (updated == 0) {
|
||||||
|
// 不存在则新建
|
||||||
|
AnnotationResult result = new AnnotationResult();
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setCompanyId(principal.getCompanyId());
|
||||||
|
result.setResultJson(resultJson);
|
||||||
|
resultMapper.insert(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 提交 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交提取结果(IN_PROGRESS → SUBMITTED)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void submit(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.SUBMITTED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "SUBMITTED")
|
||||||
|
.set(AnnotationTask::getSubmittedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
task.getStatus(), "SUBMITTED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 审批通过 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批通过(SUBMITTED → APPROVED)。
|
||||||
|
*
|
||||||
|
* 两阶段:
|
||||||
|
* 1. 同步事务:is_final=true,状态推进,写历史
|
||||||
|
* 2. 事务提交后(AFTER_COMMIT):AI 生成问答对 → training_dataset → QA 任务 → source_data 状态
|
||||||
|
*
|
||||||
|
* 注:AI 调用严禁在此事务内执行。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void approve(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许审批自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
|
||||||
|
|
||||||
|
// 标记为最终结果
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "APPROVED")
|
||||||
|
.set(AnnotationTask::getIsFinal, true)
|
||||||
|
.set(AnnotationTask::getCompletedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "APPROVED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
|
||||||
|
// 获取资料信息,用于事件
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
String sourceType = source != null ? source.getDataType() : "TEXT";
|
||||||
|
|
||||||
|
// 发布事件(@TransactionalEventListener(AFTER_COMMIT) 处理 AI 调用)
|
||||||
|
eventPublisher.publishEvent(new ExtractionApprovedEvent(
|
||||||
|
this, taskId, task.getSourceId(), sourceType,
|
||||||
|
principal.getCompanyId(), principal.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 驳回 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回提取结果(SUBMITTED → REJECTED)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reject(Long taskId, String reason, TokenPrincipal principal) {
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许驳回自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.REJECTED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "REJECTED")
|
||||||
|
.set(AnnotationTask::getRejectReason, reason));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "REJECTED",
|
||||||
|
principal.getUserId(), principal.getRole(), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前标注结果。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
AnnotationResult result = resultMapper.selectByTaskId(taskId);
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"taskId", taskId,
|
||||||
|
"sourceType", source != null ? source.getDataType() : "",
|
||||||
|
"sourceFilePath", source != null && source.getFilePath() != null ? source.getFilePath() : "",
|
||||||
|
"isFinal", task.getIsFinal() != null && task.getIsFinal(),
|
||||||
|
"resultJson", result != null ? result.getResultJson() : "[]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验任务存在性(多租户自动过滤)。
|
||||||
|
*/
|
||||||
|
private AnnotationTask validateAndGetTask(Long taskId, Long companyId) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null || !companyId.equals(task.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/main/java/com/label/module/annotation/service/QaService.java
Normal file
252
src/main/java/com/label/module/annotation/service/QaService.java
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package com.label.module.annotation.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.common.statemachine.StateValidator;
|
||||||
|
import com.label.common.statemachine.TaskStatus;
|
||||||
|
import com.label.module.annotation.entity.TrainingDataset;
|
||||||
|
import com.label.module.annotation.mapper.TrainingDatasetMapper;
|
||||||
|
import com.label.module.source.entity.SourceData;
|
||||||
|
import com.label.module.source.mapper.SourceDataMapper;
|
||||||
|
import com.label.module.task.entity.AnnotationTask;
|
||||||
|
import com.label.module.task.mapper.AnnotationTaskMapper;
|
||||||
|
import com.label.module.task.service.TaskClaimService;
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 问答生成阶段标注服务:查询候选问答对、更新、提交、审批、驳回。
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - QA 阶段无 AI 调用(候选问答对已由 ExtractionApprovedEventListener 生成)
|
||||||
|
* - approve() 同一事务内完成:training_dataset → APPROVED、task → APPROVED、source_data → APPROVED
|
||||||
|
* - reject() 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class QaService {
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取候选问答对(从 training_dataset.glm_format_json 解析)。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
TrainingDataset dataset = getDataset(taskId);
|
||||||
|
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
String sourceType = source != null ? source.getDataType() : "TEXT";
|
||||||
|
|
||||||
|
List<?> items = Collections.emptyList();
|
||||||
|
if (dataset != null && dataset.getGlmFormatJson() != null) {
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> parsed = objectMapper.readValue(dataset.getGlmFormatJson(), Map.class);
|
||||||
|
Object conversations = parsed.get("conversations");
|
||||||
|
if (conversations instanceof List) {
|
||||||
|
items = (List<?>) conversations;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("解析 QA JSON 失败(taskId={}):{}", taskId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"taskId", taskId,
|
||||||
|
"sourceType", sourceType,
|
||||||
|
"items", items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 更新 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整体覆盖问答对(PUT 语义)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param body 包含 items 数组的 JSON,格式:{"items": [...]}
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateResult(Long taskId, String body, TokenPrincipal principal) {
|
||||||
|
validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 校验 JSON 格式
|
||||||
|
try {
|
||||||
|
objectMapper.readTree(body);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("INVALID_JSON", "请求体 JSON 格式不合法", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 items 格式包装为 GLM 格式:{"conversations": items}
|
||||||
|
String glmJson;
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> parsed = objectMapper.readValue(body, Map.class);
|
||||||
|
Object items = parsed.getOrDefault("items", Collections.emptyList());
|
||||||
|
glmJson = objectMapper.writeValueAsString(Map.of("conversations", items));
|
||||||
|
} catch (Exception e) {
|
||||||
|
glmJson = "{\"conversations\":[]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainingDataset dataset = getDataset(taskId);
|
||||||
|
if (dataset != null) {
|
||||||
|
datasetMapper.update(null, new LambdaUpdateWrapper<TrainingDataset>()
|
||||||
|
.eq(TrainingDataset::getTaskId, taskId)
|
||||||
|
.set(TrainingDataset::getGlmFormatJson, glmJson)
|
||||||
|
.set(TrainingDataset::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
} else {
|
||||||
|
// 若 training_dataset 不存在(异常情况),自动创建
|
||||||
|
TrainingDataset newDataset = new TrainingDataset();
|
||||||
|
newDataset.setCompanyId(principal.getCompanyId());
|
||||||
|
newDataset.setTaskId(taskId);
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
newDataset.setSourceId(task.getSourceId());
|
||||||
|
newDataset.setSampleType("TEXT");
|
||||||
|
newDataset.setGlmFormatJson(glmJson);
|
||||||
|
newDataset.setStatus("PENDING_REVIEW");
|
||||||
|
datasetMapper.insert(newDataset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 提交 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交 QA 结果(IN_PROGRESS → SUBMITTED)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void submit(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.SUBMITTED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "SUBMITTED")
|
||||||
|
.set(AnnotationTask::getSubmittedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
task.getStatus(), "SUBMITTED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 审批通过 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批通过(SUBMITTED → APPROVED)。
|
||||||
|
*
|
||||||
|
* 同一事务:
|
||||||
|
* 1. 校验任务(先于一切 DB 写入)
|
||||||
|
* 2. 自审校验
|
||||||
|
* 3. StateValidator
|
||||||
|
* 4. training_dataset → APPROVED
|
||||||
|
* 5. annotation_task → APPROVED + is_final=true + completedAt
|
||||||
|
* 6. source_data → APPROVED(整条流水线完成)
|
||||||
|
* 7. 写任务历史
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void approve(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许审批自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
|
||||||
|
|
||||||
|
// training_dataset → APPROVED
|
||||||
|
datasetMapper.approveByTaskId(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// annotation_task → APPROVED + is_final=true
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "APPROVED")
|
||||||
|
.set(AnnotationTask::getIsFinal, true)
|
||||||
|
.set(AnnotationTask::getCompletedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
// source_data → APPROVED(整条流水线终态)
|
||||||
|
sourceDataMapper.updateStatus(task.getSourceId(), "APPROVED", principal.getCompanyId());
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "APPROVED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
|
||||||
|
log.info("QA 审批通过,整条流水线完成: taskId={}, sourceId={}", taskId, task.getSourceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 驳回 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回 QA 结果(SUBMITTED → REJECTED)。
|
||||||
|
*
|
||||||
|
* 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态不变。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reject(Long taskId, String reason, TokenPrincipal principal) {
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许驳回自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.REJECTED);
|
||||||
|
|
||||||
|
// 清除候选问答对
|
||||||
|
datasetMapper.deleteByTaskId(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "REJECTED")
|
||||||
|
.set(AnnotationTask::getRejectReason, reason));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "REJECTED",
|
||||||
|
principal.getUserId(), principal.getRole(), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private AnnotationTask validateAndGetTask(Long taskId, Long companyId) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null || !companyId.equals(task.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TrainingDataset getDataset(Long taskId) {
|
||||||
|
return datasetMapper.selectOne(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TrainingDataset>()
|
||||||
|
.eq(TrainingDataset::getTaskId, taskId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.label.module.config.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.config.entity.SysConfig;
|
||||||
|
import com.label.module.config.service.SysConfigService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置接口(2 个端点,均需 ADMIN 权限)。
|
||||||
|
*
|
||||||
|
* GET /api/config — 查询当前公司所有可见配置(公司专属 + 全局默认合并)
|
||||||
|
* PUT /api/config/{key} — 更新/创建公司专属配置(UPSERT)
|
||||||
|
*/
|
||||||
|
@Tag(name = "系统配置", description = "全局和公司级系统配置管理")
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SysConfigController {
|
||||||
|
|
||||||
|
private final SysConfigService sysConfigService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/config — 查询合并后的配置列表。
|
||||||
|
*
|
||||||
|
* 响应中每条配置含 scope 字段:
|
||||||
|
* - "COMPANY":当前公司专属配置(优先生效)
|
||||||
|
* - "GLOBAL":全局默认配置(公司未覆盖时生效)
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询合并后的系统配置")
|
||||||
|
@GetMapping("/api/config")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<List<Map<String, Object>>> listConfig(HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(sysConfigService.list(principal.getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/config/{key} — UPSERT 公司专属配置。
|
||||||
|
*
|
||||||
|
* Body: { "value": "...", "description": "..." }
|
||||||
|
*/
|
||||||
|
@Operation(summary = "更新或创建公司专属配置")
|
||||||
|
@PutMapping("/api/config/{key}")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<SysConfig> updateConfig(@PathVariable String key,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String value = body.get("value");
|
||||||
|
String description = body.get("description");
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(
|
||||||
|
sysConfigService.update(key, value, description, principal.getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/com/label/module/config/entity/SysConfig.java
Normal file
41
src/main/java/com/label/module/config/entity/SysConfig.java
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package com.label.module.config.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置实体,对应 sys_config 表。
|
||||||
|
*
|
||||||
|
* company_id 为 NULL 时表示全局默认配置,非 NULL 时表示租户专属配置(优先级更高)。
|
||||||
|
* 注:sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES,不走多租户过滤器。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_config")
|
||||||
|
public class SysConfig {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属公司 ID(NULL = 全局默认配置;非 NULL = 租户专属配置)。
|
||||||
|
* 注意:不能用 @TableField(exist = false) 排除,必须保留以支持 company_id IS NULL 查询。
|
||||||
|
*/
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 配置键 */
|
||||||
|
private String configKey;
|
||||||
|
|
||||||
|
/** 配置值 */
|
||||||
|
private String configValue;
|
||||||
|
|
||||||
|
/** 配置说明 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.label.module.config.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.config.entity.SysConfig;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_config 表 Mapper。
|
||||||
|
*
|
||||||
|
* 注意:sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES,不走多租户过滤器,
|
||||||
|
* 需手动传入 companyId 进行过滤。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysConfigMapper extends BaseMapper<SysConfig> {
|
||||||
|
|
||||||
|
/** 查询指定公司的配置(租户专属,优先级高) */
|
||||||
|
@Select("SELECT * FROM sys_config WHERE company_id = #{companyId} AND config_key = #{configKey}")
|
||||||
|
SysConfig selectByCompanyAndKey(@Param("companyId") Long companyId,
|
||||||
|
@Param("configKey") String configKey);
|
||||||
|
|
||||||
|
/** 查询全局默认配置(company_id IS NULL) */
|
||||||
|
@Select("SELECT * FROM sys_config WHERE company_id IS NULL AND config_key = #{configKey}")
|
||||||
|
SysConfig selectGlobalByKey(@Param("configKey") String configKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定公司所有可见配置(公司专属 + 全局默认),
|
||||||
|
* 按 company_id DESC NULLS LAST 排序(公司专属优先于全局默认)。
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM sys_config WHERE company_id = #{companyId} OR company_id IS NULL " +
|
||||||
|
"ORDER BY company_id DESC NULLS LAST")
|
||||||
|
List<SysConfig> selectAllForCompany(@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.label.module.config.service;
|
||||||
|
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.module.config.entity.SysConfig;
|
||||||
|
import com.label.module.config.mapper.SysConfigMapper;
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置服务。
|
||||||
|
*
|
||||||
|
* 配置查找优先级:公司专属(company_id = N)> 全局默认(company_id IS NULL)。
|
||||||
|
*
|
||||||
|
* get() — 按优先级返回单个配置值
|
||||||
|
* list() — 返回合并后的配置列表(公司专属覆盖同名全局配置),附 scope 字段
|
||||||
|
* update() — 以公司专属配置进行 UPSERT(仅允许已知配置键)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SysConfigService {
|
||||||
|
|
||||||
|
/** 系统已知配置键白名单(防止写入未知键) */
|
||||||
|
private static final Set<String> KNOWN_KEYS = Set.of(
|
||||||
|
"token_ttl_seconds",
|
||||||
|
"model_default",
|
||||||
|
"video_frame_interval"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final SysConfigMapper configMapper;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询单值 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按优先级获取配置值:公司专属优先,否则回退全局默认。
|
||||||
|
*
|
||||||
|
* @param configKey 配置键
|
||||||
|
* @param companyId 当前公司 ID
|
||||||
|
* @return 配置值(不存在时返回 null)
|
||||||
|
*/
|
||||||
|
public String get(String configKey, Long companyId) {
|
||||||
|
// 先查公司专属
|
||||||
|
SysConfig company = configMapper.selectByCompanyAndKey(companyId, configKey);
|
||||||
|
if (company != null) {
|
||||||
|
return company.getConfigValue();
|
||||||
|
}
|
||||||
|
// 回退全局默认
|
||||||
|
SysConfig global = configMapper.selectGlobalByKey(configKey);
|
||||||
|
return global != null ? global.getConfigValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询列表 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前公司所有可见配置(公司专属 + 全局默认合并),
|
||||||
|
* 附加 scope 字段("COMPANY" / "GLOBAL")标识来源。
|
||||||
|
*
|
||||||
|
* @param companyId 当前公司 ID
|
||||||
|
* @return 配置列表(含 scope)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> list(Long companyId) {
|
||||||
|
List<SysConfig> all = configMapper.selectAllForCompany(companyId);
|
||||||
|
|
||||||
|
// 按 configKey 分组,公司专属优先(排序保证公司专属在前)
|
||||||
|
Map<String, SysConfig> merged = new LinkedHashMap<>();
|
||||||
|
for (SysConfig cfg : all) {
|
||||||
|
// 由于 SQL 按 company_id DESC NULLS LAST 排序,公司专属先出现,直接 putIfAbsent
|
||||||
|
merged.putIfAbsent(cfg.getConfigKey(), cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.values().stream()
|
||||||
|
.map(cfg -> {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("id", cfg.getId());
|
||||||
|
item.put("configKey", cfg.getConfigKey());
|
||||||
|
item.put("configValue", cfg.getConfigValue());
|
||||||
|
item.put("description", cfg.getDescription());
|
||||||
|
item.put("scope", cfg.getCompanyId() != null ? "COMPANY" : "GLOBAL");
|
||||||
|
item.put("companyId", cfg.getCompanyId());
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 更新配置 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新公司专属配置(UPSERT)。
|
||||||
|
*
|
||||||
|
* 仅允许 KNOWN_KEYS 中的配置键,防止写入未定义的配置项。
|
||||||
|
*
|
||||||
|
* @param configKey 配置键
|
||||||
|
* @param value 新配置值
|
||||||
|
* @param description 配置说明(可选)
|
||||||
|
* @param companyId 当前公司 ID
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SysConfig update(String configKey, String value,
|
||||||
|
String description, Long companyId) {
|
||||||
|
if (!KNOWN_KEYS.contains(configKey)) {
|
||||||
|
throw new BusinessException("UNKNOWN_CONFIG_KEY",
|
||||||
|
"未知配置键: " + configKey, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new BusinessException("INVALID_CONFIG_VALUE",
|
||||||
|
"配置值不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPSERT:如公司专属配置已存在则更新,否则插入
|
||||||
|
SysConfig existing = configMapper.selectByCompanyAndKey(companyId, configKey);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.setConfigValue(value);
|
||||||
|
if (description != null && !description.isBlank()) {
|
||||||
|
existing.setDescription(description);
|
||||||
|
}
|
||||||
|
existing.setUpdatedAt(LocalDateTime.now());
|
||||||
|
configMapper.updateById(existing);
|
||||||
|
log.info("公司配置已更新: companyId={}, key={}, value={}", companyId, configKey, value);
|
||||||
|
return existing;
|
||||||
|
} else {
|
||||||
|
SysConfig cfg = new SysConfig();
|
||||||
|
cfg.setCompanyId(companyId);
|
||||||
|
cfg.setConfigKey(configKey);
|
||||||
|
cfg.setConfigValue(value);
|
||||||
|
cfg.setDescription(description);
|
||||||
|
configMapper.insert(cfg);
|
||||||
|
log.info("公司配置已创建: companyId={}, key={}, value={}", companyId, configKey, value);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.label.module.export.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.annotation.entity.TrainingDataset;
|
||||||
|
import com.label.module.export.entity.ExportBatch;
|
||||||
|
import com.label.module.export.service.ExportService;
|
||||||
|
import com.label.module.export.service.FinetuneService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 训练数据导出与微调接口(5 个端点,全部 ADMIN 权限)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "导出管理", description = "训练样本查询、导出批次和微调任务")
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExportController {
|
||||||
|
|
||||||
|
private final ExportService exportService;
|
||||||
|
private final FinetuneService finetuneService;
|
||||||
|
|
||||||
|
/** GET /api/training/samples — 分页查询已审批可导出样本 */
|
||||||
|
@Operation(summary = "分页查询可导出训练样本")
|
||||||
|
@GetMapping("/api/training/samples")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<PageResult<TrainingDataset>> listSamples(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@RequestParam(required = false) String sampleType,
|
||||||
|
@RequestParam(required = false) Boolean exported,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(exportService.listSamples(page, pageSize, sampleType, exported, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/export/batch — 创建导出批次 */
|
||||||
|
@Operation(summary = "创建导出批次")
|
||||||
|
@PostMapping("/api/export/batch")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Result<ExportBatch> createBatch(@RequestBody Map<String, Object> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Object> rawIds = (List<Object>) body.get("sampleIds");
|
||||||
|
List<Long> sampleIds = rawIds.stream()
|
||||||
|
.map(id -> Long.parseLong(id.toString()))
|
||||||
|
.toList();
|
||||||
|
return Result.success(exportService.createBatch(sampleIds, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/export/{batchId}/finetune — 提交微调任务 */
|
||||||
|
@Operation(summary = "提交微调任务")
|
||||||
|
@PostMapping("/api/export/{batchId}/finetune")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<Map<String, Object>> triggerFinetune(@PathVariable Long batchId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(finetuneService.trigger(batchId, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/export/{batchId}/status — 查询微调状态 */
|
||||||
|
@Operation(summary = "查询微调状态")
|
||||||
|
@GetMapping("/api/export/{batchId}/status")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<Map<String, Object>> getFinetuneStatus(@PathVariable Long batchId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(finetuneService.getStatus(batchId, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/export/list — 分页查询导出批次列表 */
|
||||||
|
@Operation(summary = "分页查询导出批次")
|
||||||
|
@GetMapping("/api/export/list")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<PageResult<ExportBatch>> listBatches(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(exportService.listBatches(page, pageSize, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.label.module.export.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出批次实体,对应 export_batch 表。
|
||||||
|
*
|
||||||
|
* finetuneStatus 取值:NOT_STARTED / RUNNING / COMPLETED / FAILED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("export_batch")
|
||||||
|
public class ExportBatch {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 批次唯一标识(UUID,DB 默认 gen_random_uuid()) */
|
||||||
|
private UUID batchUuid;
|
||||||
|
|
||||||
|
/** 本批次样本数量 */
|
||||||
|
private Integer sampleCount;
|
||||||
|
|
||||||
|
/** 导出 JSONL 的 RustFS 路径 */
|
||||||
|
private String datasetFilePath;
|
||||||
|
|
||||||
|
/** GLM fine-tune 任务 ID(提交微调后填写) */
|
||||||
|
private String glmJobId;
|
||||||
|
|
||||||
|
/** 微调任务状态:NOT_STARTED / RUNNING / COMPLETED / FAILED */
|
||||||
|
private String finetuneStatus;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.label.module.export.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.export.entity.ExportBatch;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* export_batch 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ExportBatchMapper extends BaseMapper<ExportBatch> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新微调任务信息(glm_job_id + finetune_status)。
|
||||||
|
*
|
||||||
|
* @param id 批次 ID
|
||||||
|
* @param glmJobId GLM fine-tune 任务 ID
|
||||||
|
* @param finetuneStatus 新状态
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE export_batch SET glm_job_id = #{glmJobId}, " +
|
||||||
|
"finetune_status = #{finetuneStatus}, updated_at = NOW() " +
|
||||||
|
"WHERE id = #{id} AND company_id = #{companyId}")
|
||||||
|
int updateFinetuneInfo(@Param("id") Long id,
|
||||||
|
@Param("glmJobId") String glmJobId,
|
||||||
|
@Param("finetuneStatus") String finetuneStatus,
|
||||||
|
@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
177
src/main/java/com/label/module/export/service/ExportService.java
Normal file
177
src/main/java/com/label/module/export/service/ExportService.java
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package com.label.module.export.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.common.storage.RustFsClient;
|
||||||
|
import com.label.module.annotation.entity.TrainingDataset;
|
||||||
|
import com.label.module.annotation.mapper.TrainingDatasetMapper;
|
||||||
|
import com.label.module.export.entity.ExportBatch;
|
||||||
|
import com.label.module.export.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.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 训练数据导出服务。
|
||||||
|
*
|
||||||
|
* createBatch() 步骤:
|
||||||
|
* 1. 校验 sampleIds 非空(EMPTY_SAMPLES 400)
|
||||||
|
* 2. 查询 training_dataset,校验全部为 APPROVED(INVALID_SAMPLES 400)
|
||||||
|
* 3. 生成 JSONL(每行一个 glm_format_json)
|
||||||
|
* 4. 上传 RustFS(bucket: finetune-export, key: export/{batchUuid}.jsonl)
|
||||||
|
* 5. 插入 export_batch 记录
|
||||||
|
* 6. 批量更新 training_dataset.export_batch_id + exported_at
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExportService {
|
||||||
|
|
||||||
|
private static final String EXPORT_BUCKET = "finetune-export";
|
||||||
|
|
||||||
|
private final ExportBatchMapper exportBatchMapper;
|
||||||
|
private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final RustFsClient rustFsClient;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 创建批次 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建导出批次。
|
||||||
|
*
|
||||||
|
* @param sampleIds 待导出的 training_dataset ID 列表
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @return 新建的 ExportBatch
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ExportBatch createBatch(List<Long> sampleIds, TokenPrincipal principal) {
|
||||||
|
if (sampleIds == null || sampleIds.isEmpty()) {
|
||||||
|
throw new BusinessException("EMPTY_SAMPLES", "导出样本 ID 列表不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询样本
|
||||||
|
List<TrainingDataset> samples = datasetMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<TrainingDataset>()
|
||||||
|
.in(TrainingDataset::getId, sampleIds)
|
||||||
|
.eq(TrainingDataset::getCompanyId, principal.getCompanyId()));
|
||||||
|
|
||||||
|
// 校验全部已审批
|
||||||
|
boolean hasNonApproved = samples.stream()
|
||||||
|
.anyMatch(s -> !"APPROVED".equals(s.getStatus()));
|
||||||
|
if (hasNonApproved || samples.size() != sampleIds.size()) {
|
||||||
|
throw new BusinessException("INVALID_SAMPLES",
|
||||||
|
"部分样本不处于 APPROVED 状态或不属于当前租户", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 JSONL(每行一个 JSON 对象)
|
||||||
|
String jsonl = samples.stream()
|
||||||
|
.map(TrainingDataset::getGlmFormatJson)
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
byte[] jsonlBytes = jsonl.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// 生成唯一批次 UUID,上传 RustFS
|
||||||
|
UUID batchUuid = UUID.randomUUID();
|
||||||
|
String filePath = "export/" + batchUuid + ".jsonl";
|
||||||
|
|
||||||
|
rustFsClient.upload(EXPORT_BUCKET, filePath,
|
||||||
|
new ByteArrayInputStream(jsonlBytes), jsonlBytes.length,
|
||||||
|
"application/jsonl");
|
||||||
|
|
||||||
|
// 插入 export_batch 记录(若 DB 写入失败,尝试清理 RustFS 孤儿文件)
|
||||||
|
ExportBatch batch = new ExportBatch();
|
||||||
|
batch.setCompanyId(principal.getCompanyId());
|
||||||
|
batch.setBatchUuid(batchUuid);
|
||||||
|
batch.setSampleCount(samples.size());
|
||||||
|
batch.setDatasetFilePath(filePath);
|
||||||
|
batch.setFinetuneStatus("NOT_STARTED");
|
||||||
|
try {
|
||||||
|
exportBatchMapper.insert(batch);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// DB 插入失败:尝试删除已上传的 RustFS 文件,防止产生孤儿文件
|
||||||
|
try {
|
||||||
|
rustFsClient.delete(EXPORT_BUCKET, filePath);
|
||||||
|
} catch (Exception deleteEx) {
|
||||||
|
log.error("DB 写入失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", EXPORT_BUCKET, filePath, deleteEx);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新 training_dataset.export_batch_id + exported_at
|
||||||
|
datasetMapper.update(null, new LambdaUpdateWrapper<TrainingDataset>()
|
||||||
|
.in(TrainingDataset::getId, sampleIds)
|
||||||
|
.set(TrainingDataset::getExportBatchId, batch.getId())
|
||||||
|
.set(TrainingDataset::getExportedAt, LocalDateTime.now())
|
||||||
|
.set(TrainingDataset::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
log.info("导出批次已创建: batchId={}, sampleCount={}, path={}",
|
||||||
|
batch.getId(), samples.size(), filePath);
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询样本 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询已审批、可导出的训练样本。
|
||||||
|
*/
|
||||||
|
public PageResult<TrainingDataset> listSamples(int page, int pageSize,
|
||||||
|
String sampleType, Boolean exported,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<TrainingDataset> wrapper = new LambdaQueryWrapper<TrainingDataset>()
|
||||||
|
.eq(TrainingDataset::getStatus, "APPROVED")
|
||||||
|
.eq(TrainingDataset::getCompanyId, principal.getCompanyId())
|
||||||
|
.orderByDesc(TrainingDataset::getCreatedAt);
|
||||||
|
|
||||||
|
if (sampleType != null && !sampleType.isBlank()) {
|
||||||
|
wrapper.eq(TrainingDataset::getSampleType, sampleType);
|
||||||
|
}
|
||||||
|
if (exported != null) {
|
||||||
|
if (exported) {
|
||||||
|
wrapper.isNotNull(TrainingDataset::getExportBatchId);
|
||||||
|
} else {
|
||||||
|
wrapper.isNull(TrainingDataset::getExportBatchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<TrainingDataset> result = datasetMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询批次列表 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询导出批次。
|
||||||
|
*/
|
||||||
|
public PageResult<ExportBatch> listBatches(int page, int pageSize, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
Page<ExportBatch> result = exportBatchMapper.selectPage(
|
||||||
|
new Page<>(page, pageSize),
|
||||||
|
new LambdaQueryWrapper<ExportBatch>()
|
||||||
|
.eq(ExportBatch::getCompanyId, principal.getCompanyId())
|
||||||
|
.orderByDesc(ExportBatch::getCreatedAt));
|
||||||
|
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询批次 --
|
||||||
|
|
||||||
|
public ExportBatch getById(Long batchId, TokenPrincipal principal) {
|
||||||
|
ExportBatch batch = exportBatchMapper.selectById(batchId);
|
||||||
|
if (batch == null || !batch.getCompanyId().equals(principal.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "导出批次不存在: " + batchId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.label.module.export.service;
|
||||||
|
|
||||||
|
import com.label.common.ai.AiServiceClient;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.export.entity.ExportBatch;
|
||||||
|
import com.label.module.export.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 final ExportBatchMapper exportBatchMapper;
|
||||||
|
private final ExportService exportService;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 提交微调 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 AI 服务(无事务,不持有 DB 连接)
|
||||||
|
AiServiceClient.FinetuneRequest req = AiServiceClient.FinetuneRequest.builder()
|
||||||
|
.datasetPath(batch.getDatasetFilePath())
|
||||||
|
.model("glm-4")
|
||||||
|
.batchId(batchId)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
AiServiceClient.FinetuneResponse response;
|
||||||
|
try {
|
||||||
|
response = aiServiceClient.startFinetune(req);
|
||||||
|
} catch (Exception e) {
|
||||||
|
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());
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"glmJobId", response.getJobId(),
|
||||||
|
"finetuneStatus", "RUNNING"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询状态 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询微调任务实时状态(向 AI 服务查询)。
|
||||||
|
*
|
||||||
|
* @param batchId 批次 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @return 状态 Map
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStatus(Long batchId, TokenPrincipal principal) {
|
||||||
|
ExportBatch batch = exportService.getById(batchId, principal);
|
||||||
|
|
||||||
|
if (batch.getGlmJobId() == null) {
|
||||||
|
return Map.of(
|
||||||
|
"batchId", batchId,
|
||||||
|
"glmJobId", "",
|
||||||
|
"finetuneStatus", batch.getFinetuneStatus(),
|
||||||
|
"progress", 0,
|
||||||
|
"errorMessage", ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向 AI 服务实时查询
|
||||||
|
AiServiceClient.FinetuneStatusResponse statusResp;
|
||||||
|
try {
|
||||||
|
statusResp = aiServiceClient.getFinetuneStatus(batch.getGlmJobId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("查询微调状态失败(batchId={}):{}", batchId, e.getMessage());
|
||||||
|
// 查询失败时返回 DB 中的缓存状态
|
||||||
|
return Map.of(
|
||||||
|
"batchId", batchId,
|
||||||
|
"glmJobId", batch.getGlmJobId(),
|
||||||
|
"finetuneStatus", batch.getFinetuneStatus(),
|
||||||
|
"progress", 0,
|
||||||
|
"errorMessage", "AI 服务查询失败: " + e.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"batchId", batchId,
|
||||||
|
"glmJobId", statusResp.getJobId() != null ? statusResp.getJobId() : batch.getGlmJobId(),
|
||||||
|
"finetuneStatus", statusResp.getStatus() != null ? statusResp.getStatus() : batch.getFinetuneStatus(),
|
||||||
|
"progress", statusResp.getProgress() != null ? statusResp.getProgress() : 0,
|
||||||
|
"errorMessage", statusResp.getErrorMessage() != null ? statusResp.getErrorMessage() : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.label.module.source.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.source.dto.SourceResponse;
|
||||||
|
import com.label.module.source.service.SourceService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原始资料管理接口。
|
||||||
|
*
|
||||||
|
* 权限设计:
|
||||||
|
* - 上传 / 列表 / 详情:UPLOADER 及以上角色(含 ANNOTATOR、REVIEWER、ADMIN)
|
||||||
|
* - 删除:仅 ADMIN
|
||||||
|
*/
|
||||||
|
@Tag(name = "资料管理", description = "原始资料上传、查询和删除")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/source")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SourceController {
|
||||||
|
|
||||||
|
private final SourceService sourceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件(multipart/form-data)。
|
||||||
|
* 返回 201 Created + 资料摘要。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "上传原始资料", description = "dataType: text,image, video")
|
||||||
|
@PostMapping("/upload")
|
||||||
|
@RequiresRoles("UPLOADER")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Result<SourceResponse> upload(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam("dataType") String dataType,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
return Result.success(sourceService.upload(file, dataType, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询资料列表。
|
||||||
|
* UPLOADER 只见自己的资料;ADMIN 见全公司资料。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "分页查询资料列表")
|
||||||
|
@GetMapping("/list")
|
||||||
|
@RequiresRoles("UPLOADER")
|
||||||
|
public Result<PageResult<SourceResponse>> list(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@RequestParam(required = false) String dataType,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
return Result.success(sourceService.list(page, pageSize, dataType, status, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询资料详情(含 15 分钟预签名下载链接)。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询资料详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequiresRoles("UPLOADER")
|
||||||
|
public Result<SourceResponse> findById(@PathVariable Long id) {
|
||||||
|
return Result.success(sourceService.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除资料(仅 PENDING 状态可删)。
|
||||||
|
* 同步删除 RustFS 文件及 DB 记录。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "删除资料")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
sourceService.delete(id, principal.getCompanyId());
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.label.module.source.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资料接口统一响应体(上传、列表、详情均复用此类)。
|
||||||
|
* 各端点按需填充字段,未填充字段序列化时因 jackson non_null 配置自动省略。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "原始资料响应")
|
||||||
|
public class SourceResponse {
|
||||||
|
@Schema(description = "资料主键")
|
||||||
|
private Long id;
|
||||||
|
@Schema(description = "文件名")
|
||||||
|
private String fileName;
|
||||||
|
@Schema(description = "资料类型", example = "TEXT")
|
||||||
|
private String dataType;
|
||||||
|
@Schema(description = "文件大小(字节)")
|
||||||
|
private Long fileSize;
|
||||||
|
@Schema(description = "资料状态", example = "PENDING")
|
||||||
|
private String status;
|
||||||
|
/** 上传用户 ID(列表端点返回) */
|
||||||
|
@Schema(description = "上传用户 ID")
|
||||||
|
private Long uploaderId;
|
||||||
|
/** 15 分钟预签名下载链接(详情端点返回) */
|
||||||
|
@Schema(description = "预签名下载链接")
|
||||||
|
private String presignedUrl;
|
||||||
|
/** 父资料 ID(视频帧 / 文本片段;详情端点返回) */
|
||||||
|
@Schema(description = "父资料 ID")
|
||||||
|
private Long parentSourceId;
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
56
src/main/java/com/label/module/source/entity/SourceData.java
Normal file
56
src/main/java/com/label/module/source/entity/SourceData.java
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package com.label.module.source.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原始资料实体,对应 source_data 表。
|
||||||
|
*
|
||||||
|
* dataType 取值:TEXT / IMAGE / VIDEO
|
||||||
|
* status 取值:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("source_data")
|
||||||
|
public class SourceData {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 上传用户 ID */
|
||||||
|
private Long uploaderId;
|
||||||
|
|
||||||
|
/** 资料类型:TEXT / IMAGE / VIDEO */
|
||||||
|
private String dataType;
|
||||||
|
|
||||||
|
/** RustFS 对象路径 */
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
/** 原始文件名 */
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/** RustFS Bucket 名称 */
|
||||||
|
private String bucketName;
|
||||||
|
|
||||||
|
/** 父资料 ID(视频帧或文本片段的自引用外键) */
|
||||||
|
private Long parentSourceId;
|
||||||
|
|
||||||
|
/** 流水线状态:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 保留字段(当前无 REJECTED 状态) */
|
||||||
|
private String rejectReason;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.label.module.source.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.source.entity.SourceData;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* source_data 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SourceDataMapper extends BaseMapper<SourceData> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 更新资料状态(带 company_id 租户隔离)。
|
||||||
|
*
|
||||||
|
* @param id 资料 ID
|
||||||
|
* @param status 新状态
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数(0 表示记录不存在或不属于当前租户)
|
||||||
|
*/
|
||||||
|
@Update("UPDATE source_data SET status = #{status}, updated_at = NOW() " +
|
||||||
|
"WHERE id = #{id} AND company_id = #{companyId}")
|
||||||
|
int updateStatus(@Param("id") Long id,
|
||||||
|
@Param("status") String status,
|
||||||
|
@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
230
src/main/java/com/label/module/source/service/SourceService.java
Normal file
230
src/main/java/com/label/module/source/service/SourceService.java
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package com.label.module.source.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.common.storage.RustFsClient;
|
||||||
|
import com.label.module.source.dto.SourceResponse;
|
||||||
|
import com.label.module.source.entity.SourceData;
|
||||||
|
import com.label.module.source.mapper.SourceDataMapper;
|
||||||
|
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;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原始资料业务服务。
|
||||||
|
*
|
||||||
|
* 上传流程:先 INSERT 获取 ID → 构造 RustFS 路径 → 上传文件 → UPDATE filePath。
|
||||||
|
* 删除规则:仅 PENDING 状态可删(防止删除已进入标注流水线的资料)。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SourceService {
|
||||||
|
|
||||||
|
private static final Set<String> VALID_DATA_TYPES = Set.of("TEXT", "IMAGE", "VIDEO");
|
||||||
|
private static final int PRESIGNED_URL_MINUTES = 15;
|
||||||
|
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final RustFsClient rustFsClient;
|
||||||
|
|
||||||
|
@Value("${rustfs.bucket:label-source-data}")
|
||||||
|
private String bucket;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 上传 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件并创建 source_data 记录。
|
||||||
|
*
|
||||||
|
* @param file 上传的文件
|
||||||
|
* @param dataType 资料类型(TEXT / IMAGE / VIDEO)
|
||||||
|
* @param principal 当前登录用户
|
||||||
|
* @return 创建成功的资料摘要
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SourceResponse upload(MultipartFile file, String dataType, TokenPrincipal principal) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("FILE_EMPTY", "上传文件不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (!VALID_DATA_TYPES.contains(dataType)) {
|
||||||
|
throw new BusinessException("INVALID_TYPE", "不支持的资料类型: " + dataType, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取纯文件名,防止路径遍历(如 ../../admin/secret.txt)
|
||||||
|
String rawName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown";
|
||||||
|
String originalName = java.nio.file.Paths.get(rawName).getFileName().toString();
|
||||||
|
|
||||||
|
// 1. 先插入占位记录,拿到自增 ID
|
||||||
|
SourceData source = new SourceData();
|
||||||
|
source.setCompanyId(principal.getCompanyId());
|
||||||
|
source.setUploaderId(principal.getUserId());
|
||||||
|
source.setDataType(dataType);
|
||||||
|
source.setFileName(originalName);
|
||||||
|
source.setFileSize(file.getSize());
|
||||||
|
source.setBucketName(bucket);
|
||||||
|
source.setFilePath(""); // 占位,后面更新
|
||||||
|
source.setStatus("PENDING");
|
||||||
|
sourceDataMapper.insert(source);
|
||||||
|
|
||||||
|
// 2. 构造 RustFS 对象路径
|
||||||
|
String objectKey = String.format("%d/%s/%d/%s",
|
||||||
|
principal.getCompanyId(), dataType.toLowerCase(), source.getId(), originalName);
|
||||||
|
|
||||||
|
// 3. 上传文件到 RustFS
|
||||||
|
try {
|
||||||
|
rustFsClient.upload(bucket, objectKey, file.getInputStream(),
|
||||||
|
file.getSize(), file.getContentType());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("文件上传到 RustFS 失败: bucket={}, key={}", bucket, objectKey, e);
|
||||||
|
throw new BusinessException("UPLOAD_FAILED", "文件上传失败,请重试", HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 更新 filePath(若失败则清理 RustFS 孤儿文件)
|
||||||
|
try {
|
||||||
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
||||||
|
.eq(SourceData::getId, source.getId())
|
||||||
|
.set(SourceData::getFilePath, objectKey));
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
rustFsClient.delete(bucket, objectKey);
|
||||||
|
} catch (Exception deleteEx) {
|
||||||
|
log.error("DB 更新失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", bucket, objectKey, deleteEx);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("资料上传成功: id={}, key={}", source.getId(), objectKey);
|
||||||
|
return toUploadResponse(source, objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 列表 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询资料列表。
|
||||||
|
* UPLOADER 只见自己上传的资料;ADMIN 见本公司全部资料(多租户自动过滤)。
|
||||||
|
*/
|
||||||
|
public PageResult<SourceResponse> list(int page, int pageSize,
|
||||||
|
String dataType, String status,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
|
||||||
|
LambdaQueryWrapper<SourceData> wrapper = new LambdaQueryWrapper<SourceData>()
|
||||||
|
.orderByDesc(SourceData::getCreatedAt);
|
||||||
|
|
||||||
|
// UPLOADER 只能查自己的资料
|
||||||
|
if ("UPLOADER".equals(principal.getRole())) {
|
||||||
|
wrapper.eq(SourceData::getUploaderId, principal.getUserId());
|
||||||
|
}
|
||||||
|
if (dataType != null && !dataType.isBlank()) {
|
||||||
|
wrapper.eq(SourceData::getDataType, dataType);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(SourceData::getStatus, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<SourceData> pageResult = sourceDataMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
|
||||||
|
List<SourceResponse> items = pageResult.getRecords().stream()
|
||||||
|
.map(this::toListItem)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return PageResult.of(items, pageResult.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 详情 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 查询资料详情,含 15 分钟预签名下载链接。
|
||||||
|
*/
|
||||||
|
public SourceResponse findById(Long id) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(id);
|
||||||
|
if (source == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
String presignedUrl = null;
|
||||||
|
if (source.getFilePath() != null && !source.getFilePath().isBlank()) {
|
||||||
|
presignedUrl = rustFsClient.getPresignedUrl(bucket, source.getFilePath(), PRESIGNED_URL_MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SourceResponse.builder()
|
||||||
|
.id(source.getId())
|
||||||
|
.fileName(source.getFileName())
|
||||||
|
.dataType(source.getDataType())
|
||||||
|
.fileSize(source.getFileSize())
|
||||||
|
.status(source.getStatus())
|
||||||
|
.presignedUrl(presignedUrl)
|
||||||
|
.parentSourceId(source.getParentSourceId())
|
||||||
|
.createdAt(source.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 删除 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除资料:仅 PENDING 状态可删,同步删除 RustFS 文件。
|
||||||
|
*
|
||||||
|
* @throws BusinessException SOURCE_IN_PIPELINE(409) 资料已进入标注流程
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id, Long companyId) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(id);
|
||||||
|
if (source == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"PENDING".equals(source.getStatus())) {
|
||||||
|
throw new BusinessException("SOURCE_IN_PIPELINE",
|
||||||
|
"资料已进入标注流程,不可删除(当前状态:" + source.getStatus() + ")",
|
||||||
|
HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先删 RustFS 文件(幂等,不抛异常)
|
||||||
|
if (source.getFilePath() != null && !source.getFilePath().isBlank()) {
|
||||||
|
try {
|
||||||
|
rustFsClient.delete(bucket, source.getFilePath());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("RustFS 文件删除失败(继续删 DB 记录): bucket={}, key={}", bucket, source.getFilePath(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDataMapper.deleteById(id);
|
||||||
|
log.info("资料删除成功: id={}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private SourceResponse toUploadResponse(SourceData source, String filePath) {
|
||||||
|
return SourceResponse.builder()
|
||||||
|
.id(source.getId())
|
||||||
|
.fileName(source.getFileName())
|
||||||
|
.dataType(source.getDataType())
|
||||||
|
.fileSize(source.getFileSize())
|
||||||
|
.status(source.getStatus())
|
||||||
|
.createdAt(source.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SourceResponse toListItem(SourceData source) {
|
||||||
|
return SourceResponse.builder()
|
||||||
|
.id(source.getId())
|
||||||
|
.fileName(source.getFileName())
|
||||||
|
.dataType(source.getDataType())
|
||||||
|
.status(source.getStatus())
|
||||||
|
.uploaderId(source.getUploaderId())
|
||||||
|
.createdAt(source.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.label.module.task.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.task.dto.TaskResponse;
|
||||||
|
import com.label.module.task.service.TaskClaimService;
|
||||||
|
import com.label.module.task.service.TaskService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务管理接口(10 个端点)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "任务管理", description = "任务池、我的任务、审批队列和管理操作")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/tasks")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TaskController {
|
||||||
|
|
||||||
|
private final TaskService taskService;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
|
||||||
|
/** GET /api/tasks/pool — 查询可领取任务池(角色感知) */
|
||||||
|
@Operation(summary = "查询可领取任务池")
|
||||||
|
@GetMapping("/pool")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<PageResult<TaskResponse>> getPool(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(taskService.getPool(page, pageSize, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks/mine — 查询我的任务 */
|
||||||
|
@Operation(summary = "查询我的任务")
|
||||||
|
@GetMapping("/mine")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<PageResult<TaskResponse>> getMine(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(taskService.getMine(page, pageSize, status, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks/pending-review — 待审批队列(REVIEWER 专属) */
|
||||||
|
@Operation(summary = "查询待审批任务")
|
||||||
|
@GetMapping("/pending-review")
|
||||||
|
@RequiresRoles("REVIEWER")
|
||||||
|
public Result<PageResult<TaskResponse>> getPendingReview(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@RequestParam(required = false) String taskType) {
|
||||||
|
return Result.success(taskService.getPendingReview(page, pageSize, taskType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks — 查询全部任务(ADMIN) */
|
||||||
|
@Operation(summary = "管理员查询全部任务")
|
||||||
|
@GetMapping
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<PageResult<TaskResponse>> getAll(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String taskType) {
|
||||||
|
return Result.success(taskService.getAll(page, pageSize, status, taskType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks — 创建任务(ADMIN) */
|
||||||
|
@Operation(summary = "管理员创建任务")
|
||||||
|
@PostMapping
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<TaskResponse> createTask(@RequestBody Map<String, Object> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long sourceId = Long.parseLong(body.get("sourceId").toString());
|
||||||
|
String taskType = body.get("taskType").toString();
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(taskService.toPublicResponse(
|
||||||
|
taskService.createTask(sourceId, taskType, principal.getCompanyId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks/{id} — 查询任务详情 */
|
||||||
|
@Operation(summary = "查询任务详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<TaskResponse> getById(@PathVariable Long id) {
|
||||||
|
return Result.success(taskService.toPublicResponse(taskService.getById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks/{id}/claim — 领取任务 */
|
||||||
|
@Operation(summary = "领取任务")
|
||||||
|
@PostMapping("/{id}/claim")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Void> claim(@PathVariable Long id, HttpServletRequest request) {
|
||||||
|
taskClaimService.claim(id, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks/{id}/unclaim — 放弃任务 */
|
||||||
|
@Operation(summary = "放弃任务")
|
||||||
|
@PostMapping("/{id}/unclaim")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Void> unclaim(@PathVariable Long id, HttpServletRequest request) {
|
||||||
|
taskClaimService.unclaim(id, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks/{id}/reclaim — 重领被驳回的任务 */
|
||||||
|
@Operation(summary = "重领被驳回的任务")
|
||||||
|
@PostMapping("/{id}/reclaim")
|
||||||
|
@RequiresRoles("ANNOTATOR")
|
||||||
|
public Result<Void> reclaim(@PathVariable Long id, HttpServletRequest request) {
|
||||||
|
taskClaimService.reclaim(id, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/tasks/{id}/reassign — ADMIN 强制指派 */
|
||||||
|
@Operation(summary = "管理员强制指派任务")
|
||||||
|
@PutMapping("/{id}/reassign")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<Void> reassign(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, Object> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long targetUserId = Long.parseLong(body.get("userId").toString());
|
||||||
|
taskService.reassign(id, targetUserId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main/java/com/label/module/task/dto/TaskResponse.java
Normal file
38
src/main/java/com/label/module/task/dto/TaskResponse.java
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package com.label.module.task.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务接口统一响应体(任务池、我的任务、任务详情均复用)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "标注任务响应")
|
||||||
|
public class TaskResponse {
|
||||||
|
@Schema(description = "任务主键")
|
||||||
|
private Long id;
|
||||||
|
@Schema(description = "关联资料 ID")
|
||||||
|
private Long sourceId;
|
||||||
|
/** 任务类型(对应 taskType 字段):EXTRACTION / QA_GENERATION */
|
||||||
|
@Schema(description = "任务类型", example = "EXTRACTION")
|
||||||
|
private String taskType;
|
||||||
|
@Schema(description = "任务状态", example = "UNCLAIMED")
|
||||||
|
private String status;
|
||||||
|
@Schema(description = "领取人用户 ID")
|
||||||
|
private Long claimedBy;
|
||||||
|
@Schema(description = "领取时间")
|
||||||
|
private LocalDateTime claimedAt;
|
||||||
|
@Schema(description = "提交时间")
|
||||||
|
private LocalDateTime submittedAt;
|
||||||
|
@Schema(description = "完成时间")
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
/** 驳回原因(REJECTED 状态时非空) */
|
||||||
|
@Schema(description = "驳回原因")
|
||||||
|
private String rejectReason;
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.label.module.task.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注任务实体,对应 annotation_task 表。
|
||||||
|
*
|
||||||
|
* taskType 取值:EXTRACTION / QA_GENERATION
|
||||||
|
* status 取值:UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("annotation_task")
|
||||||
|
public class AnnotationTask {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 关联的原始资料 ID */
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
/** 任务类型:EXTRACTION / QA_GENERATION */
|
||||||
|
private String taskType;
|
||||||
|
|
||||||
|
/** 任务状态 */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 领取任务的用户 ID */
|
||||||
|
private Long claimedBy;
|
||||||
|
|
||||||
|
/** 领取时间 */
|
||||||
|
private LocalDateTime claimedAt;
|
||||||
|
|
||||||
|
/** 提交时间 */
|
||||||
|
private LocalDateTime submittedAt;
|
||||||
|
|
||||||
|
/** 完成时间(APPROVED 时设置) */
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
|
/** 是否最终结果(APPROVED 且无需再审)*/
|
||||||
|
private Boolean isFinal;
|
||||||
|
|
||||||
|
/** 使用的 AI 模型名称 */
|
||||||
|
private String aiModel;
|
||||||
|
|
||||||
|
/** 驳回原因 */
|
||||||
|
private String rejectReason;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.label.module.task.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务状态历史,对应 annotation_task_history 表(仅追加,无 UPDATE/DELETE)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@TableName("annotation_task_history")
|
||||||
|
public class AnnotationTaskHistory {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 转换前状态(首次插入时为 null) */
|
||||||
|
private String fromStatus;
|
||||||
|
|
||||||
|
/** 转换后状态 */
|
||||||
|
private String toStatus;
|
||||||
|
|
||||||
|
/** 操作人 ID */
|
||||||
|
private Long operatorId;
|
||||||
|
|
||||||
|
/** 操作人角色 */
|
||||||
|
private String operatorRole;
|
||||||
|
|
||||||
|
/** 备注(驳回原因等) */
|
||||||
|
private String comment;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.label.module.task.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.task.entity.AnnotationTask;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* annotation_task 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AnnotationTaskMapper extends BaseMapper<AnnotationTask> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子性领取任务:仅当任务为 UNCLAIMED 且属于当前租户时才更新。
|
||||||
|
* 使用乐观 WHERE 条件实现并发安全(依赖数据库行级锁)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param userId 领取用户 ID
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数(0 = 任务已被他人领取或不存在)
|
||||||
|
*/
|
||||||
|
@Update("UPDATE annotation_task " +
|
||||||
|
"SET status = 'IN_PROGRESS', claimed_by = #{userId}, claimed_at = NOW(), updated_at = NOW() " +
|
||||||
|
"WHERE id = #{taskId} AND status = 'UNCLAIMED' AND company_id = #{companyId}")
|
||||||
|
int claimTask(@Param("taskId") Long taskId,
|
||||||
|
@Param("userId") Long userId,
|
||||||
|
@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.label.module.task.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.task.entity.AnnotationTaskHistory;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* annotation_task_history 表 Mapper(仅追加,禁止 UPDATE/DELETE)。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface TaskHistoryMapper extends BaseMapper<AnnotationTaskHistory> {
|
||||||
|
// 继承 BaseMapper 的 insert 用于追加历史记录
|
||||||
|
// 严禁调用 update/delete 相关方法
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package com.label.module.task.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.redis.RedisKeyManager;
|
||||||
|
import com.label.common.redis.RedisService;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.common.statemachine.StateValidator;
|
||||||
|
import com.label.common.statemachine.TaskStatus;
|
||||||
|
import com.label.module.task.entity.AnnotationTask;
|
||||||
|
import com.label.module.task.entity.AnnotationTaskHistory;
|
||||||
|
import com.label.module.task.mapper.AnnotationTaskMapper;
|
||||||
|
import com.label.module.task.mapper.TaskHistoryMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务领取/放弃/重领服务。
|
||||||
|
*
|
||||||
|
* 并发安全设计:
|
||||||
|
* 1. Redis SET NX 作为分布式预锁(TTL 30s),快速拒绝并发请求
|
||||||
|
* 2. DB UPDATE WHERE status='UNCLAIMED' 作为兜底原子操作
|
||||||
|
* 两层防护确保同一任务只有一人可领取
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TaskClaimService {
|
||||||
|
|
||||||
|
/** Redis 分布式锁 TTL(秒) */
|
||||||
|
private static final long CLAIM_LOCK_TTL = 30L;
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final TaskHistoryMapper historyMapper;
|
||||||
|
private final RedisService redisService;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 领取 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取任务(双重防护:Redis NX + DB 原子更新)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @throws BusinessException TASK_CLAIMED(409) 任务已被他人领取
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void claim(Long taskId, TokenPrincipal principal) {
|
||||||
|
String lockKey = RedisKeyManager.taskClaimKey(taskId);
|
||||||
|
|
||||||
|
// 1. Redis SET NX 预锁(快速失败)
|
||||||
|
boolean lockAcquired = redisService.setIfAbsent(
|
||||||
|
lockKey, principal.getUserId().toString(), CLAIM_LOCK_TTL);
|
||||||
|
if (!lockAcquired) {
|
||||||
|
throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. DB 原子更新(WHERE status='UNCLAIMED' 兜底)
|
||||||
|
int affected = taskMapper.claimTask(taskId, principal.getUserId(), principal.getCompanyId());
|
||||||
|
if (affected == 0) {
|
||||||
|
// DB 更新失败说明任务状态已变,清除刚设置的锁
|
||||||
|
redisService.delete(lockKey);
|
||||||
|
throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 写入状态历史
|
||||||
|
insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"UNCLAIMED", "IN_PROGRESS",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
|
||||||
|
log.info("任务领取成功: taskId={}, userId={}", taskId, principal.getUserId());
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e; // 业务异常直接上抛,锁已在上方清除
|
||||||
|
} catch (Exception e) {
|
||||||
|
// DB 写入异常(含 insertHistory 失败):清除 Redis 锁,事务回滚
|
||||||
|
redisService.delete(lockKey);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 放弃 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 放弃任务(IN_PROGRESS → UNCLAIMED)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void unclaim(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
validateTaskExists(task, taskId);
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.UNCLAIMED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "UNCLAIMED")
|
||||||
|
.set(AnnotationTask::getClaimedBy, null)
|
||||||
|
.set(AnnotationTask::getClaimedAt, null));
|
||||||
|
|
||||||
|
// 清除 Redis 分布式锁
|
||||||
|
redisService.delete(RedisKeyManager.taskClaimKey(taskId));
|
||||||
|
|
||||||
|
insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"IN_PROGRESS", "UNCLAIMED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 重领 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重领任务(REJECTED → IN_PROGRESS,仅原领取人可重领)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reclaim(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
validateTaskExists(task, taskId);
|
||||||
|
|
||||||
|
if (!"REJECTED".equals(task.getStatus())) {
|
||||||
|
throw new BusinessException("INVALID_STATE_TRANSITION",
|
||||||
|
"只有 REJECTED 状态的任务可以重领", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("FORBIDDEN",
|
||||||
|
"只有原领取人可以重领该任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.IN_PROGRESS);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.eq(AnnotationTask::getStatus, "REJECTED")
|
||||||
|
.set(AnnotationTask::getStatus, "IN_PROGRESS")
|
||||||
|
.set(AnnotationTask::getClaimedAt, java.time.LocalDateTime.now()));
|
||||||
|
|
||||||
|
// 重新设置 Redis 锁(防止并发再次争抢)
|
||||||
|
redisService.setIfAbsent(
|
||||||
|
RedisKeyManager.taskClaimKey(taskId),
|
||||||
|
principal.getUserId().toString(), CLAIM_LOCK_TTL);
|
||||||
|
|
||||||
|
insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"REJECTED", "IN_PROGRESS",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private void validateTaskExists(AnnotationTask task, Long taskId) {
|
||||||
|
if (task == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 annotation_task_history 追加一条历史记录(仅 INSERT,禁止 UPDATE/DELETE)。
|
||||||
|
*/
|
||||||
|
public void insertHistory(Long taskId, Long companyId,
|
||||||
|
String fromStatus, String toStatus,
|
||||||
|
Long operatorId, String operatorRole, String comment) {
|
||||||
|
historyMapper.insert(AnnotationTaskHistory.builder()
|
||||||
|
.taskId(taskId)
|
||||||
|
.companyId(companyId)
|
||||||
|
.fromStatus(fromStatus)
|
||||||
|
.toStatus(toStatus)
|
||||||
|
.operatorId(operatorId)
|
||||||
|
.operatorRole(operatorRole)
|
||||||
|
.comment(comment)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/main/java/com/label/module/task/service/TaskService.java
Normal file
201
src/main/java/com/label/module/task/service/TaskService.java
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package com.label.module.task.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.task.dto.TaskResponse;
|
||||||
|
import com.label.module.task.entity.AnnotationTask;
|
||||||
|
import com.label.module.task.mapper.AnnotationTaskMapper;
|
||||||
|
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.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务管理服务:创建、查询任务池、我的任务、待审批队列、指派。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TaskService {
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 创建 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标注任务(内部调用,例如视频处理完成后)。
|
||||||
|
*
|
||||||
|
* @param sourceId 资料 ID
|
||||||
|
* @param taskType 任务类型(EXTRACTION / QA_GENERATION)
|
||||||
|
* @param companyId 租户 ID
|
||||||
|
* @return 新任务
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public AnnotationTask createTask(Long sourceId, String taskType, Long companyId) {
|
||||||
|
AnnotationTask task = new AnnotationTask();
|
||||||
|
task.setCompanyId(companyId);
|
||||||
|
task.setSourceId(sourceId);
|
||||||
|
task.setTaskType(taskType);
|
||||||
|
task.setStatus("UNCLAIMED");
|
||||||
|
task.setIsFinal(false);
|
||||||
|
taskMapper.insert(task);
|
||||||
|
log.info("任务已创建: id={}, type={}, sourceId={}", task.getId(), taskType, sourceId);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 任务池 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务池(按角色过滤):
|
||||||
|
* - ANNOTATOR → EXTRACTION 类型、UNCLAIMED 状态
|
||||||
|
* - REVIEWER/ADMIN → SUBMITTED 状态(任意类型)
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getPool(int page, int pageSize, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.orderByAsc(AnnotationTask::getCreatedAt);
|
||||||
|
|
||||||
|
String role = principal.getRole();
|
||||||
|
if ("ANNOTATOR".equals(role)) {
|
||||||
|
wrapper.eq(AnnotationTask::getTaskType, "EXTRACTION")
|
||||||
|
.eq(AnnotationTask::getStatus, "UNCLAIMED");
|
||||||
|
} else {
|
||||||
|
// REVIEWER / ADMIN 看待审批队列
|
||||||
|
wrapper.eq(AnnotationTask::getStatus, "SUBMITTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 我的任务 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户的任务(IN_PROGRESS、SUBMITTED、REJECTED)。
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getMine(int page, int pageSize,
|
||||||
|
String status, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getClaimedBy, principal.getUserId())
|
||||||
|
.in(AnnotationTask::getStatus, "IN_PROGRESS", "SUBMITTED", "REJECTED")
|
||||||
|
.orderByDesc(AnnotationTask::getUpdatedAt);
|
||||||
|
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getStatus, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 待审批 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询待审批任务(REVIEWER 专属,status=SUBMITTED)。
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getPendingReview(int page, int pageSize, String taskType) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getStatus, "SUBMITTED")
|
||||||
|
.orderByAsc(AnnotationTask::getSubmittedAt);
|
||||||
|
|
||||||
|
if (taskType != null && !taskType.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getTaskType, taskType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询单条 --
|
||||||
|
|
||||||
|
public AnnotationTask getById(Long id) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(id);
|
||||||
|
if (task == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + id, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 全部任务(ADMIN)--
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部任务(ADMIN 专用)。
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getAll(int page, int pageSize, String status, String taskType) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.orderByDesc(AnnotationTask::getCreatedAt);
|
||||||
|
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getStatus, status);
|
||||||
|
}
|
||||||
|
if (taskType != null && !taskType.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getTaskType, taskType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 指派(ADMIN)--
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADMIN 强制指派任务给指定用户(IN_PROGRESS → IN_PROGRESS)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reassign(Long taskId, Long targetUserId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null || !principal.getCompanyId().equals(task.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getClaimedBy, targetUserId)
|
||||||
|
.set(AnnotationTask::getClaimedAt, java.time.LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
task.getStatus(), "IN_PROGRESS",
|
||||||
|
principal.getUserId(), principal.getRole(),
|
||||||
|
"ADMIN 强制指派给用户 " + targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private PageResult<TaskResponse> toPageResult(Page<AnnotationTask> pageResult, int page, int pageSize) {
|
||||||
|
List<TaskResponse> items = pageResult.getRecords().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return PageResult.of(items, pageResult.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TaskResponse toPublicResponse(AnnotationTask task) {
|
||||||
|
return toResponse(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskResponse toResponse(AnnotationTask task) {
|
||||||
|
return TaskResponse.builder()
|
||||||
|
.id(task.getId())
|
||||||
|
.sourceId(task.getSourceId())
|
||||||
|
.taskType(task.getTaskType())
|
||||||
|
.status(task.getStatus())
|
||||||
|
.claimedBy(task.getClaimedBy())
|
||||||
|
.claimedAt(task.getClaimedAt())
|
||||||
|
.submittedAt(task.getSubmittedAt())
|
||||||
|
.completedAt(task.getCompletedAt())
|
||||||
|
.rejectReason(task.getRejectReason())
|
||||||
|
.createdAt(task.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.label.module.user.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.user.dto.LoginRequest;
|
||||||
|
import com.label.module.user.dto.LoginResponse;
|
||||||
|
import com.label.module.user.dto.UserInfoResponse;
|
||||||
|
import com.label.module.user.service.AuthService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证接口:登录、退出、获取当前用户。
|
||||||
|
*
|
||||||
|
* 路由设计:
|
||||||
|
* - POST /api/auth/login → 匿名(TokenFilter.shouldNotFilter 跳过)
|
||||||
|
* - POST /api/auth/logout → 需要有效 Token(TokenFilter 校验)
|
||||||
|
* - GET /api/auth/me → 需要有效 Token(TokenFilter 校验)
|
||||||
|
*/
|
||||||
|
@Tag(name = "认证管理", description = "登录、退出和当前用户信息")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录接口(匿名,无需 Token)。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "用户登录,返回 Bearer Token")
|
||||||
|
@PostMapping("/login")
|
||||||
|
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
|
||||||
|
return Result.success(authService.login(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录,立即删除 Redis Token。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "退出登录并立即失效当前 Token")
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public Result<Void> logout(HttpServletRequest request) {
|
||||||
|
String token = extractToken(request);
|
||||||
|
authService.logout(token);
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户信息。
|
||||||
|
* TokenPrincipal 由 TokenFilter 写入请求属性 "__token_principal__"。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "获取当前登录用户信息")
|
||||||
|
@GetMapping("/me")
|
||||||
|
public Result<UserInfoResponse> me(HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
return Result.success(authService.me(principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Authorization 头提取 Bearer token 字符串 */
|
||||||
|
private String extractToken(HttpServletRequest request) {
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
return authHeader.substring(7).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.label.module.user.controller;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.user.entity.SysUser;
|
||||||
|
import com.label.module.user.service.UserService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理接口(5 个端点,全部 ADMIN 权限)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "用户管理", description = "管理员维护公司用户")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/** GET /api/users — 分页查询用户列表 */
|
||||||
|
@Operation(summary = "分页查询用户列表")
|
||||||
|
@GetMapping
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<PageResult<SysUser>> listUsers(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(userService.listUsers(page, pageSize, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/users — 创建用户 */
|
||||||
|
@Operation(summary = "创建用户")
|
||||||
|
@PostMapping
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<SysUser> createUser(@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(userService.createUser(
|
||||||
|
body.get("username"),
|
||||||
|
body.get("password"),
|
||||||
|
body.get("realName"),
|
||||||
|
body.get("role"),
|
||||||
|
principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/users/{id} — 更新用户基本信息 */
|
||||||
|
@Operation(summary = "更新用户基本信息")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<SysUser> updateUser(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(userService.updateUser(
|
||||||
|
id,
|
||||||
|
body.get("realName"),
|
||||||
|
body.get("password"),
|
||||||
|
principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/users/{id}/status — 变更用户状态 */
|
||||||
|
@Operation(summary = "变更用户状态", description = "status:ACTIVE、DISABLED")
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<Void> updateStatus(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
userService.updateStatus(id, body.get("status"), principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/users/{id}/role — 变更用户角色 */
|
||||||
|
@Operation(summary = "变更用户角色", description = "role:ADMIN、UPLOADER、VIEWER")
|
||||||
|
@PutMapping("/{id}/role")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<Void> updateRole(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
userService.updateRole(id, body.get("role"), principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/main/java/com/label/module/user/dto/LoginRequest.java
Normal file
21
src/main/java/com/label/module/user/dto/LoginRequest.java
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package com.label.module.user.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求体。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "登录请求")
|
||||||
|
public class LoginRequest {
|
||||||
|
/** 公司代码(英文简写),用于确定租户 */
|
||||||
|
@Schema(description = "公司代码(英文简写)", example = "DEMO")
|
||||||
|
private String companyCode;
|
||||||
|
/** 登录用户名 */
|
||||||
|
@Schema(description = "登录用户名", example = "admin")
|
||||||
|
private String username;
|
||||||
|
/** 明文密码(传输层应使用 HTTPS 保护) */
|
||||||
|
@Schema(description = "明文密码", example = "admin123")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
29
src/main/java/com/label/module/user/dto/LoginResponse.java
Normal file
29
src/main/java/com/label/module/user/dto/LoginResponse.java
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package com.label.module.user.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录成功响应体。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "登录响应")
|
||||||
|
public class LoginResponse {
|
||||||
|
/** Bearer Token(UUID v4),后续请求放入 Authorization 头 */
|
||||||
|
@Schema(description = "Bearer Token", example = "550e8400-e29b-41d4-a716-446655440000")
|
||||||
|
private String token;
|
||||||
|
/** 用户主键 */
|
||||||
|
@Schema(description = "用户主键")
|
||||||
|
private Long userId;
|
||||||
|
/** 登录用户名 */
|
||||||
|
@Schema(description = "登录用户名")
|
||||||
|
private String username;
|
||||||
|
/** 角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
|
||||||
|
@Schema(description = "角色", example = "ADMIN")
|
||||||
|
private String role;
|
||||||
|
/** Token 有效期(秒) */
|
||||||
|
@Schema(description = "Token 有效期(秒)", example = "7200")
|
||||||
|
private Long expiresIn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.label.module.user.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/me 响应体,包含当前登录用户的详细信息。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "当前登录用户信息")
|
||||||
|
public class UserInfoResponse {
|
||||||
|
@Schema(description = "用户主键")
|
||||||
|
private Long id;
|
||||||
|
@Schema(description = "用户名")
|
||||||
|
private String username;
|
||||||
|
@Schema(description = "真实姓名")
|
||||||
|
private String realName;
|
||||||
|
@Schema(description = "角色", example = "ADMIN")
|
||||||
|
private String role;
|
||||||
|
@Schema(description = "所属公司 ID")
|
||||||
|
private Long companyId;
|
||||||
|
@Schema(description = "所属公司名称")
|
||||||
|
private String companyName;
|
||||||
|
}
|
||||||
34
src/main/java/com/label/module/user/entity/SysCompany.java
Normal file
34
src/main/java/com/label/module/user/entity/SysCompany.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package com.label.module.user.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户公司实体,对应 sys_company 表。
|
||||||
|
* status 取值:ACTIVE / DISABLED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_company")
|
||||||
|
public class SysCompany {
|
||||||
|
|
||||||
|
/** 公司主键,自增 */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 公司全称,全局唯一 */
|
||||||
|
private String companyName;
|
||||||
|
|
||||||
|
/** 公司代码(英文简写),全局唯一 */
|
||||||
|
private String companyCode;
|
||||||
|
|
||||||
|
/** 状态:ACTIVE / DISABLED */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
49
src/main/java/com/label/module/user/entity/SysUser.java
Normal file
49
src/main/java/com/label/module/user/entity/SysUser.java
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package com.label.module.user.entity;
|
||||||
|
|
||||||
|
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 lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统用户实体,对应 sys_user 表。
|
||||||
|
* role 取值:UPLOADER / ANNOTATOR / REVIEWER / ADMIN
|
||||||
|
* status 取值:ACTIVE / DISABLED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_user")
|
||||||
|
public class SysUser {
|
||||||
|
|
||||||
|
/** 用户主键,自增 */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司 ID(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 登录用户名(同公司内唯一) */
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt 哈希密码(strength ≥ 10)。
|
||||||
|
* 序列化时排除,防止密码哈希泄漏到 API 响应。
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
|
/** 真实姓名 */
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
/** 角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/** 状态:ACTIVE / DISABLED */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.label.module.user.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.user.entity.SysCompany;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_company 表 Mapper。
|
||||||
|
* 继承 BaseMapper 获得标准 CRUD;自定义方法用注解 SQL。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysCompanyMapper extends BaseMapper<SysCompany> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按公司代码查询公司(忽略多租户过滤,sys_company 无 company_id 字段)。
|
||||||
|
*
|
||||||
|
* @param companyCode 公司代码
|
||||||
|
* @return 公司实体,不存在则返回 null
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}")
|
||||||
|
SysCompany selectByCompanyCode(String companyCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.label.module.user.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.user.entity.SysUser;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_user 表 Mapper。
|
||||||
|
* 继承 BaseMapper 获得标准 CRUD;自定义登录查询方法绕过多租户过滤器,
|
||||||
|
* 由调用方显式传入 companyId。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按公司 ID + 用户名查询用户(登录场景使用)。
|
||||||
|
* <p>
|
||||||
|
* 使用 @InterceptorIgnore 绕过 TenantLineInnerInterceptor,
|
||||||
|
* 由参数 companyId 显式限定租户,防止登录时 CompanyContext 尚未注入
|
||||||
|
* 导致查询条件变为 {@code company_id = NULL}。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param companyId 公司 ID
|
||||||
|
* @param username 用户名
|
||||||
|
* @return 用户实体(含 passwordHash),不存在则返回 null
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'")
|
||||||
|
SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId,
|
||||||
|
@Param("username") String username);
|
||||||
|
}
|
||||||
140
src/main/java/com/label/module/user/service/AuthService.java
Normal file
140
src/main/java/com/label/module/user/service/AuthService.java
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package com.label.module.user.service;
|
||||||
|
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.redis.RedisKeyManager;
|
||||||
|
import com.label.common.redis.RedisService;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.user.dto.LoginRequest;
|
||||||
|
import com.label.module.user.dto.LoginResponse;
|
||||||
|
import com.label.module.user.dto.UserInfoResponse;
|
||||||
|
import com.label.module.user.entity.SysCompany;
|
||||||
|
import com.label.module.user.entity.SysUser;
|
||||||
|
import com.label.module.user.mapper.SysCompanyMapper;
|
||||||
|
import com.label.module.user.mapper.SysUserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务:登录、退出、查询当前用户信息。
|
||||||
|
*
|
||||||
|
* Token 生命周期:
|
||||||
|
* - 登录成功 → UUID v4 → Redis Hash token:{uuid} → TTL = token.ttl-seconds
|
||||||
|
* - 退出登录 → 直接 DEL token:{uuid}(立即失效)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final SysCompanyMapper companyMapper;
|
||||||
|
private final SysUserMapper userMapper;
|
||||||
|
private final RedisService redisService;
|
||||||
|
|
||||||
|
/** BCryptPasswordEncoder 线程安全,可复用 */
|
||||||
|
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
|
||||||
|
|
||||||
|
@Value("${token.ttl-seconds:7200}")
|
||||||
|
private long tokenTtlSeconds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录。
|
||||||
|
*
|
||||||
|
* @param request 包含 companyCode / username / password
|
||||||
|
* @return LoginResponse(含 token、userId、role、expiresIn)
|
||||||
|
* @throws BusinessException USER_NOT_FOUND(401) 凭证错误
|
||||||
|
* @throws BusinessException USER_DISABLED(403) 账号已禁用
|
||||||
|
*/
|
||||||
|
public LoginResponse login(LoginRequest request) {
|
||||||
|
// 1. 查公司(绕过多租户过滤器,sys_company 无 company_id 字段)
|
||||||
|
SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode());
|
||||||
|
if (company == null || !"ACTIVE".equals(company.getStatus())) {
|
||||||
|
// 公司不存在或禁用,统一报 USER_NOT_FOUND 防止信息泄漏
|
||||||
|
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查用户(显式传入 companyId,绕过多租户拦截器)
|
||||||
|
SysUser user = userMapper.selectByCompanyAndUsername(company.getId(), request.getUsername());
|
||||||
|
if (user == null) {
|
||||||
|
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 账号禁用检查(先于密码校验,防止暴力破解已知用户状态)
|
||||||
|
if (!"ACTIVE".equals(user.getStatus())) {
|
||||||
|
throw new BusinessException("USER_DISABLED", "账号已禁用,请联系管理员", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. BCrypt 密码校验
|
||||||
|
if (!PASSWORD_ENCODER.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 生成 UUID v4 Token,写入 Redis Hash
|
||||||
|
String token = UUID.randomUUID().toString();
|
||||||
|
Map<String, String> tokenData = new HashMap<>();
|
||||||
|
tokenData.put("userId", user.getId().toString());
|
||||||
|
tokenData.put("role", user.getRole());
|
||||||
|
tokenData.put("companyId", user.getCompanyId().toString());
|
||||||
|
tokenData.put("username", user.getUsername());
|
||||||
|
redisService.hSetAll(RedisKeyManager.tokenKey(token), tokenData, tokenTtlSeconds);
|
||||||
|
|
||||||
|
// 将 token 加入该用户的活跃会话集合(用于角色变更时批量更新/失效)
|
||||||
|
String sessionsKey = RedisKeyManager.userSessionsKey(user.getId());
|
||||||
|
redisService.sAdd(sessionsKey, token);
|
||||||
|
// 防止 Set 无限增长:TTL = token 有效期(最后一次登录时滑动续期)
|
||||||
|
redisService.expire(sessionsKey, tokenTtlSeconds);
|
||||||
|
|
||||||
|
log.info("用户登录成功: companyCode={}, username={}", request.getCompanyCode(), request.getUsername());
|
||||||
|
return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole(), tokenTtlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录,立即删除 Redis Token(Token 立即失效)。
|
||||||
|
*
|
||||||
|
* @param token 来自 Authorization 头的 Bearer token
|
||||||
|
*/
|
||||||
|
public void logout(String token) {
|
||||||
|
if (token != null && !token.isBlank()) {
|
||||||
|
// 从用户会话集合中移除(若 token 仍有效则先读取 userId)
|
||||||
|
String userId = redisService.hGet(RedisKeyManager.tokenKey(token), "userId");
|
||||||
|
redisService.delete(RedisKeyManager.tokenKey(token));
|
||||||
|
if (userId != null) {
|
||||||
|
try {
|
||||||
|
redisService.sRemove(RedisKeyManager.userSessionsKey(Long.parseLong(userId)), token);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
log.info("用户退出,Token 已删除: {}", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户详情(含 realName、companyName)。
|
||||||
|
*
|
||||||
|
* @param principal TokenFilter 注入的当前用户主体
|
||||||
|
* @return 用户信息响应体
|
||||||
|
*/
|
||||||
|
public UserInfoResponse me(TokenPrincipal principal) {
|
||||||
|
// 从 DB 获取 realName(Token 中未存储)
|
||||||
|
SysUser user = userMapper.selectById(principal.getUserId());
|
||||||
|
SysCompany company = companyMapper.selectById(principal.getCompanyId());
|
||||||
|
|
||||||
|
String realName = (user != null) ? user.getRealName() : principal.getUsername();
|
||||||
|
String companyName = (company != null) ? company.getCompanyName() : "";
|
||||||
|
|
||||||
|
return new UserInfoResponse(
|
||||||
|
principal.getUserId(),
|
||||||
|
principal.getUsername(),
|
||||||
|
realName,
|
||||||
|
principal.getRole(),
|
||||||
|
principal.getCompanyId(),
|
||||||
|
companyName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/main/java/com/label/module/user/service/UserService.java
Normal file
204
src/main/java/com/label/module/user/service/UserService.java
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package com.label.module.user.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.redis.RedisKeyManager;
|
||||||
|
import com.label.common.redis.RedisService;
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.user.entity.SysUser;
|
||||||
|
import com.label.module.user.mapper.SysUserMapper;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理服务(ADMIN 专属)。
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - 角色变更:DB 写入后立即更新所有活跃 Token 中的 role 字段,无需重新登录
|
||||||
|
* - 状态禁用:DB 写入后删除用户所有活跃 Token(立即失效)
|
||||||
|
* - 使用 user:sessions:{userId} Set 跟踪活跃会话
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
|
||||||
|
|
||||||
|
private final SysUserMapper userMapper;
|
||||||
|
private final RedisService redisService;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 创建用户 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新用户(ADMIN 操作)。
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @param password 明文密码(将以 BCrypt strength=10 哈希)
|
||||||
|
* @param realName 真实姓名(可选)
|
||||||
|
* @param role 角色(UPLOADER / ANNOTATOR / REVIEWER / ADMIN)
|
||||||
|
* @param principal 当前管理员
|
||||||
|
* @return 新建用户(不含 passwordHash)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SysUser createUser(String username, String password,
|
||||||
|
String realName, String role,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
// 校验用户名唯一性
|
||||||
|
SysUser existing = userMapper.selectByCompanyAndUsername(principal.getCompanyId(), username);
|
||||||
|
if (existing != null) {
|
||||||
|
throw new BusinessException("DUPLICATE_USERNAME",
|
||||||
|
"用户名 '" + username + "' 已存在", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateRole(role);
|
||||||
|
|
||||||
|
SysUser user = new SysUser();
|
||||||
|
user.setCompanyId(principal.getCompanyId());
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPasswordHash(PASSWORD_ENCODER.encode(password));
|
||||||
|
user.setRealName(realName);
|
||||||
|
user.setRole(role);
|
||||||
|
user.setStatus("ACTIVE");
|
||||||
|
userMapper.insert(user);
|
||||||
|
|
||||||
|
log.info("用户已创建: userId={}, username={}, role={}", user.getId(), username, role);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 更新基本信息 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户基本信息(realName、password)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SysUser updateUser(Long userId, String realName, String password,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
SysUser user = getExistingUser(userId, principal.getCompanyId());
|
||||||
|
|
||||||
|
LambdaUpdateWrapper<SysUser> wrapper = new LambdaUpdateWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getId, userId)
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId());
|
||||||
|
|
||||||
|
if (realName != null && !realName.isBlank()) {
|
||||||
|
wrapper.set(SysUser::getRealName, realName);
|
||||||
|
user.setRealName(realName);
|
||||||
|
}
|
||||||
|
if (password != null && !password.isBlank()) {
|
||||||
|
wrapper.set(SysUser::getPasswordHash, PASSWORD_ENCODER.encode(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
userMapper.update(null, wrapper);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 变更角色 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变更用户角色。
|
||||||
|
*
|
||||||
|
* DB 写入后,立即更新该用户所有活跃 Token 中的 role 字段,
|
||||||
|
* 确保角色变更对下一次请求立即生效(无需重新登录)。
|
||||||
|
*
|
||||||
|
* @param userId 目标用户 ID
|
||||||
|
* @param newRole 新角色
|
||||||
|
* @param principal 当前管理员
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateRole(Long userId, String newRole, TokenPrincipal principal) {
|
||||||
|
getExistingUser(userId, principal.getCompanyId());
|
||||||
|
validateRole(newRole);
|
||||||
|
|
||||||
|
// 1. DB 写入
|
||||||
|
userMapper.update(null, new LambdaUpdateWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getId, userId)
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId())
|
||||||
|
.set(SysUser::getRole, newRole));
|
||||||
|
|
||||||
|
// 2. 更新所有活跃 Token 中的 role 字段(立即生效,无需重新登录)
|
||||||
|
Set<String> tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId));
|
||||||
|
tokens.forEach(token -> redisService.hPut(RedisKeyManager.tokenKey(token), "role", newRole));
|
||||||
|
|
||||||
|
// 3. 删除权限缓存(如 Shiro 缓存存在)
|
||||||
|
redisService.delete(RedisKeyManager.userPermKey(userId));
|
||||||
|
|
||||||
|
log.info("用户角色已变更: userId={}, newRole={}, 更新 {} 个活跃 Token", userId, newRole, tokens.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 变更状态 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变更用户状态(启用/禁用)。
|
||||||
|
*
|
||||||
|
* 禁用时:DB 写入后立即删除该用户所有活跃 Token,现有会话立即失效。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateStatus(Long userId, String newStatus, TokenPrincipal principal) {
|
||||||
|
getExistingUser(userId, principal.getCompanyId());
|
||||||
|
|
||||||
|
if (!"ACTIVE".equals(newStatus) && !"DISABLED".equals(newStatus)) {
|
||||||
|
throw new BusinessException("INVALID_STATUS",
|
||||||
|
"状态值不合法,应为 ACTIVE 或 DISABLED", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 写入
|
||||||
|
userMapper.update(null, new LambdaUpdateWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getId, userId)
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId())
|
||||||
|
.set(SysUser::getStatus, newStatus));
|
||||||
|
|
||||||
|
// 禁用时:删除所有活跃 Token(立即失效)
|
||||||
|
if ("DISABLED".equals(newStatus)) {
|
||||||
|
Set<String> tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId));
|
||||||
|
tokens.forEach(token -> redisService.delete(RedisKeyManager.tokenKey(token)));
|
||||||
|
redisService.delete(RedisKeyManager.userSessionsKey(userId));
|
||||||
|
log.info("账号已禁用,已删除 {} 个活跃 Token: userId={}", tokens.size(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除权限缓存
|
||||||
|
redisService.delete(RedisKeyManager.userPermKey(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询当前公司用户列表。
|
||||||
|
*/
|
||||||
|
public PageResult<SysUser> listUsers(int page, int pageSize, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
Page<SysUser> result = userMapper.selectPage(
|
||||||
|
new Page<>(page, pageSize),
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId())
|
||||||
|
.orderByAsc(SysUser::getCreatedAt));
|
||||||
|
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private SysUser getExistingUser(Long userId, Long companyId) {
|
||||||
|
SysUser user = userMapper.selectById(userId);
|
||||||
|
if (user == null || !companyId.equals(user.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "用户不存在: " + userId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRole(String role) {
|
||||||
|
if (!List.of("UPLOADER", "ANNOTATOR", "REVIEWER", "ADMIN").contains(role)) {
|
||||||
|
throw new BusinessException("INVALID_ROLE",
|
||||||
|
"角色值不合法: " + role, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.label.module.video.controller;
|
||||||
|
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.common.shiro.TokenPrincipal;
|
||||||
|
import com.label.module.video.entity.VideoProcessJob;
|
||||||
|
import com.label.module.video.service.VideoProcessService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频处理接口(4 个端点)。
|
||||||
|
*
|
||||||
|
* POST /api/video/process — 触发视频处理(ADMIN)
|
||||||
|
* GET /api/video/jobs/{jobId} — 查询任务状态(ADMIN)
|
||||||
|
* POST /api/video/jobs/{jobId}/reset — 重置失败任务(ADMIN)
|
||||||
|
* POST /api/video/callback — AI 回调接口(无需认证,已在 TokenFilter 中排除)
|
||||||
|
*/
|
||||||
|
@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VideoController {
|
||||||
|
|
||||||
|
private final VideoProcessService videoProcessService;
|
||||||
|
|
||||||
|
@Value("${video.callback-secret:}")
|
||||||
|
private String callbackSecret;
|
||||||
|
|
||||||
|
/** POST /api/video/process — 触发视频处理任务 */
|
||||||
|
@Operation(summary = "触发视频处理任务")
|
||||||
|
@PostMapping("/api/video/process")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<VideoProcessJob> createJob(@RequestBody Map<String, Object> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Object sourceIdVal = body.get("sourceId");
|
||||||
|
Object jobTypeVal = body.get("jobType");
|
||||||
|
if (sourceIdVal == null || jobTypeVal == null) {
|
||||||
|
return Result.failure("INVALID_PARAMS", "sourceId 和 jobType 不能为空");
|
||||||
|
}
|
||||||
|
Long sourceId = Long.parseLong(sourceIdVal.toString());
|
||||||
|
String jobType = jobTypeVal.toString();
|
||||||
|
String params = body.containsKey("params") ? body.get("params").toString() : null;
|
||||||
|
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(
|
||||||
|
videoProcessService.createJob(sourceId, jobType, params, principal.getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/video/jobs/{jobId} — 查询视频处理任务 */
|
||||||
|
@Operation(summary = "查询视频处理任务状态")
|
||||||
|
@GetMapping("/api/video/jobs/{jobId}")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<VideoProcessJob> getJob(@PathVariable Long jobId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/video/jobs/{jobId}/reset — 管理员重置失败任务 */
|
||||||
|
@Operation(summary = "重置失败的视频处理任务")
|
||||||
|
@PostMapping("/api/video/jobs/{jobId}/reset")
|
||||||
|
@RequiresRoles("ADMIN")
|
||||||
|
public Result<VideoProcessJob> resetJob(@PathVariable Long jobId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(videoProcessService.reset(jobId, principal(request).getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/video/callback — AI 服务回调(无需 Bearer Token)。
|
||||||
|
*
|
||||||
|
* 此端点已在 TokenFilter.shouldNotFilter() 中排除认证,
|
||||||
|
* 由 AI 服务直接调用,携带 jobId、status、outputPath 等参数。
|
||||||
|
*
|
||||||
|
* Body 示例:
|
||||||
|
* { "jobId": 123, "status": "SUCCESS", "outputPath": "processed/123/frames.zip" }
|
||||||
|
* { "jobId": 123, "status": "FAILED", "errorMessage": "ffmpeg error: ..." }
|
||||||
|
*/
|
||||||
|
@Operation(summary = "接收 AI 服务视频处理回调")
|
||||||
|
@PostMapping("/api/video/callback")
|
||||||
|
public Result<Void> handleCallback(@RequestBody Map<String, Object> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
// 共享密钥校验(配置了 VIDEO_CALLBACK_SECRET 时强制校验)
|
||||||
|
if (callbackSecret != null && !callbackSecret.isBlank()) {
|
||||||
|
String provided = request.getHeader("X-Callback-Secret");
|
||||||
|
if (!callbackSecret.equals(provided)) {
|
||||||
|
return Result.failure("UNAUTHORIZED", "回调密钥无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Long jobId = Long.parseLong(body.get("jobId").toString());
|
||||||
|
String status = (String) body.get("status");
|
||||||
|
String outputPath = body.containsKey("outputPath") ? (String) body.get("outputPath") : null;
|
||||||
|
String errorMessage = body.containsKey("errorMessage") ? (String) body.get("errorMessage") : null;
|
||||||
|
|
||||||
|
log.info("视频处理回调:jobId={}, status={}", jobId, status);
|
||||||
|
videoProcessService.handleCallback(jobId, status, outputPath, errorMessage);
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.label.module.video.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频处理任务实体,对应 video_process_job 表。
|
||||||
|
*
|
||||||
|
* jobType 取值:FRAME_EXTRACT / VIDEO_TO_TEXT
|
||||||
|
* status 取值:PENDING / RUNNING / SUCCESS / FAILED / RETRYING
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("video_process_job")
|
||||||
|
public class VideoProcessJob {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 关联资料 ID */
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
/** 任务类型:FRAME_EXTRACT / VIDEO_TO_TEXT */
|
||||||
|
private String jobType;
|
||||||
|
|
||||||
|
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED / RETRYING */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 任务参数(JSONB,例如 {"frameInterval": 30}) */
|
||||||
|
private String params;
|
||||||
|
|
||||||
|
/** AI 处理输出路径(成功后填写) */
|
||||||
|
private String outputPath;
|
||||||
|
|
||||||
|
/** 已重试次数 */
|
||||||
|
private Integer retryCount;
|
||||||
|
|
||||||
|
/** 最大重试次数(默认 3) */
|
||||||
|
private Integer maxRetries;
|
||||||
|
|
||||||
|
/** 错误信息 */
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.label.module.video.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.module.video.entity.VideoProcessJob;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* video_process_job 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface VideoProcessJobMapper extends BaseMapper<VideoProcessJob> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package com.label.module.video.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
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.module.source.entity.SourceData;
|
||||||
|
import com.label.module.source.mapper.SourceDataMapper;
|
||||||
|
import com.label.module.video.entity.VideoProcessJob;
|
||||||
|
import com.label.module.video.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;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频处理服务:创建任务、处理回调、管理员重置。
|
||||||
|
*
|
||||||
|
* 状态流转:
|
||||||
|
* - 创建时:source_data → PREPROCESSING,job → PENDING
|
||||||
|
* - 回调成功:job → SUCCESS,source_data → PENDING(进入提取队列)
|
||||||
|
* - 回调失败(可重试):job → RETRYING,retryCount++,重新触发 AI
|
||||||
|
* - 回调失败(超出上限):job → FAILED,source_data → PENDING
|
||||||
|
* - 管理员重置:job → PENDING(可手动重新触发)
|
||||||
|
*
|
||||||
|
* T074 设计说明:
|
||||||
|
* AI 调用通过 TransactionSynchronizationManager.registerSynchronization().afterCommit()
|
||||||
|
* 延迟到事务提交后执行,避免在持有 DB 连接期间进行 HTTP 调用。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VideoProcessService {
|
||||||
|
|
||||||
|
private final VideoProcessJobMapper jobMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(sourceId);
|
||||||
|
if (source == null || !companyId.equals(source.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "资料不存在: " + sourceId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateJobType(jobType);
|
||||||
|
|
||||||
|
// source_data → PREPROCESSING
|
||||||
|
StateValidator.assertTransition(
|
||||||
|
SourceStatus.TRANSITIONS,
|
||||||
|
SourceStatus.valueOf(source.getStatus()), SourceStatus.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);
|
||||||
|
job.setJobType(jobType);
|
||||||
|
job.setStatus("PENDING");
|
||||||
|
job.setParams(params != null ? params : "{}");
|
||||||
|
job.setRetryCount(0);
|
||||||
|
job.setMaxRetries(3);
|
||||||
|
jobMapper.insert(job);
|
||||||
|
|
||||||
|
// 事务提交后触发 AI(不在事务内,不占用 DB 连接)
|
||||||
|
final Long jobId = job.getId();
|
||||||
|
final String filePath = source.getFilePath();
|
||||||
|
final String finalJobType = jobType;
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
triggerAi(jobId, sourceId, filePath, finalJobType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("视频处理任务已创建(AI 将在事务提交后触发): 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),此处显式校验
|
||||||
|
VideoProcessJob job = jobMapper.selectById(jobId);
|
||||||
|
if (job == null || job.getCompanyId() == null) {
|
||||||
|
log.warn("视频处理回调:job 不存在,jobId={}", jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等:已成功则忽略重复回调
|
||||||
|
if ("SUCCESS".equals(job.getStatus())) {
|
||||||
|
log.info("视频处理回调幂等:jobId={} 已为 SUCCESS,跳过", jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("SUCCESS".equals(callbackStatus)) {
|
||||||
|
handleSuccess(job, outputPath);
|
||||||
|
} else {
|
||||||
|
handleFailure(job, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 管理员重置 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员手动重置失败任务(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"FAILED".equals(job.getStatus())) {
|
||||||
|
throw new BusinessException("INVALID_TRANSITION",
|
||||||
|
"只有 FAILED 状态的任务可以重置,当前状态: " + job.getStatus(),
|
||||||
|
HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
||||||
|
.eq(VideoProcessJob::getId, jobId)
|
||||||
|
.set(VideoProcessJob::getStatus, "PENDING")
|
||||||
|
.set(VideoProcessJob::getRetryCount, 0)
|
||||||
|
.set(VideoProcessJob::getErrorMessage, null)
|
||||||
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
.set(VideoProcessJob::getOutputPath, outputPath)
|
||||||
|
.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) {
|
||||||
|
int newRetryCount = job.getRetryCount() + 1;
|
||||||
|
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")
|
||||||
|
.set(VideoProcessJob::getRetryCount, newRetryCount)
|
||||||
|
.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 String filePath = source.getFilePath();
|
||||||
|
final String jobType = job.getJobType();
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
triggerAi(jobId, sourceId, filePath, jobType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 超出最大重试次数:job → FAILED,source_data → PENDING
|
||||||
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
||||||
|
.eq(VideoProcessJob::getId, job.getId())
|
||||||
|
.set(VideoProcessJob::getStatus, "FAILED")
|
||||||
|
.set(VideoProcessJob::getRetryCount, newRetryCount)
|
||||||
|
.set(VideoProcessJob::getErrorMessage, errorMessage)
|
||||||
|
.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();
|
||||||
|
try {
|
||||||
|
if ("FRAME_EXTRACT".equals(jobType)) {
|
||||||
|
aiServiceClient.extractFrames(req);
|
||||||
|
} else {
|
||||||
|
aiServiceClient.videoToText(req);
|
||||||
|
}
|
||||||
|
log.info("AI 触发成功: jobId={}", jobId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("触发视频处理 AI 失败(jobId={}):{},job 保持当前状态,需管理员手动重置", jobId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user