Compare commits
88 Commits
2c2aa116d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf0b00ed08 | ||
|
|
ccbcfd2c74 | ||
|
|
4708aa0f28 | ||
|
|
5a24ebd49b | ||
|
|
3ce2deb0a6 | ||
|
|
13945b239e | ||
|
|
eb22998b28 | ||
|
|
f6ba09521a | ||
|
|
73a13fd16d | ||
|
|
00032dd491 | ||
| c65fdbab5b | |||
| 9fd8971732 | |||
|
|
b65b1c6ee0 | ||
|
|
d9486a8c75 | ||
|
|
8d9e7cb027 | ||
| 5d5308cf57 | |||
|
|
e30b288894 | ||
|
|
325ea3b486 | ||
|
|
756734db44 | ||
|
|
8ba3de17ab | ||
|
|
5839bc2ece | ||
|
|
b0e2b3c81a | ||
|
|
999856e110 | ||
|
|
a30b648d30 | ||
|
|
158873d5ae | ||
|
|
ceaac48051 | ||
|
|
c524fb08e1 | ||
|
|
ba42b6f50e | ||
|
|
ef1e4f5106 | ||
|
|
0dbb88b803 | ||
|
|
3e33398dd2 | ||
|
|
29766ebd28 | ||
|
|
0af19cf1b5 | ||
|
|
e3c796da27 | ||
|
|
63ed9e6771 | ||
|
|
7b8bf21e51 | ||
|
|
21f3a92f7d | ||
|
|
3f3c355d4e | ||
| 29b62b6ca0 | |||
|
|
f4a8592c92 | ||
|
|
c7201b03e1 | ||
|
|
e8235eeec5 | ||
|
|
5d74578aa3 | ||
|
|
ef8b75a03e | ||
|
|
7172861e67 | ||
|
|
a489e2b204 | ||
|
|
c3308e069d | ||
|
|
b8d9aec4ca | ||
|
|
5103dac16c | ||
|
|
c2a254cba4 | ||
|
|
d231180bff | ||
|
|
3f0dee0826 | ||
|
|
8eb3c77abd | ||
|
|
b7d6cbc1e2 | ||
|
|
7b25064593 | ||
|
|
ff3b38ab2e | ||
|
|
011a731f4b | ||
|
|
0fa3981a85 | ||
|
|
a14c3f5559 | ||
|
|
f6c3b0b4c6 | ||
|
|
49666d1579 | ||
|
|
6d972511ff | ||
|
|
927e4f1cf3 | ||
|
|
7f12fc520a | ||
|
|
a28fecd16a | ||
|
|
b5f35a7414 | ||
|
|
4a002bd84e | ||
|
|
0cd99aa22c | ||
|
|
556f7b9672 | ||
|
|
8fb730d281 | ||
|
|
3d1790ad64 | ||
|
|
42fb748949 | ||
|
|
52d5dd9c24 | ||
|
|
ae55e87e2c | ||
|
|
94cb27e95f | ||
|
|
0e2b1e291b | ||
|
|
3da0e49b38 | ||
|
|
600a8b8669 | ||
|
|
672fe888c9 | ||
|
|
bc33194b6e | ||
|
|
fba3701cb9 | ||
|
|
3b99b1d8c3 | ||
|
|
4054a1133b | ||
|
|
0891ae188d | ||
|
|
ba3b7389f0 | ||
|
|
badffd8bca | ||
|
|
6e0677e06a | ||
|
|
e382995718 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.claude
|
||||||
|
specs
|
||||||
|
docs
|
||||||
|
target
|
||||||
|
*.md
|
||||||
|
.gitignore
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# ==========================================
|
||||||
|
# 1. Maven/Java 构建产物 (一键忽略整个目录)
|
||||||
|
# ==========================================
|
||||||
|
target/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
docs/
|
||||||
|
specs/
|
||||||
|
src/test/
|
||||||
|
CLAUDE.md
|
||||||
|
# ==========================================
|
||||||
|
# 2. IDE 配置文件
|
||||||
|
# ==========================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.agents/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. 项目特定工具目录 (根据你的文件列表)
|
||||||
|
# ==========================================
|
||||||
|
# 忽略 Specifiy 工具生成的所有配置和脚本
|
||||||
|
.specify/
|
||||||
|
|
||||||
|
# 忽略 Claude 本地设置和技能文件
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. 操作系统文件
|
||||||
|
# ==========================================
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM registry.bjzgzp.com:4433/library/eclipse-temurin:21-jdk-ubi10-minimal
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./label-backend-1.0.0-SNAPSHOT.jar /app/label-backend-1.0.0-SNAPSHOT.jar
|
||||||
|
|
||||||
|
EXPOSE 18082
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-Djava.net.preferIPv4Stack=true", "-jar", "/app/label-backend-1.0.0-SNAPSHOT.jar"]
|
||||||
401
README.md
401
README.md
@@ -1,2 +1,401 @@
|
|||||||
# 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/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` 传递
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
当前约束摘要:
|
||||||
|
|
||||||
|
- 统一扁平目录结构,避免再次引入按业务域分层的旧目录
|
||||||
|
- DTO 统一放在 `dto/`,不再拆分 `request/response`
|
||||||
|
- Service 统一放在 `service/`,不拆 `service/impl`
|
||||||
|
- 业务规则优先放在 Service,Controller 只负责 HTTP 协议层
|
||||||
|
- 新增接口需同步补齐 Swagger 注解与测试
|
||||||
|
- 所有对外接口参数必须在 Swagger 中明确体现名称、类型和含义
|
||||||
|
- 固定结构请求体禁止继续使用匿名 `Map<String, Object>` 或 `Map<String, String>`,必须定义 DTO 并补齐 `@Schema` 字段说明
|
||||||
|
- 固定结构响应应优先使用明确 DTO,或至少为 Swagger 暴露对象补齐字段级 `@Schema` 注解
|
||||||
|
- 路径参数、查询参数、请求体、分页包装和通用返回体都必须维护可读的 OpenAPI 文档说明
|
||||||
|
- 需要保持历史兼容的原始 JSON 字符串请求体可以继续使用 `String`,但必须在 Swagger `@RequestBody` 中说明完整 JSON body 的提交方式和兼容原因
|
||||||
|
- 修改 Controller 参数、请求 DTO、响应 DTO 或对外实体后,必须运行 `mvn -Dtest=OpenApiAnnotationTest test`,确保 Swagger 参数名称、类型和含义没有回退
|
||||||
|
- 目录、配置、打包方式变化后,README、设计文档和部署说明必须同步更新
|
||||||
|
|||||||
48
assembly/distribution.xml
Normal file
48
assembly/distribution.xml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0
|
||||||
|
https://maven.apache.org/xsd/assembly-2.1.0.xsd">
|
||||||
|
<id>dist</id>
|
||||||
|
<formats>
|
||||||
|
<format>zip</format>
|
||||||
|
<format>tar.gz</format>
|
||||||
|
</formats>
|
||||||
|
<includeBaseDirectory>true</includeBaseDirectory>
|
||||||
|
|
||||||
|
<!-- bin/start.sh(0755 可执行) -->
|
||||||
|
<files>
|
||||||
|
<file>
|
||||||
|
<source>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>*.yml</include>
|
||||||
|
<include>*.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>assembly/empty-logs</directory>
|
||||||
|
<outputDirectory>logs</outputDirectory>
|
||||||
|
</fileSet>
|
||||||
|
</fileSets>
|
||||||
|
|
||||||
|
</assembly>
|
||||||
0
assembly/empty-logs/.gitkeep
Normal file
0
assembly/empty-logs/.gitkeep
Normal file
96
docker-compose.yml
Normal file
96
docker-compose.yml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: label_db
|
||||||
|
POSTGRES_USER: label
|
||||||
|
POSTGRES_PASSWORD: label_password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./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
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: redis-server --requirepass redis_password
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "redis_password", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# RustFS is an S3-compatible object storage service.
|
||||||
|
# Using MinIO as a drop-in S3 API substitute for development/testing.
|
||||||
|
# Replace with the actual RustFS image in production environments.
|
||||||
|
rustfs:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- rustfs_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/label_db
|
||||||
|
SPRING_DATASOURCE_USERNAME: label
|
||||||
|
SPRING_DATASOURCE_PASSWORD: label_password
|
||||||
|
SPRING_DATA_REDIS_HOST: redis
|
||||||
|
SPRING_DATA_REDIS_PORT: 6379
|
||||||
|
SPRING_DATA_REDIS_PASSWORD: redis_password
|
||||||
|
RUSTFS_ENDPOINT: http://rustfs:9000
|
||||||
|
RUSTFS_ACCESS_KEY: minioadmin
|
||||||
|
RUSTFS_SECRET_KEY: minioadmin
|
||||||
|
AI_SERVICE_BASE_URL: http://ai-service:8000
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
rustfs:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/actuator/health 2>/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# Placeholder AI service — replace with the actual FastAPI image in production.
|
||||||
|
ai-service:
|
||||||
|
image: python:3.11-slim
|
||||||
|
command: ["python3", "-m", "http.server", "8000"]
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
# Placeholder frontend — replace with the actual Nginx + static build in production.
|
||||||
|
frontend:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
rustfs_data:
|
||||||
174
pom.xml
Normal file
174
pom.xml
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>com.label</groupId>
|
||||||
|
<artifactId>label-backend</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<spring.boot.version>3.1.5</spring.boot.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<postgrescp.version>42.2.24</postgrescp.version>
|
||||||
|
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
|
||||||
|
<springdoc-openapi.version>2.3.0</springdoc-openapi.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-dependencies</artifactId>
|
||||||
|
<version>${spring.boot.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- AWS SDK v2 BOM -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>bom</artifactId>
|
||||||
|
<version>2.26.31</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Testcontainers BOM -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-bom</artifactId>
|
||||||
|
<version>1.20.1</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>commons-logging</groupId>
|
||||||
|
<artifactId>commons-logging</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</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>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
<version>3.5.10</version>
|
||||||
|
</dependency> -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<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.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- AWS SDK v2 - S3 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>s3</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- AWS SDK v2 - STS -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>sts</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Security Crypto (BCrypt only, no web filter chain) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Boot Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Testcontainers - PostgreSQL -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Testcontainers - JUnit Jupiter -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>${java.version}</source>
|
||||||
|
<target>${java.version}</target>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.30</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>${spring.boot.version}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
25
scripts/start.sh
Normal file
25
scripts/start.sh
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 1. 获取脚本所在目录的绝对路径
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# 2. 获取项目根目录 (假设 bin 在根目录下)
|
||||||
|
APP_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# 3. 【关键步骤】切换到项目根目录
|
||||||
|
# 这样相对路径 "logs" 就会指向 $APP_HOME/logs
|
||||||
|
cd "$APP_HOME"
|
||||||
|
|
||||||
|
# 4. 确保 logs 目录存在
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# 5. 定义其他变量
|
||||||
|
JAR_FILE="$APP_HOME/libs/label-backend-1.0.0-SNAPSHOT.jar"
|
||||||
|
|
||||||
|
# 6. 启动应用
|
||||||
|
nohup java -Xms512m -Xmx512m \
|
||||||
|
-jar "$JAR_FILE" \
|
||||||
|
> /dev/null 2>&1 &
|
||||||
|
# 如果希望保留控制台日志备份,可以重定向到 $APP_HOME/logs/console.log
|
||||||
|
|
||||||
|
echo "Application started. Logs at: $APP_HOME/logs/"
|
||||||
15
src/main/java/com/label/LabelBackendApplication.java
Normal file
15
src/main/java/com/label/LabelBackendApplication.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.label;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用入口。
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class LabelBackendApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(LabelBackendApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/main/java/com/label/annotation/OperationLog.java
Normal file
18
src/main/java/com/label/annotation/OperationLog.java
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.label.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a method for audit logging.
|
||||||
|
* The AuditAspect intercepts this annotation and writes to sys_operation_log.
|
||||||
|
*/
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
public @interface OperationLog {
|
||||||
|
/** Operation type, e.g., "EXTRACTION_APPROVE", "USER_LOGIN", "TASK_CLAIM" */
|
||||||
|
String type();
|
||||||
|
|
||||||
|
/** Target entity type, e.g., "annotation_task", "sys_user" */
|
||||||
|
String targetType() default "";
|
||||||
|
}
|
||||||
11
src/main/java/com/label/annotation/RequireAuth.java
Normal file
11
src/main/java/com/label/annotation/RequireAuth.java
Normal 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 {
|
||||||
|
}
|
||||||
13
src/main/java/com/label/annotation/RequireRole.java
Normal file
13
src/main/java/com/label/annotation/RequireRole.java
Normal 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();
|
||||||
|
}
|
||||||
76
src/main/java/com/label/aspect/AuditAspect.java
Normal file
76
src/main/java/com/label/aspect/AuditAspect.java
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package com.label.aspect;
|
||||||
|
|
||||||
|
import com.label.annotation.OperationLog;
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AOP aspect for audit logging.
|
||||||
|
*
|
||||||
|
* KEY DESIGN DECISIONS:
|
||||||
|
* 1. Uses JdbcTemplate directly (not MyBatis Mapper) to bypass TenantLineInnerInterceptor
|
||||||
|
* — operation logs need to capture company_id explicitly, not via thread-local injection
|
||||||
|
* 2. Written in finally block — audit log is written regardless of business method success/failure
|
||||||
|
* 3. Audit failures are logged as ERROR but NEVER rethrown — business transactions must not be
|
||||||
|
* affected by audit failures
|
||||||
|
* 4. Captures result of business method to log SUCCESS or FAILURE
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditAspect {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Around("@annotation(operationLog)")
|
||||||
|
public Object audit(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable {
|
||||||
|
Long companyId = CompanyContext.get();
|
||||||
|
// operator_id can be obtained from SecurityContext or ThreadLocal in the future
|
||||||
|
// For now, use null as a safe default when not available
|
||||||
|
Long operatorId = null;
|
||||||
|
|
||||||
|
String result = "SUCCESS";
|
||||||
|
String errorMessage = null;
|
||||||
|
Object returnValue = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
returnValue = joinPoint.proceed();
|
||||||
|
} catch (Throwable e) {
|
||||||
|
result = "FAILURE";
|
||||||
|
errorMessage = e.getMessage();
|
||||||
|
throw e; // Always rethrow business exceptions
|
||||||
|
} finally {
|
||||||
|
// Write audit log in finally block — runs regardless of success or failure
|
||||||
|
// CRITICAL: Never throw from here — would swallow the original exception
|
||||||
|
try {
|
||||||
|
writeAuditLog(companyId, operatorId, operationLog.type(),
|
||||||
|
operationLog.targetType(), result, errorMessage);
|
||||||
|
} catch (Exception auditEx) {
|
||||||
|
// Audit failure must NOT affect business transaction
|
||||||
|
log.error("审计日志写入失败: type={}, error={}",
|
||||||
|
operationLog.type(), auditEx.getMessage(), auditEx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeAuditLog(Long companyId, Long operatorId, String operationType,
|
||||||
|
String targetType, String result, String errorMessage) {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO sys_operation_log
|
||||||
|
(company_id, operator_id, operation_type, target_type, result, error_message, operated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
""";
|
||||||
|
jdbcTemplate.update(sql, companyId, operatorId, operationType,
|
||||||
|
targetType.isEmpty() ? null : targetType,
|
||||||
|
result, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/main/java/com/label/common/ai/AiServiceClient.java
Normal file
226
src/main/java/com/label/common/ai/AiServiceClient.java
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package com.label.common.ai;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
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.RestTemplate;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AiServiceClient {
|
||||||
|
|
||||||
|
@Value("${ai-service.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
@Value("${ai-service.timeout:30000}")
|
||||||
|
private int timeoutMs;
|
||||||
|
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
restTemplate = new RestTemplateBuilder()
|
||||||
|
.rootUri(baseUrl)
|
||||||
|
.setConnectTimeout(Duration.ofMillis(timeoutMs))
|
||||||
|
.setReadTimeout(Duration.ofMillis(timeoutMs))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTO classes
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class TextExtractRequest {
|
||||||
|
@JsonProperty("file_path")
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@JsonProperty("file_name")
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@JsonProperty("prompt_template")
|
||||||
|
private String promptTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class ImageExtractRequest {
|
||||||
|
@JsonProperty("file_path")
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@JsonProperty("task_id")
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@JsonProperty("prompt_template")
|
||||||
|
private String promptTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class ExtractionResponse {
|
||||||
|
private List<Map<String, Object>> items; // triple/quadruple items
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class ExtractFramesRequest {
|
||||||
|
@JsonProperty("file_path")
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@JsonProperty("source_id")
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
@JsonProperty("job_id")
|
||||||
|
private Long jobId;
|
||||||
|
|
||||||
|
private String mode;
|
||||||
|
|
||||||
|
@JsonProperty("frame_interval")
|
||||||
|
private Integer frameInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class VideoToTextRequest {
|
||||||
|
@JsonProperty("file_path")
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@JsonProperty("source_id")
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
@JsonProperty("job_id")
|
||||||
|
private Long jobId;
|
||||||
|
|
||||||
|
@JsonProperty("start_sec")
|
||||||
|
private Double startSec;
|
||||||
|
|
||||||
|
@JsonProperty("end_sec")
|
||||||
|
private Double endSec;
|
||||||
|
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@JsonProperty("prompt_template")
|
||||||
|
private String promptTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class TextQaItem {
|
||||||
|
private String subject;
|
||||||
|
private String predicate;
|
||||||
|
private String object;
|
||||||
|
|
||||||
|
@JsonProperty("source_snippet")
|
||||||
|
private String sourceSnippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class GenTextQaRequest {
|
||||||
|
private List<TextQaItem> items;
|
||||||
|
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@JsonProperty("prompt_template")
|
||||||
|
private String promptTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class ImageQaItem {
|
||||||
|
private String subject;
|
||||||
|
private String predicate;
|
||||||
|
private String object;
|
||||||
|
private String qualifier;
|
||||||
|
|
||||||
|
@JsonProperty("cropped_image_path")
|
||||||
|
private String croppedImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class GenImageQaRequest {
|
||||||
|
private List<ImageQaItem> items;
|
||||||
|
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@JsonProperty("prompt_template")
|
||||||
|
private String promptTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class QaGenResponse {
|
||||||
|
private List<Map<String, Object>> pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class FinetuneStartRequest {
|
||||||
|
@JsonProperty("jsonl_url")
|
||||||
|
private String jsonlUrl;
|
||||||
|
|
||||||
|
@JsonProperty("base_model")
|
||||||
|
private String baseModel;
|
||||||
|
|
||||||
|
private Map<String, Object> hyperparams;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class FinetuneStartResponse {
|
||||||
|
@JsonProperty("job_id")
|
||||||
|
private String jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class FinetuneStatusResponse {
|
||||||
|
@JsonProperty("job_id")
|
||||||
|
private String jobId;
|
||||||
|
|
||||||
|
private String status; // PENDING/RUNNING/COMPLETED/FAILED
|
||||||
|
private Integer progress; // 0-100
|
||||||
|
|
||||||
|
@JsonProperty("error_message")
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 8 endpoints:
|
||||||
|
|
||||||
|
public ExtractionResponse extractText(TextExtractRequest request) {
|
||||||
|
return restTemplate.postForObject("/api/v1/text/extract", request, ExtractionResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExtractionResponse extractImage(ImageExtractRequest request) {
|
||||||
|
return restTemplate.postForObject("/api/v1/image/extract", request, ExtractionResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void extractFrames(ExtractFramesRequest request) {
|
||||||
|
restTemplate.postForLocation("/api/v1/video/extract-frames", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void videoToText(VideoToTextRequest request) {
|
||||||
|
restTemplate.postForLocation("/api/v1/video/to-text", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public QaGenResponse genTextQa(GenTextQaRequest request) {
|
||||||
|
return restTemplate.postForObject("/api/v1/qa/gen-text", request, QaGenResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public QaGenResponse genImageQa(GenImageQaRequest request) {
|
||||||
|
return restTemplate.postForObject("/api/v1/qa/gen-image", request, QaGenResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinetuneStartResponse startFinetune(FinetuneStartRequest request) {
|
||||||
|
return restTemplate.postForObject("/api/v1/finetune/start", request, FinetuneStartResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinetuneStatusResponse getFinetuneStatus(String jobId) {
|
||||||
|
return restTemplate.getForObject("/api/v1/finetune/status/{jobId}", FinetuneStatusResponse.class, jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/java/com/label/common/auth/TokenPrincipal.java
Normal file
16
src/main/java/com/label/common/auth/TokenPrincipal.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package com.label.common.auth;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TokenPrincipal implements Serializable {
|
||||||
|
private final Long userId;
|
||||||
|
private final String role;
|
||||||
|
private final Long companyId;
|
||||||
|
private final String username;
|
||||||
|
private final String token;
|
||||||
|
}
|
||||||
24
src/main/java/com/label/common/context/CompanyContext.java
Normal file
24
src/main/java/com/label/common/context/CompanyContext.java
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.label.common.context;
|
||||||
|
|
||||||
|
public class CompanyContext {
|
||||||
|
private static final ThreadLocal<Long> COMPANY_ID = new ThreadLocal<>().withInitial(() -> -1L);
|
||||||
|
|
||||||
|
public static void set(Long companyId) {
|
||||||
|
COMPANY_ID.set(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long get() {
|
||||||
|
if (COMPANY_ID.get() == null) {
|
||||||
|
throw new IllegalStateException("Company ID not set");
|
||||||
|
}
|
||||||
|
return COMPANY_ID.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
COMPANY_ID.remove(); // Use remove() not set(null) to prevent memory leaks
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompanyContext() { // Prevent instantiation
|
||||||
|
throw new UnsupportedOperationException("Utility class");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.label.common.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
private final String code;
|
||||||
|
private final HttpStatus httpStatus;
|
||||||
|
|
||||||
|
public BusinessException(String code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.httpStatus = HttpStatus.BAD_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(String code, String message, HttpStatus httpStatus) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.label.common.exception;
|
||||||
|
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ResponseEntity<Result<?>> handleBusinessException(BusinessException e) {
|
||||||
|
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
|
||||||
|
return ResponseEntity
|
||||||
|
.status(e.getHttpStatus())
|
||||||
|
.body(Result.failure(e.getCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Result<?>> handleException(Exception e) {
|
||||||
|
log.error("系统异常", e);
|
||||||
|
return ResponseEntity
|
||||||
|
.internalServerError()
|
||||||
|
.body(Result.failure("INTERNAL_ERROR", "系统内部错误"));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/main/java/com/label/common/result/PageResult.java
Normal file
31
src/main/java/com/label/common/result/PageResult.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.label.common.result;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "分页响应")
|
||||||
|
public class PageResult<T> {
|
||||||
|
@Schema(description = "当前页数据列表")
|
||||||
|
private List<T> items;
|
||||||
|
|
||||||
|
@Schema(description = "总条数", example = "123")
|
||||||
|
private long total;
|
||||||
|
|
||||||
|
@Schema(description = "页码(从 1 开始)", example = "1")
|
||||||
|
private int page;
|
||||||
|
|
||||||
|
@Schema(description = "每页条数", example = "20")
|
||||||
|
private int pageSize;
|
||||||
|
|
||||||
|
public static <T> PageResult<T> of(List<T> items, long total, int page, int pageSize) {
|
||||||
|
PageResult<T> pageResult = new PageResult<>();
|
||||||
|
pageResult.setItems(items);
|
||||||
|
pageResult.setTotal(total);
|
||||||
|
pageResult.setPage(page);
|
||||||
|
pageResult.setPageSize(pageSize);
|
||||||
|
return pageResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/main/java/com/label/common/result/Result.java
Normal file
44
src/main/java/com/label/common/result/Result.java
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package com.label.common.result;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "通用响应包装")
|
||||||
|
public class Result<T> {
|
||||||
|
@Schema(description = "业务状态码", example = "SUCCESS")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Schema(description = "响应数据")
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
@Schema(description = "提示信息", example = "操作成功")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
public static <T> Result<T> success(T data) {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(ResultCode.SUCCESS.name());
|
||||||
|
result.setData(data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> success() {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(ResultCode.SUCCESS.name());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> failure(ResultCode code, String message) {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(code.name());
|
||||||
|
result.setMessage(message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> failure(String code, String message) {
|
||||||
|
Result<T> result = new Result<>();
|
||||||
|
result.setCode(code);
|
||||||
|
result.setMessage(message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main/java/com/label/common/result/ResultCode.java
Normal file
19
src/main/java/com/label/common/result/ResultCode.java
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package com.label.common.result;
|
||||||
|
|
||||||
|
public enum ResultCode {
|
||||||
|
SUCCESS,
|
||||||
|
FAILURE,
|
||||||
|
UNAUTHORIZED, // 401 - no valid token
|
||||||
|
FORBIDDEN, // 403 - insufficient role
|
||||||
|
NOT_FOUND, // 404
|
||||||
|
CONFLICT, // 409
|
||||||
|
INVALID_STATE, // 409 state machine violation
|
||||||
|
TASK_CLAIMED, // 409 task already claimed
|
||||||
|
SELF_REVIEW_FORBIDDEN, // 403 self-review prevention
|
||||||
|
UNKNOWN_CONFIG_KEY, // 400 unknown config key
|
||||||
|
INVALID_SAMPLES, // 400 invalid export samples
|
||||||
|
EMPTY_SAMPLES, // 400 empty sample list
|
||||||
|
FINETUNE_ALREADY_STARTED, // 409 fine-tune already started
|
||||||
|
INVALID_STATE_TRANSITION, // 409 invalid state machine transition
|
||||||
|
INTERNAL_ERROR // 500
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.label.common.statemachine;
|
||||||
|
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic state machine validator.
|
||||||
|
* Validates state transitions against a predefined transitions map.
|
||||||
|
*/
|
||||||
|
public final class StateValidator {
|
||||||
|
|
||||||
|
private StateValidator() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a state transition from {@code current} to {@code next} is valid.
|
||||||
|
*
|
||||||
|
* @param transitions the allowed transitions map
|
||||||
|
* @param current the current state
|
||||||
|
* @param next the desired next state
|
||||||
|
* @param <S> the state type (enum)
|
||||||
|
* @throws BusinessException with code INVALID_STATE_TRANSITION if transition not allowed
|
||||||
|
*/
|
||||||
|
public static <S> void assertTransition(Map<S, Set<S>> transitions, S current, S next) {
|
||||||
|
Set<S> allowed = transitions.get(current);
|
||||||
|
if (allowed == null || !allowed.contains(next)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"INVALID_STATE_TRANSITION",
|
||||||
|
String.format("不允许的状态转换: %s → %s", current, next),
|
||||||
|
HttpStatus.CONFLICT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/java/com/label/common/statemachine/TaskStatus.java
Normal file
16
src/main/java/com/label/common/statemachine/TaskStatus.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package com.label.common.statemachine;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public enum TaskStatus {
|
||||||
|
UNCLAIMED, IN_PROGRESS, SUBMITTED, APPROVED, REJECTED;
|
||||||
|
|
||||||
|
public static final Map<TaskStatus, Set<TaskStatus>> TRANSITIONS = Map.of(
|
||||||
|
UNCLAIMED, Set.of(IN_PROGRESS),
|
||||||
|
IN_PROGRESS, Set.of(SUBMITTED, UNCLAIMED, IN_PROGRESS), // IN_PROGRESS->IN_PROGRESS for ADMIN reassign
|
||||||
|
SUBMITTED, Set.of(APPROVED, REJECTED),
|
||||||
|
REJECTED, Set.of(IN_PROGRESS)
|
||||||
|
// APPROVED: terminal state, no outgoing transitions
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.label.common.statemachine;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public enum VideoSourceStatus {
|
||||||
|
PENDING, PREPROCESSING, EXTRACTING, QA_REVIEW, APPROVED;
|
||||||
|
|
||||||
|
public static final Map<VideoSourceStatus, Set<VideoSourceStatus>> TRANSITIONS = Map.of(
|
||||||
|
PENDING, Set.of(EXTRACTING, PREPROCESSING),
|
||||||
|
PREPROCESSING, Set.of(PENDING),
|
||||||
|
EXTRACTING, Set.of(QA_REVIEW),
|
||||||
|
QA_REVIEW, Set.of(APPROVED)
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/main/java/com/label/common/storage/RustFsClient.java
Normal file
124
src/main/java/com/label/common/storage/RustFsClient.java
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package com.label.common.storage;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||||
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.regions.Region;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
|
||||||
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RustFsClient {
|
||||||
|
|
||||||
|
@Value("${rustfs.endpoint}")
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
@Value("${rustfs.access-key}")
|
||||||
|
private String accessKey;
|
||||||
|
|
||||||
|
@Value("${rustfs.secret-key}")
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
private S3Client s3Client;
|
||||||
|
private S3Presigner presigner;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
var credentials = StaticCredentialsProvider.create(
|
||||||
|
AwsBasicCredentials.create(accessKey, secretKey));
|
||||||
|
|
||||||
|
s3Client = S3Client.builder()
|
||||||
|
.endpointOverride(URI.create(endpoint))
|
||||||
|
.credentialsProvider(credentials)
|
||||||
|
.region(Region.US_EAST_1)
|
||||||
|
.forcePathStyle(true) // Required for MinIO/RustFS
|
||||||
|
.build();
|
||||||
|
|
||||||
|
presigner = S3Presigner.builder()
|
||||||
|
.endpointOverride(URI.create(endpoint))
|
||||||
|
.credentialsProvider(credentials)
|
||||||
|
.region(Region.US_EAST_1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file to RustFS.
|
||||||
|
* @param bucket bucket name
|
||||||
|
* @param key object key (path)
|
||||||
|
* @param inputStream file content
|
||||||
|
* @param contentLength file size in bytes
|
||||||
|
* @param contentType MIME type
|
||||||
|
*/
|
||||||
|
public void upload(String bucket, String key, InputStream inputStream,
|
||||||
|
long contentLength, String contentType) {
|
||||||
|
// Ensure bucket exists
|
||||||
|
ensureBucketExists(bucket);
|
||||||
|
|
||||||
|
s3Client.putObject(
|
||||||
|
PutObjectRequest.builder()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.contentType(contentType)
|
||||||
|
.contentLength(contentLength)
|
||||||
|
.build(),
|
||||||
|
RequestBody.fromInputStream(inputStream, contentLength)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file from RustFS.
|
||||||
|
*/
|
||||||
|
public InputStream download(String bucket, String key) {
|
||||||
|
return s3Client.getObject(
|
||||||
|
GetObjectRequest.builder().bucket(bucket).key(key).build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete file from RustFS.
|
||||||
|
*/
|
||||||
|
public void delete(String bucket, String key) {
|
||||||
|
s3Client.deleteObject(
|
||||||
|
DeleteObjectRequest.builder().bucket(bucket).key(key).build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a presigned URL for temporary read access.
|
||||||
|
* @param expirationMinutes URL validity in minutes
|
||||||
|
*/
|
||||||
|
public String getPresignedUrl(String bucket, String key, int expirationMinutes) {
|
||||||
|
var presignRequest = GetObjectPresignRequest.builder()
|
||||||
|
.signatureDuration(Duration.ofMinutes(expirationMinutes))
|
||||||
|
.getObjectRequest(GetObjectRequest.builder().bucket(bucket).key(key).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return presigner.presignGetObject(presignRequest).url().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureBucketExists(String bucket) {
|
||||||
|
try {
|
||||||
|
s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build());
|
||||||
|
} catch (NoSuchBucketException e) {
|
||||||
|
s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||||
|
log.info("Created bucket: {}", bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/java/com/label/config/AsyncConfig.java
Normal file
26
src/main/java/com/label/config/AsyncConfig.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.label.config;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
@Bean("aiTaskExecutor")
|
||||||
|
public Executor aiTaskExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(5);
|
||||||
|
executor.setMaxPoolSize(10);
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
executor.setThreadNamePrefix("ai-annotate-");
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/java/com/label/config/AuthConfig.java
Normal file
20
src/main/java/com/label/config/AuthConfig.java
Normal 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("/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main/java/com/label/config/MybatisPlusConfig.java
Normal file
58
src/main/java/com/label/config/MybatisPlusConfig.java
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package com.label.config;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
|
import net.sf.jsqlparser.expression.LongValue;
|
||||||
|
import net.sf.jsqlparser.expression.NullValue;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class MybatisPlusConfig {
|
||||||
|
|
||||||
|
// Tables that do NOT need tenant isolation (either global or tenant root tables)
|
||||||
|
private static final List<String> IGNORED_TABLES = Arrays.asList(
|
||||||
|
"sys_company", // the tenant root table itself
|
||||||
|
"sys_config", // has company_id=NULL for global defaults; service handles this manually
|
||||||
|
"video_process_job" // accessed by unauthenticated callback endpoint; service validates companyId manually
|
||||||
|
);
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
|
||||||
|
// 1. Tenant isolation - auto-injects WHERE company_id = ?
|
||||||
|
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||||
|
@Override
|
||||||
|
public Expression getTenantId() {
|
||||||
|
Long companyId = CompanyContext.get();
|
||||||
|
if (companyId == null) {
|
||||||
|
return new NullValue();
|
||||||
|
}
|
||||||
|
return new LongValue(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getTenantIdColumn() {
|
||||||
|
return "company_id";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean ignoreTable(String tableName) {
|
||||||
|
return IGNORED_TABLES.contains(tableName);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Pagination interceptor (required for MyBatis Plus Page queries)
|
||||||
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||||
|
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/label/config/OpenApiConfig.java
Normal file
33
src/main/java/com/label/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.label.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpringDoc OpenAPI 全局配置:API 基本信息 + Bearer Token 安全方案。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("Label Backend API")
|
||||||
|
.version("1.0.0")
|
||||||
|
.description("知识图谱智能标注平台后端接口文档"))
|
||||||
|
.addSecurityItem(new SecurityRequirement().addList("BearerAuth"))
|
||||||
|
.components(new Components()
|
||||||
|
.addSecuritySchemes("BearerAuth",
|
||||||
|
new SecurityScheme()
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.scheme("bearer")
|
||||||
|
.bearerFormat("UUID")
|
||||||
|
.description("登录后返回的 Token,格式:Bearer {uuid}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/main/java/com/label/config/RedisConfig.java
Normal file
24
src/main/java/com/label/config/RedisConfig.java
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.label.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(connectionFactory);
|
||||||
|
StringRedisSerializer serializer = new StringRedisSerializer();
|
||||||
|
template.setKeySerializer(serializer);
|
||||||
|
template.setValueSerializer(serializer);
|
||||||
|
template.setHashKeySerializer(serializer);
|
||||||
|
template.setHashValueSerializer(serializer);
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/main/java/com/label/controller/AuthController.java
Normal file
77
src/main/java/com/label/controller/AuthController.java
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package com.label.controller;
|
||||||
|
|
||||||
|
import com.label.annotation.RequireAuth;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
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;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证接口:登录、退出、获取当前用户。
|
||||||
|
*
|
||||||
|
* 路由设计:
|
||||||
|
* - POST /api/auth/login → 匿名(AuthInterceptor 跳过)
|
||||||
|
* - POST /api/auth/logout → 需要有效 Token(AuthInterceptor 校验)
|
||||||
|
* - GET /api/auth/me → 需要有效 Token(AuthInterceptor 校验)
|
||||||
|
*/
|
||||||
|
@Tag(name = "认证管理", description = "登录、退出和当前用户信息")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录接口(匿名,无需 Token)。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "用户登录,返回 Bearer Token")
|
||||||
|
@PostMapping("/login")
|
||||||
|
public Result<LoginResponse> login(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "用户登录请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody LoginRequest request) {
|
||||||
|
return Result.success(authService.login(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录,立即删除 Redis Token。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "退出登录并立即失效当前 Token")
|
||||||
|
@PostMapping("/logout")
|
||||||
|
@RequireAuth
|
||||||
|
public Result<Void> logout(HttpServletRequest request) {
|
||||||
|
String token = extractToken(request);
|
||||||
|
authService.logout(token);
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户信息。
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Authorization 头提取 Bearer token 字符串 */
|
||||||
|
private String extractToken(HttpServletRequest request) {
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
return authHeader.substring(7).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/main/java/com/label/controller/CompanyController.java
Normal file
96
src/main/java/com/label/controller/CompanyController.java
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package com.label.controller;
|
||||||
|
|
||||||
|
import com.label.annotation.RequireRole;
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.dto.CompanyCreateRequest;
|
||||||
|
import com.label.dto.CompanyStatusUpdateRequest;
|
||||||
|
import com.label.dto.CompanyUpdateRequest;
|
||||||
|
import com.label.entity.SysCompany;
|
||||||
|
import com.label.service.CompanyService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Tag(name = "公司管理", description = "租户公司增删改查")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label/api/companies")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CompanyController {
|
||||||
|
|
||||||
|
private final CompanyService companyService;
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询公司列表")
|
||||||
|
@GetMapping
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<PageResult<SysCompany>> list(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@Parameter(description = "公司状态过滤,可选值:ACTIVE、DISABLED", example = "ACTIVE")
|
||||||
|
@RequestParam(required = false) String status) {
|
||||||
|
return Result.success(companyService.list(page, pageSize, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "创建公司")
|
||||||
|
@PostMapping
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Result<SysCompany> create(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "创建公司请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody CompanyCreateRequest body) {
|
||||||
|
return Result.success(companyService.create(body.getCompanyName(), body.getCompanyCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新公司信息")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<SysCompany> update(
|
||||||
|
@Parameter(description = "公司 ID", example = "100")
|
||||||
|
@PathVariable Long id,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "更新公司信息请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody CompanyUpdateRequest body) {
|
||||||
|
return Result.success(companyService.update(id, body.getCompanyName(), body.getCompanyCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新公司状态")
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<Void> updateStatus(
|
||||||
|
@Parameter(description = "公司 ID", example = "100")
|
||||||
|
@PathVariable Long id,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "更新公司状态请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody CompanyStatusUpdateRequest body) {
|
||||||
|
companyService.updateStatus(id, body.getStatus());
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除公司")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<Void> delete(
|
||||||
|
@Parameter(description = "公司 ID", example = "100")
|
||||||
|
@PathVariable Long id) {
|
||||||
|
companyService.delete(id);
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/main/java/com/label/controller/ExportController.java
Normal file
127
src/main/java/com/label/controller/ExportController.java
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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.dto.ExportBatchCreateRequest;
|
||||||
|
import com.label.dto.FinetuneJobResponse;
|
||||||
|
import com.label.entity.TrainingDataset;
|
||||||
|
import com.label.entity.ExportBatch;
|
||||||
|
import com.label.service.ExportService;
|
||||||
|
import com.label.service.FinetuneService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 训练数据导出与微调接口(5 个端点,全部 ADMIN 权限)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "导出管理", description = "训练样本查询、导出批次和微调任务")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExportController {
|
||||||
|
|
||||||
|
private final ExportService exportService;
|
||||||
|
private final FinetuneService finetuneService;
|
||||||
|
|
||||||
|
/** GET /api/training/samples — 分页查询已审批可导出样本 */
|
||||||
|
@Operation(summary = "分页查询可导出训练样本")
|
||||||
|
@GetMapping("/api/training/samples")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<PageResult<TrainingDataset>> listSamples(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@Parameter(description = "样本类型过滤,可选值:EXTRACTION、QA_GENERATION", example = "EXTRACTION")
|
||||||
|
@RequestParam(required = false) String sampleType,
|
||||||
|
@Parameter(description = "是否已导出过滤", example = "false")
|
||||||
|
@RequestParam(required = false) Boolean exported,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(exportService.listSamples(page, pageSize, sampleType, exported, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/export/batch — 创建导出批次 */
|
||||||
|
@Operation(summary = "创建导出批次")
|
||||||
|
@PostMapping("/api/export/batch")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Result<ExportBatch> createBatch(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "创建训练数据导出批次请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody ExportBatchCreateRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(exportService.createBatch(body.getSampleIds(), principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/export/{batchId}/finetune — 提交微调任务 */
|
||||||
|
@Operation(summary = "提交微调任务")
|
||||||
|
@PostMapping("/api/export/{batchId}/finetune")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<FinetuneJobResponse> triggerFinetune(
|
||||||
|
@Parameter(description = "导出批次 ID", example = "501")
|
||||||
|
@PathVariable Long batchId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(toFinetuneJobResponse(finetuneService.trigger(batchId, principal(request))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/export/{batchId}/status — 查询微调状态 */
|
||||||
|
@Operation(summary = "查询微调状态")
|
||||||
|
@GetMapping("/api/export/{batchId}/status")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<FinetuneJobResponse> getFinetuneStatus(
|
||||||
|
@Parameter(description = "导出批次 ID", example = "501")
|
||||||
|
@PathVariable Long batchId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(toFinetuneJobResponse(finetuneService.getStatus(batchId, principal(request))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/export/list — 分页查询导出批次列表 */
|
||||||
|
@Operation(summary = "分页查询导出批次")
|
||||||
|
@GetMapping("/api/export/list")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<PageResult<ExportBatch>> listBatches(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(exportService.listBatches(page, pageSize, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private FinetuneJobResponse toFinetuneJobResponse(Map<String, Object> values) {
|
||||||
|
FinetuneJobResponse response = new FinetuneJobResponse();
|
||||||
|
response.setBatchId(asLong(values.get("batchId")));
|
||||||
|
response.setGlmJobId(asString(values.get("glmJobId")));
|
||||||
|
response.setFinetuneStatus(asString(values.get("finetuneStatus")));
|
||||||
|
response.setProgress(asInteger(values.get("progress")));
|
||||||
|
response.setErrorMessage(asString(values.get("errorMessage")));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long asLong(Object value) {
|
||||||
|
return value == null ? null : Long.parseLong(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer asInteger(Object value) {
|
||||||
|
return value == null ? null : Integer.parseInt(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String asString(Object value) {
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/main/java/com/label/controller/ExtractionController.java
Normal file
107
src/main/java/com/label/controller/ExtractionController.java
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package com.label.controller;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import com.label.annotation.RequireRole;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.dto.RejectRequest;
|
||||||
|
import com.label.service.ExtractionService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取阶段标注工作台接口(5 个端点)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "提取标注", description = "提取阶段的查看、编辑、提交和审批")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label/api/extraction")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExtractionController {
|
||||||
|
|
||||||
|
private final ExtractionService extractionService;
|
||||||
|
|
||||||
|
/** POST /api/extraction/{taskId}/ai-annotate — AI 辅助预标注 */
|
||||||
|
@Operation(summary = "AI 辅助预标注", description = "调用 AI 服务自动生成预标注结果,可重复调用")
|
||||||
|
@PostMapping("/{taskId}/ai-annotate")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> aiPreAnnotate(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
extractionService.aiPreAnnotate(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/extraction/{taskId} — 获取当前标注结果 */
|
||||||
|
@Operation(summary = "获取提取标注结果")
|
||||||
|
@GetMapping("/{taskId}")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Map<String, Object>> getResult(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(extractionService.getResult(taskId, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/extraction/{taskId} — 更新标注结果(整体覆盖) */
|
||||||
|
@Operation(summary = "更新提取标注结果")
|
||||||
|
@PutMapping("/{taskId}")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> updateResult(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "完整提取标注结果 JSON 字符串,保持原始 JSON body 直接提交", required = true) @RequestBody String resultJson,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
extractionService.updateResult(taskId, resultJson, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/extraction/{taskId}/submit — 提交标注结果 */
|
||||||
|
@Operation(summary = "提交提取标注结果")
|
||||||
|
@PostMapping("/{taskId}/submit")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> submit(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
extractionService.submit(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/extraction/{taskId}/approve — 审批通过(REVIEWER) */
|
||||||
|
@Operation(summary = "审批通过提取结果")
|
||||||
|
@PostMapping("/{taskId}/approve")
|
||||||
|
@RequireRole("REVIEWER")
|
||||||
|
public Result<Void> approve(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
extractionService.approve(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/extraction/{taskId}/reject — 驳回(REVIEWER) */
|
||||||
|
@Operation(summary = "驳回提取结果")
|
||||||
|
@PostMapping("/{taskId}/reject")
|
||||||
|
@RequireRole("REVIEWER")
|
||||||
|
public Result<Void> reject(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001") @PathVariable Long taskId,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "驳回提取结果请求体", required = true) @RequestBody RejectRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String reason = body != null ? body.getReason() : null;
|
||||||
|
extractionService.reject(taskId, reason, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/main/java/com/label/controller/QaController.java
Normal file
99
src/main/java/com/label/controller/QaController.java
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package com.label.controller;
|
||||||
|
|
||||||
|
import com.label.annotation.RequireRole;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.dto.RejectRequest;
|
||||||
|
import com.label.service.QaService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 问答生成阶段标注工作台接口(5 个端点)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "问答生成", description = "问答生成阶段的查看、编辑、提交和审批")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label/api/qa")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class QaController {
|
||||||
|
|
||||||
|
private final QaService qaService;
|
||||||
|
|
||||||
|
/** GET /api/qa/{taskId} — 获取候选问答对 */
|
||||||
|
@Operation(summary = "获取候选问答对")
|
||||||
|
@GetMapping("/{taskId}")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Map<String, Object>> getResult(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(qaService.getResult(taskId, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/qa/{taskId} — 整体覆盖问答对 */
|
||||||
|
@Operation(summary = "更新候选问答对")
|
||||||
|
@PutMapping("/{taskId}")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> updateResult(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "完整问答标注结果 JSON 字符串,保持原始 JSON body 直接提交",
|
||||||
|
required = true)
|
||||||
|
@RequestBody String body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
qaService.updateResult(taskId, body, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/qa/{taskId}/submit — 提交问答对 */
|
||||||
|
@Operation(summary = "提交问答对")
|
||||||
|
@PostMapping("/{taskId}/submit")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> submit(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
qaService.submit(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/qa/{taskId}/approve — 审批通过(REVIEWER) */
|
||||||
|
@Operation(summary = "审批通过问答对")
|
||||||
|
@PostMapping("/{taskId}/approve")
|
||||||
|
@RequireRole("REVIEWER")
|
||||||
|
public Result<Void> approve(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
qaService.approve(taskId, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/qa/{taskId}/reject — 驳回(REVIEWER) */
|
||||||
|
@Operation(summary = "驳回答案对")
|
||||||
|
@PostMapping("/{taskId}/reject")
|
||||||
|
@RequireRole("REVIEWER")
|
||||||
|
public Result<Void> reject(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "驳回问答结果请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody RejectRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String reason = body != null ? body.getReason() : null;
|
||||||
|
qaService.reject(taskId, reason, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/main/java/com/label/controller/SourceController.java
Normal file
99
src/main/java/com/label/controller/SourceController.java
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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.dto.SourceResponse;
|
||||||
|
import com.label.service.SourceService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原始资料管理接口。
|
||||||
|
*
|
||||||
|
* 权限设计:
|
||||||
|
* - 上传 / 列表 / 详情:UPLOADER 及以上角色(含 ANNOTATOR、REVIEWER、ADMIN)
|
||||||
|
* - 删除:仅 ADMIN
|
||||||
|
*/
|
||||||
|
@Tag(name = "资料管理", description = "原始资料上传、查询和删除")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label/api/source")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SourceController {
|
||||||
|
|
||||||
|
private final SourceService sourceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件(multipart/form-data)。
|
||||||
|
* 返回 201 Created + 资料摘要。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "上传原始资料", description = "dataType: text,image, video")
|
||||||
|
@PostMapping("/upload")
|
||||||
|
@RequireRole("UPLOADER")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Result<SourceResponse> upload(
|
||||||
|
@Parameter(description = "上传文件,支持文本、图片、视频", required = true)
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@Parameter(description = "资料类型,可选值:text、image、video", example = "text", required = true)
|
||||||
|
@RequestParam("dataType") String dataType,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
return Result.success(sourceService.upload(file, dataType, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询资料列表。
|
||||||
|
* UPLOADER 只见自己的资料;ADMIN 见全公司资料。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "分页查询资料列表")
|
||||||
|
@GetMapping("/list")
|
||||||
|
@RequireRole("UPLOADER")
|
||||||
|
public Result<PageResult<SourceResponse>> list(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@Parameter(description = "资料类型过滤,可选值:text、image、video", example = "text")
|
||||||
|
@RequestParam(required = false) String dataType,
|
||||||
|
@Parameter(description = "资料状态过滤", example = "PENDING")
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
return Result.success(sourceService.list(page, pageSize, dataType, status, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询资料详情(含 15 分钟预签名下载链接)。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询资料详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequireRole("UPLOADER")
|
||||||
|
public Result<SourceResponse> findById(
|
||||||
|
@Parameter(description = "资料 ID", example = "1001")
|
||||||
|
@PathVariable Long id) {
|
||||||
|
return Result.success(sourceService.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除资料(仅 PENDING 状态可删)。
|
||||||
|
* 同步删除 RustFS 文件及 DB 记录。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "删除资料")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<Void> delete(
|
||||||
|
@Parameter(description = "资料 ID", example = "1001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
sourceService.delete(id, principal.getCompanyId());
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/main/java/com/label/controller/SysConfigController.java
Normal file
94
src/main/java/com/label/controller/SysConfigController.java
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package com.label.controller;
|
||||||
|
|
||||||
|
import com.label.annotation.RequireRole;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.dto.SysConfigItemResponse;
|
||||||
|
import com.label.dto.SysConfigUpdateRequest;
|
||||||
|
import com.label.entity.SysConfig;
|
||||||
|
import com.label.service.SysConfigService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置接口(2 个端点,均需 ADMIN 权限)。
|
||||||
|
*
|
||||||
|
* GET /api/config — 查询当前公司所有可见配置(公司专属 + 全局默认合并)
|
||||||
|
* PUT /api/config/{key} — 更新/创建公司专属配置(UPSERT)
|
||||||
|
*/
|
||||||
|
@Tag(name = "系统配置", description = "全局和公司级系统配置管理")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SysConfigController {
|
||||||
|
|
||||||
|
private final SysConfigService sysConfigService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/config — 查询合并后的配置列表。
|
||||||
|
*
|
||||||
|
* 响应中每条配置含 scope 字段:
|
||||||
|
* - "COMPANY":当前公司专属配置(优先生效)
|
||||||
|
* - "GLOBAL":全局默认配置(公司未覆盖时生效)
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询合并后的系统配置")
|
||||||
|
@GetMapping("/api/config")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<List<SysConfigItemResponse>> listConfig(HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(sysConfigService.list(principal.getCompanyId()).stream()
|
||||||
|
.map(this::toConfigItemResponse)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/config/{key} — UPSERT 公司专属配置。
|
||||||
|
*
|
||||||
|
* Body: { "value": "...", "description": "..." }
|
||||||
|
*/
|
||||||
|
@Operation(summary = "更新或创建公司专属配置")
|
||||||
|
@PutMapping("/api/config/{key}")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<SysConfig> updateConfig(
|
||||||
|
@Parameter(description = "系统配置键,可选值:token_ttl_seconds、model_default、video_frame_interval", example = "model_default")
|
||||||
|
@PathVariable String key,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "系统配置更新请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody SysConfigUpdateRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(
|
||||||
|
sysConfigService.update(key, body.getValue(), body.getDescription(), principal.getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysConfigItemResponse toConfigItemResponse(Map<String, Object> item) {
|
||||||
|
SysConfigItemResponse response = new SysConfigItemResponse();
|
||||||
|
response.setId(asLong(item.get("id")));
|
||||||
|
response.setConfigKey(asString(item.get("configKey")));
|
||||||
|
response.setConfigValue(asString(item.get("configValue")));
|
||||||
|
response.setDescription(asString(item.get("description")));
|
||||||
|
response.setScope(asString(item.get("scope")));
|
||||||
|
response.setCompanyId(asLong(item.get("companyId")));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long asLong(Object value) {
|
||||||
|
return value == null ? null : Long.parseLong(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String asString(Object value) {
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/main/java/com/label/controller/TaskController.java
Normal file
169
src/main/java/com/label/controller/TaskController.java
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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.dto.CreateTaskRequest;
|
||||||
|
import com.label.dto.TaskReassignRequest;
|
||||||
|
import com.label.dto.TaskResponse;
|
||||||
|
import com.label.service.TaskClaimService;
|
||||||
|
import com.label.service.TaskService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务管理接口(10 个端点)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "任务管理", description = "任务池、我的任务、审批队列和管理操作")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label/api/tasks")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TaskController {
|
||||||
|
|
||||||
|
private final TaskService taskService;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
|
||||||
|
/** GET /api/tasks/pool — 查询可领取任务池(角色感知) */
|
||||||
|
@Operation(summary = "查询可领取任务池")
|
||||||
|
@GetMapping("/pool")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<PageResult<TaskResponse>> getPool(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(taskService.getPool(page, pageSize, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks/mine — 查询我的任务 */
|
||||||
|
@Operation(summary = "查询我的任务")
|
||||||
|
@GetMapping("/mine")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<PageResult<TaskResponse>> getMine(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@Parameter(description = "任务状态过滤,可选值:UNCLAIMED、IN_PROGRESS、SUBMITTED、APPROVED、REJECTED", example = "IN_PROGRESS")
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(taskService.getMine(page, pageSize, status, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks/pending-review — 待审批队列(REVIEWER 专属) */
|
||||||
|
@Operation(summary = "查询待审批任务")
|
||||||
|
@GetMapping("/pending-review")
|
||||||
|
@RequireRole("REVIEWER")
|
||||||
|
public Result<PageResult<TaskResponse>> getPendingReview(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@Parameter(description = "任务类型过滤,可选值:EXTRACTION、QA_GENERATION", example = "EXTRACTION")
|
||||||
|
@RequestParam(required = false) String taskType) {
|
||||||
|
return Result.success(taskService.getPendingReview(page, pageSize, taskType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks — 查询全部任务(ADMIN) */
|
||||||
|
@Operation(summary = "管理员查询全部任务")
|
||||||
|
@GetMapping
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<PageResult<TaskResponse>> getAll(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@Parameter(description = "任务状态过滤,可选值:UNCLAIMED、IN_PROGRESS、SUBMITTED、APPROVED、REJECTED", example = "SUBMITTED")
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@Parameter(description = "任务类型过滤,可选值:EXTRACTION、QA_GENERATION", example = "QA_GENERATION")
|
||||||
|
@RequestParam(required = false) String taskType) {
|
||||||
|
return Result.success(taskService.getAll(page, pageSize, status, taskType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks — 创建任务(ADMIN) */
|
||||||
|
@Operation(summary = "管理员创建任务")
|
||||||
|
@PostMapping
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<TaskResponse> createTask(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "创建标注任务请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody CreateTaskRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(taskService.toPublicResponse(
|
||||||
|
taskService.createTask(body.getSourceId(), body.getTaskType(), principal.getCompanyId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tasks/{id} — 查询任务详情 */
|
||||||
|
@Operation(summary = "查询任务详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<TaskResponse> getById(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long id) {
|
||||||
|
return Result.success(taskService.toPublicResponse(taskService.getById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks/{id}/claim — 领取任务 */
|
||||||
|
@Operation(summary = "领取任务")
|
||||||
|
@PostMapping("/{id}/claim")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> claim(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
taskClaimService.claim(id, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks/{id}/unclaim — 放弃任务 */
|
||||||
|
@Operation(summary = "放弃任务")
|
||||||
|
@PostMapping("/{id}/unclaim")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> unclaim(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
taskClaimService.unclaim(id, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/tasks/{id}/reclaim — 重领被驳回的任务 */
|
||||||
|
@Operation(summary = "重领被驳回的任务")
|
||||||
|
@PostMapping("/{id}/reclaim")
|
||||||
|
@RequireRole("ANNOTATOR")
|
||||||
|
public Result<Void> reclaim(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
taskClaimService.reclaim(id, principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/tasks/{id}/reassign — ADMIN 强制指派 */
|
||||||
|
@Operation(summary = "管理员强制指派任务")
|
||||||
|
@PutMapping("/{id}/reassign")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<Void> reassign(
|
||||||
|
@Parameter(description = "任务 ID", example = "1001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "管理员强制改派任务请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody TaskReassignRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
taskService.reassign(id, body.getUserId(), principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/main/java/com/label/controller/UserController.java
Normal file
125
src/main/java/com/label/controller/UserController.java
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package com.label.controller;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import com.label.annotation.RequireRole;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.dto.UserCreateRequest;
|
||||||
|
import com.label.dto.UserRoleUpdateRequest;
|
||||||
|
import com.label.dto.UserStatusUpdateRequest;
|
||||||
|
import com.label.dto.UserUpdateRequest;
|
||||||
|
import com.label.entity.SysUser;
|
||||||
|
import com.label.service.UserService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理接口(5 个端点,全部 ADMIN 权限)。
|
||||||
|
*/
|
||||||
|
@Tag(name = "用户管理", description = "管理员维护公司用户")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label/api/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/** GET /api/users — 分页查询用户列表 */
|
||||||
|
@Operation(summary = "分页查询用户列表")
|
||||||
|
@GetMapping
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<PageResult<SysUser>> listUsers(
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@Parameter(description = "每页条数", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(userService.listUsers(page, pageSize, principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/users — 创建用户 */
|
||||||
|
@Operation(summary = "创建用户")
|
||||||
|
@PostMapping
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<SysUser> createUser(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "创建用户请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody UserCreateRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(userService.createUser(
|
||||||
|
body.getUsername(),
|
||||||
|
body.getPassword(),
|
||||||
|
body.getRealName(),
|
||||||
|
body.getRole(),
|
||||||
|
principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/users/{id} — 更新用户基本信息 */
|
||||||
|
@Operation(summary = "更新用户基本信息")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<SysUser> updateUser(
|
||||||
|
@Parameter(description = "用户 ID", example = "2001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "更新用户基本信息请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody UserUpdateRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(userService.updateUser(
|
||||||
|
id,
|
||||||
|
body.getRealName(),
|
||||||
|
body.getPassword(),
|
||||||
|
principal(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/users/{id}/status — 变更用户状态 */
|
||||||
|
@Operation(summary = "变更用户状态", description = "status:ACTIVE、DISABLED")
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<Void> updateStatus(
|
||||||
|
@Parameter(description = "用户 ID", example = "2001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "更新用户状态请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody UserStatusUpdateRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
userService.updateStatus(id, body.getStatus(), principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/users/{id}/role — 变更用户角色 */
|
||||||
|
@Operation(summary = "变更用户角色", description = "role:ADMIN、UPLOADER、VIEWER")
|
||||||
|
@PutMapping("/{id}/role")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<Void> updateRole(
|
||||||
|
@Parameter(description = "用户 ID", example = "2001")
|
||||||
|
@PathVariable Long id,
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "更新用户角色请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody UserRoleUpdateRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
userService.updateRole(id, body.getRole(), principal(request));
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/main/java/com/label/controller/VideoController.java
Normal file
122
src/main/java/com/label/controller/VideoController.java
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package com.label.controller;
|
||||||
|
|
||||||
|
import com.label.annotation.RequireRole;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.result.Result;
|
||||||
|
import com.label.dto.VideoProcessCallbackRequest;
|
||||||
|
import com.label.dto.VideoProcessCreateRequest;
|
||||||
|
import com.label.entity.VideoProcessJob;
|
||||||
|
import com.label.service.VideoProcessService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频处理接口(4 个端点)。
|
||||||
|
*
|
||||||
|
* POST /api/video/process — 触发视频处理(ADMIN)
|
||||||
|
* GET /api/video/jobs/{jobId} — 查询任务状态(ADMIN)
|
||||||
|
* POST /api/video/jobs/{jobId}/reset — 重置失败任务(ADMIN)
|
||||||
|
* POST /api/video/callback — AI 回调接口(无需认证,已在 AuthInterceptor 中排除)
|
||||||
|
*/
|
||||||
|
@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/label")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VideoController {
|
||||||
|
|
||||||
|
private final VideoProcessService videoProcessService;
|
||||||
|
|
||||||
|
@Value("${video.callback-secret:}")
|
||||||
|
private String callbackSecret;
|
||||||
|
|
||||||
|
/** POST /api/video/process — 触发视频处理任务 */
|
||||||
|
@Operation(summary = "触发视频处理任务")
|
||||||
|
@PostMapping("/api/video/process")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<VideoProcessJob> createJob(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "创建视频处理任务请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody VideoProcessCreateRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long sourceId = body.getSourceId();
|
||||||
|
String jobType = body.getJobType();
|
||||||
|
if (sourceId == null || jobType == null) {
|
||||||
|
return Result.failure("INVALID_PARAMS", "sourceId 和 jobType 不能为空");
|
||||||
|
}
|
||||||
|
String params = body.getParams();
|
||||||
|
|
||||||
|
TokenPrincipal principal = principal(request);
|
||||||
|
return Result.success(
|
||||||
|
videoProcessService.createJob(sourceId, jobType, params, principal.getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/video/jobs/{jobId} — 查询视频处理任务 */
|
||||||
|
@Operation(summary = "查询视频处理任务状态")
|
||||||
|
@GetMapping("/api/video/jobs/{jobId}")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<VideoProcessJob> getJob(
|
||||||
|
@Parameter(description = "视频处理任务 ID", example = "9001")
|
||||||
|
@PathVariable Long jobId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/video/jobs/{jobId}/reset — 管理员重置失败任务 */
|
||||||
|
@Operation(summary = "重置失败的视频处理任务")
|
||||||
|
@PostMapping("/api/video/jobs/{jobId}/reset")
|
||||||
|
@RequireRole("ADMIN")
|
||||||
|
public Result<VideoProcessJob> resetJob(
|
||||||
|
@Parameter(description = "视频处理任务 ID", example = "9001")
|
||||||
|
@PathVariable Long jobId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return Result.success(videoProcessService.reset(jobId, principal(request).getCompanyId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/video/callback — AI 服务回调(无需 Bearer Token)。
|
||||||
|
*
|
||||||
|
* 此端点已在 AuthInterceptor 中排除认证,
|
||||||
|
* 由 AI 服务直接调用,携带 jobId、status、outputPath 等参数。
|
||||||
|
*
|
||||||
|
* Body 示例:
|
||||||
|
* { "jobId": 123, "status": "SUCCESS", "outputPath": "processed/123/frames.zip" }
|
||||||
|
* { "jobId": 123, "status": "FAILED", "errorMessage": "ffmpeg error: ..." }
|
||||||
|
*/
|
||||||
|
@Operation(summary = "接收 AI 服务视频处理回调")
|
||||||
|
@PostMapping("/api/video/callback")
|
||||||
|
public Result<Void> handleCallback(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
description = "AI 服务视频处理回调请求体",
|
||||||
|
required = true)
|
||||||
|
@RequestBody VideoProcessCallbackRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
// 共享密钥校验(配置了 VIDEO_CALLBACK_SECRET 时强制校验)
|
||||||
|
if (callbackSecret != null && !callbackSecret.isBlank()) {
|
||||||
|
String provided = request.getHeader("X-Callback-Secret");
|
||||||
|
if (!callbackSecret.equals(provided)) {
|
||||||
|
return Result.failure("UNAUTHORIZED", "回调密钥无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Long jobId = body.getJobId();
|
||||||
|
String status = body.getStatus();
|
||||||
|
String outputPath = body.getOutputPath();
|
||||||
|
String errorMessage = body.getErrorMessage();
|
||||||
|
|
||||||
|
log.info("视频处理回调:jobId={}, status={}", jobId, status);
|
||||||
|
videoProcessService.handleCallback(jobId, status, outputPath, errorMessage);
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenPrincipal principal(HttpServletRequest request) {
|
||||||
|
return (TokenPrincipal) request.getAttribute("__token_principal__");
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/com/label/dto/CompanyCreateRequest.java
Normal file
14
src/main/java/com/label/dto/CompanyCreateRequest.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "创建公司请求")
|
||||||
|
public class CompanyCreateRequest {
|
||||||
|
@Schema(description = "公司名称", example = "示例科技")
|
||||||
|
private String companyName;
|
||||||
|
|
||||||
|
@Schema(description = "公司代码(英文简写)", example = "DEMO")
|
||||||
|
private String companyCode;
|
||||||
|
}
|
||||||
11
src/main/java/com/label/dto/CompanyStatusUpdateRequest.java
Normal file
11
src/main/java/com/label/dto/CompanyStatusUpdateRequest.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "公司状态变更请求")
|
||||||
|
public class CompanyStatusUpdateRequest {
|
||||||
|
@Schema(description = "公司状态,可选值:ACTIVE / DISABLED", example = "ACTIVE")
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
14
src/main/java/com/label/dto/CompanyUpdateRequest.java
Normal file
14
src/main/java/com/label/dto/CompanyUpdateRequest.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "更新公司请求")
|
||||||
|
public class CompanyUpdateRequest {
|
||||||
|
@Schema(description = "公司名称", example = "示例科技(升级版)")
|
||||||
|
private String companyName;
|
||||||
|
|
||||||
|
@Schema(description = "公司代码(英文简写)", example = "DEMO")
|
||||||
|
private String companyCode;
|
||||||
|
}
|
||||||
14
src/main/java/com/label/dto/CreateTaskRequest.java
Normal file
14
src/main/java/com/label/dto/CreateTaskRequest.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "创建任务请求")
|
||||||
|
public class CreateTaskRequest {
|
||||||
|
@Schema(description = "资料 ID", example = "1001")
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
@Schema(description = "任务类型,可选值:EXTRACTION / QA_GENERATION", example = "EXTRACTION")
|
||||||
|
private String taskType;
|
||||||
|
}
|
||||||
13
src/main/java/com/label/dto/DynamicJsonResponse.java
Normal file
13
src/main/java/com/label/dto/DynamicJsonResponse.java
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "动态 JSON 响应")
|
||||||
|
public class DynamicJsonResponse {
|
||||||
|
@Schema(description = "动态 JSON 内容", example = "{\"label\":\"cat\",\"score\":0.98}")
|
||||||
|
private Map<String, Object> content;
|
||||||
|
}
|
||||||
13
src/main/java/com/label/dto/ExportBatchCreateRequest.java
Normal file
13
src/main/java/com/label/dto/ExportBatchCreateRequest.java
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "创建导出批次请求")
|
||||||
|
public class ExportBatchCreateRequest {
|
||||||
|
@Schema(description = "样本 ID 列表", example = "[101, 102, 103]")
|
||||||
|
private List<Long> sampleIds;
|
||||||
|
}
|
||||||
23
src/main/java/com/label/dto/FinetuneJobResponse.java
Normal file
23
src/main/java/com/label/dto/FinetuneJobResponse.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "微调任务响应")
|
||||||
|
public class FinetuneJobResponse {
|
||||||
|
@Schema(description = "导出批次 ID", example = "501")
|
||||||
|
private Long batchId;
|
||||||
|
|
||||||
|
@Schema(description = "GLM 微调任务 ID", example = "glm-ft-001")
|
||||||
|
private String glmJobId;
|
||||||
|
|
||||||
|
@Schema(description = "微调状态", example = "RUNNING")
|
||||||
|
private String finetuneStatus;
|
||||||
|
|
||||||
|
@Schema(description = "进度百分比", example = "35")
|
||||||
|
private Integer progress;
|
||||||
|
|
||||||
|
@Schema(description = "错误信息", example = "")
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
21
src/main/java/com/label/dto/LoginRequest.java
Normal file
21
src/main/java/com/label/dto/LoginRequest.java
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求体。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "登录请求")
|
||||||
|
public class LoginRequest {
|
||||||
|
/** 公司代码(英文简写),用于确定租户 */
|
||||||
|
@Schema(description = "公司代码(英文简写)", example = "DEMO")
|
||||||
|
private String companyCode;
|
||||||
|
/** 登录用户名 */
|
||||||
|
@Schema(description = "登录用户名", example = "admin")
|
||||||
|
private String username;
|
||||||
|
/** 明文密码(传输层应使用 HTTPS 保护) */
|
||||||
|
@Schema(description = "明文密码", example = "admin123")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
29
src/main/java/com/label/dto/LoginResponse.java
Normal file
29
src/main/java/com/label/dto/LoginResponse.java
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录成功响应体。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "登录响应")
|
||||||
|
public class LoginResponse {
|
||||||
|
/** Bearer Token(UUID v4),后续请求放入 Authorization 头 */
|
||||||
|
@Schema(description = "Bearer Token", example = "550e8400-e29b-41d4-a716-446655440000")
|
||||||
|
private String token;
|
||||||
|
/** 用户主键 */
|
||||||
|
@Schema(description = "用户主键", example = "1")
|
||||||
|
private Long userId;
|
||||||
|
/** 登录用户名 */
|
||||||
|
@Schema(description = "登录用户名", example = "admin")
|
||||||
|
private String username;
|
||||||
|
/** 角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
|
||||||
|
@Schema(description = "角色", example = "ADMIN")
|
||||||
|
private String role;
|
||||||
|
/** Token 有效期(秒) */
|
||||||
|
@Schema(description = "Token 有效期(秒)", example = "7200")
|
||||||
|
private Long expiresIn;
|
||||||
|
}
|
||||||
11
src/main/java/com/label/dto/RejectRequest.java
Normal file
11
src/main/java/com/label/dto/RejectRequest.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "驳回请求")
|
||||||
|
public class RejectRequest {
|
||||||
|
@Schema(description = "驳回原因", example = "标注结果缺少关键字段")
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
38
src/main/java/com/label/dto/SourceResponse.java
Normal file
38
src/main/java/com/label/dto/SourceResponse.java
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资料接口统一响应体(上传、列表、详情均复用此类)。
|
||||||
|
* 各端点按需填充字段,未填充字段序列化时因 jackson non_null 配置自动省略。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "原始资料响应")
|
||||||
|
public class SourceResponse {
|
||||||
|
@Schema(description = "资料主键", example = "2001")
|
||||||
|
private Long id;
|
||||||
|
@Schema(description = "文件名", example = "demo.txt")
|
||||||
|
private String fileName;
|
||||||
|
@Schema(description = "资料类型", example = "TEXT")
|
||||||
|
private String dataType;
|
||||||
|
@Schema(description = "文件大小(字节)", example = "1024")
|
||||||
|
private Long fileSize;
|
||||||
|
@Schema(description = "资料状态", example = "PENDING")
|
||||||
|
private String status;
|
||||||
|
/** 上传用户 ID(列表端点返回) */
|
||||||
|
@Schema(description = "上传用户 ID", example = "1")
|
||||||
|
private Long uploaderId;
|
||||||
|
/** 15 分钟预签名下载链接(详情端点返回) */
|
||||||
|
@Schema(description = "预签名下载链接", example = "https://example.com/presigned-url")
|
||||||
|
private String presignedUrl;
|
||||||
|
/** 父资料 ID(视频帧 / 文本片段;详情端点返回) */
|
||||||
|
@Schema(description = "父资料 ID", example = "1001")
|
||||||
|
private Long parentSourceId;
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
26
src/main/java/com/label/dto/SysConfigItemResponse.java
Normal file
26
src/main/java/com/label/dto/SysConfigItemResponse.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "系统配置项响应")
|
||||||
|
public class SysConfigItemResponse {
|
||||||
|
@Schema(description = "配置主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "配置键", example = "model_default")
|
||||||
|
private String configKey;
|
||||||
|
|
||||||
|
@Schema(description = "配置值", example = "glm-4-flash")
|
||||||
|
private String configValue;
|
||||||
|
|
||||||
|
@Schema(description = "配置说明", example = "默认文本模型")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Schema(description = "配置来源作用域,可选值:COMPANY、GLOBAL", example = "COMPANY")
|
||||||
|
private String scope;
|
||||||
|
|
||||||
|
@Schema(description = "所属公司 ID;GLOBAL 配置为空,COMPANY 配置为当前公司 ID", example = "100")
|
||||||
|
private Long companyId;
|
||||||
|
}
|
||||||
14
src/main/java/com/label/dto/SysConfigUpdateRequest.java
Normal file
14
src/main/java/com/label/dto/SysConfigUpdateRequest.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "系统配置更新请求")
|
||||||
|
public class SysConfigUpdateRequest {
|
||||||
|
@Schema(description = "配置值", example = "https://api.example.com")
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Schema(description = "配置说明", example = "AI 服务基础地址")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
11
src/main/java/com/label/dto/TaskReassignRequest.java
Normal file
11
src/main/java/com/label/dto/TaskReassignRequest.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "任务改派请求")
|
||||||
|
public class TaskReassignRequest {
|
||||||
|
@Schema(description = "目标用户 ID", example = "2001")
|
||||||
|
private Long userId;
|
||||||
|
}
|
||||||
40
src/main/java/com/label/dto/TaskResponse.java
Normal file
40
src/main/java/com/label/dto/TaskResponse.java
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务接口统一响应体(任务池、我的任务、任务详情均复用)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "标注任务响应")
|
||||||
|
public class TaskResponse {
|
||||||
|
@Schema(description = "任务主键", example = "1001")
|
||||||
|
private Long id;
|
||||||
|
@Schema(description = "关联资料 ID", example = "2001")
|
||||||
|
private Long sourceId;
|
||||||
|
/** 任务类型(对应 taskType 字段):EXTRACTION / QA_GENERATION */
|
||||||
|
@Schema(description = "任务类型", example = "EXTRACTION")
|
||||||
|
private String taskType;
|
||||||
|
@Schema(description = "任务状态", example = "UNCLAIMED")
|
||||||
|
private String status;
|
||||||
|
@Schema(description = "领取人用户 ID", example = "1")
|
||||||
|
private Long claimedBy;
|
||||||
|
@Schema(description = "AI 预标注状态:PENDING/PROCESSING/COMPLETED/FAILED", example = "COMPLETED")
|
||||||
|
private String aiStatus;
|
||||||
|
@Schema(description = "领取时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime claimedAt;
|
||||||
|
@Schema(description = "提交时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime submittedAt;
|
||||||
|
@Schema(description = "完成时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
/** 驳回原因(REJECTED 状态时非空) */
|
||||||
|
@Schema(description = "驳回原因")
|
||||||
|
private String rejectReason;
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
20
src/main/java/com/label/dto/UserCreateRequest.java
Normal file
20
src/main/java/com/label/dto/UserCreateRequest.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "创建用户请求")
|
||||||
|
public class UserCreateRequest {
|
||||||
|
@Schema(description = "登录用户名", example = "reviewer01")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "明文密码", example = "Pass@123")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Schema(description = "真实姓名", example = "张三")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@Schema(description = "角色,可选值:ADMIN / REVIEWER / ANNOTATOR / UPLOADER", example = "REVIEWER")
|
||||||
|
private String role;
|
||||||
|
}
|
||||||
26
src/main/java/com/label/dto/UserInfoResponse.java
Normal file
26
src/main/java/com/label/dto/UserInfoResponse.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/me 响应体,包含当前登录用户的详细信息。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "当前登录用户信息")
|
||||||
|
public class UserInfoResponse {
|
||||||
|
@Schema(description = "用户主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
@Schema(description = "用户名", example = "admin")
|
||||||
|
private String username;
|
||||||
|
@Schema(description = "真实姓名", example = "张三")
|
||||||
|
private String realName;
|
||||||
|
@Schema(description = "角色", example = "ADMIN")
|
||||||
|
private String role;
|
||||||
|
@Schema(description = "所属公司 ID", example = "1")
|
||||||
|
private Long companyId;
|
||||||
|
@Schema(description = "所属公司名称", example = "示例科技有限公司")
|
||||||
|
private String companyName;
|
||||||
|
}
|
||||||
11
src/main/java/com/label/dto/UserRoleUpdateRequest.java
Normal file
11
src/main/java/com/label/dto/UserRoleUpdateRequest.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "用户角色变更请求")
|
||||||
|
public class UserRoleUpdateRequest {
|
||||||
|
@Schema(description = "用户角色,可选值:ADMIN / REVIEWER / ANNOTATOR / UPLOADER", example = "ANNOTATOR")
|
||||||
|
private String role;
|
||||||
|
}
|
||||||
11
src/main/java/com/label/dto/UserStatusUpdateRequest.java
Normal file
11
src/main/java/com/label/dto/UserStatusUpdateRequest.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "用户状态变更请求")
|
||||||
|
public class UserStatusUpdateRequest {
|
||||||
|
@Schema(description = "用户状态,可选值:ACTIVE / DISABLED", example = "DISABLED")
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
14
src/main/java/com/label/dto/UserUpdateRequest.java
Normal file
14
src/main/java/com/label/dto/UserUpdateRequest.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "更新用户请求")
|
||||||
|
public class UserUpdateRequest {
|
||||||
|
@Schema(description = "真实姓名", example = "李四")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@Schema(description = "新密码,可为空或 null 表示保持不变", example = "")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
20
src/main/java/com/label/dto/VideoProcessCallbackRequest.java
Normal file
20
src/main/java/com/label/dto/VideoProcessCallbackRequest.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "视频处理回调请求")
|
||||||
|
public class VideoProcessCallbackRequest {
|
||||||
|
@Schema(description = "视频处理任务 ID", example = "9001")
|
||||||
|
private Long jobId;
|
||||||
|
|
||||||
|
@Schema(description = "处理状态", example = "SUCCESS")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "输出文件路径", example = "/data/output/video-9001.json")
|
||||||
|
private String outputPath;
|
||||||
|
|
||||||
|
@Schema(description = "失败时的错误信息", example = "ffmpeg error")
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
17
src/main/java/com/label/dto/VideoProcessCreateRequest.java
Normal file
17
src/main/java/com/label/dto/VideoProcessCreateRequest.java
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package com.label.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "创建视频处理任务请求")
|
||||||
|
public class VideoProcessCreateRequest {
|
||||||
|
@Schema(description = "资料 ID", example = "3001")
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
@Schema(description = "处理任务类型,可选值:FRAME_EXTRACT、VIDEO_TO_TEXT", example = "FRAME_EXTRACT")
|
||||||
|
private String jobType;
|
||||||
|
|
||||||
|
@Schema(description = "任务参数 JSON 字符串", example = "{\"frameInterval\":5}")
|
||||||
|
private String params;
|
||||||
|
}
|
||||||
32
src/main/java/com/label/entity/AnnotationResult.java
Normal file
32
src/main/java/com/label/entity/AnnotationResult.java
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注结果实体,对应 annotation_result 表。
|
||||||
|
* resultJson 存储 JSONB 格式的标注内容(整体替换语义)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("annotation_result")
|
||||||
|
public class AnnotationResult {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 标注结果 JSON(JSONB,整体覆盖) */
|
||||||
|
private String resultJson;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
62
src/main/java/com/label/entity/AnnotationTask.java
Normal file
62
src/main/java/com/label/entity/AnnotationTask.java
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注任务实体,对应 annotation_task 表。
|
||||||
|
*
|
||||||
|
* taskType 取值:EXTRACTION / QA_GENERATION
|
||||||
|
* status 取值:UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("annotation_task")
|
||||||
|
public class AnnotationTask {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 关联的原始资料 ID */
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
/** 任务类型:EXTRACTION / QA_GENERATION */
|
||||||
|
private String taskType;
|
||||||
|
|
||||||
|
/** 任务状态 */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 领取任务的用户 ID */
|
||||||
|
private Long claimedBy;
|
||||||
|
|
||||||
|
/** 领取时间 */
|
||||||
|
private LocalDateTime claimedAt;
|
||||||
|
|
||||||
|
/** 提交时间 */
|
||||||
|
private LocalDateTime submittedAt;
|
||||||
|
|
||||||
|
/** 完成时间(APPROVED 时设置) */
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
|
/** 是否最终结果(APPROVED 且无需再审) */
|
||||||
|
private Boolean isFinal;
|
||||||
|
|
||||||
|
/** 使用的 AI 模型名称 */
|
||||||
|
private String aiModel;
|
||||||
|
|
||||||
|
/** 驳回原因 */
|
||||||
|
private String rejectReason;
|
||||||
|
|
||||||
|
/** AI 预标注状态:PENDING / PROCESSING / COMPLETED / FAILED */
|
||||||
|
private String aiStatus;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
43
src/main/java/com/label/entity/AnnotationTaskHistory.java
Normal file
43
src/main/java/com/label/entity/AnnotationTaskHistory.java
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务状态历史,对应 annotation_task_history 表(仅追加,无 UPDATE/DELETE)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@TableName("annotation_task_history")
|
||||||
|
public class AnnotationTaskHistory {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 转换前状态(首次插入时为 null) */
|
||||||
|
private String fromStatus;
|
||||||
|
|
||||||
|
/** 转换后状态 */
|
||||||
|
private String toStatus;
|
||||||
|
|
||||||
|
/** 操作人 ID */
|
||||||
|
private Long operatorId;
|
||||||
|
|
||||||
|
/** 操作人角色 */
|
||||||
|
private String operatorRole;
|
||||||
|
|
||||||
|
/** 备注(驳回原因等) */
|
||||||
|
private String comment;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
55
src/main/java/com/label/entity/ExportBatch.java
Normal file
55
src/main/java/com/label/entity/ExportBatch.java
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出批次实体,对应 export_batch 表。
|
||||||
|
*
|
||||||
|
* finetuneStatus 取值:NOT_STARTED / RUNNING / COMPLETED / FAILED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("export_batch")
|
||||||
|
@Schema(description = "导出批次")
|
||||||
|
public class ExportBatch {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
@Schema(description = "导出批次主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
@Schema(description = "所属公司 ID", example = "1")
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 批次唯一标识(UUID,DB 默认 gen_random_uuid()) */
|
||||||
|
@Schema(description = "批次 UUID", example = "550e8400-e29b-41d4-a716-446655440000")
|
||||||
|
private UUID batchUuid;
|
||||||
|
|
||||||
|
/** 本批次样本数量 */
|
||||||
|
@Schema(description = "样本数量", example = "1000")
|
||||||
|
private Integer sampleCount;
|
||||||
|
|
||||||
|
/** 导出 JSONL 的 RustFS 路径 */
|
||||||
|
@Schema(description = "数据集文件路径(JSONL)", example = "datasets/export/2026-04-15/batch.jsonl")
|
||||||
|
private String datasetFilePath;
|
||||||
|
|
||||||
|
/** GLM fine-tune 任务 ID(提交微调后填写) */
|
||||||
|
@Schema(description = "GLM 微调任务 ID", example = "glm-job-123456")
|
||||||
|
private String glmJobId;
|
||||||
|
|
||||||
|
/** 微调任务状态:NOT_STARTED / RUNNING / COMPLETED / FAILED */
|
||||||
|
@Schema(description = "微调任务状态", example = "NOT_STARTED")
|
||||||
|
private String finetuneStatus;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
56
src/main/java/com/label/entity/SourceData.java
Normal file
56
src/main/java/com/label/entity/SourceData.java
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原始资料实体,对应 source_data 表。
|
||||||
|
*
|
||||||
|
* dataType 取值:TEXT / IMAGE / VIDEO
|
||||||
|
* status 取值:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("source_data")
|
||||||
|
public class SourceData {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 上传用户 ID */
|
||||||
|
private Long uploaderId;
|
||||||
|
|
||||||
|
/** 资料类型:TEXT / IMAGE / VIDEO */
|
||||||
|
private String dataType;
|
||||||
|
|
||||||
|
/** RustFS 对象路径 */
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
/** 原始文件名 */
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/** RustFS Bucket 名称 */
|
||||||
|
private String bucketName;
|
||||||
|
|
||||||
|
/** 父资料 ID(视频帧或文本片段的自引用外键) */
|
||||||
|
private Long parentSourceId;
|
||||||
|
|
||||||
|
/** 流水线状态:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 保留字段(当前无 REJECTED 状态) */
|
||||||
|
private String rejectReason;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
42
src/main/java/com/label/entity/SysCompany.java
Normal file
42
src/main/java/com/label/entity/SysCompany.java
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户公司实体,对应 sys_company 表。
|
||||||
|
* status 取值:ACTIVE / DISABLED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_company")
|
||||||
|
@Schema(description = "租户公司")
|
||||||
|
public class SysCompany {
|
||||||
|
|
||||||
|
/** 公司主键,自增 */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
@Schema(description = "公司主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 公司全称,全局唯一 */
|
||||||
|
@Schema(description = "公司全称", example = "示例科技有限公司")
|
||||||
|
private String companyName;
|
||||||
|
|
||||||
|
/** 公司代码(英文简写),全局唯一 */
|
||||||
|
@Schema(description = "公司代码(英文简写)", example = "DEMO")
|
||||||
|
private String companyCode;
|
||||||
|
|
||||||
|
/** 状态:ACTIVE / DISABLED */
|
||||||
|
@Schema(description = "状态", example = "ACTIVE")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
50
src/main/java/com/label/entity/SysConfig.java
Normal file
50
src/main/java/com/label/entity/SysConfig.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置实体,对应 sys_config 表。
|
||||||
|
*
|
||||||
|
* company_id 为 NULL 时表示全局默认配置,非 NULL 时表示租户专属配置(优先级更高)。
|
||||||
|
* 注:sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES,不走多租户过滤器。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_config")
|
||||||
|
@Schema(description = "系统配置")
|
||||||
|
public class SysConfig {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
@Schema(description = "配置主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属公司 ID(NULL = 全局默认配置;非 NULL = 租户专属配置)。
|
||||||
|
* 注意:不能用 @TableField(exist = false) 排除,必须保留以支持 company_id IS NULL 查询。
|
||||||
|
*/
|
||||||
|
@Schema(description = "所属公司 ID(NULL 表示全局默认配置)", example = "1")
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 配置键 */
|
||||||
|
@Schema(description = "配置键", example = "STORAGE_BUCKET")
|
||||||
|
private String configKey;
|
||||||
|
|
||||||
|
/** 配置值 */
|
||||||
|
@Schema(description = "配置值", example = "label-bucket")
|
||||||
|
private String configValue;
|
||||||
|
|
||||||
|
/** 配置说明 */
|
||||||
|
@Schema(description = "配置说明", example = "对象存储桶名称")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
60
src/main/java/com/label/entity/SysUser.java
Normal file
60
src/main/java/com/label/entity/SysUser.java
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统用户实体,对应 sys_user 表。
|
||||||
|
* role 取值:UPLOADER / ANNOTATOR / REVIEWER / ADMIN
|
||||||
|
* status 取值:ACTIVE / DISABLED
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_user")
|
||||||
|
@Schema(description = "系统用户")
|
||||||
|
public class SysUser {
|
||||||
|
|
||||||
|
/** 用户主键,自增 */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
@Schema(description = "用户主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司 ID(多租户键) */
|
||||||
|
@Schema(description = "所属公司 ID", example = "1")
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 登录用户名(同公司内唯一) */
|
||||||
|
@Schema(description = "登录用户名", example = "admin")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt 哈希密码(strength ≥ 10)。
|
||||||
|
* 序列化时排除,防止密码哈希泄漏到 API 响应。
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
@Schema(description = "密码哈希(不会在响应中返回)")
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
|
/** 真实姓名 */
|
||||||
|
@Schema(description = "真实姓名", example = "张三")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
/** 角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
|
||||||
|
@Schema(description = "角色", example = "ADMIN")
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/** 状态:ACTIVE / DISABLED */
|
||||||
|
@Schema(description = "状态", example = "ACTIVE")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
59
src/main/java/com/label/entity/TrainingDataset.java
Normal file
59
src/main/java/com/label/entity/TrainingDataset.java
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 训练数据集实体,对应 training_dataset 表。
|
||||||
|
*
|
||||||
|
* status 取值:PENDING_REVIEW / APPROVED / REJECTED
|
||||||
|
* sampleType 取值:TEXT / IMAGE / VIDEO_FRAME
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("training_dataset")
|
||||||
|
@Schema(description = "训练数据集样本")
|
||||||
|
public class TrainingDataset {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
@Schema(description = "样本主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
@Schema(description = "所属公司 ID", example = "1")
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
@Schema(description = "关联任务 ID", example = "1001")
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
@Schema(description = "关联资料 ID", example = "2001")
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
/** 样本类型:TEXT / IMAGE / VIDEO_FRAME */
|
||||||
|
@Schema(description = "样本类型", example = "TEXT")
|
||||||
|
private String sampleType;
|
||||||
|
|
||||||
|
/** GLM fine-tune 格式的 JSON 字符串(JSONB) */
|
||||||
|
@Schema(description = "GLM 微调格式 JSON", example = "{\"messages\":[{\"role\":\"user\",\"content\":\"...\"},{\"role\":\"assistant\",\"content\":\"...\"}]}")
|
||||||
|
private String glmFormatJson;
|
||||||
|
|
||||||
|
/** 状态:PENDING_REVIEW / APPROVED / REJECTED */
|
||||||
|
@Schema(description = "状态", example = "APPROVED")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "导出批次 ID", example = "3001")
|
||||||
|
private Long exportBatchId;
|
||||||
|
|
||||||
|
@Schema(description = "导出时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime exportedAt;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
73
src/main/java/com/label/entity/VideoProcessJob.java
Normal file
73
src/main/java/com/label/entity/VideoProcessJob.java
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package com.label.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频处理任务实体,对应 video_process_job 表。
|
||||||
|
*
|
||||||
|
* jobType 取值:FRAME_EXTRACT / VIDEO_TO_TEXT
|
||||||
|
* status 取值:PENDING / RUNNING / SUCCESS / FAILED / RETRYING
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("video_process_job")
|
||||||
|
@Schema(description = "视频处理任务")
|
||||||
|
public class VideoProcessJob {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
@Schema(description = "任务主键", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 所属公司(多租户键) */
|
||||||
|
@Schema(description = "所属公司 ID", example = "1")
|
||||||
|
private Long companyId;
|
||||||
|
|
||||||
|
/** 关联资料 ID */
|
||||||
|
@Schema(description = "关联资料 ID", example = "2001")
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
/** 任务类型:FRAME_EXTRACT / VIDEO_TO_TEXT */
|
||||||
|
@Schema(description = "任务类型", example = "FRAME_EXTRACT")
|
||||||
|
private String jobType;
|
||||||
|
|
||||||
|
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED / RETRYING */
|
||||||
|
@Schema(description = "任务状态", example = "PENDING")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 任务参数(JSONB,例如 {"frameInterval": 30}) */
|
||||||
|
@Schema(description = "任务参数(JSON)", example = "{\"frameInterval\":30}")
|
||||||
|
private String params;
|
||||||
|
|
||||||
|
/** AI 处理输出路径(成功后填写) */
|
||||||
|
@Schema(description = "输出路径", example = "outputs/video/2026-04-15/result.json")
|
||||||
|
private String outputPath;
|
||||||
|
|
||||||
|
/** 已重试次数 */
|
||||||
|
@Schema(description = "已重试次数", example = "0")
|
||||||
|
private Integer retryCount;
|
||||||
|
|
||||||
|
/** 最大重试次数(默认 3) */
|
||||||
|
@Schema(description = "最大重试次数", example = "3")
|
||||||
|
private Integer maxRetries;
|
||||||
|
|
||||||
|
/** 错误信息 */
|
||||||
|
@Schema(description = "错误信息")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
@Schema(description = "开始时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
|
||||||
|
@Schema(description = "完成时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
31
src/main/java/com/label/event/ExtractionApprovedEvent.java
Normal file
31
src/main/java/com/label/event/ExtractionApprovedEvent.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.label.event;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取任务审批通过事件。
|
||||||
|
* 由 ExtractionService.approve() 在事务提交前发布(@TransactionalEventListener 在 AFTER_COMMIT 处理)。
|
||||||
|
*
|
||||||
|
* 设计约束:AI 调用禁止在审批事务内执行,必须通过此事件解耦。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class ExtractionApprovedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private final Long taskId;
|
||||||
|
private final Long sourceId;
|
||||||
|
/** 资料类型:TEXT / IMAGE,决定调用哪个 AI 生成接口 */
|
||||||
|
private final String sourceType;
|
||||||
|
private final Long companyId;
|
||||||
|
private final Long reviewerId;
|
||||||
|
|
||||||
|
public ExtractionApprovedEvent(Object source, Long taskId, Long sourceId,
|
||||||
|
String sourceType, Long companyId, Long reviewerId) {
|
||||||
|
super(source);
|
||||||
|
this.taskId = taskId;
|
||||||
|
this.sourceId = sourceId;
|
||||||
|
this.sourceType = sourceType;
|
||||||
|
this.companyId = companyId;
|
||||||
|
this.reviewerId = reviewerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/main/java/com/label/interceptor/AuthInterceptor.java
Normal file
182
src/main/java/com/label/interceptor/AuthInterceptor.java
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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 static final String API_PREFIX = "/label";
|
||||||
|
private static final String API_ROOT = API_PREFIX + "/api/";
|
||||||
|
|
||||||
|
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_ROOT)
|
||||||
|
|| path.equals(API_PREFIX + "/api/auth/login")
|
||||||
|
|| path.equals(API_PREFIX + "/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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.label.listener;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.ai.AiServiceClient;
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import com.label.entity.AnnotationResult;
|
||||||
|
import com.label.entity.SourceData;
|
||||||
|
import com.label.entity.TrainingDataset;
|
||||||
|
import com.label.event.ExtractionApprovedEvent;
|
||||||
|
import com.label.mapper.AnnotationResultMapper;
|
||||||
|
import com.label.mapper.SourceDataMapper;
|
||||||
|
import com.label.mapper.TrainingDatasetMapper;
|
||||||
|
import com.label.service.TaskService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExtractionApprovedEventListener {
|
||||||
|
|
||||||
|
private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final TaskService taskService;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
private final AnnotationResultMapper annotationResultMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void onExtractionApproved(ExtractionApprovedEvent event) {
|
||||||
|
log.info("处理提取审批通过事件: taskId={}, sourceId={}", event.getTaskId(), event.getSourceId());
|
||||||
|
|
||||||
|
CompanyContext.set(event.getCompanyId());
|
||||||
|
try {
|
||||||
|
processEvent(event);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理审批通过事件失败(taskId={}): {}", event.getTaskId(), e.getMessage(), e);
|
||||||
|
} finally {
|
||||||
|
CompanyContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processEvent(ExtractionApprovedEvent event) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(event.getSourceId());
|
||||||
|
if (source == null) {
|
||||||
|
log.warn("资料不存在,跳过后续处理: sourceId={}", event.getSourceId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> qaPairs;
|
||||||
|
try {
|
||||||
|
AiServiceClient.QaGenResponse response = "IMAGE".equals(source.getDataType())
|
||||||
|
? aiServiceClient.genImageQa(buildImageQaRequest(event.getTaskId()))
|
||||||
|
: aiServiceClient.genTextQa(buildTextQaRequest(event.getTaskId()));
|
||||||
|
qaPairs = response != null && response.getPairs() != null
|
||||||
|
? response.getPairs()
|
||||||
|
: Collections.emptyList();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("AI 问答生成失败(taskId={}): {},将使用空问答对", event.getTaskId(), e.getMessage());
|
||||||
|
qaPairs = Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT";
|
||||||
|
TrainingDataset dataset = new TrainingDataset();
|
||||||
|
dataset.setCompanyId(event.getCompanyId());
|
||||||
|
dataset.setTaskId(event.getTaskId());
|
||||||
|
dataset.setSourceId(event.getSourceId());
|
||||||
|
dataset.setSampleType(sampleType);
|
||||||
|
dataset.setGlmFormatJson(buildGlmJson(qaPairs));
|
||||||
|
dataset.setStatus("PENDING_REVIEW");
|
||||||
|
datasetMapper.insert(dataset);
|
||||||
|
|
||||||
|
taskService.createTask(event.getSourceId(), "QA_GENERATION", event.getCompanyId());
|
||||||
|
sourceDataMapper.updateStatus(event.getSourceId(), "QA_REVIEW", event.getCompanyId());
|
||||||
|
|
||||||
|
log.info("审批通过后续处理完成: taskId={}", event.getTaskId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildGlmJson(List<Map<String, Object>> qaPairs) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(Map.of("conversations", qaPairs));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("构建微调 JSON 失败", e);
|
||||||
|
return "{\"conversations\":[]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AiServiceClient.GenTextQaRequest buildTextQaRequest(Long taskId) {
|
||||||
|
List<AiServiceClient.TextQaItem> items = readAnnotationItems(taskId).stream()
|
||||||
|
.map(item -> objectMapper.convertValue(item, AiServiceClient.TextQaItem.class))
|
||||||
|
.toList();
|
||||||
|
return AiServiceClient.GenTextQaRequest.builder()
|
||||||
|
.items(items)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AiServiceClient.GenImageQaRequest buildImageQaRequest(Long taskId) {
|
||||||
|
List<AiServiceClient.ImageQaItem> items = readAnnotationItems(taskId).stream()
|
||||||
|
.map(item -> objectMapper.convertValue(item, AiServiceClient.ImageQaItem.class))
|
||||||
|
.toList();
|
||||||
|
return AiServiceClient.GenImageQaRequest.builder()
|
||||||
|
.items(items)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> readAnnotationItems(Long taskId) {
|
||||||
|
AnnotationResult result = annotationResultMapper.selectByTaskId(taskId);
|
||||||
|
if (result == null || result.getResultJson() == null || result.getResultJson().isBlank()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> parsed = objectMapper.readValue(result.getResultJson(), Map.class);
|
||||||
|
Object items = parsed.get("items");
|
||||||
|
if (items instanceof List<?>) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> typedItems = (List<Map<String, Object>>) items;
|
||||||
|
return typedItems;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("解析提取结果失败,taskId={},将使用空 items: {}", taskId, e.getMessage());
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/com/label/mapper/AnnotationResultMapper.java
Normal file
41
src/main/java/com/label/mapper/AnnotationResultMapper.java
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.AnnotationResult;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* annotation_result 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AnnotationResultMapper extends BaseMapper<AnnotationResult> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整体覆盖标注结果 JSON(JSONB 字段)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param resultJson 新的 JSON 字符串(整体替换)
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE annotation_result " +
|
||||||
|
"SET result_json = #{resultJson}::jsonb, updated_at = NOW() " +
|
||||||
|
"WHERE task_id = #{taskId} AND company_id = #{companyId}")
|
||||||
|
int updateResultJson(@Param("taskId") Long taskId,
|
||||||
|
@Param("resultJson") String resultJson,
|
||||||
|
@Param("companyId") Long companyId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务 ID 查询标注结果。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @return 标注结果(不存在则返回 null)
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM annotation_result WHERE task_id = #{taskId}")
|
||||||
|
AnnotationResult selectByTaskId(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Insert("INSERT INTO annotation_result (task_id, company_id, result_json, created_at, updated_at) " +
|
||||||
|
"VALUES (#{taskId}, #{companyId}, #{resultJson}::jsonb, NOW(), NOW())")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||||
|
int insertWithJsonb(AnnotationResult result);
|
||||||
|
}
|
||||||
30
src/main/java/com/label/mapper/AnnotationTaskMapper.java
Normal file
30
src/main/java/com/label/mapper/AnnotationTaskMapper.java
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.AnnotationTask;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* annotation_task 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AnnotationTaskMapper extends BaseMapper<AnnotationTask> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子性领取任务:仅当任务为 UNCLAIMED 且属于当前租户时才更新。
|
||||||
|
* 使用乐观 WHERE 条件实现并发安全(依赖数据库行级锁)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param userId 领取用户 ID
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数(0 = 任务已被他人领取或不存在)
|
||||||
|
*/
|
||||||
|
@Update("UPDATE annotation_task " +
|
||||||
|
"SET status = 'IN_PROGRESS', claimed_by = #{userId}, claimed_at = NOW(), updated_at = NOW() " +
|
||||||
|
"WHERE id = #{taskId} AND status = 'UNCLAIMED' AND company_id = #{companyId}")
|
||||||
|
int claimTask(@Param("taskId") Long taskId,
|
||||||
|
@Param("userId") Long userId,
|
||||||
|
@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
31
src/main/java/com/label/mapper/ExportBatchMapper.java
Normal file
31
src/main/java/com/label/mapper/ExportBatchMapper.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.ExportBatch;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* export_batch 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ExportBatchMapper extends BaseMapper<ExportBatch> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新微调任务信息(glm_job_id + finetune_status)。
|
||||||
|
*
|
||||||
|
* @param id 批次 ID
|
||||||
|
* @param glmJobId GLM fine-tune 任务 ID
|
||||||
|
* @param finetuneStatus 新状态
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE export_batch SET glm_job_id = #{glmJobId}, " +
|
||||||
|
"finetune_status = #{finetuneStatus}, updated_at = NOW() " +
|
||||||
|
"WHERE id = #{id} AND company_id = #{companyId}")
|
||||||
|
int updateFinetuneInfo(@Param("id") Long id,
|
||||||
|
@Param("glmJobId") String glmJobId,
|
||||||
|
@Param("finetuneStatus") String finetuneStatus,
|
||||||
|
@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
28
src/main/java/com/label/mapper/SourceDataMapper.java
Normal file
28
src/main/java/com/label/mapper/SourceDataMapper.java
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.SourceData;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* source_data 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SourceDataMapper extends BaseMapper<SourceData> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 更新资料状态(带 company_id 租户隔离)。
|
||||||
|
*
|
||||||
|
* @param id 资料 ID
|
||||||
|
* @param status 新状态
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数(0 表示记录不存在或不属于当前租户)
|
||||||
|
*/
|
||||||
|
@Update("UPDATE source_data SET status = #{status}, updated_at = NOW() " +
|
||||||
|
"WHERE id = #{id} AND company_id = #{companyId}")
|
||||||
|
int updateStatus(@Param("id") Long id,
|
||||||
|
@Param("status") String status,
|
||||||
|
@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
23
src/main/java/com/label/mapper/SysCompanyMapper.java
Normal file
23
src/main/java/com/label/mapper/SysCompanyMapper.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.SysCompany;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_company 表 Mapper。
|
||||||
|
* 继承 BaseMapper 获得标准 CRUD;自定义方法用注解 SQL。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysCompanyMapper extends BaseMapper<SysCompany> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按公司代码查询公司(忽略多租户过滤,sys_company 无 company_id 字段)。
|
||||||
|
*
|
||||||
|
* @param companyCode 公司代码
|
||||||
|
* @return 公司实体,不存在则返回 null
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}")
|
||||||
|
SysCompany selectByCompanyCode(String companyCode);
|
||||||
|
}
|
||||||
36
src/main/java/com/label/mapper/SysConfigMapper.java
Normal file
36
src/main/java/com/label/mapper/SysConfigMapper.java
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.SysConfig;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_config 表 Mapper。
|
||||||
|
*
|
||||||
|
* 注意:sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES,不走多租户过滤器,
|
||||||
|
* 需手动传入 companyId 进行过滤。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysConfigMapper extends BaseMapper<SysConfig> {
|
||||||
|
|
||||||
|
/** 查询指定公司的配置(租户专属,优先级高) */
|
||||||
|
@Select("SELECT * FROM sys_config WHERE company_id = #{companyId} AND config_key = #{configKey}")
|
||||||
|
SysConfig selectByCompanyAndKey(@Param("companyId") Long companyId,
|
||||||
|
@Param("configKey") String configKey);
|
||||||
|
|
||||||
|
/** 查询全局默认配置(company_id IS NULL) */
|
||||||
|
@Select("SELECT * FROM sys_config WHERE company_id IS NULL AND config_key = #{configKey}")
|
||||||
|
SysConfig selectGlobalByKey(@Param("configKey") String configKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定公司所有可见配置(公司专属 + 全局默认),
|
||||||
|
* 按 company_id DESC NULLS LAST 排序(公司专属优先于全局默认)。
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM sys_config WHERE company_id = #{companyId} OR company_id IS NULL " +
|
||||||
|
"ORDER BY company_id DESC NULLS LAST")
|
||||||
|
List<SysConfig> selectAllForCompany(@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
38
src/main/java/com/label/mapper/SysUserMapper.java
Normal file
38
src/main/java/com/label/mapper/SysUserMapper.java
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.SysUser;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_user 表 Mapper。
|
||||||
|
* 继承 BaseMapper 获得标准 CRUD;自定义登录查询方法绕过多租户过滤器,
|
||||||
|
* 由调用方显式传入 companyId。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按公司 ID + 用户名查询用户(登录场景使用)。
|
||||||
|
* <p>
|
||||||
|
* 使用 @InterceptorIgnore 绕过 TenantLineInnerInterceptor,
|
||||||
|
* 由参数 companyId 显式限定租户,防止登录时 CompanyContext 尚未注入
|
||||||
|
* 导致查询条件变为 {@code company_id = NULL}。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param companyId 公司 ID
|
||||||
|
* @param username 用户名
|
||||||
|
* @return 用户实体(含 passwordHash),不存在则返回 null
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'")
|
||||||
|
SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId,
|
||||||
|
@Param("username") String username);
|
||||||
|
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("SELECT COUNT(1) FROM sys_user WHERE company_id = #{companyId}")
|
||||||
|
Long countByCompanyId(@Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
14
src/main/java/com/label/mapper/TaskHistoryMapper.java
Normal file
14
src/main/java/com/label/mapper/TaskHistoryMapper.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.AnnotationTaskHistory;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* annotation_task_history 表 Mapper(仅追加,禁止 UPDATE/DELETE)。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface TaskHistoryMapper extends BaseMapper<AnnotationTaskHistory> {
|
||||||
|
// 继承 BaseMapper 的 insert 用于追加历史记录
|
||||||
|
// 严禁调用 update/delete 相关方法
|
||||||
|
}
|
||||||
36
src/main/java/com/label/mapper/TrainingDatasetMapper.java
Normal file
36
src/main/java/com/label/mapper/TrainingDatasetMapper.java
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.TrainingDataset;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* training_dataset 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface TrainingDatasetMapper extends BaseMapper<TrainingDataset> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务 ID 将训练样本状态改为 APPROVED。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE training_dataset SET status = 'APPROVED', updated_at = NOW() " +
|
||||||
|
"WHERE task_id = #{taskId} AND company_id = #{companyId}")
|
||||||
|
int approveByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务 ID 删除训练样本(驳回时清除候选数据)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param companyId 当前租户
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@Delete("DELETE FROM training_dataset WHERE task_id = #{taskId} AND company_id = #{companyId}")
|
||||||
|
int deleteByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId);
|
||||||
|
}
|
||||||
12
src/main/java/com/label/mapper/VideoProcessJobMapper.java
Normal file
12
src/main/java/com/label/mapper/VideoProcessJobMapper.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.label.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.label.entity.VideoProcessJob;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* video_process_job 表 Mapper。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface VideoProcessJobMapper extends BaseMapper<VideoProcessJob> {
|
||||||
|
}
|
||||||
143
src/main/java/com/label/service/AiAnnotationAsyncService.java
Normal file
143
src/main/java/com/label/service/AiAnnotationAsyncService.java
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.ai.AiServiceClient;
|
||||||
|
import com.label.common.context.CompanyContext;
|
||||||
|
import com.label.entity.AnnotationResult;
|
||||||
|
import com.label.entity.AnnotationTask;
|
||||||
|
import com.label.entity.SourceData;
|
||||||
|
import com.label.mapper.AnnotationResultMapper;
|
||||||
|
import com.label.mapper.AnnotationTaskMapper;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiAnnotationAsyncService {
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AnnotationResultMapper resultMapper;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
|
||||||
|
@Async("aiTaskExecutor")
|
||||||
|
public void processAnnotation(Long taskId, Long companyId, SourceData source) {
|
||||||
|
CompanyContext.set(companyId);
|
||||||
|
|
||||||
|
log.info("开始异步执行 AI 预标注,任务ID: {}", taskId);
|
||||||
|
String dataType = source.getDataType().toUpperCase();
|
||||||
|
AiServiceClient.ExtractionResponse aiResponse = null;
|
||||||
|
int maxRetries = 2;
|
||||||
|
Exception lastException = null;
|
||||||
|
String finalStatus = "FAILED";
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
if ("IMAGE".equals(dataType)) {
|
||||||
|
AiServiceClient.ImageExtractRequest req = AiServiceClient.ImageExtractRequest.builder()
|
||||||
|
.filePath(source.getFilePath())
|
||||||
|
.taskId(taskId)
|
||||||
|
.build();
|
||||||
|
aiResponse = aiServiceClient.extractImage(req);
|
||||||
|
} else {
|
||||||
|
AiServiceClient.TextExtractRequest req = AiServiceClient.TextExtractRequest.builder()
|
||||||
|
.filePath(source.getFilePath())
|
||||||
|
.fileName(source.getFileName())
|
||||||
|
.build();
|
||||||
|
aiResponse = aiServiceClient.extractText(req);
|
||||||
|
}
|
||||||
|
if (aiResponse != null) {
|
||||||
|
log.info("AI 预标注成功,任务ID: {}, 尝试次数: {}", taskId, attempt);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
lastException = e;
|
||||||
|
log.warn("AI 预标注调用失败(任务 {}),第 {} 次尝试:{}", taskId, attempt, e.getMessage());
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(1000L * attempt);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<?> items = Collections.emptyList();
|
||||||
|
if (aiResponse != null && aiResponse.getItems() != null) {
|
||||||
|
items = aiResponse.getItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
writeOrUpdateResult(taskId, companyId, items);
|
||||||
|
finalStatus = "COMPLETED";
|
||||||
|
} catch (Exception e) {
|
||||||
|
lastException = e;
|
||||||
|
log.error("AI 预标注处理过程中发生未知异常,任务ID: {}", taskId, e);
|
||||||
|
finalStatus = "FAILED";
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
AnnotationTask updateEntity = new AnnotationTask();
|
||||||
|
updateEntity.setId(taskId);
|
||||||
|
updateEntity.setAiStatus(finalStatus);
|
||||||
|
|
||||||
|
if ("FAILED".equals(finalStatus)) {
|
||||||
|
String reason = lastException != null ? lastException.getMessage() : "AI处理失败";
|
||||||
|
if (reason != null && reason.length() > 500) {
|
||||||
|
reason = reason.substring(0, 500);
|
||||||
|
}
|
||||||
|
updateEntity.setRejectReason(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
int rows = taskMapper.updateById(updateEntity);
|
||||||
|
log.info("异步 AI 预标注结束,任务ID: {}, 最终状态: {}, row {}", taskId, finalStatus, rows);
|
||||||
|
} catch (Exception updateEx) {
|
||||||
|
log.error("更新任务 AI 状态失败,任务ID: {}", taskId, updateEx);
|
||||||
|
} finally {
|
||||||
|
CompanyContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeOrUpdateResult(Long taskId, Long companyId, List<?> items) {
|
||||||
|
try {
|
||||||
|
String json = objectMapper
|
||||||
|
.writeValueAsString(Map.of("items", items != null ? items : Collections.emptyList()));
|
||||||
|
|
||||||
|
int updated = resultMapper.updateResultJson(taskId, json, companyId);
|
||||||
|
|
||||||
|
if (updated == 0) {
|
||||||
|
try {
|
||||||
|
AnnotationResult result = new AnnotationResult();
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setCompanyId(companyId);
|
||||||
|
result.setResultJson(json);
|
||||||
|
resultMapper.insertWithJsonb(result);
|
||||||
|
log.info("新建AI预标注结果,任务ID: {}", taskId);
|
||||||
|
} catch (Exception insertEx) {
|
||||||
|
if (insertEx.getMessage() != null && insertEx.getMessage().contains("duplicate key")) {
|
||||||
|
log.warn("检测到并发插入冲突,转为更新模式,任务ID: {}", taskId);
|
||||||
|
resultMapper.updateResultJson(taskId, json, companyId);
|
||||||
|
} else {
|
||||||
|
throw insertEx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("更新AI预标注结果,任务ID: {}", taskId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("写入 AI 预标注结果失败, taskId={}", taskId, e);
|
||||||
|
throw new RuntimeException("RESULT_WRITE_FAILED: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/main/java/com/label/service/AuthService.java
Normal file
140
src/main/java/com/label/service/AuthService.java
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
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;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务:登录、退出、查询当前用户信息。
|
||||||
|
*
|
||||||
|
* Token 生命周期:
|
||||||
|
* - 登录成功 → UUID v4 → Redis Hash token:{uuid} → TTL = token.ttl-seconds
|
||||||
|
* - 退出登录 → 直接 DEL token:{uuid}(立即失效)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final SysCompanyMapper companyMapper;
|
||||||
|
private final SysUserMapper userMapper;
|
||||||
|
private final RedisService redisService;
|
||||||
|
|
||||||
|
/** BCryptPasswordEncoder 线程安全,可复用 */
|
||||||
|
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
|
||||||
|
|
||||||
|
@Value("${token.ttl-seconds:7200}")
|
||||||
|
private long tokenTtlSeconds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录。
|
||||||
|
*
|
||||||
|
* @param request 包含 companyCode / username / password
|
||||||
|
* @return LoginResponse(含 token、userId、role、expiresIn)
|
||||||
|
* @throws BusinessException USER_NOT_FOUND(401) 凭证错误
|
||||||
|
* @throws BusinessException USER_DISABLED(403) 账号已禁用
|
||||||
|
*/
|
||||||
|
public LoginResponse login(LoginRequest request) {
|
||||||
|
// 1. 查公司(绕过多租户过滤器,sys_company 无 company_id 字段)
|
||||||
|
SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode());
|
||||||
|
if (company == null || !"ACTIVE".equals(company.getStatus())) {
|
||||||
|
// 公司不存在或禁用,统一报 USER_NOT_FOUND 防止信息泄漏
|
||||||
|
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查用户(显式传入 companyId,绕过多租户拦截器)
|
||||||
|
SysUser user = userMapper.selectByCompanyAndUsername(company.getId(), request.getUsername());
|
||||||
|
if (user == null) {
|
||||||
|
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 账号禁用检查(先于密码校验,防止暴力破解已知用户状态)
|
||||||
|
if (!"ACTIVE".equals(user.getStatus())) {
|
||||||
|
throw new BusinessException("USER_DISABLED", "账号已禁用,请联系管理员", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. BCrypt 密码校验
|
||||||
|
if (!PASSWORD_ENCODER.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 生成 UUID v4 Token,写入 Redis Hash
|
||||||
|
String token = UUID.randomUUID().toString();
|
||||||
|
Map<String, String> tokenData = new HashMap<>();
|
||||||
|
tokenData.put("userId", user.getId().toString());
|
||||||
|
tokenData.put("role", user.getRole());
|
||||||
|
tokenData.put("companyId", user.getCompanyId().toString());
|
||||||
|
tokenData.put("username", user.getUsername());
|
||||||
|
redisService.hSetAll(RedisUtil.tokenKey(token), tokenData, tokenTtlSeconds);
|
||||||
|
|
||||||
|
// 将 token 加入该用户的活跃会话集合(用于角色变更时批量更新/失效)
|
||||||
|
String sessionsKey = RedisUtil.userSessionsKey(user.getId());
|
||||||
|
redisService.sAdd(sessionsKey, token);
|
||||||
|
// 防止 Set 无限增长:TTL = token 有效期(最后一次登录时滑动续期)
|
||||||
|
redisService.expire(sessionsKey, tokenTtlSeconds);
|
||||||
|
|
||||||
|
log.info("用户登录成功: companyCode={}, username={}", request.getCompanyCode(), request.getUsername());
|
||||||
|
return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole(), tokenTtlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录,立即删除 Redis Token(Token 立即失效)。
|
||||||
|
*
|
||||||
|
* @param token 来自 Authorization 头的 Bearer token
|
||||||
|
*/
|
||||||
|
public void logout(String token) {
|
||||||
|
if (token != null && !token.isBlank()) {
|
||||||
|
// 从用户会话集合中移除(若 token 仍有效则先读取 userId)
|
||||||
|
String userId = redisService.hGet(RedisUtil.tokenKey(token), "userId");
|
||||||
|
redisService.delete(RedisUtil.tokenKey(token));
|
||||||
|
if (userId != null) {
|
||||||
|
try {
|
||||||
|
redisService.sRemove(RedisUtil.userSessionsKey(Long.parseLong(userId)), token);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
log.info("用户退出,Token 已删除: {}", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户详情(含 realName、companyName)。
|
||||||
|
*
|
||||||
|
* @param principal AuthInterceptor 注入的当前用户主体
|
||||||
|
* @return 用户信息响应体
|
||||||
|
*/
|
||||||
|
public UserInfoResponse me(TokenPrincipal principal) {
|
||||||
|
// 从 DB 获取 realName(Token 中未存储)
|
||||||
|
SysUser user = userMapper.selectById(principal.getUserId());
|
||||||
|
SysCompany company = companyMapper.selectById(principal.getCompanyId());
|
||||||
|
|
||||||
|
String realName = (user != null) ? user.getRealName() : principal.getUsername();
|
||||||
|
String companyName = (company != null) ? company.getCompanyName() : "";
|
||||||
|
|
||||||
|
return new UserInfoResponse(
|
||||||
|
principal.getUserId(),
|
||||||
|
principal.getUsername(),
|
||||||
|
realName,
|
||||||
|
principal.getRole(),
|
||||||
|
principal.getCompanyId(),
|
||||||
|
companyName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/main/java/com/label/service/CompanyService.java
Normal file
122
src/main/java/com/label/service/CompanyService.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/main/java/com/label/service/ExportService.java
Normal file
177
src/main/java/com/label/service/ExportService.java
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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.auth.TokenPrincipal;
|
||||||
|
import com.label.common.storage.RustFsClient;
|
||||||
|
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;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 训练数据导出服务。
|
||||||
|
*
|
||||||
|
* createBatch() 步骤:
|
||||||
|
* 1. 校验 sampleIds 非空(EMPTY_SAMPLES 400)
|
||||||
|
* 2. 查询 training_dataset,校验全部为 APPROVED(INVALID_SAMPLES 400)
|
||||||
|
* 3. 生成 JSONL(每行一个 glm_format_json)
|
||||||
|
* 4. 上传 RustFS(bucket: finetune-export, key: export/{batchUuid}.jsonl)
|
||||||
|
* 5. 插入 export_batch 记录
|
||||||
|
* 6. 批量更新 training_dataset.export_batch_id + exported_at
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExportService {
|
||||||
|
|
||||||
|
private static final String EXPORT_BUCKET = "finetune-export";
|
||||||
|
|
||||||
|
private final ExportBatchMapper exportBatchMapper;
|
||||||
|
private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final RustFsClient rustFsClient;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 创建批次 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建导出批次。
|
||||||
|
*
|
||||||
|
* @param sampleIds 待导出的 training_dataset ID 列表
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @return 新建的 ExportBatch
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ExportBatch createBatch(List<Long> sampleIds, TokenPrincipal principal) {
|
||||||
|
if (sampleIds == null || sampleIds.isEmpty()) {
|
||||||
|
throw new BusinessException("EMPTY_SAMPLES", "导出样本 ID 列表不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询样本
|
||||||
|
List<TrainingDataset> samples = datasetMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<TrainingDataset>()
|
||||||
|
.in(TrainingDataset::getId, sampleIds)
|
||||||
|
.eq(TrainingDataset::getCompanyId, principal.getCompanyId()));
|
||||||
|
|
||||||
|
// 校验全部已审批
|
||||||
|
boolean hasNonApproved = samples.stream()
|
||||||
|
.anyMatch(s -> !"APPROVED".equals(s.getStatus()));
|
||||||
|
if (hasNonApproved || samples.size() != sampleIds.size()) {
|
||||||
|
throw new BusinessException("INVALID_SAMPLES",
|
||||||
|
"部分样本不处于 APPROVED 状态或不属于当前租户", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 JSONL(每行一个 JSON 对象)
|
||||||
|
String jsonl = samples.stream()
|
||||||
|
.map(TrainingDataset::getGlmFormatJson)
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
byte[] jsonlBytes = jsonl.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// 生成唯一批次 UUID,上传 RustFS
|
||||||
|
UUID batchUuid = UUID.randomUUID();
|
||||||
|
String filePath = "export/" + batchUuid + ".jsonl";
|
||||||
|
|
||||||
|
rustFsClient.upload(EXPORT_BUCKET, filePath,
|
||||||
|
new ByteArrayInputStream(jsonlBytes), jsonlBytes.length,
|
||||||
|
"application/jsonl");
|
||||||
|
|
||||||
|
// 插入 export_batch 记录(若 DB 写入失败,尝试清理 RustFS 孤儿文件)
|
||||||
|
ExportBatch batch = new ExportBatch();
|
||||||
|
batch.setCompanyId(principal.getCompanyId());
|
||||||
|
batch.setBatchUuid(batchUuid);
|
||||||
|
batch.setSampleCount(samples.size());
|
||||||
|
batch.setDatasetFilePath(filePath);
|
||||||
|
batch.setFinetuneStatus("NOT_STARTED");
|
||||||
|
try {
|
||||||
|
exportBatchMapper.insert(batch);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// DB 插入失败:尝试删除已上传的 RustFS 文件,防止产生孤儿文件
|
||||||
|
try {
|
||||||
|
rustFsClient.delete(EXPORT_BUCKET, filePath);
|
||||||
|
} catch (Exception deleteEx) {
|
||||||
|
log.error("DB 写入失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", EXPORT_BUCKET, filePath, deleteEx);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新 training_dataset.export_batch_id + exported_at
|
||||||
|
datasetMapper.update(null, new LambdaUpdateWrapper<TrainingDataset>()
|
||||||
|
.in(TrainingDataset::getId, sampleIds)
|
||||||
|
.set(TrainingDataset::getExportBatchId, batch.getId())
|
||||||
|
.set(TrainingDataset::getExportedAt, LocalDateTime.now())
|
||||||
|
.set(TrainingDataset::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
log.info("导出批次已创建: batchId={}, sampleCount={}, path={}",
|
||||||
|
batch.getId(), samples.size(), filePath);
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询样本 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询已审批、可导出的训练样本。
|
||||||
|
*/
|
||||||
|
public PageResult<TrainingDataset> listSamples(int page, int pageSize,
|
||||||
|
String sampleType, Boolean exported,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<TrainingDataset> wrapper = new LambdaQueryWrapper<TrainingDataset>()
|
||||||
|
.eq(TrainingDataset::getStatus, "APPROVED")
|
||||||
|
.eq(TrainingDataset::getCompanyId, principal.getCompanyId())
|
||||||
|
.orderByDesc(TrainingDataset::getCreatedAt);
|
||||||
|
|
||||||
|
if (sampleType != null && !sampleType.isBlank()) {
|
||||||
|
wrapper.eq(TrainingDataset::getSampleType, sampleType);
|
||||||
|
}
|
||||||
|
if (exported != null) {
|
||||||
|
if (exported) {
|
||||||
|
wrapper.isNotNull(TrainingDataset::getExportBatchId);
|
||||||
|
} else {
|
||||||
|
wrapper.isNull(TrainingDataset::getExportBatchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<TrainingDataset> result = datasetMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询批次列表 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询导出批次。
|
||||||
|
*/
|
||||||
|
public PageResult<ExportBatch> listBatches(int page, int pageSize, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
Page<ExportBatch> result = exportBatchMapper.selectPage(
|
||||||
|
new Page<>(page, pageSize),
|
||||||
|
new LambdaQueryWrapper<ExportBatch>()
|
||||||
|
.eq(ExportBatch::getCompanyId, principal.getCompanyId())
|
||||||
|
.orderByDesc(ExportBatch::getCreatedAt));
|
||||||
|
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询批次 --
|
||||||
|
|
||||||
|
public ExportBatch getById(Long batchId, TokenPrincipal principal) {
|
||||||
|
ExportBatch batch = exportBatchMapper.selectById(batchId);
|
||||||
|
if (batch == null || !batch.getCompanyId().equals(principal.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "导出批次不存在: " + batchId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/main/java/com/label/service/ExtractionService.java
Normal file
251
src/main/java/com/label/service/ExtractionService.java
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.statemachine.StateValidator;
|
||||||
|
import com.label.common.statemachine.TaskStatus;
|
||||||
|
import com.label.entity.AnnotationResult;
|
||||||
|
import com.label.entity.AnnotationTask;
|
||||||
|
import com.label.entity.SourceData;
|
||||||
|
import com.label.event.ExtractionApprovedEvent;
|
||||||
|
import com.label.mapper.AnnotationResultMapper;
|
||||||
|
import com.label.mapper.AnnotationTaskMapper;
|
||||||
|
import com.label.mapper.SourceDataMapper;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取阶段标注服务:AI 预标注、更新结果、提交、审批、驳回。
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - approve() 内禁止直接调用 AI,通过 ExtractionApprovedEvent 解耦(AFTER_COMMIT)
|
||||||
|
* - 所有写操作包裹在 @Transactional 中,确保任务状态和历史的一致性
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExtractionService {
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final AnnotationResultMapper resultMapper;
|
||||||
|
// private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
// private final AiServiceClient aiServiceClient;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AiAnnotationAsyncService aiAnnotationAsyncService; // 注入异步服务
|
||||||
|
|
||||||
|
@Value("${rustfs.bucket:label-source-data}")
|
||||||
|
private String bucket;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ AI 预标注 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 辅助预标注:调用 AI 服务,将结果写入 annotation_result。
|
||||||
|
* 注:此方法在 @Transactional 外调用(AI 调用不应在事务内),由控制器直接调用。
|
||||||
|
*/
|
||||||
|
public void aiPreAnnotate(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
if (source == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "关联资料不存在", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.getFilePath() == null || source.getFilePath().isEmpty()) {
|
||||||
|
throw new BusinessException("INVALID_SOURCE", "源文件路径不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.getDataType() == null || source.getDataType().isEmpty()) {
|
||||||
|
throw new BusinessException("INVALID_SOURCE", "数据类型不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
String dataType = source.getDataType().toUpperCase();
|
||||||
|
if (!"IMAGE".equals(dataType) && !"TEXT".equals(dataType)) {
|
||||||
|
log.warn("不支持的数据类型: {}, 任务ID: {}", dataType, taskId);
|
||||||
|
throw new BusinessException("UNSUPPORTED_TYPE",
|
||||||
|
"不支持的数据类型: " + dataType, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新任务状态为 PROCESSING
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getAiStatus, "PROCESSING"));
|
||||||
|
|
||||||
|
// 触发异步任务
|
||||||
|
aiAnnotationAsyncService.processAnnotation(taskId, principal.getCompanyId(), source);
|
||||||
|
// executeAiAnnotationAsync(taskId, principal.getCompanyId(), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人工更新标注结果(整体覆盖,PUT 语义)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param resultJson 新的标注结果 JSON 字符串
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateResult(Long taskId, String resultJson, TokenPrincipal principal) {
|
||||||
|
validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 校验 JSON 格式
|
||||||
|
try {
|
||||||
|
objectMapper.readTree(resultJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("INVALID_JSON", "标注结果 JSON 格式不合法", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
int updated = resultMapper.updateResultJson(taskId, resultJson, principal.getCompanyId());
|
||||||
|
if (updated == 0) {
|
||||||
|
// 不存在则新建
|
||||||
|
AnnotationResult result = new AnnotationResult();
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setCompanyId(principal.getCompanyId());
|
||||||
|
result.setResultJson(resultJson);
|
||||||
|
resultMapper.insert(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 提交 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交提取结果(IN_PROGRESS → SUBMITTED)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void submit(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.SUBMITTED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "SUBMITTED")
|
||||||
|
.set(AnnotationTask::getSubmittedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
task.getStatus(), "SUBMITTED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 审批通过 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批通过(SUBMITTED → APPROVED)。
|
||||||
|
*
|
||||||
|
* 两阶段:
|
||||||
|
* 1. 同步事务:is_final=true,状态推进,写历史
|
||||||
|
* 2. 事务提交后(AFTER_COMMIT):AI 生成问答对 → training_dataset → QA 任务 → source_data 状态
|
||||||
|
*
|
||||||
|
* 注:AI 调用严禁在此事务内执行。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void approve(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许审批自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
|
||||||
|
|
||||||
|
// 标记为最终结果
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "APPROVED")
|
||||||
|
.set(AnnotationTask::getIsFinal, true)
|
||||||
|
.set(AnnotationTask::getCompletedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "APPROVED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
|
||||||
|
// 获取资料信息,用于事件
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
String sourceType = source != null ? source.getDataType() : "TEXT";
|
||||||
|
|
||||||
|
// 发布事件(@TransactionalEventListener(AFTER_COMMIT) 处理 AI 调用)
|
||||||
|
eventPublisher.publishEvent(new ExtractionApprovedEvent(
|
||||||
|
this, taskId, task.getSourceId(), sourceType,
|
||||||
|
principal.getCompanyId(), principal.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 驳回 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回提取结果(SUBMITTED → REJECTED)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reject(Long taskId, String reason, TokenPrincipal principal) {
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许驳回自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.REJECTED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "REJECTED")
|
||||||
|
.set(AnnotationTask::getRejectReason, reason));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "REJECTED",
|
||||||
|
principal.getUserId(), principal.getRole(), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前标注结果。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
AnnotationResult result = resultMapper.selectByTaskId(taskId);
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"taskId", taskId,
|
||||||
|
"sourceType", source != null ? source.getDataType() : "",
|
||||||
|
"sourceFilePath", source != null && source.getFilePath() != null ? source.getFilePath() : "",
|
||||||
|
"isFinal", task.getIsFinal() != null && task.getIsFinal(),
|
||||||
|
"resultJson", result != null ? result.getResultJson() : "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验任务存在性(多租户自动过滤)。
|
||||||
|
*/
|
||||||
|
private AnnotationTask validateAndGetTask(Long taskId, Long companyId) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null || !companyId.equals(task.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/main/java/com/label/service/FinetuneService.java
Normal file
113
src/main/java/com/label/service/FinetuneService.java
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import com.label.common.ai.AiServiceClient;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.storage.RustFsClient;
|
||||||
|
import com.label.entity.ExportBatch;
|
||||||
|
import com.label.mapper.ExportBatchMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FinetuneService {
|
||||||
|
|
||||||
|
private static final String FINETUNE_BUCKET = "finetune-export";
|
||||||
|
private static final int PRESIGNED_URL_MINUTES = 60;
|
||||||
|
|
||||||
|
private final ExportBatchMapper exportBatchMapper;
|
||||||
|
private final ExportService exportService;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
private final RustFsClient rustFsClient;
|
||||||
|
|
||||||
|
private String finetuneBaseModel = "qwen3-14b";
|
||||||
|
|
||||||
|
public Map<String, Object> trigger(Long batchId, TokenPrincipal principal) {
|
||||||
|
ExportBatch batch = exportService.getById(batchId, principal);
|
||||||
|
|
||||||
|
if (!"NOT_STARTED".equals(batch.getFinetuneStatus())) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"FINETUNE_ALREADY_STARTED",
|
||||||
|
"微调任务已提交,当前状态 " + batch.getFinetuneStatus(),
|
||||||
|
HttpStatus.CONFLICT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String jsonlUrl = rustFsClient.getPresignedUrl(
|
||||||
|
FINETUNE_BUCKET,
|
||||||
|
batch.getDatasetFilePath(),
|
||||||
|
PRESIGNED_URL_MINUTES
|
||||||
|
);
|
||||||
|
|
||||||
|
AiServiceClient.FinetuneStartRequest req = AiServiceClient.FinetuneStartRequest.builder()
|
||||||
|
.jsonlUrl(jsonlUrl)
|
||||||
|
.baseModel(finetuneBaseModel)
|
||||||
|
.hyperparams(Map.of())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
AiServiceClient.FinetuneStartResponse response;
|
||||||
|
try {
|
||||||
|
response = aiServiceClient.startFinetune(req);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"FINETUNE_TRIGGER_FAILED",
|
||||||
|
"提交微调任务失败: " + e.getMessage(),
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportBatchMapper.updateFinetuneInfo(
|
||||||
|
batchId,
|
||||||
|
response.getJobId(),
|
||||||
|
"RUNNING",
|
||||||
|
principal.getCompanyId()
|
||||||
|
);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"glmJobId", response.getJobId(),
|
||||||
|
"finetuneStatus", "RUNNING"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getStatus(Long batchId, TokenPrincipal principal) {
|
||||||
|
ExportBatch batch = exportService.getById(batchId, principal);
|
||||||
|
|
||||||
|
if (batch.getGlmJobId() == null) {
|
||||||
|
return Map.of(
|
||||||
|
"batchId", batchId,
|
||||||
|
"glmJobId", "",
|
||||||
|
"finetuneStatus", batch.getFinetuneStatus(),
|
||||||
|
"progress", 0,
|
||||||
|
"errorMessage", ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AiServiceClient.FinetuneStatusResponse statusResp;
|
||||||
|
try {
|
||||||
|
statusResp = aiServiceClient.getFinetuneStatus(batch.getGlmJobId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("查询微调状态失败(batchId={}): {}", batchId, e.getMessage());
|
||||||
|
return Map.of(
|
||||||
|
"batchId", batchId,
|
||||||
|
"glmJobId", batch.getGlmJobId(),
|
||||||
|
"finetuneStatus", batch.getFinetuneStatus(),
|
||||||
|
"progress", 0,
|
||||||
|
"errorMessage", "AI 服务查询失败: " + e.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"batchId", batchId,
|
||||||
|
"glmJobId", statusResp.getJobId() != null ? statusResp.getJobId() : batch.getGlmJobId(),
|
||||||
|
"finetuneStatus", statusResp.getStatus() != null ? statusResp.getStatus() : batch.getFinetuneStatus(),
|
||||||
|
"progress", statusResp.getProgress() != null ? statusResp.getProgress() : 0,
|
||||||
|
"errorMessage", statusResp.getErrorMessage() != null ? statusResp.getErrorMessage() : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/main/java/com/label/service/QaService.java
Normal file
252
src/main/java/com/label/service/QaService.java
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
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.auth.TokenPrincipal;
|
||||||
|
import com.label.common.statemachine.StateValidator;
|
||||||
|
import com.label.common.statemachine.TaskStatus;
|
||||||
|
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;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 问答生成阶段标注服务:查询候选问答对、更新、提交、审批、驳回。
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - QA 阶段无 AI 调用(候选问答对已由 ExtractionApprovedEventListener 生成)
|
||||||
|
* - approve() 同一事务内完成:training_dataset → APPROVED、task → APPROVED、source_data → APPROVED
|
||||||
|
* - reject() 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class QaService {
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final TrainingDatasetMapper datasetMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取候选问答对(从 training_dataset.glm_format_json 解析)。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
TrainingDataset dataset = getDataset(taskId);
|
||||||
|
|
||||||
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
||||||
|
String sourceType = source != null ? source.getDataType() : "TEXT";
|
||||||
|
|
||||||
|
List<?> items = Collections.emptyList();
|
||||||
|
if (dataset != null && dataset.getGlmFormatJson() != null) {
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> parsed = objectMapper.readValue(dataset.getGlmFormatJson(), Map.class);
|
||||||
|
Object conversations = parsed.get("conversations");
|
||||||
|
if (conversations instanceof List) {
|
||||||
|
items = (List<?>) conversations;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("解析 QA JSON 失败(taskId={}):{}", taskId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"taskId", taskId,
|
||||||
|
"sourceType", sourceType,
|
||||||
|
"items", items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 更新 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整体覆盖问答对(PUT 语义)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param body 包含 items 数组的 JSON,格式:{"items": [...]}
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateResult(Long taskId, String body, TokenPrincipal principal) {
|
||||||
|
validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 校验 JSON 格式
|
||||||
|
try {
|
||||||
|
objectMapper.readTree(body);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("INVALID_JSON", "请求体 JSON 格式不合法", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 items 格式包装为 GLM 格式:{"conversations": items}
|
||||||
|
String glmJson;
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> parsed = objectMapper.readValue(body, Map.class);
|
||||||
|
Object items = parsed.getOrDefault("items", Collections.emptyList());
|
||||||
|
glmJson = objectMapper.writeValueAsString(Map.of("conversations", items));
|
||||||
|
} catch (Exception e) {
|
||||||
|
glmJson = "{\"conversations\":[]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainingDataset dataset = getDataset(taskId);
|
||||||
|
if (dataset != null) {
|
||||||
|
datasetMapper.update(null, new LambdaUpdateWrapper<TrainingDataset>()
|
||||||
|
.eq(TrainingDataset::getTaskId, taskId)
|
||||||
|
.set(TrainingDataset::getGlmFormatJson, glmJson)
|
||||||
|
.set(TrainingDataset::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
} else {
|
||||||
|
// 若 training_dataset 不存在(异常情况),自动创建
|
||||||
|
TrainingDataset newDataset = new TrainingDataset();
|
||||||
|
newDataset.setCompanyId(principal.getCompanyId());
|
||||||
|
newDataset.setTaskId(taskId);
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
newDataset.setSourceId(task.getSourceId());
|
||||||
|
newDataset.setSampleType("TEXT");
|
||||||
|
newDataset.setGlmFormatJson(glmJson);
|
||||||
|
newDataset.setStatus("PENDING_REVIEW");
|
||||||
|
datasetMapper.insert(newDataset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 提交 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交 QA 结果(IN_PROGRESS → SUBMITTED)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void submit(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.SUBMITTED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "SUBMITTED")
|
||||||
|
.set(AnnotationTask::getSubmittedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
task.getStatus(), "SUBMITTED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 审批通过 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批通过(SUBMITTED → APPROVED)。
|
||||||
|
*
|
||||||
|
* 同一事务:
|
||||||
|
* 1. 校验任务(先于一切 DB 写入)
|
||||||
|
* 2. 自审校验
|
||||||
|
* 3. StateValidator
|
||||||
|
* 4. training_dataset → APPROVED
|
||||||
|
* 5. annotation_task → APPROVED + is_final=true + completedAt
|
||||||
|
* 6. source_data → APPROVED(整条流水线完成)
|
||||||
|
* 7. 写任务历史
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void approve(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许审批自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
|
||||||
|
|
||||||
|
// training_dataset → APPROVED
|
||||||
|
datasetMapper.approveByTaskId(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// annotation_task → APPROVED + is_final=true
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "APPROVED")
|
||||||
|
.set(AnnotationTask::getIsFinal, true)
|
||||||
|
.set(AnnotationTask::getCompletedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
// source_data → APPROVED(整条流水线终态)
|
||||||
|
sourceDataMapper.updateStatus(task.getSourceId(), "APPROVED", principal.getCompanyId());
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "APPROVED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
|
||||||
|
log.info("QA 审批通过,整条流水线完成: taskId={}, sourceId={}", taskId, task.getSourceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 驳回 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回 QA 结果(SUBMITTED → REJECTED)。
|
||||||
|
*
|
||||||
|
* 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态不变。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reject(Long taskId, String reason, TokenPrincipal principal) {
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
// 自审校验
|
||||||
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
||||||
|
"不允许驳回自己提交的任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.REJECTED);
|
||||||
|
|
||||||
|
// 清除候选问答对
|
||||||
|
datasetMapper.deleteByTaskId(taskId, principal.getCompanyId());
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "REJECTED")
|
||||||
|
.set(AnnotationTask::getRejectReason, reason));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"SUBMITTED", "REJECTED",
|
||||||
|
principal.getUserId(), principal.getRole(), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private AnnotationTask validateAndGetTask(Long taskId, Long companyId) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null || !companyId.equals(task.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TrainingDataset getDataset(Long taskId) {
|
||||||
|
return datasetMapper.selectOne(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TrainingDataset>()
|
||||||
|
.eq(TrainingDataset::getTaskId, taskId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/main/java/com/label/service/RedisService.java
Normal file
84
src/main/java/com/label/service/RedisService.java
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RedisService {
|
||||||
|
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
|
||||||
|
// String operations
|
||||||
|
|
||||||
|
public void set(String key, String value, long ttlSeconds) {
|
||||||
|
redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String get(String key) {
|
||||||
|
return redisTemplate.opsForValue().get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String key) {
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean exists(String key) {
|
||||||
|
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set if absent (NX). Returns true if key was set (lock acquired). */
|
||||||
|
public boolean setIfAbsent(String key, String value, long ttlSeconds) {
|
||||||
|
Boolean result = redisTemplate.opsForValue()
|
||||||
|
.setIfAbsent(key, value, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
return Boolean.TRUE.equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh TTL on an existing key (sliding expiration). */
|
||||||
|
public void expire(String key, long ttlSeconds) {
|
||||||
|
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash operations (for token storage: token:{uuid} → Hash)
|
||||||
|
|
||||||
|
public void hSetAll(String key, Map<String, String> entries, long ttlSeconds) {
|
||||||
|
redisTemplate.opsForHash().putAll(key, entries);
|
||||||
|
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Object, Object> hGetAll(String key) {
|
||||||
|
return redisTemplate.opsForHash().entries(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String hGet(String key, String field) {
|
||||||
|
Object val = redisTemplate.opsForHash().get(key, field);
|
||||||
|
return val != null ? val.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 Hash 中的单个字段(不改变其他字段和 TTL)。 */
|
||||||
|
public void hPut(String key, String field, String value) {
|
||||||
|
redisTemplate.opsForHash().put(key, field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set operations(用于用户会话跟踪)
|
||||||
|
|
||||||
|
/** 向 Set 添加成员。 */
|
||||||
|
public void sAdd(String key, String member) {
|
||||||
|
redisTemplate.opsForSet().add(key, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Set 移除成员。 */
|
||||||
|
public void sRemove(String key, String member) {
|
||||||
|
redisTemplate.opsForSet().remove(key, (Object) member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 Set 全部成员;Set 不存在时返回空集合。 */
|
||||||
|
public java.util.Set<String> sMembers(String key) {
|
||||||
|
java.util.Set<String> members = redisTemplate.opsForSet().members(key);
|
||||||
|
return members != null ? members : java.util.Collections.emptySet();
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/main/java/com/label/service/SourceService.java
Normal file
230
src/main/java/com/label/service/SourceService.java
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
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.auth.TokenPrincipal;
|
||||||
|
import com.label.common.storage.RustFsClient;
|
||||||
|
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;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原始资料业务服务。
|
||||||
|
*
|
||||||
|
* 上传流程:先 INSERT 获取 ID → 构造 RustFS 路径 → 上传文件 → UPDATE filePath。
|
||||||
|
* 删除规则:仅 PENDING 状态可删(防止删除已进入标注流水线的资料)。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SourceService {
|
||||||
|
|
||||||
|
private static final Set<String> VALID_DATA_TYPES = Set.of("TEXT", "IMAGE", "VIDEO");
|
||||||
|
private static final int PRESIGNED_URL_MINUTES = 15;
|
||||||
|
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final RustFsClient rustFsClient;
|
||||||
|
|
||||||
|
@Value("${rustfs.bucket:label-source-data}")
|
||||||
|
private String bucket;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 上传 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件并创建 source_data 记录。
|
||||||
|
*
|
||||||
|
* @param file 上传的文件
|
||||||
|
* @param dataType 资料类型(TEXT / IMAGE / VIDEO)
|
||||||
|
* @param principal 当前登录用户
|
||||||
|
* @return 创建成功的资料摘要
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SourceResponse upload(MultipartFile file, String dataType, TokenPrincipal principal) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("FILE_EMPTY", "上传文件不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (!VALID_DATA_TYPES.contains(dataType)) {
|
||||||
|
throw new BusinessException("INVALID_TYPE", "不支持的资料类型: " + dataType, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取纯文件名,防止路径遍历(如 ../../admin/secret.txt)
|
||||||
|
String rawName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown";
|
||||||
|
String originalName = java.nio.file.Paths.get(rawName).getFileName().toString();
|
||||||
|
|
||||||
|
// 1. 先插入占位记录,拿到自增 ID
|
||||||
|
SourceData source = new SourceData();
|
||||||
|
source.setCompanyId(principal.getCompanyId());
|
||||||
|
source.setUploaderId(principal.getUserId());
|
||||||
|
source.setDataType(dataType);
|
||||||
|
source.setFileName(originalName);
|
||||||
|
source.setFileSize(file.getSize());
|
||||||
|
source.setBucketName(bucket);
|
||||||
|
source.setFilePath(""); // 占位,后面更新
|
||||||
|
source.setStatus("PENDING");
|
||||||
|
sourceDataMapper.insert(source);
|
||||||
|
|
||||||
|
// 2. 构造 RustFS 对象路径
|
||||||
|
String objectKey = String.format("%d/%s/%d/%s",
|
||||||
|
principal.getCompanyId(), dataType.toLowerCase(), source.getId(), originalName);
|
||||||
|
|
||||||
|
// 3. 上传文件到 RustFS
|
||||||
|
try {
|
||||||
|
rustFsClient.upload(bucket, objectKey, file.getInputStream(),
|
||||||
|
file.getSize(), file.getContentType());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("文件上传到 RustFS 失败: bucket={}, key={}", bucket, objectKey, e);
|
||||||
|
throw new BusinessException("UPLOAD_FAILED", "文件上传失败,请重试", HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 更新 filePath(若失败则清理 RustFS 孤儿文件)
|
||||||
|
try {
|
||||||
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
||||||
|
.eq(SourceData::getId, source.getId())
|
||||||
|
.set(SourceData::getFilePath, objectKey));
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
rustFsClient.delete(bucket, objectKey);
|
||||||
|
} catch (Exception deleteEx) {
|
||||||
|
log.error("DB 更新失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", bucket, objectKey, deleteEx);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("资料上传成功: id={}, key={}", source.getId(), objectKey);
|
||||||
|
return toUploadResponse(source, objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 列表 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询资料列表。
|
||||||
|
* UPLOADER 只见自己上传的资料;ADMIN 见本公司全部资料(多租户自动过滤)。
|
||||||
|
*/
|
||||||
|
public PageResult<SourceResponse> list(int page, int pageSize,
|
||||||
|
String dataType, String status,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
|
||||||
|
LambdaQueryWrapper<SourceData> wrapper = new LambdaQueryWrapper<SourceData>()
|
||||||
|
.orderByDesc(SourceData::getCreatedAt);
|
||||||
|
|
||||||
|
// UPLOADER 只能查自己的资料
|
||||||
|
if ("UPLOADER".equals(principal.getRole())) {
|
||||||
|
wrapper.eq(SourceData::getUploaderId, principal.getUserId());
|
||||||
|
}
|
||||||
|
if (dataType != null && !dataType.isBlank()) {
|
||||||
|
wrapper.eq(SourceData::getDataType, dataType);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(SourceData::getStatus, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<SourceData> pageResult = sourceDataMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
|
||||||
|
List<SourceResponse> items = pageResult.getRecords().stream()
|
||||||
|
.map(this::toListItem)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return PageResult.of(items, pageResult.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 详情 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 查询资料详情,含 15 分钟预签名下载链接。
|
||||||
|
*/
|
||||||
|
public SourceResponse findById(Long id) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(id);
|
||||||
|
if (source == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
String presignedUrl = null;
|
||||||
|
if (source.getFilePath() != null && !source.getFilePath().isBlank()) {
|
||||||
|
presignedUrl = rustFsClient.getPresignedUrl(bucket, source.getFilePath(), PRESIGNED_URL_MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SourceResponse.builder()
|
||||||
|
.id(source.getId())
|
||||||
|
.fileName(source.getFileName())
|
||||||
|
.dataType(source.getDataType())
|
||||||
|
.fileSize(source.getFileSize())
|
||||||
|
.status(source.getStatus())
|
||||||
|
.presignedUrl(presignedUrl)
|
||||||
|
.parentSourceId(source.getParentSourceId())
|
||||||
|
.createdAt(source.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 删除 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除资料:仅 PENDING 状态可删,同步删除 RustFS 文件。
|
||||||
|
*
|
||||||
|
* @throws BusinessException SOURCE_IN_PIPELINE(409) 资料已进入标注流程
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id, Long companyId) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(id);
|
||||||
|
if (source == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"PENDING".equals(source.getStatus())) {
|
||||||
|
throw new BusinessException("SOURCE_IN_PIPELINE",
|
||||||
|
"资料已进入标注流程,不可删除(当前状态:" + source.getStatus() + ")",
|
||||||
|
HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先删 RustFS 文件(幂等,不抛异常)
|
||||||
|
if (source.getFilePath() != null && !source.getFilePath().isBlank()) {
|
||||||
|
try {
|
||||||
|
rustFsClient.delete(bucket, source.getFilePath());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("RustFS 文件删除失败(继续删 DB 记录): bucket={}, key={}", bucket, source.getFilePath(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDataMapper.deleteById(id);
|
||||||
|
log.info("资料删除成功: id={}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private SourceResponse toUploadResponse(SourceData source, String filePath) {
|
||||||
|
return SourceResponse.builder()
|
||||||
|
.id(source.getId())
|
||||||
|
.fileName(source.getFileName())
|
||||||
|
.dataType(source.getDataType())
|
||||||
|
.fileSize(source.getFileSize())
|
||||||
|
.status(source.getStatus())
|
||||||
|
.createdAt(source.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SourceResponse toListItem(SourceData source) {
|
||||||
|
return SourceResponse.builder()
|
||||||
|
.id(source.getId())
|
||||||
|
.fileName(source.getFileName())
|
||||||
|
.dataType(source.getDataType())
|
||||||
|
.status(source.getStatus())
|
||||||
|
.uploaderId(source.getUploaderId())
|
||||||
|
.createdAt(source.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/main/java/com/label/service/SysConfigService.java
Normal file
139
src/main/java/com/label/service/SysConfigService.java
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.entity.SysConfig;
|
||||||
|
import com.label.mapper.SysConfigMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置服务。
|
||||||
|
*
|
||||||
|
* 配置查找优先级:公司专属(company_id = N)> 全局默认(company_id IS NULL)。
|
||||||
|
*
|
||||||
|
* get() — 按优先级返回单个配置值
|
||||||
|
* list() — 返回合并后的配置列表(公司专属覆盖同名全局配置),附 scope 字段
|
||||||
|
* update() — 以公司专属配置进行 UPSERT(仅允许已知配置键)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SysConfigService {
|
||||||
|
|
||||||
|
/** 系统已知配置键白名单(防止写入未知键) */
|
||||||
|
private static final Set<String> KNOWN_KEYS = Set.of(
|
||||||
|
"token_ttl_seconds",
|
||||||
|
"model_default",
|
||||||
|
"video_frame_interval"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final SysConfigMapper configMapper;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询单值 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按优先级获取配置值:公司专属优先,否则回退全局默认。
|
||||||
|
*
|
||||||
|
* @param configKey 配置键
|
||||||
|
* @param companyId 当前公司 ID
|
||||||
|
* @return 配置值(不存在时返回 null)
|
||||||
|
*/
|
||||||
|
public String get(String configKey, Long companyId) {
|
||||||
|
// 先查公司专属
|
||||||
|
SysConfig company = configMapper.selectByCompanyAndKey(companyId, configKey);
|
||||||
|
if (company != null) {
|
||||||
|
return company.getConfigValue();
|
||||||
|
}
|
||||||
|
// 回退全局默认
|
||||||
|
SysConfig global = configMapper.selectGlobalByKey(configKey);
|
||||||
|
return global != null ? global.getConfigValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询列表 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前公司所有可见配置(公司专属 + 全局默认合并),
|
||||||
|
* 附加 scope 字段("COMPANY" / "GLOBAL")标识来源。
|
||||||
|
*
|
||||||
|
* @param companyId 当前公司 ID
|
||||||
|
* @return 配置列表(含 scope)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> list(Long companyId) {
|
||||||
|
List<SysConfig> all = configMapper.selectAllForCompany(companyId);
|
||||||
|
|
||||||
|
// 按 configKey 分组,公司专属优先(排序保证公司专属在前)
|
||||||
|
Map<String, SysConfig> merged = new LinkedHashMap<>();
|
||||||
|
for (SysConfig cfg : all) {
|
||||||
|
// 由于 SQL 按 company_id DESC NULLS LAST 排序,公司专属先出现,直接 putIfAbsent
|
||||||
|
merged.putIfAbsent(cfg.getConfigKey(), cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.values().stream()
|
||||||
|
.map(cfg -> {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("id", cfg.getId());
|
||||||
|
item.put("configKey", cfg.getConfigKey());
|
||||||
|
item.put("configValue", cfg.getConfigValue());
|
||||||
|
item.put("description", cfg.getDescription());
|
||||||
|
item.put("scope", cfg.getCompanyId() != null ? "COMPANY" : "GLOBAL");
|
||||||
|
item.put("companyId", cfg.getCompanyId());
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 更新配置 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新公司专属配置(UPSERT)。
|
||||||
|
*
|
||||||
|
* 仅允许 KNOWN_KEYS 中的配置键,防止写入未定义的配置项。
|
||||||
|
*
|
||||||
|
* @param configKey 配置键
|
||||||
|
* @param value 新配置值
|
||||||
|
* @param description 配置说明(可选)
|
||||||
|
* @param companyId 当前公司 ID
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SysConfig update(String configKey, String value,
|
||||||
|
String description, Long companyId) {
|
||||||
|
if (!KNOWN_KEYS.contains(configKey)) {
|
||||||
|
throw new BusinessException("UNKNOWN_CONFIG_KEY",
|
||||||
|
"未知配置键: " + configKey, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new BusinessException("INVALID_CONFIG_VALUE",
|
||||||
|
"配置值不能为空", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPSERT:如公司专属配置已存在则更新,否则插入
|
||||||
|
SysConfig existing = configMapper.selectByCompanyAndKey(companyId, configKey);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.setConfigValue(value);
|
||||||
|
if (description != null && !description.isBlank()) {
|
||||||
|
existing.setDescription(description);
|
||||||
|
}
|
||||||
|
existing.setUpdatedAt(LocalDateTime.now());
|
||||||
|
configMapper.updateById(existing);
|
||||||
|
log.info("公司配置已更新: companyId={}, key={}, value={}", companyId, configKey, value);
|
||||||
|
return existing;
|
||||||
|
} else {
|
||||||
|
SysConfig cfg = new SysConfig();
|
||||||
|
cfg.setCompanyId(companyId);
|
||||||
|
cfg.setConfigKey(configKey);
|
||||||
|
cfg.setConfigValue(value);
|
||||||
|
cfg.setDescription(description);
|
||||||
|
configMapper.insert(cfg);
|
||||||
|
log.info("公司配置已创建: companyId={}, key={}, value={}", companyId, configKey, value);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/main/java/com/label/service/TaskClaimService.java
Normal file
180
src/main/java/com/label/service/TaskClaimService.java
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.auth.TokenPrincipal;
|
||||||
|
import com.label.common.statemachine.StateValidator;
|
||||||
|
import com.label.common.statemachine.TaskStatus;
|
||||||
|
import com.label.entity.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;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务领取/放弃/重领服务。
|
||||||
|
*
|
||||||
|
* 并发安全设计:
|
||||||
|
* 1. Redis SET NX 作为分布式预锁(TTL 30s),快速拒绝并发请求
|
||||||
|
* 2. DB UPDATE WHERE status='UNCLAIMED' 作为兜底原子操作
|
||||||
|
* 两层防护确保同一任务只有一人可领取
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TaskClaimService {
|
||||||
|
|
||||||
|
/** Redis 分布式锁 TTL(秒) */
|
||||||
|
private static final long CLAIM_LOCK_TTL = 30L;
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final TaskHistoryMapper historyMapper;
|
||||||
|
private final RedisService redisService;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 领取 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取任务(双重防护:Redis NX + DB 原子更新)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @throws BusinessException TASK_CLAIMED(409) 任务已被他人领取
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void claim(Long taskId, TokenPrincipal principal) {
|
||||||
|
String lockKey = RedisUtil.taskClaimKey(taskId);
|
||||||
|
|
||||||
|
// 1. Redis SET NX 预锁(快速失败)
|
||||||
|
boolean lockAcquired = redisService.setIfAbsent(
|
||||||
|
lockKey, principal.getUserId().toString(), CLAIM_LOCK_TTL);
|
||||||
|
if (!lockAcquired) {
|
||||||
|
throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. DB 原子更新(WHERE status='UNCLAIMED' 兜底)
|
||||||
|
int affected = taskMapper.claimTask(taskId, principal.getUserId(), principal.getCompanyId());
|
||||||
|
if (affected == 0) {
|
||||||
|
// DB 更新失败说明任务状态已变,清除刚设置的锁
|
||||||
|
redisService.delete(lockKey);
|
||||||
|
throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 写入状态历史
|
||||||
|
insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"UNCLAIMED", "IN_PROGRESS",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
|
||||||
|
log.info("任务领取成功: taskId={}, userId={}", taskId, principal.getUserId());
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e; // 业务异常直接上抛,锁已在上方清除
|
||||||
|
} catch (Exception e) {
|
||||||
|
// DB 写入异常(含 insertHistory 失败):清除 Redis 锁,事务回滚
|
||||||
|
redisService.delete(lockKey);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 放弃 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 放弃任务(IN_PROGRESS → UNCLAIMED)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void unclaim(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
validateTaskExists(task, taskId);
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.UNCLAIMED);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getStatus, "UNCLAIMED")
|
||||||
|
.set(AnnotationTask::getClaimedBy, null)
|
||||||
|
.set(AnnotationTask::getClaimedAt, null));
|
||||||
|
|
||||||
|
// 清除 Redis 分布式锁
|
||||||
|
redisService.delete(RedisUtil.taskClaimKey(taskId));
|
||||||
|
|
||||||
|
insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"IN_PROGRESS", "UNCLAIMED",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 重领 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重领任务(REJECTED → IN_PROGRESS,仅原领取人可重领)。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reclaim(Long taskId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
validateTaskExists(task, taskId);
|
||||||
|
|
||||||
|
if (!"REJECTED".equals(task.getStatus())) {
|
||||||
|
throw new BusinessException("INVALID_STATE_TRANSITION",
|
||||||
|
"只有 REJECTED 状态的任务可以重领", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!principal.getUserId().equals(task.getClaimedBy())) {
|
||||||
|
throw new BusinessException("FORBIDDEN",
|
||||||
|
"只有原领取人可以重领该任务", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
||||||
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.IN_PROGRESS);
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.eq(AnnotationTask::getStatus, "REJECTED")
|
||||||
|
.set(AnnotationTask::getStatus, "IN_PROGRESS")
|
||||||
|
.set(AnnotationTask::getClaimedAt, java.time.LocalDateTime.now()));
|
||||||
|
|
||||||
|
// 重新设置 Redis 锁(防止并发再次争抢)
|
||||||
|
redisService.setIfAbsent(
|
||||||
|
RedisUtil.taskClaimKey(taskId),
|
||||||
|
principal.getUserId().toString(), CLAIM_LOCK_TTL);
|
||||||
|
|
||||||
|
insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
"REJECTED", "IN_PROGRESS",
|
||||||
|
principal.getUserId(), principal.getRole(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private void validateTaskExists(AnnotationTask task, Long taskId) {
|
||||||
|
if (task == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 annotation_task_history 追加一条历史记录(仅 INSERT,禁止 UPDATE/DELETE)。
|
||||||
|
*/
|
||||||
|
public void insertHistory(Long taskId, Long companyId,
|
||||||
|
String fromStatus, String toStatus,
|
||||||
|
Long operatorId, String operatorRole, String comment) {
|
||||||
|
historyMapper.insert(AnnotationTaskHistory.builder()
|
||||||
|
.taskId(taskId)
|
||||||
|
.companyId(companyId)
|
||||||
|
.fromStatus(fromStatus)
|
||||||
|
.toStatus(toStatus)
|
||||||
|
.operatorId(operatorId)
|
||||||
|
.operatorRole(operatorRole)
|
||||||
|
.comment(comment)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/main/java/com/label/service/TaskService.java
Normal file
202
src/main/java/com/label/service/TaskService.java
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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.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;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务管理服务:创建、查询任务池、我的任务、待审批队列、指派。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TaskService {
|
||||||
|
|
||||||
|
private final AnnotationTaskMapper taskMapper;
|
||||||
|
private final TaskClaimService taskClaimService;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 创建 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标注任务(内部调用,例如视频处理完成后)。
|
||||||
|
*
|
||||||
|
* @param sourceId 资料 ID
|
||||||
|
* @param taskType 任务类型(EXTRACTION / QA_GENERATION)
|
||||||
|
* @param companyId 租户 ID
|
||||||
|
* @return 新任务
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public AnnotationTask createTask(Long sourceId, String taskType, Long companyId) {
|
||||||
|
AnnotationTask task = new AnnotationTask();
|
||||||
|
task.setCompanyId(companyId);
|
||||||
|
task.setSourceId(sourceId);
|
||||||
|
task.setTaskType(taskType);
|
||||||
|
task.setStatus("UNCLAIMED");
|
||||||
|
task.setIsFinal(false);
|
||||||
|
taskMapper.insert(task);
|
||||||
|
log.info("任务已创建: id={}, type={}, sourceId={}", task.getId(), taskType, sourceId);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 任务池 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务池(按角色过滤):
|
||||||
|
* - ANNOTATOR → EXTRACTION 类型、UNCLAIMED 状态
|
||||||
|
* - REVIEWER/ADMIN → SUBMITTED 状态(任意类型)
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getPool(int page, int pageSize, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.orderByAsc(AnnotationTask::getCreatedAt);
|
||||||
|
|
||||||
|
String role = principal.getRole();
|
||||||
|
if ("ANNOTATOR".equals(role)) {
|
||||||
|
wrapper.eq(AnnotationTask::getTaskType, "EXTRACTION")
|
||||||
|
.eq(AnnotationTask::getStatus, "UNCLAIMED");
|
||||||
|
} else {
|
||||||
|
// REVIEWER / ADMIN 看待审批队列
|
||||||
|
wrapper.eq(AnnotationTask::getStatus, "SUBMITTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 我的任务 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户的任务(IN_PROGRESS、SUBMITTED、REJECTED)。
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getMine(int page, int pageSize,
|
||||||
|
String status, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getClaimedBy, principal.getUserId())
|
||||||
|
.in(AnnotationTask::getStatus, "IN_PROGRESS", "SUBMITTED", "REJECTED")
|
||||||
|
.orderByDesc(AnnotationTask::getUpdatedAt);
|
||||||
|
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getStatus, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 待审批 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询待审批任务(REVIEWER 专属,status=SUBMITTED)。
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getPendingReview(int page, int pageSize, String taskType) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getStatus, "SUBMITTED")
|
||||||
|
.orderByAsc(AnnotationTask::getSubmittedAt);
|
||||||
|
|
||||||
|
if (taskType != null && !taskType.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getTaskType, taskType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询单条 --
|
||||||
|
|
||||||
|
public AnnotationTask getById(Long id) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(id);
|
||||||
|
if (task == null) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + id, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 全部任务(ADMIN)--
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部任务(ADMIN 专用)。
|
||||||
|
*/
|
||||||
|
public PageResult<TaskResponse> getAll(int page, int pageSize, String status, String taskType) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
|
||||||
|
.orderByDesc(AnnotationTask::getCreatedAt);
|
||||||
|
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getStatus, status);
|
||||||
|
}
|
||||||
|
if (taskType != null && !taskType.isBlank()) {
|
||||||
|
wrapper.eq(AnnotationTask::getTaskType, taskType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<AnnotationTask> pageResult = taskMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||||
|
return toPageResult(pageResult, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 指派(ADMIN)--
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADMIN 强制指派任务给指定用户(IN_PROGRESS → IN_PROGRESS)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reassign(Long taskId, Long targetUserId, TokenPrincipal principal) {
|
||||||
|
AnnotationTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null || !principal.getCompanyId().equals(task.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
||||||
|
.eq(AnnotationTask::getId, taskId)
|
||||||
|
.set(AnnotationTask::getClaimedBy, targetUserId)
|
||||||
|
.set(AnnotationTask::getClaimedAt, java.time.LocalDateTime.now()));
|
||||||
|
|
||||||
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
||||||
|
task.getStatus(), "IN_PROGRESS",
|
||||||
|
principal.getUserId(), principal.getRole(),
|
||||||
|
"ADMIN 强制指派给用户 " + targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private PageResult<TaskResponse> toPageResult(Page<AnnotationTask> pageResult, int page, int pageSize) {
|
||||||
|
List<TaskResponse> items = pageResult.getRecords().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return PageResult.of(items, pageResult.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TaskResponse toPublicResponse(AnnotationTask task) {
|
||||||
|
return toResponse(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskResponse toResponse(AnnotationTask task) {
|
||||||
|
return TaskResponse.builder()
|
||||||
|
.id(task.getId())
|
||||||
|
.sourceId(task.getSourceId())
|
||||||
|
.taskType(task.getTaskType())
|
||||||
|
.status(task.getStatus())
|
||||||
|
.aiStatus(task.getAiStatus())
|
||||||
|
.claimedBy(task.getClaimedBy())
|
||||||
|
.claimedAt(task.getClaimedAt())
|
||||||
|
.submittedAt(task.getSubmittedAt())
|
||||||
|
.completedAt(task.getCompletedAt())
|
||||||
|
.rejectReason(task.getRejectReason())
|
||||||
|
.createdAt(task.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/main/java/com/label/service/UserService.java
Normal file
203
src/main/java/com/label/service/UserService.java
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.result.PageResult;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理服务(ADMIN 专属)。
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - 角色变更:DB 写入后立即更新所有活跃 Token 中的 role 字段,无需重新登录
|
||||||
|
* - 状态禁用:DB 写入后删除用户所有活跃 Token(立即失效)
|
||||||
|
* - 使用 user:sessions:{userId} Set 跟踪活跃会话
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
|
||||||
|
|
||||||
|
private final SysUserMapper userMapper;
|
||||||
|
private final RedisService redisService;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 创建用户 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新用户(ADMIN 操作)。
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @param password 明文密码(将以 BCrypt strength=10 哈希)
|
||||||
|
* @param realName 真实姓名(可选)
|
||||||
|
* @param role 角色(UPLOADER / ANNOTATOR / REVIEWER / ADMIN)
|
||||||
|
* @param principal 当前管理员
|
||||||
|
* @return 新建用户(不含 passwordHash)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SysUser createUser(String username, String password,
|
||||||
|
String realName, String role,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
// 校验用户名唯一性
|
||||||
|
SysUser existing = userMapper.selectByCompanyAndUsername(principal.getCompanyId(), username);
|
||||||
|
if (existing != null) {
|
||||||
|
throw new BusinessException("DUPLICATE_USERNAME",
|
||||||
|
"用户名 '" + username + "' 已存在", HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateRole(role);
|
||||||
|
|
||||||
|
SysUser user = new SysUser();
|
||||||
|
user.setCompanyId(principal.getCompanyId());
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPasswordHash(PASSWORD_ENCODER.encode(password));
|
||||||
|
user.setRealName(realName);
|
||||||
|
user.setRole(role);
|
||||||
|
user.setStatus("ACTIVE");
|
||||||
|
userMapper.insert(user);
|
||||||
|
|
||||||
|
log.info("用户已创建: userId={}, username={}, role={}", user.getId(), username, role);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 更新基本信息 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户基本信息(realName、password)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SysUser updateUser(Long userId, String realName, String password,
|
||||||
|
TokenPrincipal principal) {
|
||||||
|
SysUser user = getExistingUser(userId, principal.getCompanyId());
|
||||||
|
|
||||||
|
LambdaUpdateWrapper<SysUser> wrapper = new LambdaUpdateWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getId, userId)
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId());
|
||||||
|
|
||||||
|
if (realName != null && !realName.isBlank()) {
|
||||||
|
wrapper.set(SysUser::getRealName, realName);
|
||||||
|
user.setRealName(realName);
|
||||||
|
}
|
||||||
|
if (password != null && !password.isBlank()) {
|
||||||
|
wrapper.set(SysUser::getPasswordHash, PASSWORD_ENCODER.encode(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
userMapper.update(null, wrapper);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 变更角色 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变更用户角色。
|
||||||
|
*
|
||||||
|
* DB 写入后,立即更新该用户所有活跃 Token 中的 role 字段,
|
||||||
|
* 确保角色变更对下一次请求立即生效(无需重新登录)。
|
||||||
|
*
|
||||||
|
* @param userId 目标用户 ID
|
||||||
|
* @param newRole 新角色
|
||||||
|
* @param principal 当前管理员
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateRole(Long userId, String newRole, TokenPrincipal principal) {
|
||||||
|
getExistingUser(userId, principal.getCompanyId());
|
||||||
|
validateRole(newRole);
|
||||||
|
|
||||||
|
// 1. DB 写入
|
||||||
|
userMapper.update(null, new LambdaUpdateWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getId, userId)
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId())
|
||||||
|
.set(SysUser::getRole, newRole));
|
||||||
|
|
||||||
|
// 2. 更新所有活跃 Token 中的 role 字段(立即生效,无需重新登录)
|
||||||
|
Set<String> tokens = redisService.sMembers(RedisUtil.userSessionsKey(userId));
|
||||||
|
tokens.forEach(token -> redisService.hPut(RedisUtil.tokenKey(token), "role", newRole));
|
||||||
|
|
||||||
|
// 3. 删除权限缓存(如 Shiro 缓存存在)
|
||||||
|
redisService.delete(RedisUtil.userPermKey(userId));
|
||||||
|
|
||||||
|
log.info("用户角色已变更: userId={}, newRole={}, 更新 {} 个活跃 Token", userId, newRole, tokens.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 变更状态 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变更用户状态(启用/禁用)。
|
||||||
|
*
|
||||||
|
* 禁用时:DB 写入后立即删除该用户所有活跃 Token,现有会话立即失效。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateStatus(Long userId, String newStatus, TokenPrincipal principal) {
|
||||||
|
getExistingUser(userId, principal.getCompanyId());
|
||||||
|
|
||||||
|
if (!"ACTIVE".equals(newStatus) && !"DISABLED".equals(newStatus)) {
|
||||||
|
throw new BusinessException("INVALID_STATUS",
|
||||||
|
"状态值不合法,应为 ACTIVE 或 DISABLED", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 写入
|
||||||
|
userMapper.update(null, new LambdaUpdateWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getId, userId)
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId())
|
||||||
|
.set(SysUser::getStatus, newStatus));
|
||||||
|
|
||||||
|
// 禁用时:删除所有活跃 Token(立即失效)
|
||||||
|
if ("DISABLED".equals(newStatus)) {
|
||||||
|
Set<String> tokens = redisService.sMembers(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(RedisUtil.userPermKey(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 查询 --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询当前公司用户列表。
|
||||||
|
*/
|
||||||
|
public PageResult<SysUser> listUsers(int page, int pageSize, TokenPrincipal principal) {
|
||||||
|
pageSize = Math.min(pageSize, 100);
|
||||||
|
Page<SysUser> result = userMapper.selectPage(
|
||||||
|
new Page<>(page, pageSize),
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
|
||||||
|
.eq(SysUser::getCompanyId, principal.getCompanyId())
|
||||||
|
.orderByAsc(SysUser::getCreatedAt));
|
||||||
|
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 私有工具 --
|
||||||
|
|
||||||
|
private SysUser getExistingUser(Long userId, Long companyId) {
|
||||||
|
SysUser user = userMapper.selectById(userId);
|
||||||
|
if (user == null || !companyId.equals(user.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "用户不存在: " + userId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRole(String role) {
|
||||||
|
if (!List.of("UPLOADER", "ANNOTATOR", "REVIEWER", "ADMIN").contains(role)) {
|
||||||
|
throw new BusinessException("INVALID_ROLE",
|
||||||
|
"角色值不合法: " + role, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
268
src/main/java/com/label/service/VideoProcessService.java
Normal file
268
src/main/java/com/label/service/VideoProcessService.java
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package com.label.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.label.common.ai.AiServiceClient;
|
||||||
|
import com.label.common.exception.BusinessException;
|
||||||
|
import com.label.common.statemachine.StateValidator;
|
||||||
|
import com.label.common.statemachine.VideoSourceStatus;
|
||||||
|
import com.label.entity.SourceData;
|
||||||
|
import com.label.entity.VideoProcessJob;
|
||||||
|
import com.label.mapper.SourceDataMapper;
|
||||||
|
import com.label.mapper.VideoProcessJobMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VideoProcessService {
|
||||||
|
|
||||||
|
private final VideoProcessJobMapper jobMapper;
|
||||||
|
private final SourceDataMapper sourceDataMapper;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public VideoProcessJob createJob(Long sourceId, String jobType, String params, Long companyId) {
|
||||||
|
SourceData source = sourceDataMapper.selectById(sourceId);
|
||||||
|
if (source == null || !companyId.equals(source.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "资料不存在 " + sourceId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateJobType(jobType);
|
||||||
|
|
||||||
|
StateValidator.assertTransition(
|
||||||
|
VideoSourceStatus.TRANSITIONS,
|
||||||
|
VideoSourceStatus.valueOf(source.getStatus()),
|
||||||
|
VideoSourceStatus.PREPROCESSING
|
||||||
|
);
|
||||||
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
||||||
|
.eq(SourceData::getId, sourceId)
|
||||||
|
.set(SourceData::getStatus, "PREPROCESSING")
|
||||||
|
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
VideoProcessJob job = new VideoProcessJob();
|
||||||
|
job.setCompanyId(companyId);
|
||||||
|
job.setSourceId(sourceId);
|
||||||
|
job.setJobType(jobType);
|
||||||
|
job.setStatus("PENDING");
|
||||||
|
job.setParams(params != null ? params : "{}");
|
||||||
|
job.setRetryCount(0);
|
||||||
|
job.setMaxRetries(3);
|
||||||
|
jobMapper.insert(job);
|
||||||
|
|
||||||
|
final Long jobId = job.getId();
|
||||||
|
final String filePath = source.getFilePath();
|
||||||
|
final String finalJobType = jobType;
|
||||||
|
final String finalParams = job.getParams();
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
triggerAi(jobId, sourceId, filePath, finalJobType, finalParams);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("视频处理任务已创建: jobId={}, sourceId={}", jobId, sourceId);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void handleCallback(Long jobId, String callbackStatus, String outputPath, String errorMessage) {
|
||||||
|
VideoProcessJob job = jobMapper.selectById(jobId);
|
||||||
|
if (job == null || job.getCompanyId() == null) {
|
||||||
|
log.warn("视频处理回调时 job 不存在: jobId={}", jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("SUCCESS".equals(job.getStatus())) {
|
||||||
|
log.info("视频处理回调幂等跳过: jobId={}", jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("SUCCESS".equals(callbackStatus)) {
|
||||||
|
handleSuccess(job, outputPath);
|
||||||
|
} else {
|
||||||
|
handleFailure(job, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public VideoProcessJob reset(Long jobId, Long companyId) {
|
||||||
|
VideoProcessJob job = jobMapper.selectById(jobId);
|
||||||
|
if (job == null || !companyId.equals(job.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "视频处理任务不存在 " + jobId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"FAILED".equals(job.getStatus())) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"INVALID_TRANSITION",
|
||||||
|
"只有 FAILED 状态的任务可以重置,当前状态 " + job.getStatus(),
|
||||||
|
HttpStatus.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
||||||
|
.eq(VideoProcessJob::getId, jobId)
|
||||||
|
.set(VideoProcessJob::getStatus, "PENDING")
|
||||||
|
.set(VideoProcessJob::getRetryCount, 0)
|
||||||
|
.set(VideoProcessJob::getErrorMessage, null)
|
||||||
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
job.setStatus("PENDING");
|
||||||
|
job.setRetryCount(0);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VideoProcessJob getJob(Long jobId, Long companyId) {
|
||||||
|
VideoProcessJob job = jobMapper.selectById(jobId);
|
||||||
|
if (job == null || !companyId.equals(job.getCompanyId())) {
|
||||||
|
throw new BusinessException("NOT_FOUND", "视频处理任务不存在 " + jobId, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSuccess(VideoProcessJob job, String outputPath) {
|
||||||
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
||||||
|
.eq(VideoProcessJob::getId, job.getId())
|
||||||
|
.set(VideoProcessJob::getStatus, "SUCCESS")
|
||||||
|
.set(VideoProcessJob::getOutputPath, outputPath)
|
||||||
|
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
|
||||||
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
||||||
|
.eq(SourceData::getId, job.getSourceId())
|
||||||
|
.set(SourceData::getStatus, "PENDING")
|
||||||
|
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleFailure(VideoProcessJob job, String errorMessage) {
|
||||||
|
int newRetryCount = job.getRetryCount() + 1;
|
||||||
|
int maxRetries = job.getMaxRetries() != null ? job.getMaxRetries() : 3;
|
||||||
|
|
||||||
|
if (newRetryCount < maxRetries) {
|
||||||
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
||||||
|
.eq(VideoProcessJob::getId, job.getId())
|
||||||
|
.set(VideoProcessJob::getStatus, "RETRYING")
|
||||||
|
.set(VideoProcessJob::getRetryCount, newRetryCount)
|
||||||
|
.set(VideoProcessJob::getErrorMessage, errorMessage)
|
||||||
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
SourceData source = sourceDataMapper.selectById(job.getSourceId());
|
||||||
|
if (source != null) {
|
||||||
|
final Long jobId = job.getId();
|
||||||
|
final Long sourceId = job.getSourceId();
|
||||||
|
final String filePath = source.getFilePath();
|
||||||
|
final String jobType = job.getJobType();
|
||||||
|
final String params = job.getParams();
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
triggerAi(jobId, sourceId, filePath, jobType, params);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
||||||
|
.eq(VideoProcessJob::getId, job.getId())
|
||||||
|
.set(VideoProcessJob::getStatus, "FAILED")
|
||||||
|
.set(VideoProcessJob::getRetryCount, newRetryCount)
|
||||||
|
.set(VideoProcessJob::getErrorMessage, errorMessage)
|
||||||
|
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
|
||||||
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
||||||
|
.eq(SourceData::getId, job.getSourceId())
|
||||||
|
.set(SourceData::getStatus, "PENDING")
|
||||||
|
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void triggerAi(Long jobId, Long sourceId, String filePath, String jobType, String paramsJson) {
|
||||||
|
Map<String, Object> params = parseParams(paramsJson);
|
||||||
|
try {
|
||||||
|
if ("FRAME_EXTRACT".equals(jobType)) {
|
||||||
|
aiServiceClient.extractFrames(AiServiceClient.ExtractFramesRequest.builder()
|
||||||
|
.filePath(filePath)
|
||||||
|
.sourceId(sourceId)
|
||||||
|
.jobId(jobId)
|
||||||
|
.mode(stringParam(params, "mode", "interval"))
|
||||||
|
.frameInterval(intParam(params, "frameInterval", 30))
|
||||||
|
.build());
|
||||||
|
} else {
|
||||||
|
aiServiceClient.videoToText(AiServiceClient.VideoToTextRequest.builder()
|
||||||
|
.filePath(filePath)
|
||||||
|
.sourceId(sourceId)
|
||||||
|
.jobId(jobId)
|
||||||
|
.startSec(doubleParam(params, "startSec", 0.0))
|
||||||
|
.endSec(doubleParam(params, "endSec", 120.0))
|
||||||
|
.model(stringParam(params, "model", null))
|
||||||
|
.promptTemplate(stringParam(params, "promptTemplate", null))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
log.info("AI 视频任务已触发: jobId={}", jobId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("触发视频处理 AI 失败(jobId={}): {}", jobId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> parseParams(String paramsJson) {
|
||||||
|
if (paramsJson == null || paramsJson.isBlank()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(paramsJson, new TypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("解析视频处理参数失败,将使用默认值: {}", e.getMessage());
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stringParam(Map<String, Object> params, String key, String defaultValue) {
|
||||||
|
Object value = params.get(key);
|
||||||
|
return value == null ? defaultValue : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer intParam(Map<String, Object> params, String key, Integer defaultValue) {
|
||||||
|
Object value = params.get(key);
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
return number.intValue();
|
||||||
|
}
|
||||||
|
if (value instanceof String text && !text.isBlank()) {
|
||||||
|
return Integer.parseInt(text);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double doubleParam(Map<String, Object> params, String key, Double defaultValue) {
|
||||||
|
Object value = params.get(key);
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
return number.doubleValue();
|
||||||
|
}
|
||||||
|
if (value instanceof String text && !text.isBlank()) {
|
||||||
|
return Double.parseDouble(text);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateJobType(String jobType) {
|
||||||
|
if (!"FRAME_EXTRACT".equals(jobType) && !"VIDEO_TO_TEXT".equals(jobType)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"INVALID_JOB_TYPE",
|
||||||
|
"任务类型不合法,应为 FRAME_EXTRACT 或 VIDEO_TO_TEXT",
|
||||||
|
HttpStatus.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user