Compare commits

...

88 Commits

Author SHA1 Message Date
wh
bf0b00ed08 提取功能改为异步实现,添加ai辅助提取状态 2026-04-17 01:20:27 +08:00
wh
ccbcfd2c74 添加上传文件大小限制500M 2026-04-15 23:22:14 +08:00
wh
4708aa0f28 不追踪设计文档 2026-04-15 18:25:07 +08:00
wh
5a24ebd49b 修改yaml 2026-04-15 16:41:27 +08:00
wh
3ce2deb0a6 Merge branch 'main' of https://fun-md.com/whfh/label_backend 2026-04-15 16:39:04 +08:00
wh
13945b239e 添加前缀 2026-04-15 16:38:50 +08:00
wh
eb22998b28 修改配置文件端口 2026-04-15 16:26:09 +08:00
wh
f6ba09521a 提交swagger 对象接口补充 2026-04-15 15:28:11 +08:00
wh
73a13fd16d docs: plan swagger dto annotation rollout 2026-04-15 14:25:23 +08:00
wh
00032dd491 docs: add swagger dto annotation constraints 2026-04-15 14:18:32 +08:00
zjw
c65fdbab5b Merge branch 'main' of https://fun-md.com/whfh/label_backend
# Conflicts:
#	src/test/java/com/label/blackbox/AbstractBlackBoxTest.java
#	src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java
#	src/test/java/com/label/integration/AuthIntegrationTest.java
#	src/test/java/com/label/integration/ExportIntegrationTest.java
#	src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java
#	src/test/java/com/label/integration/MultiTenantIsolationTest.java
#	src/test/java/com/label/integration/QaApprovalIntegrationTest.java
#	src/test/java/com/label/integration/SourceIntegrationTest.java
#	src/test/java/com/label/integration/SysConfigIntegrationTest.java
#	src/test/java/com/label/integration/TaskClaimConcurrencyTest.java
#	src/test/java/com/label/integration/UserManagementIntegrationTest.java
#	src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java
#	src/test/java/com/label/unit/AuthInterceptorTest.java
2026-04-15 10:48:11 +08:00
zjw
9fd8971732 统一接口前缀 2026-04-15 10:46:57 +08:00
wh
b65b1c6ee0 Merge branch 'main' of https://fun-md.com/whfh/label_backend 2026-04-15 10:44:30 +08:00
wh
d9486a8c75 ignore文件提交 2026-04-15 10:43:34 +08:00
wh
8d9e7cb027 撤销测试用例提交 2026-04-15 10:43:12 +08:00
zjw
5d5308cf57 打包简化,dockerfile简化 2026-04-15 10:09:53 +08:00
wh
e30b288894 修改readme 2026-04-15 00:24:27 +08:00
wh
325ea3b486 修改打包部署文件 2026-04-15 00:16:25 +08:00
wh
756734db44 修改gitignore 2026-04-14 21:14:06 +08:00
wh
8ba3de17ab 停止追踪specs,docs等目录文件 2026-04-14 21:04:37 +08:00
wh
5839bc2ece 修改redis地址 2026-04-14 20:45:23 +08:00
wh
b0e2b3c81a 黑盒测试用例 2026-04-14 20:00:37 +08:00
wh
999856e110 修改相关资源路径 2026-04-14 18:36:28 +08:00
wh
a30b648d30 去掉shiro框架 2026-04-14 16:33:34 +08:00
wh
158873d5ae 项目结构类名称优化 2026-04-14 15:26:08 +08:00
wh
ceaac48051 优化现有目录结构 2026-04-14 14:59:46 +08:00
wh
c524fb08e1 refactor: complete backend directory flattening 2026-04-14 13:50:51 +08:00
wh
ba42b6f50e refactor: flatten controller packages 2026-04-14 13:47:38 +08:00
wh
ef1e4f5106 refactor: flatten service packages 2026-04-14 13:45:15 +08:00
wh
0dbb88b803 refactor: flatten dto entity and mapper packages 2026-04-14 13:39:24 +08:00
wh
3e33398dd2 Revert "refactor: flatten dto entity and mapper packages"
This reverts commit 29766ebd28.
2026-04-14 13:31:50 +08:00
wh
29766ebd28 refactor: flatten dto entity and mapper packages 2026-04-14 13:28:10 +08:00
wh
0af19cf1b5 refactor: flatten infrastructure packages 2026-04-14 13:19:39 +08:00
wh
e3c796da27 docs: add backend directory flattening design 2026-04-14 12:41:46 +08:00
wh
63ed9e6771 Merge branch 'main' of https://fun-md.com/whfh/label_backend 2026-04-14 01:10:25 +08:00
wh
7b8bf21e51 delete readme 2026-04-14 00:51:41 +08:00
wh
21f3a92f7d Merge branch 'main' of https://fun-md.com/whfh/label_backend 2026-04-14 00:50:11 +08:00
wh
3f3c355d4e test 2026-04-14 00:47:30 +08:00
29b62b6ca0 添加 readme.md 2026-04-14 00:09:41 +08:00
wh
f4a8592c92 tokenfilter 修改 2026-04-13 20:46:33 +08:00
wh
c7201b03e1 将shiro切换至jdk17 servlet api,适配springboot3 2026-04-13 20:44:42 +08:00
wh
e8235eeec5 修改shiro 兼容性问题 2026-04-13 19:58:49 +08:00
wh
5d74578aa3 Merge branch 'main' into 001-label-backend-spec 2026-04-13 18:23:15 +08:00
wh
ef8b75a03e 修改中间件地址 2026-04-13 18:22:40 +08:00
wh
7172861e67 修改用户模块 2026-04-13 17:13:29 +08:00
wh
a489e2b204 修改mybatis版本启动报错,swagger注解问题 2026-04-12 00:15:59 +08:00
wh
c3308e069d 后台添加swagger支持 2026-04-10 10:47:51 +08:00
wh
b8d9aec4ca docs(plan): 修正 pom.xml 中 includeScope=runtime(fix excludeScope 错误) 2026-04-09 19:47:40 +08:00
wh
5103dac16c fix(deploy): pom.xml maven-dependency-plugin excludeScope 改为 includeScope=runtime 2026-04-09 19:47:12 +08:00
wh
c2a254cba4 fix+refactor: 代码审查修复(11 项安全/并发缺陷)+ log.debug → log.info(21 处)
代码审查修复:
- MybatisPlusConfig: video_process_job 加入 IGNORED_TABLES(修复回调路径多租户过滤导致全部回调静默丢失)
- TokenFilter: catch(Exception) 替代 catch(NumberFormatException),防止空指针泄漏为 500
- VideoController: createJob 空指针防护 + handleCallback 共享密钥校验(X-Callback-Secret)
- VideoProcessService: handleCallback 显式校验 companyId 非空;triggerAi 失败改为 error 级日志
- ExtractionService/QaService: validateAndGetTask 显式校验 companyId(纵深防御)
- TaskClaimService: reclaim 增加原子 WHERE status='REJECTED';claim 异常时释放 Redis 锁
- TaskService: reassign 校验 targetUserId 属于同一租户
- AuthService: user:sessions:{userId} Set 设置滑动 TTL,防止 Token 无限累积
- ExportService/SourceService: RustFS + DB 非原子操作增加失败回滚清理
- SourceService: getOriginalFilename 使用 Paths.get().getFileName() 防路径遍历

