From b0e2b3c81ad76c4dd820360502d6412604f102b3 Mon Sep 17 00:00:00 2001 From: wh Date: Tue, 14 Apr 2026 20:00:37 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BB=91=E7=9B=92=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 576 ++++++++++++------ docs/swagger-blackbox-test-cases.md | 570 +++++++++++++++++ .../label/blackbox/AbstractBlackBoxTest.java | 413 +++++++++++++ .../blackbox/SwaggerLiveBlackBoxTest.java | 337 ++++++++++ 微服务开发规范文档.md | 422 ------------- 5 files changed, 1724 insertions(+), 594 deletions(-) create mode 100644 docs/swagger-blackbox-test-cases.md create mode 100644 src/test/java/com/label/blackbox/AbstractBlackBoxTest.java create mode 100644 src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java delete mode 100644 微服务开发规范文档.md diff --git a/README.md b/README.md index ad71891..d81a953 100644 --- a/README.md +++ b/README.md @@ -1,172 +1,404 @@ -# 数据同步微服务 - -## 项目简介 - -数据同步微服务用于从第三方接口同步人员、项目和报告数据到MySQL数据库。支持定时任务自动同步和手动触发同步。 - -## 功能特性 - -1. **数据同步** - - 从第三方接口同步人员数据 - - 从第三方接口同步项目(工程)数据 - - 从第三方接口同步报告数据 - - 支持字段大小写兼容 - -2. **同步方式** - - 定时任务:每晚12点自动执行全量同步 - - 手动触发:通过REST API手动触发同步 - -3. **同步策略** - - 先删除表中所有现有数据 - - 再全量同步接口返回的数据 - - 使用接口返回的ID作为主键 - -## 技术栈 - -- Java 21 -- Spring Boot 3.1.5 -- Spring Cloud 2022.0.4 -- MyBatis Plus 3.5.3.1 -- MySQL 8.2.0 -- Nacos(服务注册与发现) - -## 项目结构 - -``` -src/main/java/com/zhonghe/datasync/ -├── common/ # 公共类 -│ ├── exception/ # 异常处理 -│ ├── Result.java # 统一响应结果 -│ └── ResultCode.java # 响应码枚举 -├── config/ # 配置类 -├── controller/ # 控制器 -├── dto/ # 数据传输对象 -│ ├── request/ # 请求DTO -│ └── response/ # 响应DTO -├── entity/ # 实体类 -├── mapper/ # MyBatis映射器 -├── scheduled/ # 定时任务 -├── service/ # 业务服务 -└── util/ # 工具类 -``` - -## 数据库表结构 - -### employee(人员表) -- id: 主键(VARCHAR) -- name: 人员姓名 -- phone_number: 人员手机号 -- password: 密码(明文存储) -- created_at: 创建时间 -- updated_at: 更新时间 - -### project(项目表) -- id: 主键(BIGINT) -- project_name: 工程名称 -- created_at: 创建时间 -- updated_at: 更新时间 - -### report(报告表) -- id: 主键(BIGINT) -- order_number: 委托编号 -- sample_code: 样品编号 -- bg_sample_code: 报告编号 -- project_name: 工程名称 -- project_id: 工程主键 -- experiment_name: 试验人员 -- real_experiment_date: 试验时间 -- created_at: 创建时间 -- updated_at: 更新时间 - -## 配置说明 - -### 环境变量 - -| 变量名 | 说明 | 默认值 | -|--------|------|--------| -| MYSQL_HOST | MySQL主机地址 | localhost | -| MYSQL_PORT | MySQL端口 | 3306 | -| MYSQL_DATABASE | MySQL数据库名 | datasync | -| MYSQL_USERNAME | MySQL用户名 | root | -| MYSQL_PASSWORD | MySQL密码 | root | -| NACOS_SERVER | Nacos服务器地址 | localhost:8848 | -| NACOS_USERNAME | Nacos用户名 | nacos | -| NACOS_PASSWORD | Nacos密码 | nacos | -| THIRD_PARTY_AUTH_URL | 第三方鉴权接口地址 | http://limspro.91jiance.net/api/LoginAPI/GetAdminUserCode | -| THIRD_PARTY_EMPLOYEE_URL | 第三方人员接口地址 | http://121.40.18.211:20016/api/EmployeeAPI/GetZHEmployeeList | -| THIRD_PARTY_PROJECT_URL | 第三方项目接口地址 | http://121.40.18.211:20030/API/GetZHProjectList | -| THIRD_PARTY_REPORT_URL | 第三方报告接口地址 | http://121.40.18.211:20030/API/GetZHSampleAllList | -| THIRD_PARTY_USER_NAME | 第三方接口用户名 | 13120251031 | -| THIRD_PARTY_PASS_WORD | 第三方接口密码 | whfst@1901 | - -## API接口 - -### 1. 同步所有数据 -- **URL**: `/api/v1/sync/all` -- **方法**: POST -- **说明**: 同步人员、项目和报告数据 - -### 2. 同步人员数据 -- **URL**: `/api/v1/sync/employees` -- **方法**: POST -- **说明**: 仅同步人员数据 - -### 3. 同步项目数据 -- **URL**: `/api/v1/sync/projects` -- **方法**: POST -- **说明**: 仅同步项目数据 - -### 4. 同步报告数据 -- **URL**: `/api/v1/sync/reports` -- **方法**: POST -- **说明**: 仅同步报告数据 - -## 定时任务 - -定时任务配置在 `DataSyncScheduledTask` 类中,使用 cron 表达式: -- **执行时间**: 每晚12点(0 0 0 * * ?) -- **执行内容**: 同步所有数据(人员、项目、报告) - -## 部署说明 - -### 1. 数据库初始化 - -执行 `src/main/resources/db/schema.sql` 创建数据库表。 - -### 2. 构建项目 - -```bash -mvn clean package -``` - -### 3. Docker部署 - -```bash -docker build -t data-sync-service:1.0.0 . -docker run -d -p 8080:8080 \ - -e MYSQL_HOST=your-mysql-host \ - -e MYSQL_USERNAME=your-username \ - -e MYSQL_PASSWORD=your-password \ - data-sync-service:1.0.0 -``` - -## 注意事项 - -1. **字段大小写兼容**: 使用 `@JsonProperty` 注解处理接口返回字段的大小写差异 -2. **ID使用**: 使用接口返回的ID作为数据库主键,不自动生成 -3. **同步策略**: - - 人员数据:增量同步(不删除现有数据),新增用户密码为手机号后六位,老用户密码保持不变 - - 项目和报告数据:全量同步(先删除后插入) -4. **密码管理**: - - 新增用户:密码自动设置为手机号后六位 - - 更新用户:密码字段不会被修改,保持原有密码 -5. **事务管理**: 同步操作使用事务,失败时会回滚 -6. **日志记录**: 所有同步操作都会记录详细日志 - -## 开发规范 - -本项目遵循《微服务开发规范文档.md》中的开发规范,包括: -- 代码风格规范 -- 命名规范 -- 异常处理规范 -- 日志规范 +# label-backend + +## 项目简介 + +`label-backend` 是知识图谱智能标注平台的后端服务,负责资料上传、任务分发、提取标注、问答生成、训练样本导出、系统配置和视频预处理等核心流程。 + +系统采用 Spring Boot 3 + MyBatis-Plus + PostgreSQL + Redis 的技术组合,面向多租户标注场景设计,支持: + +- 公司级数据隔离 +- 基于 Redis Token 的认证鉴权 +- 提取与问答两阶段标注流程 +- 导出训练数据并对接微调任务 +- 视频预处理与异步回调 +- 审计日志与任务状态追踪 + +代码结构已按扁平标准目录整理,主包位于 `src/main/java/com/label`。 + +## 功能特性 + +- 认证鉴权 + - 使用 UUID Bearer Token + Redis 会话存储 + - 自定义 `@RequireAuth`、`@RequireRole` + - 角色分级:`ADMIN > REVIEWER > ANNOTATOR > UPLOADER` +- 多租户隔离 + - 基于 `CompanyContext` + `TenantLineInnerInterceptor` + - 对租户表自动追加 `company_id` 条件 + - 特殊表通过显式 `companyId` 参数校验 +- 公司与用户管理 + - 公司 CRUD + - 公司内用户创建、状态变更、角色变更 + - 角色变更和禁用后即时刷新或失效 Redis Token +- 资料管理 + - 支持文本、图片、视频三类原始资料 + - 上传到 RustFS,数据库保存元数据 + - 支持按角色查看、查询详情、删除 +- 任务管理 + - 任务池、我的任务、待审批队列、管理员全量视图 + - Redis 分布式锁 + 数据库原子更新保证任务领取并发安全 +- 提取标注 + - AI 预标注 + - 标注结果整体覆盖更新 + - 提交、审批通过、驳回 +- 问答生成 + - 基于提取审批通过事件生成候选问答对 + - 支持编辑、提交、审批、驳回 +- 训练数据导出 + - 查询已审批样本 + - 创建导出批次 + - 触发微调任务并查询状态 +- 系统配置 + - 支持公司专属配置覆盖全局默认配置 + - 配置项存储于 `sys_config` +- 视频处理 + - 支持触发视频预处理任务 + - 支持异步回调、失败重试、管理员重置 + +## 技术栈 + +- Java 21 +- Spring Boot 3.1.5 +- Spring MVC +- MyBatis-Plus 3.5.3.1 +- PostgreSQL +- Redis +- Spring AOP +- springdoc-openapi +- Testcontainers +- RustFS / S3 兼容对象存储 + +## 项目结构 + +项目根目录结构: + +```text +label_backend/ +├── assembly/ # 分发包描述与占位目录 +├── docs/ # 设计、计划、规范文档 +├── scripts/ # 启动脚本 +├── src/ +│ ├── main/ +│ │ ├── java/com/label/ # 主代码 +│ │ └── resources/ +│ │ ├── application.yml +│ │ ├── logback.xml +│ │ └── sql/ # 初始化 SQL,不打入构建产物 +│ └── test/ +│ ├── java/ # 单元测试与集成测试 +│ └── resources/db/init.sql # Testcontainers 测试初始化 SQL +├── docker-compose.yml +├── Dockerfile +├── pom.xml +└── README.md +``` + +Java 包结构: + +```text +com.label +├── annotation # 自定义注解,如 RequireAuth / RequireRole / OperationLog +├── aspect # AOP 审计切面 +├── common # 通用能力:auth、context、exception、result、storage、ai、statemachine +├── config # Spring 配置、MyBatis-Plus 配置、认证拦截器注册 +├── controller # 所有 REST 接口 +├── dto # DTO +├── entity # 实体 +├── event # 领域事件 +├── interceptor # 认证拦截器 +├── listener # 事件监听器 +├── mapper # MyBatis Mapper +├── service # 业务服务 +└── util # 工具类 +``` + +## 数据库表结构 + +初始化脚本位于 [init.sql](d:/workspace/label/label_backend/src/main/resources/sql/init.sql),包含 11 张核心表: + +- `sys_company` + - 租户公司表 +- `sys_user` + - 公司用户表,包含角色与状态 +- `source_data` + - 原始资料元数据,支持 `TEXT` / `IMAGE` / `VIDEO` +- `annotation_task` + - 标注任务表,支持 `EXTRACTION` / `QA_GENERATION` +- `annotation_result` + - 提取阶段 JSON 结果 +- `training_dataset` + - 训练样本数据,存储 GLM 格式 JSON +- `export_batch` + - 导出批次与微调任务状态 +- `sys_config` + - 全局与公司级配置 +- `sys_operation_log` + - 审计日志,只追加不更新 +- `annotation_task_history` + - 任务状态变更历史 +- `video_process_job` + - 视频预处理任务与回调状态 + +当前主要状态机: + +- `source_data.status` + - `PENDING` / `PREPROCESSING` / `EXTRACTING` / `QA_REVIEW` / `APPROVED` +- `annotation_task.status` + - `UNCLAIMED` / `IN_PROGRESS` / `SUBMITTED` / `APPROVED` / `REJECTED` +- `training_dataset.status` + - `PENDING_REVIEW` / `APPROVED` / `REJECTED` +- `video_process_job.status` + - `PENDING` / `RETRYING` / `SUCCESS` / `FAILED` + +## 配置说明 + +主配置文件位于 [application.yml](d:/workspace/label/label_backend/src/main/resources/application.yml)。 + +### 环境变量 + +| 变量名 | 说明 | +|---|---| +| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC 地址 | +| `SPRING_DATASOURCE_USERNAME` | PostgreSQL 用户名 | +| `SPRING_DATASOURCE_PASSWORD` | PostgreSQL 密码 | +| `SPRING_DATA_REDIS_HOST` | Redis 主机 | +| `SPRING_DATA_REDIS_PORT` | Redis 端口 | +| `SPRING_DATA_REDIS_PASSWORD` | Redis 密码 | +| `RUSTFS_ENDPOINT` | RustFS / S3 兼容服务地址 | +| `RUSTFS_ACCESS_KEY` | RustFS Access Key | +| `RUSTFS_SECRET_KEY` | RustFS Secret Key | +| `AI_SERVICE_BASE_URL` | AI 服务地址 | +| `VIDEO_CALLBACK_SECRET` | 视频处理回调共享密钥 | + +### 关键配置项 + +- `auth.enabled` + - `true` 时启用真实 Token 鉴权 + - `false` 时使用 mock 身份,便于本地开发 +- `auth.mock-company-id` + - 开发模式下的模拟公司 ID +- `auth.mock-user-id` + - 开发模式下的模拟用户 ID +- `auth.mock-role` + - 开发模式下的模拟角色 +- `token.ttl-seconds` + - Token 有效期,默认 7200 秒 +- `springdoc.api-docs.path` + - OpenAPI 文档路径,默认 `/v3/api-docs` +- `springdoc.swagger-ui.path` + - Swagger UI 路径,默认 `/swagger-ui.html` + +## API接口 + +以下为当前主要接口分组。 + +### 1. 认证接口 + +- `POST /api/auth/login` + - 登录并返回 Bearer Token +- `POST /api/auth/logout` + - 登出并立即失效当前 Token +- `GET /api/auth/me` + - 获取当前登录用户信息 + +### 2. 公司管理 + +- `GET /api/companies` +- `POST /api/companies` +- `PUT /api/companies/{id}` +- `PUT /api/companies/{id}/status` +- `DELETE /api/companies/{id}` + +### 3. 用户管理 + +- `GET /api/users` +- `POST /api/users` +- `PUT /api/users/{id}` +- `PUT /api/users/{id}/status` +- `PUT /api/users/{id}/role` + +### 4. 资料管理 + +- `POST /api/source/upload` +- `GET /api/source/list` +- `GET /api/source/{id}` +- `DELETE /api/source/{id}` + +### 5. 任务管理 + +- `GET /api/tasks/pool` +- `GET /api/tasks/mine` +- `GET /api/tasks/pending-review` +- `GET /api/tasks` +- `POST /api/tasks` +- `GET /api/tasks/{id}` +- `POST /api/tasks/{id}/claim` +- `POST /api/tasks/{id}/unclaim` +- `POST /api/tasks/{id}/reclaim` +- `PUT /api/tasks/{id}/reassign` + +### 6. 提取标注 + +- `GET /api/extraction/{taskId}` +- `PUT /api/extraction/{taskId}` +- `POST /api/extraction/{taskId}/submit` +- `POST /api/extraction/{taskId}/approve` +- `POST /api/extraction/{taskId}/reject` + +### 7. 问答生成 + +- `GET /api/qa/{taskId}` +- `PUT /api/qa/{taskId}` +- `POST /api/qa/{taskId}/submit` +- `POST /api/qa/{taskId}/approve` +- `POST /api/qa/{taskId}/reject` + +### 8. 导出与微调 + +- `GET /api/training/samples` +- `POST /api/export/batch` +- `POST /api/export/{batchId}/finetune` +- `GET /api/export/{batchId}/status` +- `GET /api/export/list` + +### 9. 系统配置 + +- `GET /api/config` +- `PUT /api/config/{key}` + +### 10. 视频处理 + +- `POST /api/video/process` +- `GET /api/video/jobs/{jobId}` +- `POST /api/video/jobs/{jobId}/reset` +- `POST /api/video/callback` + +## 定时任务 + +当前项目中**没有启用 Spring `@Scheduled` 定时同步任务**。 + +现有异步能力主要通过以下方式完成: + +- 事务提交后事件监听 + - 提取审批通过后触发问答生成 +- 外部 AI 服务异步回调 + - 视频处理完成后回调 `/api/video/callback` +- Redis 分布式锁 + - 用于任务领取并发控制 + +如果后续需要周期性任务,建议单独引入明确的调度场景,不要复用当前业务链路中的事件机制。 + +## 部署说明 + +### 1. 数据库初始化 + +初始化 SQL 位于: + +- 开发/部署初始化脚本 + - [src/main/resources/sql/init.sql](d:/workspace/label/label_backend/src/main/resources/sql/init.sql) + +说明: + +- `src/main/resources/sql/init.sql` 会随源码保存,但**不会被打入 jar、target/classes 或分发包** +- `docker-compose.yml` 通过挂载该文件完成 PostgreSQL 初始化 + +### 2. 本地构建 + +```bash +mvn clean package -DskipTests +``` + +构建产物: + +- `target/libs/` + - 薄 jar 与运行时依赖 +- `target/label-backend-1.0.0-SNAPSHOT.zip` +- `target/label-backend-1.0.0-SNAPSHOT.tar.gz` + +### 3. 分发包结构 + +分发包由 [distribution.xml](d:/workspace/label/label_backend/assembly/distribution.xml) 组装,解压后结构如下: + +```text +label-backend-/ +├── bin/ +│ └── start.sh +├── etc/ +│ ├── application.yml +│ └── logback.xml +├── libs/ +│ ├── label-backend-.jar +│ └── *.jar +└── logs/ +``` + +### 4. 启动脚本 + +启动脚本位于 [start.sh](d:/workspace/label/label_backend/scripts/start.sh)。 + +行为说明: + +- 在 Docker 容器中检测到 `/.dockerenv` 时,前台 `exec java ...` +- 在宿主机环境中使用 `nohup` 后台启动 +- 日志默认写入 `logs/startup.log` + +### 5. Docker Compose 启动 + +```bash +docker compose up -d +``` + +当前 `docker-compose.yml` 会启动: + +- PostgreSQL +- Redis +- RustFS(当前使用 MinIO 作为 S3 兼容替代) +- backend +- ai-service 占位服务 +- frontend 占位服务 + +### 6. Docker 镜像构建 + +```bash +docker build -t label-backend:latest . +``` + +`Dockerfile` 使用多阶段构建,并从项目根目录的 `scripts/start.sh` 复制启动脚本。 + +## 注意事项 + +1. 开发模式下 `auth.enabled=false` + - 此时会使用 mock 用户身份,不适合生产环境 + - 生产部署前必须显式启用真实鉴权 +2. 多租户隔离仍依赖 `CompanyContext` + `TenantLineInnerInterceptor` + - 租户表查询默认依赖租户拦截器 + - 个别特殊场景通过显式 `companyId` 参数校验 +3. `sys_config`、`sys_company`、`video_process_job` 属于特殊表 + - 其中部分表被排除出自动租户注入,需在服务层显式控制 +4. SQL 已迁移到 `src/main/resources/sql` + - 仅作为源码级初始化文件保留 + - 不会打进构建产物 +5. 集成测试依赖 Testcontainers + - 运行完整集成测试需要本机可用 Docker 环境 +6. 认证实现已移除 Shiro + - 当前使用自定义拦截器、注解与 Redis Token +7. 用户上下文 ThreadLocal 已移除 + - 当前只保留 `CompanyContext` + - 用户主体通过请求属性中的 `TokenPrincipal` 传递 + +## 开发规范 + +项目实现以以下文档为准: + +- 总体设计 + - [2026-04-09-label-backend-design.md](d:/workspace/label/label_backend/docs/superpowers/specs/2026-04-09-label-backend-design.md) +- 目录扁平化设计 + - [2026-04-14-label-backend-directory-flattening-design.md](d:/workspace/label/label_backend/docs/superpowers/specs/2026-04-14-label-backend-directory-flattening-design.md) + +当前约束摘要: + +- 统一扁平目录结构,避免再次引入按业务域分层的旧目录 +- DTO 统一放在 `dto/`,不再拆分 `request/response` +- Service 统一放在 `service/`,不拆 `service/impl` +- 业务规则优先放在 Service,Controller 只负责 HTTP 协议层 +- 新增接口需同步补齐 Swagger 注解与测试 +- 目录、配置、打包方式变化后,README、设计文档和部署说明必须同步更新 diff --git a/docs/swagger-blackbox-test-cases.md b/docs/swagger-blackbox-test-cases.md new file mode 100644 index 0000000..bea4b93 --- /dev/null +++ b/docs/swagger-blackbox-test-cases.md @@ -0,0 +1,570 @@ +# 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/src/test/java/com/label/blackbox/AbstractBlackBoxTest.java b/src/test/java/com/label/blackbox/AbstractBlackBoxTest.java new file mode 100644 index 0000000..54cd53f --- /dev/null +++ b/src/test/java/com/label/blackbox/AbstractBlackBoxTest.java @@ -0,0 +1,413 @@ +package com.label.blackbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ByteArrayResource; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +abstract class AbstractBlackBoxTest { + + private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10); + private static final Properties APPLICATION = loadApplicationProperties(); + + protected final TestRestTemplate restTemplate = new TestRestTemplate(); + protected final ObjectMapper objectMapper = new ObjectMapper(); + + protected JdbcTemplate jdbcTemplate; + protected String baseUrl; + protected String callbackSecret; + + protected String runId; + protected Long companyId; + protected String companyCode; + protected String companyName; + + protected TestUser adminUser; + protected TestUser reviewerUser; + protected TestUser annotatorUser; + protected TestUser annotator2User; + protected TestUser uploaderUser; + + protected String adminToken; + protected String reviewerToken; + protected String annotatorToken; + protected String annotator2Token; + protected String uploaderToken; + + protected boolean roleAwareAuthEnabled; + + @BeforeEach + void setUpBlackBox(TestInfo testInfo) { + this.jdbcTemplate = new JdbcTemplate(createDataSource()); + this.baseUrl = resolveBaseUrl(); + this.callbackSecret = resolved("video.callback-secret", ""); + this.runId = buildRunId(testInfo); + + assertBackendReachable(); + createIsolatedCompanyAndUsers(); + issueTokens(); + detectRuntimeAuthMode(); + } + + @AfterEach + void cleanUpBlackBox() { + if (companyId == null) { + return; + } + + jdbcTemplate.update("DELETE FROM annotation_task_history WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM sys_operation_log WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM video_process_job WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM training_dataset WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM annotation_result WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM export_batch WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM annotation_task WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM source_data WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM sys_config WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM sys_user WHERE company_id = ?", companyId); + jdbcTemplate.update("DELETE FROM sys_company WHERE id = ?", companyId); + } + + protected void requireRoleAwareAuth() { + org.junit.jupiter.api.Assumptions.assumeTrue( + roleAwareAuthEnabled, + "当前运行中的 backend 未启用真实多角色认证,跳过依赖角色/租户隔离的黑盒用例"); + } + + protected ResponseEntity getRaw(String path) { + return restTemplate.getForEntity(url(path), String.class); + } + + protected ResponseEntity get(String path, String token) { + return exchange(path, HttpMethod.GET, null, token, MediaType.APPLICATION_JSON); + } + + protected ResponseEntity delete(String path, String token) { + return exchange(path, HttpMethod.DELETE, null, token, MediaType.APPLICATION_JSON); + } + + protected ResponseEntity postJson(String path, Object body, String token) { + return exchange(path, HttpMethod.POST, body, token, MediaType.APPLICATION_JSON); + } + + protected ResponseEntity putJson(String path, Object body, String token) { + return exchange(path, HttpMethod.PUT, body, token, MediaType.APPLICATION_JSON); + } + + protected ResponseEntity upload(String path, String token, String filename, String dataType, byte[] bytes) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + if (token != null && !token.isBlank()) { + headers.setBearerAuth(token); + } + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("dataType", dataType); + body.add("file", new ByteArrayResource(bytes) { + @Override + public String getFilename() { + return filename; + } + }); + + return restTemplate.exchange(url(path), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); + } + + protected ResponseEntity postVideoCallback(Map body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (callbackSecret != null && !callbackSecret.isBlank()) { + headers.set("X-Callback-Secret", callbackSecret); + } + return restTemplate.exchange(url("/api/video/callback"), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); + } + + protected String login(String targetCompanyCode, String username, String password) { + Map body = Map.of( + "companyCode", targetCompanyCode, + "username", username, + "password", password + ); + ResponseEntity response = postJson("/api/auth/login", body, null); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + Map data = (Map) Objects.requireNonNull(response.getBody()).get("data"); + return String.valueOf(data.get("token")); + } + + protected Long uploadTextSource(String token) { + ResponseEntity response = upload( + "/api/source/upload", + token, + "bb-" + runId + ".txt", + "TEXT", + ("hello-blackbox-" + runId).getBytes(StandardCharsets.UTF_8)); + assertSuccess(response, HttpStatus.CREATED); + return dataId(response); + } + + protected Long uploadVideoSource(String token) { + ResponseEntity response = upload( + "/api/source/upload", + token, + "bb-" + runId + ".mp4", + "VIDEO", + ("fake-video-" + runId).getBytes(StandardCharsets.UTF_8)); + assertSuccess(response, HttpStatus.CREATED); + return dataId(response); + } + + protected Long createTask(Long sourceId, String taskType) { + ResponseEntity response = postJson("/api/tasks", Map.of( + "sourceId", sourceId, + "taskType", taskType + ), adminToken); + assertSuccess(response, HttpStatus.OK); + return dataId(response); + } + + protected Long latestTaskId(Long sourceId, String taskType) { + return jdbcTemplate.queryForObject( + "SELECT id FROM annotation_task WHERE company_id = ? AND source_id = ? AND task_type = ? " + + "ORDER BY id DESC LIMIT 1", + Long.class, + companyId, sourceId, taskType); + } + + protected Long latestApprovedDatasetId(Long sourceId) { + return jdbcTemplate.queryForObject( + "SELECT id FROM training_dataset WHERE company_id = ? AND source_id = ? AND status = 'APPROVED' " + + "ORDER BY id DESC LIMIT 1", + Long.class, + companyId, sourceId); + } + + protected Long insertFailedVideoJob(Long sourceId) { + return jdbcTemplate.queryForObject( + "INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " + + "VALUES (?, ?, 'FRAME_EXTRACT', 'FAILED', '{}'::jsonb, 3, 3) RETURNING id", + Long.class, + companyId, sourceId); + } + + protected Long insertPendingVideoJob(Long sourceId) { + return jdbcTemplate.queryForObject( + "INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " + + "VALUES (?, ?, 'FRAME_EXTRACT', 'PENDING', '{}'::jsonb, 0, 3) RETURNING id", + Long.class, + companyId, sourceId); + } + + protected void assertSuccess(ResponseEntity response, HttpStatus expectedStatus) { + assertThat(response.getStatusCode()).isEqualTo(expectedStatus); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get("code")).isEqualTo("SUCCESS"); + } + + protected Long dataId(ResponseEntity response) { + @SuppressWarnings("unchecked") + Map data = (Map) Objects.requireNonNull(response.getBody()).get("data"); + return ((Number) data.get("id")).longValue(); + } + + protected boolean responseContainsId(ResponseEntity response, Long id) { + @SuppressWarnings("unchecked") + Map data = (Map) Objects.requireNonNull(response.getBody()).get("data"); + @SuppressWarnings("unchecked") + List> items = (List>) data.get("items"); + return items.stream().anyMatch(item -> id.equals(((Number) item.get("id")).longValue())); + } + + protected Long claimedByOfTask(Long taskId) { + return jdbcTemplate.queryForObject( + "SELECT claimed_by FROM annotation_task WHERE id = ?", + Long.class, + taskId); + } + + protected String url(String path) { + return baseUrl + path; + } + + private String resolveBaseUrl() { + String override = System.getProperty("blackbox.base-url"); + if (override == null || override.isBlank()) { + override = System.getenv("BLACKBOX_BASE_URL"); + } + if (override != null && !override.isBlank()) { + return override.endsWith("/") ? override.substring(0, override.length() - 1) : override; + } + return "http://127.0.0.1:" + resolved("server.port", "8080"); + } + + private ResponseEntity exchange(String path, + HttpMethod method, + Object body, + String token, + MediaType contentType) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(contentType); + if (token != null && !token.isBlank()) { + headers.setBearerAuth(token); + } + return restTemplate.exchange(url(path), method, new HttpEntity<>(body, headers), Map.class); + } + + private void assertBackendReachable() { + try { + ResponseEntity response = getRaw("/v3/api-docs"); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } catch (Exception ex) { + fail("无法连接运行中的 backend,请确认服务已按 application.yml 配置启动: " + ex.getMessage()); + } + } + + private void createIsolatedCompanyAndUsers() { + this.companyCode = ("BB" + runId).toUpperCase(Locale.ROOT); + this.companyName = "黑盒测试-" + runId; + this.companyId = jdbcTemplate.queryForObject( + "INSERT INTO sys_company (company_name, company_code, status) VALUES (?, ?, 'ACTIVE') RETURNING id", + Long.class, + companyName, companyCode); + + this.adminUser = insertUser("admin", "ADMIN"); + this.reviewerUser = insertUser("reviewer", "REVIEWER"); + this.annotatorUser = insertUser("annotator", "ANNOTATOR"); + this.annotator2User = insertUser("annotator2", "ANNOTATOR"); + this.uploaderUser = insertUser("uploader", "UPLOADER"); + } + + private TestUser insertUser(String namePrefix, String role) { + String username = (namePrefix + "_" + runId).toLowerCase(Locale.ROOT); + String password = "Bb@" + runId; + Long userId = jdbcTemplate.queryForObject( + "INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) " + + "VALUES (?, ?, ?, ?, ?, 'ACTIVE') RETURNING id", + Long.class, + companyId, + username, + PASSWORD_ENCODER.encode(password), + "黑盒-" + namePrefix, + role); + return new TestUser(userId, username, password, role); + } + + private void issueTokens() { + this.adminToken = login(companyCode, adminUser.username(), adminUser.password()); + this.reviewerToken = login(companyCode, reviewerUser.username(), reviewerUser.password()); + this.annotatorToken = login(companyCode, annotatorUser.username(), annotatorUser.password()); + this.annotator2Token = login(companyCode, annotator2User.username(), annotator2User.password()); + this.uploaderToken = login(companyCode, uploaderUser.username(), uploaderUser.password()); + } + + private void detectRuntimeAuthMode() { + try { + ResponseEntity adminMe = get("/api/auth/me", adminToken); + ResponseEntity reviewerMe = get("/api/auth/me", reviewerToken); + if (!adminMe.getStatusCode().is2xxSuccessful() || !reviewerMe.getStatusCode().is2xxSuccessful()) { + this.roleAwareAuthEnabled = false; + return; + } + + @SuppressWarnings("unchecked") + Map adminData = (Map) Objects.requireNonNull(adminMe.getBody()).get("data"); + @SuppressWarnings("unchecked") + Map reviewerData = (Map) Objects.requireNonNull(reviewerMe.getBody()).get("data"); + + this.roleAwareAuthEnabled = + adminUser.username().equals(adminData.get("username")) + && reviewerUser.username().equals(reviewerData.get("username")) + && "ADMIN".equals(adminData.get("role")) + && "REVIEWER".equals(reviewerData.get("role")) + && companyId.equals(((Number) adminData.get("companyId")).longValue()) + && companyId.equals(((Number) reviewerData.get("companyId")).longValue()); + } catch (Exception ex) { + this.roleAwareAuthEnabled = false; + } + } + + private DataSource createDataSource() { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName(resolved("spring.datasource.driver-class-name", "org.postgresql.Driver")); + dataSource.setUrl(resolved("spring.datasource.url", "")); + dataSource.setUsername(resolved("spring.datasource.username", "")); + dataSource.setPassword(resolved("spring.datasource.password", "")); + return dataSource; + } + + private String resolved(String key, String fallback) { + String raw = APPLICATION.getProperty(key); + if (raw == null || raw.isBlank()) { + return fallback; + } + return resolvePlaceholder(raw, fallback); + } + + private static Properties loadApplicationProperties() { + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application.yml")); + Properties properties = yaml.getObject(); + if (properties == null) { + throw new IllegalStateException("无法加载 application.yml"); + } + return properties; + } + + private static String resolvePlaceholder(String raw, String fallback) { + if (!raw.startsWith("${") || !raw.endsWith("}")) { + return raw; + } + + String inner = raw.substring(2, raw.length() - 1); + int splitIndex = inner.indexOf(':'); + if (splitIndex < 0) { + String envValue = System.getenv(inner); + return envValue != null ? envValue : fallback; + } + + String envKey = inner.substring(0, splitIndex); + String defaultValue = inner.substring(splitIndex + 1); + String envValue = System.getenv(envKey); + return envValue != null ? envValue : defaultValue; + } + + private static String buildRunId(TestInfo testInfo) { + String methodName = testInfo.getTestMethod().map(method -> method.getName()).orElse("case"); + String normalized = methodName.replaceAll("[^a-zA-Z0-9]", "").toLowerCase(Locale.ROOT); + if (normalized.length() > 10) { + normalized = normalized.substring(0, 10); + } + return normalized + Long.toHexString(Instant.now().toEpochMilli()).substring(5) + UUID.randomUUID().toString().substring(0, 4); + } + + protected record TestUser(Long id, String username, String password, String role) { + } +} diff --git a/src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java b/src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java new file mode 100644 index 0000000..6abbf69 --- /dev/null +++ b/src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java @@ -0,0 +1,337 @@ +package com.label.blackbox; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class SwaggerLiveBlackBoxTest extends AbstractBlackBoxTest { + + @Test + @DisplayName("公共接口与认证接口在真实运行环境下可访问") + void publicAndAuthEndpoints_shouldWork() { + ResponseEntity openApi = getRaw("/v3/api-docs"); + assertThat(openApi.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(openApi.getBody()).contains("/api/auth/login"); + + ResponseEntity swaggerUi = getRaw("/swagger-ui.html"); + assertThat(swaggerUi.getStatusCode().is2xxSuccessful() || swaggerUi.getStatusCode().is3xxRedirection()).isTrue(); + + ResponseEntity login = postJson("/api/auth/login", Map.of( + "companyCode", companyCode, + "username", adminUser.username(), + "password", adminUser.password() + ), null); + assertSuccess(login, HttpStatus.OK); + + if (!roleAwareAuthEnabled) { + return; + } + + ResponseEntity me = get("/api/auth/me", adminToken); + assertSuccess(me, HttpStatus.OK); + @SuppressWarnings("unchecked") + Map meData = (Map) me.getBody().get("data"); + assertThat(meData.get("username")).isEqualTo(adminUser.username()); + assertThat(meData.get("role")).isEqualTo("ADMIN"); + + ResponseEntity logout = postJson("/api/auth/logout", null, adminToken); + assertSuccess(logout, HttpStatus.OK); + + ResponseEntity meAfterLogout = get("/api/auth/me", adminToken); + assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("公司管理与用户管理接口在真实运行环境下可覆盖") + void companyAndUserEndpoints_shouldWork() { + requireRoleAwareAuth(); + + ResponseEntity companyList = get("/api/companies?page=1&pageSize=20", adminToken); + assertSuccess(companyList, HttpStatus.OK); + + String extraCompanyCode = ("EXT" + runId).toUpperCase(); + String extraCompanyName = "扩展公司-" + runId; + ResponseEntity createCompany = postJson("/api/companies", Map.of( + "companyName", extraCompanyName, + "companyCode", extraCompanyCode + ), adminToken); + assertSuccess(createCompany, HttpStatus.CREATED); + Long extraCompanyId = dataId(createCompany); + + ResponseEntity updateCompany = putJson("/api/companies/" + extraCompanyId, Map.of( + "companyName", extraCompanyName + "-改", + "companyCode", extraCompanyCode + ), adminToken); + assertSuccess(updateCompany, HttpStatus.OK); + + ResponseEntity companyStatus = putJson("/api/companies/" + extraCompanyId + "/status", + Map.of("status", "DISABLED"), adminToken); + assertSuccess(companyStatus, HttpStatus.OK); + + ResponseEntity deleteCompany = delete("/api/companies/" + extraCompanyId, adminToken); + assertSuccess(deleteCompany, HttpStatus.OK); + + ResponseEntity userList = get("/api/users?page=1&pageSize=20", adminToken); + assertSuccess(userList, HttpStatus.OK); + + String username = "bb_user_" + UUID.randomUUID().toString().substring(0, 8); + String password = "BbUser@123"; + ResponseEntity createUser = postJson("/api/users", Map.of( + "username", username, + "password", password, + "realName", "黑盒用户", + "role", "ANNOTATOR" + ), adminToken); + assertSuccess(createUser, HttpStatus.OK); + Long userId = dataId(createUser); + + ResponseEntity updateUser = putJson("/api/users/" + userId, Map.of( + "realName", "黑盒用户-改", + "password", "BbUser@456" + ), adminToken); + assertSuccess(updateUser, HttpStatus.OK); + + String userToken = login(companyCode, username, "BbUser@456"); + + ResponseEntity beforeRoleChange = get("/api/tasks/pending-review", userToken); + assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + + ResponseEntity updateRole = putJson("/api/users/" + userId + "/role", + Map.of("role", "REVIEWER"), adminToken); + assertSuccess(updateRole, HttpStatus.OK); + + ResponseEntity afterRoleChange = get("/api/tasks/pending-review", userToken); + assertThat(afterRoleChange.getStatusCode()).isEqualTo(HttpStatus.OK); + + ResponseEntity updateStatus = putJson("/api/users/" + userId + "/status", + Map.of("status", "DISABLED"), adminToken); + assertSuccess(updateStatus, HttpStatus.OK); + + ResponseEntity meAfterDisable = get("/api/auth/me", userToken); + assertThat(meAfterDisable.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("资料与任务管理接口在真实运行环境下可覆盖") + void sourceAndTaskEndpoints_shouldWork() { + requireRoleAwareAuth(); + + Long disposableSourceId = uploadTextSource(uploaderToken); + ResponseEntity deleteDisposable = delete("/api/source/" + disposableSourceId, adminToken); + assertSuccess(deleteDisposable, HttpStatus.OK); + + Long sourceId = uploadTextSource(uploaderToken); + + ResponseEntity uploaderList = get("/api/source/list?page=1&pageSize=20", uploaderToken); + assertSuccess(uploaderList, HttpStatus.OK); + assertThat(responseContainsId(uploaderList, sourceId)).isTrue(); + + ResponseEntity adminList = get("/api/source/list?page=1&pageSize=20", adminToken); + assertSuccess(adminList, HttpStatus.OK); + assertThat(responseContainsId(adminList, sourceId)).isTrue(); + + ResponseEntity sourceDetail = get("/api/source/" + sourceId, adminToken); + assertSuccess(sourceDetail, HttpStatus.OK); + + Long taskId = createTask(sourceId, "EXTRACTION"); + + ResponseEntity pool = get("/api/tasks/pool?page=1&pageSize=20", annotatorToken); + assertSuccess(pool, HttpStatus.OK); + assertThat(responseContainsId(pool, taskId)).isTrue(); + + ResponseEntity allTasks = get("/api/tasks?page=1&pageSize=20&taskType=EXTRACTION", adminToken); + assertSuccess(allTasks, HttpStatus.OK); + assertThat(responseContainsId(allTasks, taskId)).isTrue(); + + ResponseEntity taskDetail = get("/api/tasks/" + taskId, annotatorToken); + assertSuccess(taskDetail, HttpStatus.OK); + + ResponseEntity claim = postJson("/api/tasks/" + taskId + "/claim", null, annotatorToken); + assertSuccess(claim, HttpStatus.OK); + + ResponseEntity mine = get("/api/tasks/mine?page=1&pageSize=20", annotatorToken); + assertSuccess(mine, HttpStatus.OK); + assertThat(responseContainsId(mine, taskId)).isTrue(); + + ResponseEntity unclaim = postJson("/api/tasks/" + taskId + "/unclaim", null, annotatorToken); + assertSuccess(unclaim, HttpStatus.OK); + + Long sourceId2 = uploadTextSource(uploaderToken); + Long taskId2 = createTask(sourceId2, "EXTRACTION"); + ResponseEntity claimTask2 = postJson("/api/tasks/" + taskId2 + "/claim", null, annotatorToken); + assertSuccess(claimTask2, HttpStatus.OK); + + ResponseEntity reassign = putJson("/api/tasks/" + taskId2 + "/reassign", + Map.of("userId", annotator2User.id()), adminToken); + assertSuccess(reassign, HttpStatus.OK); + assertThat(claimedByOfTask(taskId2)).isEqualTo(annotator2User.id()); + } + + @Test + @DisplayName("提取、问答、配置与导出接口在真实运行环境下可覆盖") + void extractionQaConfigAndExportEndpoints_shouldWork() { + requireRoleAwareAuth(); + + ResponseEntity listConfig = get("/api/config", adminToken); + assertSuccess(listConfig, HttpStatus.OK); + + ResponseEntity updateConfig = putJson("/api/config/model_default", + Map.of("value", "glm-4-blackbox-" + runId, "description", "黑盒测试默认模型"), + adminToken); + assertSuccess(updateConfig, HttpStatus.OK); + + Long sourceId = uploadTextSource(uploaderToken); + Long extractionTaskId = createTask(sourceId, "EXTRACTION"); + + assertSuccess(postJson("/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK); + + ResponseEntity extractionGet = get("/api/extraction/" + extractionTaskId, annotatorToken); + assertSuccess(extractionGet, HttpStatus.OK); + + ResponseEntity extractionPut = putJson("/api/extraction/" + extractionTaskId, + "{\"items\":[{\"label\":\"entity\",\"text\":\"北京\"}]}", + annotatorToken); + assertSuccess(extractionPut, HttpStatus.OK); + + ResponseEntity extractionSubmit = postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken); + assertSuccess(extractionSubmit, HttpStatus.OK); + + ResponseEntity pendingExtraction = get("/api/tasks/pending-review?page=1&pageSize=20&taskType=EXTRACTION", reviewerToken); + assertSuccess(pendingExtraction, HttpStatus.OK); + assertThat(responseContainsId(pendingExtraction, extractionTaskId)).isTrue(); + + ResponseEntity extractionReject = postJson("/api/extraction/" + extractionTaskId + "/reject", + Map.of("reason", "黑盒驳回一次"), reviewerToken); + assertSuccess(extractionReject, HttpStatus.OK); + + ResponseEntity reclaim = postJson("/api/tasks/" + extractionTaskId + "/reclaim", null, annotatorToken); + assertSuccess(reclaim, HttpStatus.OK); + + assertSuccess(putJson("/api/extraction/" + extractionTaskId, + "{\"items\":[{\"label\":\"entity\",\"text\":\"上海\"}]}", + annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK); + + ResponseEntity extractionApprove = postJson("/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken); + assertSuccess(extractionApprove, HttpStatus.OK); + + Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION"); + assertThat(qaTaskId).isNotNull(); + + assertSuccess(postJson("/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK); + + ResponseEntity qaGet = get("/api/qa/" + qaTaskId, annotatorToken); + assertSuccess(qaGet, HttpStatus.OK); + + ResponseEntity qaPut = putJson("/api/qa/" + qaTaskId, + Map.of("items", List.of(Map.of("question", "北京在哪里", "answer", "中国"))), + annotatorToken); + assertSuccess(qaPut, HttpStatus.OK); + + ResponseEntity qaSubmit = postJson("/api/qa/" + qaTaskId + "/submit", null, annotatorToken); + assertSuccess(qaSubmit, HttpStatus.OK); + + ResponseEntity pendingQa = get("/api/tasks/pending-review?page=1&pageSize=20&taskType=QA_GENERATION", reviewerToken); + assertSuccess(pendingQa, HttpStatus.OK); + assertThat(responseContainsId(pendingQa, qaTaskId)).isTrue(); + + ResponseEntity qaApprove = postJson("/api/qa/" + qaTaskId + "/approve", null, reviewerToken); + assertSuccess(qaApprove, HttpStatus.OK); + + ResponseEntity samples = get("/api/training/samples?page=1&pageSize=20&sampleType=TEXT", adminToken); + assertSuccess(samples, HttpStatus.OK); + + Long datasetId = latestApprovedDatasetId(sourceId); + ResponseEntity createBatch = postJson("/api/export/batch", + Map.of("sampleIds", List.of(datasetId)), adminToken); + assertSuccess(createBatch, HttpStatus.CREATED); + Long batchId = dataId(createBatch); + + ResponseEntity exportList = get("/api/export/list?page=1&pageSize=20", adminToken); + assertSuccess(exportList, HttpStatus.OK); + assertThat(responseContainsId(exportList, batchId)).isTrue(); + + ResponseEntity exportStatus = get("/api/export/" + batchId + "/status", adminToken); + assertSuccess(exportStatus, HttpStatus.OK); + + // 第二条链路覆盖 QA reject + Long sourceId2 = uploadTextSource(uploaderToken); + Long extractionTaskId2 = createTask(sourceId2, "EXTRACTION"); + assertSuccess(postJson("/api/tasks/" + extractionTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/extraction/" + extractionTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/extraction/" + extractionTaskId2 + "/approve", null, reviewerToken), HttpStatus.OK); + + Long qaTaskId2 = latestTaskId(sourceId2, "QA_GENERATION"); + assertSuccess(postJson("/api/tasks/" + qaTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/qa/" + qaTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK); + + ResponseEntity qaReject = postJson("/api/qa/" + qaTaskId2 + "/reject", + Map.of("reason", "黑盒问答驳回"), reviewerToken); + assertSuccess(qaReject, HttpStatus.OK); + } + + @Test + @DisplayName("视频处理与微调接口在显式开启重链路模式时可覆盖") + void videoAndFinetuneEndpoints_shouldWorkWhenHeavyModeEnabled() { + requireRoleAwareAuth(); + assumeTrue(Boolean.getBoolean("blackbox.heavy.enabled"), + "未开启 -Dblackbox.heavy.enabled=true,跳过视频处理与微调重链路黑盒用例"); + + Long sourceId = uploadTextSource(uploaderToken); + Long extractionTaskId = createTask(sourceId, "EXTRACTION"); + assertSuccess(postJson("/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken), HttpStatus.OK); + + Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION"); + assertSuccess(postJson("/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/qa/" + qaTaskId + "/submit", null, annotatorToken), HttpStatus.OK); + assertSuccess(postJson("/api/qa/" + qaTaskId + "/approve", null, reviewerToken), HttpStatus.OK); + + Long datasetId = latestApprovedDatasetId(sourceId); + ResponseEntity createBatch = postJson("/api/export/batch", + Map.of("sampleIds", List.of(datasetId)), adminToken); + assertSuccess(createBatch, HttpStatus.CREATED); + Long batchId = dataId(createBatch); + + ResponseEntity finetune = postJson("/api/export/" + batchId + "/finetune", null, adminToken); + assertSuccess(finetune, HttpStatus.OK); + + Long videoSourceId = uploadVideoSource(uploaderToken); + ResponseEntity createVideoJob = postJson("/api/video/process", + Map.of("sourceId", videoSourceId, "jobType", "FRAME_EXTRACT", "params", "{\"frameInterval\":30}"), + adminToken); + assertSuccess(createVideoJob, HttpStatus.OK); + Long jobId = dataId(createVideoJob); + + ResponseEntity getVideoJob = get("/api/video/jobs/" + jobId, adminToken); + assertSuccess(getVideoJob, HttpStatus.OK); + + Long failedJobId = insertFailedVideoJob(videoSourceId); + ResponseEntity resetJob = postJson("/api/video/jobs/" + failedJobId + "/reset", null, adminToken); + assertSuccess(resetJob, HttpStatus.OK); + + Long callbackJobId = insertPendingVideoJob(videoSourceId); + ResponseEntity callbackSuccess1 = postVideoCallback(Map.of( + "jobId", callbackJobId, + "status", "SUCCESS", + "outputPath", "processed/" + runId + "/frames.zip" + )); + assertSuccess(callbackSuccess1, HttpStatus.OK); + + ResponseEntity callbackSuccess2 = postVideoCallback(Map.of( + "jobId", callbackJobId, + "status", "SUCCESS", + "outputPath", "processed/" + runId + "/frames.zip" + )); + assertSuccess(callbackSuccess2, HttpStatus.OK); + } +} diff --git a/微服务开发规范文档.md b/微服务开发规范文档.md deleted file mode 100644 index 7a318dd..0000000 --- a/微服务开发规范文档.md +++ /dev/null @@ -1,422 +0,0 @@ -# 微服务开发规范文档 - -## 项目概述 -本文档记录了当前微服务项目的技术栈、依赖版本、代码风格和项目结构,供新微服务开发时参考。 - -## 技术栈版本 - -### 核心框架版本 -- **Java版本**: 21 -- **Spring Boot**: 3.1.5 -- **Spring Cloud**: 2022.0.4 -- **Spring Cloud Alibaba**: 2022.0.0.0 -- **Maven**: 3.x - -### 主要依赖版本 -```xml - - - org.springframework.boot - spring-boot-starter-parent - 3.1.5 - - - -3.5.3.1 -8.2.0 - - -3.1.1 - - -2.0.23 - - -2.3.0 - - -0.2.16 - - -0.3.10 -``` - -## 项目结构规范 - -### 标准目录结构 -``` -src/main/java/com/example/{service-name}/ -├── annotation/ # 自定义注解 -├── aspect/ # AOP切面 -├── {ServiceName}Application.java # 启动类 -├── common/ # 公共类 -│ ├── exception/ # 异常处理 -│ ├── Result.java # 统一响应结果 -│ └── ResultCode.java # 响应码枚举 -├── config/ # 配置类 -├── constant/ # 常量类 -├── controller/ # 控制器 -├── dto/ # 数据传输对象 -│ ├── request/ # 请求DTO -│ ├── response/ # 响应DTO -│ └── common/ # 公共DTO -├── entity/ # 实体类 -├── feign/ # Feign客户端 -├── mapper/ # MyBatis映射器 -├── scheduled/ # 定时任务 -├── service/ # 业务服务 -├── typehandler/ # 类型处理器 -└── util/ # 工具类 -``` - -### 资源文件结构 -``` -src/main/resources/ -├── application.yml # 主配置文件 -├── application-pro.yml # 生产环境配置 -└── mapper/ # MyBatis XML映射文件 - └── *.xml -``` - -## 代码风格规范 - -### 1. 启动类规范 -```java -@SpringBootApplication -@EnableDiscoveryClient -@EnableFeignClients("com.example.{service-name}.feign") -@EnableScheduling -@MapperScan("com.example.{service-name}.mapper") -public class {ServiceName}Application { - public static void main(String[] args) { - SpringApplication.run({ServiceName}Application.class, args); - } -} -``` - -### 2. 统一响应结果类 -```java -@Data -public class Result { - private Integer code; - private String message; - private T data; - - public Result() {} - - public Result(Integer code, String message, T data) { - this.code = code; - this.message = message; - this.data = data; - } - - public static Result success() { - return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); - } - - public static Result success(T data) { - return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); - } - - public static Result error(String message) { - return new Result<>(ResultCode.ERROR.getCode(), message, null); - } - - public static Result error(ResultCode resultCode) { - return new Result<>(resultCode.getCode(), resultCode.getMessage(), null); - } -} -``` - -### 3. 实体类规范 -```java -@Data -@TableName("table_name") -public class EntityName { - @TableId(type = IdType.AUTO) - private Long id; - - private String fieldName; - - @TableField("is_deleted") - private Integer isDeleted; - - @TableField(exist = false) - private Boolean customField; // 非数据库字段 - - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} -``` - -### 4. 控制器规范 -```java -@Tag(name = "模块名称") -@Slf4j -@RestController -@RequestMapping("/api/v1/module") -@RequiredArgsConstructor -public class ModuleController { - - private final ModuleService moduleService; - - @Operation(summary = "接口描述") - @GetMapping("/endpoint") - public Result methodName() { - // 业务逻辑 - return Result.success(data); - } -} -``` - -### 5. 服务接口规范 -```java -@Service -public interface ModuleService extends IService { - // 自定义业务方法 -} -``` - -### 6. DTO规范 -```java -@Data -public class ModuleRequest { - /** - * 字段描述 - */ - private String fieldName; -} -``` - -## 配置文件规范 - -### 1. 主配置文件 (application.yml) -```yaml -server: - port: 8080 - -spring: - application: - name: {service-name} - profiles: - active: test - data: - redis: - username: default - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: 0 - timeout: 10000ms - lettuce: - pool: - max-active: 8 - max-idle: 8 - min-idle: 0 - max-wait: -1ms - session: - store-type: redis - timeout: 30m - mvc: - pathmatch: - matching-strategy: ant_path_matcher - cloud: - nacos: - discovery: - server-addr: ${NACOS_SERVER:localhost:8848} - namespace: ${spring.profiles.active} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} - loadbalancer: - ribbon: - enabled: false - -# MongoDB配置 (新微服务使用) -spring: - data: - mongodb: - uri: ${MONGODB_URI:mongodb://localhost:27017/database_name} - database: ${MONGODB_DATABASE:database_name} - -# 日志配置 -logging: - level: - org.springframework.security: DEBUG - com.example.{service-name}: DEBUG - -# Feign配置 -feign: - client: - config: - default: - connectTimeout: 5000 - readTimeout: 10000 - loggerLevel: basic - compression: - request: - enabled: true - response: - enabled: true -``` - -### 2. 生产环境配置 (application-pro.yml) -```yaml -# 生产环境特定配置 -logging: - level: - com.example.{service-name}: INFO - -feign: - client: - config: - default: - loggerLevel: basic -``` - -## 新微服务MongoDB配置 - -### 1. 添加MongoDB依赖 -```xml - - - org.springframework.boot - spring-boot-starter-data-mongodb - -``` - -### 2. MongoDB配置类 -```java -@Configuration -@EnableMongoRepositories(basePackages = "com.example.{service-name}.repository") -public class MongoConfig { - - @Bean - public MongoTemplate mongoTemplate(MongoDatabaseFactory mongoDatabaseFactory) { - return new MongoTemplate(mongoDatabaseFactory); - } -} -``` - -### 3. MongoDB实体类规范 -```java -@Document(collection = "collection_name") -@Data -public class MongoEntity { - @Id - private String id; - - @Field("field_name") - private String fieldName; - - @CreatedDate - private LocalDateTime createdAt; - - @LastModifiedDate - private LocalDateTime updatedAt; -} -``` - -### 4. MongoDB Repository规范 -```java -@Repository -public interface MongoEntityRepository extends MongoRepository { - // 自定义查询方法 - List findByFieldName(String fieldName); -} -``` - -## 开发规范 - -### 1. 包命名规范 -- 基础包名: `com.example.{service-name}` -- 服务名使用小写字母和连字符,如: `user-service`, `order-service` - -### 2. 类命名规范 -- 实体类: 使用名词,如 `User`, `Order` -- 控制器: 以 `Controller` 结尾,如 `UserController` -- 服务类: 以 `Service` 结尾,如 `UserService` -- DTO类: 以 `Request`/`Response` 结尾,如 `UserRequest`, `UserResponse` -- 常量类: 以 `Constants` 结尾,如 `UserConstants` - -### 3. 方法命名规范 -- 查询方法: `get`, `find`, `list`, `query` -- 创建方法: `create`, `add`, `save` -- 更新方法: `update`, `modify` -- 删除方法: `delete`, `remove` - -### 4. 异常处理规范 -- 使用统一的异常处理机制 -- 自定义业务异常继承 `RuntimeException` -- 使用 `@ControllerAdvice` 进行全局异常处理 - -### 5. 日志规范 -- 使用 `@Slf4j` 注解 -- 日志级别: DEBUG(开发), INFO(生产) -- 关键操作必须记录日志 - -## 部署配置 - -### 1. Dockerfile规范 -```dockerfile -FROM registry.bjzgzp.com:4433/library/eclipse-temurin:21-jdk-ubi10-minimal - -WORKDIR /app - -COPY ./target/{service-name}.jar /app/{service-name}.jar - -EXPOSE 8080 - -ENTRYPOINT ["java", "-Djava.net.preferIPv4Stack=true", "-jar", "/app/{service-name}.jar"] -``` - -### 2. Maven配置 -```xml - - ${project.artifactId} - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-surefire-plugin - - true - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.11.0 - - 21 - 21 - - - - -``` - -## 注意事项 - -1. **数据库选择**: 新微服务使用MongoDB 6.x,移除MySQL相关依赖 -2. **版本兼容性**: 确保所有依赖版本与当前项目保持一致 -3. **配置管理**: 使用环境变量管理敏感配置信息 -4. **服务发现**: 使用Nacos进行服务注册与发现 -5. **API文档**: 使用SpringDoc OpenAPI 3生成API文档 -6. **缓存策略**: 使用Redis进行缓存和会话管理 -7. **监控日志**: 集成适当的监控和日志记录机制 - -## 快速开始模板 - -创建新微服务时,可以参考以下步骤: - -1. 复制当前项目的 `pom.xml`,修改 `artifactId` 和 `name` -2. 移除MySQL相关依赖,添加MongoDB依赖 -3. 复制项目结构,修改包名 -4. 配置MongoDB连接信息 -5. 创建基础的Controller、Service、Repository -6. 配置Dockerfile和部署脚本 - -遵循以上规范可以确保新微服务与现有系统保持一致的技术栈和代码风格。