Compare commits

..

16 Commits

Author SHA1 Message Date
wh
756734db44 修改gitignore 2026-04-14 21:14:06 +08:00
wh
8ba3de17ab 停止追踪specs,docs等目录文件 2026-04-14 21:04:37 +08:00
wh
5839bc2ece 修改redis地址 2026-04-14 20:45:23 +08:00
wh
b0e2b3c81a 黑盒测试用例 2026-04-14 20:00:37 +08:00
wh
999856e110 修改相关资源路径 2026-04-14 18:36:28 +08:00
wh
a30b648d30 去掉shiro框架 2026-04-14 16:33:34 +08:00
wh
158873d5ae 项目结构类名称优化 2026-04-14 15:26:08 +08:00
wh
ceaac48051 优化现有目录结构 2026-04-14 14:59:46 +08:00
wh
c524fb08e1 refactor: complete backend directory flattening 2026-04-14 13:50:51 +08:00
wh
ba42b6f50e refactor: flatten controller packages 2026-04-14 13:47:38 +08:00
wh
ef1e4f5106 refactor: flatten service packages 2026-04-14 13:45:15 +08:00
wh
0dbb88b803 refactor: flatten dto entity and mapper packages 2026-04-14 13:39:24 +08:00
wh
3e33398dd2 Revert "refactor: flatten dto entity and mapper packages"
This reverts commit 29766ebd28.
2026-04-14 13:31:50 +08:00
wh
29766ebd28 refactor: flatten dto entity and mapper packages 2026-04-14 13:28:10 +08:00
wh
0af19cf1b5 refactor: flatten infrastructure packages 2026-04-14 13:19:39 +08:00
wh
e3c796da27 docs: add backend directory flattening design 2026-04-14 12:41:46 +08:00
117 changed files with 2320 additions and 7212 deletions

4
.gitignore vendored
View File

@@ -6,7 +6,9 @@ target/
*.jar
*.war
*.ear
docs/
specs/
CLAUDE.md
# ==========================================
# 2. IDE 配置文件
# ==========================================

View File

@@ -1,3 +0,0 @@
# language
请始终使用简体中文与我对话,并保持回答专业、简洁。

View File

@@ -14,7 +14,7 @@ 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/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

404
README.md
View File