日志规范:
- 11 个 Service 类 21 处 log.debug 替换为 log.info
2026-04-09 19:42:20 +08:00
wh
d231180bff feat(deploy): Dockerfile 改为多阶段构建(薄 jar + start.sh) 2026-04-09 19:39:49 +08:00
wh
3f0dee0826 feat(deploy): pom.xml 替换 fat JAR → 薄 jar + maven-dependency + maven-assembly 2026-04-09 19:39:28 +08:00
wh
8eb3c77abd feat(deploy): 添加 Assembly 描述符 distribution.xml 2026-04-09 19:36:39 +08:00
wh
b7d6cbc1e2 feat(deploy): 添加 start.sh(Docker exec / VM nohup 双模式) 2026-04-09 19:35:58 +08:00
wh
7b25064593 feat(deploy): 添加 logback.xml(INFO 级,60 MB 滚动) 2026-04-09 19:31:15 +08:00
wh
ff3b38ab2e docs(plan): 添加部署优化实施计划(deploy.md 8 条需求) 2026-04-09 19:27:17 +08:00
wh
011a731f4b docs(spec): 补充九、部署与发布章节(deploy.md 需求落地)
- TOC 添加第九章入口
- 九.1 Maven 构建:移除 fat JAR,添加 maven-jar-plugin + maven-dependency-plugin + maven-assembly-plugin
- 九.2 分发包结构:bin/etc/libs/logs 四级目录
- 九.3 start.sh:Docker 用 exec 前台、VM 用 nohup 后台
- 九.4 logback.xml:INFO 级别,60 MB 滚动,30 天保留
- 九.5 Dockerfile 更新:多阶段构建,复制 etc/ 配置并调用 start.sh
- 九.6 log.debug → log.info:11 文件 21 处,附批量替换命令
- 八 合规清单新增 #12-14:包结构、start.sh Docker 兼容、日志级别
2026-04-09 19:14:56 +08:00
wh
0fa3981a85 格式化 2026-04-09 16:46:02 +08:00
wh
a14c3f5559 feat(phase9-10): US8 视频处理与系统配置模块 + 代码审查修复
Phase 9 (US8):
- VideoProcessJob 实体 + VideoProcessJobMapper
- SysConfig 实体 + SysConfigMapper(手动多租户查询)
- VideoProcessService:createJob/handleCallback(幂等)/reset
  - T074 修复:AI 触发通过 TransactionSynchronization.afterCommit() 延迟至事务提交后
