From 8ba3de17abf135effc05c058d0cc6b7b4575a75b Mon Sep 17 00:00:00 2001 From: wh Date: Tue, 14 Apr 2026 21:04:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=9C=E6=AD=A2=E8=BF=BD=E8=B8=AAspecs,docs?= =?UTF-8?q?=E7=AD=89=E7=9B=AE=E5=BD=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 3 - .../plans/2026-04-09-deploy-optimization.md | 517 ----- .../plans/2026-04-09-swagger-shiro-toggle.md | 1411 ------------ .../2026-04-14-auth-company-optimization.md | 66 - ...4-14-label-backend-directory-flattening.md | 528 ----- .../specs/2026-04-09-label-backend-design.md | 1943 ----------------- ...bel-backend-directory-flattening-design.md | 244 --- docs/swagger-blackbox-test-cases.md | 570 ----- .../checklists/requirements.md | 34 - .../001-label-backend-spec/contracts/auth.md | 148 -- .../contracts/config.md | 53 - .../contracts/export.md | 113 - .../contracts/extraction.md | 97 - specs/001-label-backend-spec/contracts/qa.md | 83 - .../contracts/source.md | 96 - .../001-label-backend-spec/contracts/tasks.md | 150 -- .../001-label-backend-spec/contracts/video.md | 87 - specs/001-label-backend-spec/data-model.md | 355 --- specs/001-label-backend-spec/plan.md | 137 -- specs/001-label-backend-spec/quickstart.md | 179 -- specs/001-label-backend-spec/research.md | 150 -- specs/001-label-backend-spec/spec.md | 273 --- specs/001-label-backend-spec/tasks.md | 310 --- 23 files changed, 7547 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 docs/superpowers/plans/2026-04-09-deploy-optimization.md delete mode 100644 docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md delete mode 100644 docs/superpowers/plans/2026-04-14-auth-company-optimization.md delete mode 100644 docs/superpowers/plans/2026-04-14-label-backend-directory-flattening.md delete mode 100644 docs/superpowers/specs/2026-04-09-label-backend-design.md delete mode 100644 docs/superpowers/specs/2026-04-14-label-backend-directory-flattening-design.md delete mode 100644 docs/swagger-blackbox-test-cases.md delete mode 100644 specs/001-label-backend-spec/checklists/requirements.md delete mode 100644 specs/001-label-backend-spec/contracts/auth.md delete mode 100644 specs/001-label-backend-spec/contracts/config.md delete mode 100644 specs/001-label-backend-spec/contracts/export.md delete mode 100644 specs/001-label-backend-spec/contracts/extraction.md delete mode 100644 specs/001-label-backend-spec/contracts/qa.md delete mode 100644 specs/001-label-backend-spec/contracts/source.md delete mode 100644 specs/001-label-backend-spec/contracts/tasks.md delete mode 100644 specs/001-label-backend-spec/contracts/video.md delete mode 100644 specs/001-label-backend-spec/data-model.md delete mode 100644 specs/001-label-backend-spec/plan.md delete mode 100644 specs/001-label-backend-spec/quickstart.md delete mode 100644 specs/001-label-backend-spec/research.md delete mode 100644 specs/001-label-backend-spec/spec.md delete mode 100644 specs/001-label-backend-spec/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 091f3bc..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -# language - 请始终使用简体中文与我对话,并保持回答专业、简洁。 - diff --git a/docs/superpowers/plans/2026-04-09-deploy-optimization.md b/docs/superpowers/plans/2026-04-09-deploy-optimization.md deleted file mode 100644 index 6814367..0000000 --- a/docs/superpowers/plans/2026-04-09-deploy-optimization.md +++ /dev/null @@ -1,517 +0,0 @@ -# Deploy Optimization Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 将 label_backend 从 Spring Boot fat JAR 方式改造为薄 jar + Maven Assembly 分发包,并统一日志级别为 INFO。 - -**Architecture:** 移除 `spring-boot-maven-plugin`,改用 `maven-jar-plugin`(薄 jar)+ `maven-dependency-plugin`(复制依赖到 `target/libs/`)+ `maven-assembly-plugin`(组装 zip/tar.gz)。新增 `start.sh`(Docker/VM 双模式启动)和 `logback.xml`(60 MB 滚动)。Dockerfile 改为多阶段构建,从 `target/libs/` 和 `src/main/resources/` 复制构建产物。 - -**Tech Stack:** Maven 3.9, JDK 17, Spring Boot 3.2.5, maven-assembly-plugin 3.x, logback 1.4.x (Spring Boot 管理版本) - ---- - -## 文件结构 - -| 操作 | 路径 | 说明 | -|------|------|------| -| 新建 | `src/main/resources/logback.xml` | INFO 级,60 MB 滚动日志配置 | -| 新建 | `src/main/scripts/start.sh` | Docker/VM 双模式启动脚本 | -| 新建 | `src/main/assembly/distribution.xml` | Assembly 描述符 | -| 新建 | `src/main/assembly/empty-logs/.gitkeep` | logs/ 目录占位(Assembly 引用) | -| 修改 | `pom.xml` | 替换 spring-boot-maven-plugin | -| 修改 | `Dockerfile` | 多阶段构建,复制 etc/ + libs/ | -| 批量修改 | 11 个 Service 类 | `log.debug` → `log.info`(21 处) | - ---- - -## Task 1: 创建 logback.xml - -**Files:** -- Create: `src/main/resources/logback.xml` - -- [ ] **Step 1: 创建 logback.xml 文件** - -```xml - - - - - - - - - - - ${LOG_PATTERN} - - - - - - ${LOG_PATH}/${APP_NAME}.log - - ${LOG_PATTERN} - - - ${LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log - 60MB - 30 - 3GB - - - - - - - - - -``` - -- [ ] **Step 2: 验证文件存在** - -```bash -ls src/main/resources/logback.xml -``` - -预期输出:`src/main/resources/logback.xml` - -- [ ] **Step 3: Commit** - -```bash -git add src/main/resources/logback.xml -git commit -m "feat(deploy): 添加 logback.xml(INFO 级,60 MB 滚动)" -``` - ---- - -## Task 2: 创建 start.sh 启动脚本 - -**Files:** -- Create: `src/main/scripts/start.sh` - -- [ ] **Step 1: 创建目录并写入 start.sh** - -```bash -mkdir -p src/main/scripts -``` - -文件内容 `src/main/scripts/start.sh`: - -```bash -#!/bin/bash -# label-backend 启动脚本 -# - Docker 环境(检测 /.dockerenv):exec 前台运行,保持容器进程存活 -# - 裸机 / VM:nohup 后台运行,日志追加至 logs/startup.log - -set -e - -BASEDIR=$(cd "$(dirname "$0")/.." && pwd) -LIBDIR="$BASEDIR/libs" -CONFDIR="$BASEDIR/etc" -LOGDIR="$BASEDIR/logs" - -mkdir -p "$LOGDIR" - -JVM_OPTS="${JVM_OPTS:--Xms512m -Xmx1024m}" -MAIN_CLASS="com.label.LabelBackendApplication" -JAVA_ARGS="$JVM_OPTS \ - -Dspring.config.location=file:$CONFDIR/application.yml \ - -Dlogging.config=file:$CONFDIR/logback.xml \ - -cp $LIBDIR/*" - -if [ -f /.dockerenv ]; then - # Docker 容器:exec 替换当前进程,PID=1 接管信号 - exec java $JAVA_ARGS $MAIN_CLASS -else - # 裸机 / VM:nohup 后台运行 - nohup java $JAVA_ARGS $MAIN_CLASS >> "$LOGDIR/startup.log" 2>&1 & - echo "label-backend started, PID=$!" -fi -``` - -- [ ] **Step 2: 验证文件存在** - -```bash -ls src/main/scripts/start.sh -``` - -预期输出:`src/main/scripts/start.sh` - -- [ ] **Step 3: Commit** - -```bash -git add src/main/scripts/start.sh -git commit -m "feat(deploy): 添加 start.sh(Docker exec / VM nohup 双模式)" -``` - ---- - -## Task 3: 创建 Assembly 描述符和目录占位 - -**Files:** -- Create: `src/main/assembly/distribution.xml` -- Create: `src/main/assembly/empty-logs/.gitkeep` - -- [ ] **Step 1: 创建 assembly 目录结构** - -```bash -mkdir -p src/main/assembly/empty-logs -touch src/main/assembly/empty-logs/.gitkeep -``` - -- [ ] **Step 2: 创建 distribution.xml** - -文件内容 `src/main/assembly/distribution.xml`: - -```xml - - dist - - zip - tar.gz - - true - - - - - src/main/scripts/start.sh - bin - 0755 - - - - - - - src/main/resources - etc - - application.yml - logback.xml - - - - - - ${project.build.directory}/libs - libs - - **/*.jar - - - - - - src/main/assembly/empty-logs - logs - - - - -``` - -- [ ] **Step 3: 验证文件存在** - -```bash -ls src/main/assembly/distribution.xml src/main/assembly/empty-logs/.gitkeep -``` - -预期输出:两个文件路径均显示 - -- [ ] **Step 4: Commit** - -```bash -git add src/main/assembly/ -git commit -m "feat(deploy): 添加 Assembly 描述符 distribution.xml" -``` - ---- - -## Task 4: 更新 pom.xml(替换 spring-boot-maven-plugin) - -**Files:** -- Modify: `pom.xml` - -- [ ] **Step 1: 替换 `` 段落** - -将 `pom.xml` 的 `` 段(当前仅含 spring-boot-maven-plugin)替换为: - -```xml - - - - - - org.apache.maven.plugins - maven-jar-plugin - - ${project.build.directory}/libs - - - com.label.LabelBackendApplication - false - - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - - - copy-dependencies - package - copy-dependencies - - ${project.build.directory}/libs - runtime - - - - - - - - org.apache.maven.plugins - maven-assembly-plugin - - - create-distribution - package - single - - - src/main/assembly/distribution.xml - - ${project.artifactId}-${project.version} - false - - - - - - - -``` - -即用上述内容完整替换 `pom.xml` 中现有的 `...` 块(原内容为含 spring-boot-maven-plugin 的单插件配置)。 - -- [ ] **Step 2: 验证语法并试构建** - -```bash -mvn validate -q -``` - -预期输出:无错误(只做 pom.xml 解析校验,不实际编译) - -- [ ] **Step 3: Commit** - -```bash -git add pom.xml -git commit -m "feat(deploy): pom.xml 替换 fat JAR → 薄 jar + maven-dependency + maven-assembly" -``` - ---- - -## Task 5: 更新 Dockerfile - -**Files:** -- Modify: `Dockerfile` - -- [ ] **Step 1: 替换 Dockerfile 全文** - -```dockerfile -# 构建阶段:Maven + JDK 17 编译,生成薄 jar 及依赖 -FROM maven:3.9-eclipse-temurin-17-alpine AS builder -WORKDIR /app - -# 优先复制 pom.xml 利用 Docker 层缓存(依赖不变时跳过 go-offline) -COPY pom.xml . -RUN mvn dependency:go-offline -q - -COPY src ./src -RUN mvn clean package -DskipTests -q - -# 运行阶段:仅含 JRE 的精简镜像 -FROM eclipse-temurin:17-jre-alpine -WORKDIR /app - -# 复制部署结构:bin/ libs/ etc/ -COPY --from=builder /app/src/main/scripts/start.sh bin/start.sh -COPY --from=builder /app/target/libs/ libs/ -COPY --from=builder /app/src/main/resources/application.yml etc/application.yml -COPY --from=builder /app/src/main/resources/logback.xml etc/logback.xml - -RUN mkdir -p logs && chmod +x bin/start.sh - -EXPOSE 8080 - -# start.sh 检测到 /.dockerenv 后以 exec 前台方式运行 -ENTRYPOINT ["bin/start.sh"] -``` - -- [ ] **Step 2: 验证 Dockerfile 语法** - -```bash -docker build --no-cache --dry-run . 2>/dev/null || echo "docker not available; syntax check skipped" -``` - -预期:无语法错误(或 docker 不可用时跳过) - -- [ ] **Step 3: Commit** - -```bash -git add Dockerfile -git commit -m "feat(deploy): Dockerfile 改为多阶段构建(薄 jar + start.sh)" -``` - ---- - -## Task 6: 批量替换 log.debug → log.info(11 文件,21 处) - -**Files:** -- Modify: `src/main/java/com/label/module/video/service/VideoProcessService.java` (5 处) -- Modify: `src/main/java/com/label/module/source/service/SourceService.java` (2 处) -- Modify: `src/main/java/com/label/module/user/service/UserService.java` (3 处) -- Modify: `src/main/java/com/label/module/user/service/AuthService.java` (2 处) -- Modify: `src/main/java/com/label/module/config/service/SysConfigService.java` (2 处) -- Modify: `src/main/java/com/label/module/annotation/service/ExtractionApprovedEventListener.java` (2 处) -- Modify: `src/main/java/com/label/module/task/service/TaskService.java` (1 处) -- Modify: `src/main/java/com/label/module/annotation/service/QaService.java` (1 处) -- Modify: `src/main/java/com/label/module/export/service/FinetuneService.java` (1 处) -- Modify: `src/main/java/com/label/module/task/service/TaskClaimService.java` (1 处) -- Modify: `src/main/java/com/label/module/export/service/ExportService.java` (1 处) - -- [ ] **Step 1: 批量替换** - -```bash -find src/main/java -name "*.java" \ - -exec sed -i 's/log\.debug(/log.info(/g' {} + -``` - -- [ ] **Step 2: 验证替换完整,无 log.debug 残留** - -```bash -grep -r "log\.debug" src/main/java && echo "FAIL: 仍有 log.debug 残留" || echo "PASS: 无 log.debug" -``` - -预期输出:`PASS: 无 log.debug` - -- [ ] **Step 3: 验证替换数量正确(应有 21 处 log.info 新增)** - -```bash -git diff --stat src/main/java | tail -1 -``` - -预期:显示 11 个文件,21 处改动(`21 insertions(+), 21 deletions(-)`) - -- [ ] **Step 4: Commit** - -```bash -git add src/main/java/ -git commit -m "refactor(log): log.debug 全量替换为 log.info(11 文件,21 处)" -``` - ---- - -## Task 7: 全量构建验证 - -**Files:** 只读操作,不修改文件 - -- [ ] **Step 1: 执行完整构建(跳过测试)** - -```bash -mvn clean package -DskipTests -``` - -预期:`BUILD SUCCESS`,无 ERROR 输出 - -- [ ] **Step 2: 验证 target/libs/ 目录存在且包含 jar** - -```bash -ls target/libs/*.jar | head -5 -ls target/libs/label-backend-1.0.0-SNAPSHOT.jar -``` - -预期:显示薄 jar 及多个依赖 jar - -- [ ] **Step 3: 验证分发包已生成** - -```bash -ls target/label-backend-1.0.0-SNAPSHOT.zip -ls target/label-backend-1.0.0-SNAPSHOT.tar.gz -``` - -预期:两个文件均存在 - -- [ ] **Step 4: 验证分发包内部结构** - -```bash -# 使用 unzip 检查 zip 内容(Windows Git Bash 或 Linux 均可用) -unzip -l target/label-backend-1.0.0-SNAPSHOT.zip | grep -E "bin/|etc/|libs/|logs/" -``` - -预期输出包含: -``` -label-backend-1.0.0-SNAPSHOT/bin/start.sh -label-backend-1.0.0-SNAPSHOT/etc/application.yml -label-backend-1.0.0-SNAPSHOT/etc/logback.xml -label-backend-1.0.0-SNAPSHOT/libs/label-backend-1.0.0-SNAPSHOT.jar -label-backend-1.0.0-SNAPSHOT/logs/ -``` - -- [ ] **Step 5: 验证 logback.xml 已包含在 etc/ 内** - -```bash -unzip -l target/label-backend-1.0.0-SNAPSHOT.zip | grep logback -``` - -预期:`label-backend-1.0.0-SNAPSHOT/etc/logback.xml` - -- [ ] **Step 6: Commit 构建验证记录(如有必要则修正问题后提交)** - -若 Step 1-5 全部通过,无需额外提交。若发现问题(如缺少文件、路径错误),修正对应 Task 后重新执行本 Task。 - ---- - -## 自检 - -### Spec 覆盖检查 - -| deploy.md 需求 | 对应任务 | -|---------------|---------| -| 1. 分发包结构 bin/etc/libs/logs | Task 3 (distribution.xml) + Task 7 验证 | -| 2. start.sh nohup 后台启动 | Task 2 | -| 3. logback.xml INFO + 60 MB 滚动 | Task 1 | -| 4. logback.xml + application.yml 在 etc/ | Task 3 (distribution.xml fileSets) | -| 5. maven-jar-plugin + maven-dependency-plugin | Task 4 | -| 6. Maven Assembly Plugin → zip/tar.gz | Task 3 + Task 4 | -| 7. Dockerfile 复制 etc/ + 调用 start.sh | Task 5 | -| 8. log.debug → log.info | Task 6 | - -所有 8 条需求均有对应任务。✅ - -### 类型一致性 - -- `MAIN_CLASS="com.label.LabelBackendApplication"` — 与 `src/main/java/com/label/LabelBackendApplication.java` 一致 ✅ -- `maven-jar-plugin` 输出路径 `target/libs/` — 与 Dockerfile `COPY --from=builder /app/target/libs/` 一致 ✅ -- `distribution.xml` `${project.build.directory}/libs` — 与 `maven-dependency-plugin` 的 `outputDirectory` 一致 ✅ - -## GSTACK REVIEW REPORT - -| Review | Trigger | Why | Runs | Status | Findings | -|--------|---------|-----|------|--------|----------| -| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | -| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | -| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — | -| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | - -**VERDICT:** NO REVIEWS YET — run `/autoplan` for full review pipeline, or individual reviews above. diff --git a/docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md b/docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md deleted file mode 100644 index 1cb3f13..0000000 --- a/docs/superpowers/plans/2026-04-09-swagger-shiro-toggle.md +++ /dev/null @@ -1,1411 +0,0 @@ -# Swagger + Shiro 认证开关 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 为所有 REST 接口添加 springdoc-openapi Swagger 文档注解,并在 application.yml 中提供 Shiro 认证鉴权开关以方便测试。 - -**Architecture:** 通过在 `pom.xml` 引入 `springdoc-openapi-starter-webmvc-ui`,新建 `OpenApiConfig` 配置 Bearer Token 安全方案;修改 `TokenFilter` 支持 Swagger 路径白名单和 `shiro.auth.enabled=false` 时注入 mock Principal;在所有 Controller 上添加 `@Tag`/`@Operation`,核心 DTO 添加 `@Schema`。 - -**Tech Stack:** Spring Boot 3.2.5、Apache Shiro 1.13、springdoc-openapi-starter-webmvc-ui 2.5.0、Lombok、io.swagger.v3.oas.annotations - ---- - -## 文件变更清单 - -| 操作 | 文件路径 | -|------|----------| -| 修改 | `pom.xml` | -| 修改 | `src/main/resources/application.yml` | -| 修改 | `src/main/java/com/label/common/shiro/TokenFilter.java` | -| 新建 | `src/main/java/com/label/common/config/OpenApiConfig.java` | -| 修改 | `src/main/java/com/label/module/user/dto/LoginRequest.java` | -| 修改 | `src/main/java/com/label/module/user/dto/LoginResponse.java` | -| 修改 | `src/main/java/com/label/module/user/dto/UserInfoResponse.java` | -| 修改 | `src/main/java/com/label/module/task/dto/TaskResponse.java` | -| 修改 | `src/main/java/com/label/module/source/dto/SourceResponse.java` | -| 修改 | `src/main/java/com/label/module/user/controller/AuthController.java` | -| 修改 | `src/main/java/com/label/module/user/controller/UserController.java` | -| 修改 | `src/main/java/com/label/module/source/controller/SourceController.java` | -| 修改 | `src/main/java/com/label/module/task/controller/TaskController.java` | -| 修改 | `src/main/java/com/label/module/annotation/controller/ExtractionController.java` | -| 修改 | `src/main/java/com/label/module/annotation/controller/QaController.java` | -| 修改 | `src/main/java/com/label/module/export/controller/ExportController.java` | -| 修改 | `src/main/java/com/label/module/config/controller/SysConfigController.java` | -| 修改 | `src/main/java/com/label/module/video/controller/VideoController.java` | - ---- - -## Task 1: 依赖与配置(pom.xml + application.yml) - -**Files:** -- Modify: `pom.xml` -- Modify: `src/main/resources/application.yml` - -- [ ] **Step 1: 在 pom.xml `` 末尾添加 springdoc 依赖** - -在 `` 依赖之前插入: - -```xml - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 2.5.0 - -``` - -- [ ] **Step 2: 在 application.yml 末尾追加 springdoc 和 shiro.auth 配置** - -在 `logging:` 块之前插入以下内容(保留原 `logging:` 块): - -```yaml -springdoc: - api-docs: - enabled: true - path: /v3/api-docs - swagger-ui: - enabled: true - path: /swagger-ui.html - -shiro: - auth: - enabled: true - mock-company-id: 1 - mock-user-id: 1 - mock-role: ADMIN - mock-username: mock -``` - -- [ ] **Step 3: 编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS`,无编译错误。 - -- [ ] **Step 4: Commit** - -```bash -cd label_backend -git add pom.xml src/main/resources/application.yml -git commit -m "feat: add springdoc-openapi dependency and swagger/shiro-auth config" -``` - ---- - -## Task 2: 修改 TokenFilter(Swagger 白名单 + 认证开关) - -**Files:** -- Modify: `src/main/java/com/label/common/shiro/TokenFilter.java` - -- [ ] **Step 1: 用以下完整内容替换 TokenFilter.java** - -```java -package com.label.common.shiro; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.label.common.context.CompanyContext; -import com.label.common.redis.RedisKeyManager; -import com.label.common.redis.RedisService; -import com.label.common.result.Result; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.shiro.SecurityUtils; -import org.apache.shiro.util.ThreadContext; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Map; - -/** - * JWT-style Bearer Token 过滤器。 - * 继承 Spring 的 OncePerRequestFilter(jakarta.servlet),避免与 Shiro 1.x - * 的 PathMatchingFilter(javax.servlet)产生命名空间冲突。 - * - * 过滤逻辑: - * - 跳过非 /api/ 路径、/api/auth/login、Swagger UI 路径(公开端点) - * - shiro.auth.enabled=false 时:注入 mock Principal,跳过 Redis 校验(测试模式) - * - 解析 "Authorization: Bearer {uuid}",查询 Redis Hash token:{uuid} - * - Token 存在 → 注入 CompanyContext,登录 Shiro Subject,继续请求链路 - * - Token 缺失或过期 → 直接返回 401 - * - finally 块中清除 CompanyContext 和 ThreadContext Subject,防止线程池串漏 - */ -@Slf4j -@RequiredArgsConstructor -public class TokenFilter extends OncePerRequestFilter { - - private final RedisService redisService; - private final ObjectMapper objectMapper; - - @Value("${shiro.auth.enabled:true}") - private boolean authEnabled; - - @Value("${shiro.auth.mock-company-id:1}") - private Long mockCompanyId; - - @Value("${shiro.auth.mock-user-id:1}") - private Long mockUserId; - - @Value("${shiro.auth.mock-role:ADMIN}") - private String mockRole; - - @Value("${shiro.auth.mock-username:mock}") - private String mockUsername; - - /** - * 公开端点跳过过滤: - * - 非 /api/ 前缀路径 - * - 登录接口 - * - AI 服务内部回调 - * - Swagger UI / OpenAPI 文档路径 - */ - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getServletPath(); - return !path.startsWith("/api/") - || path.equals("/api/auth/login") - || path.equals("/api/video/callback") - || path.startsWith("/swagger-ui") - || path.startsWith("/v3/api-docs"); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - try { - // 认证开关关闭时:注入固定测试上下文,跳过 Redis 校验 - if (!authEnabled) { - TokenPrincipal mockPrincipal = new TokenPrincipal( - mockUserId, mockRole, mockCompanyId, mockUsername, "mock-token"); - CompanyContext.set(mockCompanyId); - SecurityUtils.getSubject().login(new BearerToken("mock-token", mockPrincipal)); - request.setAttribute("__token_principal__", mockPrincipal); - filterChain.doFilter(request, response); - return; - } - - String authHeader = request.getHeader("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - writeUnauthorized(response, "缺少或无效的认证令牌"); - return; - } - - String token = authHeader.substring(7).trim(); - Map tokenData = redisService.hGetAll(RedisKeyManager.tokenKey(token)); - - if (tokenData == null || tokenData.isEmpty()) { - writeUnauthorized(response, "令牌已过期或不存在"); - return; - } - - Long userId = Long.parseLong(tokenData.get("userId").toString()); - String role = tokenData.get("role").toString(); - Long companyId = Long.parseLong(tokenData.get("companyId").toString()); - String username = tokenData.get("username").toString(); - - // 注入多租户上下文(finally 中清除,防止线程池串漏) - CompanyContext.set(companyId); - - // 创建 TokenPrincipal 并登录 Shiro Subject,使 @RequiresRoles 等注解生效 - TokenPrincipal principal = new TokenPrincipal(userId, role, companyId, username, token); - SecurityUtils.getSubject().login(new BearerToken(token, principal)); - request.setAttribute("__token_principal__", principal); - - filterChain.doFilter(request, response); - } catch (Exception e) { - log.error("解析 Token 数据失败: {}", e.getMessage()); - writeUnauthorized(response, "令牌数据格式错误"); - } finally { - // 关键:必须清除 ThreadLocal,防止线程池复用时数据串漏 - CompanyContext.clear(); - ThreadContext.unbindSubject(); - } - } - - private void writeUnauthorized(HttpServletResponse resp, String message) throws IOException { - resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - resp.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"); - resp.getWriter().write(objectMapper.writeValueAsString(Result.failure("UNAUTHORIZED", message))); - } -} -``` - -- [ ] **Step 2: 编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS` - -- [ ] **Step 3: Commit** - -```bash -cd label_backend -git add src/main/java/com/label/common/shiro/TokenFilter.java -git commit -m "feat: add swagger path whitelist and shiro.auth.enabled toggle to TokenFilter" -``` - ---- - -## Task 3: 新建 OpenApiConfig - -**Files:** -- Create: `src/main/java/com/label/common/config/OpenApiConfig.java` - -- [ ] **Step 1: 创建 OpenApiConfig.java** - -```java -package com.label.common.config; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * SpringDoc OpenAPI 全局配置:API 基本信息 + Bearer Token 安全方案。 - */ -@Configuration -public class OpenApiConfig { - - @Bean - public OpenAPI openAPI() { - return new OpenAPI() - .info(new Info() - .title("Label Backend API") - .version("1.0.0") - .description("知识图谱智能标注平台后端接口文档")) - .addSecurityItem(new SecurityRequirement().addList("BearerAuth")) - .components(new Components() - .addSecuritySchemes("BearerAuth", - new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("UUID") - .description("登录后返回的 Token,格式:Bearer {uuid}"))); - } -} -``` - -- [ ] **Step 2: 编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS` - -- [ ] **Step 3: Commit** - -```bash -cd label_backend -git add src/main/java/com/label/common/config/OpenApiConfig.java -git commit -m "feat: add OpenApiConfig with Bearer Token security scheme" -``` - ---- - -## Task 4: 核心 DTO 添加 @Schema 注解 - -**Files:** -- Modify: `src/main/java/com/label/module/user/dto/LoginRequest.java` -- Modify: `src/main/java/com/label/module/user/dto/LoginResponse.java` -- Modify: `src/main/java/com/label/module/user/dto/UserInfoResponse.java` -- Modify: `src/main/java/com/label/module/task/dto/TaskResponse.java` -- Modify: `src/main/java/com/label/module/source/dto/SourceResponse.java` - -- [ ] **Step 1: 替换 LoginRequest.java** - -```java -package com.label.module.user.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -/** - * 登录请求体。 - */ -@Data -@Schema(description = "登录请求") -public class LoginRequest { - @Schema(description = "公司代码(英文简写),用于确定租户", example = "acme") - private String companyCode; - @Schema(description = "登录用户名", example = "admin") - private String username; - @Schema(description = "明文密码(传输层应使用 HTTPS 保护)", example = "password123") - private String password; -} -``` - -- [ ] **Step 2: 替换 LoginResponse.java** - -```java -package com.label.module.user.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; - -/** - * 登录成功响应体。 - */ -@Data -@AllArgsConstructor -@Schema(description = "登录响应") -public class LoginResponse { - @Schema(description = "Bearer Token(UUID v4),后续请求放入 Authorization 头") - private String token; - @Schema(description = "用户主键") - private Long userId; - @Schema(description = "登录用户名") - private String username; - @Schema(description = "角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN") - private String role; - @Schema(description = "Token 有效期(秒)") - private Long expiresIn; -} -``` - -- [ ] **Step 3: 替换 UserInfoResponse.java** - -```java -package com.label.module.user.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; - -/** - * GET /api/auth/me 响应体,包含当前登录用户的详细信息。 - */ -@Data -@AllArgsConstructor -@Schema(description = "当前登录用户信息") -public class UserInfoResponse { - @Schema(description = "用户主键") - private Long id; - @Schema(description = "用户名") - private String username; - @Schema(description = "真实姓名") - private String realName; - @Schema(description = "角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN") - private String role; - @Schema(description = "所属公司 ID") - private Long companyId; - @Schema(description = "所属公司名称") - private String companyName; -} -``` - -- [ ] **Step 4: 替换 TaskResponse.java** - -```java -package com.label.module.task.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 任务接口统一响应体(任务池、我的任务、任务详情均复用)。 - */ -@Data -@Builder -@Schema(description = "标注任务响应") -public class TaskResponse { - @Schema(description = "任务主键") - private Long id; - @Schema(description = "关联资料 ID") - private Long sourceId; - @Schema(description = "任务阶段:EXTRACTION / QA_GENERATION") - private String taskType; - @Schema(description = "任务状态:UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED") - private String status; - @Schema(description = "领取人用户 ID") - private Long claimedBy; - @Schema(description = "领取时间") - private LocalDateTime claimedAt; - @Schema(description = "提交时间") - private LocalDateTime submittedAt; - @Schema(description = "完成时间") - private LocalDateTime completedAt; - @Schema(description = "驳回原因(REJECTED 状态时非空)") - private String rejectReason; - @Schema(description = "创建时间") - private LocalDateTime createdAt; -} -``` - -- [ ] **Step 5: 替换 SourceResponse.java** - -```java -package com.label.module.source.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 资料接口统一响应体(上传、列表、详情均复用此类)。 - * 各端点按需填充字段,未填充字段序列化时因 jackson non_null 配置自动省略。 - */ -@Data -@Builder -@Schema(description = "原始资料响应") -public class SourceResponse { - @Schema(description = "资料主键") - private Long id; - @Schema(description = "原始文件名") - private String fileName; - @Schema(description = "资料类型:TEXT / IMAGE / VIDEO") - private String dataType; - @Schema(description = "文件大小(字节)") - private Long fileSize; - @Schema(description = "状态:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED") - private String status; - @Schema(description = "上传用户 ID(列表端点返回)") - private Long uploaderId; - @Schema(description = "15 分钟预签名下载链接(详情端点返回)") - private String presignedUrl; - @Schema(description = "父资料 ID(视频帧/文本片段;详情端点返回)") - private Long parentSourceId; - @Schema(description = "创建时间") - private LocalDateTime createdAt; -} -``` - -- [ ] **Step 6: 编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS` - -- [ ] **Step 7: Commit** - -```bash -cd label_backend -git add src/main/java/com/label/module/user/dto/ \ - src/main/java/com/label/module/task/dto/ \ - src/main/java/com/label/module/source/dto/ -git commit -m "feat: add @Schema annotations to core DTOs" -``` - ---- - -## Task 5: AuthController + UserController 添加 Swagger 注解 - -**Files:** -- Modify: `src/main/java/com/label/module/user/controller/AuthController.java` -- Modify: `src/main/java/com/label/module/user/controller/UserController.java` - -- [ ] **Step 1: 替换 AuthController.java** - -```java -package com.label.module.user.controller; - -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.user.dto.LoginRequest; -import com.label.module.user.dto.LoginResponse; -import com.label.module.user.dto.UserInfoResponse; -import com.label.module.user.service.AuthService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -/** - * 认证接口:登录、退出、获取当前用户。 - */ -@Tag(name = "认证管理", description = "登录、退出、获取当前用户信息") -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -public class AuthController { - - private final AuthService authService; - - @Operation(summary = "用户登录,返回 Bearer Token") - @PostMapping("/login") - public Result login(@RequestBody LoginRequest request) { - return Result.success(authService.login(request)); - } - - @Operation(summary = "退出登录,立即删除 Redis Token") - @PostMapping("/logout") - public Result logout(HttpServletRequest request) { - String token = extractToken(request); - authService.logout(token); - return Result.success(null); - } - - @Operation(summary = "获取当前登录用户信息") - @GetMapping("/me") - public Result me(HttpServletRequest request) { - TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); - return Result.success(authService.me(principal)); - } - - private String extractToken(HttpServletRequest request) { - String authHeader = request.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - return authHeader.substring(7).trim(); - } - return null; - } -} -``` - -- [ ] **Step 2: 替换 UserController.java** - -```java -package com.label.module.user.controller; - -import com.label.common.result.PageResult; -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.user.entity.SysUser; -import com.label.module.user.service.UserService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -/** - * 用户管理接口(5 个端点,全部 ADMIN 权限)。 - */ -@Tag(name = "用户管理", description = "用户 CRUD、状态与角色变更(ADMIN 专属)") -@RestController -@RequestMapping("/api/users") -@RequiredArgsConstructor -public class UserController { - - private final UserService userService; - - @Operation(summary = "分页查询用户列表") - @GetMapping - @RequiresRoles("ADMIN") - public Result> listUsers( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - HttpServletRequest request) { - return Result.success(userService.listUsers(page, pageSize, principal(request))); - } - - @Operation(summary = "创建用户") - @PostMapping - @RequiresRoles("ADMIN") - public Result createUser(@RequestBody Map body, - HttpServletRequest request) { - return Result.success(userService.createUser( - body.get("username"), - body.get("password"), - body.get("realName"), - body.get("role"), - principal(request))); - } - - @Operation(summary = "更新用户基本信息(realName / password)") - @PutMapping("/{id}") - @RequiresRoles("ADMIN") - public Result updateUser(@PathVariable Long id, - @RequestBody Map body, - HttpServletRequest request) { - return Result.success(userService.updateUser( - id, - body.get("realName"), - body.get("password"), - principal(request))); - } - - @Operation(summary = "变更用户状态(ACTIVE / DISABLED)") - @PutMapping("/{id}/status") - @RequiresRoles("ADMIN") - public Result updateStatus(@PathVariable Long id, - @RequestBody Map body, - HttpServletRequest request) { - userService.updateStatus(id, body.get("status"), principal(request)); - return Result.success(null); - } - - @Operation(summary = "变更用户角色,立即驱逐权限缓存") - @PutMapping("/{id}/role") - @RequiresRoles("ADMIN") - public Result updateRole(@PathVariable Long id, - @RequestBody Map body, - HttpServletRequest request) { - userService.updateRole(id, body.get("role"), principal(request)); - return Result.success(null); - } - - private TokenPrincipal principal(HttpServletRequest request) { - return (TokenPrincipal) request.getAttribute("__token_principal__"); - } -} -``` - -- [ ] **Step 3: 编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS` - -- [ ] **Step 4: Commit** - -```bash -cd label_backend -git add src/main/java/com/label/module/user/controller/ -git commit -m "feat: add @Tag/@Operation to AuthController and UserController" -``` - ---- - -## Task 6: SourceController + TaskController 添加 Swagger 注解 - -**Files:** -- Modify: `src/main/java/com/label/module/source/controller/SourceController.java` -- Modify: `src/main/java/com/label/module/task/controller/TaskController.java` - -- [ ] **Step 1: 替换 SourceController.java** - -```java -package com.label.module.source.controller; - -import com.label.common.result.PageResult; -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.source.dto.SourceResponse; -import com.label.module.source.service.SourceService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -/** - * 原始资料管理接口。 - */ -@Tag(name = "资料管理", description = "文件上传至 RustFS、资料元数据管理") -@RestController -@RequestMapping("/api/source") -@RequiredArgsConstructor -public class SourceController { - - private final SourceService sourceService; - - @Operation(summary = "上传文件(multipart/form-data),返回资料摘要") - @PostMapping("/upload") - @RequiresRoles("UPLOADER") - @ResponseStatus(HttpStatus.CREATED) - public Result upload( - @RequestParam("file") MultipartFile file, - @RequestParam("dataType") String dataType, - HttpServletRequest request) { - TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); - return Result.success(sourceService.upload(file, dataType, principal)); - } - - @Operation(summary = "分页查询资料列表(UPLOADER 只见自己,ADMIN 见全部)") - @GetMapping("/list") - @RequiresRoles("UPLOADER") - public Result> list( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - @RequestParam(required = false) String dataType, - @RequestParam(required = false) String status, - HttpServletRequest request) { - TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); - return Result.success(sourceService.list(page, pageSize, dataType, status, principal)); - } - - @Operation(summary = "查询资料详情(含 15 分钟预签名下载链接)") - @GetMapping("/{id}") - @RequiresRoles("UPLOADER") - public Result findById(@PathVariable Long id) { - return Result.success(sourceService.findById(id)); - } - - @Operation(summary = "删除资料(仅 PENDING 状态可删),同步删除 RustFS 文件") - @DeleteMapping("/{id}") - @RequiresRoles("ADMIN") - public Result delete(@PathVariable Long id, HttpServletRequest request) { - TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); - sourceService.delete(id, principal.getCompanyId()); - return Result.success(null); - } -} -``` - -- [ ] **Step 2: 替换 TaskController.java** - -```java -package com.label.module.task.controller; - -import com.label.common.result.PageResult; -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.task.dto.TaskResponse; -import com.label.module.task.service.TaskClaimService; -import com.label.module.task.service.TaskService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -/** - * 任务管理接口(10 个端点)。 - */ -@Tag(name = "任务管理", description = "任务创建、领取、放弃、审批、强制转移") -@RestController -@RequestMapping("/api/tasks") -@RequiredArgsConstructor -public class TaskController { - - private final TaskService taskService; - private final TaskClaimService taskClaimService; - - @Operation(summary = "查询可领取任务池(UNCLAIMED,ANNOTATOR 看 EXTRACTION,REVIEWER 看 SUBMITTED)") - @GetMapping("/pool") - @RequiresRoles("ANNOTATOR") - public Result> getPool( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - HttpServletRequest request) { - return Result.success(taskService.getPool(page, pageSize, principal(request))); - } - - @Operation(summary = "查询我的任务(IN_PROGRESS / SUBMITTED / REJECTED)") - @GetMapping("/mine") - @RequiresRoles("ANNOTATOR") - public Result> getMine( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - @RequestParam(required = false) String status, - HttpServletRequest request) { - return Result.success(taskService.getMine(page, pageSize, status, principal(request))); - } - - @Operation(summary = "查询待审批队列(REVIEWER 专属,status=SUBMITTED)") - @GetMapping("/pending-review") - @RequiresRoles("REVIEWER") - public Result> getPendingReview( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - @RequestParam(required = false) String taskType) { - return Result.success(taskService.getPendingReview(page, pageSize, taskType)); - } - - @Operation(summary = "查询全部任务(ADMIN,支持状态/类型过滤)") - @GetMapping - @RequiresRoles("ADMIN") - public Result> getAll( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - @RequestParam(required = false) String status, - @RequestParam(required = false) String taskType) { - return Result.success(taskService.getAll(page, pageSize, status, taskType)); - } - - @Operation(summary = "为指定资料创建标注任务(ADMIN)") - @PostMapping - @RequiresRoles("ADMIN") - public Result createTask(@RequestBody Map body, - HttpServletRequest request) { - Long sourceId = Long.parseLong(body.get("sourceId").toString()); - String taskType = body.get("taskType").toString(); - TokenPrincipal principal = principal(request); - return Result.success(taskService.toPublicResponse( - taskService.createTask(sourceId, taskType, principal.getCompanyId()))); - } - - @Operation(summary = "查询任务详情") - @GetMapping("/{id}") - @RequiresRoles("ANNOTATOR") - public Result getById(@PathVariable Long id) { - return Result.success(taskService.toPublicResponse(taskService.getById(id))); - } - - @Operation(summary = "领取任务(Redis SET NX + DB 乐观锁双重保障)") - @PostMapping("/{id}/claim") - @RequiresRoles("ANNOTATOR") - public Result claim(@PathVariable Long id, HttpServletRequest request) { - taskClaimService.claim(id, principal(request)); - return Result.success(null); - } - - @Operation(summary = "放弃任务,退回任务池") - @PostMapping("/{id}/unclaim") - @RequiresRoles("ANNOTATOR") - public Result unclaim(@PathVariable Long id, HttpServletRequest request) { - taskClaimService.unclaim(id, principal(request)); - return Result.success(null); - } - - @Operation(summary = "重领被驳回的任务(task.status=REJECTED 且 claimedBy=当前用户)") - @PostMapping("/{id}/reclaim") - @RequiresRoles("ANNOTATOR") - public Result reclaim(@PathVariable Long id, HttpServletRequest request) { - taskClaimService.reclaim(id, principal(request)); - return Result.success(null); - } - - @Operation(summary = "ADMIN 强制转移任务归属") - @PutMapping("/{id}/reassign") - @RequiresRoles("ADMIN") - public Result reassign(@PathVariable Long id, - @RequestBody Map body, - HttpServletRequest request) { - Long targetUserId = Long.parseLong(body.get("userId").toString()); - taskService.reassign(id, targetUserId, principal(request)); - return Result.success(null); - } - - private TokenPrincipal principal(HttpServletRequest request) { - return (TokenPrincipal) request.getAttribute("__token_principal__"); - } -} -``` - -- [ ] **Step 3: 编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS` - -- [ ] **Step 4: Commit** - -```bash -cd label_backend -git add src/main/java/com/label/module/source/controller/ \ - src/main/java/com/label/module/task/controller/ -git commit -m "feat: add @Tag/@Operation to SourceController and TaskController" -``` - ---- - -## Task 7: ExtractionController + QaController 添加 Swagger 注解 - -**Files:** -- Modify: `src/main/java/com/label/module/annotation/controller/ExtractionController.java` -- Modify: `src/main/java/com/label/module/annotation/controller/QaController.java` - -- [ ] **Step 1: 替换 ExtractionController.java** - -```java -package com.label.module.annotation.controller; - -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.annotation.service.ExtractionService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -/** - * 提取阶段标注工作台接口(5 个端点)。 - */ -@Tag(name = "标注工作台", description = "EXTRACTION 阶段:AI 预标注、结果编辑、提交、审批") -@RestController -@RequestMapping("/api/extraction") -@RequiredArgsConstructor -public class ExtractionController { - - private final ExtractionService extractionService; - - @Operation(summary = "获取当前提取结果(含 AI 预标注数据)") - @GetMapping("/{taskId}") - @RequiresRoles("ANNOTATOR") - public Result> getResult(@PathVariable Long taskId, - HttpServletRequest request) { - return Result.success(extractionService.getResult(taskId, principal(request))); - } - - @Operation(summary = "更新提取结果(整体 JSONB 覆盖,PUT 语义)") - @PutMapping("/{taskId}") - @RequiresRoles("ANNOTATOR") - public Result updateResult(@PathVariable Long taskId, - @RequestBody String resultJson, - HttpServletRequest request) { - extractionService.updateResult(taskId, resultJson, principal(request)); - return Result.success(null); - } - - @Operation(summary = "提交提取结果,进入审批队列") - @PostMapping("/{taskId}/submit") - @RequiresRoles("ANNOTATOR") - public Result submit(@PathVariable Long taskId, - HttpServletRequest request) { - extractionService.submit(taskId, principal(request)); - return Result.success(null); - } - - @Operation(summary = "审批通过(REVIEWER),自动触发 QA 任务创建") - @PostMapping("/{taskId}/approve") - @RequiresRoles("REVIEWER") - public Result approve(@PathVariable Long taskId, - HttpServletRequest request) { - extractionService.approve(taskId, principal(request)); - return Result.success(null); - } - - @Operation(summary = "驳回提取结果(REVIEWER),标注员可重领后修改") - @PostMapping("/{taskId}/reject") - @RequiresRoles("REVIEWER") - public Result reject(@PathVariable Long taskId, - @RequestBody Map body, - HttpServletRequest request) { - String reason = body != null ? body.get("reason") : null; - extractionService.reject(taskId, reason, principal(request)); - return Result.success(null); - } - - private TokenPrincipal principal(HttpServletRequest request) { - return (TokenPrincipal) request.getAttribute("__token_principal__"); - } -} -``` - -- [ ] **Step 2: 替换 QaController.java** - -```java -package com.label.module.annotation.controller; - -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.annotation.service.QaService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -/** - * 问答生成阶段标注工作台接口(5 个端点)。 - */ -@Tag(name = "问答生成", description = "QA_GENERATION 阶段:查看候选问答对、修改、提交、审批") -@RestController -@RequestMapping("/api/qa") -@RequiredArgsConstructor -public class QaController { - - private final QaService qaService; - - @Operation(summary = "获取候选问答对列表") - @GetMapping("/{taskId}") - @RequiresRoles("ANNOTATOR") - public Result> getResult(@PathVariable Long taskId, - HttpServletRequest request) { - return Result.success(qaService.getResult(taskId, principal(request))); - } - - @Operation(summary = "修改问答对(整体覆盖,PUT 语义)") - @PutMapping("/{taskId}") - @RequiresRoles("ANNOTATOR") - public Result updateResult(@PathVariable Long taskId, - @RequestBody String body, - HttpServletRequest request) { - qaService.updateResult(taskId, body, principal(request)); - return Result.success(null); - } - - @Operation(summary = "提交问答对,进入审批队列") - @PostMapping("/{taskId}/submit") - @RequiresRoles("ANNOTATOR") - public Result submit(@PathVariable Long taskId, - HttpServletRequest request) { - qaService.submit(taskId, principal(request)); - return Result.success(null); - } - - @Operation(summary = "审批通过(REVIEWER),写入 training_dataset,流水线完成") - @PostMapping("/{taskId}/approve") - @RequiresRoles("REVIEWER") - public Result approve(@PathVariable Long taskId, - HttpServletRequest request) { - qaService.approve(taskId, principal(request)); - return Result.success(null); - } - - @Operation(summary = "驳回问答对(REVIEWER),删除候选记录,标注员重新生成") - @PostMapping("/{taskId}/reject") - @RequiresRoles("REVIEWER") - public Result reject(@PathVariable Long taskId, - @RequestBody Map body, - HttpServletRequest request) { - String reason = body != null ? body.get("reason") : null; - qaService.reject(taskId, reason, principal(request)); - return Result.success(null); - } - - private TokenPrincipal principal(HttpServletRequest request) { - return (TokenPrincipal) request.getAttribute("__token_principal__"); - } -} -``` - -- [ ] **Step 3: 编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS` - -- [ ] **Step 4: Commit** - -```bash -cd label_backend -git add src/main/java/com/label/module/annotation/controller/ -git commit -m "feat: add @Tag/@Operation to ExtractionController and QaController" -``` - ---- - -## Task 8: ExportController + SysConfigController + VideoController 添加 Swagger 注解 - -**Files:** -- Modify: `src/main/java/com/label/module/export/controller/ExportController.java` -- Modify: `src/main/java/com/label/module/config/controller/SysConfigController.java` -- Modify: `src/main/java/com/label/module/video/controller/VideoController.java` - -- [ ] **Step 1: 替换 ExportController.java** - -```java -package com.label.module.export.controller; - -import com.label.common.result.PageResult; -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.annotation.entity.TrainingDataset; -import com.label.module.export.entity.ExportBatch; -import com.label.module.export.service.ExportService; -import com.label.module.export.service.FinetuneService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; - -/** - * 训练数据导出与微调接口(5 个端点,全部 ADMIN 权限)。 - */ -@Tag(name = "导出管理", description = "训练样本查询、JSONL 批次导出、GLM 微调任务管理") -@RestController -@RequiredArgsConstructor -public class ExportController { - - private final ExportService exportService; - private final FinetuneService finetuneService; - - @Operation(summary = "分页查询已审批可导出的训练样本") - @GetMapping("/api/training/samples") - @RequiresRoles("ADMIN") - public Result> listSamples( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - @RequestParam(required = false) String sampleType, - @RequestParam(required = false) Boolean exported, - HttpServletRequest request) { - return Result.success(exportService.listSamples(page, pageSize, sampleType, exported, principal(request))); - } - - @Operation(summary = "创建导出批次,合并样本为 JSONL 并上传 RustFS") - @PostMapping("/api/export/batch") - @RequiresRoles("ADMIN") - @ResponseStatus(HttpStatus.CREATED) - public Result createBatch(@RequestBody Map body, - HttpServletRequest request) { - @SuppressWarnings("unchecked") - List rawIds = (List) body.get("sampleIds"); - List sampleIds = rawIds.stream() - .map(id -> Long.parseLong(id.toString())) - .toList(); - return Result.success(exportService.createBatch(sampleIds, principal(request))); - } - - @Operation(summary = "向 GLM 工厂提交微调任务") - @PostMapping("/api/export/{batchId}/finetune") - @RequiresRoles("ADMIN") - public Result> triggerFinetune(@PathVariable Long batchId, - HttpServletRequest request) { - return Result.success(finetuneService.trigger(batchId, principal(request))); - } - - @Operation(summary = "查询微调任务状态") - @GetMapping("/api/export/{batchId}/status") - @RequiresRoles("ADMIN") - public Result> getFinetuneStatus(@PathVariable Long batchId, - HttpServletRequest request) { - return Result.success(finetuneService.getStatus(batchId, principal(request))); - } - - @Operation(summary = "分页查询所有导出批次") - @GetMapping("/api/export/list") - @RequiresRoles("ADMIN") - public Result> listBatches( - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int pageSize, - HttpServletRequest request) { - return Result.success(exportService.listBatches(page, pageSize, principal(request))); - } - - private TokenPrincipal principal(HttpServletRequest request) { - return (TokenPrincipal) request.getAttribute("__token_principal__"); - } -} -``` - -- [ ] **Step 2: 替换 SysConfigController.java** - -```java -package com.label.module.config.controller; - -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.config.entity.SysConfig; -import com.label.module.config.service.SysConfigService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; - -/** - * 系统配置接口(2 个端点,均需 ADMIN 权限)。 - */ -@Tag(name = "系统配置", description = "Prompt 模板、模型参数等全局/公司级配置管理") -@RestController -@RequiredArgsConstructor -public class SysConfigController { - - private final SysConfigService sysConfigService; - - @Operation(summary = "查询合并后的配置列表(公司专属 + 全局默认)") - @GetMapping("/api/config") - @RequiresRoles("ADMIN") - public Result>> listConfig(HttpServletRequest request) { - TokenPrincipal principal = principal(request); - return Result.success(sysConfigService.list(principal.getCompanyId())); - } - - @Operation(summary = "UPSERT 公司专属配置项") - @PutMapping("/api/config/{key}") - @RequiresRoles("ADMIN") - public Result updateConfig(@PathVariable String key, - @RequestBody Map body, - HttpServletRequest request) { - String value = body.get("value"); - String description = body.get("description"); - TokenPrincipal principal = principal(request); - return Result.success( - sysConfigService.update(key, value, description, principal.getCompanyId())); - } - - private TokenPrincipal principal(HttpServletRequest request) { - return (TokenPrincipal) request.getAttribute("__token_principal__"); - } -} -``` - -- [ ] **Step 3: 替换 VideoController.java** - -```java -package com.label.module.video.controller; - -import com.label.common.result.Result; -import com.label.common.shiro.TokenPrincipal; -import com.label.module.video.entity.VideoProcessJob; -import com.label.module.video.service.VideoProcessService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -/** - * 视频处理接口(4 个端点)。 - */ -@Tag(name = "视频处理", description = "触发视频抽帧/转文本、查询任务状态、AI 回调接收") -@Slf4j -@RestController -@RequiredArgsConstructor -public class VideoController { - - private final VideoProcessService videoProcessService; - - @Value("${video.callback-secret:}") - private String callbackSecret; - - @Operation(summary = "触发视频处理任务(FRAME_EXTRACT 或 VIDEO_TO_TEXT)") - @PostMapping("/api/video/process") - @RequiresRoles("ADMIN") - public Result createJob(@RequestBody Map body, - HttpServletRequest request) { - Object sourceIdVal = body.get("sourceId"); - Object jobTypeVal = body.get("jobType"); - if (sourceIdVal == null || jobTypeVal == null) { - return Result.failure("INVALID_PARAMS", "sourceId 和 jobType 不能为空"); - } - Long sourceId = Long.parseLong(sourceIdVal.toString()); - String jobType = jobTypeVal.toString(); - String params = body.containsKey("params") ? body.get("params").toString() : null; - - TokenPrincipal principal = principal(request); - return Result.success( - videoProcessService.createJob(sourceId, jobType, params, principal.getCompanyId())); - } - - @Operation(summary = "查询视频处理任务状态") - @GetMapping("/api/video/jobs/{jobId}") - @RequiresRoles("ADMIN") - public Result getJob(@PathVariable Long jobId, - HttpServletRequest request) { - return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId())); - } - - @Operation(summary = "管理员重置失败的视频处理任务(FAILED → PENDING)") - @PostMapping("/api/video/jobs/{jobId}/reset") - @RequiresRoles("ADMIN") - public Result resetJob(@PathVariable Long jobId, - HttpServletRequest request) { - return Result.success(videoProcessService.reset(jobId, principal(request).getCompanyId())); - } - - @Operation(summary = "AI 服务回调接口(无需 Bearer Token,内部调用)") - @PostMapping("/api/video/callback") - public Result handleCallback(@RequestBody Map body, - HttpServletRequest request) { - if (callbackSecret != null && !callbackSecret.isBlank()) { - String provided = request.getHeader("X-Callback-Secret"); - if (!callbackSecret.equals(provided)) { - return Result.failure("UNAUTHORIZED", "回调密钥无效"); - } - } - - Long jobId = Long.parseLong(body.get("jobId").toString()); - String status = (String) body.get("status"); - String outputPath = body.containsKey("outputPath") ? (String) body.get("outputPath") : null; - String errorMessage = body.containsKey("errorMessage") ? (String) body.get("errorMessage") : null; - - log.info("视频处理回调:jobId={}, status={}", jobId, status); - videoProcessService.handleCallback(jobId, status, outputPath, errorMessage); - return Result.success(null); - } - - private TokenPrincipal principal(HttpServletRequest request) { - return (TokenPrincipal) request.getAttribute("__token_principal__"); - } -} -``` - -- [ ] **Step 4: 最终编译验证** - -```bash -cd label_backend -mvn compile -q -``` - -预期:`BUILD SUCCESS` - -- [ ] **Step 5: 最终打包验证** - -```bash -cd label_backend -mvn package -DskipTests -q -``` - -预期:`BUILD SUCCESS`,`target/` 下生成 `.zip` 和 `.tar.gz` - -- [ ] **Step 6: Commit** - -```bash -cd label_backend -git add src/main/java/com/label/module/export/controller/ \ - src/main/java/com/label/module/config/controller/ \ - src/main/java/com/label/module/video/controller/ -git commit -m "feat: add @Tag/@Operation to ExportController, SysConfigController, VideoController" -``` - ---- - -## Self-Review - -**Spec coverage 核对:** - -| 规格要求 | 对应 Task | -|----------|-----------| -| pom.xml 新增 springdoc 依赖 | Task 1 | -| application.yml 配置 springdoc | Task 1 | -| application.yml 配置 shiro.auth 开关 | Task 1 | -| TokenFilter swagger 路径白名单 | Task 2 | -| TokenFilter shiro.auth.enabled=false 分支 | Task 2 | -| OpenApiConfig Bearer SecurityScheme | Task 3 | -| 5 个核心 DTO @Schema | Task 4 | -| AuthController + UserController @Tag/@Operation | Task 5 | -| SourceController + TaskController @Tag/@Operation | Task 6 | -| ExtractionController + QaController @Tag/@Operation | Task 7 | -| ExportController + SysConfigController + VideoController @Tag/@Operation | Task 8 | - -**全部覆盖,无遗漏。** diff --git a/docs/superpowers/plans/2026-04-14-auth-company-optimization.md b/docs/superpowers/plans/2026-04-14-auth-company-optimization.md deleted file mode 100644 index 9b68b0e..0000000 --- a/docs/superpowers/plans/2026-04-14-auth-company-optimization.md +++ /dev/null @@ -1,66 +0,0 @@ -# Auth And Company Optimization Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the remaining Shiro authorization layer with project-owned Redis token authentication and add company CRUD APIs. - -**Architecture:** Keep the existing UUID token, Redis session storage, and `CompanyContext` tenant injection. Add project-owned `@RequireAuth` and `@RequireRole` annotations plus a Spring MVC `AuthInterceptor`, then remove Shiro config/classes/dependencies. Add `CompanyService` and `CompanyController` for `sys_company` management. - -**Tech Stack:** Java 21, Spring Boot 3.1.5, Spring MVC HandlerInterceptor, RedisTemplate, MyBatis-Plus, JUnit 5, Mockito, AssertJ. - ---- - -### Task 1: Replace Shiro With Custom Auth Interceptor - -**Files:** -- Create: `src/main/java/com/label/annotation/RequireAuth.java` -- Create: `src/main/java/com/label/annotation/RequireRole.java` -- Create: `src/main/java/com/label/interceptor/AuthInterceptor.java` -- Create: `src/main/java/com/label/common/auth/TokenPrincipal.java` -- Create: `src/main/java/com/label/common/context/UserContext.java` -- Modify: `src/main/java/com/label/config/ShiroConfig.java` -- Modify: `src/main/java/com/label/common/shiro/TokenFilter.java` -- Modify: `src/main/java/com/label/common/shiro/BearerToken.java` -- Modify: `src/main/java/com/label/common/shiro/UserRealm.java` -- Modify: `src/main/java/com/label/controller/*.java` -- Modify: `src/main/java/com/label/service/*.java` -- Modify: `pom.xml` -- Test: `src/test/java/com/label/unit/AuthInterceptorTest.java` - -- [x] Write failing tests for token loading, TTL refresh, role hierarchy, and context cleanup. -- [x] Implement annotations, principal, context, and interceptor. -- [x] Register the interceptor via Spring MVC config. -- [x] Replace controller `@RequiresRoles` usage with `@RequireRole`. -- [x] Remove Shiro-only classes, tests, dependencies, and exception handling. -- [x] Run `mvn -q "-Dtest=AuthInterceptorTest,OpenApiAnnotationTest" test` and `mvn -q -DskipTests compile`. - -### Task 2: Add Company Management - -**Files:** -- Create: `src/main/java/com/label/service/CompanyService.java` -- Create: `src/main/java/com/label/controller/CompanyController.java` -- Modify: `src/main/java/com/label/mapper/SysUserMapper.java` -- Test: `src/test/java/com/label/unit/CompanyServiceTest.java` -- Test: `src/test/java/com/label/unit/OpenApiAnnotationTest.java` - -- [x] Write failing tests for create/list/update/status/delete behavior. -- [x] Implement service validation and duplicate checks. -- [x] Implement admin-only controller endpoints under `/api/companies`. -- [x] Run `mvn -q "-Dtest=CompanyServiceTest,OpenApiAnnotationTest" test` and `mvn -q -DskipTests compile`. - -### Task 3: Configuration And Verification - -**Files:** -- Modify: `src/main/resources/application.yml` -- Modify: `src/test/java/com/label/unit/ApplicationConfigTest.java` - -- [x] Rename `shiro.auth.*` config to `auth.*`. -- [x] Update safe defaults and type-aliases package. -- [x] Run targeted unit tests and compile. -- [x] Run `mvn clean test` once and record any external environment blockers. - -### Verification Notes - -- `mvn -q "-Dtest=LabelBackendApplicationTests,ApplicationConfigTest,AuthInterceptorTest,CompanyServiceTest,OpenApiAnnotationTest" test` passed. -- `mvn -q -DskipTests compile` passed. -- `mvn clean test` compiled main/test sources and passed unit tests, then failed only because 10 Testcontainers integration tests could not find a valid Docker environment. diff --git a/docs/superpowers/plans/2026-04-14-label-backend-directory-flattening.md b/docs/superpowers/plans/2026-04-14-label-backend-directory-flattening.md deleted file mode 100644 index fe24d0e..0000000 --- a/docs/superpowers/plans/2026-04-14-label-backend-directory-flattening.md +++ /dev/null @@ -1,528 +0,0 @@ -# label_backend 标准目录扁平化 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 把 `com.label.module.*` 与 `com.label.common.aop/common.config` 迁移到规范要求的扁平目录,保证行为不变。 - -**Architecture:** 先写迁移守卫测试,再按 `基础设施 -> 数据层 -> 服务层 -> 控制层 -> 清理回归` 的顺序做 `git mv` 和导包修正,每阶段都用最小测试集验证。 - -**Tech Stack:** Java 21、Spring Boot 3.1.5、MyBatis-Plus、Shiro、JUnit 5、AssertJ、Maven - ---- - -## 目标目录 - -- `src/main/java/com/label/annotation` -- `src/main/java/com/label/aspect` -- `src/main/java/com/label/config` -- `src/main/java/com/label/controller` -- `src/main/java/com/label/dto` -- `src/main/java/com/label/entity` -- `src/main/java/com/label/event` -- `src/main/java/com/label/listener` -- `src/main/java/com/label/mapper` -- `src/main/java/com/label/service` -- `src/test/java/com/label/unit/PackageStructureMigrationTest.java` - ---- - -### Task 1: 锁定基础设施迁移目标 - -**Files:** -- Create: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` -- Modify: `src/main/java/com/label/common/aop/OperationLog.java` -- Modify: `src/main/java/com/label/common/aop/AuditAspect.java` -- Modify: `src/main/java/com/label/common/config/MybatisPlusConfig.java` -- Modify: `src/main/java/com/label/common/config/OpenApiConfig.java` -- Modify: `src/main/java/com/label/common/config/RedisConfig.java` -- Modify: `src/main/java/com/label/module/annotation/event/ExtractionApprovedEvent.java` -- Modify: `src/main/java/com/label/module/annotation/service/ExtractionApprovedEventListener.java` -- Modify: `src/main/java/com/label/module/annotation/service/ExtractionService.java` -- Test: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` - -- [ ] **Step 1: 写失败测试** - -```java -package com.label.unit; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -@DisplayName("标准目录扁平化迁移守卫测试") -class PackageStructureMigrationTest { - - @Test - @DisplayName("基础设施类已迁移到目标目录") - void infrastructureTypesMoved() { - assertClassExists("com.label.annotation.OperationLog"); - assertClassExists("com.label.aspect.AuditAspect"); - assertClassExists("com.label.config.MybatisPlusConfig"); - assertClassExists("com.label.config.OpenApiConfig"); - assertClassExists("com.label.config.RedisConfig"); - assertClassExists("com.label.event.ExtractionApprovedEvent"); - assertClassExists("com.label.listener.ExtractionApprovedEventListener"); - - assertClassMissing("com.label.common.aop.OperationLog"); - assertClassMissing("com.label.common.aop.AuditAspect"); - assertClassMissing("com.label.common.config.MybatisPlusConfig"); - assertClassMissing("com.label.common.config.OpenApiConfig"); - assertClassMissing("com.label.common.config.RedisConfig"); - assertClassMissing("com.label.module.annotation.event.ExtractionApprovedEvent"); - assertClassMissing("com.label.module.annotation.service.ExtractionApprovedEventListener"); - } - - private static void assertClassExists(String fqcn) { - assertThatCode(() -> Class.forName(fqcn)).doesNotThrowAnyException(); - } - - private static void assertClassMissing(String fqcn) { - assertThatThrownBy(() -> Class.forName(fqcn)).isInstanceOf(ClassNotFoundException.class); - } -} -``` - -- [ ] **Step 2: 跑红** - -Run: `mvn -q "-Dtest=PackageStructureMigrationTest#infrastructureTypesMoved" test` - -Expected: FAIL,提示新包类不存在。 - -- [ ] **Step 3: 最小实现** - -先执行迁移: - -```powershell -git mv src/main/java/com/label/common/aop/OperationLog.java src/main/java/com/label/annotation/OperationLog.java -git mv src/main/java/com/label/common/aop/AuditAspect.java src/main/java/com/label/aspect/AuditAspect.java -git mv src/main/java/com/label/common/config/MybatisPlusConfig.java src/main/java/com/label/config/MybatisPlusConfig.java -git mv src/main/java/com/label/common/config/OpenApiConfig.java src/main/java/com/label/config/OpenApiConfig.java -git mv src/main/java/com/label/common/config/RedisConfig.java src/main/java/com/label/config/RedisConfig.java -git mv src/main/java/com/label/module/annotation/event/ExtractionApprovedEvent.java src/main/java/com/label/event/ExtractionApprovedEvent.java -git mv src/main/java/com/label/module/annotation/service/ExtractionApprovedEventListener.java src/main/java/com/label/listener/ExtractionApprovedEventListener.java -``` - -再做精确替换: - -```java -// OperationLog.java -package com.label.annotation; - -// AuditAspect.java -package com.label.aspect; -import com.label.annotation.OperationLog; - -// MybatisPlusConfig.java / OpenApiConfig.java / RedisConfig.java -package com.label.config; - -// ExtractionApprovedEvent.java -package com.label.event; - -// ExtractionApprovedEventListener.java -package com.label.listener; -import com.label.event.ExtractionApprovedEvent; -import com.label.module.annotation.entity.TrainingDataset; -import com.label.module.annotation.mapper.AnnotationResultMapper; -import com.label.module.annotation.mapper.TrainingDatasetMapper; -import com.label.module.source.entity.SourceData; -import com.label.module.source.mapper.SourceDataMapper; -import com.label.module.task.service.TaskService; -``` - -并把 `src/main/java/com/label/module/annotation/service/ExtractionService.java` 中事件 import 改成 `com.label.event.ExtractionApprovedEvent`。 - -- [ ] **Step 4: 跑绿** - -Run: -- `mvn -q "-Dtest=PackageStructureMigrationTest#infrastructureTypesMoved" test` -- `mvn -q -DskipTests compile` - -Expected: 两条命令都 `BUILD SUCCESS`。 - -- [ ] **Step 5: Commit** - -```powershell -git add src/test/java/com/label/unit/PackageStructureMigrationTest.java src/main/java/com/label/annotation src/main/java/com/label/aspect src/main/java/com/label/config src/main/java/com/label/event src/main/java/com/label/listener src/main/java/com/label/module/annotation/service/ExtractionService.java -git commit -m "refactor: flatten infrastructure packages" -``` - ---- - -### Task 2: 迁移 DTO、Entity、Mapper - -**Files:** -- Modify: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` -- Modify: `src/test/java/com/label/unit/OpenApiAnnotationTest.java` -- Modify: `src/test/java/com/label/integration/AuthIntegrationTest.java` -- Modify: `src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java` -- Modify: `src/test/java/com/label/integration/QaApprovalIntegrationTest.java` -- Modify: `src/test/java/com/label/integration/UserManagementIntegrationTest.java` -- Modify: `src/main/java/com/label/module/annotation/service/ExtractionService.java` -- Modify: `src/main/java/com/label/module/annotation/service/QaService.java` -- Modify: `src/main/java/com/label/module/config/service/SysConfigService.java` -- Modify: `src/main/java/com/label/module/config/controller/SysConfigController.java` -- Modify: `src/main/java/com/label/module/export/service/ExportService.java` -- Modify: `src/main/java/com/label/module/export/service/FinetuneService.java` -- Modify: `src/main/java/com/label/module/export/controller/ExportController.java` -- Modify: `src/main/java/com/label/module/source/service/SourceService.java` -- Modify: `src/main/java/com/label/module/source/controller/SourceController.java` -- Modify: `src/main/java/com/label/module/task/service/TaskClaimService.java` -- Modify: `src/main/java/com/label/module/task/service/TaskService.java` -- Modify: `src/main/java/com/label/module/task/controller/TaskController.java` -- Modify: `src/main/java/com/label/module/user/service/AuthService.java` -- Modify: `src/main/java/com/label/module/user/service/UserService.java` -- Modify: `src/main/java/com/label/module/user/controller/AuthController.java` -- Modify: `src/main/java/com/label/module/user/controller/UserController.java` -- Modify: `src/main/java/com/label/module/video/service/VideoProcessService.java` -- Modify: `src/main/java/com/label/module/video/controller/VideoController.java` -- Test: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` - -- [ ] **Step 1: 写失败测试** - -在 `PackageStructureMigrationTest.java` 里追加: - -```java - @Test - @DisplayName("DTO、实体、Mapper 已迁移到扁平数据层") - void dataTypesMoved() { - for (String fqcn : java.util.List.of( - "com.label.dto.LoginRequest", "com.label.dto.LoginResponse", "com.label.dto.UserInfoResponse", - "com.label.dto.TaskResponse", "com.label.dto.SourceResponse", - "com.label.entity.AnnotationResult", "com.label.entity.TrainingDataset", "com.label.entity.SysConfig", - "com.label.entity.ExportBatch", "com.label.entity.SourceData", "com.label.entity.AnnotationTask", - "com.label.entity.AnnotationTaskHistory", "com.label.entity.SysCompany", "com.label.entity.SysUser", - "com.label.entity.VideoProcessJob", - "com.label.mapper.AnnotationResultMapper", "com.label.mapper.TrainingDatasetMapper", - "com.label.mapper.SysConfigMapper", "com.label.mapper.ExportBatchMapper", "com.label.mapper.SourceDataMapper", - "com.label.mapper.AnnotationTaskMapper", "com.label.mapper.TaskHistoryMapper", - "com.label.mapper.SysCompanyMapper", "com.label.mapper.SysUserMapper", "com.label.mapper.VideoProcessJobMapper")) { - assertClassExists(fqcn); - } - } -``` - -- [ ] **Step 2: 跑红** - -Run: `mvn -q "-Dtest=PackageStructureMigrationTest#dataTypesMoved" test` - -Expected: FAIL,提示 `com.label.dto.LoginRequest` 等不存在。 - -- [ ] **Step 3: 最小实现** - -执行迁移: - -```powershell -git mv src/main/java/com/label/module/user/dto/LoginRequest.java src/main/java/com/label/dto/LoginRequest.java -git mv src/main/java/com/label/module/user/dto/LoginResponse.java src/main/java/com/label/dto/LoginResponse.java -git mv src/main/java/com/label/module/user/dto/UserInfoResponse.java src/main/java/com/label/dto/UserInfoResponse.java -git mv src/main/java/com/label/module/task/dto/TaskResponse.java src/main/java/com/label/dto/TaskResponse.java -git mv src/main/java/com/label/module/source/dto/SourceResponse.java src/main/java/com/label/dto/SourceResponse.java -git mv src/main/java/com/label/module/annotation/entity/AnnotationResult.java src/main/java/com/label/entity/AnnotationResult.java -git mv src/main/java/com/label/module/annotation/entity/TrainingDataset.java src/main/java/com/label/entity/TrainingDataset.java -git mv src/main/java/com/label/module/config/entity/SysConfig.java src/main/java/com/label/entity/SysConfig.java -git mv src/main/java/com/label/module/export/entity/ExportBatch.java src/main/java/com/label/entity/ExportBatch.java -git mv src/main/java/com/label/module/source/entity/SourceData.java src/main/java/com/label/entity/SourceData.java -git mv src/main/java/com/label/module/task/entity/AnnotationTask.java src/main/java/com/label/entity/AnnotationTask.java -git mv src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java src/main/java/com/label/entity/AnnotationTaskHistory.java -git mv src/main/java/com/label/module/user/entity/SysCompany.java src/main/java/com/label/entity/SysCompany.java -git mv src/main/java/com/label/module/user/entity/SysUser.java src/main/java/com/label/entity/SysUser.java -git mv src/main/java/com/label/module/video/entity/VideoProcessJob.java src/main/java/com/label/entity/VideoProcessJob.java -git mv src/main/java/com/label/module/annotation/mapper/AnnotationResultMapper.java src/main/java/com/label/mapper/AnnotationResultMapper.java -git mv src/main/java/com/label/module/annotation/mapper/TrainingDatasetMapper.java src/main/java/com/label/mapper/TrainingDatasetMapper.java -git mv src/main/java/com/label/module/config/mapper/SysConfigMapper.java src/main/java/com/label/mapper/SysConfigMapper.java -git mv src/main/java/com/label/module/export/mapper/ExportBatchMapper.java src/main/java/com/label/mapper/ExportBatchMapper.java -git mv src/main/java/com/label/module/source/mapper/SourceDataMapper.java src/main/java/com/label/mapper/SourceDataMapper.java -git mv src/main/java/com/label/module/task/mapper/AnnotationTaskMapper.java src/main/java/com/label/mapper/AnnotationTaskMapper.java -git mv src/main/java/com/label/module/task/mapper/TaskHistoryMapper.java src/main/java/com/label/mapper/TaskHistoryMapper.java -git mv src/main/java/com/label/module/user/mapper/SysCompanyMapper.java src/main/java/com/label/mapper/SysCompanyMapper.java -git mv src/main/java/com/label/module/user/mapper/SysUserMapper.java src/main/java/com/label/mapper/SysUserMapper.java -git mv src/main/java/com/label/module/video/mapper/VideoProcessJobMapper.java src/main/java/com/label/mapper/VideoProcessJobMapper.java -``` - -统一替换包声明: - -```java -package com.label.dto; -package com.label.entity; -package com.label.mapper; -``` - -然后把上面 `Files` 列表中的旧 `com.label.module.*.(dto|entity|mapper)` import 全部改到新包。 - -- [ ] **Step 4: 跑绿** - -Run: -- `mvn -q "-Dtest=PackageStructureMigrationTest#dataTypesMoved,OpenApiAnnotationTest,AuthIntegrationTest,ExtractionApprovalIntegrationTest,QaApprovalIntegrationTest,UserManagementIntegrationTest" test` - -Expected: PASS。 - -- [ ] **Step 5: Commit** - -```powershell -git add src/main/java/com/label/dto src/main/java/com/label/entity src/main/java/com/label/mapper src/test/java/com/label/unit/PackageStructureMigrationTest.java src/test/java/com/label/unit/OpenApiAnnotationTest.java src/test/java/com/label/integration/AuthIntegrationTest.java src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java src/test/java/com/label/integration/QaApprovalIntegrationTest.java src/test/java/com/label/integration/UserManagementIntegrationTest.java src/main/java/com/label/module -git commit -m "refactor: flatten dto entity and mapper packages" -``` - ---- - -### Task 3: 迁移业务服务层 - -**Files:** -- Modify: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` -- Modify: `src/main/java/com/label/module/annotation/controller/ExtractionController.java` -- Modify: `src/main/java/com/label/module/annotation/controller/QaController.java` -- Modify: `src/main/java/com/label/module/config/controller/SysConfigController.java` -- Modify: `src/main/java/com/label/module/export/controller/ExportController.java` -- Modify: `src/main/java/com/label/module/source/controller/SourceController.java` -- Modify: `src/main/java/com/label/module/task/controller/TaskController.java` -- Modify: `src/main/java/com/label/module/user/controller/AuthController.java` -- Modify: `src/main/java/com/label/module/user/controller/UserController.java` -- Modify: `src/main/java/com/label/module/video/controller/VideoController.java` -- Test: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` - -- [ ] **Step 1: 写失败测试** - -```java - @Test - @DisplayName("服务类已迁移到扁平 service 目录") - void serviceTypesMoved() { - for (String fqcn : java.util.List.of( - "com.label.service.ExtractionService", "com.label.service.QaService", - "com.label.service.SysConfigService", "com.label.service.ExportService", - "com.label.service.FinetuneService", "com.label.service.SourceService", - "com.label.service.TaskClaimService", "com.label.service.TaskService", - "com.label.service.AuthService", "com.label.service.UserService", - "com.label.service.VideoProcessService")) { - assertClassExists(fqcn); - } - } -``` - -- [ ] **Step 2: 跑红** - -Run: `mvn -q "-Dtest=PackageStructureMigrationTest#serviceTypesMoved" test` - -Expected: FAIL,提示 `com.label.service.*` 不存在。 - -- [ ] **Step 3: 最小实现** - -执行迁移: - -```powershell -git mv src/main/java/com/label/module/annotation/service/ExtractionService.java src/main/java/com/label/service/ExtractionService.java -git mv src/main/java/com/label/module/annotation/service/QaService.java src/main/java/com/label/service/QaService.java -git mv src/main/java/com/label/module/config/service/SysConfigService.java src/main/java/com/label/service/SysConfigService.java -git mv src/main/java/com/label/module/export/service/ExportService.java src/main/java/com/label/service/ExportService.java -git mv src/main/java/com/label/module/export/service/FinetuneService.java src/main/java/com/label/service/FinetuneService.java -git mv src/main/java/com/label/module/source/service/SourceService.java src/main/java/com/label/service/SourceService.java -git mv src/main/java/com/label/module/task/service/TaskClaimService.java src/main/java/com/label/service/TaskClaimService.java -git mv src/main/java/com/label/module/task/service/TaskService.java src/main/java/com/label/service/TaskService.java -git mv src/main/java/com/label/module/user/service/AuthService.java src/main/java/com/label/service/AuthService.java -git mv src/main/java/com/label/module/user/service/UserService.java src/main/java/com/label/service/UserService.java -git mv src/main/java/com/label/module/video/service/VideoProcessService.java src/main/java/com/label/service/VideoProcessService.java -``` - -统一替换: - -```java -package com.label.service; -``` - -并把 `ExtractionController`、`QaController`、`SysConfigController`、`ExportController`、`SourceController`、`TaskController`、`AuthController`、`UserController`、`VideoController` 的服务导包改到 `com.label.service.*`。 - -- [ ] **Step 4: 跑绿** - -Run: -- `mvn -q "-Dtest=PackageStructureMigrationTest#serviceTypesMoved" test` -- `mvn -q -DskipTests compile` - -Expected: PASS。 - -- [ ] **Step 5: Commit** - -```powershell -git add src/main/java/com/label/service src/main/java/com/label/module/annotation/controller/ExtractionController.java src/main/java/com/label/module/annotation/controller/QaController.java src/main/java/com/label/module/config/controller/SysConfigController.java src/main/java/com/label/module/export/controller/ExportController.java src/main/java/com/label/module/source/controller/SourceController.java src/main/java/com/label/module/task/controller/TaskController.java src/main/java/com/label/module/user/controller/AuthController.java src/main/java/com/label/module/user/controller/UserController.java src/main/java/com/label/module/video/controller/VideoController.java src/test/java/com/label/unit/PackageStructureMigrationTest.java -git commit -m "refactor: flatten service packages" -``` - ---- - -### Task 4: 迁移控制器并收敛 OpenAPI 测试 - -**Files:** -- Modify: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` -- Modify: `src/test/java/com/label/unit/OpenApiAnnotationTest.java` -- Test: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` -- Test: `src/test/java/com/label/unit/OpenApiAnnotationTest.java` - -- [ ] **Step 1: 写失败测试** - -```java - @Test - @DisplayName("控制器已迁移到扁平 controller 目录") - void controllerTypesMoved() { - for (String fqcn : java.util.List.of( - "com.label.controller.AuthController", "com.label.controller.UserController", - "com.label.controller.SourceController", "com.label.controller.TaskController", - "com.label.controller.ExtractionController", "com.label.controller.QaController", - "com.label.controller.ExportController", "com.label.controller.SysConfigController", - "com.label.controller.VideoController")) { - assertClassExists(fqcn); - } - } -``` - -- [ ] **Step 2: 跑红** - -Run: `mvn -q "-Dtest=PackageStructureMigrationTest#controllerTypesMoved" test` - -Expected: FAIL,提示 `com.label.controller.*` 不存在。 - -- [ ] **Step 3: 最小实现** - -执行迁移: - -```powershell -git mv src/main/java/com/label/module/annotation/controller/ExtractionController.java src/main/java/com/label/controller/ExtractionController.java -git mv src/main/java/com/label/module/annotation/controller/QaController.java src/main/java/com/label/controller/QaController.java -git mv src/main/java/com/label/module/config/controller/SysConfigController.java src/main/java/com/label/controller/SysConfigController.java -git mv src/main/java/com/label/module/export/controller/ExportController.java src/main/java/com/label/controller/ExportController.java -git mv src/main/java/com/label/module/source/controller/SourceController.java src/main/java/com/label/controller/SourceController.java -git mv src/main/java/com/label/module/task/controller/TaskController.java src/main/java/com/label/controller/TaskController.java -git mv src/main/java/com/label/module/user/controller/AuthController.java src/main/java/com/label/controller/AuthController.java -git mv src/main/java/com/label/module/user/controller/UserController.java src/main/java/com/label/controller/UserController.java -git mv src/main/java/com/label/module/video/controller/VideoController.java src/main/java/com/label/controller/VideoController.java -``` - -统一替换: - -```java -package com.label.controller; -``` - -并把 `OpenApiAnnotationTest.java` 的 import 替换成: - -```java -import com.label.controller.AuthController; -import com.label.controller.UserController; -import com.label.controller.SourceController; -import com.label.controller.TaskController; -import com.label.controller.ExtractionController; -import com.label.controller.QaController; -import com.label.controller.ExportController; -import com.label.controller.SysConfigController; -import com.label.controller.VideoController; -import com.label.dto.LoginRequest; -import com.label.dto.LoginResponse; -import com.label.dto.UserInfoResponse; -import com.label.dto.TaskResponse; -import com.label.dto.SourceResponse; -``` - -- [ ] **Step 4: 跑绿** - -Run: `mvn -q "-Dtest=PackageStructureMigrationTest#controllerTypesMoved,OpenApiAnnotationTest" test` - -Expected: PASS。 - -- [ ] **Step 5: Commit** - -```powershell -git add src/main/java/com/label/controller src/test/java/com/label/unit/PackageStructureMigrationTest.java src/test/java/com/label/unit/OpenApiAnnotationTest.java -git commit -m "refactor: flatten controller packages" -``` - ---- - -### Task 5: 清理旧包残留并做全量回归 - -**Files:** -- Modify: `src/test/java/com/label/unit/PackageStructureMigrationTest.java` -- Test: `src/test/java/com/label/LabelBackendApplicationTests.java` -- Test: `src/test/java/com/label/unit/ApplicationConfigTest.java` -- Test: `src/test/java/com/label/unit/ShiroConfigTest.java` -- Test: `src/test/java/com/label/unit/StateMachineTest.java` -- Test: `src/test/java/com/label/unit/TokenFilterTest.java` -- Test: `src/test/java/com/label/integration/AuthIntegrationTest.java` -- Test: `src/test/java/com/label/integration/ExportIntegrationTest.java` -- Test: `src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java` -- Test: `src/test/java/com/label/integration/MultiTenantIsolationTest.java` -- Test: `src/test/java/com/label/integration/QaApprovalIntegrationTest.java` -- Test: `src/test/java/com/label/integration/ShiroFilterIntegrationTest.java` -- Test: `src/test/java/com/label/integration/SourceIntegrationTest.java` -- Test: `src/test/java/com/label/integration/SysConfigIntegrationTest.java` -- Test: `src/test/java/com/label/integration/TaskClaimConcurrencyTest.java` -- Test: `src/test/java/com/label/integration/UserManagementIntegrationTest.java` -- Test: `src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java` - -- [ ] **Step 1: 写最终守卫测试** - -在 `PackageStructureMigrationTest.java` 中补充: - -```java - @Test - @DisplayName("源码中不再引用旧的 module、common.aop、common.config 包") - void sourceTreeHasNoLegacyPackageReferences() throws Exception { - try (java.util.stream.Stream paths = java.nio.file.Files.walk(java.nio.file.Path.of("src"))) { - java.util.List violations = paths - .filter(path -> path.toString().endsWith(".java")) - .map(path -> { - try { - String text = java.nio.file.Files.readString(path); - boolean legacy = text.contains("com.label.module.") - || text.contains("com.label.common.aop") - || text.contains("com.label.common.config"); - return legacy ? path.toString() : null; - } catch (Exception e) { - throw new RuntimeException(e); - } - }) - .filter(java.util.Objects::nonNull) - .toList(); - - org.assertj.core.api.Assertions.assertThat(violations).isEmpty(); - } - } -``` - -- [ ] **Step 2: 跑红并定位残留** - -Run: -- `mvn -q "-Dtest=PackageStructureMigrationTest#sourceTreeHasNoLegacyPackageReferences" test` -- `rg -n "com\.label\.module\.|com\.label\.common\.aop|com\.label\.common\.config" src/main/java src/test/java` - -Expected: 初次 FAIL,并列出残留文件。 - -- [ ] **Step 3: 清理残留并确认旧目录为空** - -Run: - -```powershell -Get-ChildItem -Path src/main/java/com/label/module -Recurse -``` - -Expected: 没有 Java 文件残留;确认后删除空目录。 - -- [ ] **Step 4: 全量回归** - -Run: `mvn clean test` - -Expected: `BUILD SUCCESS`。 - -- [ ] **Step 5: Commit** - -```powershell -git add src/main/java src/test/java -git commit -m "refactor: complete backend directory flattening" -``` - ---- - -## 自检 - -- 覆盖了 `annotation / aspect / config / event / listener / dto / entity / mapper / service / controller` -- 没有引入 `service.impl` -- 没有拆分 `dto/request`、`dto/response` -- 最终门槛是 `mvn clean test` diff --git a/docs/superpowers/specs/2026-04-09-label-backend-design.md b/docs/superpowers/specs/2026-04-09-label-backend-design.md deleted file mode 100644 index 82bdfef..0000000 --- a/docs/superpowers/specs/2026-04-09-label-backend-design.md +++ /dev/null @@ -1,1943 +0,0 @@ -# label_backend 开发实施指南 - -**版本:1.0.0 | 日期:2026-04-09 | 依据:backend后台设计.md v2.2 + constitution.md v1.1.0** - ---- - -## 目录 - -- [一、项目总览](#一项目总览) - - [1.1 系统定位](#11-系统定位) - - [1.2 数据流水线](#12-数据流水线) - - [1.3 技术栈矩阵](#13-技术栈矩阵) - - [1.4 后端模块清单](#14-后端模块清单) - - [1.5 包结构](#15-包结构) -- [二、数据库 DDL](#二数据库-ddl) -- [三、公共基础设施](#三公共基础设施) - - [3.1 统一响应封装](#31-统一响应封装) - - [3.2 全局异常处理](#32-全局异常处理) - - [3.3 多租户 CompanyContext(ThreadLocal)](#33-多租户-companycontextthreadlocal) - - [3.4 Shiro 配置](#34-shiro-配置) - - [3.5 Redis Key 管理](#35-redis-key-管理) - - [3.6 AOP 审计日志切面](#36-aop-审计日志切面) - - [3.7 RustFS S3 客户端](#37-rustfs-s3-客户端) - - [3.8 AI 服务 HTTP 客户端](#38-ai-服务-http-客户端) -- [四、业务模块纵切](#四业务模块纵切) - - [4.1 用户与权限模块](#41-用户与权限模块) - - [4.2 资料管理模块](#42-资料管理模块) - - [4.3 任务管理模块(含并发控制)](#43-任务管理模块含并发控制) - - [4.4 标注工作台模块(EXTRACTION 阶段)](#44-标注工作台模块extraction-阶段) - - [4.5 问答生成模块(QA_GENERATION 阶段)](#45-问答生成模块qa_generation-阶段) - - [4.6 训练数据导出模块](#46-训练数据导出模块) - - [4.7 系统配置模块](#47-系统配置模块) - - [4.8 视频处理模块](#48-视频处理模块) -- [五、状态机实现规范](#五状态机实现规范) - - [5.1 StateValidator](#51-statevalidator) - - [5.2 source\_data 状态机](#52-source_data-状态机) - - [5.3 annotation\_task 状态机](#53-annotation_task-状态机) - - [5.4 training\_dataset 状态机](#54-training_dataset-状态机) - - [5.5 video\_process\_job 状态机](#55-video_process_job-状态机) -- [六、Docker Compose 配置](#六docker-compose-配置) -- [七、测试策略](#七测试策略) - - [7.1 基本原则](#71-基本原则) - - [7.2 并发任务领取测试(必须)](#72-并发任务领取测试必须) - - [7.3 视频回调幂等测试(必须)](#73-视频回调幂等测试必须) - - [7.4 状态机越界拒绝测试](#74-状态机越界拒绝测试) - - [7.5 多租户隔离测试](#75-多租户隔离测试) -- [八、宪章合规检查清单](#八宪章合规检查清单) -- [九、部署与发布](#九部署与发布) - - [9.1 Maven 构建配置变更](#91-maven-构建配置变更) - - [9.2 分发包结构](#92-分发包结构) - - [9.3 start.sh 启动脚本](#93-startsh-启动脚本) - - [9.4 logback.xml 配置](#94-logbackxml-配置) - - [9.5 Dockerfile 更新](#95-dockerfile-更新) - - [9.6 日志级别规范(log.debug → log.info)](#96-日志级别规范logdebug--loginfo) - ---- - -## 一、项目总览 - -### 1.1 系统定位 - -label_backend 是知识图谱智能标注平台的后端服务,基于 Spring Boot 3 构建,驱动**文本线**和**图片线**两条标注流水线,将文档、图片、视频原始资料处理为 GLM 微调格式的训练数据集。 - -### 1.2 数据流水线 - -``` -文本线: 文档 → 三元组提取(主语/谓语/宾语 + 原文片段 + 字符偏移) - → 问答对生成 → 审批 → 训练样本(GLM 文本格式) - -图片线: 图片 → 四元组提取(主体/关系/客体/修饰词 + bbox + 裁剪图) - → 问答对生成 → 审批 → 训练样本(GLM 图文格式) - -视频预处理(非独立流水线): - 帧模式: 视频 → AI 抽帧 → 每帧作为图片进入图片线 - 片段模式: 视频 → 多模态模型转文本 → 派生 TEXT source_data → 进入文本线 -``` - -### 1.3 技术栈矩阵 - -| 层次 | 技术 | 版本约束 | -|------|------|----------| -| 运行时 | JDK | 17(LTS) | -| 框架 | Spring Boot | ≥ 3.0.x | -| 认证/鉴权 | Apache Shiro | ≥ 1.13.x(兼容 Spring Boot 3) | -| ORM | MyBatis Plus | ≥ 3.5.x | -| 数据库 | PostgreSQL | ≥ 14 | -| 缓存/锁 | Redis | ≥ 6.x | -| 对象存储 | RustFS(S3 兼容接口) | 当前稳定版 | -| AI 服务 | Python FastAPI(HTTP 调用) | JVM 侧仅作 RestClient 调用 | -| 容器化 | Docker Compose | ≥ 2.x | -| 构建工具 | Maven | ≥ 3.8 | - -### 1.4 后端模块清单 - -| 模块 | 职责 | -|------|------| -| 用户与权限模块 | 用户管理、Shiro RBAC、Token 认证 | -| 资料管理模块 | 文件上传至 RustFS、source_data 元数据管理 | -| 任务池模块 | 任务创建、领取(分布式锁 + 乐观锁)、状态流转 | -| 标注工作台模块 | 调用 AI 提取三/四元组、annotation_result JSONB 写入 | -| 问答生成模块 | 调用 AI 生成候选问答对、training_dataset 管理 | -| 训练数据导出模块 | JSONL 批次导出、GLM 微调对接 | -| 系统配置模块 | Prompt 模板、模型参数、全局/公司级配置管理 | -| 视频处理模块 | 异步抽帧 / 转文本任务跟踪、幂等回调处理 | - -### 1.5 包结构 - -``` -com.label -├── LabelBackendApplication.java -├── common/ -│ ├── result/ # Result、ResultCode、PageResult -│ ├── exception/ # BusinessException、GlobalExceptionHandler -│ ├── context/ # CompanyContext(ThreadLocal) -│ ├── shiro/ # TokenFilter、UserRealm、ShiroConfig -│ ├── redis/ # RedisKeyManager、RedisService -│ ├── aop/ # AuditAspect、@OperationLog 注解 -│ ├── storage/ # RustFsClient(S3 兼容封装) -│ ├── ai/ # AiServiceClient(RestClient 封装 8 个端点) -│ └── statemachine/ # StateValidator、各状态枚举 -├── module/ -│ ├── user/ -│ │ ├── controller/ # AuthController、UserController -│ │ ├── service/ # AuthService、UserService -│ │ ├── mapper/ # SysUserMapper、SysCompanyMapper -│ │ ├── entity/ # SysUser、SysCompany -│ │ └── dto/ # LoginRequest、UserDTO、UserCreateRequest -│ ├── source/ -│ │ ├── controller/ # SourceController -│ │ ├── service/ # SourceService -│ │ ├── mapper/ # SourceDataMapper -│ │ ├── entity/ # SourceData -│ │ └── dto/ # SourceUploadResponse、SourceListQuery -│ ├── task/ -│ │ ├── controller/ # TaskController -│ │ ├── service/ # TaskService、TaskClaimService -│ │ ├── mapper/ # AnnotationTaskMapper、TaskHistoryMapper -│ │ ├── entity/ # AnnotationTask、AnnotationTaskHistory -│ │ └── dto/ # TaskCreateRequest、TaskPoolQuery、TaskDetailDTO -│ ├── annotation/ -│ │ ├── controller/ # ExtractionController、QaController -│ │ ├── service/ # ExtractionService、QaService -│ │ ├── mapper/ # AnnotationResultMapper、TrainingDatasetMapper -│ │ ├── entity/ # AnnotationResult、TrainingDataset -│ │ └── dto/ # ExtractionResultDTO、QaItemDTO、RejectRequest -│ ├── export/ -│ │ ├── controller/ # ExportController -│ │ ├── service/ # ExportService、FinetuneService -│ │ ├── mapper/ # ExportBatchMapper -│ │ ├── entity/ # ExportBatch -│ │ └── dto/ # ExportBatchRequest、FinetuneRequest -│ ├── config/ -│ │ ├── controller/ # SysConfigController -│ │ ├── service/ # SysConfigService -│ │ ├── mapper/ # SysConfigMapper -│ │ ├── entity/ # SysConfig -│ │ └── dto/ # ConfigUpdateRequest -│ └── video/ -│ ├── controller/ # VideoController -│ ├── service/ # VideoProcessService -│ ├── mapper/ # VideoProcessJobMapper -│ ├── entity/ # VideoProcessJob -│ └── dto/ # VideoProcessRequest、VideoCallbackRequest -``` - -[↑ 返回目录](#目录) - ---- - -## 二、数据库 DDL - -> 执行顺序:sys_company → sys_user → source_data → annotation_task → annotation_result → training_dataset → export_batch → sys_config → sys_operation_log → annotation_task_history → video_process_job - -```sql --- ============================================= --- 1. sys_company — 公司表(多租户根节点) --- ============================================= -CREATE TABLE sys_company ( - id BIGSERIAL PRIMARY KEY, - company_name VARCHAR(100) NOT NULL UNIQUE, - company_code VARCHAR(50) NOT NULL UNIQUE, - status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- ============================================= --- 2. sys_user — 用户表 --- ============================================= -CREATE TABLE sys_user ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - username VARCHAR(50) NOT NULL, - password_hash VARCHAR(255) NOT NULL, -- BCrypt,强度因子 >= 10 - real_name VARCHAR(50), - role VARCHAR(20) NOT NULL, -- UPLOADER / ANNOTATOR / REVIEWER / ADMIN - status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT uq_company_username UNIQUE (company_id, username) -); -CREATE INDEX idx_sys_user_company ON sys_user(company_id); - --- ============================================= --- 3. source_data — 原始资料元数据表 --- ============================================= -CREATE TABLE source_data ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - uploader_id BIGINT REFERENCES sys_user(id), - data_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO - file_path VARCHAR(500) NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_size BIGINT, - bucket_name VARCHAR(100) NOT NULL, - parent_source_id BIGINT REFERENCES source_data(id), -- 视频转文本时指向原视频 - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - -- PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED(无 REJECTED 状态,QA 驳回作用于 annotation_task) - reject_reason TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_source_data_company ON source_data(company_id); -CREATE INDEX idx_source_data_status ON source_data(company_id, status); -CREATE INDEX idx_source_data_parent ON source_data(parent_source_id); - --- ============================================= --- 4. annotation_task — 标注任务表 --- ============================================= -CREATE TABLE annotation_task ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - source_id BIGINT NOT NULL REFERENCES source_data(id), - phase VARCHAR(20) NOT NULL, -- EXTRACTION / QA_GENERATION - task_type VARCHAR(20) NOT NULL, -- AI_ASSISTED / MANUAL - ai_model VARCHAR(50), - video_unit_type VARCHAR(20), -- FRAME(仅视频帧模式填写,其余为空) - video_unit_info JSONB, -- {"frame_index":150,"time_sec":5.0,"frame_path":"..."} - claimed_by BIGINT REFERENCES sys_user(id), - claimed_at TIMESTAMP, - status VARCHAR(20) NOT NULL DEFAULT 'UNCLAIMED', - -- UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED - reject_reason TEXT, - submitted_at TIMESTAMP, - completed_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_annotation_task_company ON annotation_task(company_id); -CREATE INDEX idx_annotation_task_pool ON annotation_task(company_id, phase, status); -CREATE INDEX idx_annotation_task_claimed ON annotation_task(claimed_by, status); - --- ============================================= --- 5. annotation_result — 标注结果表(EXTRACTION 阶段,JSONB 聚合) --- ============================================= -CREATE TABLE annotation_result ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - task_id BIGINT NOT NULL REFERENCES annotation_task(id), - result_json JSONB NOT NULL, -- 整体覆盖,禁止局部 PATCH - is_final BOOLEAN NOT NULL DEFAULT FALSE, - submitted_by BIGINT REFERENCES sys_user(id), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_annotation_result_task ON annotation_result(task_id); -CREATE INDEX idx_annotation_result_final ON annotation_result(company_id, is_final); - --- ============================================= --- 6. training_dataset — 训练样本表(问答对终态) --- ============================================= -CREATE TABLE training_dataset ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - task_id BIGINT NOT NULL REFERENCES annotation_task(id), - source_id BIGINT NOT NULL REFERENCES source_data(id), - extraction_result_id BIGINT NOT NULL REFERENCES annotation_result(id), - sample_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO_FRAME - glm_format_json JSONB NOT NULL, -- GLM 微调格式,JSONB 支持字段级查询 - export_batch_id VARCHAR(50), -- 未导出时为空 - status VARCHAR(20) NOT NULL DEFAULT 'PENDING_REVIEW', - -- PENDING_REVIEW / APPROVED / REJECTED - reject_reason TEXT, - reviewed_by BIGINT REFERENCES sys_user(id), - exported_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_training_dataset_company ON training_dataset(company_id); -CREATE INDEX idx_training_dataset_status ON training_dataset(company_id, status); -CREATE INDEX idx_training_dataset_batch ON training_dataset(export_batch_id); - --- ============================================= --- 7. export_batch — 导出批次表 --- ============================================= -CREATE TABLE export_batch ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - batch_uuid VARCHAR(50) NOT NULL UNIQUE, - dataset_file_path VARCHAR(500), - sample_count INT NOT NULL DEFAULT 0, - glm_job_id VARCHAR(100), -- 调用微调接口后填写,初始为 NULL - finetune_status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED', - -- NOT_STARTED / RUNNING / SUCCESS / FAILED - error_message TEXT, - created_by BIGINT REFERENCES sys_user(id), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_export_batch_company ON export_batch(company_id); - --- ============================================= --- 8. sys_config — 系统配置表(支持全局默认 + 公司级覆盖) --- ============================================= -CREATE TABLE sys_config ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT REFERENCES sys_company(id), -- NULL = 全局默认配置 - config_key VARCHAR(100) NOT NULL, - config_value TEXT NOT NULL, - description TEXT, - updated_by BIGINT REFERENCES sys_user(id), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT uq_config_company_key UNIQUE (company_id, config_key) -); - --- 预置全局配置项 -INSERT INTO sys_config (company_id, config_key, config_value, description) VALUES -(NULL, 'prompt_extract_text', '...', '文本三元组提取 Prompt 模板'), -(NULL, 'prompt_extract_image', '...', '图像四元组提取 Prompt 模板'), -(NULL, 'prompt_video_to_text', '...', '视频转文本 Prompt 模板'), -(NULL, 'prompt_qa_gen_text', '...', '文本问答对生成 Prompt 模板'), -(NULL, 'prompt_qa_gen_image', '...', '图像问答对生成 Prompt 模板'), -(NULL, 'model_default', 'glm-4', '默认 AI 辅助模型'), -(NULL, 'video_frame_interval', '30', '视频帧模式抽帧间隔(帧数)'), -(NULL, 'token_ttl_seconds', '7200', 'Token 有效期(秒)'), -(NULL, 'glm_api_base_url', 'http://ai-service:8000', 'GLM API 地址'); - --- ============================================= --- 9. sys_operation_log — 操作审计日志(只追加,按月分区) --- ============================================= -CREATE TABLE sys_operation_log ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT REFERENCES sys_company(id), - operator_id BIGINT REFERENCES sys_user(id), -- 登录失败时可为 NULL - operator_name VARCHAR(50) NOT NULL, -- 操作时用户名快照 - operation_type VARCHAR(50) NOT NULL, - target_type VARCHAR(30), - target_id BIGINT, - detail JSONB, - ip_address VARCHAR(50), - result VARCHAR(10) NOT NULL, -- SUCCESS / FAIL - error_message TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -) PARTITION BY RANGE (created_at); - --- 初始分区(建议使用 pg_partman 自动维护) -CREATE TABLE sys_operation_log_2026_04 - PARTITION OF sys_operation_log - FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); - -CREATE TABLE sys_operation_log_2026_05 - PARTITION OF sys_operation_log - FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); - --- ============================================= --- 10. annotation_task_history — 任务流转历史(只追加) --- ============================================= -CREATE TABLE annotation_task_history ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - task_id BIGINT NOT NULL REFERENCES annotation_task(id), - from_status VARCHAR(20), -- 任务初建时为 NULL - to_status VARCHAR(20) NOT NULL, - operator_id BIGINT NOT NULL REFERENCES sys_user(id), - operator_role VARCHAR(20) NOT NULL, -- 操作时角色快照 - note TEXT, -- 驳回原因、强制转移说明等 - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_task_history_task ON annotation_task_history(task_id); - --- ============================================= --- 11. video_process_job — 视频异步处理任务表 --- ============================================= -CREATE TABLE video_process_job ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - source_id BIGINT NOT NULL REFERENCES source_data(id), - job_type VARCHAR(20) NOT NULL, -- FRAME_EXTRACT / VIDEO_TO_TEXT - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - -- PENDING / RUNNING / SUCCESS / FAILED / RETRYING - params JSONB NOT NULL, - total_units INT, - processed_units INT NOT NULL DEFAULT 0, - output_path VARCHAR(500), - retry_count INT NOT NULL DEFAULT 0, - max_retries INT NOT NULL DEFAULT 3, - error_message TEXT, - started_at TIMESTAMP, - completed_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_video_process_job_source ON video_process_job(source_id); -CREATE INDEX idx_video_process_job_status ON video_process_job(status); -``` - -[↑ 返回目录](#目录) - ---- - -## 三、公共基础设施 - -### 3.1 统一响应封装 - -```java -// com/label/common/result/Result.java -public record Result(String code, T data, String message) { - public static Result ok(T data) { - return new Result<>("SUCCESS", data, null); - } - public static Result ok() { - return new Result<>("SUCCESS", null, null); - } - public static Result fail(String code, String message) { - return new Result<>(code, null, message); - } -} - -// com/label/common/result/PageResult.java -public record PageResult(List items, long total, int page, int pageSize) {} -``` - -所有 Controller 返回 `Result`,禁止直接返回裸 POJO 或裸 List。分页接口返回 `Result>`。 - -### 3.2 全局异常处理 - -```java -// com/label/common/exception/GlobalExceptionHandler.java -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusiness(BusinessException ex) { - return ResponseEntity.status(ex.getHttpStatus()) - .body(Result.fail(ex.getCode(), ex.getMessage())); - } - - @ExceptionHandler(UnauthorizedException.class) - public ResponseEntity> handleUnauthorized() { - return ResponseEntity.status(403) - .body(Result.fail("FORBIDDEN", "权限不足")); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleUnexpected(Exception ex, HttpServletRequest req) { - log.error("Unexpected error on {}", req.getRequestURI(), ex); - // 生产响应禁止包含堆栈跟踪 - return ResponseEntity.status(500) - .body(Result.fail("INTERNAL_ERROR", "服务器内部错误")); - } -} -``` - -### 3.3 多租户 CompanyContext(ThreadLocal) - -```java -// com/label/common/context/CompanyContext.java -public final class CompanyContext { - private static final ThreadLocal COMPANY_ID = new ThreadLocal<>(); - - public static void set(Long companyId) { COMPANY_ID.set(companyId); } - public static Long get() { return COMPANY_ID.get(); } - public static void clear() { COMPANY_ID.remove(); } // 必须在 finally 块调用 -} -``` - -在 Shiro `TokenFilter` 的 `executeLogin` 成功后注入;在过滤器的 `finally` 块调用 `CompanyContext.clear()`,防止线程池复用时数据串漏至其他租户。 - -**禁止**调用方通过请求体传入 `company_id` 作为参数。所有 Service 通过 `CompanyContext.get()` 获取。 - -MyBatis Plus 配置 `TenantLineInnerInterceptor` 自动向所有 Mapper 查询注入 `company_id = ?` 条件: - -```java -@Bean -public MybatisPlusInterceptor mybatisPlusInterceptor() { - MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); - interceptor.addInnerInterceptor(new TenantLineInnerInterceptor( - new TenantLineHandler() { - public Expression getTenantId() { - return new LongValue(CompanyContext.get()); - } - public String getTenantIdColumn() { return "company_id"; } - } - )); - return interceptor; -} -``` - -### 3.4 Shiro 配置 - -#### TokenFilter(继承 AuthenticatingFilter) - -```java -// com/label/common/shiro/TokenFilter.java -public class TokenFilter extends AuthenticatingFilter { - - @Override - protected AuthenticationToken createToken(ServletRequest req, ServletResponse res) { - String token = extractToken((HttpServletRequest) req); - return new TokenAuthenticationToken(token); - } - - @Override - protected boolean onAccessDenied(ServletRequest req, ServletResponse res) throws Exception { - String token = extractToken((HttpServletRequest) req); - if (token == null) { - sendUnauthorized(res, "缺少 Token"); - return false; - } - return executeLogin(req, res); - } - - @Override - protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, - ServletRequest req, ServletResponse res) throws Exception { - UserSession session = (UserSession) subject.getPrincipal(); - // 滑动过期:每次有效请求重置 TTL - long ttl = sysConfigService.getLong("token_ttl_seconds", 7200L); - redisTemplate.expire(RedisKeyManager.tokenKey(session.getToken()), ttl, SECONDS); - // 注入租户上下文(在整个 Filter 链执行期间有效,由 doFilterInternal 的 finally 清理) - CompanyContext.set(session.getCompanyId()); - return true; // 继续执行 Filter 链(进入 Controller) - } - - // ⚠️ 必须在此处清理 ThreadLocal,而非 onLoginSuccess—— - // onLoginSuccess 返回 true 后 Filter 链才继续执行,此时 Controller 尚未运行。 - // doFilterInternal 的 finally 块保证请求结束后(无论正常还是异常)都清理上下文。 - @Override - protected void doFilterInternal(ServletRequest req, ServletResponse res, FilterChain chain) - throws ServletException, IOException { - try { - super.doFilterInternal(req, res, chain); - } finally { - CompanyContext.clear(); - } - } - - private String extractToken(HttpServletRequest req) { - String header = req.getHeader("Authorization"); - if (header != null && header.startsWith("Bearer ")) { - return header.substring(7); - } - return null; - } -} -``` - -#### UserRealm(继承 AuthorizingRealm) - -```java -// com/label/common/shiro/UserRealm.java -public class UserRealm extends AuthorizingRealm { - - // 认证:从 Redis 验证 token:{uuid} 是否存在 - @Override - protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { - String uuid = ((TokenAuthenticationToken) token).getToken(); - Map data = redisTemplate.opsForHash() - .entries(RedisKeyManager.tokenKey(uuid)); - if (data.isEmpty()) throw new UnknownAccountException("Token 无效或已过期"); - UserSession session = UserSession.from(uuid, data); - return new SimpleAuthenticationInfo(session, uuid, getName()); - } - - // 鉴权:先查 Redis user:perm:{userId},未命中查 PostgreSQL - @Override - protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { - UserSession session = (UserSession) principals.getPrimaryPrincipal(); - String permKey = RedisKeyManager.userPermKey(session.getUserId()); - String cachedRole = (String) redisTemplate.opsForValue().get(permKey); - if (cachedRole == null) { - SysUser user = sysUserMapper.selectById(session.getUserId()); - cachedRole = user.getRole(); - redisTemplate.opsForValue().set(permKey, cachedRole, 5, MINUTES); - } - SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); - info.addRole(cachedRole); - // 角色继承:高级角色包含所有低级角色权限 - addInheritedRoles(info, cachedRole); - return info; - } - - private void addInheritedRoles(SimpleAuthorizationInfo info, String role) { - // ADMIN ⊃ REVIEWER ⊃ ANNOTATOR ⊃ UPLOADER - switch (role) { - case "ADMIN" -> info.addRoles(Set.of("REVIEWER", "ANNOTATOR", "UPLOADER")); - case "REVIEWER" -> info.addRoles(Set.of("ANNOTATOR", "UPLOADER")); - case "ANNOTATOR" -> info.addRoles(Set.of("UPLOADER")); - } - } -} -``` - -#### ShiroConfig 过滤器链 - -```java -@Bean -public ShiroFilterChainDefinition shiroFilterChainDefinition() { - DefaultShiroFilterChainDefinition chain = new DefaultShiroFilterChainDefinition(); - chain.addPathDefinition("/api/auth/login", "anon"); - chain.addPathDefinition("/api/**", "tokenFilter"); - return chain; -} -``` - -权限声明使用注解,禁止在 Service 内使用 if-role 临时判断: - -```java -@RequiresRoles("ADMIN") -public void createTask(...) - -@RequiresRoles(value = {"ANNOTATOR", "REVIEWER", "ADMIN"}, logical = Logical.OR) -public void claimTask(...) -``` - -#### 角色变更立即驱逐缓存 - -```java -// UserService.updateRole() 内 -@Transactional -public void updateRole(Long userId, String newRole) { - userMapper.updateRole(userId, newRole); - // 立即驱逐,不等 TTL 自然过期(延迟在禁用高权限账号时存在安全窗口) - redisTemplate.delete(RedisKeyManager.userPermKey(userId)); -} -``` - -### 3.5 Redis Key 管理 - -```java -// com/label/common/redis/RedisKeyManager.java -public final class RedisKeyManager { - public static String tokenKey(String uuid) { return "token:" + uuid; } - public static String userPermKey(Long userId) { return "user:perm:" + userId; } - public static String taskClaimKey(Long taskId){ return "task:claim:" + taskId; } -} -``` - -禁止在上述三类命名空间之外自造 Key 用于认证、权限或锁目的。 - -### 3.6 AOP 审计日志切面 - -```java -// com/label/common/aop/OperationLog.java(注解) -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface OperationLog { - String type(); // 对应 operation_type 枚举值,如 "TASK_CLAIM" - String targetType() default ""; -} - -// com/label/common/aop/AuditAspect.java -@Aspect -@Component -public class AuditAspect { - - @Around("@annotation(operationLog)") - public Object audit(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable { - String result = "SUCCESS"; - String errorMsg = null; - Object returnVal = null; - try { - returnVal = pjp.proceed(); - } catch (Throwable ex) { - result = "FAIL"; - errorMsg = ex.getMessage(); - throw ex; - } finally { - // 业务事务已 commit/rollback 后,以独立操作写入审计记录 - // 审计写入失败只记录 error 日志,禁止回滚业务事务 - try { - saveAuditLog(operationLog.type(), operationLog.targetType(), result, errorMsg); - } catch (Exception auditEx) { - log.error("审计日志写入失败: type={}", operationLog.type(), auditEx); - } - } - return returnVal; - } - - private void saveAuditLog(String type, String targetType, String result, String errorMsg) { - UserSession session = getCurrentSession(); - SysOperationLog log = SysOperationLog.builder() - .companyId(CompanyContext.get()) - .operatorId(session != null ? session.getUserId() : null) - .operatorName(session != null ? session.getUsername() : "unknown") - .operationType(type) - .targetType(targetType.isEmpty() ? null : targetType) - .result(result) - .errorMessage(errorMsg) - .ipAddress(getClientIp()) - .build(); - operationLogMapper.insert(log); - } -} -``` - -### 3.7 RustFS S3 客户端 - -RustFS 实现 S3 兼容接口,使用 AWS SDK for Java v2 连接: - -```java -// com/label/common/storage/RustFsClient.java -@Component -public class RustFsClient { - - private final S3Client s3Client; // 配置 endpoint 指向 RustFS 地址 - - public String upload(String bucket, String key, InputStream data, long size) { - PutObjectRequest req = PutObjectRequest.builder().bucket(bucket).key(key).build(); - s3Client.putObject(req, RequestBody.fromInputStream(data, size)); - return key; - } - - public InputStream download(String bucket, String key) { - GetObjectRequest req = GetObjectRequest.builder().bucket(bucket).key(key).build(); - return s3Client.getObject(req); - } - - public void delete(String bucket, String key) { - s3Client.deleteObject(b -> b.bucket(bucket).key(key)); - } - - public String getPresignedUrl(String bucket, String key, Duration expiry) { - S3Presigner presigner = S3Presigner.builder().endpointOverride(endpointUri).build(); - return presigner.presignGetObject(b -> b.signatureDuration(expiry) - .getObjectRequest(r -> r.bucket(bucket).key(key))).url().toString(); - } -} -``` - -**对象存储路径规范:** - -| 资源类型 | 存储桶 | 路径格式 | -|----------|--------|----------| -| 上传文本文件 | `source-data` | `text/{yyyyMM}/{source_id}.txt` | -| 上传图片 | `source-data` | `image/{yyyyMM}/{source_id}.jpg` | -| 上传视频 | `source-data` | `video/{yyyyMM}/{source_id}.mp4` | -| 视频帧图 | `source-data` | `frames/{source_id}/{frame_index}.jpg` | -| 视频片段转译文本 | `source-data` | `video-text/{parent_source_id}/{timestamp}.txt` | -| bbox 裁剪图 | `source-data` | `crops/{task_id}/{item_index}.jpg` | -| 导出 JSONL | `finetune-export` | `export/{batchUuid}.jsonl` | - -### 3.8 AI 服务 HTTP 客户端 - -Spring Boot 3 使用 `RestClient` 封装对 Python FastAPI 服务的同步 HTTP 调用: - -```java -// com/label/common/ai/AiServiceClient.java -@Component -public class AiServiceClient { - - private final RestClient restClient; - - public AiServiceClient(@Value("${ai.service.base-url}") String baseUrl) { - this.restClient = RestClient.builder().baseUrl(baseUrl).build(); - } - - // POST /api/v1/text/extract - public TextExtractResponse extractText(String filePath, String model) { - return restClient.post().uri("/api/v1/text/extract") - .body(Map.of("file_path", filePath, "model", model)) - .retrieve().body(TextExtractResponse.class); - } - - // POST /api/v1/image/extract - public ImageExtractResponse extractImage(String imagePath, String model) { - return restClient.post().uri("/api/v1/image/extract") - .body(Map.of("image_path", imagePath, "model", model)) - .retrieve().body(ImageExtractResponse.class); - } - - // POST /api/v1/video/extract-frames - public VideoFramesResponse extractFrames(String videoPath, String frameInterval, String mode) { - return restClient.post().uri("/api/v1/video/extract-frames") - .body(Map.of("video_path", videoPath, "frame_interval", frameInterval, "mode", mode)) - .retrieve().body(VideoFramesResponse.class); - } - - // POST /api/v1/video/to-text - public VideoToTextResponse videoToText(String videoPath, int startSec, int endSec, String model) { - return restClient.post().uri("/api/v1/video/to-text") - .body(Map.of("video_path", videoPath, "start_sec", startSec, "end_sec", endSec, "model", model)) - .retrieve().body(VideoToTextResponse.class); - } - - // POST /api/v1/qa/gen-text - public QaGenResponse genTextQa(List triples, String model, String promptTemplate) { - return restClient.post().uri("/api/v1/qa/gen-text") - .body(Map.of("items", triples, "model", model, "prompt_template", promptTemplate)) - .retrieve().body(QaGenResponse.class); - } - - // POST /api/v1/qa/gen-image - public QaGenResponse genImageQa(List quadruples, String model, String promptTemplate) { - return restClient.post().uri("/api/v1/qa/gen-image") - .body(Map.of("items", quadruples, "model", model, "prompt_template", promptTemplate)) - .retrieve().body(QaGenResponse.class); - } - - // POST /api/v1/finetune/start - public FinetuneStartResponse startFinetune(String jsonlUrl, String baseModel, Map params) { - return restClient.post().uri("/api/v1/finetune/start") - .body(Map.of("jsonl_url", jsonlUrl, "base_model", baseModel, "params", params)) - .retrieve().body(FinetuneStartResponse.class); - } - - // GET /api/v1/finetune/status/{jobId} - public FinetuneStatusResponse getFinetuneStatus(String jobId) { - return restClient.get().uri("/api/v1/finetune/status/{jobId}", jobId) - .retrieve().body(FinetuneStatusResponse.class); - } -} -``` - -[↑ 返回目录](#目录) - ---- - -## 四、业务模块纵切 - -### 4.1 用户与权限模块 - -**Entity(SysUser)**:字段同 DDL,`passwordHash` 字段加 `@JsonIgnore` 禁止序列化到响应。 - -**AuthService 核心逻辑:** - -```java -// 登录 -@OperationLog(type = "USER_LOGIN") -public String login(Long companyId, String username, String password, String ip) { - SysUser user = userMapper.selectByCompanyAndUsername(companyId, username); - if (user == null || !BCrypt.checkpw(password, user.getPasswordHash())) - throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误"); - if ("DISABLED".equals(user.getStatus())) - throw new BusinessException("USER_DISABLED", "账号已禁用"); - - String token = UUID.randomUUID().toString(); - Map tokenData = Map.of( - "userId", user.getId(), "role", user.getRole(), "companyId", user.getCompanyId(), - "username", user.getUsername() - ); - long ttl = sysConfigService.getLong("token_ttl_seconds", 7200L); - redisTemplate.opsForHash().putAll(RedisKeyManager.tokenKey(token), tokenData); - redisTemplate.expire(RedisKeyManager.tokenKey(token), ttl, SECONDS); - return token; -} - -// 退出登录 -@OperationLog(type = "USER_LOGOUT") -public void logout(String token) { - redisTemplate.delete(RedisKeyManager.tokenKey(token)); -} -``` - -**接口清单:** - -| 方法 | 路径 | 最低权限 | 说明 | -|------|------|----------|------| -| POST | `/api/auth/login` | 匿名 | 返回 Token(UUID) | -| POST | `/api/auth/logout` | 已登录 | 删除 Redis Token | -| GET | `/api/auth/me` | 已登录 | 当前用户信息与角色 | -| GET | `/api/users` | ADMIN | 分页查询用户列表 | -| POST | `/api/users` | ADMIN | 创建用户 | -| PUT | `/api/users/{id}` | ADMIN | 更新用户信息 | -| PUT | `/api/users/{id}/status` | ADMIN | 启用/禁用账号(同步驱逐权限缓存) | -| PUT | `/api/users/{id}/role` | ADMIN | 变更角色(同步驱逐权限缓存) | - ---- - -### 4.2 资料管理模块 - -**SourceService 核心逻辑:** - -```java -@OperationLog(type = "SOURCE_UPLOAD") -@Transactional -public SourceData upload(MultipartFile file, String dataType) { - Long companyId = CompanyContext.get(); - // 1. 先创建 source_data 记录获取 ID(用于构造存储路径) - SourceData sd = SourceData.builder() - .companyId(companyId).uploaderId(getCurrentUserId()) - .dataType(dataType).fileName(file.getOriginalFilename()) - .fileSize(file.getSize()).bucketName("source-data") - .status("PENDING").build(); - sourceDataMapper.insert(sd); - - // 2. 构造路径并上传至 RustFS - String path = buildPath(dataType, sd.getId()); - rustFsClient.upload("source-data", path, file.getInputStream(), file.getSize()); - - // 3. 更新 file_path - sd.setFilePath(path); - sourceDataMapper.updateById(sd); - return sd; -} - -private String buildPath(String dataType, Long sourceId) { - String ym = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); - return switch (dataType) { - case "TEXT" -> "text/" + ym + "/" + sourceId + ".txt"; - case "IMAGE" -> "image/" + ym + "/" + sourceId + ".jpg"; - case "VIDEO" -> "video/" + ym + "/" + sourceId + ".mp4"; - default -> throw new BusinessException("INVALID_TYPE", "不支持的资料类型"); - }; -} -``` - -**接口清单:** - -| 方法 | 路径 | 最低权限 | 说明 | -|------|------|----------|------| -| POST | `/api/source/upload` | UPLOADER | 上传文件,创建 source_data 记录 | -| GET | `/api/source/list` | UPLOADER | 分页查询(UPLOADER 只见自己,ADMIN 见全部) | -| GET | `/api/source/{id}` | UPLOADER | 查看资料详情 | -| DELETE | `/api/source/{id}` | ADMIN | 删除资料及 RustFS 文件 | - ---- - -### 4.3 任务管理模块(含并发控制) - -**TaskClaimService.claim() — 双重保障:** - -```java -@Transactional -@OperationLog(type = "TASK_CLAIM") -public void claim(Long taskId) { - Long userId = getCurrentUserId(); - // 第一重:Redis 分布式锁(TTL 30s,SET NX) - Boolean locked = redisTemplate.opsForValue() - .setIfAbsent(RedisKeyManager.taskClaimKey(taskId), userId.toString(), 30, SECONDS); - if (!Boolean.TRUE.equals(locked)) - throw new BusinessException("TASK_CLAIMED", "任务已被他人领取"); - - // 第二重:数据库乐观约束(WHERE status = 'UNCLAIMED') - // UPDATE annotation_task SET status='IN_PROGRESS', claimed_by=?, claimed_at=NOW() - // WHERE id=? AND status='UNCLAIMED' AND company_id=? - int affected = taskMapper.claimTask(taskId, userId, CompanyContext.get()); - if (affected == 0) - throw new BusinessException("TASK_CLAIMED", "任务已被他人领取"); - - // 写入任务历史(同一事务) - insertHistory(taskId, "UNCLAIMED", "IN_PROGRESS", userId, null); -} - -public void unclaim(Long taskId) { - StateValidator.assertTransition(TaskStatus.IN_PROGRESS, TaskStatus.UNCLAIMED, TaskStatus.TRANSITIONS); - // 更新 claimed_by = NULL, status = UNCLAIMED - taskMapper.unclaim(taskId, getCurrentUserId(), CompanyContext.get()); - redisTemplate.delete(RedisKeyManager.taskClaimKey(taskId)); - insertHistory(taskId, "IN_PROGRESS", "UNCLAIMED", getCurrentUserId(), null); -} -``` - -**每次 status 变更必须在同一事务中调用 `insertHistory()` 写入 `annotation_task_history`。** - -**接口清单:** - -| 方法 | 路径 | 最低权限 | 说明 | -|------|------|----------|------| -| POST | `/api/tasks` | ADMIN | 为指定 source 创建 EXTRACTION 任务 | -| GET | `/api/tasks/pool` | ANNOTATOR | 查看可领取任务池(UNCLAIMED 状态)。ANNOTATOR 只看到 EXTRACTION 类型;REVIEWER 只看到 SUBMITTED 状态(即审批队列,与 pending-review 等价);两者均分页,不可无界查询 | -| POST | `/api/tasks/{id}/claim` | ANNOTATOR | 领取任务(争抢式,Redis SET NX + DB 乐观锁) | -| POST | `/api/tasks/{id}/unclaim` | ANNOTATOR | 放弃任务,退回任务池 | -| GET | `/api/tasks/mine` | ANNOTATOR | 查询我领取的任务列表(包含 IN_PROGRESS、SUBMITTED、REJECTED 状态,分页) | -| POST | `/api/tasks/{id}/reclaim` | ANNOTATOR | 重领被驳回的任务(task.status 必须为 REJECTED 且 claimedBy = 当前用户),状态流转 REJECTED → IN_PROGRESS | -| GET | `/api/tasks/pending-review` | REVIEWER | 查看待我审批的任务列表(status = SUBMITTED,分页);REVIEWER 的专属审批入口 | -| GET | `/api/tasks/{id}` | ANNOTATOR | 查看任务详情 | -| GET | `/api/tasks` | ADMIN | 查询全部任务(支持过滤,分页) | -| PUT | `/api/tasks/{id}/reassign` | ADMIN | 强制转移任务归属 | - ---- - -### 4.4 标注工作台模块(EXTRACTION 阶段) - -**ExtractionService 核心逻辑:** - -```java -// AI 辅助预标注(标注员领取任务后调用) -public AnnotationResult aiPreAnnotate(Long taskId) { - AnnotationTask task = taskMapper.selectById(taskId); - SourceData source = sourceDataMapper.selectById(task.getSourceId()); - Object aiResult = "IMAGE".equals(source.getDataType()) || task.getVideoUnitType() != null - ? aiServiceClient.extractImage(resolveImagePath(task), task.getAiModel()) - : aiServiceClient.extractText(source.getFilePath(), task.getAiModel()); - AnnotationResult result = buildAnnotationResult(task, aiResult); - annotationResultMapper.insertOrReplace(result); - return result; -} - -// 更新提取结果(整体覆盖,PUT 语义) -public void updateResult(Long taskId, String resultJsonStr) { - // 验证 JSON 格式合法性 - validateResultJson(resultJsonStr); - // 整体替换 result_json,禁止局部 PATCH 或逐条追加 - annotationResultMapper.updateResultJson(taskId, resultJsonStr, CompanyContext.get()); -} - -// 审批通过——两阶段:事务内完成同步步骤,事务提交后异步触发 QA 生成 -@Transactional -@OperationLog(type = "EXTRACTION_APPROVE") -public void approve(Long taskId) { - AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED"); - - // 自审校验:提交者不能审批自己的任务 - if (task.getClaimedBy().equals(getCurrentUserId())) - throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许审批自己提交的任务"); - - AnnotationResult result = annotationResultMapper.selectByTaskId(taskId); - - // 1. annotation_result.is_final = true - result.setIsFinal(true); - annotationResultMapper.updateById(result); - - // 2. annotation_task.status → APPROVED - StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.APPROVED, TaskStatus.TRANSITIONS); - task.setStatus("APPROVED"); - task.setCompletedAt(LocalDateTime.now()); - taskMapper.updateById(task); - - // 3. 写入任务历史 - insertHistory(taskId, "SUBMITTED", "APPROVED", getCurrentUserId(), null); - - // 4. 发布领域事件,事务提交后异步执行 QA 生成(步骤 5-7) - // 注:AI HTTP 调用禁止在 @Transactional 内同步执行——会占用数据库连接直至 AI 响应, - // 且 AI 失败会错误地回滚已完成的审批。 - // 使用 @TransactionalEventListener(phase = AFTER_COMMIT) 保证先提交再触发。 - eventPublisher.publishEvent(new ExtractionApprovedEvent(taskId, task.getSourceId(), - getSourceType(task), CompanyContext.get())); -} - -// 驳回——状态回退,标注员可重领 -@Transactional -@OperationLog(type = "EXTRACTION_REJECT") -public void reject(Long taskId, String reason) { - AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED"); - - // 自审校验 - if (task.getClaimedBy().equals(getCurrentUserId())) - throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许驳回自己提交的任务"); - - StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.REJECTED, TaskStatus.TRANSITIONS); - task.setStatus("REJECTED"); - taskMapper.updateById(task); - insertHistory(taskId, "SUBMITTED", "REJECTED", getCurrentUserId(), reason); - // source_data.status 保持 EXTRACTING 不变,待标注员重新提交后再推进 -} - -// ExtractionApprovedEventListener(@TransactionalEventListener,独立事务) -// 负责 5-7 步:AI 调用 → 写 training_dataset → 创建 QA 任务 → 更新 source_data -@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) -@Transactional(propagation = Propagation.REQUIRES_NEW) -public void onExtractionApproved(ExtractionApprovedEvent event) { - AnnotationTask task = taskMapper.selectById(event.getTaskId()); - AnnotationResult result = annotationResultMapper.selectByTaskId(event.getTaskId()); - - // 5. 调用 AI 生成候选问答对(在事务外执行,失败不影响审批结果) - String promptKey = "IMAGE".equals(event.getSourceType()) ? "prompt_qa_gen_image" : "prompt_qa_gen_text"; - String promptTemplate = sysConfigService.get(promptKey); - QaGenResponse qaResponse = generateQa(task, result, promptTemplate); - - // 6. 将候选问答对写入 training_dataset(PENDING_REVIEW) - List samples = buildTrainingSamples(task, result, qaResponse); - trainingDatasetMapper.batchInsert(samples); - - // 7. 创建 QA_GENERATION 阶段任务(UNCLAIMED) - AnnotationTask qaTask = buildQaTask(task); - taskMapper.insert(qaTask); - insertHistory(qaTask.getId(), null, "UNCLAIMED", task.getClaimedBy(), null); - - // 8. source_data.status → QA_REVIEW - sourceDataMapper.updateStatus(event.getSourceId(), "QA_REVIEW", event.getCompanyId()); -} -``` - -**接口清单:** - -| 方法 | 路径 | 最低权限 | 说明 | -|------|------|----------|------| -| GET | `/api/extraction/{taskId}` | ANNOTATOR | 获取当前提取结果(含 AI 预标注) | -| PUT | `/api/extraction/{taskId}` | ANNOTATOR | 更新提取结果(整体 JSONB 覆盖) | -| POST | `/api/extraction/{taskId}/submit` | ANNOTATOR | 提交提取结果,进入审批队列 | -| POST | `/api/extraction/{taskId}/approve` | REVIEWER | 审批通过,自动触发 QA 任务创建 | -| POST | `/api/extraction/{taskId}/reject` | REVIEWER | 驳回,附驳回原因 | - ---- - -### 4.5 问答生成模块(QA_GENERATION 阶段) - -`QaService` 的整体覆盖逻辑与 `ExtractionService` 一致(PUT 语义,禁止局部 PATCH)。 - -**approve 级联动作(同一事务):** - -```java -@Transactional -@OperationLog(type = "QA_APPROVE") -public void approve(Long taskId) { - // 1. 先校验任务合法性(必须在任何 DB 写入之前执行,避免校验失败时数据已被修改) - AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED"); - - // 自审校验:提交者不能审批自己的任务 - if (task.getClaimedBy().equals(getCurrentUserId())) - throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许审批自己提交的任务"); - - // 2. training_dataset.status → APPROVED - trainingDatasetMapper.approveByTaskId(taskId, getCurrentUserId(), CompanyContext.get()); - - // 3. annotation_task.status → APPROVED - StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.APPROVED, TaskStatus.TRANSITIONS); - task.setStatus("APPROVED"); - task.setCompletedAt(LocalDateTime.now()); - taskMapper.updateById(task); - - // 4. source_data.status → APPROVED(整条流水线完成) - sourceDataMapper.updateStatus(task.getSourceId(), "APPROVED", CompanyContext.get()); - - // 5. 写入任务历史 - insertHistory(taskId, "SUBMITTED", "APPROVED", getCurrentUserId(), null); -} - -// 驳回问答对——任务退回 IN_PROGRESS,training_dataset 删除候选记录 -@Transactional -@OperationLog(type = "QA_REJECT") -public void reject(Long taskId, String reason) { - AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED"); - - // 自审校验 - if (task.getClaimedBy().equals(getCurrentUserId())) - throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许驳回自己提交的任务"); - - // 删除本次生成的候选问答对(PENDING_REVIEW 状态),待标注员修改后重新提交 - trainingDatasetMapper.deleteByTaskId(taskId, CompanyContext.get()); - - StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.REJECTED, TaskStatus.TRANSITIONS); - task.setStatus("REJECTED"); - taskMapper.updateById(task); - insertHistory(taskId, "SUBMITTED", "REJECTED", getCurrentUserId(), reason); - // source_data.status 保持 QA_REVIEW 不变 -} -``` - -**接口清单:** - -| 方法 | 路径 | 最低权限 | 说明 | -|------|------|----------|------| -| GET | `/api/qa/{taskId}` | ANNOTATOR | 获取候选问答对列表 | -| PUT | `/api/qa/{taskId}` | ANNOTATOR | 修改问答对(整体覆盖) | -| POST | `/api/qa/{taskId}/submit` | ANNOTATOR | 提交问答对,进入审批队列 | -| POST | `/api/qa/{taskId}/approve` | REVIEWER | 审批通过,写入 training_dataset | -| POST | `/api/qa/{taskId}/reject` | REVIEWER | 驳回,附驳回原因 | - ---- - -### 4.6 训练数据导出模块 - -**ExportService.createBatch() 核心逻辑:** - -```java -@Transactional -@OperationLog(type = "EXPORT_CREATE") -public ExportBatch createBatch(List sampleIds) { - Long companyId = CompanyContext.get(); - // 1. 校验样本均为 APPROVED 状态 - List samples = trainingDatasetMapper - .selectByIdsAndStatus(sampleIds, "APPROVED", companyId); - if (samples.size() != sampleIds.size()) - throw new BusinessException("INVALID_SAMPLES", "部分样本不处于 APPROVED 状态"); - - // 2. 生成 JSONL 内容(每行一个 glm_format_json) - String batchUuid = UUID.randomUUID().toString(); - String jsonl = samples.stream() - .map(s -> s.getGlmFormatJson().toString()) - .collect(Collectors.joining("\n")); - - // 3. 上传至 RustFS:finetune-export/export/{batchUuid}.jsonl - String path = "export/" + batchUuid + ".jsonl"; - byte[] bytes = jsonl.getBytes(StandardCharsets.UTF_8); - rustFsClient.upload("finetune-export", path, new ByteArrayInputStream(bytes), bytes.length); - - // 4. 更新样本 export_batch_id 与 exported_at - trainingDatasetMapper.batchUpdateExportInfo(sampleIds, batchUuid, LocalDateTime.now()); - - // 5. 写入 export_batch 记录 - ExportBatch batch = ExportBatch.builder() - .companyId(companyId).batchUuid(batchUuid).datasetFilePath(path) - .sampleCount(samples.size()).finetuneStatus("NOT_STARTED") - .createdBy(getCurrentUserId()).build(); - exportBatchMapper.insert(batch); - return batch; -} -``` - -**FinetuneService.trigger():** 调用 `aiServiceClient.startFinetune(...)` 获取 `glmJobId`,更新 `export_batch.glm_job_id` 和 `finetune_status = RUNNING`。 - -**接口清单(全部需要 ADMIN 权限):** - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/training/samples` | 分页查询已审批通过、待导出的样本 | -| POST | `/api/export/batch` | 创建导出批次,合并 JSONL 并上传 RustFS | -| POST | `/api/export/{batchId}/finetune` | 向 GLM 工厂提交微调任务 | -| GET | `/api/export/{batchId}/status` | 查询微调任务状态 | -| GET | `/api/export/list` | 分页查询所有导出批次 | - ---- - -### 4.7 系统配置模块 - -**SysConfigService.get(configKey):** 先按 `(companyId, configKey)` 查,未命中则按 `(NULL, configKey)` 查全局默认值,公司级配置优先覆盖全局值。 - -```java -public String get(String configKey) { - Long companyId = CompanyContext.get(); - // 先查公司级配置 - SysConfig cfg = configMapper.selectByCompanyAndKey(companyId, configKey); - if (cfg == null) { - // 回退到全局默认(company_id IS NULL) - cfg = configMapper.selectByCompanyAndKey(null, configKey); - } - return cfg != null ? cfg.getConfigValue() : null; -} -``` - -**接口清单(全部需要 ADMIN 权限):** - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/config` | 获取所有配置项(公司级 + 全局默认) | -| PUT | `/api/config/{key}` | 更新单项配置(含 Prompt 模板) | - ---- - -### 4.8 视频处理模块 - -**VideoProcessService 核心逻辑:** - -```java -@Transactional -@OperationLog(type = "TASK_CREATE") -public VideoProcessJob createJob(Long sourceId, String jobType, JsonNode params) { - Long companyId = CompanyContext.get(); - // 1. source_data.status → PREPROCESSING - sourceDataMapper.updateStatus(sourceId, "PREPROCESSING", companyId); - // 2. 创建 video_process_job(PENDING) - VideoProcessJob job = VideoProcessJob.builder() - .companyId(companyId).sourceId(sourceId) - .jobType(jobType).params(params).status("PENDING").build(); - videoProcessJobMapper.insert(job); - // 3. 异步调用 AI 服务(非阻塞,由 AI 服务自行回调) - triggerAiAsync(job); - return job; -} - -// AI 服务回调:幂等处理 -@Transactional -public void handleCallback(Long jobId, boolean success, String outputPath, String errorMsg) { - VideoProcessJob job = videoProcessJobMapper.selectById(jobId); - // 幂等:已是 SUCCESS 状态则静默忽略重复回调,不得重复创建 annotation_task - if ("SUCCESS".equals(job.getStatus())) return; - - if (success) { - job.setStatus("SUCCESS"); - job.setOutputPath(outputPath); - job.setCompletedAt(LocalDateTime.now()); - // source_data.status → PENDING(进入后续标注流程) - sourceDataMapper.updateStatus(job.getSourceId(), "PENDING", job.getCompanyId()); - } else { - if (job.getRetryCount() >= job.getMaxRetries()) { - // 达最大重试次数,置 FAILED,需 ADMIN 手动重置为 PENDING 后才可重新触发 - job.setStatus("FAILED"); - job.setErrorMessage(errorMsg); - sourceDataMapper.updateStatus(job.getSourceId(), "PENDING", job.getCompanyId()); - } else { - job.setStatus("RETRYING"); - job.setRetryCount(job.getRetryCount() + 1); - job.setErrorMessage(errorMsg); - } - } - videoProcessJobMapper.updateById(job); -} -``` - -[↑ 返回目录](#目录) - ---- - -## 五、状态机实现规范 - -所有状态变更**必须**经过 `StateValidator.assertTransition()` 校验,禁止绕过直接调用 Mapper 更新状态字段。 - -### 5.1 StateValidator - -```java -// com/label/common/statemachine/StateValidator.java -public final class StateValidator { - public static void assertTransition(S from, S to, Map> transitions) { - Set allowed = transitions.getOrDefault(from, Set.of()); - if (!allowed.contains(to)) - throw new BusinessException("INVALID_STATE_TRANSITION", - "非法状态转换: " + from + " → " + to); - } -} -``` - -### 5.2 source_data 状态机 - -```java -public enum SourceStatus { - PENDING, PREPROCESSING, EXTRACTING, QA_REVIEW, APPROVED; - // 注:source_data 无 REJECTED 状态。QA 阶段驳回的是 annotation_task(→ REJECTED), - // 不改变 source_data.status(保持 QA_REVIEW);重新提交后 source_data 随任务推进。 - - public static final Map> TRANSITIONS = Map.of( - PENDING, Set.of(EXTRACTING, PREPROCESSING), - PREPROCESSING, Set.of(PENDING), - EXTRACTING, Set.of(QA_REVIEW), - QA_REVIEW, Set.of(APPROVED) - ); -} -``` - -### 5.3 annotation_task 状态机 - -```java -public enum TaskStatus { - UNCLAIMED, IN_PROGRESS, SUBMITTED, APPROVED, REJECTED; - - public static final Map> TRANSITIONS = Map.of( - UNCLAIMED, Set.of(IN_PROGRESS), - IN_PROGRESS, Set.of(SUBMITTED, UNCLAIMED, IN_PROGRESS), - // IN_PROGRESS → IN_PROGRESS 用于 ADMIN 强制转移(持有人变更,状态不变) - SUBMITTED, Set.of(APPROVED, REJECTED), - REJECTED, Set.of(IN_PROGRESS) // 驳回后重拾 - ); -} -``` - -### 5.4 training_dataset 状态机 - -```java -public enum DatasetStatus { - PENDING_REVIEW, APPROVED, REJECTED; - - public static final Map> TRANSITIONS = Map.of( - PENDING_REVIEW, Set.of(APPROVED, REJECTED), - REJECTED, Set.of(PENDING_REVIEW) // 驳回后可修改重提 - ); -} -``` - -### 5.5 video_process_job 状态机 - -```java -public enum VideoJobStatus { - PENDING, RUNNING, SUCCESS, FAILED, RETRYING; - - public static final Map> TRANSITIONS = Map.of( - PENDING, Set.of(RUNNING), - RUNNING, Set.of(SUCCESS, RETRYING, FAILED), - RETRYING, Set.of(RUNNING, FAILED) - // FAILED → PENDING 由 ADMIN 手动触发接口,不在此处声明自动流转 - ); -} -``` - -[↑ 返回目录](#目录) - ---- - -## 六、Docker Compose 配置 - -```yaml -# docker-compose.yml -version: "3.9" - -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_DB: label_db - POSTGRES_USER: label - POSTGRES_PASSWORD: label_password - volumes: - - postgres_data:/var/lib/postgresql/data - - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql - ports: - - "5432:5432" - 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 - volumes: - - redis_data:/data - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "-a", "redis_password", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - rustfs: - image: rustfs/rustfs:latest # 替换为生产可用的实际镜像 - environment: - RUSTFS_ACCESS_KEY: minioadmin - RUSTFS_SECRET_KEY: minioadmin - volumes: - - rustfs_data:/data - ports: - - "9000:9000" # S3 API 端口 - - "9001:9001" # Web 控制台端口 - - backend: - build: - context: . - dockerfile: Dockerfile - environment: - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/label_db - SPRING_DATASOURCE_USERNAME: label - SPRING_DATASOURCE_PASSWORD: label_password - SPRING_REDIS_HOST: redis - SPRING_REDIS_PORT: 6379 - SPRING_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 - ports: - - "8080:8080" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - rustfs: - condition: service_started - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] - interval: 15s - retries: 5 - - ai-service: - build: - context: ../ai_service # Python FastAPI 服务所在目录 - dockerfile: Dockerfile - environment: - RUSTFS_ENDPOINT: http://rustfs:9000 - RUSTFS_ACCESS_KEY: minioadmin - RUSTFS_SECRET_KEY: minioadmin - ports: - - "8000:8000" - depends_on: - - rustfs - - frontend: - image: nginx:alpine - volumes: - - ../frontend/dist:/usr/share/nginx/html:ro - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ports: - - "80:80" - depends_on: - backend: - condition: service_healthy - -volumes: - postgres_data: - redis_data: - rustfs_data: -``` - -**Dockerfile(backend):** - -> 完整的多阶段构建 Dockerfile 见 [9.5 Dockerfile 更新](#95-dockerfile-更新)。 -> 以下为旧版占位,已被新版替代(使用薄 jar + start.sh 方式)。 - -```dockerfile -# 已废弃——使用 fat JAR 方式,不符合新部署规范 -# FROM eclipse-temurin:17-jre-alpine -# COPY target/label-backend-*.jar app.jar -# ENTRYPOINT ["java", "-jar", "app.jar"] -``` - -[↑ 返回目录](#目录) - ---- - -## 七、测试策略 - -### 7.1 基本原则 - -- 集成测试**必须**使用真实的 PostgreSQL 和 Redis 实例(Testcontainers),禁止仅 Mock 数据库 -- 每个受保护接口组**必须**有至少一个集成测试覆盖 Shiro 认证/鉴权过滤器链(缺少 Token → 401,角色不足 → 403) - -### 7.2 并发任务领取测试(必须) - -```java -@Test -void concurrentClaimShouldOnlySucceedOnce() throws InterruptedException { - // 准备:创建一个 UNCLAIMED 任务 - Long taskId = createUnclaimedTask(); - int threadCount = 10; - CountDownLatch latch = new CountDownLatch(1); - AtomicInteger successCount = new AtomicInteger(0); - ExecutorService pool = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - pool.submit(() -> { - try { - latch.await(); - taskClaimService.claim(taskId); - successCount.incrementAndGet(); - } catch (BusinessException e) { - // 预期:其余线程抛出"任务已被领取" - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - } - latch.countDown(); - pool.awaitTermination(5, SECONDS); - - // 验证:仅 1 个线程领取成功 - assertThat(successCount.get()).isEqualTo(1); - AnnotationTask task = taskMapper.selectById(taskId); - assertThat(task.getStatus()).isEqualTo("IN_PROGRESS"); - assertThat(task.getClaimedBy()).isNotNull(); -} -``` - -### 7.3 视频回调幂等测试(必须) - -```java -@Test -void duplicateSuccessCallbackShouldBeIdempotent() { - Long sourceId = createVideoSource(); - VideoProcessJob job = videoProcessService.createJob(sourceId, "FRAME_EXTRACT", params); - - // 第一次成功回调 - videoProcessService.handleCallback(job.getId(), true, "frames/1/0.jpg", null); - // 重复成功回调 - videoProcessService.handleCallback(job.getId(), true, "frames/1/0.jpg", null); - - // 验证:只创建一个 annotation_task - long taskCount = annotationTaskMapper.countBySourceId(sourceId); - assertThat(taskCount).isEqualTo(1); -} -``` - -### 7.4 状态机越界拒绝测试 - -```java -@Test -void illegalStateTransitionShouldThrow() { - // 验证:APPROVED → IN_PROGRESS 被拒绝 - assertThatThrownBy(() -> - StateValidator.assertTransition(TaskStatus.APPROVED, TaskStatus.IN_PROGRESS, TaskStatus.TRANSITIONS) - ).isInstanceOf(BusinessException.class) - .hasMessageContaining("非法状态转换"); -} -``` - -### 7.5 多租户隔离测试 - -```java -@Test -void companyACannotAccessCompanyBData() { - Long sourceIdB = createSourceForCompany(companyB); - // 以 companyA 身份请求 companyB 的资源 - CompanyContext.set(companyA); - assertThatThrownBy(() -> sourceService.findById(sourceIdB)) - .isInstanceOf(BusinessException.class); -} -``` - -[↑ 返回目录](#目录) - ---- - -## 八、宪章合规检查清单 - -PR 合并前评审人**必须**逐条核对以下清单: - -| # | 宪章原则 | 实现位置 | 检查要点 | -|---|----------|----------|----------| -| 1 | 环境约束(JDK 17、SB 3、Shiro、MyBatis Plus) | `pom.xml` | 版本号符合约束;无 Spring Security 并行引入 | -| 2 | 多租户数据隔离 | `TenantLineInnerInterceptor`、`CompanyContext` | 所有 Mapper 自动注入 company_id;ThreadLocal 在 finally 块清理 | -| 3 | BCrypt 密码、UUID T//oken、滑动过期、禁 JWT | `AuthService`、`TokenFilter`、`RedisKeyManager` | 无明文密码存储;每次有效请求重置 TTL;无 JWT 库引入 | -| 4 | 分级 RBAC、权限注解、角色变更驱逐缓存 | `UserRealm`、`@RequiresRoles`、`UserService` | 无 if-role 临时判断;`updateRole()` 立即删缓存 | -| 5 | 双流水线、级联触发 QA 任务、parent_source_id 溯源 | `ExtractionService.approve()` | 级联动作在同一 @Transactional 内;视频转文本的 parent_source_id 不为空 | -| 6 | 状态机完整性 | `StateValidator`、各 `*Status` 枚举 | 所有状态变更调用 `StateValidator`;无绕过直接写 Mapper 的路径 | -| 7 | 任务争抢双重保障 | `TaskClaimService.claim()` | Redis SET NX + DB WHERE status='UNCLAIMED' 两道并存 | -| 8 | 异步任务幂等、重试上限、FAILED 需手动重置 | `VideoProcessService.handleCallback()` | 重复成功回调静默忽略;`retry_count >= max_retries` 置 FAILED | -| 9 | 只追加审计日志、AOP 切面、审计失败不回滚业务 | `AuditAspect`、`@OperationLog` | `sys_operation_log` 无 UPDATE/DELETE;审计异常仅 error 日志 | -| 10 | RESTful URL、统一响应格式、分页必须 | `Result`、各 Controller | 无动词路径;无裸 POJO 返回;所有列表接口有分页参数 | -| 11 | YAGNI:业务在 Service,Controller 只处理 HTTP | 包结构 | Controller 无业务判断逻辑;无未使用的抽象层 | -| 12 | 部署包结构规范(bin/etc/libs/logs 四级) | `pom.xml`、`distribution.xml` | fat JAR 已移除;assembly 产出 zip + tar.gz;libs/ 含薄 jar + 所有依赖 | -| 13 | start.sh 启动可执行、Docker 兼容 | `src/main/scripts/start.sh` | Docker 内 exec 前台运行;VM 内 nohup 后台运行;日志写入 logs/ | -| 14 | 日志级别统一 INFO、无 log.debug 残留 | 全体 Service 类 | `grep -r "log\.debug"` 返回 0 结果;logback.xml root level=INFO | - -[↑ 返回目录](#目录) - ---- - -## 九、部署与发布 - -### 9.1 Maven 构建配置变更 - -**目标**:用薄 jar(thin jar)替代 Spring Boot fat JAR,由 Assembly Plugin 组装可分发压缩包。 - -**移除** `spring-boot-maven-plugin`(fat JAR),**替换为**以下三个插件: - -```xml - - - - - - org.apache.maven.plugins - maven-jar-plugin - - ${project.build.directory}/libs - - - com.label.LabelBackendApplication - false - - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - - - copy-dependencies - package - copy-dependencies - - ${project.build.directory}/libs - test - - - - - - - - org.apache.maven.plugins - maven-assembly-plugin - - - create-distribution - package - single - - - src/main/assembly/distribution.xml - - ${project.artifactId}-${project.version} - false - - - - - - - -``` - -**Assembly 描述符** `src/main/assembly/distribution.xml`: - -```xml - - dist - - zip - tar.gz - - true - - - - - src/main/scripts/start.sh - bin - 0755 - - - - - - - src/main/resources - etc - - application.yml - logback.xml - - - - - src/main/assembly/empty-logs - logs - - - - - - - libs - true - runtime - - - -``` - -> **注意**:需在项目根创建空目录占位文件 `src/main/assembly/empty-logs/.gitkeep`, -> 否则 Assembly Plugin 会因源目录不存在而报错。 - ---- - -### 9.2 分发包结构 - -`mvn clean package` 后在 `target/` 生成: - -``` -target/ -├── libs/ -│ ├── label-backend-1.0.0-SNAPSHOT.jar ← 薄 jar(仅 class) -│ ├── spring-boot-starter-web-3.2.5.jar -│ ├── mybatis-plus-boot-starter-3.5.9.jar -│ └── ... (全部运行时依赖) -├── label-backend-1.0.0-SNAPSHOT.zip -└── label-backend-1.0.0-SNAPSHOT.tar.gz -``` - -压缩包解压后的内部结构: - -``` -label-backend-1.0.0-SNAPSHOT/ -├── bin/ -│ └── start.sh # 启动脚本(0755) -├── etc/ -│ ├── application.yml # Spring 配置(生产环境按需修改) -│ └── logback.xml # 日志配置(INFO + 60 MB 滚动) -├── libs/ -│ ├── label-backend-1.0.0-SNAPSHOT.jar -│ └── ... (所有依赖 jar) -└── logs/ # 空目录,运行时写入日志 -``` - ---- - -### 9.3 start.sh 启动脚本 - -新增文件:`src/main/scripts/start.sh` - -```bash -#!/bin/bash -# label-backend 启动脚本 -# - Docker 环境(检测 /.dockerenv):exec 前台运行,保持容器进程存活 -# - 裸机 / VM:nohup 后台运行,日志追加至 logs/startup.log - -set -e - -BASEDIR=$(cd "$(dirname "$0")/.." && pwd) -LIBDIR="$BASEDIR/libs" -CONFDIR="$BASEDIR/etc" -LOGDIR="$BASEDIR/logs" - -mkdir -p "$LOGDIR" - -JVM_OPTS="${JVM_OPTS:--Xms512m -Xmx1024m}" -MAIN_CLASS="com.label.LabelBackendApplication" -JAVA_ARGS="$JVM_OPTS \ - -Dspring.config.location=file:$CONFDIR/application.yml \ - -Dlogging.config=file:$CONFDIR/logback.xml \ - -cp $LIBDIR/*" - -if [ -f /.dockerenv ]; then - # Docker 容器:exec 替换当前进程,PID=1 接管信号 - exec java $JAVA_ARGS $MAIN_CLASS -else - # 裸机 / VM:nohup 后台运行 - nohup java $JAVA_ARGS $MAIN_CLASS >> "$LOGDIR/startup.log" 2>&1 & - echo "label-backend started, PID=$!" -fi -``` - ---- - -### 9.4 logback.xml 配置 - -新增文件:`src/main/resources/logback.xml` - -```xml - - - - - - - - - - - ${LOG_PATTERN} - - - - - - ${LOG_PATH}/${APP_NAME}.log - - ${LOG_PATTERN} - - - ${LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log - 60MB - 30 - 3GB - - - - - - - - - -``` - -> **说明**:`LOG_PATH` 可通过环境变量或 JVM 参数覆盖,默认指向相对于工作目录的 `logs/`。 -> Docker Compose 中如需持久化日志,挂载 `/app/logs` 卷并设置 `LOG_PATH=/app/logs`。 - ---- - -### 9.5 Dockerfile 更新 - -替换 `Dockerfile` 全文: - -```dockerfile -# 构建阶段:Maven + JDK 17 编译,生成薄 jar 及依赖 -FROM maven:3.9-eclipse-temurin-17-alpine AS builder -WORKDIR /app - -# 优先复制 pom.xml 利用 Docker 层缓存(依赖不变时跳过 go-offline) -COPY pom.xml . -RUN mvn dependency:go-offline -q - -COPY src ./src -RUN mvn clean package -DskipTests -q - -# 运行阶段:仅含 JRE 的精简镜像 -FROM eclipse-temurin:17-jre-alpine -WORKDIR /app - -# 复制部署结构:bin/ libs/ etc/ -COPY --from=builder /app/src/main/scripts/start.sh bin/start.sh -COPY --from=builder /app/target/libs/ libs/ -COPY --from=builder /app/src/main/resources/application.yml etc/application.yml -COPY --from=builder /app/src/main/resources/logback.xml etc/logback.xml - -RUN mkdir -p logs && chmod +x bin/start.sh - -EXPOSE 8080 - -# start.sh 检测到 /.dockerenv 后自动以 exec 前台方式运行 -ENTRYPOINT ["bin/start.sh"] -``` - -**Docker Compose 中配置日志路径持久化(可选):** - -```yaml -backend: - build: ./label_backend - volumes: - - ./logs/backend:/app/logs - environment: - LOG_PATH: /app/logs - # ... 其他环境变量保持不变 -``` - ---- - -### 9.6 日志级别规范(log.debug → log.info) - -代码约定:**所有业务 Service 的正常流程日志使用 `log.info`**,`log.debug` 仅保留在需要 -开发期详细追踪的工具类(当前项目无此场景,全部改为 `log.info`)。 - -**需修改的文件(共 11 个,21 处):** - -| 文件 | `log.debug` 数量 | -|------|-----------------| -| `module/video/service/VideoProcessService.java` | 5 | -| `module/source/service/SourceService.java` | 2 | -| `module/user/service/UserService.java` | 3 | -| `module/user/service/AuthService.java` | 2 | -| `module/config/service/SysConfigService.java` | 2 | -| `module/annotation/service/ExtractionApprovedEventListener.java` | 2 | -| `module/task/service/TaskService.java` | 1 | -| `module/annotation/service/QaService.java` | 1 | -| `module/export/service/FinetuneService.java` | 1 | -| `module/task/service/TaskClaimService.java` | 1 | -| `module/export/service/ExportService.java` | 1 | - -**执行方式**(实现阶段批量替换): - -```bash -# 验证变更范围 -grep -rn "log\.debug" src/main/java - -# 批量替换 -find src/main/java -name "*.java" \ - -exec sed -i 's/log\.debug(/log.info(/g' {} + - -# 确认清零 -grep -r "log\.debug" src/main/java && echo "FAIL" || echo "PASS" -``` - -[↑ 返回目录](#目录) diff --git a/docs/superpowers/specs/2026-04-14-label-backend-directory-flattening-design.md b/docs/superpowers/specs/2026-04-14-label-backend-directory-flattening-design.md deleted file mode 100644 index 83d4aee..0000000 --- a/docs/superpowers/specs/2026-04-14-label-backend-directory-flattening-design.md +++ /dev/null @@ -1,244 +0,0 @@ -# label_backend 标准目录扁平化设计 - -**日期**: 2026-04-14 -**范围**: `label_backend` 主工程 Java 包结构调整 -**目标**: 将当前按业务域分层的 `com.label.module.*` 结构重组为符合《微服务开发规范文档》的扁平标准目录结构,同时保持现有业务行为不变。 - ---- - -## 1. 背景与现状 - -当前项目主代码位于 `src/main/java/com/label`,整体结构分为两部分: - -- `com.label.common.*`:放置公共能力,如配置、异常、AOP、鉴权、Redis、状态机、存储与 AI 客户端 -- `com.label.module.*`:按业务域划分的模块目录,如 `user`、`task`、`source`、`annotation`、`export`、`config`、`video` - -现有结构具备一定领域边界,但与《微服务开发规范文档》中规定的标准扁平目录结构不一致。后续如果继续沿用两套组织方式,会增加新代码接入成本,也会让公共能力与业务层级的归属变得不稳定。 - ---- - -## 2. 设计目标 - -本次调整目标如下: - -- 将主代码目录统一调整为规范文档中的扁平结构 -- 保持包职责清晰,按“层级职责”而不是“业务模块”组织类 -- 在不改变业务逻辑和接口行为的前提下完成迁移 -- 规范包归属与少量命名,不引入新的技术分层 -- 为后续新增功能提供统一、可预测的目录组织方式 - ---- - -## 3. 明确不做的事情 - -为控制改造范围,本次不包含以下内容: - -- 不新增 `service.impl` -- 不将 `dto` 拆分为 `request`、`response`、`common` -- 不主动修改接口 URL、接口契约或返回结构 -- 不进行与目录调整无关的业务重构 -- 不改造数据库结构、SQL 文件或资源文件布局 - ---- - -## 4. 目标目录结构 - -目标结构定义如下: - -```text -src/main/java/com/label/ -├── annotation/ -├── aspect/ -├── common/ -│ ├── ai/ -│ ├── context/ -│ ├── exception/ -│ ├── redis/ -│ ├── result/ -│ ├── shiro/ -│ ├── statemachine/ -│ └── storage/ -├── config/ -├── constant/ -├── controller/ -├── dto/ -├── entity/ -├── event/ -├── feign/ -├── listener/ -├── mapper/ -├── scheduled/ -├── service/ -├── typehandler/ -├── util/ -└── LabelBackendApplication.java -``` - -说明: - -- `common` 只保留真正跨业务复用的基础能力 -- `config` 从原 `common.config` 提升到顶层 -- `annotation` 与 `aspect` 从原 `common.aop` 中拆分 -- `event` 与 `listener` 分离,避免事件定义与监听逻辑混放 -- `controller`、`service`、`mapper`、`entity`、`dto` 全部收敛为顶层扁平结构 - ---- - -## 5. 包迁移规则 - -### 5.1 顶层迁移规则 - -- `com.label.module.*.controller` -> `com.label.controller` -- `com.label.module.*.service` -> `com.label.service` -- `com.label.module.*.mapper` -> `com.label.mapper` -- `com.label.module.*.entity` -> `com.label.entity` -- `com.label.module.*.dto` -> `com.label.dto` -- `com.label.module.annotation.event` -> `com.label.event` -- 事件监听类 -> `com.label.listener` -- `com.label.common.config` -> `com.label.config` -- `com.label.common.aop` 中注解类 -> `com.label.annotation` -- `com.label.common.aop` 中切面类 -> `com.label.aspect` - -### 5.2 典型映射示例 - -- `com.label.module.user.controller.AuthController` -> `com.label.controller.AuthController` -- `com.label.module.user.service.UserService` -> `com.label.service.UserService` -- `com.label.module.task.mapper.AnnotationTaskMapper` -> `com.label.mapper.AnnotationTaskMapper` -- `com.label.module.source.entity.SourceData` -> `com.label.entity.SourceData` -- `com.label.module.user.dto.LoginRequest` -> `com.label.dto.LoginRequest` -- `com.label.common.aop.OperationLog` -> `com.label.annotation.OperationLog` -- `com.label.common.aop.AuditAspect` -> `com.label.aspect.AuditAspect` -- `com.label.module.annotation.event.ExtractionApprovedEvent` -> `com.label.event.ExtractionApprovedEvent` -- `com.label.module.annotation.service.ExtractionApprovedEventListener` -> `com.label.listener.ExtractionApprovedEventListener` -- `com.label.common.config.RedisConfig` -> `com.label.config.RedisConfig` - -### 5.3 命名策略 - -- 以“最小必要变更”为原则,优先迁移包路径,不主动重命名类 -- 仅在类职责与包语义明显不匹配时做必要归位 -- 现阶段主代码类名不存在重名冲突,因此不需要为扁平化提前引入前缀或后缀 - ---- - -## 6. 实施顺序 - -为降低迁移风险,实际执行采用“目标一次性扁平化,操作分阶段迁移”的方式。 - -### 阶段 1:基础公共层归位 - -先迁移以下内容,建立新骨架: - -- `annotation` -- `aspect` -- `config` -- `event` -- `listener` - -目标是先完成最基础的结构纠偏,并尽早暴露切面、配置和事件扫描问题。 - -### 阶段 2:数据承载层迁移 - -再迁移下列包: - -- `entity` -- `dto` -- `mapper` - -这些类依赖通常较窄,适合优先扁平化,也能及早验证 MyBatis 相关扫描与引用是否正常。 - -### 阶段 3:业务服务层迁移 - -迁移所有业务服务类到 `service`,原则如下: - -- 不新增实现层 -- 不调整现有业务编排 -- 只修正导包、注解引用和必要的包路径依赖 - -### 阶段 4:控制层迁移 - -最后迁移全部控制器到 `controller`。控制层依赖最广,放在后面可以减少中间态反复修改。 - -### 阶段 5:启动与扫描校正 - -统一检查并修正以下内容: - -- `@MapperScan` -- 组件扫描隐式路径 -- OpenAPI 相关配置 -- 事件监听与切面装配 -- 测试代码中的旧包引用 - -### 阶段 6:编译与回归验证 - -迁移完成后进行全量编译与测试回归,确认结构调整没有引入行为回归。 - ---- - -## 7. 风险与控制策略 - -### 风险 1:导包失效 - -大量类迁移后,主代码和测试代码中的 import 会同时失效。 - -控制策略: - -- 按阶段迁移并同步修复引用 -- 每个阶段结束后至少执行一次编译检查 - -### 风险 2:Spring 扫描路径异常 - -若配置类、切面、监听器或 Mapper 的扫描路径与旧包结构耦合,迁移后可能导致 Bean 缺失。 - -控制策略: - -- 显式检查启动类与配置类中的扫描配置 -- 将路径校正作为独立阶段处理 - -### 风险 3:事件监听失效 - -监听器从 `service` 拆到 `listener` 后,若注解或组件扫描不正确,会导致事件未被消费。 - -控制策略: - -- 迁移时同步校验事件类与监听器依赖关系 -- 通过现有集成测试覆盖事件链路 - -### 风险 4:测试回归失败 - -测试代码同样引用旧包名,若只改主代码,会造成测试集整体失效。 - -控制策略: - -- 测试代码与主代码同步迁移 -- 将 `mvn test` 作为最终验收门槛 - ---- - -## 8. 验收标准 - -本次结构调整完成后,应满足以下标准: - -1. `src/main/java/com/label/module` 目录被完全移除 -2. 主代码包结构符合标准扁平目录规范 -3. 项目能够成功编译 -4. 现有测试能够通过,至少覆盖当前可运行的启动测试、单元测试和集成测试 -5. 调整范围内不存在残留旧包引用 - ---- - -## 9. 实现原则 - -执行本设计时应遵循以下原则: - -- 先保证结构归位,再追求风格统一 -- 先保证行为不变,再做命名优化 -- 所有改动以“目录标准化”为中心,不引入额外架构决策 -- 每次修改都要让工程更接近规范,而不是制造新的混合结构 - ---- - -## 10. 结论 - -本次改造将 `label_backend` 从“按业务域分层 + 公共包混合”的现状,统一整理为规范文档定义的扁平标准目录结构。该调整不会改变系统能力边界,但会显著提升代码组织一致性、后续开发可预测性以及新成员理解成本。 - -实施时采用“目标结构一次确定、操作按阶段推进”的方式,以降低大规模包迁移带来的编译与装配风险。 diff --git a/docs/swagger-blackbox-test-cases.md b/docs/swagger-blackbox-test-cases.md deleted file mode 100644 index bea4b93..0000000 --- a/docs/swagger-blackbox-test-cases.md +++ /dev/null @@ -1,570 +0,0 @@ -# label-backend Swagger 接口黑盒测试用例 - -## 1. 文档说明 - -- 生成时间:2026-04-14 -- 适用项目:`label-backend` -- 覆盖范围:当前 `controller` 中已开放的全部 Swagger/OpenAPI 接口 -- 生成依据: - - `src/main/java/com/label/controller/**/*.java` - - `src/main/java/com/label/service/**/*.java` - - `src/main/resources/sql/init.sql` - - `src/test/java/com/label/integration/**/*.java` -- 说明: - - 当前会话内未能直接访问 `http://127.0.0.1:8080/v3/api-docs`,本文档按代码、设计和现有集成测试反推生成。 - - 文中“预期结果”以黑盒测试应验证的业务契约为主;若当前实现存在契约空洞,用例应保留,用于发现缺陷。 - -## 2. 测试前提 - -### 2.1 基础环境 - -- Base URL:`http://127.0.0.1:8080` -- OpenAPI:`GET /v3/api-docs` -- Swagger UI:`GET /swagger-ui.html` -- 默认返回体:`{ "code": "...", "message": "...", "data": ... }` - -### 2.2 认证前提 - -- 若要执行认证、鉴权、越权、Token 失效类用例,建议运行时配置 `auth.enabled=true`。 -- 若当前运行环境仍为 `auth.enabled=false` 的 mock 模式,则以下用例中所有 `401/403` 校验需要在真实认证模式下执行。 - -### 2.3 种子数据建议 - -基于 `src/main/resources/sql/init.sql`,至少准备以下账号: - -| 公司 | 用户名 | 密码 | 角色 | -|---|---|---|---| -| `DEMO` | `admin` | `admin123` | `ADMIN` | -| `DEMO` | `reviewer01` | `review123` | `REVIEWER` | -| `DEMO` | `annotator01` | `annot123` | `ANNOTATOR` | -| `DEMO` | `uploader01` | `upload123` | `UPLOADER` | - -额外建议准备: - -- 第二家公司 `TESTB` -- `TESTB` 下至少 1 个 `ADMIN` 账号 -- `DEMO`、`TESTB` 各自的资料、任务、配置、导出批次、视频任务样本 - -### 2.4 通用 Header - -- JSON 接口:`Content-Type: application/json` -- 文件上传:`multipart/form-data` -- 受保护接口:`Authorization: Bearer ` -- 视频回调启用密钥时:`X-Callback-Secret: ` - -## 3. 通用黑盒用例 - -除 `POST /api/auth/login` 与 `POST /api/video/callback` 外,所有受保护接口均应复用以下通用用例。 - -| 用例ID | 适用范围 | 场景 | 操作 | 预期结果 | -|---|---|---|---|---| -| `G-AUTH-001` | 全部受保护接口 | 缺少 Token | 不传 `Authorization` | HTTP `401`,`code=UNAUTHORIZED` | -| `G-AUTH-002` | 全部受保护接口 | Token 格式错误 | 传 `Authorization: BearerX xxx` 或 `Basic xxx` | HTTP `401`,`code=UNAUTHORIZED` | -| `G-AUTH-003` | 全部受保护接口 | Token 无效或过期 | 传不存在/已失效 Token | HTTP `401`,`code=UNAUTHORIZED` | -| `G-ROLE-001` | 有角色要求的接口 | 角色不足 | 用低权限账号访问高权限接口 | HTTP `403`,`code=FORBIDDEN` | -| `G-TENANT-001` | 租户数据相关接口 | 跨租户访问数据 | 用 `TESTB` Token 访问 `DEMO` 数据 | 返回 `404`、空列表或业务拒绝;不能读到 `DEMO` 数据 | -| `G-PAGE-001` | 分页接口 | 大页码限制 | 传超大 `pageSize` | 返回成功,`pageSize` 被限制到系统上限,不出现异常 | -| `G-ERR-001` | 全部接口 | 非法请求不能打穿到 5xx | 传缺参/错参/非法状态 | 返回可解释的 `4xx` 或失败业务码,不应无意义 `500` | - -## 4. 认证管理 - -### 4.1 `POST /api/auth/login` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `AUTH-LOGIN-001` | 正常登录 | `{"companyCode":"DEMO","username":"admin","password":"admin123"}` | HTTP `200`,`code=SUCCESS`,返回 `token/userId/username/role/expiresIn` | -| `AUTH-LOGIN-002` | 密码错误 | `password=wrong` | HTTP `401`,`code=USER_NOT_FOUND` | -| `AUTH-LOGIN-003` | 公司代码不存在 | `companyCode=NO_SUCH` | HTTP `401`,`code=USER_NOT_FOUND` | -| `AUTH-LOGIN-004` | 账号被禁用 | 先将用户禁用,再登录 | HTTP `403`,`code=USER_DISABLED` | -| `AUTH-LOGIN-005` | 缺少必填字段 | 缺 `companyCode`、`username` 或 `password` | 返回失败,不能生成有效 Token,不应出现无提示 `500` | - -### 4.2 `POST /api/auth/logout` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `AUTH-LOGOUT-001` | 正常退出 | 用有效 Token 调用退出 | HTTP `200`,`code=SUCCESS` | -| `AUTH-LOGOUT-002` | 退出后 Token 立即失效 | 退出后继续访问 `/api/auth/me` | HTTP `401`,`code=UNAUTHORIZED` | -| `AUTH-LOGOUT-003` | 无 Token 退出 | 复用 `G-AUTH-001` | HTTP `401` | - -### 4.3 `GET /api/auth/me` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `AUTH-ME-001` | 查询当前用户信息 | 用 `admin` Token 调用 | HTTP `200`,返回 `id/username/realName/role/companyId/companyName` | -| `AUTH-ME-002` | 已退出 Token 查询自己 | 先登录再退出,再调 `/me` | HTTP `401`,`code=UNAUTHORIZED` | -| `AUTH-ME-003` | 被禁用账号的旧 Token 查询自己 | 先登录,管理员禁用该用户,再调 `/me` | HTTP `401`,`code=UNAUTHORIZED` | - -## 5. 公司管理 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`。 - -### 5.1 `GET /api/companies` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `COMPANY-LIST-001` | 管理员分页查询公司 | `?page=1&pageSize=20` | HTTP `200`,返回分页结构 | -| `COMPANY-LIST-002` | 按状态筛选 | `?status=ACTIVE` | 仅返回对应状态公司 | -| `COMPANY-LIST-003` | 非管理员访问 | 用 `REVIEWER` 或 `UPLOADER` 调用 | HTTP `403`,`code=FORBIDDEN` | - -### 5.2 `POST /api/companies` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `COMPANY-CREATE-001` | 创建公司成功 | `{"companyName":"测试公司B","companyCode":"TESTB"}` | HTTP `201`,创建成功,状态默认 `ACTIVE` | -| `COMPANY-CREATE-002` | 公司代码重复 | 使用已存在 `companyCode` | HTTP `409`,`code=DUPLICATE_COMPANY_CODE` | -| `COMPANY-CREATE-003` | 公司名称重复 | 使用已存在 `companyName` | HTTP `409`,`code=DUPLICATE_COMPANY_NAME` | -| `COMPANY-CREATE-004` | 公司名为空 | `companyName` 空或空白 | HTTP `400`,`code=INVALID_COMPANY_FIELD` | -| `COMPANY-CREATE-005` | 公司代码为空 | `companyCode` 空或空白 | HTTP `400`,`code=INVALID_COMPANY_FIELD` | - -### 5.3 `PUT /api/companies/{id}` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `COMPANY-UPDATE-001` | 更新公司成功 | 修改 `companyName/companyCode` | HTTP `200`,字段更新成功 | -| `COMPANY-UPDATE-002` | 更新不存在公司 | `id` 不存在 | HTTP `404`,`code=NOT_FOUND` | -| `COMPANY-UPDATE-003` | 更新为重复代码 | `companyCode` 改成已存在值 | HTTP `409`,`code=DUPLICATE_COMPANY_CODE` | -| `COMPANY-UPDATE-004` | 更新为重复名称 | `companyName` 改成已存在值 | HTTP `409`,`code=DUPLICATE_COMPANY_NAME` | - -### 5.4 `PUT /api/companies/{id}/status` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `COMPANY-STATUS-001` | 禁用公司成功 | `{"status":"DISABLED"}` | HTTP `200`,公司状态变为 `DISABLED` | -| `COMPANY-STATUS-002` | 恢复公司成功 | `{"status":"ACTIVE"}` | HTTP `200`,公司状态变为 `ACTIVE` | -| `COMPANY-STATUS-003` | 非法状态值 | `{"status":"UNKNOWN"}` | HTTP `400`,`code=INVALID_COMPANY_STATUS` | -| `COMPANY-STATUS-004` | 公司不存在 | 不存在 `id` | HTTP `404`,`code=NOT_FOUND` | - -### 5.5 `DELETE /api/companies/{id}` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `COMPANY-DELETE-001` | 删除空公司成功 | 删除无用户的公司 | HTTP `200`,删除成功 | -| `COMPANY-DELETE-002` | 公司下仍有用户 | 删除已有用户公司 | HTTP `409`,`code=COMPANY_HAS_USERS` | -| `COMPANY-DELETE-003` | 删除不存在公司 | 不存在 `id` | HTTP `404`,`code=NOT_FOUND` | - -## 6. 用户管理 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001`。 - -### 6.1 `GET /api/users` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `USER-LIST-001` | 管理员查看本公司用户 | `?page=1&pageSize=20` | HTTP `200`,仅返回当前公司用户 | -| `USER-LIST-002` | 跨租户隔离 | 用 `TESTB` 管理员查看列表 | 不出现 `DEMO` 用户 | -| `USER-LIST-003` | 非管理员访问 | 用 `REVIEWER` 调用 | HTTP `403` | - -### 6.2 `POST /api/users` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `USER-CREATE-001` | 创建用户成功 | `{"username":"qa01","password":"qa123456","realName":"测试员","role":"ANNOTATOR"}` | HTTP `200`,创建成功,状态默认为 `ACTIVE` | -| `USER-CREATE-002` | 用户名重复 | 同公司创建同名用户 | HTTP `409`,`code=DUPLICATE_USERNAME` | -| `USER-CREATE-003` | 角色非法 | `role=VIEWER` 或其他未支持角色 | HTTP `400`,`code=INVALID_ROLE` | -| `USER-CREATE-004` | 跨租户污染校验 | `TESTB` 管理员创建用户后,`DEMO` 不可见 | 创建成功且仅属于当前公司 | - -### 6.3 `PUT /api/users/{id}` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `USER-UPDATE-001` | 更新真实姓名成功 | `{"realName":"新名字"}` | HTTP `200`,真实姓名更新 | -| `USER-UPDATE-002` | 更新密码成功 | `{"password":"newpass123"}`,再重新登录 | 新密码可登录,旧密码失效 | -| `USER-UPDATE-003` | 更新不存在用户 | 不存在 `id` | HTTP `404`,`code=NOT_FOUND` | -| `USER-UPDATE-004` | 更新他租户用户 | 用 `TESTB` 管理员修改 `DEMO` 用户 | HTTP `404` 或不可见 | - -### 6.4 `PUT /api/users/{id}/status` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `USER-STATUS-001` | 禁用用户成功 | `{"status":"DISABLED"}` | HTTP `200`,用户状态更新成功 | -| `USER-STATUS-002` | 禁用后旧 Token 失效 | 禁用后用该用户旧 Token 调 `/api/auth/me` | HTTP `401`,`code=UNAUTHORIZED` | -| `USER-STATUS-003` | 恢复用户成功 | `{"status":"ACTIVE"}` | HTTP `200` | -| `USER-STATUS-004` | 非法状态值 | `{"status":"LOCKED"}` | HTTP `400`,`code=INVALID_STATUS` | - -### 6.5 `PUT /api/users/{id}/role` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `USER-ROLE-001` | 修改角色成功 | `{"role":"REVIEWER"}` | HTTP `200`,用户角色更新成功 | -| `USER-ROLE-002` | 角色变更立即生效 | 同一 Token 变更前访问 reviewer 接口 `403`,变更后重试 | 变更后无需重新登录即可访问成功 | -| `USER-ROLE-003` | 非法角色值 | `{"role":"VIEWER"}` | HTTP `400`,`code=INVALID_ROLE` | -| `USER-ROLE-004` | 修改不存在用户 | 不存在 `id` | HTTP `404`,`code=NOT_FOUND` | - -## 7. 资料管理 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001`。 - -### 7.1 `POST /api/source/upload` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `SOURCE-UPLOAD-001` | 上传文本资料成功 | `multipart/form-data`,传 `file` + `dataType=TEXT` | HTTP `201`,返回资料 `id/fileName/dataType/status` | -| `SOURCE-UPLOAD-002` | 上传图片资料成功 | `dataType=IMAGE` | HTTP `201` | -| `SOURCE-UPLOAD-003` | 上传视频资料成功 | `dataType=VIDEO` | HTTP `201` | -| `SOURCE-UPLOAD-004` | 空文件上传 | 文件为空 | HTTP `400`,`code=FILE_EMPTY` | -| `SOURCE-UPLOAD-005` | 不支持的资料类型 | `dataType=PDF` | HTTP `400`,`code=INVALID_TYPE` | -| `SOURCE-UPLOAD-006` | Swagger 文案兼容性检查 | `dataType=text` 小写 | 应与文档约定一致;若失败需修正文档或实现 | -| `SOURCE-UPLOAD-007` | 低权限无权上传 | 用无上传权限账号访问 | HTTP `403` | - -### 7.2 `GET /api/source/list` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `SOURCE-LIST-001` | 上传员查看自己资料 | 用 `uploader01` 调用 | 仅返回自己上传的数据 | -| `SOURCE-LIST-002` | 管理员查看公司全部资料 | 用 `admin` 调用 | 返回公司内全部资料 | -| `SOURCE-LIST-003` | 按类型筛选 | `?dataType=TEXT` | 仅返回对应类型 | -| `SOURCE-LIST-004` | 按状态筛选 | `?status=PENDING` | 仅返回对应状态 | -| `SOURCE-LIST-005` | 跨租户隔离 | `TESTB` 调用 | 看不到 `DEMO` 数据 | - -### 7.3 `GET /api/source/{id}` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `SOURCE-DETAIL-001` | 查看资料详情成功 | 查询本公司资料 | HTTP `200`,返回详情及下载地址 | -| `SOURCE-DETAIL-002` | 查询不存在资料 | `id` 不存在 | HTTP `404`,`code=NOT_FOUND` | -| `SOURCE-DETAIL-003` | 跨租户查看详情 | 用 `TESTB` Token 查 `DEMO` 资料 | HTTP `404` 或不可见 | - -### 7.4 `DELETE /api/source/{id}` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `SOURCE-DELETE-001` | 删除 `PENDING` 资料成功 | 删除未进入流水线资料 | HTTP `200` | -| `SOURCE-DELETE-002` | 删除不存在资料 | `id` 不存在 | HTTP `404`,`code=NOT_FOUND` | -| `SOURCE-DELETE-003` | 删除已进入流水线资料 | 状态为 `PREPROCESSING/EXTRACTING/QA_REVIEW/APPROVED` | HTTP `409`,`code=SOURCE_IN_PIPELINE` | -| `SOURCE-DELETE-004` | 非管理员删除 | 用 `UPLOADER` 删除资料 | HTTP `403` | - -## 8. 任务管理 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001`。 - -### 8.1 `GET /api/tasks/pool` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `TASK-POOL-001` | 标注员查看任务池 | 用 `ANNOTATOR` 调用 | 仅返回 `EXTRACTION + UNCLAIMED` 任务 | -| `TASK-POOL-002` | Reviewer/Admin 调用任务池 | 用 `REVIEWER` 或 `ADMIN` 调用 | 返回 `SUBMITTED` 待审任务 | -| `TASK-POOL-003` | 跨租户隔离 | `TESTB` 调用 | 仅能看到本公司任务 | - -### 8.2 `GET /api/tasks/mine` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `TASK-MINE-001` | 查询我的任务成功 | `?page=1&pageSize=20` | 返回当前用户的 `IN_PROGRESS/SUBMITTED/REJECTED` 任务 | -| `TASK-MINE-002` | 按状态过滤 | `?status=REJECTED` | 仅返回对应状态 | -| `TASK-MINE-003` | 未领取任务不应出现在 mine | 准备 `UNCLAIMED` 任务 | 列表中不出现该任务 | - -### 8.3 `GET /api/tasks/pending-review` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `TASK-REVIEW-001` | Reviewer 查询待审批任务 | `?taskType=EXTRACTION` | 仅返回 `SUBMITTED` 且符合类型的任务 | -| `TASK-REVIEW-002` | 非 Reviewer 访问 | 用 `ANNOTATOR` 调用 | HTTP `403` | -| `TASK-REVIEW-003` | 跨租户隔离 | `TESTB` 调用 | 看不到 `DEMO` 待审任务 | - -### 8.4 `GET /api/tasks` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `TASK-LIST-001` | 管理员查询全部任务 | `?status=SUBMITTED&taskType=QA_GENERATION` | 返回过滤后的本公司任务 | -| `TASK-LIST-002` | 非管理员访问 | 用 `REVIEWER` 调用 | HTTP `403` | - -### 8.5 `POST /api/tasks` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `TASK-CREATE-001` | 创建提取任务成功 | `{"sourceId":,"taskType":"EXTRACTION"}` | HTTP `200`,返回新任务,状态 `UNCLAIMED` | -| `TASK-CREATE-002` | 创建 QA 任务成功 | `{"sourceId":,"taskType":"QA_GENERATION"}` | HTTP `200` | -| `TASK-CREATE-003` | 缺少 `sourceId` | 仅传 `taskType` | 应返回明确失败,不应出现裸 `500` | -| `TASK-CREATE-004` | 缺少 `taskType` | 仅传 `sourceId` | 应返回明确失败,不应出现裸 `500` | - -### 8.6 `GET /api/tasks/{id}` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `TASK-DETAIL-001` | 查询任务详情成功 | 查询本公司任务 | HTTP `200`,返回任务详情 | -| `TASK-DETAIL-002` | 查询不存在任务 | 不存在 `id` | HTTP `404`,`code=NOT_FOUND` | -| `TASK-DETAIL-003` | 跨租户查看任务 | 用 `TESTB` 查询 `DEMO` 任务 | HTTP `404` 或不可见 | - -### 8.7 `POST /api/tasks/{id}/claim` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `TASK-CLAIM-001` | 正常领取未领取任务 | `ANNOTATOR` 领取 `UNCLAIMED` 任务 | HTTP `200`,任务状态变为 `IN_PROGRESS` | -| `TASK-CLAIM-002` | 并发抢任务 | 10 并发领取同一任务 | 恰好 1 个成功,其余 HTTP `409`,`code=TASK_CLAIMED` | -| `TASK-CLAIM-003` | 重复领取已被占用任务 | 第二个用户再领取 | HTTP `409`,`code=TASK_CLAIMED` | - -### 8.8 `POST /api/tasks/{id}/unclaim` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `TASK-UNCLAIM-001` | 放弃进行中任务成功 | 当前领取人放弃 `IN_PROGRESS` 任务 | HTTP `200`,状态回到 `UNCLAIMED` | -| `TASK-UNCLAIM-002` | 非法状态放弃 | 对 `SUBMITTED/REJECTED/APPROVED` 任务调用 | HTTP `409`,`code=INVALID_STATE_TRANSITION` | -| `TASK-UNCLAIM-003` | 非领取人放弃他人任务 | 用别的标注员调用 | 应被拒绝,不能让他人释放任务 | - -### 8.9 `POST /api/tasks/{id}/reclaim` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `TASK-RECLAIM-001` | 原领取人重领被驳回任务 | 原领取人对 `REJECTED` 任务调用 | HTTP `200`,状态变为 `IN_PROGRESS` | -| `TASK-RECLAIM-002` | 非原领取人重领 | 其他用户调用 | HTTP `403`,`code=FORBIDDEN` | -| `TASK-RECLAIM-003` | 非驳回状态重领 | 对 `IN_PROGRESS` 任务调用 | HTTP `409`,`code=INVALID_STATE_TRANSITION` | - -### 8.10 `PUT /api/tasks/{id}/reassign` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `TASK-REASSIGN-001` | 管理员强制指派成功 | `{"userId":}` | HTTP `200`,任务归属变更到目标用户 | -| `TASK-REASSIGN-002` | 指派不存在任务 | 不存在 `id` | HTTP `404`,`code=NOT_FOUND` | -| `TASK-REASSIGN-003` | 缺少 `userId` | 空请求体或缺字段 | 应返回明确失败,不应裸 `500` | -| `TASK-REASSIGN-004` | 指派后状态一致性 | 指派后查询任务 | 任务应处于可执行状态,归属人与时间被更新 | - -## 9. 提取标注 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001`。 - -### 9.1 `GET /api/extraction/{taskId}` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `EXT-GET-001` | 获取提取结果成功 | 查询本公司任务 | HTTP `200`,返回 `taskId/sourceType/sourceFilePath/isFinal/resultJson` | -| `EXT-GET-002` | 查询不存在任务 | 不存在 `taskId` | HTTP `404`,`code=NOT_FOUND` | -| `EXT-GET-003` | 跨租户查询结果 | 用 `TESTB` 查询 `DEMO` 任务 | HTTP `404` 或不可见 | - -### 9.2 `PUT /api/extraction/{taskId}` - -| 用例ID | 场景 | 请求体 | 预期结果 | -|---|---|---|---| -| `EXT-PUT-001` | 更新结果成功 | 传合法 JSON 字符串 | HTTP `200` | -| `EXT-PUT-002` | 非法 JSON | 传坏 JSON | HTTP `400`,`code=INVALID_JSON` | -| `EXT-PUT-003` | 任务不存在 | 不存在 `taskId` | HTTP `404`,`code=NOT_FOUND` | - -### 9.3 `POST /api/extraction/{taskId}/submit` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `EXT-SUBMIT-001` | 提交成功 | `IN_PROGRESS` 任务提交 | HTTP `200`,任务变为 `SUBMITTED` | -| `EXT-SUBMIT-002` | 非法状态提交 | `UNCLAIMED/REJECTED/APPROVED` 时提交 | HTTP `409`,`code=INVALID_STATE_TRANSITION` | - -### 9.4 `POST /api/extraction/{taskId}/approve` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `EXT-APPROVE-001` | 审批通过成功 | Reviewer 审批 `SUBMITTED` 任务 | HTTP `200`,原任务 `APPROVED`,`isFinal=true` | -| `EXT-APPROVE-002` | 审批通过触发后续链路 | 审批完成后查数据库/接口 | 自动创建 `QA_GENERATION` 任务,`source_data.status=QA_REVIEW`,创建 `training_dataset` | -| `EXT-APPROVE-003` | 自审拦截 | 提交人与审批人相同 | HTTP `403`,`code=SELF_REVIEW_FORBIDDEN` | -| `EXT-APPROVE-004` | 非法状态审批 | 未提交任务直接审批 | HTTP `409`,`code=INVALID_STATE_TRANSITION` | - -### 9.5 `POST /api/extraction/{taskId}/reject` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `EXT-REJECT-001` | 驳回成功 | `{"reason":"实体识别有误"}` | HTTP `200`,任务变为 `REJECTED` | -| `EXT-REJECT-002` | 驳回原因为空 | 空 body 或空 reason | HTTP `400`,`code=REASON_REQUIRED` | -| `EXT-REJECT-003` | 自审驳回 | 提交人与驳回人相同 | HTTP `403`,`code=SELF_REVIEW_FORBIDDEN` | -| `EXT-REJECT-004` | 驳回后可重领重提 | 驳回后原领取人调 `/reclaim` 再 `/submit` | 可重领,任务恢复到 `SUBMITTED` | - -## 10. 问答生成 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001`。 - -### 10.1 `GET /api/qa/{taskId}` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `QA-GET-001` | 获取候选问答对成功 | 查询本公司 QA 任务 | HTTP `200`,返回 `taskId/sourceType/items` | -| `QA-GET-002` | 查询不存在任务 | 不存在 `taskId` | HTTP `404`,`code=NOT_FOUND` | -| `QA-GET-003` | 跨租户查询 | 用 `TESTB` 查询 `DEMO` 任务 | HTTP `404` 或不可见 | - -### 10.2 `PUT /api/qa/{taskId}` - -| 用例ID | 场景 | 请求体 | 预期结果 | -|---|---|---|---| -| `QA-PUT-001` | 更新候选问答对成功 | `{"items":[...]}` | HTTP `200` | -| `QA-PUT-002` | 非法 JSON | 传坏 JSON | HTTP `400`,`code=INVALID_JSON` | -| `QA-PUT-003` | 缺失 items 字段 | 传空对象 `{}` | 不应报 5xx;若允许则生成空 conversations | - -### 10.3 `POST /api/qa/{taskId}/submit` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `QA-SUBMIT-001` | 提交成功 | `IN_PROGRESS` 任务提交 | HTTP `200`,任务变为 `SUBMITTED` | -| `QA-SUBMIT-002` | 非法状态提交 | 对 `UNCLAIMED/REJECTED/APPROVED` 任务提交 | HTTP `409`,`code=INVALID_STATE_TRANSITION` | - -### 10.4 `POST /api/qa/{taskId}/approve` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `QA-APPROVE-001` | QA 审批通过成功 | Reviewer 审批 `SUBMITTED` QA 任务 | HTTP `200` | -| `QA-APPROVE-002` | 审批通过完成整条流水线 | 审批完成后查状态 | `training_dataset.status=APPROVED`,任务 `APPROVED`,`source_data.status=APPROVED` | -| `QA-APPROVE-003` | 自审拦截 | 提交人与审批人相同 | HTTP `403`,`code=SELF_REVIEW_FORBIDDEN` | - -### 10.5 `POST /api/qa/{taskId}/reject` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `QA-REJECT-001` | 驳回成功 | `{"reason":"问题描述不准确"}` | HTTP `200`,任务变为 `REJECTED` | -| `QA-REJECT-002` | 驳回原因为空 | 空 body 或空 reason | HTTP `400`,`code=REASON_REQUIRED` | -| `QA-REJECT-003` | 驳回后候选数据删除 | 驳回后检查 `training_dataset` | 候选问答对被删除,`source_data` 维持 `QA_REVIEW` | -| `QA-REJECT-004` | 驳回后可重领重提 | 原领取人调 `/reclaim` 再 `/submit` | 可重领,重新提交成功 | - -## 11. 导出与微调 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001`。 - -### 11.1 `GET /api/training/samples` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `EXPORT-SAMPLE-001` | 查询已审批样本成功 | `?page=1&pageSize=20` | HTTP `200`,返回 `APPROVED` 样本 | -| `EXPORT-SAMPLE-002` | 仅看未导出样本 | `?exported=false` | 只返回 `export_batch_id` 为空的数据 | -| `EXPORT-SAMPLE-003` | 按样本类型过滤 | `?sampleType=TEXT` | 仅返回对应类型 | -| `EXPORT-SAMPLE-004` | 非管理员访问 | 用 `ANNOTATOR` 调用 | HTTP `403` | - -### 11.2 `POST /api/export/batch` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `EXPORT-BATCH-001` | 创建导出批次成功 | `{"sampleIds":[,]}` | HTTP `201`,返回批次信息 | -| `EXPORT-BATCH-002` | 样本列表为空 | `{"sampleIds":[]}` | HTTP `400`,`code=EMPTY_SAMPLES` | -| `EXPORT-BATCH-003` | 包含未审批样本 | 混入 `PENDING_REVIEW/REJECTED` 样本 | HTTP `400`,`code=INVALID_SAMPLES` | -| `EXPORT-BATCH-004` | 包含他租户样本 | 混入其他公司样本 ID | HTTP `400`,`code=INVALID_SAMPLES` | -| `EXPORT-BATCH-005` | 批次创建后回写样本导出信息 | 创建成功后查询样本 | `exportBatchId/exportedAt` 已写入 | - -### 11.3 `POST /api/export/{batchId}/finetune` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `FINETUNE-TRIGGER-001` | 首次触发微调成功 | 对 `NOT_STARTED` 批次调用 | HTTP `200`,返回 `glmJobId/finetuneStatus=RUNNING` | -| `FINETUNE-TRIGGER-002` | 重复触发微调 | 对已启动批次再次调用 | HTTP `409`,`code=FINETUNE_ALREADY_STARTED` | -| `FINETUNE-TRIGGER-003` | 触发不存在批次 | 不存在 `batchId` | HTTP `404`,`code=NOT_FOUND` | - -### 11.4 `GET /api/export/{batchId}/status` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `FINETUNE-STATUS-001` | 未启动批次查询状态 | `glmJobId` 为空批次 | HTTP `200`,返回 `NOT_STARTED/progress=0` | -| `FINETUNE-STATUS-002` | 已启动批次查询状态 | 对已提交微调任务批次调用 | HTTP `200`,返回 `batchId/glmJobId/finetuneStatus/progress/errorMessage` | -| `FINETUNE-STATUS-003` | 跨租户状态隔离 | 用 `TESTB` 查 `DEMO` 批次 | HTTP `404` 或不可见 | - -### 11.5 `GET /api/export/list` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `EXPORT-LIST-001` | 分页查询导出批次成功 | `?page=1&pageSize=20` | HTTP `200`,仅返回当前公司批次 | -| `EXPORT-LIST-002` | 跨租户隔离 | `TESTB` 调用 | 看不到 `DEMO` 批次 | - -## 12. 系统配置 - -说明:本组接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001`。 - -### 12.1 `GET /api/config` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `CONFIG-LIST-001` | 查询合并配置成功 | 管理员查询 | HTTP `200`,返回当前公司可见配置列表 | -| `CONFIG-LIST-002` | 公司配置覆盖全局默认 | 先写公司专属 `model_default`,再查询 | 返回 `scope=COMPANY` 且值为公司专属值 | -| `CONFIG-LIST-003` | 未配置公司专属时回退全局 | 清除公司专属后查询 | 返回 `scope=GLOBAL` | -| `CONFIG-LIST-004` | 跨租户隔离 | `DEMO` 配置不影响 `TESTB` | 另一公司仍看到自己的配置或全局默认 | - -### 12.2 `PUT /api/config/{key}` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `CONFIG-UPSERT-001` | 新增公司专属配置成功 | `{"value":"glm-4-plus","description":"默认模型"}` | HTTP `200`,创建成功 | -| `CONFIG-UPSERT-002` | 更新同键配置成功 | 同一个 `key` 连续两次 `PUT` | HTTP `200`,第二次为更新而不是重复插入 | -| `CONFIG-UPSERT-003` | 未知配置键 | `PUT /api/config/unknown_key` | HTTP `400`,`code=UNKNOWN_CONFIG_KEY` | -| `CONFIG-UPSERT-004` | 空配置值 | `{"value":""}` | HTTP `400`,`code=INVALID_CONFIG_VALUE` | - -## 13. 视频处理 - -说明: - -- `POST /api/video/callback` 为公开接口,不复用 `G-AUTH-001~003` -- 其余视频接口复用 `G-AUTH-001~003`、`G-ROLE-001`、`G-TENANT-001` - -### 13.1 `POST /api/video/process` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `VIDEO-CREATE-001` | 创建视频处理任务成功 | `{"sourceId":,"jobType":"FRAME_EXTRACT","params":"{\"frameInterval\":30}"}` | 创建成功,返回 job,资料状态进入 `PREPROCESSING` | -| `VIDEO-CREATE-002` | 使用另一种任务类型成功 | `jobType=VIDEO_TO_TEXT` | 创建成功 | -| `VIDEO-CREATE-003` | 缺少必要字段 | 缺 `sourceId` 或 `jobType` | 返回失败,`code=INVALID_PARAMS`,且不能创建 job | -| `VIDEO-CREATE-004` | 非法任务类型 | `jobType=UNKNOWN` | HTTP `400`,`code=INVALID_JOB_TYPE` | -| `VIDEO-CREATE-005` | 非视频资料创建视频任务 | 对非 `VIDEO` 资料调用 | 应返回明确失败,不应产生错误状态迁移 | -| `VIDEO-CREATE-006` | 跨租户创建视频任务 | 用 `TESTB` 处理 `DEMO` 资料 | HTTP `404` 或不可见 | - -### 13.2 `GET /api/video/jobs/{jobId}` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `VIDEO-GET-001` | 查询视频任务成功 | 查询本公司 `jobId` | HTTP `200`,返回任务状态、重试次数、输出路径等 | -| `VIDEO-GET-002` | 查询不存在任务 | 不存在 `jobId` | HTTP `404`,`code=NOT_FOUND` | -| `VIDEO-GET-003` | 跨租户查询 | 用 `TESTB` 查 `DEMO` 任务 | HTTP `404` 或不可见 | - -### 13.3 `POST /api/video/jobs/{jobId}/reset` - -| 用例ID | 场景 | 操作 | 预期结果 | -|---|---|---|---| -| `VIDEO-RESET-001` | 重置失败任务成功 | 对 `FAILED` 任务调用 | HTTP `200`,job 状态变为 `PENDING`,`retryCount=0` | -| `VIDEO-RESET-002` | 非失败任务不允许重置 | 对 `PENDING/RETRYING/SUCCESS` 调用 | HTTP `400`,`code=INVALID_TRANSITION` | -| `VIDEO-RESET-003` | 跨租户重置 | 用 `TESTB` 调 `DEMO` 任务 | HTTP `404` 或不可见 | - -### 13.4 `POST /api/video/callback` - -| 用例ID | 场景 | 请求 | 预期结果 | -|---|---|---|---| -| `VIDEO-CALLBACK-001` | SUCCESS 回调成功 | `{"jobId":123,"status":"SUCCESS","outputPath":"processed/frames.zip"}` | HTTP `200`,job 变 `SUCCESS`,`source_data.status=PENDING` | -| `VIDEO-CALLBACK-002` | SUCCESS 回调幂等 | 对同一 `jobId` 连续两次发送相同 SUCCESS 回调 | 两次都成功,第二次不重复改状态、不重复产出副作用 | -| `VIDEO-CALLBACK-003` | FAILED 回调触发重试 | 对未达重试上限 job 发 `FAILED` | job 变 `RETRYING`,`retryCount+1` | -| `VIDEO-CALLBACK-004` | 超过最大重试次数 | `retryCount=maxRetries-1` 时再发 `FAILED` | job 变 `FAILED`,`source_data.status=PENDING` | -| `VIDEO-CALLBACK-005` | 回调 job 不存在 | `jobId` 不存在 | 接口不应打穿服务;应安全返回,无脏数据写入 | -| `VIDEO-CALLBACK-006` | 启用共享密钥时密钥错误 | 不传或传错 `X-Callback-Secret` | 返回失败,`code=UNAUTHORIZED` | -| `VIDEO-CALLBACK-007` | 回调缺少 `jobId/status` | 缺关键字段 | 返回明确失败,不应裸 `500` | - -## 14. 推荐执行顺序 - -建议按以下顺序执行,能更快定位问题来源: - -1. 认证管理 -2. 公司管理 -3. 用户管理 -4. 资料管理 -5. 任务管理 -6. 提取标注 -7. 问答生成 -8. 导出与微调 -9. 系统配置 -10. 视频处理 - -## 15. 高风险回归点 - -每次版本回归至少覆盖以下高风险用例: - -| 优先级 | 用例ID | 说明 | -|---|---|---| -| P0 | `AUTH-LOGIN-001` | 登录主链路 | -| P0 | `AUTH-LOGOUT-002` | 退出后 Token 立即失效 | -| P0 | `USER-STATUS-002` | 禁用账号后旧 Token 失效 | -| P0 | `SOURCE-DELETE-003` | 资料进入流水线后不可删除 | -| P0 | `TASK-CLAIM-002` | 并发抢任务只允许一个成功 | -| P0 | `EXT-APPROVE-002` | 提取审批通过自动推进 QA 链路 | -| P0 | `QA-APPROVE-002` | QA 审批通过完成整条流水线 | -| P0 | `EXPORT-BATCH-003` | 非 APPROVED 样本不可导出 | -| P0 | `CONFIG-LIST-004` | 配置跨租户隔离 | -| P0 | `VIDEO-CALLBACK-002` | 视频回调幂等 | - -## 16. 后续落地建议 - -本文档适合作为三类产物的源: - -- Swagger 手工测试清单 -- Postman/Apifox/Newman 自动化用例集 -- `src/test/java/com/label/blackbox` 下的 API 黑盒自动化测试 - -若需要继续落地,建议优先把以下用例自动化: - -- 认证与 Token 生命周期 -- 任务领取并发 -- 提取/问答审批状态流转 -- 导出样本校验 -- 视频回调幂等与重试 diff --git a/specs/001-label-backend-spec/checklists/requirements.md b/specs/001-label-backend-spec/checklists/requirements.md deleted file mode 100644 index 407b074..0000000 --- a/specs/001-label-backend-spec/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# 规格质量检查清单:label_backend 知识图谱智能标注平台 - -**用途**: 在进入规划阶段前验证规格说明的完整性和质量 -**创建日期**: 2026-04-09 -**功能**: [查看规格说明](../spec.md) - -## 内容质量 - -- [x] 无实现细节(无编程语言、框架、API 引用) -- [x] 聚焦用户价值和业务需求 -- [x] 面向非技术干系人编写 -- [x] 所有必填章节均已完成 - -## 需求完整性 - -- [x] 无 [NEEDS CLARIFICATION] 标记残留 -- [x] 需求可测试且无歧义 -- [x] 成功标准可度量 -- [x] 成功标准与技术无关(无实现细节) -- [x] 所有验收场景均已定义 -- [x] 已识别边界情况 -- [x] 范围边界清晰 -- [x] 已识别依赖和假设 - -## 功能就绪性 - -- [x] 所有功能性需求均有明确验收标准 -- [x] 用户场景覆盖主流程(认证、上传、标注、审批、导出) -- [x] 功能满足成功标准中定义的可度量结果 -- [x] 无实现细节渗入规格说明 - -## 备注 - -所有检查项均通过。规格说明已就绪,可进行 `/speckit.plan` 规划阶段。 diff --git a/specs/001-label-backend-spec/contracts/auth.md b/specs/001-label-backend-spec/contracts/auth.md deleted file mode 100644 index 47b1fc8..0000000 --- a/specs/001-label-backend-spec/contracts/auth.md +++ /dev/null @@ -1,148 +0,0 @@ -# API 契约:认证与用户管理 - -**统一响应格式**: -- 成功:`{"code": "SUCCESS", "data": {...}}` -- 成功(无数据):`{"code": "SUCCESS", "data": null}` -- 失败:`{"code": "ERROR_CODE", "message": "描述"}` -- 分页成功:`{"code": "SUCCESS", "data": {"items": [...], "total": 100, "page": 1, "pageSize": 20}}` - ---- - -## POST /api/auth/login - -**权限**: 匿名 -**描述**: 用户登录,返回会话凭证 - -**请求体**: -```json -{ - "companyCode": "COMPANY_A", - "username": "zhangsan", - "password": "plaintext_password" -} -``` - -**成功响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "token": "550e8400-e29b-41d4-a716-446655440000", - "userId": 1, - "username": "zhangsan", - "role": "ANNOTATOR", - "expiresIn": 7200 - } -} -``` - -**失败响应**: -- `401` `USER_NOT_FOUND`: 用户名或密码错误(不区分哪个错误,防止枚举) -- `403` `USER_DISABLED`: 账号已禁用 - ---- - -## POST /api/auth/logout - -**权限**: 已登录(Bearer Token) -**描述**: 退出登录,立即删除 Redis 会话 - -**请求头**: `Authorization: Bearer {token}` -**响应** `200`: `{"code": "SUCCESS", "data": null}` - ---- - -## GET /api/auth/me - -**权限**: 已登录 -**描述**: 获取当前用户信息 - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "id": 1, - "username": "zhangsan", - "realName": "张三", - "role": "ANNOTATOR", - "companyId": 10, - "companyName": "测试公司" - } -} -``` - ---- - -## GET /api/users - -**权限**: ADMIN -**描述**: 分页查询本公司用户列表 - -**查询参数**: `page`(默认 1)、`pageSize`(默认 20,最大 100)、`role`(可选过滤)、`status`(可选过滤) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "items": [ - {"id": 1, "username": "zhangsan", "realName": "张三", "role": "ANNOTATOR", "status": "ACTIVE"} - ], - "total": 50, - "page": 1, - "pageSize": 20 - } -} -``` - ---- - -## POST /api/users - -**权限**: ADMIN -**描述**: 创建用户 - -**请求体**: -```json -{ - "username": "lisi", - "password": "initial_password", - "realName": "李四", - "role": "ANNOTATOR" -} -``` - -**响应** `201`: `{"code": "SUCCESS", "data": {"id": 2, "username": "lisi", ...}}` -**失败**: `409` `USERNAME_EXISTS`: 用户名已存在 - ---- - -## PUT /api/users/{id} - -**权限**: ADMIN -**描述**: 更新用户基本信息 - -**请求体**: `{"realName": "新姓名"}` -**响应** `200`: `{"code": "SUCCESS", "data": null}` - ---- - -## PUT /api/users/{id}/status - -**权限**: ADMIN -**描述**: 启用或禁用账号,立即驱逐权限缓存 - -**请求体**: `{"status": "DISABLED"}` -**响应** `200`: `{"code": "SUCCESS", "data": null}` - ---- - -## PUT /api/users/{id}/role - -**权限**: ADMIN -**描述**: 变更用户角色,立即驱逐权限缓存 - -**请求体**: `{"role": "REVIEWER"}` -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: `400` `INVALID_ROLE`: 角色值不合法 diff --git a/specs/001-label-backend-spec/contracts/config.md b/specs/001-label-backend-spec/contracts/config.md deleted file mode 100644 index 66c3aff..0000000 --- a/specs/001-label-backend-spec/contracts/config.md +++ /dev/null @@ -1,53 +0,0 @@ -# API 契约:系统配置 - -*所有接口需要 ADMIN 权限* - ---- - -## GET /api/config - -**描述**: 获取所有配置项(公司级配置 + 全局默认配置合并,公司级优先) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "items": [ - { - "configKey": "prompt_extract_text", - "configValue": "请提取以下文本中的主语-谓语-宾语三元组...", - "description": "文本三元组提取 Prompt 模板", - "scope": "GLOBAL", - "updatedAt": "2026-04-09T00:00:00" - }, - { - "configKey": "model_default", - "configValue": "glm-4-turbo", - "description": "默认 AI 辅助模型", - "scope": "COMPANY", - "updatedAt": "2026-04-09T09:00:00" - } - ] - } -} -``` - -`scope` 字段:`GLOBAL`(来自全局默认)、`COMPANY`(来自公司级覆盖) - ---- - -## PUT /api/config/{key} - -**描述**: 更新单项配置(若公司级配置不存在则创建;若存在则覆盖) - -**请求体**: -```json -{ - "configValue": "glm-4-turbo", - "description": "升级到 GLM-4-Turbo 模型" -} -``` - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: `400` `UNKNOWN_CONFIG_KEY`: 未知的配置键(防止拼写错误创建无效配置) diff --git a/specs/001-label-backend-spec/contracts/export.md b/specs/001-label-backend-spec/contracts/export.md deleted file mode 100644 index 20a93d5..0000000 --- a/specs/001-label-backend-spec/contracts/export.md +++ /dev/null @@ -1,113 +0,0 @@ -# API 契约:训练数据导出与微调 - -*所有接口需要 ADMIN 权限* - ---- - -## GET /api/training/samples - -**描述**: 分页查询已审批、可导出的训练样本 - -**查询参数**: `page`、`pageSize`、`sampleType`(TEXT / IMAGE / VIDEO_FRAME,可选)、`exported`(true/false,可选) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "items": [ - { - "id": 1001, - "sampleType": "TEXT", - "status": "APPROVED", - "exportBatchId": null, - "sourceId": 50, - "createdAt": "2026-04-09T12:00:00" - } - ], - "total": 500, - "page": 1, - "pageSize": 20 - } -} -``` - ---- - -## POST /api/export/batch - -**描述**: 创建导出批次,合并选定样本为 JSONL 并上传 RustFS - -**请求体**: -```json -{ - "sampleIds": [1001, 1002, 1003] -} -``` - -**成功响应** `201`: -```json -{ - "code": "SUCCESS", - "data": { - "id": 10, - "batchUuid": "550e8400-e29b-41d4-a716-446655440000", - "sampleCount": 3, - "datasetFilePath": "export/550e8400.jsonl", - "finetuneStatus": "NOT_STARTED" - } -} -``` - -**失败**: -- `400` `INVALID_SAMPLES`: 部分样本不处于 APPROVED 状态 -- `400` `EMPTY_SAMPLES`: sampleIds 为空 - ---- - -## POST /api/export/{batchId}/finetune - -**描述**: 向 GLM AI 服务提交微调任务 - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "glmJobId": "glm-finetune-abc123", - "finetuneStatus": "RUNNING" - } -} -``` - -**失败**: `409` `FINETUNE_ALREADY_STARTED`: 微调任务已提交 - ---- - -## GET /api/export/{batchId}/status - -**描述**: 查询微调任务状态(向 AI 服务实时查询) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "batchId": 10, - "glmJobId": "glm-finetune-abc123", - "finetuneStatus": "RUNNING", - "progress": 45, - "errorMessage": null - } -} -``` - ---- - -## GET /api/export/list - -**描述**: 分页查询所有导出批次 - -**查询参数**: `page`、`pageSize` - -**响应** `200`: 批次列表(含 finetuneStatus、sampleCount、createdAt 等字段) diff --git a/specs/001-label-backend-spec/contracts/extraction.md b/specs/001-label-backend-spec/contracts/extraction.md deleted file mode 100644 index 5c4461f..0000000 --- a/specs/001-label-backend-spec/contracts/extraction.md +++ /dev/null @@ -1,97 +0,0 @@ -# API 契约:提取阶段标注工作台 - ---- - -## GET /api/extraction/{taskId} - -**权限**: ANNOTATOR(且为任务持有者) -**描述**: 获取当前提取结果(含 AI 预标注候选,供人工编辑) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "taskId": 101, - "sourceType": "TEXT", - "sourceFilePath": "text/202604/50.txt", - "isFinal": false, - "resultJson": { - "items": [ - { - "subject": "北京", - "predicate": "是...首都", - "object": "中国", - "sourceText": "北京是中国的首都", - "startOffset": 0, - "endOffset": 8 - } - ] - } - } -} -``` - ---- - -## PUT /api/extraction/{taskId} - -**权限**: ANNOTATOR(且为任务持有者) -**描述**: 更新提取结果(**整体 JSONB 覆盖,PUT 语义,禁止局部 PATCH**) - -**请求体**: -```json -{ - "items": [ - { - "subject": "北京", - "predicate": "是...首都", - "object": "中国", - "sourceText": "北京是中国的首都", - "startOffset": 0, - "endOffset": 8 - } - ] -} -``` - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: `400` `INVALID_JSON`: 提交的 JSON 格式不合法 - ---- - -## POST /api/extraction/{taskId}/submit - -**权限**: ANNOTATOR(且为任务持有者) -**描述**: 提交提取结果,任务状态 IN_PROGRESS → SUBMITTED,进入审批队列 - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: `409` `INVALID_STATE`: 任务当前状态不允许提交 - ---- - -## POST /api/extraction/{taskId}/approve - -**权限**: REVIEWER -**描述**: 审批通过。**两阶段操作**: -1. 同步(同一事务):`annotation_result.is_final = true`,任务状态 SUBMITTED → APPROVED,写任务历史 -2. 异步(事务提交后):AI 生成候选问答对 → 写 training_dataset → 创建 QA_GENERATION 任务 → source_data 状态推进 - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: -- `403` `SELF_REVIEW_FORBIDDEN`: 不允许审批自己提交的任务 -- `409` `INVALID_STATE`: 任务状态不为 SUBMITTED - ---- - -## POST /api/extraction/{taskId}/reject - -**权限**: REVIEWER -**描述**: 驳回提取结果,任务状态 SUBMITTED → REJECTED,标注员可重领 - -**请求体**: `{"reason": "三元组边界不准确,请重新标注"}` -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: -- `403` `SELF_REVIEW_FORBIDDEN`: 不允许驳回自己提交的任务 -- `409` `INVALID_STATE`: 任务状态不为 SUBMITTED -- `400` `REASON_REQUIRED`: 驳回原因不能为空 diff --git a/specs/001-label-backend-spec/contracts/qa.md b/specs/001-label-backend-spec/contracts/qa.md deleted file mode 100644 index f113c5f..0000000 --- a/specs/001-label-backend-spec/contracts/qa.md +++ /dev/null @@ -1,83 +0,0 @@ -# API 契约:问答生成阶段 - ---- - -## GET /api/qa/{taskId} - -**权限**: ANNOTATOR(且为任务持有者) -**描述**: 获取候选问答对列表(由提取阶段审批触发 AI 生成) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "taskId": 202, - "sourceType": "TEXT", - "items": [ - { - "id": 1001, - "question": "北京是哪个国家的首都?", - "answer": "中国", - "status": "PENDING_REVIEW" - } - ] - } -} -``` - ---- - -## PUT /api/qa/{taskId} - -**权限**: ANNOTATOR(且为任务持有者) -**描述**: 修改问答对(**整体覆盖,PUT 语义**,每次提交包含完整 items 数组) - -**请求体**: -```json -{ - "items": [ - { - "question": "北京是哪个国家的首都?", - "answer": "中国。北京自1949年起成为中华人民共和国的首都。" - } - ] -} -``` - -**响应** `200`: `{"code": "SUCCESS", "data": null}` - ---- - -## POST /api/qa/{taskId}/submit - -**权限**: ANNOTATOR(且为任务持有者) -**描述**: 提交问答对,任务状态 IN_PROGRESS → SUBMITTED - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: `409` `INVALID_STATE`: 任务当前状态不允许提交 - ---- - -## POST /api/qa/{taskId}/approve - -**权限**: REVIEWER -**描述**: 审批通过。同一事务中:先校验任务 → training_dataset 状态 → 任务状态 SUBMITTED → APPROVED → source_data 状态 → 写任务历史 - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: -- `403` `SELF_REVIEW_FORBIDDEN`: 不允许审批自己提交的任务 -- `409` `INVALID_STATE`: 任务状态不为 SUBMITTED - ---- - -## POST /api/qa/{taskId}/reject - -**权限**: REVIEWER -**描述**: 驳回问答对,删除候选记录,任务状态 SUBMITTED → REJECTED - -**请求体**: `{"reason": "问题描述不准确,请修改"}` -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: -- `403` `SELF_REVIEW_FORBIDDEN`: 不允许驳回自己提交的任务 -- `400` `REASON_REQUIRED`: 驳回原因不能为空 diff --git a/specs/001-label-backend-spec/contracts/source.md b/specs/001-label-backend-spec/contracts/source.md deleted file mode 100644 index 4647fbe..0000000 --- a/specs/001-label-backend-spec/contracts/source.md +++ /dev/null @@ -1,96 +0,0 @@ -# API 契约:资料管理 - ---- - -## POST /api/source/upload - -**权限**: UPLOADER -**描述**: 上传文件,创建 source_data 记录,文件字节流写入 RustFS - -**请求**: `multipart/form-data`,字段:`file`(必填)、`dataType`(TEXT / IMAGE / VIDEO) - -**响应** `201`: -```json -{ - "code": "SUCCESS", - "data": { - "id": 50, - "fileName": "document.txt", - "dataType": "TEXT", - "fileSize": 204800, - "status": "PENDING", - "createdAt": "2026-04-09T10:00:00" - } -} -``` - -**失败**: -- `400` `INVALID_TYPE`: 不支持的资料类型 -- `400` `FILE_EMPTY`: 文件为空 - ---- - -## GET /api/source/list - -**权限**: UPLOADER -**描述**: 分页查询资料列表。UPLOADER 只见自己上传的资料;ADMIN 见本公司全部资料 - -**查询参数**: `page`(默认 1)、`pageSize`(默认 20)、`dataType`(可选)、`status`(可选) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "items": [ - { - "id": 50, - "fileName": "document.txt", - "dataType": "TEXT", - "status": "PENDING", - "uploaderId": 1, - "createdAt": "2026-04-09T10:00:00" - } - ], - "total": 120, - "page": 1, - "pageSize": 20 - } -} -``` - ---- - -## GET /api/source/{id} - -**权限**: UPLOADER -**描述**: 查看资料详情,含 RustFS 预签名临时下载链接(有效期 15 分钟) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "id": 50, - "dataType": "TEXT", - "fileName": "document.txt", - "fileSize": 204800, - "status": "EXTRACTING", - "presignedUrl": "https://rustfs.example.com/...", - "parentSourceId": null, - "createdAt": "2026-04-09T10:00:00" - } -} -``` - ---- - -## DELETE /api/source/{id} - -**权限**: ADMIN -**描述**: 删除资料(同时删除 RustFS 文件及元数据) - -**前置条件**: 资料状态为 PENDING(不允许删除已进入流水线的资料) - -**响应** `204`: 无响应体 -**失败**: `409` `SOURCE_IN_PIPELINE`: 资料已进入标注流程,不可删除 diff --git a/specs/001-label-backend-spec/contracts/tasks.md b/specs/001-label-backend-spec/contracts/tasks.md deleted file mode 100644 index 033fee8..0000000 --- a/specs/001-label-backend-spec/contracts/tasks.md +++ /dev/null @@ -1,150 +0,0 @@ -# API 契约:任务管理 - ---- - -## GET /api/tasks/pool - -**权限**: ANNOTATOR -**描述**: 查看可领取任务池。角色过滤规则: -- ANNOTATOR:仅返回 EXTRACTION 阶段、status=UNCLAIMED 的任务 -- REVIEWER/ADMIN:仅返回 SUBMITTED 状态(待审批队列)的任务 - -**查询参数**: `page`(默认 1)、`pageSize`(默认 20) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "items": [ - { - "id": 101, - "sourceId": 50, - "sourceType": "TEXT", - "phase": "EXTRACTION", - "status": "UNCLAIMED", - "createdAt": "2026-04-09T10:00:00" - } - ], - "total": 30, - "page": 1, - "pageSize": 20 - } -} -``` - ---- - -## GET /api/tasks/pending-review - -**权限**: REVIEWER -**描述**: REVIEWER 专属审批入口,查看 status=SUBMITTED 的任务列表 - -**查询参数**: `page`、`pageSize`、`phase`(可选,EXTRACTION / QA_GENERATION) - -**响应**: 同 `/api/tasks/pool` 结构 - ---- - -## POST /api/tasks/{id}/claim - -**权限**: ANNOTATOR -**描述**: 领取任务(双重并发保障:Redis SET NX + DB 乐观约束) - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: -- `409` `TASK_CLAIMED`: 任务已被他人领取 -- `404` `TASK_NOT_FOUND`: 任务不存在 - ---- - -## POST /api/tasks/{id}/unclaim - -**权限**: ANNOTATOR(且为任务持有者) -**描述**: 放弃任务,退回任务池(status: IN_PROGRESS → UNCLAIMED) - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: `403` `NOT_TASK_OWNER`: 非任务持有者 - ---- - -## GET /api/tasks/mine - -**权限**: ANNOTATOR -**描述**: 查询当前用户领取的任务(含 IN_PROGRESS、SUBMITTED、REJECTED 三种状态) - -**查询参数**: `page`、`pageSize`、`status`(可选过滤) - -**响应**: 同任务列表结构,含 `rejectReason` 字段(REJECTED 状态时非空) - ---- - -## POST /api/tasks/{id}/reclaim - -**权限**: ANNOTATOR -**描述**: 重领被驳回的任务(status 必须为 REJECTED 且 claimedBy = 当前用户,流转 REJECTED → IN_PROGRESS) - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: -- `403` `NOT_TASK_OWNER`: 非原持有者 -- `409` `INVALID_STATE`: 任务状态不为 REJECTED - ---- - -## GET /api/tasks/{id} - -**权限**: ANNOTATOR -**描述**: 查看任务详情(含驳回原因、历史记录摘要) - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "id": 101, - "sourceId": 50, - "phase": "EXTRACTION", - "status": "IN_PROGRESS", - "claimedBy": 1, - "claimedAt": "2026-04-09T10:05:00", - "rejectReason": null, - "historyCount": 2 - } -} -``` - ---- - -## GET /api/tasks - -**权限**: ADMIN -**描述**: 查询全部任务(支持过滤,分页) - -**查询参数**: `page`、`pageSize`、`phase`、`status`、`claimedBy`、`sourceId` - ---- - -## PUT /api/tasks/{id}/reassign - -**权限**: ADMIN -**描述**: 强制转移任务归属(status 保持 IN_PROGRESS,仅 claimedBy 变更) - -**请求体**: `{"newOwnerId": 5, "reason": "原持有者长期未操作"}` -**响应** `200`: `{"code": "SUCCESS", "data": null}` - ---- - -## POST /api/tasks - -**权限**: ADMIN -**描述**: 为指定资料创建 EXTRACTION 任务 - -**请求体**: -```json -{ - "sourceId": 50, - "taskType": "AI_ASSISTED", - "aiModel": "glm-4" -} -``` -**响应** `201`: `{"code": "SUCCESS", "data": {"id": 101, ...}}` diff --git a/specs/001-label-backend-spec/contracts/video.md b/specs/001-label-backend-spec/contracts/video.md deleted file mode 100644 index 8906c80..0000000 --- a/specs/001-label-backend-spec/contracts/video.md +++ /dev/null @@ -1,87 +0,0 @@ -# API 契约:视频处理 - ---- - -## POST /api/video/process - -**权限**: ADMIN -**描述**: 为已上传的视频资料创建异步处理任务 - -**请求体**: -```json -{ - "sourceId": 50, - "jobType": "FRAME_EXTRACT", - "params": { - "frameInterval": 30, - "mode": "FRAME" - } -} -``` - -jobType 可选值:`FRAME_EXTRACT`(帧提取)、`VIDEO_TO_TEXT`(片段转文字) - -**响应** `201`: -```json -{ - "code": "SUCCESS", - "data": { - "jobId": 200, - "sourceId": 50, - "jobType": "FRAME_EXTRACT", - "status": "PENDING" - } -} -``` - ---- - -## GET /api/video/jobs/{jobId} - -**权限**: ADMIN -**描述**: 查询视频处理任务状态 - -**响应** `200`: -```json -{ - "code": "SUCCESS", - "data": { - "id": 200, - "status": "RUNNING", - "processedUnits": 15, - "totalUnits": 50, - "retryCount": 0, - "errorMessage": null, - "startedAt": "2026-04-09T10:05:00" - } -} -``` - ---- - -## POST /api/video/jobs/{jobId}/reset - -**权限**: ADMIN -**描述**: 手动重置 FAILED 状态的任务为 PENDING,允许重新触发(FAILED → PENDING 不在自动状态机中) - -**响应** `200`: `{"code": "SUCCESS", "data": null}` -**失败**: `409` `INVALID_STATE`: 任务状态不为 FAILED - ---- - -## POST /api/video/callback(内部接口) - -**权限**: AI 服务内部调用(IP 白名单 / 服务密钥) -**描述**: AI 服务回调,通知视频处理结果(幂等:重复成功回调静默忽略) - -**请求体**: -```json -{ - "jobId": 200, - "success": true, - "outputPath": "frames/50/", - "errorMessage": null -} -``` - -**响应** `200`: `{"code": "SUCCESS", "data": null}` diff --git a/specs/001-label-backend-spec/data-model.md b/specs/001-label-backend-spec/data-model.md deleted file mode 100644 index eb5ac9c..0000000 --- a/specs/001-label-backend-spec/data-model.md +++ /dev/null @@ -1,355 +0,0 @@ -# 数据模型:label_backend - -**日期**: 2026-04-09 -**分支**: `001-label-backend-spec` - ---- - -## 实体关系概览 - -``` -sys_company ─┬─ sys_user (company_id FK) - ├─ source_data (company_id FK) - │ └─ source_data (parent_source_id 自引用,视频溯源链) - ├─ annotation_task (company_id FK) - │ ├─ annotation_result (task_id FK) - │ └─ annotation_task_history (task_id FK) - ├─ training_dataset (company_id FK) - ├─ export_batch (company_id FK) - ├─ sys_config (company_id FK,可为 NULL 表示全局默认) - ├─ sys_operation_log (company_id FK) - └─ video_process_job (company_id FK) -``` - -**多租户规则**:除 `sys_company` 本身外,所有业务表均包含 `company_id NOT NULL`。查询时由 `TenantLineInnerInterceptor` 自动注入 `WHERE company_id = ?`。唯一例外:`sys_config` 允许 `company_id = NULL` 表示全局默认配置。 - ---- - -## 实体详情 - -### 1. sys_company — 公司(租户) - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | 自增主键 | -| company_name | VARCHAR(100) | NOT NULL UNIQUE | 公司名称 | -| company_code | VARCHAR(50) | NOT NULL UNIQUE | 公司编码 | -| status | VARCHAR(10) | NOT NULL DEFAULT 'ACTIVE' | ACTIVE / DISABLED | -| created_at | TIMESTAMP | NOT NULL DEFAULT NOW() | | -| updated_at | TIMESTAMP | NOT NULL DEFAULT NOW() | | - -**状态**: 无状态机(仅 ACTIVE/DISABLED 标志) - ---- - -### 2. sys_user — 用户 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | 租户隔离键 | -| username | VARCHAR(50) | NOT NULL | 同公司内唯一 | -| password_hash | VARCHAR(255) | NOT NULL | BCrypt 强度≥10,禁止序列化到响应 | -| real_name | VARCHAR(50) | — | | -| role | VARCHAR(20) | NOT NULL | UPLOADER / ANNOTATOR / REVIEWER / ADMIN | -| status | VARCHAR(10) | NOT NULL DEFAULT 'ACTIVE' | ACTIVE / DISABLED | -| created_at / updated_at | TIMESTAMP | NOT NULL | | - -**约束**: `UNIQUE(company_id, username)` -**索引**: `(company_id)` -**角色继承**: ADMIN ⊃ REVIEWER ⊃ ANNOTATOR ⊃ UPLOADER(由 Shiro Realm 的 addInheritedRoles() 实现) - ---- - -### 3. source_data — 原始资料 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | | -| uploader_id | BIGINT | FK→sys_user | | -| data_type | VARCHAR(20) | NOT NULL | TEXT / IMAGE / VIDEO | -| file_path | VARCHAR(500) | NOT NULL | RustFS 对象路径 | -| file_name | VARCHAR(255) | NOT NULL | 原始文件名 | -| file_size | BIGINT | — | 字节数 | -| bucket_name | VARCHAR(100) | NOT NULL | RustFS 桶名 | -| parent_source_id | BIGINT | FK→source_data | 视频片段转文本时指向原视频 | -| status | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | 见状态机 | -| reject_reason | TEXT | — | 保留字段(当前无 REJECTED 状态) | -| created_at / updated_at | TIMESTAMP | NOT NULL | | - -**索引**: `(company_id)`、`(company_id, status)`、`(parent_source_id)` - -**状态机**: -``` -PENDING → EXTRACTING(直接上传的文本/图片) -PENDING → PREPROCESSING(视频上传后) -PREPROCESSING → PENDING(视频预处理完成后进入标注流程) -EXTRACTING → QA_REVIEW(提取任务审批通过后) -QA_REVIEW → APPROVED(QA 任务审批通过后,整条流水线完成) -``` - -*注:source_data 无 REJECTED 状态。QA 阶段驳回作用于 annotation_task(→REJECTED),source_data 保持 QA_REVIEW 不变。* - ---- - -### 4. annotation_task — 标注任务 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | | -| source_id | BIGINT | NOT NULL FK→source_data | | -| phase | VARCHAR(20) | NOT NULL | EXTRACTION / QA_GENERATION | -| task_type | VARCHAR(20) | NOT NULL | AI_ASSISTED / MANUAL | -| ai_model | VARCHAR(50) | — | 使用的 AI 模型 | -| video_unit_type | VARCHAR(20) | — | FRAME(视频帧模式)/ NULL | -| video_unit_info | JSONB | — | `{frame_index, time_sec, frame_path}` | -| claimed_by | BIGINT | FK→sys_user | 当前持有者 | -| claimed_at | TIMESTAMP | — | | -| status | VARCHAR(20) | NOT NULL DEFAULT 'UNCLAIMED' | 见状态机 | -| reject_reason | TEXT | — | 驳回原因 | -| submitted_at | TIMESTAMP | — | | -| completed_at | TIMESTAMP | — | | -| created_at / updated_at | TIMESTAMP | NOT NULL | | - -**索引**: `(company_id)`、`(company_id, phase, status)`(任务池查询)、`(claimed_by, status)`(我的任务) - -**状态机**: -``` -UNCLAIMED → IN_PROGRESS(领取) -IN_PROGRESS → SUBMITTED(提交) -IN_PROGRESS → UNCLAIMED(放弃) -IN_PROGRESS → IN_PROGRESS(ADMIN 强制转移,持有人变更,状态不变) -SUBMITTED → APPROVED(审批通过) -SUBMITTED → REJECTED(审批驳回) -REJECTED → IN_PROGRESS(标注员重领) -``` - -**并发控制**: 领取时双重保障:① Redis `SET NX task:claim:{taskId}` TTL 30s;② DB `UPDATE ... WHERE status='UNCLAIMED'` 影响行数为 0 时返回错误 - ---- - -### 5. annotation_result — 标注结果(提取阶段) - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | | -| task_id | BIGINT | NOT NULL FK→annotation_task | | -| result_json | JSONB | NOT NULL | 整体覆盖,禁止局部 PATCH | -| is_final | BOOLEAN | NOT NULL DEFAULT FALSE | 审批通过后置 TRUE | -| submitted_by | BIGINT | FK→sys_user | | -| created_at / updated_at | TIMESTAMP | NOT NULL | | - -**result_json 结构**(文本三元组示例): -```json -{ - "items": [ - { - "subject": "北京", - "predicate": "是...首都", - "object": "中国", - "source_text": "北京是中国的首都", - "start_offset": 0, - "end_offset": 8 - } - ] -} -``` - -**result_json 结构**(图片四元组示例): -```json -{ - "items": [ - { - "subject": "猫", - "relation": "坐在", - "object": "椅子", - "modifier": "白色的", - "bbox": [100, 200, 300, 400], - "crop_path": "crops/123/0.jpg" - } - ] -} -``` - -**索引**: `(task_id)`、`(company_id, is_final)` - ---- - -### 6. training_dataset — 训练样本 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | | -| task_id | BIGINT | NOT NULL FK→annotation_task | | -| source_id | BIGINT | NOT NULL FK→source_data | | -| extraction_result_id | BIGINT | NOT NULL FK→annotation_result | | -| sample_type | VARCHAR(20) | NOT NULL | TEXT / IMAGE / VIDEO_FRAME | -| glm_format_json | JSONB | NOT NULL | GLM 微调格式 | -| export_batch_id | VARCHAR(50) | — | NULL 表示未导出 | -| status | VARCHAR(20) | NOT NULL DEFAULT 'PENDING_REVIEW' | 见状态机 | -| reject_reason | TEXT | — | | -| reviewed_by | BIGINT | FK→sys_user | | -| exported_at | TIMESTAMP | — | | -| created_at / updated_at | TIMESTAMP | NOT NULL | | - -**状态机**: -``` -PENDING_REVIEW → APPROVED(QA 审批通过) -PENDING_REVIEW → REJECTED(QA 审批驳回) -REJECTED → PENDING_REVIEW(标注员修改后重提) -``` - -**glm_format_json 结构**: -```json -{ - "conversations": [ - {"role": "user", "content": "..."}, - {"role": "assistant", "content": "..."} - ], - "source_type": "TEXT" -} -``` - -**索引**: `(company_id)`、`(company_id, status)`、`(export_batch_id)` - ---- - -### 7. export_batch — 导出批次 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | | -| batch_uuid | VARCHAR(50) | NOT NULL UNIQUE | 批次标识符 | -| dataset_file_path | VARCHAR(500) | — | RustFS JSONL 路径 | -| sample_count | INT | NOT NULL DEFAULT 0 | | -| glm_job_id | VARCHAR(100) | — | 微调任务 ID | -| finetune_status | VARCHAR(20) | NOT NULL DEFAULT 'NOT_STARTED' | 见状态 | -| error_message | TEXT | — | | -| created_by | BIGINT | FK→sys_user | | -| created_at / updated_at | TIMESTAMP | NOT NULL | | - -**finetune_status 值**: NOT_STARTED / RUNNING / SUCCESS / FAILED - -**索引**: `(company_id)` - ---- - -### 8. sys_config — 系统配置 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | FK→sys_company,可 NULL | NULL = 全局默认配置 | -| config_key | VARCHAR(100) | NOT NULL | | -| config_value | TEXT | NOT NULL | | -| description | TEXT | — | | -| updated_by | BIGINT | FK→sys_user | | -| updated_at | TIMESTAMP | NOT NULL | | - -**约束**: `UNIQUE(company_id, config_key)` -**查询规则**: 先按 `(companyId, configKey)` 查;未命中则按 `(NULL, configKey)` 查全局默认。 - -**预置全局配置键**: -- `prompt_extract_text`、`prompt_extract_image`、`prompt_video_to_text` -- `prompt_qa_gen_text`、`prompt_qa_gen_image` -- `model_default`(默认:`glm-4`) -- `video_frame_interval`(默认:`30`) -- `token_ttl_seconds`(默认:`7200`) -- `glm_api_base_url` - ---- - -### 9. sys_operation_log — 操作审计日志 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | FK→sys_company | | -| operator_id | BIGINT | FK→sys_user | 登录失败时可为 NULL | -| operator_name | VARCHAR(50) | NOT NULL | **操作时用户名快照**(不随改名变化) | -| operation_type | VARCHAR(50) | NOT NULL | 见枚举列表 | -| target_type | VARCHAR(30) | — | | -| target_id | BIGINT | — | | -| detail | JSONB | — | 补充信息 | -| ip_address | VARCHAR(50) | — | | -| result | VARCHAR(10) | NOT NULL | SUCCESS / FAIL | -| error_message | TEXT | — | | -| created_at | TIMESTAMP | NOT NULL DEFAULT NOW() | 分区键 | - -**只追加**:应用层禁止 UPDATE/DELETE,建议 DB 层添加触发器强制执行 -**分区**:按 `created_at` Range 分区,以月为单位(`sys_operation_log_YYYY_MM`) - -**operation_type 枚举**: -`USER_LOGIN`、`USER_LOGOUT`、`USER_CREATE`、`USER_UPDATE`、`USER_DISABLE`、`USER_ROLE_CHANGE`、`SOURCE_UPLOAD`、`SOURCE_DELETE`、`TASK_CREATE`、`TASK_CLAIM`、`TASK_UNCLAIM`、`TASK_SUBMIT`、`EXTRACTION_APPROVE`、`EXTRACTION_REJECT`、`QA_APPROVE`、`QA_REJECT`、`TASK_REASSIGN`、`EXPORT_CREATE`、`FINETUNE_START`、`CONFIG_UPDATE`、`VIDEO_JOB_RESET` - ---- - -### 10. annotation_task_history — 任务流转历史 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | | -| task_id | BIGINT | NOT NULL FK→annotation_task | | -| from_status | VARCHAR(20) | — | 任务初建时为 NULL | -| to_status | VARCHAR(20) | NOT NULL | | -| operator_id | BIGINT | NOT NULL FK→sys_user | | -| operator_role | VARCHAR(20) | NOT NULL | **操作时角色快照** | -| note | TEXT | — | 驳回原因、转移说明等 | -| created_at | TIMESTAMP | NOT NULL | | - -**只追加**:每次 annotation_task.status 变更时同步插入,与业务操作在同一事务中 -**索引**: `(task_id)` - ---- - -### 11. video_process_job — 视频异步处理任务 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGSERIAL | PK | | -| company_id | BIGINT | NOT NULL FK→sys_company | | -| source_id | BIGINT | NOT NULL FK→source_data | | -| job_type | VARCHAR(20) | NOT NULL | FRAME_EXTRACT / VIDEO_TO_TEXT | -| status | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | 见状态机 | -| params | JSONB | NOT NULL | 处理参数 | -| total_units | INT | — | 总帧数/片段数 | -| processed_units | INT | NOT NULL DEFAULT 0 | | -| output_path | VARCHAR(500) | — | | -| retry_count | INT | NOT NULL DEFAULT 0 | | -| max_retries | INT | NOT NULL DEFAULT 3 | | -| error_message | TEXT | — | | -| started_at / completed_at | TIMESTAMP | — | | -| created_at / updated_at | TIMESTAMP | NOT NULL | | - -**状态机**: -``` -PENDING → RUNNING -RUNNING → SUCCESS(处理成功) -RUNNING → RETRYING(失败且 retry_count < max_retries) -RUNNING → FAILED(失败且 retry_count >= max_retries) -RETRYING → RUNNING(AI 服务自动重试) -RETRYING → FAILED(超过最大重试次数) -``` -*FAILED → PENDING:由 ADMIN 手动触发接口,不在状态机自动流转中* - -**幂等规则**: 回调时若 `status == SUCCESS` 则静默忽略,不执行任何 DB 写入 - -**索引**: `(source_id)`、`(status)` - ---- - -## Redis 数据结构 - -| Key 模式 | 类型 | TTL | 内容 | -|---------|------|-----|------| -| `token:{uuid}` | Hash | 2h(滑动) | `{userId, role, companyId, username}` | -| `user:perm:{userId}` | String | 5min | 用户角色字符串 | -| `task:claim:{taskId}` | String | 30s | 持有者 userId | - -*禁止在上述三类命名空间之外自造 Key 用于认证、权限或锁目的。* diff --git a/specs/001-label-backend-spec/plan.md b/specs/001-label-backend-spec/plan.md deleted file mode 100644 index 548ae4d..0000000 --- a/specs/001-label-backend-spec/plan.md +++ /dev/null @@ -1,137 +0,0 @@ -# 实施计划:label_backend 知识图谱智能标注平台 - -**分支**: `001-label-backend-spec` | **日期**: 2026-04-09 | **规格说明**: [spec.md](spec.md) -**输入**: 功能规格说明 `/specs/001-label-backend-spec/spec.md` - ---- - -## 摘要 - -构建面向多租户的知识图谱智能标注平台后端服务,驱动**文本线**(三元组提取 → 问答对生成 → 训练样本)和**图片线**(四元组提取 → 问答对生成 → 训练样本)两条流水线。视频作为预处理入口异步汇入两条流水线。系统基于 Spring Boot 3 + Apache Shiro + MyBatis Plus + PostgreSQL + Redis + RustFS 构建,通过 HTTP 调用 Python FastAPI AI 服务完成 AI 辅助标注和问答生成能力。 - ---- - -## 技术上下文 - -**语言/版本**: Java 17(LTS) -**主要依赖**: Spring Boot ≥ 3.0.x、Apache Shiro ≥ 1.13.x、MyBatis Plus ≥ 3.5.x、Spring Data Redis -**存储**: PostgreSQL ≥ 14(主库)、Redis ≥ 6.x(会话/权限缓存/分布式锁)、RustFS(S3 兼容对象存储) -**测试**: JUnit 5 + Testcontainers(真实 PostgreSQL + Redis 实例)、Spring Boot Test -**目标平台**: Linux 服务器,Docker Compose 容器化部署 -**项目类型**: Web Service(REST API) -**性能目标**: 任务领取并发下有且仅有一人成功;权限变更延迟 < 1 秒生效 -**约束**: 禁止 JWT;禁止 Spring Security;禁止文件字节流存入数据库;AI HTTP 调用禁止在 @Transactional 内同步执行;所有列表接口强制分页 -**规模**: 多租户(多公司),每公司独立数据空间;11 张核心业务表 - ---- - -## 宪章合规检查 - -*门控:Phase 0 研究前必须通过。Phase 1 设计后重检。* - -| # | 宪章原则 | 状态 | 说明 | -|---|---------|------|------| -| 1 | 环境约束(JDK 17、SB 3、Shiro、MyBatis Plus) | ✅ 通过 | pom.xml 中版本约束与宪章完全对齐;无 Spring Security 引入 | -| 2 | 多租户数据隔离(company_id + ThreadLocal) | ✅ 通过 | TenantLineInnerInterceptor 自动注入;CompanyContext 在 finally 块清理 | -| 3 | BCrypt 密码 + UUID Token + 禁 JWT | ✅ 通过 | AuthService 使用 BCrypt ≥ 10;UUID v4 Token 存 Redis;无 JWT 库 | -| 4 | 分级 RBAC + 权限注解 + 角色变更驱逐缓存 | ✅ 通过 | @RequiresRoles 声明权限;updateRole() 立即删 user:perm:{userId} | -| 5 | 双流水线 + 级联触发 + parent_source_id 溯源 | ✅ 通过 | 仅文本线/图片线;审批通过用 @TransactionalEventListener 触发 QA | -| 6 | 状态机完整性(StateValidator) | ✅ 通过 | 所有状态变更经 StateValidator.assertTransition();禁止绕过 Mapper 直写 | -| 7 | 任务争抢双重保障(Redis SET NX + DB 乐观锁) | ✅ 通过 | task:claim:{taskId} TTL 30s + WHERE status='UNCLAIMED' | -| 8 | 异步视频处理幂等 + 重试上限 + FAILED 手动重置 | ✅ 通过 | SUCCESS 回调静默忽略;retry_count ≥ max_retries → FAILED | -| 9 | 只追加审计日志 + AOP 切面 + 审计失败不回滚业务 | ✅ 通过 | @OperationLog AOP;sys_operation_log 无 UPDATE/DELETE;异常仅 error 日志 | -| 10 | RESTful URL + 统一响应格式 + 强制分页 | ✅ 通过 | Result 包装;无动词路径;PageResult 分页 | -| 11 | YAGNI:业务在 Service,Controller 只处理 HTTP | ✅ 通过 | 分层明确;无预测性抽象层 | - -**门控结果:全部通过,可进入 Phase 0。** - ---- - -## 项目结构 - -### 规格说明文档(本功能) - -```text -specs/001-label-backend-spec/ -├── plan.md # 本文件(/speckit.plan 输出) -├── research.md # Phase 0 输出 -├── data-model.md # Phase 1 输出 -├── quickstart.md # Phase 1 输出 -├── contracts/ # Phase 1 输出(REST API 契约) -│ ├── auth.md -│ ├── source.md -│ ├── tasks.md -│ ├── extraction.md -│ ├── qa.md -│ ├── export.md -│ ├── config.md -│ └── video.md -└── tasks.md # Phase 2 输出(/speckit.tasks 命令创建,非本命令) -``` - -### 源代码(仓库根目录) - -```text -src/ -└── main/ - └── java/com/label/ - ├── LabelBackendApplication.java - ├── common/ - │ ├── result/ # Result、ResultCode、PageResult - │ ├── exception/ # BusinessException、GlobalExceptionHandler - │ ├── context/ # CompanyContext(ThreadLocal) - │ ├── shiro/ # TokenFilter、UserRealm、ShiroConfig - │ ├── redis/ # RedisKeyManager、RedisService - │ ├── aop/ # AuditAspect、@OperationLog 注解 - │ ├── storage/ # RustFsClient(S3 兼容封装) - │ ├── ai/ # AiServiceClient(RestClient 封装 8 个端点) - │ └── statemachine/ # StateValidator、各状态枚举 - └── module/ - ├── user/ # AuthController、UserController、AuthService、UserService - ├── source/ # SourceController、SourceService - ├── task/ # TaskController、TaskService、TaskClaimService - ├── annotation/ # ExtractionController、QaController、ExtractionService、QaService - ├── export/ # ExportController、ExportService、FinetuneService - ├── config/ # SysConfigController、SysConfigService - └── video/ # VideoController、VideoProcessService - -src/ -└── test/ - └── java/com/label/ - ├── integration/ # Testcontainers(真实 PG + Redis)集成测试 - │ ├── AuthIntegrationTest.java - │ ├── TaskClaimConcurrencyTest.java - │ ├── VideoCallbackIdempotencyTest.java - │ ├── MultiTenantIsolationTest.java - │ └── ShiroFilterIntegrationTest.java - └── unit/ # 纯单元测试(状态机、业务逻辑) - └── StateMachineTest.java - -sql/ -└── init.sql # 全部 DDL(11 张表,按依赖顺序执行) - -docker-compose.yml # postgres、redis、rustfs、backend、ai-service、frontend -Dockerfile # eclipse-temurin:17-jre-alpine -pom.xml -``` - -**结构决策**:单一后端服务(Web Service),无前端代码。标准 Maven 项目布局,源代码在 `src/main/java/com/label/`,测试在 `src/test/java/com/label/`,按 `common/` + `module/` 两层分包。 - ---- - -## 复杂度追踪 - -> 宪章检查无违规,本节留空。 - ---- - -## GSTACK REVIEW REPORT - -| Review | Trigger | Why | Runs | Status | Findings | -|--------|---------|-----|------|--------|----------| -| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | -| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | -| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — | -| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | - -**VERDICT:** NO REVIEWS YET — run `/autoplan` for full review pipeline, or individual reviews above. diff --git a/specs/001-label-backend-spec/quickstart.md b/specs/001-label-backend-spec/quickstart.md deleted file mode 100644 index 952a590..0000000 --- a/specs/001-label-backend-spec/quickstart.md +++ /dev/null @@ -1,179 +0,0 @@ -# 快速启动指南:label_backend - -**日期**: 2026-04-09 -**分支**: `001-label-backend-spec` - ---- - -## 前置条件 - -- Docker Desktop ≥ 4.x(含 Docker Compose v2) -- JDK 17(本地开发时) -- Maven ≥ 3.8(本地开发时) - ---- - -## 一、使用 Docker Compose 启动完整环境 - -```bash -# 克隆仓库 -git clone -cd label_backend - -# 启动所有服务(PostgreSQL + Redis + RustFS + AI Service + Backend + Frontend) -docker compose up -d - -# 查看后端启动日志 -docker compose logs -f backend - -# 检查健康状态 -docker compose ps -``` - -**服务端口**: -| 服务 | 端口 | -|------|------| -| 前端(Nginx) | http://localhost:80 | -| 后端 REST API | http://localhost:8080 | -| AI 服务(FastAPI) | http://localhost:8000 | -| PostgreSQL | localhost:5432 | -| Redis | localhost:6379 | -| RustFS S3 API | http://localhost:9000 | -| RustFS Web 控制台 | http://localhost:9001 | - ---- - -## 二、初始化数据库 - -数据库 DDL 通过 `./sql/init.sql` 在 PostgreSQL 容器启动时自动执行(`docker-entrypoint-initdb.d`)。 - -若需手动执行: -```bash -docker compose exec postgres psql -U label -d label_db -f /docker-entrypoint-initdb.d/init.sql -``` - -**初始账号**(由 `init.sql` 中的 INSERT 语句创建): -| 用户名 | 密码 | 角色 | 公司 | -|--------|------|------|------| -| admin | admin123 | ADMIN | 演示公司 | -| reviewer01 | review123 | REVIEWER | 演示公司 | -| annotator01 | annot123 | ANNOTATOR | 演示公司 | -| uploader01 | upload123 | UPLOADER | 演示公司 | - ---- - -## 三、本地开发模式(不使用 Docker) - -```bash -# 启动依赖服务(仅 PostgreSQL + Redis + RustFS,不启动后端) -docker compose up -d postgres redis rustfs - -# 设置环境变量 -export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/label_db -export SPRING_DATASOURCE_USERNAME=label -export SPRING_DATASOURCE_PASSWORD=label_password -export SPRING_REDIS_HOST=localhost -export SPRING_REDIS_PORT=6379 -export SPRING_REDIS_PASSWORD=redis_password -export RUSTFS_ENDPOINT=http://localhost:9000 -export RUSTFS_ACCESS_KEY=minioadmin -export RUSTFS_SECRET_KEY=minioadmin -export AI_SERVICE_BASE_URL=http://localhost:8000 - -# 编译并启动 -mvn clean spring-boot:run -``` - ---- - -## 四、验证安装 - -```bash -# 1. 登录(获取 Token) -curl -X POST http://localhost:8080/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"companyCode":"DEMO","username":"admin","password":"admin123"}' - -# 期望响应:{"code":"SUCCESS","data":{"token":"...","role":"ADMIN",...}} - -# 2. 使用 Token 访问受保护接口(将 {TOKEN} 替换为上一步返回的 token) -curl http://localhost:8080/api/auth/me \ - -H "Authorization: Bearer {TOKEN}" - -# 期望响应:{"code":"SUCCESS","data":{"username":"admin","role":"ADMIN",...}} -``` - ---- - -## 五、运行测试 - -```bash -# 运行所有测试(Testcontainers 会自动启动真实 PG + Redis 容器) -mvn test - -# 运行特定测试(并发任务领取) -mvn test -Dtest=TaskClaimConcurrencyTest - -# 运行集成测试套件 -mvn test -Dtest=*IntegrationTest -``` - -**注意**: Testcontainers 需要本地 Docker 可用。首次运行会拉取 PostgreSQL 和 Redis 镜像(约 200MB)。 - ---- - -## 六、关键配置项说明 - -配置文件位于 `src/main/resources/application.yml`。以下配置项可在运行时通过 `PUT /api/config/{key}` 接口(ADMIN 权限)动态调整,无需重启服务: - -| 配置键 | 说明 | 默认值 | -|--------|------|--------| -| `token_ttl_seconds` | 会话凭证有效期(秒) | 7200(2小时) | -| `model_default` | AI 辅助默认模型 | glm-4 | -| `video_frame_interval` | 视频帧提取间隔(帧数) | 30 | -| `prompt_extract_text` | 文本三元组提取 Prompt | 见 init.sql | -| `prompt_extract_image` | 图片四元组提取 Prompt | 见 init.sql | -| `prompt_qa_gen_text` | 文本问答生成 Prompt | 见 init.sql | -| `prompt_qa_gen_image` | 图片问答生成 Prompt | 见 init.sql | - ---- - -## 七、标注流水线快速验证 - -```bash -TOKEN="your-admin-token" - -# 步骤 1:上传文本资料 -curl -X POST http://localhost:8080/api/source/upload \ - -H "Authorization: Bearer $TOKEN" \ - -F "file=@sample.txt" -F "dataType=TEXT" - -# 步骤 2:为资料创建提取任务(sourceId 从上一步响应中获取) -curl -X POST http://localhost:8080/api/tasks \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"sourceId": 1, "taskType": "AI_ASSISTED", "aiModel": "glm-4"}' - -# 步骤 3:标注员领取任务(使用 annotator01 的 Token) -ANNOTATOR_TOKEN="annotator-token" -curl -X POST http://localhost:8080/api/tasks/1/claim \ - -H "Authorization: Bearer $ANNOTATOR_TOKEN" - -# 步骤 4:获取 AI 预标注结果 -curl http://localhost:8080/api/extraction/1 \ - -H "Authorization: Bearer $ANNOTATOR_TOKEN" - -# 步骤 5:提交标注结果 -curl -X PUT http://localhost:8080/api/extraction/1 \ - -H "Authorization: Bearer $ANNOTATOR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"items":[{"subject":"北京","predicate":"是首都","object":"中国","sourceText":"北京是中国的首都","startOffset":0,"endOffset":8}]}' - -curl -X POST http://localhost:8080/api/extraction/1/submit \ - -H "Authorization: Bearer $ANNOTATOR_TOKEN" - -# 步骤 6:审批员审批通过(使用 reviewer01 的 Token) -REVIEWER_TOKEN="reviewer-token" -curl -X POST http://localhost:8080/api/extraction/1/approve \ - -H "Authorization: Bearer $REVIEWER_TOKEN" -``` diff --git a/specs/001-label-backend-spec/research.md b/specs/001-label-backend-spec/research.md deleted file mode 100644 index cd0ae81..0000000 --- a/specs/001-label-backend-spec/research.md +++ /dev/null @@ -1,150 +0,0 @@ -# Phase 0 研究报告:label_backend - -**日期**: 2026-04-09 -**分支**: `001-label-backend-spec` - ---- - -## 技术决策汇总 - -所有技术选型均由宪章强制约束,无需评估备选方案。本报告记录关键设计决策的理由,供后续实施参考。 - ---- - -## 决策 1:认证机制 - -**决策**: UUID v4 Token 存储于 Redis,滑动过期,禁止 JWT - -**理由**: -- JWT 自包含令牌无法按需吊销,无法满足"管理员禁用账号立即生效"的安全要求 -- UUID Token 在 Redis 中可精确控制生命周期:退出登录或禁用账号时同步删除 Key,下一次请求立即失效 -- 滑动过期(每次有效请求重置 TTL)确保活跃用户不被意外踢出 - -**备选方案放弃理由**: -- JWT:无法即时吊销,存在安全窗口 -- Session Cookie:在无状态 REST API 架构中不适用 -- OAuth2:过度设计,当前场景无第三方授权需求 - ---- - -## 决策 2:多租户隔离机制 - -**决策**: MyBatis Plus `TenantLineInnerInterceptor` + `ThreadLocal CompanyContext` - -**理由**: -- `TenantLineInnerInterceptor` 在 SQL 拦截器层自动在每条查询的 WHERE 子句中注入 `company_id`,覆盖范围广且无需逐方法手动添加条件 -- ThreadLocal 存储当前请求的 `companyId`,由 Shiro TokenFilter 在解析 Token 时从 Redis 会话数据注入,确保 companyId 来自服务端权威来源而非客户端参数 -- `finally` 块强制清理 ThreadLocal,防止线程池复用时数据串漏 - -**备选方案放弃理由**: -- 行级安全(RLS):PostgreSQL 原生支持,但与 MyBatis Plus 集成复杂,且宪章已指定 ThreadLocal 方案 -- 逐方法手动添加 WHERE:容易遗漏,维护成本高 - ---- - -## 决策 3:任务并发领取控制 - -**决策**: Redis `SET NX`(分布式锁)+ 数据库乐观约束(`WHERE status = 'UNCLAIMED'`)双重保障 - -**理由**: -- 单纯使用数据库乐观锁在高并发下存在写放大问题(大量 UPDATE 竞争) -- 单纯使用 Redis 锁若锁过期后 DB 写入失败可能导致数据不一致 -- 双重保障:Redis 锁(TTL 30s)快速拦截大部分并发请求,减少数据库压力;DB 乐观约束作为最终一致性兜底 - -**Key 命名**: `task:claim:{taskId}`(TTL 30s,与宪章 Redis Key 规范一致) - ---- - -## 决策 4:审批触发 QA 任务的异步解耦 - -**决策**: Spring `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Transactional(REQUIRES_NEW)` - -**理由**: -- 提取阶段审批通过后需调用 AI HTTP 生成候选问答对,该 HTTP 调用延迟不确定(秒级到分钟级) -- 若在 `@Transactional` 内同步调用,数据库连接被长时间占用,且 AI 失败会错误地回滚已完成的审批操作 -- `AFTER_COMMIT` 保证业务审批先提交再触发事件,避免事务回滚导致的幽灵任务 -- `REQUIRES_NEW` 为 QA 生成开启独立事务,AI 失败仅影响 QA 任务创建,不影响审批结果 - -**事件流**: `approve()` → publish `ExtractionApprovedEvent` → 事务提交 → `onExtractionApproved()` 异步执行(AI 调用 + 创建 QA 任务) - ---- - -## 决策 5:标注结果存储语义 - -**决策**: JSONB 整体覆盖(PUT 语义),禁止局部 PATCH - -**理由**: -- 三元组/四元组条目具有强关联性(主语-谓语-宾语作为整体,或主体-关系-客体-修饰词作为整体),局部更新易导致不一致 -- 整体替换简化服务端逻辑,前端每次提交完整 items 数组,服务端直接执行 UPDATE `result_json = ?` -- 避免局部追加导致的索引层数据不一致(如删除某条目后残留旧数据) - ---- - -## 决策 6:审计日志事务边界 - -**决策**: 审计日志写入不要求与业务操作在同一事务,AOP `finally` 块中独立写入 - -**理由**: -- 审计写入失败不应回滚业务操作(用户的标注/审批结果比审计日志更重要) -- `@Around` 通知在业务方法执行完成(commit 或 rollback)后捕获最终 `result`,可记录准确的成功/失败状态 -- 审计失败仅 error 级别日志 + 告警,不影响用户体验 - ---- - -## 决策 7:视频预处理幂等回调 - -**决策**: 回调处理时检查 `video_process_job.status`,已为 `SUCCESS` 则静默忽略 - -**理由**: -- AI 服务可能因网络抖动对同一 jobId 发起多次成功回调 -- 幂等检查确保第一次成功回调创建标注任务,后续重复回调无任何副作用 -- 检查粒度:`status == SUCCESS` 即返回,不进行任何 DB 写入 - ---- - -## 决策 8:对象存储路径规范 - -**决策**: RustFS(S3 兼容),文件字节流禁止入库,路径按资源类型分桶分目录 - -**路径规范**: - -| 资源 | 桶 | 路径格式 | -|------|-----|---------| -| 文本文件 | `source-data` | `text/{yyyyMM}/{source_id}.txt` | -| 图片 | `source-data` | `image/{yyyyMM}/{source_id}.jpg` | -| 视频 | `source-data` | `video/{yyyyMM}/{source_id}.mp4` | -| 视频帧 | `source-data` | `frames/{source_id}/{frame_index}.jpg` | -| 视频转文本 | `source-data` | `video-text/{parent_source_id}/{timestamp}.txt` | -| bbox 裁剪图 | `source-data` | `crops/{task_id}/{item_index}.jpg` | -| 导出 JSONL | `finetune-export` | `export/{batchUuid}.jsonl` | - ---- - -## 决策 9:测试策略 - -**决策**: 集成测试使用 Testcontainers(真实 PG + Redis),不允许 Mock 数据库 - -**必须覆盖的测试场景**: - -1. **并发任务领取**:10 线程同时争抢同一任务,验证恰好 1 人成功(Redis + DB 双重锁) -2. **视频回调幂等**:同一 jobId 两次成功回调,验证只创建 1 个 annotation_task -3. **状态机越界拒绝**:非法状态转换(如 APPROVED → IN_PROGRESS)抛出 BusinessException -4. **多租户隔离**:公司 A 身份访问公司 B 资源,验证被拒绝 -5. **Shiro 过滤器链**:无 Token → 401;Token 有效但角色不足 → 403 - ---- - -## 无需澄清事项汇总 - -| 项目 | 状态 | 来源 | -|------|------|------| -| 认证方案 | ✅ 已确定(UUID Token) | 宪章原则三 | -| 数据库选型 | ✅ 已确定(PostgreSQL) | 宪章原则一 | -| ORM | ✅ 已确定(MyBatis Plus) | 宪章原则一 | -| 缓存/锁 | ✅ 已确定(Redis) | 宪章原则一 | -| 对象存储 | ✅ 已确定(RustFS S3) | 宪章原则一 | -| AI 集成方式 | ✅ 已确定(HTTP RestClient) | 宪章原则一 | -| 多租户隔离 | ✅ 已确定(ThreadLocal + Interceptor) | 宪章原则二 | -| 并发控制 | ✅ 已确定(双重锁) | 宪章原则七 | -| 审批事务边界 | ✅ 已确定(@TransactionalEventListener) | 宪章原则五 | -| 测试策略 | ✅ 已确定(Testcontainers) | 宪章开发工作流 | diff --git a/specs/001-label-backend-spec/spec.md b/specs/001-label-backend-spec/spec.md deleted file mode 100644 index d8efb9a..0000000 --- a/specs/001-label-backend-spec/spec.md +++ /dev/null @@ -1,273 +0,0 @@ -# 功能规格说明:label_backend 知识图谱智能标注平台 - -**功能分支**: `001-label-backend-spec` -**创建日期**: 2026-04-09 -**状态**: 草稿 -**输入**: 根据文档 docs/superpowers/specs/2026-04-09-label-backend-design.md 生成需求规格文档 - ---- - -## 用户场景与测试 *(必填)* - -### 用户故事 1 - 用户登录与身份认证 (优先级: P1) - -公司员工使用用户名和密码登录平台,获取会话凭证后访问受权限保护的功能。会话在持续活跃时保持有效,用户主动退出或管理员禁用账号后会话立即失效。 - -**优先级理由**: 认证是所有其他功能的前提,无法登录则所有功能均不可用。 - -**独立测试**: 可独立通过以下方式测试:用正确凭证登录,携带返回凭证请求受保护接口,验证正常访问;携带错误凭证或过期凭证,验证被拒绝。 - -**验收场景**: - -1. **给定** 用户持有效用户名和密码,**当** 提交登录请求,**则** 系统返回会话凭证,且该凭证可用于后续请求 -2. **给定** 用户已登录并持有效凭证,**当** 发起正常业务请求,**则** 会话有效期自动延长 -3. **给定** 用户主动退出登录,**当** 使用旧凭证访问任意受保护接口,**则** 系统立即拒绝,返回未授权响应 -4. **给定** 管理员禁用某用户账号,**当** 被禁用用户使用现有凭证访问接口,**则** 系统立即拒绝,不设任何宽限期 -5. **给定** 用户使用错误密码,**当** 提交登录请求,**则** 系统返回认证失败,不泄露用户是否存在 - ---- - -### 用户故事 2 - 原始资料上传 (优先级: P1) - -上传员将文本文件、图片或视频上传至平台,系统存储文件并记录元数据,视频文件额外触发异步预处理流程(帧提取或转文字)。 - -**优先级理由**: 资料上传是整条标注流水线的起点,没有资料则无法产生任何标注任务。 - -**独立测试**: 可独立测试:上传一个文本文件或图片,验证系统成功接收并记录;上传视频,验证系统创建预处理任务并开始异步处理。 - -**验收场景**: - -1. **给定** 上传员已登录,**当** 上传一个文本文件,**则** 系统保存文件并创建资料记录,状态为"待提取" -2. **给定** 上传员已登录,**当** 上传一张图片,**则** 系统保存图片并创建资料记录,状态为"待提取" -3. **给定** 上传员已登录,**当** 上传一个视频文件,**则** 系统保存视频,创建预处理任务,资料状态变为"预处理中" -4. **给定** 视频预处理成功完成,**当** AI 服务回调成功,**则** 每帧(帧模式)或每段转译文本(片段模式)均作为独立资料进入标注队列,原视频状态变为"已完成" -5. **给定** 视频预处理因 AI 服务故障失败且已达最大重试次数,**当** 回调失败,**则** 任务标记为失败,管理员可查阅错误信息并手动重新触发 - ---- - -### 用户故事 3 - 提取阶段标注(EXTRACTION) (优先级: P1) - -标注员从任务池中领取一个提取任务,借助 AI 辅助预标注对文本资料完成三元组标注或对图片资料完成四元组标注,提交后由审批员审核。 - -**优先级理由**: 提取阶段是双流水线的第一个生产阶段,直接产出结构化知识。 - -**独立测试**: 可独立测试:标注员领取任务,修改 AI 预标注结果,提交后验证任务进入"待审批"状态;同一任务被多人同时尝试领取,验证只有一人成功。 - -**验收场景**: - -1. **给定** 存在未被领取的提取任务,**当** 标注员请求领取,**则** 任务归属到该标注员,状态变为"进行中" -2. **给定** 同一任务被 10 名标注员同时争抢,**当** 所有人同时发起领取请求,**则** 恰好一名标注员领取成功,其余人收到"任务已被他人领取"响应 -3. **给定** 标注员已领取任务,**当** 请求 AI 辅助预标注,**则** 系统调用 AI 服务返回结构化候选结果(不直接提交,供人工编辑) -4. **给定** 标注员完成人工编辑,**当** 提交标注结果,**则** 任务状态变为"已提交",进入审批队列 -5. **给定** 标注员领取任务后决定放弃,**当** 放弃任务,**则** 任务回到任务池,可被其他标注员重新领取 - ---- - -### 用户故事 4 - 提取阶段审批 (优先级: P1) - -审批员查看提交的提取标注结果,选择通过或驳回。审批通过后系统自动创建问答生成任务;驳回时需填写驳回原因,标注员可重新领取该任务修改后再次提交。 - -**优先级理由**: 审批控制标注质量,是推进流水线到下一阶段的门控节点。 - -**独立测试**: 可独立测试:审批通过一个提取任务,验证系统自动创建 QA 生成任务;驳回一个任务,验证标注员可重领并修改。 - -**验收场景**: - -1. **给定** 审批员进入待审批队列,**当** 查看列表,**则** 只看到状态为"已提交"的任务 -2. **给定** 审批员查看某提取任务的标注结果,**当** 点击通过,**则** 标注结果标记为最终版,系统自动创建对应的问答生成任务并置于任务池中 -3. **给定** 审批员本人提交了某提取任务,**当** 该审批员尝试审批自己提交的任务,**则** 系统拒绝,提示不允许自审 -4. **给定** 审批员认为标注结果不合格,**当** 附带驳回原因并驳回,**则** 任务状态变为"已驳回",标注员可在我的任务列表中看到该任务及原因 -5. **给定** 标注员查看被驳回的任务,**当** 重新领取并修改后提交,**则** 任务重新进入审批队列 - ---- - -### 用户故事 5 - 问答生成阶段标注与审批(QA_GENERATION) (优先级: P2) - -标注员领取问答生成任务,在 AI 候选问答对基础上完成人工编辑,提交后由审批员审批。审批通过即写入训练样本库;驳回则退回标注员修改。 - -**优先级理由**: QA 阶段是流水线的最后生产阶段,直接决定训练样本质量。 - -**独立测试**: 可独立测试:领取 QA 任务,修改候选问答对并提交;审批员通过后,验证训练样本库中出现对应记录。 - -**验收场景**: - -1. **给定** 存在由提取阶段审批通过自动创建的问答生成任务,**当** 标注员进入任务池,**则** 可以看到并领取该任务 -2. **给定** 标注员已领取问答生成任务,**当** 整体提交修改后的问答对列表,**则** 任务进入审批队列(每次提交均为完整列表替换,不允许部分追加) -3. **给定** 审批员通过问答生成任务,**当** 审批完成,**则** 对应训练样本状态变为"已审批",整条资料流水线标记为完成 -4. **给定** 审批员驳回问答生成任务,**当** 驳回完成,**则** 候选问答对记录被清除,标注员可重领任务重新生成 - ---- - -### 用户故事 6 - 训练数据导出与微调提交 (优先级: P2) - -管理员从已审批的训练样本中选择一批次,导出为 GLM 微调格式的 JSONL 文件,并可选择一键提交至 GLM 微调服务。 - -**优先级理由**: 导出是将标注成果转化为 AI 训练价值的最终步骤。 - -**独立测试**: 可独立测试:选择若干已审批样本创建导出批次,验证生成 JSONL 文件;将批次提交微调服务,验证可查询到微调任务状态。 - -**验收场景**: - -1. **给定** 管理员查看样本库,**当** 筛选已审批样本,**则** 只返回状态为"已审批"的样本(分页,不可无界查询) -2. **给定** 管理员选择若干已审批样本,**当** 创建导出批次,**则** 系统生成 JSONL 文件并存储,返回批次标识;若任意样本不处于已审批状态则整批失败 -3. **给定** 导出批次已创建,**当** 管理员提交微调任务,**则** 系统向 AI 服务发起微调请求,记录微调任务标识,状态变为"进行中" -4. **给定** 微调任务已提交,**当** 管理员查询状态,**则** 返回最新的微调进度信息 - ---- - -### 用户故事 7 - 用户与权限管理 (优先级: P2) - -管理员管理本公司用户,包括创建用户、分配角色、启用/禁用账号。角色变更和账号禁用在保存后立即生效,无延迟窗口。 - -**优先级理由**: 人员和权限管理是平台运营的基础管控能力。 - -**独立测试**: 可独立测试:创建一个标注员角色用户,验证该用户可以领取任务但无法执行审批;将其角色升为审批员,立即验证可以审批;禁用该用户,验证其现有会话立即失效。 - -**验收场景**: - -1. **给定** 管理员创建一个新用户并分配角色,**当** 新用户登录,**则** 该用户拥有该角色对应的权限(高级角色自动包含低级角色权限) -2. **给定** 管理员将用户角色从标注员升为审批员,**当** 角色变更保存后,**则** 该用户无需重新登录即可使用审批功能 -3. **给定** 管理员禁用某用户账号,**当** 被禁用用户下次发起请求,**则** 系统立即返回拒绝响应,不设过渡期 -4. **给定** 管理员查询用户列表,**当** 获取结果,**则** 仅返回本公司用户,不可看到其他公司用户数据 - ---- - -### 用户故事 8 - 系统配置管理 (优先级: P3) - -管理员维护 AI Prompt 模板、模型参数、Token 有效期等系统配置项,支持公司级配置覆盖全局默认值。 - -**优先级理由**: 配置管理是运营支撑能力,可在系统运行后按需调整,不影响核心标注流程。 - -**独立测试**: 可独立测试:修改某公司的 Prompt 模板配置,验证该公司后续标注使用新模板,其他公司仍使用全局默认值。 - -**验收场景**: - -1. **给定** 管理员查看配置列表,**当** 获取结果,**则** 同时展示本公司专属配置和全局默认配置,公司专属配置对同一 Key 优先 -2. **给定** 管理员更新某配置项,**当** 保存成功,**则** 后续相关操作立即使用新配置值 -3. **给定** 某配置项仅有全局默认值无公司级覆盖,**当** 系统查询该配置,**则** 返回全局默认值 - ---- - -### 边界情况 - -- 标注员领取任务后长时间未操作——管理员可强制转移任务给其他标注员(状态保持"进行中",持有人变更) -- 视频预处理回调因网络抖动发生重复投递——系统对同一任务的重复成功回调静默忽略,不重复创建标注任务 -- 某租户上传量极大时的无界查询——所有列表接口强制分页,无法绕过分页限制获取全量数据 -- 审批员同时兼任标注员角色时尝试自审——系统按提交者身份校验,自审请求被拒绝 -- 跨公司数据访问尝试——每次数据查询自动注入当前用户所属公司标识,无法通过参数篡改访问其他公司数据 -- 操作日志写入失败——审计写入失败不影响业务操作,仅记录错误并触发告警 -- 同一账号在多设备登录——每次登录生成独立会话凭证,互不影响;退出某设备仅使该设备凭证失效 - ---- - -## 需求说明 *(必填)* - -### 功能性需求 - -**认证与会话** - -- **FR-001**: 系统必须支持基于用户名和密码的登录认证,验证通过后返回会话凭证 -- **FR-002**: 系统必须在每次有效请求时自动延长会话有效期(滑动过期) -- **FR-003**: 系统必须支持主动退出登录,退出后凭证立即失效 -- **FR-004**: 系统必须在管理员禁用账号后立即使该账号所有有效凭证失效,不设任何宽限期 -- **FR-005**: 系统必须拒绝无凭证或过期凭证的请求,返回未授权响应 - -**访问控制** - -- **FR-006**: 系统必须实现四级角色体系:上传员 ⊂ 标注员 ⊂ 审批员 ⊂ 管理员,高级角色自动继承低级角色权限 -- **FR-007**: 系统必须在接口层声明每个接口所需的最低角色,角色不足时拒绝访问 -- **FR-008**: 系统必须在角色变更保存后立即生效,无需等待会话自然过期 - -**多租户数据隔离** - -- **FR-009**: 系统必须保证每个公司的数据完全隔离,任何查询均只返回当前用户所属公司的数据 -- **FR-010**: 系统必须禁止调用方通过请求参数指定公司标识来访问其他公司数据;公司标识必须从服务端会话中获取 -- **FR-011**: 全局系统配置对所有公司可见,公司级配置对同一配置项优先覆盖全局值 - -**资料管理** - -- **FR-012**: 系统必须支持文本、图片、视频三种原始资料的上传,文件内容存储至对象存储服务,数据库只保存元数据和存储路径 -- **FR-013**: 视频上传后必须触发异步预处理任务,不阻塞上传响应 -- **FR-014**: 系统必须支持视频帧提取模式(每帧作为独立图片进入图片标注流水线)和视频片段转文本模式(派生文本资料进入文本标注流水线) -- **FR-015**: 视频片段转文本产生的派生资料必须记录对原始视频资料的引用,可追溯来源 - -**任务管理** - -- **FR-016**: 系统必须支持并发安全的任务领取机制,确保同一任务不会被两名标注员同时持有 -- **FR-017**: 系统必须支持任务放弃(退回任务池)和管理员强制转移任务归属 -- **FR-018**: 每次任务状态变更必须记录历史快照(含操作人、操作时间、驳回原因等),不可修改或删除历史记录 -- **FR-019**: 所有任务列表接口必须强制分页,不允许无界查询 - -**提取阶段标注工作台** - -- **FR-020**: 系统必须调用 AI 服务生成候选提取结果供标注员参考编辑,不直接写入最终结果 -- **FR-021**: 标注员提交的提取结果以整体替换方式存储,禁止局部追加修改 -- **FR-022**: 审批员审批通过时,系统必须在同一操作中将提取结果标记为最终版并自动创建问答生成任务,该级联操作不得由前端发起独立请求触发 -- **FR-023**: 系统必须拒绝提交者本人审批或驳回自己提交的任务(禁止自审) -- **FR-024**: 审批驳回时,标注员必须可以看到被驳回任务及驳回原因,并可重新领取修改后再次提交 - -**问答生成阶段** - -- **FR-025**: 问答生成任务的标注结果采用整体替换,每次提交包含完整问答对列表 -- **FR-026**: 问答生成阶段审批通过时,对应训练样本必须写入训练样本库,资料状态标记为"已完成" -- **FR-027**: 问答生成阶段审批驳回时,候选问答对记录必须被清除,标注员可重领任务重新生成 - -**训练数据导出** - -- **FR-028**: 系统必须支持将已审批的训练样本批量导出为 GLM 微调格式,每条样本一行 -- **FR-029**: 导出时若任意选定样本不处于已审批状态,整批导出请求必须失败 -- **FR-030**: 系统必须支持将导出批次提交至外部 AI 微调服务,并可追踪微调任务进度 - -**审计日志** - -- **FR-031**: 系统必须对所有状态变更操作自动记录审计日志,包含操作人姓名快照、操作类型、结果、IP 地址等信息 -- **FR-032**: 审计日志只追加不修改,禁止对审计记录执行更新或删除 -- **FR-033**: 审计日志写入失败不得导致业务操作失败或回滚 - -**视频异步处理** - -- **FR-034**: 视频预处理任务必须支持自动重试,达到最大重试次数后置为失败状态,需管理员手动重新触发 -- **FR-035**: AI 服务对同一视频处理任务的重复成功回调必须被幂等处理,不得重复创建标注任务 - -### 核心实体 - -- **公司(Company)**: 多租户根节点,每个公司拥有独立的用户、资料和任务数据空间 -- **用户(User)**: 属于某公司,拥有角色(上传员/标注员/审批员/管理员),通过会话凭证访问系统 -- **原始资料(SourceData)**: 待标注的文件(文本/图片/视频),拥有状态流转(待处理→提取中→QA审核中→已完成);视频派生资料通过父资料引用保留溯源链 -- **标注任务(AnnotationTask)**: 标注工作单元,分提取阶段和问答生成阶段,拥有领取、提交、审批、驳回完整生命周期 -- **标注结果(AnnotationResult)**: 提取阶段的结构化输出(三元组或四元组),以整体 JSON 存储 -- **训练样本(TrainingDataset)**: 经审批的问答对,GLM 微调格式,待导出 -- **导出批次(ExportBatch)**: 一批训练样本的导出记录,关联外部微调任务标识 -- **视频处理任务(VideoProcessJob)**: 视频预处理的异步任务跟踪,包含重试计数和最终输出路径 -- **系统配置(SysConfig)**: 配置键值对,分全局默认和公司级两层,公司级优先 - ---- - -## 成功标准 *(必填)* - -### 可度量结果 - -- **SC-001**: 同一标注任务被多人同时争抢时,有且仅有一人领取成功,其余人立即收到明确的"已被领取"响应,成功率 100%,无数据竞争导致的双重持有 -- **SC-002**: 管理员禁用账号或变更角色后,该账号的权限变更在下一次请求时立即生效(延迟小于 1 秒) -- **SC-003**: 提取阶段审批通过时,问答生成任务在同一次操作中自动出现在任务池,无需任何人工干预步骤 -- **SC-004**: 视频预处理回调的重复投递(同一任务多次成功回调)不产生重复标注任务,幂等处理成功率 100% -- **SC-005**: 跨公司数据访问尝试 100% 被系统拒绝,无任何数据泄露至非所属租户 -- **SC-006**: 审计日志对所有状态变更操作的覆盖率达到 100%,审计写入失败不影响业务成功率 -- **SC-007**: 所有列表接口在数据量增长时保持稳定响应,用户无法绕过分页限制一次性获取不受限制数量的记录 -- **SC-008**: 标注员完成一次任务领取→标注→提交的完整操作流程(不含 AI 辅助预标注等待时间)可在 5 分钟内完成 -- **SC-009**: 从资料上传到训练样本进入样本库的完整流水线(含两次人工标注和两次审批)中,每个节点的操作人、时间、结果均可查询追溯 - ---- - -## 假设与前提 - -- 系统服务于多个公司,每家公司的用户、资料和标注数据完全独立,不存在跨公司协作场景 -- 每位用户在同一时刻只属于一家公司,不存在用户跨公司兼职的场景 -- 视频预处理(帧提取、转文字)由外部 AI 服务异步完成,后端只负责触发和回调处理 -- 微调结果的质量评估不在本平台范围内,平台只负责提交微调任务并查询状态 -- 前端应用已独立开发,本规格仅覆盖后端 API 能力 -- 所有文件二进制内容存储在兼容 S3 协议的对象存储服务中,不存入关系型数据库 -- 生产环境使用容器化部署,后端服务、数据库、缓存、对象存储均为独立容器 -- AI 服务通过 HTTP 提供结构化的提取和问答生成能力,后端不内嵌 AI 模型 -- 标注流水线中一条资料同一时间只有一个活跃的提取任务或问答生成任务,不支持并行多版本标注 -- 审计日志的长期归档(超过月分区范围)由数据库运维团队负责,不在本系统范围内 diff --git a/specs/001-label-backend-spec/tasks.md b/specs/001-label-backend-spec/tasks.md deleted file mode 100644 index 7954237..0000000 --- a/specs/001-label-backend-spec/tasks.md +++ /dev/null @@ -1,310 +0,0 @@ -# 任务清单:label_backend 知识图谱智能标注平台 - -**输入**: `/specs/001-label-backend-spec/` 全部设计文档 -**前置条件**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/ ✅ | quickstart.md ✅ - -## 格式说明 - -- **[P]**: 可并行执行(不同文件,无未完成任务的依赖) -- **[USn]**: 对应 spec.md 中的用户故事编号 -- 每条任务包含精确的文件路径 - ---- - -## Phase 1: 项目初始化 - -**目标**: 创建 Maven 项目骨架、基础配置和 Docker 环境 - -- [ ] T001 创建 Maven 项目骨架(`com.label` GroupId,`label-backend` ArtifactId,Java 17 编译目标) -- [ ] T002 配置 `pom.xml`(Spring Boot 3、Apache Shiro 1.13.x、MyBatis Plus 3.5.x、Spring Data Redis、AWS S3 SDK v2、Testcontainers、Lombok) -- [ ] T003 [P] 创建 `sql/init.sql`(按依赖顺序建全部 11 张表:sys_company → sys_user → source_data → annotation_task → annotation_result → training_dataset → export_batch → sys_config → sys_operation_log → annotation_task_history → video_process_job;含所有索引和初始配置数据) -- [ ] T004 [P] 创建 `docker-compose.yml`(postgres、redis、rustfs、backend、ai-service、frontend 六个服务,含健康检查)和后端 `Dockerfile`(eclipse-temurin:17-jre-alpine) -- [ ] T005 创建 `src/main/resources/application.yml`(数据源、Redis、RustFS、AI 服务 base-url、Shiro 相关配置项) - -**检查点**: Maven 编译通过(`mvn compile`),Docker Compose `up -d` 全部服务健康 - ---- - -## Phase 2: 公共基础设施(阻塞性前置条件) - -**目标**: 所有业务模块依赖的公共组件。**必须全部完成后用户故事阶段才能开始** - -**⚠️ 重要**: 此阶段未完成前任何用户故事均不可开始实现 - -- [ ] T006 创建 `Result`、`ResultCode`、`PageResult` — `src/main/java/com/label/common/result/`(统一响应格式:`{"code":"SUCCESS","data":{...}}`) -- [ ] T007 [P] 创建 `BusinessException`(含 `code`、`message`、`httpStatus`)和 `GlobalExceptionHandler`(`@RestControllerAdvice`)— `src/main/java/com/label/common/exception/` -- [ ] T008 [P] 创建 `CompanyContext`(ThreadLocal,含 `set/get/clear` 三个方法,clear 必须在 finally 块调用)— `src/main/java/com/label/common/context/CompanyContext.java` -- [ ] T009 创建 `RedisKeyManager`(三个静态方法:`tokenKey`、`userPermKey`、`taskClaimKey`)和 `RedisService` — `src/main/java/com/label/common/redis/` -- [ ] T010 创建 MyBatis Plus 配置类 `MybatisPlusConfig`,注册 `TenantLineInnerInterceptor`(从 `CompanyContext` 获取 `companyId` 自动注入 WHERE 子句;`sys_company`、`sys_config` 加入忽略表列表)— `src/main/java/com/label/common/config/MybatisPlusConfig.java` -- [ ] T011 创建 `StateValidator`(`assertTransition` 泛型方法,违规时抛出 `BusinessException("INVALID_STATE_TRANSITION",...)`)— `src/main/java/com/label/common/statemachine/StateValidator.java` -- [ ] T012 [P] 创建 `SourceStatus` 枚举(PENDING/PREPROCESSING/EXTRACTING/QA_REVIEW/APPROVED,含 TRANSITIONS Map)— `src/main/java/com/label/common/statemachine/SourceStatus.java` -- [ ] T013 [P] 创建 `TaskStatus` 枚举(UNCLAIMED/IN_PROGRESS/SUBMITTED/APPROVED/REJECTED,含 TRANSITIONS Map,含 IN_PROGRESS→IN_PROGRESS 用于 ADMIN 强制转移)— `src/main/java/com/label/common/statemachine/TaskStatus.java` -- [ ] T014 [P] 创建 `DatasetStatus` 枚举(PENDING_REVIEW/APPROVED/REJECTED,含 TRANSITIONS Map)— `src/main/java/com/label/common/statemachine/DatasetStatus.java` -- [ ] T015 [P] 创建 `VideoJobStatus` 枚举(PENDING/RUNNING/SUCCESS/FAILED/RETRYING,含 TRANSITIONS Map,注释说明 FAILED→PENDING 由 ADMIN 手动触发)— `src/main/java/com/label/common/statemachine/VideoJobStatus.java` -- [ ] T016 创建 `@OperationLog` 注解(`type` 和 `targetType` 两个属性,`@Around` 级别)— `src/main/java/com/label/common/aop/OperationLog.java` -- [ ] T017 创建 `AuditAspect`(`@Around("@annotation(operationLog)")`,在 finally 块以独立操作写入 `sys_operation_log`;审计写入失败只记录 error 日志,禁止抛出异常回滚业务)— `src/main/java/com/label/common/aop/AuditAspect.java` -- [ ] T018 [P] 创建 `RustFsClient`(AWS S3 SDK v2 封装,endpoint 指向 RustFS;实现 `upload`、`download`、`delete`、`getPresignedUrl`)— `src/main/java/com/label/common/storage/RustFsClient.java` -- [ ] T019 [P] 创建 `AiServiceClient`(`RestClient` 封装,8 个端点:`extractText`、`extractImage`、`extractFrames`、`videoToText`、`genTextQa`、`genImageQa`、`startFinetune`、`getFinetuneStatus`)— `src/main/java/com/label/common/ai/AiServiceClient.java` -- [ ] T020 创建 Shiro 三件套:`TokenFilter`(解析 `Authorization: Bearer {uuid}`,查 Redis `token:{uuid}`,注入 `CompanyContext`,请求结束 finally 清理 ThreadLocal)、`UserRealm`(先查 Redis `user:perm:{userId}` TTL 5min,未命中查 PG;含 `addInheritedRoles`)、`ShiroConfig`(过滤器链:`/api/auth/login` → `anon`,`/api/**` → `tokenFilter`)— `src/main/java/com/label/common/shiro/` -- [ ] T021 创建 `AbstractIntegrationTest`(Testcontainers,启动真实 PostgreSQL + Redis 容器,执行 sql/init.sql,注入测试用的公司和用户数据)— `src/test/java/com/label/AbstractIntegrationTest.java` -- [ ] T022 集成测试:`ShiroFilterIntegrationTest`(无 Token → 401;有效 Token 但角色不足 → 403;有效 Token 且角色满足 → 200)— `src/test/java/com/label/integration/ShiroFilterIntegrationTest.java` -- [ ] T023 单元测试:`StateMachineTest`(验证所有枚举的合法转换通过;非法转换抛出 `BusinessException("INVALID_STATE_TRANSITION")`)— `src/test/java/com/label/unit/StateMachineTest.java` - -**检查点**: 基础设施就绪,所有 Phase 3+ 的用户故事可并行开始 - ---- - -## Phase 3: 用户故事 1 — 用户登录与身份认证(优先级: P1)🎯 MVP - -**目标**: 用户可以用用户名和密码登录,获得会话凭证,使用凭证访问受保护接口,退出后凭证立即失效 - -**独立测试**: 登录 → 获取 Token → 访问 `/api/auth/me` 返回用户信息 → 退出 → 再次访问返回 401 - -- [ ] T024 [P] [US1] 创建 `SysCompany` 实体(MyBatis Plus `@TableName`)和 `SysCompanyMapper` — `src/main/java/com/label/module/user/entity/SysCompany.java` + `mapper/SysCompanyMapper.java` -- [ ] T025 [P] [US1] 创建 `SysUser` 实体(`passwordHash` 字段加 `@JsonIgnore`)和 `SysUserMapper`(含 `selectByCompanyAndUsername` 方法)— `src/main/java/com/label/module/user/entity/SysUser.java` + `mapper/SysUserMapper.java` -- [ ] T026 [US1] 实现 `AuthService`:`login()`(BCrypt 校验密码 → UUID v4 Token → Redis Hash 存储 userId/role/companyId/username → 设置 TTL = `token_ttl_seconds` 配置值);`logout()`(删除 Redis Token Key)— `src/main/java/com/label/module/user/service/AuthService.java` -- [ ] T027 [US1] 实现 `AuthController`:`POST /api/auth/login`(`anon`,调用 `AuthService.login()`)、`POST /api/auth/logout`(已登录)、`GET /api/auth/me`(返回当前用户信息);所有响应用 `Result` 包装 — `src/main/java/com/label/module/user/controller/AuthController.java` -- [ ] T028 [US1] 集成测试:正确密码登录返回 Token;Token 有效时 `/api/auth/me` 返回 200;主动退出后再访问返回 401;错误密码登录返回 401 — `src/test/java/com/label/integration/AuthIntegrationTest.java` - -**检查点**: US1 独立可测试 — 登录/退出流程完整可用 - ---- - -## Phase 4: 用户故事 2 — 原始资料上传(优先级: P1) - -**目标**: 上传员可以上传文本/图片/视频,查询自己的资料列表;管理员可查看全公司资料 - -**独立测试**: 上传文本文件 → 列表查到 → 详情含预签名 URL → 管理员可删除 - -- [ ] T029 [P] [US2] 创建 `SourceData` 实体(含 `parentSourceId` 自引用字段)和 `SourceDataMapper`(含 `updateStatus` 方法)— `src/main/java/com/label/module/source/entity/SourceData.java` + `mapper/SourceDataMapper.java` -- [ ] T030 [US2] 实现 `SourceService`:`upload()`(先 insert 获取 ID → 构造路径 → 上传 RustFS → 更新 filePath);`list()`(UPLOADER 按 `uploaderId` 过滤,ADMIN 不过滤,强制分页);`findById()`(含 15 分钟预签名 URL);`delete()`(仅 PENDING 状态可删,同步删 RustFS 文件)— `src/main/java/com/label/module/source/service/SourceService.java` -- [ ] T031 [US2] 实现 `SourceController`(`POST /api/source/upload`、`GET /api/source/list`、`GET /api/source/{id}`、`DELETE /api/source/{id}`;`@RequiresRoles` 注解声明权限;所有响应 `Result` 包装)— `src/main/java/com/label/module/source/controller/SourceController.java` -- [ ] T032 [US2] 集成测试:UPLOADER 上传文本/图片 → 列表仅返回自己的资料;ADMIN 查看列表返回全部;上传视频 → source_data 状态为 PENDING(视频预处理 Phase 9 覆盖);已进入流水线的资料删除返回 409 — `src/test/java/com/label/integration/SourceIntegrationTest.java` - -**检查点**: US2 独立可测试 — 上传/查询/删除流程完整可用 - ---- - -## Phase 5: 用户故事 3+4 — 提取阶段标注与审批(优先级: P1) - -**目标**: 标注员可以领取任务(并发安全)、AI 辅助预标注、编辑并提交;审批员可以通过(自动触发 QA 任务)或驳回(标注员可重领) - -**独立测试**: 创建任务 → 标注员领取 → AI 预标注 → 提交 → 审批通过 → QA 任务自动出现在任务池 - -### 实体与数据层 - -- [ ] T033 [P] [US3] 创建 `AnnotationTask` 实体 + `AnnotationTaskMapper`(含 `claimTask(taskId, userId, companyId)` 方法,SQL:`UPDATE ... SET status='IN_PROGRESS', claimed_by=?, claimed_at=NOW() WHERE id=? AND status='UNCLAIMED' AND company_id=?`,返回影响行数)— `src/main/java/com/label/module/task/entity/AnnotationTask.java` + `mapper/AnnotationTaskMapper.java` -- [ ] T034 [P] [US3] 创建 `AnnotationTaskHistory` 实体 + `TaskHistoryMapper` — `src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java` + `mapper/TaskHistoryMapper.java` -- [ ] T035 [P] [US3] 创建 `AnnotationResult` 实体 + `AnnotationResultMapper`(含 `updateResultJson` 整体覆盖方法和 `selectByTaskId` 方法)— `src/main/java/com/label/module/annotation/entity/AnnotationResult.java` + `mapper/AnnotationResultMapper.java` - -### 任务管理服务与控制器 - -- [ ] T036 [US3] 实现 `TaskClaimService.claim()`(① Redis `SET NX task:claim:{taskId}` TTL 30s,失败抛 `TASK_CLAIMED`;② DB `claimTask()` 影响行数为 0 时抛 `TASK_CLAIMED`;③ `insertHistory(UNCLAIMED→IN_PROGRESS)`)和 `unclaim()`(StateValidator + 清 Redis 锁 + 历史)和 `reclaim()`(校验 REJECTED + claimedBy = 当前用户 + REJECTED→IN_PROGRESS + 历史)— `src/main/java/com/label/module/task/service/TaskClaimService.java` -- [ ] T037 [US3] 实现 `TaskService`(`createTask`、`getPool`(按角色过滤:ANNOTATOR→UNCLAIMED/EXTRACTION;REVIEWER→SUBMITTED)、`getMine`(含 IN_PROGRESS/SUBMITTED/REJECTED)、`getPendingReview`(SUBMITTED,分页)、`getById`、`reassign`(ADMIN,仅更新 claimedBy + 历史))— `src/main/java/com/label/module/task/service/TaskService.java` -- [ ] T038 [US3] 实现 `TaskController`(10 个端点:`POST /api/tasks`、`GET /api/tasks/pool`、`POST /api/tasks/{id}/claim`、`POST /api/tasks/{id}/unclaim`、`GET /api/tasks/mine`、`POST /api/tasks/{id}/reclaim`、`GET /api/tasks/pending-review`、`GET /api/tasks/{id}`、`GET /api/tasks`、`PUT /api/tasks/{id}/reassign`)— `src/main/java/com/label/module/task/controller/TaskController.java` - -### 提取标注服务与控制器 - -- [ ] T039 [US3] 实现 `ExtractionService.aiPreAnnotate()`(调用 `AiServiceClient.extractText/extractImage`,写入 `annotation_result`)和 `updateResult()`(整体覆盖 `result_json`,校验 JSON 格式)— `src/main/java/com/label/module/annotation/service/ExtractionService.java` -- [ ] T040 [US3] 实现 `ExtractionService.submit()`(`@Transactional`:IN_PROGRESS→SUBMITTED + `submitted_at` + insertHistory)— `src/main/java/com/label/module/annotation/service/ExtractionService.java` -- [ ] T041 [US4] 创建 `ExtractionApprovedEvent`(携带 `taskId`、`sourceId`、`sourceType`、`companyId`)— `src/main/java/com/label/module/annotation/event/ExtractionApprovedEvent.java` -- [ ] T042 [US4] 实现 `ExtractionService.approve()`(`@Transactional`:① 自审校验;② `is_final=true`;③ SUBMITTED→APPROVED + `completedAt` + 历史;④ `publishEvent(ExtractionApprovedEvent)`;AI 调用禁止在此事务内执行)— `src/main/java/com/label/module/annotation/service/ExtractionService.java` -- [ ] T043 [US4] 实现 `ExtractionApprovedEventListener`(`@TransactionalEventListener(AFTER_COMMIT)` + `@Transactional(REQUIRES_NEW)`:调用 AI 生成候选问答对 → 写 `training_dataset`(PENDING_REVIEW)→ 创建 QA_GENERATION 任务(UNCLAIMED)→ `source_data` 状态→ QA_REVIEW)— `src/main/java/com/label/module/annotation/service/ExtractionApprovedEventListener.java` -- [ ] T044 [US4] 实现 `ExtractionService.reject()`(`@Transactional`:① 自审校验;② StateValidator;③ SUBMITTED→REJECTED + 历史)— `src/main/java/com/label/module/annotation/service/ExtractionService.java` -- [ ] T045 [US4] 实现 `ExtractionController`(5 个端点:`GET /api/extraction/{taskId}`、`PUT /api/extraction/{taskId}`、`POST /api/extraction/{taskId}/submit`、`POST /api/extraction/{taskId}/approve`、`POST /api/extraction/{taskId}/reject`)— `src/main/java/com/label/module/annotation/controller/ExtractionController.java` - -### 集成测试 - -- [ ] T046 [US3] 并发集成测试:10 个线程同时争抢同一 UNCLAIMED 任务,验证恰好 1 人成功、其余均收到 `TASK_CLAIMED` 错误、DB 中 `claimed_by` 唯一 — `src/test/java/com/label/integration/TaskClaimConcurrencyTest.java` -- [ ] T047 [US4] 集成测试:审批通过 → QA 任务自动出现在任务池;自审返回 `SELF_REVIEW_FORBIDDEN` 403;驳回后标注员可重领并再次提交 — `src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java` - -**检查点**: US3+US4 独立可测试 — 完整提取流水线(领取→标注→提交→审批→QA任务自动创建)可用 - ---- - -## Phase 6: 用户故事 5 — 问答生成阶段标注与审批(优先级: P2) - -**目标**: 标注员领取 QA 任务、修改候选问答对并提交;审批员通过后训练样本入库,整条流水线完成 - -**独立测试**: 领取 QA 任务 → 修改问答对 → 提交 → 审批通过 → training_dataset 状态 APPROVED,source_data 状态 APPROVED - -- [ ] T048 [P] [US5] 创建 `TrainingDataset` 实体 + `TrainingDatasetMapper`(含 `approveByTaskId`、`deleteByTaskId` 方法)— `src/main/java/com/label/module/annotation/entity/TrainingDataset.java` + `mapper/TrainingDatasetMapper.java` -- [ ] T049 [US5] 实现 `QaService.updateResult()`(整体覆盖问答对 JSONB)和 `submit()`(`@Transactional`:IN_PROGRESS→SUBMITTED + 历史)— `src/main/java/com/label/module/annotation/service/QaService.java` -- [ ] T050 [US5] 实现 `QaService.approve()`(`@Transactional`:① `validateAndGetTask` 先于一切 DB 写入;② 自审校验;③ `training_dataset` → APPROVED;④ `annotation_task` → APPROVED + 历史;⑤ `source_data` → APPROVED)— `src/main/java/com/label/module/annotation/service/QaService.java` -- [ ] T051 [US5] 实现 `QaService.reject()`(`@Transactional`:① 自审校验;② `deleteByTaskId` 清除候选问答对;③ SUBMITTED→REJECTED + 历史;④ `source_data` 保持 QA_REVIEW 不变)— `src/main/java/com/label/module/annotation/service/QaService.java` -- [ ] T052 [US5] 实现 `QaController`(5 个端点:`GET /api/qa/{taskId}`、`PUT /api/qa/{taskId}`、`POST /api/qa/{taskId}/submit`、`POST /api/qa/{taskId}/approve`、`POST /api/qa/{taskId}/reject`)— `src/main/java/com/label/module/annotation/controller/QaController.java` -- [ ] T053 [US5] 集成测试:QA 审批通过 → `training_dataset.status = APPROVED`,`source_data.status = APPROVED`;QA 驳回 → 候选记录被删除,标注员可重领 — `src/test/java/com/label/integration/QaApprovalIntegrationTest.java` - -**检查点**: US5 独立可测试 — 完整 QA 流水线可用,training_dataset 产出验证通过 - ---- - -## Phase 7: 用户故事 6 — 训练数据导出与微调提交(优先级: P2) - -**目标**: 管理员将已审批样本批量导出为 JSONL,并可提交 GLM 微调任务 - -**独立测试**: 选取已审批样本 → 创建批次 → RustFS 中存在 JSONL 文件 → 提交微调 → 可查询状态 - -- [ ] T054 [P] [US6] 创建 `ExportBatch` 实体 + `ExportBatchMapper` — `src/main/java/com/label/module/export/entity/ExportBatch.java` + `mapper/ExportBatchMapper.java` -- [ ] T055 [US6] 实现 `ExportService.createBatch()`(`@Transactional`:① 校验全部样本为 APPROVED;② 生成 JSONL(每行一个 `glm_format_json`);③ 上传 RustFS `finetune-export/export/{batchUuid}.jsonl`;④ 批量更新 `export_batch_id`/`exported_at`;⑤ 插入 `export_batch` 记录)— `src/main/java/com/label/module/export/service/ExportService.java` -- [ ] T056 [US6] 实现 `FinetuneService`:`trigger()`(调用 `AiServiceClient.startFinetune()`,更新 `glm_job_id` 和 `finetune_status = RUNNING`)和 `getStatus()`(调用 `AiServiceClient.getFinetuneStatus()`)— `src/main/java/com/label/module/export/service/FinetuneService.java` -- [ ] T057 [US6] 实现 `ExportController`(`GET /api/training/samples`、`POST /api/export/batch`、`POST /api/export/{batchId}/finetune`、`GET /api/export/{batchId}/status`、`GET /api/export/list`;全部 `@RequiresRoles("ADMIN")`)— `src/main/java/com/label/module/export/controller/ExportController.java` -- [ ] T058 [US6] 集成测试:成功创建批次后 JSONL 文件存在于 RustFS;包含非 APPROVED 样本时返回 `INVALID_SAMPLES` 400 — `src/test/java/com/label/integration/ExportIntegrationTest.java` - -**检查点**: US6 独立可测试 — 导出批次创建和微调提交流程可用 - ---- - -## Phase 8: 用户故事 7 — 用户与权限管理(优先级: P2) - -**目标**: 管理员可以创建用户、变更角色(立即生效)、禁用账号(立即失效) - -**独立测试**: 创建标注员用户 → 验证其能领取任务 → 升为审批员 → 验证立即可以审批 → 禁用账号 → 已有 Token 立即失效 - -- [ ] T059 [US7] 实现 `UserService`:`createUser()`(BCrypt 哈希密码,强度因子 ≥ 10);`updateUser()`;`updateRole()`(DB 写入后立即 `redisTemplate.delete(userPermKey(userId))`);`updateStatus()`(禁用时删 Redis Token + 权限缓存)— `src/main/java/com/label/module/user/service/UserService.java` -- [ ] T060 [US7] 实现 `UserController`(`GET /api/users`、`POST /api/users`、`PUT /api/users/{id}`、`PUT /api/users/{id}/status`、`PUT /api/users/{id}/role`;全部 `@RequiresRoles("ADMIN")`)— `src/main/java/com/label/module/user/controller/UserController.java` -- [ ] T061 [US7] 集成测试:变更角色后权限下一次请求立即生效(无需重新登录);禁用账号后现有 Token 下一次请求立即返回 401 — `src/test/java/com/label/integration/UserManagementIntegrationTest.java` - -**检查点**: US7 独立可测试 — 用户管理和即时权限变更可用 - ---- - -## Phase 9: 用户故事 8 — 视频处理与系统配置(优先级: P3) - -**目标**: 上传视频后触发异步预处理(帧提取/转文字);AI 回调幂等处理;管理员可配置 Prompt 模板等系统参数 - -**独立测试(视频)**: 上传视频 → 创建处理任务 → 模拟成功回调 → annotation_task 出现在任务池;重复成功回调 → 任务数量不增加 -**独立测试(配置)**: 为公司设置专属 Prompt → 验证该公司使用新值;其他公司使用全局默认 - -- [ ] T062 [P] [US8] 创建 `VideoProcessJob` 实体 + `VideoProcessJobMapper` — `src/main/java/com/label/module/video/entity/VideoProcessJob.java` + `mapper/VideoProcessJobMapper.java` -- [ ] T063 [P] [US8] 创建 `SysConfig` 实体 + `SysConfigMapper`(含 `selectByCompanyAndKey(companyId, configKey)` 方法,支持 `companyId IS NULL` 查询)— `src/main/java/com/label/module/config/entity/SysConfig.java` + `mapper/SysConfigMapper.java` -- [ ] T064 [US8] 实现 `VideoProcessService`:`createJob()`(`@Transactional`:`source_data.status → PREPROCESSING` + 插入 job + 触发 AI 异步调用);`handleCallback()`(`@Transactional`:幂等检查 status==SUCCESS 则 return;成功 → SUCCESS + `source_data.status → PENDING`;失败 → 按 retry_count 决定 RETRYING 或 FAILED);`reset()`(FAILED → PENDING)— `src/main/java/com/label/module/video/service/VideoProcessService.java` -- [ ] T065 [US8] 实现 `VideoController`(`POST /api/video/process`、`GET /api/video/jobs/{jobId}`、`POST /api/video/jobs/{jobId}/reset`、`POST /api/video/callback`(内部接口,IP 白名单或服务密钥保护))— `src/main/java/com/label/module/video/controller/VideoController.java` -- [ ] T066 [US8] 实现 `SysConfigService.get(configKey)`(先按 `(companyId, key)` 查;未命中按 `(NULL, key)` 查全局默认)和 `update(key, value)`(UPSERT:公司级配置不存在则创建,存在则覆盖)— `src/main/java/com/label/module/config/service/SysConfigService.java` -- [ ] T067 [US8] 实现 `SysConfigController`(`GET /api/config`(合并公司级 + 全局,标注 scope)、`PUT /api/config/{key}`;均 `@RequiresRoles("ADMIN")`)— `src/main/java/com/label/module/config/controller/SysConfigController.java` -- [ ] T068 [US8] 集成测试:同一 jobId 两次成功回调,`annotation_task` 记录数为 1(幂等);达最大重试次数后 status = FAILED — `src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java` -- [ ] T069 [US8] 集成测试:公司级配置覆盖同 Key 的全局默认;其他公司读取全局默认 — `src/test/java/com/label/integration/SysConfigIntegrationTest.java` - -**检查点**: US8 独立可测试 — 视频处理幂等和配置管理可用 - ---- - -## Phase 10: 收尾与横切关注点 - -**目标**: 多租户隔离验证、整体合规检查、快速启动验证 - -- [ ] T070 集成测试:`MultiTenantIsolationTest`(公司 A 身份查询公司 B 的资料/任务 → 返回空列表或 404,不泄露数据)— `src/test/java/com/label/integration/MultiTenantIsolationTest.java` -- [ ] T071 [P] 代码审查:检查所有 Controller 方法返回值均为 `Result` 或 `Result>`,无裸 POJO 或裸 List 返回 -- [ ] T072 [P] 代码审查:检查所有列表查询方法均含分页参数(`page`/`pageSize`),无 `selectAll()` 或不分页的查询 -- [ ] T073 [P] 代码审查:检查 `sys_operation_log` 相关代码,确认应用层零处 UPDATE 或 DELETE -- [ ] T074 [P] 代码审查:检查所有 `@Transactional` 方法内无 `AiServiceClient` 的同步 HTTP 调用(审批触发 AI 必须通过 `@TransactionalEventListener`) -- [ ] T075 运行 `quickstart.md` 端到端验证:`docker compose up -d` → 登录 → 上传文件 → 创建任务 → 领取 → 提交 → 审批通过 → 确认 QA 任务出现 - ---- - -## 依赖关系与执行顺序 - -### 阶段依赖 - -``` -Phase 1(初始化) - ↓ -Phase 2(基础设施)[全部完成后解锁所有用户故事] - ↓ -Phase 3(US1 认证) ← 可与 Phase 4/5/6/7/8/9 并行 -Phase 4(US2 上传) ← 依赖 Phase 2,独立于其他用户故事 -Phase 5(US3+4 提取) ← 依赖 Phase 2(上传已有资料的集成测试依赖 US2) -Phase 6(US5 QA) ← 依赖 Phase 5 完成(QA 任务由提取审批自动创建) -Phase 7(US6 导出) ← 依赖 Phase 6 完成(需要 APPROVED 的 training_dataset) -Phase 8(US7 用户管理) ← 依赖 Phase 3(UserService 在 AuthService 基础上扩展) -Phase 9(US8 视频+配置) ← 依赖 Phase 2,其余独立 - ↓ -Phase 10(收尾) -``` - -### 用户故事间依赖 - -- **US1(认证)**: 仅依赖 Phase 2,完全独立 -- **US2(上传)**: 仅依赖 Phase 2,完全独立 -- **US3+4(提取)**: 依赖 Phase 2;集成测试中使用已上传资料需 US2 -- **US5(QA)**: 依赖 US3+4(QA 任务来源于提取阶段审批通过的级联触发) -- **US6(导出)**: 依赖 US5(需要 APPROVED 状态的 training_dataset) -- **US7(用户管理)**: 依赖 US1(UserService 扩展 AuthService 的用户实体) -- **US8(视频+配置)**: 仅依赖 Phase 2 - -### 阶段内并行机会 - -- Phase 2:T007-T010、T012-T015、T018-T019 均可并行(独立文件) -- Phase 3:T024、T025 可并行(独立文件) -- Phase 5:T033、T034、T035 可并行(独立文件) -- Phase 9:T062、T063 可并行(独立文件) -- Phase 10:T071-T074 全部可并行(仅代码审查,无文件修改) - ---- - -## 并行执行示例 - -### Phase 2 基础设施并行 - -``` -同时启动: - 任务: "创建 BusinessException、GlobalExceptionHandler — common/exception/" [T007] - 任务: "创建 CompanyContext(ThreadLocal)— common/context/" [T008] - 任务: "创建 RustFsClient — common/storage/" [T018] - 任务: "创建 AiServiceClient — common/ai/" [T019] - 任务: "创建 SourceStatus 枚举" [T012] - 任务: "创建 TaskStatus 枚举" [T013] -``` - -### Phase 5 提取阶段并行 - -``` -同时启动(实体/Mapper): - 任务: "创建 AnnotationTask 实体 + Mapper" [T033] - 任务: "创建 AnnotationTaskHistory 实体 + Mapper" [T034] - 任务: "创建 AnnotationResult 实体 + Mapper" [T035] -``` - ---- - -## 实施策略 - -### MVP 优先(仅用户故事 1) - -1. 完成 Phase 1(初始化) -2. 完成 Phase 2(基础设施)— **关键,阻塞所有故事** -3. 完成 Phase 3(US1 认证) -4. **停止并验证**: 登录/退出/权限校验全流程可用 -5. 可以独立部署演示认证功能 - -### 增量交付 - -1. Phase 1 + Phase 2 → 基础就绪 -2. Phase 3(US1)→ 验证 → 演示(MVP) -3. Phase 4(US2)→ 验证 → 演示(上传功能) -4. Phase 5(US3+4)→ 验证 → 演示(标注流程) -5. Phase 6(US5)→ 验证 → 演示(完整双阶段流水线) -6. Phase 7(US6)→ 验证 → 演示(训练数据产出) -7. Phase 8+9 → 验证 → 演示(完整平台) -8. Phase 10 → 收尾 - -### 多人协作策略 - -Phase 2 完成后: -- 开发者 A:Phase 3(US1 认证)+ Phase 8(US7 用户管理) -- 开发者 B:Phase 4(US2 上传)+ Phase 5(US3+4 提取) -- 开发者 C:Phase 9(US8 视频+配置) - -Phase 5 完成后: -- 开发者 A/B 合力:Phase 6(US5 QA)→ Phase 7(US6 导出) - ---- - -## 说明 - -- `[P]` 任务 = 不同文件,无依赖,可并行 -- `[USn]` 标签将任务映射到具体用户故事,便于追踪 -- 每个用户故事应独立可完成和可测试 -- 每完成一个阶段后提交 git commit -- 在每个检查点停下来独立验证该用户故事 -- 避免:模糊任务、同文件并发冲突、破坏独立性的跨故事依赖