@@ -1,2 +1,404 @@
# label_backend
# label-backend
## 项目简介
`label-backend` 是知识图谱智能标注平台的后端服务,负责资料上传、任务分发、提取标注、问答生成、训练样本导出、系统配置和视频预处理等核心流程。
系统采用 Spring Boot 3 + MyBatis-Plus + PostgreSQL + Redis 的技术组合,面向多租户标注场景设计,支持:
- 公司级数据隔离
- 基于 Redis Token 的认证鉴权
- 提取与问答两阶段标注流程
- 导出训练数据并对接微调任务
- 视频预处理与异步回调
- 审计日志与任务状态追踪
代码结构已按扁平标准目录整理,主包位于 `src/main/java/com/label`
## 功能特性
- 认证鉴权
- 使用 UUID Bearer Token + Redis 会话存储
- 自定义 `@RequireAuth``@RequireRole`
- 角色分级:`ADMIN > REVIEWER > ANNOTATOR > UPLOADER`
- 多租户隔离
- 基于 `CompanyContext` + `TenantLineInnerInterceptor`
- 对租户表自动追加 `company_id` 条件
- 特殊表通过显式 `companyId` 参数校验
- 公司与用户管理
- 公司 CRUD
- 公司内用户创建、状态变更、角色变更
- 角色变更和禁用后即时刷新或失效 Redis Token
- 资料管理
- 支持文本、图片、视频三类原始资料
- 上传到 RustFS数据库保存元数据
- 支持按角色查看、查询详情、删除
- 任务管理
- 任务池、我的任务、待审批队列、管理员全量视图
- Redis 分布式锁 + 数据库原子更新保证任务领取并发安全
- 提取标注
- AI 预标注
- 标注结果整体覆盖更新
- 提交、审批通过、驳回
- 问答生成
- 基于提取审批通过事件生成候选问答对
- 支持编辑、提交、审批、驳回
- 训练数据导出
- 查询已审批样本
- 创建导出批次
- 触发微调任务并查询状态
- 系统配置
- 支持公司专属配置覆盖全局默认配置
- 配置项存储于 `sys_config`
- 视频处理
- 支持触发视频预处理任务
- 支持异步回调、失败重试、管理员重置
## 技术栈
- Java 21
- Spring Boot 3.1.5
- Spring MVC
- MyBatis-Plus 3.5.3.1
- PostgreSQL
- Redis
- Spring AOP
- springdoc-openapi
- Testcontainers
- RustFS / S3 兼容对象存储
## 项目结构
项目根目录结构:
```text
label_backend/
├── assembly/ # 分发包描述与占位目录
├── docs/ # 设计、计划、规范文档
├── scripts/ # 启动脚本
├── src/
│ ├── main/
│ │ ├── java/com/label/ # 主代码
│ │ └── resources/
│ │ ├── application.yml
│ │ ├── logback.xml
│ │ └── sql/ # 初始化 SQL不打入构建产物
│ └── test/
│ ├── java/ # 单元测试与集成测试
│ └── resources/db/init.sql # Testcontainers 测试初始化 SQL
├── docker-compose.yml
├── Dockerfile
├── pom.xml
└── README.md
```
Java 包结构:
```text
com.label
├── annotation # 自定义注解,如 RequireAuth / RequireRole / OperationLog
├── aspect # AOP 审计切面
├── common # 通用能力auth、context、exception、result、storage、ai、statemachine
├── config # Spring 配置、MyBatis-Plus 配置、认证拦截器注册
├── controller # 所有 REST 接口
├── dto # DTO
├── entity # 实体
├── event # 领域事件
├── interceptor # 认证拦截器
├── listener # 事件监听器
├── mapper # MyBatis Mapper
├── service # 业务服务
└── util # 工具类
```
## 数据库表结构
初始化脚本位于 [init.sql](d:/workspace/label/label_backend/src/main/resources/sql/init.sql),包含 11 张核心表:
- `sys_company`
- 租户公司表
- `sys_user`
- 公司用户表,包含角色与状态
- `source_data`
- 原始资料元数据,支持 `TEXT` / `IMAGE` / `VIDEO`
- `annotation_task`
- 标注任务表,支持 `EXTRACTION` / `QA_GENERATION`
- `annotation_result`
- 提取阶段 JSON 结果
- `training_dataset`
- 训练样本数据,存储 GLM 格式 JSON
- `export_batch`
- 导出批次与微调任务状态
- `sys_config`
- 全局与公司级配置
- `sys_operation_log`
- 审计日志,只追加不更新
- `annotation_task_history`
- 任务状态变更历史
- `video_process_job`
- 视频预处理任务与回调状态
当前主要状态机:
- `source_data.status`
- `PENDING` / `PREPROCESSING` / `EXTRACTING` / `QA_REVIEW` / `APPROVED`
- `annotation_task.status`
- `UNCLAIMED` / `IN_PROGRESS` / `SUBMITTED` / `APPROVED` / `REJECTED`
- `training_dataset.status`
- `PENDING_REVIEW` / `APPROVED` / `REJECTED`
- `video_process_job.status`
- `PENDING` / `RETRYING` / `SUCCESS` / `FAILED`
## 配置说明
主配置文件位于 [application.yml](d:/workspace/label/label_backend/src/main/resources/application.yml)。
### 环境变量
| 变量名 | 说明 |
|---|---|
| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC 地址 |
| `SPRING_DATASOURCE_USERNAME` | PostgreSQL 用户名 |
| `SPRING_DATASOURCE_PASSWORD` | PostgreSQL 密码 |
| `SPRING_DATA_REDIS_HOST` | Redis 主机 |
| `SPRING_DATA_REDIS_PORT` | Redis 端口 |
| `SPRING_DATA_REDIS_PASSWORD` | Redis 密码 |
| `RUSTFS_ENDPOINT` | RustFS / S3 兼容服务地址 |
| `RUSTFS_ACCESS_KEY` | RustFS Access Key |
| `RUSTFS_SECRET_KEY` | RustFS Secret Key |
| `AI_SERVICE_BASE_URL` | AI 服务地址 |
| `VIDEO_CALLBACK_SECRET` | 视频处理回调共享密钥 |
### 关键配置项
- `auth.enabled`
- `true` 时启用真实 Token 鉴权
- `false` 时使用 mock 身份,便于本地开发
- `auth.mock-company-id`
- 开发模式下的模拟公司 ID
- `auth.mock-user-id`
- 开发模式下的模拟用户 ID
- `auth.mock-role`
- 开发模式下的模拟角色
- `token.ttl-seconds`
- Token 有效期,默认 7200 秒
- `springdoc.api-docs.path`
- OpenAPI 文档路径,默认 `/v3/api-docs`
- `springdoc.swagger-ui.path`
- Swagger UI 路径,默认 `/swagger-ui.html`
## API接口
以下为当前主要接口分组。
### 1. 认证接口
- `POST /api/auth/login`
- 登录并返回 Bearer Token
- `POST /api/auth/logout`
- 登出并立即失效当前 Token
- `GET /api/auth/me`
- 获取当前登录用户信息
### 2. 公司管理
- `GET /api/companies`
- `POST /api/companies`
- `PUT /api/companies/{id}`
- `PUT /api/companies/{id}/status`
- `DELETE /api/companies/{id}`
### 3. 用户管理
- `GET /api/users`
- `POST /api/users`
- `PUT /api/users/{id}`
- `PUT /api/users/{id}/status`
- `PUT /api/users/{id}/role`
### 4. 资料管理
- `POST /api/source/upload`
- `GET /api/source/list`
- `GET /api/source/{id}`
- `DELETE /api/source/{id}`
### 5. 任务管理
- `GET /api/tasks/pool`
- `GET /api/tasks/mine`
- `GET /api/tasks/pending-review`
- `GET /api/tasks`
- `POST /api/tasks`
- `GET /api/tasks/{id}`
- `POST /api/tasks/{id}/claim`
- `POST /api/tasks/{id}/unclaim`
- `POST /api/tasks/{id}/reclaim`
- `PUT /api/tasks/{id}/reassign`
### 6. 提取标注
- `GET /api/extraction/{taskId}`
- `PUT /api/extraction/{taskId}`
- `POST /api/extraction/{taskId}/submit`
- `POST /api/extraction/{taskId}/approve`
- `POST /api/extraction/{taskId}/reject`
### 7. 问答生成
- `GET /api/qa/{taskId}`
- `PUT /api/qa/{taskId}`
- `POST /api/qa/{taskId}/submit`
- `POST /api/qa/{taskId}/approve`
- `POST /api/qa/{taskId}/reject`
### 8. 导出与微调
- `GET /api/training/samples`
- `POST /api/export/batch`
- `POST /api/export/{batchId}/finetune`
- `GET /api/export/{batchId}/status`
- `GET /api/export/list`
### 9. 系统配置
- `GET /api/config`
- `PUT /api/config/{key}`
### 10. 视频处理
- `POST /api/video/process`
- `GET /api/video/jobs/{jobId}`
- `POST /api/video/jobs/{jobId}/reset`
- `POST /api/video/callback`
## 定时任务
当前项目中**没有启用 Spring `@Scheduled` 定时同步任务**。
现有异步能力主要通过以下方式完成:
- 事务提交后事件监听
- 提取审批通过后触发问答生成
- 外部 AI 服务异步回调
- 视频处理完成后回调 `/api/video/callback`
- Redis 分布式锁
- 用于任务领取并发控制
如果后续需要周期性任务,建议单独引入明确的调度场景,不要复用当前业务链路中的事件机制。
## 部署说明
### 1. 数据库初始化
初始化 SQL 位于:
- 开发/部署初始化脚本
- [src/main/resources/sql/init.sql](d:/workspace/label/label_backend/src/main/resources/sql/init.sql)
说明:
- `src/main/resources/sql/init.sql` 会随源码保存,但**不会被打入 jar、target/classes 或分发包**
- `docker-compose.yml` 通过挂载该文件完成 PostgreSQL 初始化
### 2. 本地构建
```bash
mvn clean package -DskipTests
```
构建产物:
- `target/libs/`
- 薄 jar 与运行时依赖
- `target/label-backend-1.0.0-SNAPSHOT.zip`
- `target/label-backend-1.0.0-SNAPSHOT.tar.gz`
### 3. 分发包结构
分发包由 [distribution.xml](d:/workspace/label/label_backend/assembly/distribution.xml) 组装,解压后结构如下:
```text
label-backend-<version>/
├── bin/
│ └── start.sh
├── etc/
│ ├── application.yml
│ └── logback.xml
├── libs/
│ ├── label-backend-<version>.jar
│ └── *.jar
└── logs/
```
### 4. 启动脚本
启动脚本位于 [start.sh](d:/workspace/label/label_backend/scripts/start.sh)。
行为说明:
- 在 Docker 容器中检测到 `/.dockerenv` 时,前台 `exec java ...`
- 在宿主机环境中使用 `nohup` 后台启动
- 日志默认写入 `logs/startup.log`
### 5. Docker Compose 启动
```bash
docker compose up -d
```
当前 `docker-compose.yml` 会启动:
- PostgreSQL
- Redis
- RustFS当前使用 MinIO 作为 S3 兼容替代)
- backend
- ai-service 占位服务
- frontend 占位服务
### 6. Docker 镜像构建
```bash
docker build -t label-backend:latest .
```
`Dockerfile` 使用多阶段构建,并从项目根目录的 `scripts/start.sh` 复制启动脚本。
## 注意事项
1. 开发模式下 `auth.enabled=false`
- 此时会使用 mock 用户身份,不适合生产环境
- 生产部署前必须显式启用真实鉴权
2. 多租户隔离仍依赖 `CompanyContext` + `TenantLineInnerInterceptor`
- 租户表查询默认依赖租户拦截器
- 个别特殊场景通过显式 `companyId` 参数校验
3. `sys_config``sys_company``video_process_job` 属于特殊表
- 其中部分表被排除出自动租户注入,需在服务层显式控制
4. SQL 已迁移到 `src/main/resources/sql`
- 仅作为源码级初始化文件保留
- 不会打进构建产物
5. 集成测试依赖 Testcontainers
- 运行完整集成测试需要本机可用 Docker 环境
6. 认证实现已移除 Shiro
- 当前使用自定义拦截器、注解与 Redis Token
7. 用户上下文 ThreadLocal 已移除
- 当前只保留 `CompanyContext`
- 用户主体通过请求属性中的 `TokenPrincipal` 传递
## 开发规范
项目实现以以下文档为准:
- 总体设计
- [2026-04-09-label-backend-design.md](d:/workspace/label/label_backend/docs/superpowers/specs/2026-04-09-label-backend-design.md)
- 目录扁平化设计
- [2026-04-14-label-backend-directory-flattening-design.md](d:/workspace/label/label_backend/docs/superpowers/specs/2026-04-14-label-backend-directory-flattening-design.md)
当前约束摘要:
- 统一扁平目录结构,避免再次引入按业务域分层的旧目录
- DTO 统一放在 `dto/`,不再拆分 `request/response`
- Service 统一放在 `service/`,不拆 `service/impl`
- 业务规则优先放在 ServiceController 只负责 HTTP 协议层
- 新增接口需同步补齐 Swagger 注解与测试
- 目录、配置、打包方式变化后README、设计文档和部署说明必须同步更新

View File

@@ -12,7 +12,7 @@
<!-- bin/start.sh0755 可执行) -->
<files>
<file>
<source>src/main/scripts/start.sh</source>
<source>scripts/start.sh</source>
<outputDirectory>bin</outputDirectory>
<fileMode>0755</fileMode>
</file>
@@ -40,7 +40,7 @@
<!-- logs/ 空目录占位 -->
<fileSet>
<directory>src/main/assembly/empty-logs</directory>
<directory>assembly/empty-logs</directory>
<outputDirectory>logs</outputDirectory>
</fileSet>
</fileSets>

View File

@@ -11,7 +11,7 @@ services:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
- ./src/main/resources/sql/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U label -d label_db"]
interval: 10s

View File

@@ -1,517 +0,0 @@
# 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.xmlINFO 级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 环境(检测 /.dockerenvexec 前台运行,保持容器进程存活
# - 裸机 / VMnohup 后台运行,日志追加至 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
# 裸机 / VMnohup 后台运行
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.shDocker 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.sh0755 可执行) -->
<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.info11 文件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.info11 文件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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

95
pom.xml
View File