- VideoController:4 个端点,/api/video/callback 无需认证
- SysConfigService:公司专属优先 > 全局默认回退,UPSERT 仅允许已知键
- SysConfigController:GET /api/config + PUT /api/config/{key}
- TokenFilter:/api/video/callback 绕过 Token 认证
- 集成测试:VideoCallbackIdempotencyTest、SysConfigIntegrationTest

Phase 10 (代码审查与修复):
- T070 MultiTenantIsolationTest:跨公司资料/配置隔离验证
- T071 SourceController.upload():ResponseEntity<Result<T>> → Result<T> + @ResponseStatus
- T074 FinetuneService.trigger():移除 @Transactional,AI 调用在事务外执行
2026-04-09 16:18:39 +08:00
wh
f6c3b0b4c6 feat(phase8): US7 用户管理模块(角色变更立即生效、禁用即失效)
- RedisService:新增 hPut/sAdd/sRemove/sMembers Set 操作
- RedisKeyManager:新增 userSessionsKey(userId) = user:sessions:{userId}
- AuthService:login 后将 token 加入 user:sessions 集合;logout 时从集合移除
- UserService:createUser/updateUser/updateRole/updateStatus
  - updateRole:DB 写入后更新所有活跃 Token 的 role 字段(立即生效,无需重新登录)
  - updateStatus(DISABLED):删除所有活跃 Token(立即失效),清除 sessions 集合
- UserController:5 个端点全部 @RequiresRoles("ADMIN")
- 集成测试:角色变更同一 Token 立即生效;禁用后 Token 立即 401
2026-04-09 15:48:07 +08:00
wh
49666d1579 feat(phase7): US6 训练数据导出与 GLM 微调提交模块
- ExportBatch 实体 + ExportBatchMapper(updateFinetuneInfo)
- ExportService:createBatch(JSONL生成+RustFS上传+批量更新)、listSamples、listBatches
  - 双重校验:sampleIds非空(EMPTY_SAMPLES 400)、全部APPROVED(INVALID_SAMPLES 400)
- FinetuneService:trigger(提交GLM微调)、getStatus(实时查询)
  - AI调用不在@Transactional内,仅DB写入部分受事务保护
- ExportController:5个端点全部@RequiresRoles("ADMIN")
- 集成测试:权限403、空列表400、非APPROVED样本400、已审批样本查询200
2026-04-09 15:43:45 +08:00
wh
6d972511ff feat(phase6): US5 QA 问答生成阶段标注与审批模块
- QaService:getResult/updateResult/submit/approve/reject 五大方法
  - approve() 单事务内完成:training_dataset→APPROVED + task→APPROVED + source_data→APPROVED
  - reject() 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态
  - 与 ExtractionService 同款自审校验(SELF_REVIEW_FORBIDDEN 403)
- QaController:5 个端点 /api/qa/{taskId} 系列,ANNOTATOR/REVIEWER 权限分离
- 集成测试 QaApprovalIntegrationTest:
  - 审批通过验证整条流水线终态(training_dataset+source_data 均为 APPROVED)
  - 驳回验证候选记录清除 + 重领再提交全流程
2026-04-09 15:39:28 +08:00
wh
927e4f1cf3 feat(phase5): US3+US4 任务领取、提取标注与审批模块
- 任务领取(TaskClaimService):Redis SET NX + DB WHERE status=UNCLAIMED 双重并发防护
- 任务管理(TaskService/TaskController):任务池/我的任务/待审批/全部任务/创建/指派 10 端点
- 提取标注(ExtractionService/ExtractionController):AI 预标注/更新/提交/审批/驳回 5 端点
- 审批解耦(ExtractionApprovedEventListener):@TransactionalEventListener(AFTER_COMMIT) + REQUIRES_NEW
  确保 AI QA 生成在审批事务提交后独立执行,异常不回滚审批结果
- 状态实体:AnnotationTask/AnnotationTaskHistory/AnnotationResult/TrainingDataset
- 集成测试:并发领取安全(10 线程恰好 1 成功)+ 审批流(通过/自审/驳回重领)
2026-04-09 15:36:11 +08:00
wh
7f12fc520a Phase 4 完成:US2 原始资料上传(SourceData / SourceService / SourceController)
新增:
- SourceData 实体 + SourceDataMapper(含 updateStatus 方法)
- SourceResponse DTO(上传/列表/详情复用)
- SourceService(upload/list/findById/delete,upload 先 INSERT 获取 ID
  再构造 RustFS 路径,delete 仅允许 PENDING 状态)
- SourceController(POST /api/source/upload 返回 201,GET /list,
  GET /{id},DELETE /{id};@RequiresRoles 声明权限)
- SourceIntegrationTest(权限校验、空列表、删除不存在资料、
  已进入流水线资料删除返回 409)
- application.yml 添加 token.ttl-seconds 配置项
2026-04-09 15:21:32 +08:00
wh
a28fecd16a Phase 2/3 完成:修复 Shiro javax/jakarta 兼容性,实现 US1 认证模块
修复:
- TokenFilter 改继承 OncePerRequestFilter(jakarta.servlet),
  移除 PathMatchingFilter(javax.servlet)依赖,解决 Lombok 级联失败
- ShiroConfig 用 FilterRegistrationBean 替代 ShiroFilterFactoryBean,
  避免 javax/jakarta Filter 类型不兼容;securityManager 调用
  SecurityUtils.setSecurityManager() 确保 @RequiresRoles AOP 可用
- LabelBackendApplication 排除 ShiroWeb 自动配置(WebAutoConfiguration、
  WebFilterConfiguration、WebMvcAutoConfiguration)
- SysUserMapper @InterceptorIgnore 修正为 mybatis-plus 包路径

新增(Phase 2 尾声):
- SysCompany / SysCompanyMapper
- SysUser / SysUserMapper
- ShiroFilterIntegrationTest(无 Token→401、过期→401、角色不足→403、满足→200)