@@ -3,24 +3,24 @@
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>
<version>3.1.5</version>
<relativePath/>
</parent>
<groupId>com.label</groupId>
<artifactId>label-backend</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<postgrescp.version>42.2.24</postgrescp.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<springdoc-openapi.version>2.3.0</springdoc-openapi.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<!-- AWS SDK v2 BOM -->
@@ -41,132 +41,89 @@
</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>
<version>${postgrescp.version}</version>
<scope>runtime</scope>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<!-- <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> -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.10</version>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- JSqlParser required by TenantLineInnerInterceptor in MyBatis-Plus 3.5.3.1 -->
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.4</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
<version>2.3.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>
@@ -174,10 +131,16 @@
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<excludes>
<exclude>sql/**</exclude>
</excludes>
</resource>
</resources>
<plugins>
<!-- 薄 jar仅打包编译后的 class输出到 target/libs/ -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -192,7 +155,6 @@
</archive>
</configuration>
</plugin>
<!-- 将所有运行时依赖复制到 target/libs/ -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -211,7 +173,6 @@
</execution>
</executions>
</plugin>
<!-- 组装分发包zip + tar.gz -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -225,7 +186,7 @@
</goals>
<configuration>
<descriptors>
<descriptor>src/main/assembly/distribution.xml</descriptor>
<descriptor>assembly/distribution.xml</descriptor>
</descriptors>
<finalName>${project.artifactId}-${project.version}</finalName>
<appendAssemblyId>false</appendAssemblyId>
@@ -233,8 +194,6 @@
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,34 +0,0 @@
# 规格质量检查清单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` 规划阶段。

View File

@@ -1,148 +0,0 @@
# 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`: 角色值不合法

View File

@@ -1,53 +0,0 @@
# 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`: 未知的配置键(防止拼写错误创建无效配置)

View File

@@ -1,113 +0,0 @@
# 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 等字段)

View File

@@ -1,97 +0,0 @@
# 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`: 驳回原因不能为空

View File

@@ -1,83 +0,0 @@
# 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`: 驳回原因不能为空

View File

@@ -1,96 +0,0 @@
# 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`: 资料已进入标注流程,不可删除

View File

@@ -1,150 +0,0 @@
# 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, ...}}`

View File

@@ -1,87 +0,0 @@
# 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}`

View File

@@ -1,355 +0,0 @@
# 数据模型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 → APPROVEDQA 任务审批通过后,整条流水线完成)
```
*注source_data 无 REJECTED 状态。QA 阶段驳回作用于 annotation_task→REJECTEDsource_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_PROGRESSADMIN 强制转移,持有人变更,状态不变)
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 → APPROVEDQA 审批通过)
PENDING_REVIEW → REJECTEDQA 审批驳回)
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 → RUNNINGAI 服务自动重试)
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 用于认证、权限或锁目的。*

View File

@@ -1,137 +0,0 @@
# 实施计划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 17LTS
**主要依赖**: Spring Boot ≥ 3.0.x、Apache Shiro ≥ 1.13.x、MyBatis Plus ≥ 3.5.x、Spring Data Redis
**存储**: PostgreSQL ≥ 14主库、Redis ≥ 6.x会话/权限缓存/分布式锁、RustFSS3 兼容对象存储)
**测试**: JUnit 5 + Testcontainers真实 PostgreSQL + Redis 实例、Spring Boot Test
**目标平台**: Linux 服务器Docker Compose 容器化部署
**项目类型**: Web ServiceREST API
**性能目标**: 任务领取并发下有且仅有一人成功;权限变更延迟 < 1 秒生效
**约束**: 禁止 JWT禁止 Spring Security禁止文件字节流存入数据库AI HTTP 调用禁止在 @Transactional 内同步执行所有列表接口强制分页
**规模**: 多租户多公司每公司独立数据空间11 张核心业务表
---
## 宪章合规检查
*门控Phase 0 研究前必须通过。Phase 1 设计后重检。*
| # | 宪章原则 | 状态 | 说明 |
|---|---------|------|------|
| 1 | 环境约束JDK 17SB 3ShiroMyBatis Plus | 通过 | pom.xml 中版本约束与宪章完全对齐 Spring Security 引入 |
| 2 | 多租户数据隔离company_id + ThreadLocal | 通过 | TenantLineInnerInterceptor 自动注入CompanyContext finally 块清理 |
| 3 | BCrypt 密码 + UUID Token + JWT | 通过 | AuthService 使用 BCrypt 10UUID 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 AOPsys_operation_log UPDATE/DELETE异常仅 error 日志 |
| 10 | RESTful URL + 统一响应格式 + 强制分页 | 通过 | Result<T> 包装无动词路径PageResult<T> 分页 |
| 11 | YAGNI业务在 ServiceController 只处理 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/ # CompanyContextThreadLocal
│ ├── shiro/ # TokenFilter、UserRealm、ShiroConfig
│ ├── redis/ # RedisKeyManager、RedisService
│ ├── aop/ # AuditAspect、@OperationLog 注解
│ ├── storage/ # RustFsClientS3 兼容封装)
│ ├── ai/ # AiServiceClientRestClient 封装 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 # 全部 DDL11 张表,按依赖顺序执行)
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.

View File

@@ -1,179 +0,0 @@
# 快速启动指南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` | 会话凭证有效期(秒) | 72002小时 |
| `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"
```

View File

@@ -1,150 +0,0 @@
# 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防止线程池复用时数据串漏
**备选方案放弃理由**:
- 行级安全RLSPostgreSQL 原生支持,但与 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对象存储路径规范
**决策**: RustFSS3 兼容),文件字节流禁止入库,路径按资源类型分桶分目录
**路径规范**:
| 资源 | 桶 | 路径格式 |
|------|-----|---------|
| 文本文件 | `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 → 401Token 有效但角色不足 → 403
---
## 无需澄清事项汇总
| 项目 | 状态 | 来源 |
|------|------|------|
| 认证方案 | ✅ 已确定UUID Token | 宪章原则三 |
| 数据库选型 | ✅ 已确定PostgreSQL | 宪章原则一 |
| ORM | ✅ 已确定MyBatis Plus | 宪章原则一 |
| 缓存/锁 | ✅ 已确定Redis | 宪章原则一 |
| 对象存储 | ✅ 已确定RustFS S3 | 宪章原则一 |
| AI 集成方式 | ✅ 已确定HTTP RestClient | 宪章原则一 |
| 多租户隔离 | ✅ 已确定ThreadLocal + Interceptor | 宪章原则二 |
| 并发控制 | ✅ 已确定(双重锁) | 宪章原则七 |
| 审批事务边界 | ✅ 已确定(@TransactionalEventListener | 宪章原则五 |
| 测试策略 | ✅ 已确定Testcontainers | 宪章开发工作流 |

View File

@@ -1,273 +0,0 @@
# 功能规格说明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 模型
- 标注流水线中一条资料同一时间只有一个活跃的提取任务或问答生成任务,不支持并行多版本标注
- 审计日志的长期归档(超过月分区范围)由数据库运维团队负责,不在本系统范围内

View File

@@ -1,310 +0,0 @@
# 任务清单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` ArtifactIdJava 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] 集成测试:正确密码登录返回 TokenToken 有效时 `/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/EXTRACTIONREVIEWER→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 状态 APPROVEDsource_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 3US1 认证) ← 可与 Phase 4/5/6/7/8/9 并行
Phase 4US2 上传) ← 依赖 Phase 2独立于其他用户故事
Phase 5US3+4 提取) ← 依赖 Phase 2上传已有资料的集成测试依赖 US2
Phase 6US5 QA ← 依赖 Phase 5 完成QA 任务由提取审批自动创建)
Phase 7US6 导出) ← 依赖 Phase 6 完成(需要 APPROVED 的 training_dataset
Phase 8US7 用户管理) ← 依赖 Phase 3UserService 在 AuthService 基础上扩展)
Phase 9US8 视频+配置) ← 依赖 Phase 2其余独立
Phase 10收尾
```
### 用户故事间依赖
- **US1认证**: 仅依赖 Phase 2完全独立
- **US2上传**: 仅依赖 Phase 2完全独立
- **US3+4提取**: 依赖 Phase 2集成测试中使用已上传资料需 US2
- **US5QA**: 依赖 US3+4QA 任务来源于提取阶段审批通过的级联触发)
- **US6导出**: 依赖 US5需要 APPROVED 状态的 training_dataset
- **US7用户管理**: 依赖 US1UserService 扩展 AuthService 的用户实体)
- **US8视频+配置)**: 仅依赖 Phase 2
### 阶段内并行机会
- Phase 2T007-T010、T012-T015、T018-T019 均可并行(独立文件)
- Phase 3T024、T025 可并行(独立文件)
- Phase 5T033、T034、T035 可并行(独立文件)
- Phase 9T062、T063 可并行(独立文件)
- Phase 10T071-T074 全部可并行(仅代码审查,无文件修改)
---
## 并行执行示例
### Phase 2 基础设施并行
```
同时启动:
任务: "创建 BusinessException、GlobalExceptionHandler — common/exception/" [T007]
任务: "创建 CompanyContextThreadLocal— 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 3US1 认证)
4. **停止并验证**: 登录/退出/权限校验全流程可用
5. 可以独立部署演示认证功能
### 增量交付
1. Phase 1 + Phase 2 → 基础就绪
2. Phase 3US1→ 验证 → 演示MVP
3. Phase 4US2→ 验证 → 演示(上传功能)
4. Phase 5US3+4→ 验证 → 演示(标注流程)
5. Phase 6US5→ 验证 → 演示(完整双阶段流水线)
6. Phase 7US6→ 验证 → 演示(训练数据产出)
7. Phase 8+9 → 验证 → 演示(完整平台)
8. Phase 10 → 收尾
### 多人协作策略
Phase 2 完成后:
- 开发者 APhase 3US1 认证)+ Phase 8US7 用户管理)
- 开发者 BPhase 4US2 上传)+ Phase 5US3+4 提取)
- 开发者 CPhase 9US8 视频+配置)
Phase 5 完成后:
- 开发者 A/B 合力Phase 6US5 QA→ Phase 7US6 导出)
---
## 说明
- `[P]` 任务 = 不同文件,无依赖,可并行
- `[USn]` 标签将任务映射到具体用户故事,便于追踪
- 每个用户故事应独立可完成和可测试
- 每完成一个阶段后提交 git commit
- 在每个检查点停下来独立验证该用户故事
- 避免:模糊任务、同文件并发冲突、破坏独立性的跨故事依赖

View File

@@ -1,4 +1,3 @@
package com.label;
import org.springframework.boot.SpringApplication;
@@ -6,18 +5,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 应用入口。
*
* 排除 Shiro Web 自动配置ShiroWebAutoConfiguration、ShiroWebFilterConfiguration、
* ShiroWebMvcAutoConfiguration避免其依赖的 ShiroFilterjavax.servlet.Filter
* Spring Boot 3. 的 jakarta.servlet 命名空间冲突。 认证/ 授权逻辑改由
* TokenFilterOncePerRequestFilter+ 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 {

View File

@@ -1,4 +1,4 @@
package com.label.common.aop;
package com.label.annotation;
import java.lang.annotation.*;

View File

@@ -0,0 +1,11 @@
package com.label.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireAuth {
}

View File

@@ -0,0 +1,13 @@
package com.label.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@RequireAuth
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String value();
}

View File

@@ -1,5 +1,6 @@
package com.label.common.aop;
package com.label.aspect;
import com.label.annotation.OperationLog;
import com.label.common.context.CompanyContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

View File

@@ -3,10 +3,12 @@ package com.label.common.ai;
import lombok.Builder;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate;
import jakarta.annotation.PostConstruct;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@@ -19,11 +21,15 @@ public class AiServiceClient {
@Value("${ai-service.timeout:30000}")
private int timeoutMs;
private RestClient restClient;
private RestTemplate restTemplate;
@PostConstruct
public void init() {
restClient = RestClient.builder().baseUrl(baseUrl).build();
restTemplate = new RestTemplateBuilder()
.rootUri(baseUrl)
.setConnectTimeout(Duration.ofMillis(timeoutMs))
.setReadTimeout(Duration.ofMillis(timeoutMs))
.build();
}
// DTO classes
@@ -83,34 +89,34 @@ public class AiServiceClient {
// The 8 endpoints:
public ExtractionResponse extractText(ExtractionRequest request) {
return restClient.post().uri("/extract/text").body(request).retrieve().body(ExtractionResponse.class);
return restTemplate.postForObject("/extract/text", request, ExtractionResponse.class);
}
public ExtractionResponse extractImage(ExtractionRequest request) {
return restClient.post().uri("/extract/image").body(request).retrieve().body(ExtractionResponse.class);
return restTemplate.postForObject("/extract/image", request, ExtractionResponse.class);
}
public void extractFrames(VideoProcessRequest request) {
restClient.post().uri("/video/extract-frames").body(request).retrieve().toBodilessEntity();
restTemplate.postForLocation("/video/extract-frames", request);
}
public void videoToText(VideoProcessRequest request) {
restClient.post().uri("/video/to-text").body(request).retrieve().toBodilessEntity();
restTemplate.postForLocation("/video/to-text", request);
}
public QaGenResponse genTextQa(ExtractionRequest request) {
return restClient.post().uri("/qa/gen-text").body(request).retrieve().body(QaGenResponse.class);
return restTemplate.postForObject("/qa/gen-text", request, QaGenResponse.class);
}
public QaGenResponse genImageQa(ExtractionRequest request) {
return restClient.post().uri("/qa/gen-image").body(request).retrieve().body(QaGenResponse.class);
return restTemplate.postForObject("/qa/gen-image", request, QaGenResponse.class);
}
public FinetuneResponse startFinetune(FinetuneRequest request) {
return restClient.post().uri("/finetune/start").body(request).retrieve().body(FinetuneResponse.class);
return restTemplate.postForObject("/finetune/start", request, FinetuneResponse.class);
}
public FinetuneStatusResponse getFinetuneStatus(String jobId) {
return restClient.get().uri("/finetune/status/{jobId}", jobId).retrieve().body(FinetuneStatusResponse.class);
return restTemplate.getForObject("/finetune/status/{jobId}", FinetuneStatusResponse.class, jobId);
}
}

View File

@@ -1,12 +1,10 @@
package com.label.common.shiro;
package com.label.common.auth;
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 {

View File

@@ -2,8 +2,6 @@ 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;
@@ -20,17 +18,6 @@ public class GlobalExceptionHandler {
.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);

View File

@@ -1,26 +0,0 @@
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;
}
}

View File

@@ -1,64 +0,0 @@
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 注册 TokenFilterjakarta.servlet
* 替代 Shiro 的 ShiroFilterFactoryBeanjavax.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;
}
}

View File

@@ -1,139 +0,0 @@
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 的 OncePerRequestFilterjakarta.servlet避免与 Shiro 1.x
* 的 PathMatchingFilterjavax.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)));
}
}

View File

@@ -1,87 +0,0 @@
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;
}
}
}

View File

@@ -0,0 +1,20 @@
package com.label.config;
import com.label.interceptor.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class AuthConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**");
}
}

View File

@@ -1,4 +1,4 @@
package com.label.common.config;
package com.label.config;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;

View File

@@ -1,4 +1,4 @@
package com.label.common.config;
package com.label.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;

View File

@@ -1,4 +1,4 @@
package com.label.common.config;
package com.label.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,11 +1,12 @@
package com.label.module.user.controller;
package com.label.controller;
import com.label.annotation.RequireAuth;
import com.label.common.auth.TokenPrincipal;
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 com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
import com.label.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -16,9 +17,9 @@ import org.springframework.web.bind.annotation.*;
* 认证接口登录退出获取当前用户
*
* 路由设计
* - POST /api/auth/login 匿名TokenFilter.shouldNotFilter 跳过
* - POST /api/auth/logout 需要有效 TokenTokenFilter 校验
* - GET /api/auth/me 需要有效 TokenTokenFilter 校验
* - POST /api/auth/login 匿名AuthInterceptor 跳过
* - POST /api/auth/logout 需要有效 TokenAuthInterceptor 校验
* - GET /api/auth/me 需要有效 TokenAuthInterceptor 校验
*/
@Tag(name = "认证管理", description = "登录、退出和当前用户信息")
@RestController
@@ -42,6 +43,7 @@ public class AuthController {
*/
@Operation(summary = "退出登录并立即失效当前 Token")
@PostMapping("/logout")
@RequireAuth
public Result<Void> logout(HttpServletRequest request) {
String token = extractToken(request);
authService.logout(token);
@@ -50,10 +52,11 @@ public class AuthController {
/**
* 获取当前登录用户信息
* TokenPrincipal TokenFilter 写入请求属性 "__token_principal__"
* TokenPrincipal AuthInterceptor 写入请求属性 "__token_principal__"
*/
@Operation(summary = "获取当前登录用户信息")
@GetMapping("/me")
@RequireAuth
public Result<UserInfoResponse> me(HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
return Result.success(authService.me(principal));

View File

@@ -0,0 +1,73 @@
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.entity.SysCompany;
import com.label.service.CompanyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Tag(name = "公司管理", description = "租户公司增删改查")
@RestController
@RequestMapping("/api/companies")
@RequiredArgsConstructor
public class CompanyController {
private final CompanyService companyService;
@Operation(summary = "分页查询公司列表")
@GetMapping
@RequireRole("ADMIN")
public Result<PageResult<SysCompany>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String status) {
return Result.success(companyService.list(page, pageSize, status));
}
@Operation(summary = "创建公司")
@PostMapping
@RequireRole("ADMIN")
@ResponseStatus(HttpStatus.CREATED)
public Result<SysCompany> create(@RequestBody Map<String, String> body) {
return Result.success(companyService.create(body.get("companyName"), body.get("companyCode")));
}
@Operation(summary = "更新公司信息")
@PutMapping("/{id}")
@RequireRole("ADMIN")
public Result<SysCompany> update(@PathVariable Long id, @RequestBody Map<String, String> body) {
return Result.success(companyService.update(id, body.get("companyName"), body.get("companyCode")));
}
@Operation(summary = "更新公司状态")
@PutMapping("/{id}/status")
@RequireRole("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
companyService.updateStatus(id, body.get("status"));
return Result.success(null);
}
@Operation(summary = "删除公司")
@DeleteMapping("/{id}")
@RequireRole("ADMIN")
public Result<Void> delete(@PathVariable Long id) {
companyService.delete(id);
return Result.success(null);
}
}

View File

@@ -1,17 +1,17 @@
package com.label.module.export.controller;
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.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 com.label.entity.TrainingDataset;
import com.label.entity.ExportBatch;
import com.label.service.ExportService;
import com.label.service.FinetuneService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.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.*;
@@ -32,7 +32,7 @@ public class ExportController {
/** GET /api/training/samples — 分页查询已审批可导出样本 */
@Operation(summary = "分页查询可导出训练样本")
@GetMapping("/api/training/samples")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<TrainingDataset>> listSamples(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -45,7 +45,7 @@ public class ExportController {
/** POST /api/export/batch — 创建导出批次 */
@Operation(summary = "创建导出批次")
@PostMapping("/api/export/batch")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
@ResponseStatus(HttpStatus.CREATED)
public Result<ExportBatch> createBatch(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
@@ -60,7 +60,7 @@ public class ExportController {
/** POST /api/export/{batchId}/finetune — 提交微调任务 */
@Operation(summary = "提交微调任务")
@PostMapping("/api/export/{batchId}/finetune")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Map<String, Object>> triggerFinetune(@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(finetuneService.trigger(batchId, principal(request)));
@@ -69,7 +69,7 @@ public class ExportController {
/** GET /api/export/{batchId}/status — 查询微调状态 */
@Operation(summary = "查询微调状态")
@GetMapping("/api/export/{batchId}/status")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Map<String, Object>> getFinetuneStatus(@PathVariable Long batchId,
HttpServletRequest request) {
return Result.success(finetuneService.getStatus(batchId, principal(request)));
@@ -78,7 +78,7 @@ public class ExportController {
/** GET /api/export/list — 分页查询导出批次列表 */
@Operation(summary = "分页查询导出批次")
@GetMapping("/api/export/list")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<ExportBatch>> listBatches(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,

View File

@@ -1,13 +1,13 @@
package com.label.module.annotation.controller;
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.annotation.service.ExtractionService;
import com.label.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;
@@ -26,7 +26,7 @@ public class ExtractionController {
/** GET /api/extraction/{taskId} — 获取当前标注结果 */
@Operation(summary = "获取提取标注结果")
@GetMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(extractionService.getResult(taskId, principal(request)));
@@ -35,7 +35,7 @@ public class ExtractionController {
/** PUT /api/extraction/{taskId} — 更新标注结果(整体覆盖) */
@Operation(summary = "更新提取标注结果")
@PutMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> updateResult(@PathVariable Long taskId,
@RequestBody String resultJson,
HttpServletRequest request) {
@@ -46,7 +46,7 @@ public class ExtractionController {
/** POST /api/extraction/{taskId}/submit — 提交标注结果 */
@Operation(summary = "提交提取标注结果")
@PostMapping("/{taskId}/submit")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> submit(@PathVariable Long taskId,
HttpServletRequest request) {
extractionService.submit(taskId, principal(request));
@@ -56,7 +56,7 @@ public class ExtractionController {
/** POST /api/extraction/{taskId}/approve — 审批通过REVIEWER */
@Operation(summary = "审批通过提取结果")
@PostMapping("/{taskId}/approve")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> approve(@PathVariable Long taskId,
HttpServletRequest request) {
extractionService.approve(taskId, principal(request));
@@ -66,7 +66,7 @@ public class ExtractionController {
/** POST /api/extraction/{taskId}/reject — 驳回REVIEWER */
@Operation(summary = "驳回提取结果")
@PostMapping("/{taskId}/reject")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,13 +1,13 @@
package com.label.module.annotation.controller;
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.annotation.service.QaService;
import com.label.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;
@@ -26,7 +26,7 @@ public class QaController {
/** GET /api/qa/{taskId} — 获取候选问答对 */
@Operation(summary = "获取候选问答对")
@GetMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Map<String, Object>> getResult(@PathVariable Long taskId,
HttpServletRequest request) {
return Result.success(qaService.getResult(taskId, principal(request)));
@@ -35,7 +35,7 @@ public class QaController {
/** PUT /api/qa/{taskId} — 整体覆盖问答对 */
@Operation(summary = "更新候选问答对")
@PutMapping("/{taskId}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> updateResult(@PathVariable Long taskId,
@RequestBody String body,
HttpServletRequest request) {
@@ -46,7 +46,7 @@ public class QaController {
/** POST /api/qa/{taskId}/submit — 提交问答对 */
@Operation(summary = "提交问答对")
@PostMapping("/{taskId}/submit")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> submit(@PathVariable Long taskId,
HttpServletRequest request) {
qaService.submit(taskId, principal(request));
@@ -56,7 +56,7 @@ public class QaController {
/** POST /api/qa/{taskId}/approve — 审批通过REVIEWER */
@Operation(summary = "审批通过问答对")
@PostMapping("/{taskId}/approve")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> approve(@PathVariable Long taskId,
HttpServletRequest request) {
qaService.approve(taskId, principal(request));
@@ -66,7 +66,7 @@ public class QaController {
/** POST /api/qa/{taskId}/reject — 驳回REVIEWER */
@Operation(summary = "驳回答案对")
@PostMapping("/{taskId}/reject")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<Void> reject(@PathVariable Long taskId,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,15 +1,15 @@
package com.label.module.source.controller;
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.source.dto.SourceResponse;
import com.label.module.source.service.SourceService;
import com.label.dto.SourceResponse;
import com.label.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;
@@ -35,7 +35,7 @@ public class SourceController {
*/
@Operation(summary = "上传原始资料", description = "dataType: text,image, video")
@PostMapping("/upload")
@RequiresRoles("UPLOADER")
@RequireRole("UPLOADER")
@ResponseStatus(HttpStatus.CREATED)
public Result<SourceResponse> upload(
@RequestParam("file") MultipartFile file,
@@ -51,7 +51,7 @@ public class SourceController {
*/
@Operation(summary = "分页查询资料列表")
@GetMapping("/list")
@RequiresRoles("UPLOADER")
@RequireRole("UPLOADER")
public Result<PageResult<SourceResponse>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -67,7 +67,7 @@ public class SourceController {
*/
@Operation(summary = "查询资料详情")
@GetMapping("/{id}")
@RequiresRoles("UPLOADER")
@RequireRole("UPLOADER")
public Result<SourceResponse> findById(@PathVariable Long id) {
return Result.success(sourceService.findById(id));
}
@@ -78,7 +78,7 @@ public class SourceController {
*/
@Operation(summary = "删除资料")
@DeleteMapping("/{id}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
sourceService.delete(id, principal.getCompanyId());

View File

@@ -1,14 +1,14 @@
package com.label.module.config.controller;
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
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 com.label.entity.SysConfig;
import com.label.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;
@@ -36,7 +36,7 @@ public class SysConfigController {
*/
@Operation(summary = "查询合并后的系统配置")
@GetMapping("/api/config")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<List<Map<String, Object>>> listConfig(HttpServletRequest request) {
TokenPrincipal principal = principal(request);
return Result.success(sysConfigService.list(principal.getCompanyId()));
@@ -49,7 +49,7 @@ public class SysConfigController {
*/
@Operation(summary = "更新或创建公司专属配置")
@PutMapping("/api/config/{key}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<SysConfig> updateConfig(@PathVariable String key,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,16 +1,16 @@
package com.label.module.task.controller;
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.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 com.label.dto.TaskResponse;
import com.label.service.TaskClaimService;
import com.label.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.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;
@@ -30,7 +30,7 @@ public class TaskController {
/** GET /api/tasks/pool — 查询可领取任务池(角色感知) */
@Operation(summary = "查询可领取任务池")
@GetMapping("/pool")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<PageResult<TaskResponse>> getPool(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -41,7 +41,7 @@ public class TaskController {
/** GET /api/tasks/mine — 查询我的任务 */
@Operation(summary = "查询我的任务")
@GetMapping("/mine")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<PageResult<TaskResponse>> getMine(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -53,7 +53,7 @@ public class TaskController {
/** GET /api/tasks/pending-review — 待审批队列REVIEWER 专属) */
@Operation(summary = "查询待审批任务")
@GetMapping("/pending-review")
@RequiresRoles("REVIEWER")
@RequireRole("REVIEWER")
public Result<PageResult<TaskResponse>> getPendingReview(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -64,7 +64,7 @@ public class TaskController {
/** GET /api/tasks — 查询全部任务ADMIN */
@Operation(summary = "管理员查询全部任务")
@GetMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<TaskResponse>> getAll(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -76,7 +76,7 @@ public class TaskController {
/** POST /api/tasks — 创建任务ADMIN */
@Operation(summary = "管理员创建任务")
@PostMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<TaskResponse> createTask(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
Long sourceId = Long.parseLong(body.get("sourceId").toString());
@@ -89,7 +89,7 @@ public class TaskController {
/** GET /api/tasks/{id} — 查询任务详情 */
@Operation(summary = "查询任务详情")
@GetMapping("/{id}")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<TaskResponse> getById(@PathVariable Long id) {
return Result.success(taskService.toPublicResponse(taskService.getById(id)));
}
@@ -97,7 +97,7 @@ public class TaskController {
/** POST /api/tasks/{id}/claim — 领取任务 */
@Operation(summary = "领取任务")
@PostMapping("/{id}/claim")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> claim(@PathVariable Long id, HttpServletRequest request) {
taskClaimService.claim(id, principal(request));
return Result.success(null);
@@ -106,7 +106,7 @@ public class TaskController {
/** POST /api/tasks/{id}/unclaim — 放弃任务 */
@Operation(summary = "放弃任务")
@PostMapping("/{id}/unclaim")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> unclaim(@PathVariable Long id, HttpServletRequest request) {
taskClaimService.unclaim(id, principal(request));
return Result.success(null);
@@ -115,7 +115,7 @@ public class TaskController {
/** POST /api/tasks/{id}/reclaim — 重领被驳回的任务 */
@Operation(summary = "重领被驳回的任务")
@PostMapping("/{id}/reclaim")
@RequiresRoles("ANNOTATOR")
@RequireRole("ANNOTATOR")
public Result<Void> reclaim(@PathVariable Long id, HttpServletRequest request) {
taskClaimService.reclaim(id, principal(request));
return Result.success(null);
@@ -124,7 +124,7 @@ public class TaskController {
/** PUT /api/tasks/{id}/reassign — ADMIN 强制指派 */
@Operation(summary = "管理员强制指派任务")
@PutMapping("/{id}/reassign")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> reassign(@PathVariable Long id,
@RequestBody Map<String, Object> body,
HttpServletRequest request) {

View File

@@ -1,8 +1,7 @@
package com.label.module.user.controller;
package com.label.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;
@@ -12,11 +11,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.user.entity.SysUser;
import com.label.module.user.service.UserService;
import com.label.entity.SysUser;
import com.label.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -37,7 +37,7 @@ public class UserController {
/** GET /api/users — 分页查询用户列表 */
@Operation(summary = "分页查询用户列表")
@GetMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<PageResult<SysUser>> listUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -48,7 +48,7 @@ public class UserController {
/** POST /api/users — 创建用户 */
@Operation(summary = "创建用户")
@PostMapping
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<SysUser> createUser(@RequestBody Map<String, String> body,
HttpServletRequest request) {
return Result.success(userService.createUser(
@@ -62,7 +62,7 @@ public class UserController {
/** PUT /api/users/{id} — 更新用户基本信息 */
@Operation(summary = "更新用户基本信息")
@PutMapping("/{id}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<SysUser> updateUser(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
@@ -76,7 +76,7 @@ public class UserController {
/** PUT /api/users/{id}/status — 变更用户状态 */
@Operation(summary = "变更用户状态", description = "statusACTIVE、DISABLED")
@PutMapping("/{id}/status")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
@@ -87,7 +87,7 @@ public class UserController {
/** PUT /api/users/{id}/role — 变更用户角色 */
@Operation(summary = "变更用户角色", description = "roleADMIN、UPLOADER、VIEWER")
@PutMapping("/{id}/role")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<Void> updateRole(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {

View File

@@ -1,15 +1,15 @@
package com.label.module.video.controller;
package com.label.controller;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
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 com.label.entity.VideoProcessJob;
import com.label.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.*;
@@ -21,7 +21,7 @@ import java.util.Map;
* POST /api/video/process 触发视频处理ADMIN
* GET /api/video/jobs/{jobId} 查询任务状态ADMIN
* POST /api/video/jobs/{jobId}/reset 重置失败任务ADMIN
* POST /api/video/callback AI 回调接口无需认证已在 TokenFilter 中排除
* POST /api/video/callback AI 回调接口无需认证已在 AuthInterceptor 中排除
*/
@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调")
@Slf4j
@@ -37,7 +37,7 @@ public class VideoController {
/** POST /api/video/process — 触发视频处理任务 */
@Operation(summary = "触发视频处理任务")
@PostMapping("/api/video/process")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<VideoProcessJob> createJob(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
Object sourceIdVal = body.get("sourceId");
@@ -57,7 +57,7 @@ public class VideoController {
/** GET /api/video/jobs/{jobId} — 查询视频处理任务 */
@Operation(summary = "查询视频处理任务状态")
@GetMapping("/api/video/jobs/{jobId}")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<VideoProcessJob> getJob(@PathVariable Long jobId,
HttpServletRequest request) {
return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId()));
@@ -66,7 +66,7 @@ public class VideoController {
/** POST /api/video/jobs/{jobId}/reset — 管理员重置失败任务 */
@Operation(summary = "重置失败的视频处理任务")
@PostMapping("/api/video/jobs/{jobId}/reset")
@RequiresRoles("ADMIN")
@RequireRole("ADMIN")
public Result<VideoProcessJob> resetJob(@PathVariable Long jobId,
HttpServletRequest request) {
return Result.success(videoProcessService.reset(jobId, principal(request).getCompanyId()));
@@ -75,7 +75,7 @@ public class VideoController {
/**
* POST /api/video/callback AI 服务回调无需 Bearer Token
*
* 此端点已在 TokenFilter.shouldNotFilter() 中排除认证
* 此端点已在 AuthInterceptor 中排除认证
* AI 服务直接调用携带 jobIdstatusoutputPath 等参数
*
* Body 示例

View File

@@ -1,4 +1,4 @@
package com.label.module.user.dto;
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.label.module.user.dto;
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;

View File

@@ -1,4 +1,4 @@
package com.label.module.source.dto;
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

View File

@@ -1,4 +1,4 @@
package com.label.module.task.dto;
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

View File

@@ -1,4 +1,4 @@
package com.label.module.user.dto;
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;

View File

@@ -1,4 +1,4 @@
package com.label.module.annotation.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.task.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.task.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.export.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.source.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.user.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.config.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.user.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.annotation.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.video.entity;
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

View File

@@ -1,4 +1,4 @@
package com.label.module.annotation.event;
package com.label.event;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;

View File

@@ -0,0 +1,179 @@
package com.label.interceptor;
import java.io.IOException;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.annotation.RequireRole;
import com.label.common.auth.TokenPrincipal;
import com.label.common.context.CompanyContext;
import com.label.common.result.Result;
import com.label.service.RedisService;
import com.label.util.RedisUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final RedisService redisService;
private final ObjectMapper objectMapper;
@Value("${auth.enabled:true}")
private boolean authEnabled;
@Value("${auth.mock-company-id:1}")
private Long mockCompanyId;
@Value("${auth.mock-user-id:1}")
private Long mockUserId;
@Value("${auth.mock-role:ADMIN}")
private String mockRole;
@Value("${auth.mock-username:mock}")
private String mockUsername;
@Value("${token.ttl-seconds:7200}")
private long tokenTtlSeconds;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String path = requestPath(request);
if (isPublicPath(path)) {
return true;
}
TokenPrincipal principal = authEnabled
? resolvePrincipal(request, response)
: new TokenPrincipal(mockUserId, mockRole, mockCompanyId, mockUsername, "mock-token");
if (principal == null) {
return false;
}
bindPrincipal(request, principal);
RequireRole requiredRole = requiredRole(handler);
if (requiredRole != null && !hasRole(principal.getRole(), requiredRole.value())) {
writeFailure(response, HttpServletResponse.SC_FORBIDDEN, "FORBIDDEN", "权限不足");
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
CompanyContext.clear();
}
private TokenPrincipal resolvePrincipal(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.toLowerCase().startsWith("bearer ")) {
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "缺少或无效的认证令牌");
return null;
}
String[] parts = authHeader.split("\\s+");
if (parts.length != 2 || !"Bearer".equalsIgnoreCase(parts[0])) {
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "无效的认证格式");
return null;
}
String token = parts[1];
Map<Object, Object> tokenData = redisService.hGetAll(RedisUtil.tokenKey(token));
if (tokenData == null || tokenData.isEmpty()) {
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "令牌已过期或不存在");
return null;
}
try {
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();
redisService.expire(RedisUtil.tokenKey(token), tokenTtlSeconds);
redisService.expire(RedisUtil.userSessionsKey(userId), tokenTtlSeconds);
return new TokenPrincipal(userId, role, companyId, username, token);
} catch (Exception e) {
log.warn("解析 Token 数据失败: {}", e.getMessage());
writeFailure(response, HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED", "令牌数据格式错误");
return null;
}
}
private void bindPrincipal(HttpServletRequest request, TokenPrincipal principal) {
CompanyContext.set(principal.getCompanyId());
request.setAttribute("__token_principal__", principal);
}
private RequireRole requiredRole(Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return null;
}
RequireRole methodRole = AnnotatedElementUtils.findMergedAnnotation(
handlerMethod.getMethod(), RequireRole.class);
if (methodRole != null) {
return methodRole;
}
return AnnotatedElementUtils.findMergedAnnotation(
handlerMethod.getBeanType(), RequireRole.class);
}
private boolean hasRole(String actualRole, String requiredRole) {
return roleLevel(actualRole) >= roleLevel(requiredRole);
}
private int roleLevel(String role) {
return switch (role) {
case "ADMIN" -> 4;
case "REVIEWER" -> 3;
case "ANNOTATOR" -> 2;
case "UPLOADER" -> 1;
default -> 0;
};
}
private boolean isPublicPath(String path) {
return !path.startsWith("/api/")
|| path.equals("/api/auth/login")
|| path.equals("/api/video/callback")
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs");
}
private String requestPath(HttpServletRequest request) {
String path = request.getServletPath();
if (path == null || path.isBlank()) {
path = request.getRequestURI();
}
return path != null ? path : "";
}
private void writeFailure(HttpServletResponse response, int status, String code, String message)
throws IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(Result.failure(code, message)));
}
}

View File

@@ -1,18 +1,9 @@
package com.label.module.annotation.service;
package com.label.listener;
import java.util.Collections;
import java.util.List;
import java.util.Map;
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;
@@ -20,9 +11,18 @@ 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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.context.CompanyContext;
import com.label.entity.SourceData;
import com.label.entity.TrainingDataset;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.SourceDataMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.service.TaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 提取审批通过后的异步处理器
@@ -89,7 +89,8 @@ public class ExtractionApprovedEventListener {
? aiServiceClient.genImageQa(req)
: aiServiceClient.genTextQa(req);
qaPairs = response != null && response.getQaPairs() != null
? response.getQaPairs() : Collections.emptyList();
? response.getQaPairs()
: Collections.emptyList();
} catch (Exception e) {
log.warn("AI 问答生成失败taskId={}{},将使用空问答对", event.getTaskId(), e.getMessage());
qaPairs = Collections.emptyList();

View File

@@ -1,7 +1,7 @@
package com.label.module.annotation.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.annotation.entity.AnnotationResult;
import com.label.entity.AnnotationResult;
import org.apache.ibatis.annotations.*;
/**

View File

@@ -1,7 +1,7 @@
package com.label.module.task.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.task.entity.AnnotationTask;
import com.label.entity.AnnotationTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

View File

@@ -1,7 +1,7 @@
package com.label.module.export.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.export.entity.ExportBatch;
import com.label.entity.ExportBatch;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

View File

@@ -1,7 +1,7 @@
package com.label.module.source.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.source.entity.SourceData;
import com.label.entity.SourceData;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

View File

@@ -1,7 +1,7 @@
package com.label.module.user.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysCompany;
import com.label.entity.SysCompany;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

View File

@@ -1,7 +1,7 @@
package com.label.module.config.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.config.entity.SysConfig;
import com.label.entity.SysConfig;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

View File

@@ -1,8 +1,8 @@
package com.label.module.user.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysUser;
import com.label.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -31,4 +31,8 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
@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);
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT COUNT(1) FROM sys_user WHERE company_id = #{companyId}")
Long countByCompanyId(@Param("companyId") Long companyId);
}

View File

@@ -1,7 +1,7 @@
package com.label.module.task.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.task.entity.AnnotationTaskHistory;
import com.label.entity.AnnotationTaskHistory;
import org.apache.ibatis.annotations.Mapper;
/**

View File

@@ -1,7 +1,7 @@
package com.label.module.annotation.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.annotation.entity.TrainingDataset;
import com.label.entity.TrainingDataset;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

View File

@@ -1,7 +1,7 @@
package com.label.module.video.mapper;
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.video.entity.VideoProcessJob;
import com.label.entity.VideoProcessJob;
import org.apache.ibatis.annotations.Mapper;
/**

View File

@@ -1,16 +1,16 @@
package com.label.module.user.service;
package com.label.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 com.label.common.auth.TokenPrincipal;
import com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
import com.label.entity.SysCompany;
import com.label.entity.SysUser;
import com.label.mapper.SysCompanyMapper;
import com.label.mapper.SysUserMapper;
import com.label.util.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -83,10 +83,10 @@ public class AuthService {
tokenData.put("role", user.getRole());
tokenData.put("companyId", user.getCompanyId().toString());
tokenData.put("username", user.getUsername());
redisService.hSetAll(RedisKeyManager.tokenKey(token), tokenData, tokenTtlSeconds);
redisService.hSetAll(RedisUtil.tokenKey(token), tokenData, tokenTtlSeconds);
// token 加入该用户的活跃会话集合用于角色变更时批量更新/失效
String sessionsKey = RedisKeyManager.userSessionsKey(user.getId());
String sessionsKey = RedisUtil.userSessionsKey(user.getId());
redisService.sAdd(sessionsKey, token);
// 防止 Set 无限增长TTL = token 有效期最后一次登录时滑动续期
redisService.expire(sessionsKey, tokenTtlSeconds);
@@ -103,11 +103,11 @@ public class AuthService {
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));
String userId = redisService.hGet(RedisUtil.tokenKey(token), "userId");
redisService.delete(RedisUtil.tokenKey(token));
if (userId != null) {
try {
redisService.sRemove(RedisKeyManager.userSessionsKey(Long.parseLong(userId)), token);
redisService.sRemove(RedisUtil.userSessionsKey(Long.parseLong(userId)), token);
} catch (NumberFormatException ignored) {}
}
log.info("用户退出Token 已删除: {}", token);
@@ -117,7 +117,7 @@ public class AuthService {
/**
* 获取当前登录用户详情 realNamecompanyName
*
* @param principal TokenFilter 注入的当前用户主体
* @param principal AuthInterceptor 注入的当前用户主体
* @return 用户信息响应体
*/
public UserInfoResponse me(TokenPrincipal principal) {

View File

@@ -0,0 +1,122 @@
package com.label.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.entity.SysCompany;
import com.label.mapper.SysCompanyMapper;
import com.label.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class CompanyService {
private final SysCompanyMapper companyMapper;
private final SysUserMapper userMapper;
public PageResult<SysCompany> list(int page, int pageSize, String status) {
pageSize = Math.min(pageSize, 100);
LambdaQueryWrapper<SysCompany> wrapper = new LambdaQueryWrapper<SysCompany>()
.orderByDesc(SysCompany::getCreatedAt);
if (status != null && !status.isBlank()) {
wrapper.eq(SysCompany::getStatus, status);
}
Page<SysCompany> result = companyMapper.selectPage(new Page<>(page, pageSize), wrapper);
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
}
@Transactional
public SysCompany create(String companyName, String companyCode) {
String normalizedName = requireText(companyName, "公司名称不能为空");
String normalizedCode = normalizeCode(companyCode);
ensureUniqueCode(null, normalizedCode);
ensureUniqueName(null, normalizedName);
SysCompany company = new SysCompany();
company.setCompanyName(normalizedName);
company.setCompanyCode(normalizedCode);
company.setStatus("ACTIVE");
companyMapper.insert(company);
log.info("公司已创建: id={}, code={}", company.getId(), normalizedCode);
return company;
}
@Transactional
public SysCompany update(Long companyId, String companyName, String companyCode) {
SysCompany company = getExistingCompany(companyId);
String normalizedName = requireText(companyName, "公司名称不能为空");
String normalizedCode = normalizeCode(companyCode);
ensureUniqueCode(companyId, normalizedCode);
ensureUniqueName(companyId, normalizedName);
company.setCompanyName(normalizedName);
company.setCompanyCode(normalizedCode);
companyMapper.updateById(company);
return company;
}
@Transactional
public void updateStatus(Long companyId, String status) {
SysCompany company = getExistingCompany(companyId);
if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) {
throw new BusinessException("INVALID_COMPANY_STATUS", "公司状态不合法", HttpStatus.BAD_REQUEST);
}
company.setStatus(status);
companyMapper.updateById(company);
}
@Transactional
public void delete(Long companyId) {
getExistingCompany(companyId);
Long userCount = userMapper.countByCompanyId(companyId);
if (userCount != null && userCount > 0) {
throw new BusinessException("COMPANY_HAS_USERS", "公司下仍存在用户,无法删除", HttpStatus.CONFLICT);
}
companyMapper.deleteById(companyId);
}
private SysCompany getExistingCompany(Long companyId) {
SysCompany company = companyMapper.selectById(companyId);
if (company == null) {
throw new BusinessException("NOT_FOUND", "公司不存在: " + companyId, HttpStatus.NOT_FOUND);
}
return company;
}
private void ensureUniqueCode(Long companyId, String companyCode) {
SysCompany existing = companyMapper.selectByCompanyCode(companyCode);
if (existing != null && !existing.getId().equals(companyId)) {
throw new BusinessException("DUPLICATE_COMPANY_CODE", "公司代码已存在", HttpStatus.CONFLICT);
}
}
private void ensureUniqueName(Long companyId, String companyName) {
SysCompany existing = companyMapper.selectOne(new LambdaQueryWrapper<SysCompany>()
.eq(SysCompany::getCompanyName, companyName)
.last("LIMIT 1"));
if (existing != null && !existing.getId().equals(companyId)) {
throw new BusinessException("DUPLICATE_COMPANY_NAME", "公司名称已存在", HttpStatus.CONFLICT);
}
}
private String requireText(String text, String message) {
if (text == null || text.isBlank()) {
throw new BusinessException("INVALID_COMPANY_FIELD", message, HttpStatus.BAD_REQUEST);
}
return text.trim();
}
private String normalizeCode(String companyCode) {
return requireText(companyCode, "公司代码不能为空").toUpperCase();
}
}

View File

@@ -1,16 +1,16 @@
package com.label.module.export.service;
package com.label.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.auth.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 com.label.entity.TrainingDataset;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.ExportBatch;
import com.label.mapper.ExportBatchMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;

View File

@@ -1,22 +1,22 @@
package com.label.module.annotation.service;
package com.label.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.auth.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 com.label.entity.AnnotationResult;
import com.label.entity.TrainingDataset;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;
import com.label.service.TaskClaimService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,10 +1,10 @@
package com.label.module.export.service;
package com.label.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 com.label.common.auth.TokenPrincipal;
import com.label.entity.ExportBatch;
import com.label.mapper.ExportBatchMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;

View File

@@ -1,18 +1,18 @@
package com.label.module.annotation.service;
package com.label.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.auth.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 com.label.entity.TrainingDataset;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;
import com.label.service.TaskClaimService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;

View File

@@ -1,4 +1,4 @@
package com.label.common.redis;
package com.label.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;

View File

@@ -1,15 +1,15 @@
package com.label.module.source.service;
package com.label.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.auth.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 com.label.dto.SourceResponse;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,8 +1,8 @@
package com.label.module.config.service;
package com.label.service;
import com.label.common.exception.BusinessException;
import com.label.module.config.entity.SysConfig;
import com.label.module.config.mapper.SysConfigMapper;
import com.label.entity.SysConfig;
import com.label.mapper.SysConfigMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;

View File

@@ -1,16 +1,16 @@
package com.label.module.task.service;
package com.label.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.auth.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 com.label.entity.AnnotationTask;
import com.label.entity.AnnotationTaskHistory;
import com.label.mapper.AnnotationTaskMapper;
import com.label.mapper.TaskHistoryMapper;
import com.label.util.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -48,7 +48,7 @@ public class TaskClaimService {
*/
@Transactional
public void claim(Long taskId, TokenPrincipal principal) {
String lockKey = RedisKeyManager.taskClaimKey(taskId);
String lockKey = RedisUtil.taskClaimKey(taskId);
// 1. Redis SET NX 预锁快速失败
boolean lockAcquired = redisService.setIfAbsent(
@@ -104,7 +104,7 @@ public class TaskClaimService {
.set(AnnotationTask::getClaimedAt, null));
// 清除 Redis 分布式锁
redisService.delete(RedisKeyManager.taskClaimKey(taskId));
redisService.delete(RedisUtil.taskClaimKey(taskId));
insertHistory(taskId, principal.getCompanyId(),
"IN_PROGRESS", "UNCLAIMED",
@@ -145,7 +145,7 @@ public class TaskClaimService {
// 重新设置 Redis 防止并发再次争抢
redisService.setIfAbsent(
RedisKeyManager.taskClaimKey(taskId),
RedisUtil.taskClaimKey(taskId),
principal.getUserId().toString(), CLAIM_LOCK_TTL);
insertHistory(taskId, principal.getCompanyId(),

View File

@@ -1,14 +1,14 @@
package com.label.module.task.service;
package com.label.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 com.label.common.auth.TokenPrincipal;
import com.label.dto.TaskResponse;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;

View File

@@ -1,4 +1,4 @@
package com.label.module.user.service;
package com.label.service;
import java.util.List;
import java.util.Set;
@@ -11,12 +11,11 @@ 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 com.label.common.auth.TokenPrincipal;
import com.label.entity.SysUser;
import com.label.mapper.SysUserMapper;
import com.label.util.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -127,11 +126,11 @@ public class UserService {
.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));
Set<String> tokens = redisService.sMembers(RedisUtil.userSessionsKey(userId));
tokens.forEach(token -> redisService.hPut(RedisUtil.tokenKey(token), "role", newRole));
// 3. 删除权限缓存 Shiro 缓存存在
redisService.delete(RedisKeyManager.userPermKey(userId));
redisService.delete(RedisUtil.userPermKey(userId));
log.info("用户角色已变更: userId={}, newRole={}, 更新 {} 个活跃 Token", userId, newRole, tokens.size());
}
@@ -160,14 +159,14 @@ public class UserService {
// 禁用时删除所有活跃 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));
Set<String> tokens = redisService.sMembers(RedisUtil.userSessionsKey(userId));
tokens.forEach(token -> redisService.delete(RedisUtil.tokenKey(token)));
redisService.delete(RedisUtil.userSessionsKey(userId));
log.info("账号已禁用,已删除 {} 个活跃 Token: userId={}", tokens.size(), userId);
}
// 删除权限缓存
redisService.delete(RedisKeyManager.userPermKey(userId));
redisService.delete(RedisUtil.userPermKey(userId));
}
// ------------------------------------------------------------------ 查询 --

View File

@@ -1,14 +1,14 @@
package com.label.module.video.service;
package com.label.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 com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.VideoProcessJob;
import com.label.mapper.VideoProcessJobMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,12 +1,13 @@
package com.label.common.redis;
package com.label.util;
/**
* Centralized Redis key naming conventions.
* All keys follow the pattern: prefix:{id}
*/
public final class RedisKeyManager {
public final class RedisUtil {
private RedisKeyManager() {}
private RedisUtil() {
}
/** Session token key: token:{uuid} */
public static String tokenKey(String uuid) {

View File

@@ -16,7 +16,7 @@ spring:
data:
redis:
host: ${SPRING_DATA_REDIS_HOST:39.107.112.174}
host: ${SPRING_DATA_REDIS_HOST:39.107.227.165}
port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:jsti@2024}
timeout: 5000ms
@@ -33,7 +33,7 @@ spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher # Shiro 与 Spring Boot 3 兼容性需要
matching-strategy: ant_path_matcher
springdoc:
api-docs:
@@ -45,7 +45,7 @@ springdoc:
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.label.module
type-aliases-package: com.label.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
@@ -61,10 +61,9 @@ rustfs:
ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000}
timeout: 30000 # milliseconds
timeout: 30000
shiro:
auth:
auth:
enabled: false
mock-company-id: 1
mock-user-id: 1
@@ -72,13 +71,12 @@ shiro:
mock-username: mock
token:
ttl-seconds: 7200 # Token 默认有效期(秒),与 sys_config token_ttl_seconds 保持一致
ttl-seconds: 7200
video:
callback-secret: ${VIDEO_CALLBACK_SECRET:} # AI 服务回调共享密钥,为空时跳过校验(开发环境)
callback-secret: ${VIDEO_CALLBACK_SECRET:}
logging:
level:
com.label: INFO
org.apache.shiro: INFO
com.baomidou.mybatisplus: INFO

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.common.result.Result;
import com.label.module.user.dto.LoginRequest;
import com.label.dto.LoginRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

Some files were not shown because too many files have changed in this diff Show More