新增(Phase 3 / US1):
- LoginRequest / LoginResponse / UserInfoResponse DTO
- AuthService(login + logout + me;BCrypt 校验;Redis Hash 存 Token)
- AuthController(POST /api/auth/login、POST /logout、GET /me)
- AuthIntegrationTest(正确密码→token、错误密码→401、退出后→401)
2026-04-09 15:16:49 +08:00
wh
b5f35a7414 Merge branch '001-label-backend-spec' 2026-04-09 14:09:02 +08:00
wh
4a002bd84e 提交gitignore 2026-04-09 13:57:25 +08:00
wh
0cd99aa22c On branch 001-label-backend-spec
Changes to be committed:
	new file:   src/main/java/com/label/common/shiro/BearerToken.java
	new file:   src/main/java/com/label/common/shiro/ShiroConfig.java
	new file:   src/main/java/com/label/common/shiro/TokenFilter.java
	new file:   src/main/java/com/label/common/shiro/TokenPrincipal.java
	new file:   src/main/java/com/label/common/shiro/UserRealm.java
	modified:   src/main/java/com/label/common/statemachine/DatasetStatus.java
	new file:   src/test/java/com/label/AbstractIntegrationTest.java
	new file:   src/test/java/com/label/unit/StateMachineTest.java
	new file:   src/test/resources/db/init.sql
2026-04-09 13:54:35 +08:00
wh
556f7b9672 feat(common): 添加 MybatisPlusConfig/StateValidator,修复 jsqlparser 依赖 (T010/T011)
- MybatisPlusConfig: TenantLineInnerInterceptor + PaginationInnerInterceptor
- StateValidator: 通用状态机校验,失败抛出 INVALID_STATE_TRANSITION
- pom.xml: 新增 mybatis-plus-jsqlparser 3.5.9(3.5.7+ 必须显式引入)
2026-04-09 13:31:14 +08:00
wh
8fb730d281 feat(common): 添加 @OperationLog 注解和 AuditAspect (T016/T017) 2026-04-09 13:28:38 +08:00
wh
3d1790ad64 feat(common): 添加 RedisKeyManager/RedisService/RedisConfig (T009) 2026-04-09 13:27:47 +08:00
wh
42fb748949 chore: 添加 .gitignore 和 tasks.md 到版本控制 2026-04-09 13:25:50 +08:00
wh
52d5dd9c24 feat(common): 添加 BusinessException/GlobalExceptionHandler/CompanyContext/状态枚举 (T007/T008/T012-T015) 2026-04-09 13:21:06 +08:00
wh
ae55e87e2c fix(init): 更新真实 BCrypt 哈希值,添加 actuator 依赖,修复健康检查 (T003/T004 followup) 2026-04-09 13:20:07 +08:00
wh
94cb27e95f feat(common): 添加 RustFsClient 和 AiServiceClient (T018/T019) 2026-04-09 13:16:53 +08:00
wh
0e2b1e291b feat(common): 添加统一响应格式 Result/ResultCode/PageResult (T006) 2026-04-09 13:16:31 +08:00
wh
3da0e49b38 feat(init): 添加 application.yml 配置文件 (T005) 2026-04-09 13:12:58 +08:00
wh
600a8b8669 feat(init): 配置全量依赖 (T002) 2026-04-09 13:10:33 +08:00
wh
672fe888c9 feat(db): 创建全部 11 张表 DDL 及初始数据 (T003) 2026-04-09 13:09:30 +08:00
wh
bc33194b6e feat(infra): 添加 Docker Compose 配置和后端 Dockerfile (T004) 2026-04-09 13:08:49 +08:00
wh
fba3701cb9 fix(init): 修复 pom.xml 冗余编译器属性,测试类加 webEnvironment=NONE (T001) 2026-04-09 13:05:47 +08:00
wh
3b99b1d8c3 feat(init): 创建 Maven 项目骨架 (T001) 2026-04-09 13:00:30 +08:00
wh
4054a1133b feat(plan): 生成 label_backend 完整实施规划文档
Phase 0:research.md(10项技术决策,无需澄清项)
Phase 1:data-model.md(11张表+Redis结构),contracts/(8个模块API契约),quickstart.md(Docker Compose启动+流水线验证)
plan.md:宪章11条全部通过,项目结构确认
2026-04-09 12:27:16 +08:00
wh
0891ae188d feat(spec): 新增 label_backend 需求规格说明文档
包含 8 个用户故事、35 条功能性需求、9 条可度量成功标准
涵盖认证、多租户隔离、双标注流水线、并发任务领取、异步视频处理等核心场景
2026-04-09 12:11:10 +08:00
wh
ba3b7389f0 docs: 添加文档目录及各章节返回目录链接 2026-04-09 11:47:31 +08:00
wh
badffd8bca docs: 添加文档目录及各章节返回目录链接 2026-04-09 11:43:10 +08:00
wh
6e0677e06a docs: 数据库表设计完善性专项评审(第三轮)
新增 §9.5 评审,10 项问题(N–W):
- N: sys_config 全局唯一约束修复(NULL != NULL 问题,改为两个局部唯一索引)
- O: annotation_result 新增 UNIQUE(task_id)
- P: training_dataset.export_batch_id 改为 BIGINT FK
- Q: 全部枚举字段添加 CHECK 约束(role/status/phase/task_type)
- R: annotation_task_history 补充 operator_name 快照字段
- S: annotation_task 新增 (company_id, source_id) 索引
- T: training_dataset 新增 task_id 索引
- U: sys_user 补充 created_by 字段
- V: source_data 补充 mime_type 字段
- W: 新增 set_updated_at() 触发器,覆盖全部有 updated_at 的表

附:DDL 修复补丁(ALTER TABLE + 触发器),可直接在开发库执行
2026-04-09 11:39:19 +08:00
wh
e382995718 docs: 审批流程合理性专项评审(第二轮)
- 新增 §9.4 审批流程合理性专项评审,5 项问题(I–M)
- 新增 GET /api/tasks/pending-review(REVIEWER 审批收件箱)
- 新增 POST /api/tasks/{id}/reclaim(REJECTED 任务重拾)
- GET /api/tasks/mine 说明补充:包含 REJECTED 状态
- ExtractionService.approve() 重构为两阶段:同步审批 + 异步 AI 调用(发布 ExtractionApprovedEvent)
- 修复 QaService.approve() 重复变量声明(编译错误)
- 修复 SourceStatus 状态机:移除不可达的 QA_REVIEW → REJECTED 转换
2026-04-09 11:34:31 +08:00
104 changed files with 7376 additions and 1 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.claude
specs
docs
target
*.md
.gitignore

37
.gitignore vendored Normal file
View 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
View 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
View File

@@ -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`
- 业务规则优先放在 ServiceController 只负责 HTTP 协议层
- 新增接口需同步补齐 Swagger 注解与测试
- 所有对外接口参数必须在 Swagger 中明确体现名称、类型和含义
- 固定结构请求体禁止继续使用匿名 `Map<String, Object>``Map<String, String>`,必须定义 DTO 并补齐 `@Schema` 字段说明
- 固定结构响应应优先使用明确 DTO或至少为 Swagger 暴露对象补齐字段级 `@Schema` 注解
- 路径参数、查询参数、请求体、分页包装和通用返回体都必须维护可读的 OpenAPI 文档说明
- 需要保持历史兼容的原始 JSON 字符串请求体可以继续使用 `String`,但必须在 Swagger `@RequestBody` 中说明完整 JSON body 的提交方式和兼容原因
- 修改 Controller 参数、请求 DTO、响应 DTO 或对外实体后,必须运行 `mvn -Dtest=OpenApiAnnotationTest test`,确保 Swagger 参数名称、类型和含义没有回退
- 目录、配置、打包方式变化后README、设计文档和部署说明必须同步更新

48
assembly/distribution.xml Normal file
View 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.sh0755 可执行) -->
<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>

View File

96
docker-compose.yml Normal file
View 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
View 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
View 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/"

View 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);
}
}

View 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 "";
}

View File

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

View File

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

View File

@@ -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);
}
}

View 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);
}
}

View 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;
}

View 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");
}
}

View File

@@ -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;
}
}

View File

@@ -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", "系统内部错误"));
}
}

View 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;
}
}

View 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;
}
}

View 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
}

View File

@@ -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
);
}
}
}

View 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
);
}

View File

@@ -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)
);
}

View 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);
}
}
}

View File

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

View File

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

View File

@@ -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;
}
}

View 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}")));
}
}

View 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;
}
}

View 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 → 需要有效 TokenAuthInterceptor 校验)
* - GET /api/auth/me → 需要有效 TokenAuthInterceptor 校验)
*/
@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;
}
}

View 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);
}
}

View 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__");
}
}

View 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__");
}
}

View 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__");
}
}

View 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);
}
}

View 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__");
}
}

View 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__");
}
}

View 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 = "statusACTIVE、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 = "roleADMIN、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__");
}
}

View 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__");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View 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 TokenUUID 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;
}

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
/** 标注结果 JSONJSONB整体覆盖 */
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View 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;
}

View 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;
}

View 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;
/** 批次唯一标识UUIDDB 默认 gen_random_uuid() */
@Schema(description = "批次 UUID", example = "550e8400-e29b-41d4-a716-446655440000")
private UUID batchUuid;
/** 本批次样本数量 */
@Schema(description = "样本数量", example = "1000")
private Integer sampleCount;
/** 导出 JSONL 的 RustFS 路径 */
@Schema(description = "数据集文件路径JSONL", example = "datasets/export/2026-04-15/batch.jsonl")
private String datasetFilePath;
/** GLM fine-tune 任务 ID提交微调后填写 */
@Schema(description = "GLM 微调任务 ID", example = "glm-job-123456")
private String glmJobId;
/** 微调任务状态NOT_STARTED / RUNNING / COMPLETED / FAILED */
@Schema(description = "微调任务状态", example = "NOT_STARTED")
private String finetuneStatus;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -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;
}

View 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;
}

View 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;
/**
* 所属公司 IDNULL = 全局默认配置;非 NULL = 租户专属配置)。
* 注意:不能用 @TableField(exist = false) 排除,必须保留以支持 company_id IS NULL 查询。
*/
@Schema(description = "所属公司 IDNULL 表示全局默认配置)", example = "1")
private Long companyId;
/** 配置键 */
@Schema(description = "配置键", example = "STORAGE_BUCKET")
private String configKey;
/** 配置值 */
@Schema(description = "配置值", example = "label-bucket")
private String configValue;
/** 配置说明 */
@Schema(description = "配置说明", example = "对象存储桶名称")
private String description;
@Schema(description = "创建时间", example = "2026-04-15T12:34:56")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-04-15T12:34:56")
private LocalDateTime updatedAt;
}

View File

@@ -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;
}

View 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;
}

View 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;
}

View 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;
}
}

View 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)));
}
}

View File

@@ -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();
}
}

View 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> {
/**
* 整体覆盖标注结果 JSONJSONB 字段)。
*
* @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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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 相关方法
}

View 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);
}

View 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> {
}

View File

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

View File

@@ -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 TokenToken 立即失效)。
*
* @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 获取 realNameToken 中未存储)
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
);
}
}

View File

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

View File

@@ -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校验全部为 APPROVEDINVALID_SAMPLES 400
* 3. 生成 JSONL每行一个 glm_format_json
* 4. 上传 RustFSbucket: 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;
}
}

View 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_COMMITAI 生成问答对 → 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;
}
}

View 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() : ""
);
}
}

View 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() 清除候选问答对deleteByTaskIdsource_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
*
* 清除候选问答对deleteByTaskIdsource_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"));
}
}

View 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();
}
}

View 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();
}
}

View 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;
}
}
}

View 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());
}
}

View 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();
}
}

View 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);
}
}
}

View 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