From 3e33398dd257c8ba2ace637140c56410b72ab64c Mon Sep 17 00:00:00 2001 From: wh Date: Tue, 14 Apr 2026 13:31:50 +0800 Subject: [PATCH] Revert "refactor: flatten dto entity and mapper packages" This reverts commit 29766ebd280d0b5251b563eda7bc82f6cc6fa5ee. --- src/main/java/com/label/dto/LoginRequest.java | 20 --- .../java/com/label/dto/LoginResponse.java | 28 ---- .../java/com/label/dto/SourceResponse.java | 36 ----- src/main/java/com/label/dto/TaskResponse.java | 37 ----- .../java/com/label/dto/UserInfoResponse.java | 25 ---- .../java/com/label/entity/AnnotationTask.java | 58 -------- .../java/com/label/entity/SourceData.java | 55 -------- src/main/java/com/label/entity/SysConfig.java | 36 ----- src/main/java/com/label/entity/SysUser.java | 46 ------ .../com/label/entity/VideoProcessJob.java | 56 -------- .../ExtractionApprovedEventListener.java | 57 ++++---- .../label/mapper/AnnotationTaskMapper.java | 26 ---- .../com/label/mapper/SysCompanyMapper.java | 20 --- .../java/com/label/mapper/SysUserMapper.java | 27 ---- .../com/label/mapper/TaskHistoryMapper.java | 13 -- .../annotation}/entity/AnnotationResult.java | 62 ++++---- .../annotation}/entity/TrainingDataset.java | 91 ++++++------ .../mapper/AnnotationResultMapper.java | 67 +++++---- .../mapper/TrainingDatasetMapper.java | 69 ++++----- .../annotation/service/ExtractionService.java | 116 ++++++++------- .../module/annotation/service/QaService.java | 102 ++++++++------ .../controller/SysConfigController.java | 25 ++-- .../label/module/config/entity/SysConfig.java | 41 ++++++ .../config}/mapper/SysConfigMapper.java | 69 ++++----- .../config/service/SysConfigService.java | 74 ++++++---- .../export/controller/ExportController.java | 29 ++-- .../export}/entity/ExportBatch.java | 87 ++++++------ .../export}/mapper/ExportBatchMapper.java | 59 ++++---- .../module/export/service/ExportService.java | 71 ++++++---- .../export/service/FinetuneService.java | 58 ++++---- .../source/controller/SourceController.java | 34 +++-- .../module/source/dto/SourceResponse.java | 38 +++++ .../module/source/entity/SourceData.java | 56 ++++++++ .../source}/mapper/SourceDataMapper.java | 53 +++---- .../module/source/service/SourceService.java | 88 +++++++----- .../task/controller/TaskController.java | 47 ++++--- .../label/module/task/dto/TaskResponse.java | 38 +++++ .../module/task/entity/AnnotationTask.java | 59 ++++++++ .../task}/entity/AnnotationTaskHistory.java | 85 +++++------ .../task/mapper/AnnotationTaskMapper.java | 30 ++++ .../module/task/mapper/TaskHistoryMapper.java | 14 ++ .../module/task/service/TaskClaimService.java | 85 ++++++----- .../module/task/service/TaskService.java | 63 +++++---- .../user/controller/AuthController.java | 35 +++-- .../user/controller/UserController.java | 27 ++-- .../label/module/user/dto/LoginRequest.java | 21 +++ .../label/module/user/dto/LoginResponse.java | 29 ++++ .../module/user/dto/UserInfoResponse.java | 26 ++++ .../{ => module/user}/entity/SysCompany.java | 67 ++++----- .../com/label/module/user/entity/SysUser.java | 49 +++++++ .../module/user/mapper/SysCompanyMapper.java | 23 +++ .../module/user/mapper/SysUserMapper.java | 34 +++++ .../module/user/service/AuthService.java | 84 ++++++----- .../module/user/service/UserService.java | 94 ++++++++----- .../video/controller/VideoController.java | 45 +++--- .../module/video/entity/VideoProcessJob.java | 57 ++++++++ .../video}/mapper/VideoProcessJobMapper.java | 23 +-- .../video/service/VideoProcessService.java | 133 ++++++++++-------- .../integration/AuthIntegrationTest.java | 51 ++++--- .../ExtractionApprovalIntegrationTest.java | 84 ++++++----- .../QaApprovalIntegrationTest.java | 74 +++++----- .../UserManagementIntegrationTest.java | 47 ++++--- .../com/label/unit/OpenApiAnnotationTest.java | 18 +-- .../unit/PackageStructureMigrationTest.java | 33 ----- 64 files changed, 1780 insertions(+), 1524 deletions(-) delete mode 100644 src/main/java/com/label/dto/LoginRequest.java delete mode 100644 src/main/java/com/label/dto/LoginResponse.java delete mode 100644 src/main/java/com/label/dto/SourceResponse.java delete mode 100644 src/main/java/com/label/dto/TaskResponse.java delete mode 100644 src/main/java/com/label/dto/UserInfoResponse.java delete mode 100644 src/main/java/com/label/entity/AnnotationTask.java delete mode 100644 src/main/java/com/label/entity/SourceData.java delete mode 100644 src/main/java/com/label/entity/SysConfig.java delete mode 100644 src/main/java/com/label/entity/SysUser.java delete mode 100644 src/main/java/com/label/entity/VideoProcessJob.java delete mode 100644 src/main/java/com/label/mapper/AnnotationTaskMapper.java delete mode 100644 src/main/java/com/label/mapper/SysCompanyMapper.java delete mode 100644 src/main/java/com/label/mapper/SysUserMapper.java delete mode 100644 src/main/java/com/label/mapper/TaskHistoryMapper.java rename src/main/java/com/label/{ => module/annotation}/entity/AnnotationResult.java (58%) rename src/main/java/com/label/{ => module/annotation}/entity/TrainingDataset.java (56%) rename src/main/java/com/label/{ => module/annotation}/mapper/AnnotationResultMapper.java (50%) rename src/main/java/com/label/{ => module/annotation}/mapper/TrainingDatasetMapper.java (54%) create mode 100644 src/main/java/com/label/module/config/entity/SysConfig.java rename src/main/java/com/label/{ => module/config}/mapper/SysConfigMapper.java (50%) rename src/main/java/com/label/{ => module/export}/entity/ExportBatch.java (50%) rename src/main/java/com/label/{ => module/export}/mapper/ExportBatchMapper.java (60%) create mode 100644 src/main/java/com/label/module/source/dto/SourceResponse.java create mode 100644 src/main/java/com/label/module/source/entity/SourceData.java rename src/main/java/com/label/{ => module/source}/mapper/SourceDataMapper.java (52%) create mode 100644 src/main/java/com/label/module/task/dto/TaskResponse.java create mode 100644 src/main/java/com/label/module/task/entity/AnnotationTask.java rename src/main/java/com/label/{ => module/task}/entity/AnnotationTaskHistory.java (57%) create mode 100644 src/main/java/com/label/module/task/mapper/AnnotationTaskMapper.java create mode 100644 src/main/java/com/label/module/task/mapper/TaskHistoryMapper.java create mode 100644 src/main/java/com/label/module/user/dto/LoginRequest.java create mode 100644 src/main/java/com/label/module/user/dto/LoginResponse.java create mode 100644 src/main/java/com/label/module/user/dto/UserInfoResponse.java rename src/main/java/com/label/{ => module/user}/entity/SysCompany.java (56%) create mode 100644 src/main/java/com/label/module/user/entity/SysUser.java create mode 100644 src/main/java/com/label/module/user/mapper/SysCompanyMapper.java create mode 100644 src/main/java/com/label/module/user/mapper/SysUserMapper.java create mode 100644 src/main/java/com/label/module/video/entity/VideoProcessJob.java rename src/main/java/com/label/{ => module/video}/mapper/VideoProcessJobMapper.java (59%) diff --git a/src/main/java/com/label/dto/LoginRequest.java b/src/main/java/com/label/dto/LoginRequest.java deleted file mode 100644 index 1e70171..0000000 --- a/src/main/java/com/label/dto/LoginRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.label.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -/** - * 鐧诲綍璇锋眰浣撱€? */ -@Data -@Schema(description = "鐧诲綍璇锋眰") -public class LoginRequest { - /** 鍏徃浠g爜锛堣嫳鏂囩畝鍐欙級锛岀敤浜庣‘瀹氱鎴?*/ - @Schema(description = "鍏徃浠g爜锛堣嫳鏂囩畝鍐欙級", example = "DEMO") - private String companyCode; - /** 鐧诲綍鐢ㄦ埛鍚?*/ - @Schema(description = "鐧诲綍鐢ㄦ埛鍚?, example = "admin") - private String username; - /** 鏄庢枃瀵嗙爜锛堜紶杈撳眰搴斾娇鐢?HTTPS 淇濇姢锛?*/ - @Schema(description = "鏄庢枃瀵嗙爜", example = "admin123") - private String password; -} diff --git a/src/main/java/com/label/dto/LoginResponse.java b/src/main/java/com/label/dto/LoginResponse.java deleted file mode 100644 index e188693..0000000 --- a/src/main/java/com/label/dto/LoginResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.label.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; - -/** - * 鐧诲綍鎴愬姛鍝嶅簲浣撱€? */ -@Data -@AllArgsConstructor -@Schema(description = "鐧诲綍鍝嶅簲") -public class LoginResponse { - /** Bearer Token锛圲UID v4锛夛紝鍚庣画璇锋眰鏀惧叆 Authorization 澶?*/ - @Schema(description = "Bearer Token", example = "550e8400-e29b-41d4-a716-446655440000") - private String token; - /** 鐢ㄦ埛涓婚敭 */ - @Schema(description = "鐢ㄦ埛涓婚敭") - private Long userId; - /** 鐧诲綍鐢ㄦ埛鍚?*/ - @Schema(description = "鐧诲綍鐢ㄦ埛鍚?) - private String username; - /** 瑙掕壊锛歎PLOADER / ANNOTATOR / REVIEWER / ADMIN */ - @Schema(description = "瑙掕壊", example = "ADMIN") - private String role; - /** Token 鏈夋晥鏈燂紙绉掞級 */ - @Schema(description = "Token 鏈夋晥鏈燂紙绉掞級", example = "7200") - private Long expiresIn; -} diff --git a/src/main/java/com/label/dto/SourceResponse.java b/src/main/java/com/label/dto/SourceResponse.java deleted file mode 100644 index c3a5eec..0000000 --- a/src/main/java/com/label/dto/SourceResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.label.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 璧勬枡鎺ュ彛缁熶竴鍝嶅簲浣擄紙涓婁紶銆佸垪琛ㄣ€佽鎯呭潎澶嶇敤姝ょ被锛夈€? * 鍚勭鐐规寜闇€濉厖瀛楁锛屾湭濉厖瀛楁搴忓垪鍖栨椂鍥?jackson non_null 閰嶇疆鑷姩鐪佺暐銆? */ -@Data -@Builder -@Schema(description = "鍘熷璧勬枡鍝嶅簲") -public class SourceResponse { - @Schema(description = "璧勬枡涓婚敭") - private Long id; - @Schema(description = "鏂囦欢鍚?) - private String fileName; - @Schema(description = "璧勬枡绫诲瀷", example = "TEXT") - private String dataType; - @Schema(description = "鏂囦欢澶у皬锛堝瓧鑺傦級") - private Long fileSize; - @Schema(description = "璧勬枡鐘舵€?, example = "PENDING") - private String status; - /** 涓婁紶鐢ㄦ埛 ID锛堝垪琛ㄧ鐐硅繑鍥烇級 */ - @Schema(description = "涓婁紶鐢ㄦ埛 ID") - private Long uploaderId; - /** 15 鍒嗛挓棰勭鍚嶄笅杞介摼鎺ワ紙璇︽儏绔偣杩斿洖锛?*/ - @Schema(description = "棰勭鍚嶄笅杞介摼鎺?) - private String presignedUrl; - /** 鐖惰祫鏂?ID锛堣棰戝抚 / 鏂囨湰鐗囨锛涜鎯呯鐐硅繑鍥烇級 */ - @Schema(description = "鐖惰祫鏂?ID") - private Long parentSourceId; - @Schema(description = "鍒涘缓鏃堕棿") - private LocalDateTime createdAt; -} diff --git a/src/main/java/com/label/dto/TaskResponse.java b/src/main/java/com/label/dto/TaskResponse.java deleted file mode 100644 index 478866b..0000000 --- a/src/main/java/com/label/dto/TaskResponse.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.label.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 浠诲姟鎺ュ彛缁熶竴鍝嶅簲浣擄紙浠诲姟姹犮€佹垜鐨勪换鍔°€佷换鍔¤鎯呭潎澶嶇敤锛夈€? */ -@Data -@Builder -@Schema(description = "鏍囨敞浠诲姟鍝嶅簲") -public class TaskResponse { - @Schema(description = "浠诲姟涓婚敭") - private Long id; - @Schema(description = "鍏宠仈璧勬枡 ID") - private Long sourceId; - /** 浠诲姟绫诲瀷锛堝搴?taskType 瀛楁锛夛細EXTRACTION / QA_GENERATION */ - @Schema(description = "浠诲姟绫诲瀷", example = "EXTRACTION") - private String taskType; - @Schema(description = "浠诲姟鐘舵€?, example = "UNCLAIMED") - private String status; - @Schema(description = "棰嗗彇浜虹敤鎴?ID") - private Long claimedBy; - @Schema(description = "棰嗗彇鏃堕棿") - private LocalDateTime claimedAt; - @Schema(description = "鎻愪氦鏃堕棿") - private LocalDateTime submittedAt; - @Schema(description = "瀹屾垚鏃堕棿") - private LocalDateTime completedAt; - /** 椹冲洖鍘熷洜锛圧EJECTED 鐘舵€佹椂闈炵┖锛?*/ - @Schema(description = "椹冲洖鍘熷洜") - private String rejectReason; - @Schema(description = "鍒涘缓鏃堕棿") - private LocalDateTime createdAt; -} diff --git a/src/main/java/com/label/dto/UserInfoResponse.java b/src/main/java/com/label/dto/UserInfoResponse.java deleted file mode 100644 index af3fa9d..0000000 --- a/src/main/java/com/label/dto/UserInfoResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.label.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; - -/** - * GET /api/auth/me 鍝嶅簲浣擄紝鍖呭惈褰撳墠鐧诲綍鐢ㄦ埛鐨勮缁嗕俊鎭€? */ -@Data -@AllArgsConstructor -@Schema(description = "褰撳墠鐧诲綍鐢ㄦ埛淇℃伅") -public class UserInfoResponse { - @Schema(description = "鐢ㄦ埛涓婚敭") - private Long id; - @Schema(description = "鐢ㄦ埛鍚?) - private String username; - @Schema(description = "鐪熷疄濮撳悕") - private String realName; - @Schema(description = "瑙掕壊", example = "ADMIN") - private String role; - @Schema(description = "鎵€灞炲叕鍙?ID") - private Long companyId; - @Schema(description = "鎵€灞炲叕鍙稿悕绉?) - private String companyName; -} diff --git a/src/main/java/com/label/entity/AnnotationTask.java b/src/main/java/com/label/entity/AnnotationTask.java deleted file mode 100644 index 1378899..0000000 --- a/src/main/java/com/label/entity/AnnotationTask.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 鏍囨敞浠诲姟瀹炰綋锛屽搴?annotation_task 琛ㄣ€? * - * taskType 鍙栧€硷細EXTRACTION / QA_GENERATION - * status 鍙栧€硷細UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED - */ -@Data -@TableName("annotation_task") -public class AnnotationTask { - - @TableId(type = IdType.AUTO) - private Long id; - - /** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/ - private Long companyId; - - /** 鍏宠仈鐨勫師濮嬭祫鏂?ID */ - private Long sourceId; - - /** 浠诲姟绫诲瀷锛欵XTRACTION / QA_GENERATION */ - private String taskType; - - /** 浠诲姟鐘舵€?*/ - private String status; - - /** 棰嗗彇浠诲姟鐨勭敤鎴?ID */ - private Long claimedBy; - - /** 棰嗗彇鏃堕棿 */ - private LocalDateTime claimedAt; - - /** 鎻愪氦鏃堕棿 */ - private LocalDateTime submittedAt; - - /** 瀹屾垚鏃堕棿锛圓PPROVED 鏃惰缃級 */ - private LocalDateTime completedAt; - - /** 鏄惁鏈€缁堢粨鏋滐紙APPROVED 涓旀棤闇€鍐嶅锛?/ - private Boolean isFinal; - - /** 浣跨敤鐨?AI 妯″瀷鍚嶇О */ - private String aiModel; - - /** 椹冲洖鍘熷洜 */ - private String rejectReason; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/label/entity/SourceData.java b/src/main/java/com/label/entity/SourceData.java deleted file mode 100644 index f311f81..0000000 --- a/src/main/java/com/label/entity/SourceData.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 鍘熷璧勬枡瀹炰綋锛屽搴?source_data 琛ㄣ€? * - * dataType 鍙栧€硷細TEXT / IMAGE / VIDEO - * status 鍙栧€硷細PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED - */ -@Data -@TableName("source_data") -public class SourceData { - - @TableId(type = IdType.AUTO) - private Long id; - - /** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/ - private Long companyId; - - /** 涓婁紶鐢ㄦ埛 ID */ - private Long uploaderId; - - /** 璧勬枡绫诲瀷锛歍EXT / IMAGE / VIDEO */ - private String dataType; - - /** RustFS 瀵硅薄璺緞 */ - private String filePath; - - /** 鍘熷鏂囦欢鍚?*/ - private String fileName; - - /** 鏂囦欢澶у皬锛堝瓧鑺傦級 */ - private Long fileSize; - - /** RustFS Bucket 鍚嶇О */ - private String bucketName; - - /** 鐖惰祫鏂?ID锛堣棰戝抚鎴栨枃鏈墖娈电殑鑷紩鐢ㄥ閿級 */ - private Long parentSourceId; - - /** 娴佹按绾跨姸鎬侊細PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED */ - private String status; - - /** 淇濈暀瀛楁锛堝綋鍓嶆棤 REJECTED 鐘舵€侊級 */ - private String rejectReason; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/label/entity/SysConfig.java b/src/main/java/com/label/entity/SysConfig.java deleted file mode 100644 index 221824a..0000000 --- a/src/main/java/com/label/entity/SysConfig.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 绯荤粺閰嶇疆瀹炰綋锛屽搴?sys_config 琛ㄣ€? * - * company_id 涓?NULL 鏃惰〃绀哄叏灞€榛樿閰嶇疆锛岄潪 NULL 鏃惰〃绀虹鎴蜂笓灞為厤缃紙浼樺厛绾ф洿楂橈級銆? * 娉細sys_config 宸插姞鍏?MybatisPlusConfig.IGNORED_TABLES锛屼笉璧板绉熸埛杩囨护鍣ㄣ€? */ -@Data -@TableName("sys_config") -public class SysConfig { - - @TableId(type = IdType.AUTO) - private Long id; - - /** - * 鎵€灞炲叕鍙?ID锛圢ULL = 鍏ㄥ眬榛樿閰嶇疆锛涢潪 NULL = 绉熸埛涓撳睘閰嶇疆锛夈€? * 娉ㄦ剰锛氫笉鑳界敤 @TableField(exist = false) 鎺掗櫎锛屽繀椤讳繚鐣欎互鏀寔 company_id IS NULL 鏌ヨ銆? */ - private Long companyId; - - /** 閰嶇疆閿?*/ - private String configKey; - - /** 閰嶇疆鍊?*/ - private String configValue; - - /** 閰嶇疆璇存槑 */ - private String description; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/label/entity/SysUser.java b/src/main/java/com/label/entity/SysUser.java deleted file mode 100644 index 64ec61c..0000000 --- a/src/main/java/com/label/entity/SysUser.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonIgnore; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 绯荤粺鐢ㄦ埛瀹炰綋锛屽搴?sys_user 琛ㄣ€? * role 鍙栧€硷細UPLOADER / ANNOTATOR / REVIEWER / ADMIN - * status 鍙栧€硷細ACTIVE / DISABLED - */ -@Data -@TableName("sys_user") -public class SysUser { - - /** 鐢ㄦ埛涓婚敭锛岃嚜澧?*/ - @TableId(type = IdType.AUTO) - private Long id; - - /** 鎵€灞炲叕鍙?ID锛堝绉熸埛閿級 */ - private Long companyId; - - /** 鐧诲綍鐢ㄦ埛鍚嶏紙鍚屽叕鍙稿唴鍞竴锛?*/ - private String username; - - /** - * BCrypt 鍝堝笇瀵嗙爜锛坰trength 鈮?10锛夈€? * 搴忓垪鍖栨椂鎺掗櫎锛岄槻姝㈠瘑鐮佸搱甯屾硠婕忓埌 API 鍝嶅簲銆? */ - @JsonIgnore - private String passwordHash; - - /** 鐪熷疄濮撳悕 */ - private String realName; - - /** 瑙掕壊锛歎PLOADER / ANNOTATOR / REVIEWER / ADMIN */ - private String role; - - /** 鐘舵€侊細ACTIVE / DISABLED */ - private String status; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/label/entity/VideoProcessJob.java b/src/main/java/com/label/entity/VideoProcessJob.java deleted file mode 100644 index 445b6d6..0000000 --- a/src/main/java/com/label/entity/VideoProcessJob.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 瑙嗛澶勭悊浠诲姟瀹炰綋锛屽搴?video_process_job 琛ㄣ€? * - * jobType 鍙栧€硷細FRAME_EXTRACT / VIDEO_TO_TEXT - * status 鍙栧€硷細PENDING / RUNNING / SUCCESS / FAILED / RETRYING - */ -@Data -@TableName("video_process_job") -public class VideoProcessJob { - - @TableId(type = IdType.AUTO) - private Long id; - - /** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/ - private Long companyId; - - /** 鍏宠仈璧勬枡 ID */ - private Long sourceId; - - /** 浠诲姟绫诲瀷锛欶RAME_EXTRACT / VIDEO_TO_TEXT */ - private String jobType; - - /** 浠诲姟鐘舵€侊細PENDING / RUNNING / SUCCESS / FAILED / RETRYING */ - private String status; - - /** 浠诲姟鍙傛暟锛圝SONB锛屼緥濡?{"frameInterval": 30}锛?*/ - private String params; - - /** AI 澶勭悊杈撳嚭璺緞锛堟垚鍔熷悗濉啓锛?*/ - private String outputPath; - - /** 宸查噸璇曟鏁?*/ - private Integer retryCount; - - /** 鏈€澶ч噸璇曟鏁帮紙榛樿 3锛?*/ - private Integer maxRetries; - - /** 閿欒淇℃伅 */ - private String errorMessage; - - private LocalDateTime startedAt; - - private LocalDateTime completedAt; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/label/listener/ExtractionApprovedEventListener.java b/src/main/java/com/label/listener/ExtractionApprovedEventListener.java index e9bc6d8..6e222ae 100644 --- a/src/main/java/com/label/listener/ExtractionApprovedEventListener.java +++ b/src/main/java/com/label/listener/ExtractionApprovedEventListener.java @@ -3,11 +3,11 @@ package com.label.listener; import com.fasterxml.jackson.databind.ObjectMapper; import com.label.common.ai.AiServiceClient; import com.label.common.context.CompanyContext; -import com.label.entity.TrainingDataset; -import com.label.mapper.AnnotationResultMapper; -import com.label.mapper.TrainingDatasetMapper; -import com.label.entity.SourceData; -import com.label.mapper.SourceDataMapper; +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.TaskClaimService; import com.label.module.task.service.TaskService; import com.label.event.ExtractionApprovedEvent; @@ -25,13 +25,18 @@ import java.util.List; import java.util.Map; /** - * 鎻愬彇瀹℃壒閫氳繃鍚庣殑寮傛澶勭悊鍣ㄣ€? * - * 璁捐绾︽潫锛堝叧閿級锛? * - @TransactionalEventListener(AFTER_COMMIT)锛氱‘淇濆湪瀹℃壒浜嬪姟鎻愪氦鍚庢墠瑙﹀彂 AI 璋冪敤 - * - @Transactional(REQUIRES_NEW)锛氬湪鐙珛鏂颁簨鍔′腑鍐?DB锛屼笌瀹℃壒浜嬪姟瀹屽叏闅旂 - * - 寮傚父涓嶄細鍥炴粴瀹℃壒浜嬪姟锛堝凡鎻愪氦锛夛紝浣嗕細鍦ㄦ棩蹇椾腑璁板綍 + * 提取审批通过后的异步处理器。 * - * 澶勭悊娴佺▼锛? * 1. 璋冪敤 AI 鐢熸垚鍊欓€夐棶绛斿锛圱ext/Image 璧颁笉鍚岀鐐癸級 - * 2. 鍐欏叆 training_dataset锛坰tatus=PENDING_REVIEW锛? * 3. 鍒涘缓 QA_GENERATION 浠诲姟锛坰tatus=UNCLAIMED锛? * 4. 鏇存柊 source_data 鐘舵€佷负 QA_REVIEW + * 设计约束(关键): + * - @TransactionalEventListener(AFTER_COMMIT):确保在审批事务提交后才触发 AI 调用 + * - @Transactional(REQUIRES_NEW):在独立新事务中写 DB,与审批事务完全隔离 + * - 异常不会回滚审批事务(已提交),但会在日志中记录 + * + * 处理流程: + * 1. 调用 AI 生成候选问答对(Text/Image 走不同端点) + * 2. 写入 training_dataset(status=PENDING_REVIEW) + * 3. 创建 QA_GENERATION 任务(status=UNCLAIMED) + * 4. 更新 source_data 状态为 QA_REVIEW */ @Slf4j @Component @@ -50,15 +55,16 @@ public class ExtractionApprovedEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) public void onExtractionApproved(ExtractionApprovedEvent event) { - log.info("澶勭悊鎻愬彇瀹℃壒閫氳繃浜嬩欢: taskId={}, sourceId={}", event.getTaskId(), event.getSourceId()); + log.info("处理提取审批通过事件: taskId={}, sourceId={}", event.getTaskId(), event.getSourceId()); - // 璁剧疆澶氱鎴蜂笂涓嬫枃锛堟柊浜嬪姟涓?ThreadLocal 宸叉竻闄わ級 + // 设置多租户上下文(新事务中 ThreadLocal 已清除) CompanyContext.set(event.getCompanyId()); try { processEvent(event); } catch (Exception e) { - log.error("澶勭悊瀹℃壒閫氳繃浜嬩欢澶辫触锛坱askId={}锛夛細{}", event.getTaskId(), e.getMessage(), e); - // 涓嶅悜涓婃姏鍑猴紝瀹℃壒鎿嶄綔宸叉彁浜わ紝姝ゅ澶辫触涓嶅洖婊氬鎵? } finally { + log.error("处理审批通过事件失败(taskId={}):{}", event.getTaskId(), e.getMessage(), e); + // 不向上抛出,审批操作已提交,此处失败不回滚审批 + } finally { CompanyContext.clear(); } } @@ -66,11 +72,11 @@ public class ExtractionApprovedEventListener { private void processEvent(ExtractionApprovedEvent event) { SourceData source = sourceDataMapper.selectById(event.getSourceId()); if (source == null) { - log.warn("璧勬枡涓嶅瓨鍦紝璺宠繃鍚庣画澶勭悊: sourceId={}", event.getSourceId()); + log.warn("资料不存在,跳过后续处理: sourceId={}", event.getSourceId()); return; } - // 1. 璋冪敤 AI 鐢熸垚鍊欓€夐棶绛斿 + // 1. 调用 AI 生成候选问答对 AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder() .sourceId(source.getId()) .filePath(source.getFilePath()) @@ -85,11 +91,12 @@ public class ExtractionApprovedEventListener { qaPairs = response != null && response.getQaPairs() != null ? response.getQaPairs() : Collections.emptyList(); } catch (Exception e) { - log.warn("AI 闂瓟鐢熸垚澶辫触锛坱askId={}锛夛細{}锛屽皢浣跨敤绌洪棶绛斿", event.getTaskId(), e.getMessage()); + log.warn("AI 问答生成失败(taskId={}):{},将使用空问答对", event.getTaskId(), e.getMessage()); qaPairs = Collections.emptyList(); } - // 2. 鍐欏叆 training_dataset锛圥ENDING_REVIEW锛? String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT"; + // 2. 写入 training_dataset(PENDING_REVIEW) + String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT"; String glmJson = buildGlmJson(qaPairs); TrainingDataset dataset = new TrainingDataset(); @@ -101,21 +108,23 @@ public class ExtractionApprovedEventListener { dataset.setStatus("PENDING_REVIEW"); datasetMapper.insert(dataset); - // 3. 鍒涘缓 QA_GENERATION 浠诲姟锛圲NCLAIMED锛? taskService.createTask(event.getSourceId(), "QA_GENERATION", event.getCompanyId()); + // 3. 创建 QA_GENERATION 任务(UNCLAIMED) + taskService.createTask(event.getSourceId(), "QA_GENERATION", event.getCompanyId()); - // 4. 鏇存柊 source_data 鐘舵€佷负 QA_REVIEW + // 4. 更新 source_data 状态为 QA_REVIEW sourceDataMapper.updateStatus(event.getSourceId(), "QA_REVIEW", event.getCompanyId()); - log.info("瀹℃壒閫氳繃鍚庣画澶勭悊瀹屾垚: taskId={}, 鏂?QA 浠诲姟宸插垱寤?, event.getTaskId()); + log.info("审批通过后续处理完成: taskId={}, 新 QA 任务已创建", event.getTaskId()); } /** - * 灏?AI 鐢熸垚鐨勯棶绛斿鍒楄〃杞崲涓?GLM fine-tune 鏍煎紡 JSON銆? */ + * 将 AI 生成的问答对列表转换为 GLM fine-tune 格式 JSON。 + */ private String buildGlmJson(List> qaPairs) { try { return objectMapper.writeValueAsString(Map.of("conversations", qaPairs)); } catch (Exception e) { - log.error("鏋勫缓 GLM JSON 澶辫触", e); + log.error("构建 GLM JSON 失败", e); return "{\"conversations\":[]}"; } } diff --git a/src/main/java/com/label/mapper/AnnotationTaskMapper.java b/src/main/java/com/label/mapper/AnnotationTaskMapper.java deleted file mode 100644 index c3248d1..0000000 --- a/src/main/java/com/label/mapper/AnnotationTaskMapper.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.AnnotationTask; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Update; - -/** - * annotation_task 鐞?Mapper閵? */ -@Mapper -public interface AnnotationTaskMapper extends BaseMapper { - - /** - * 閸樼喎鐡欓幀褔顣崣鏍︽崲閸斺槄绱版禒鍛秼娴犺濮熸稉?UNCLAIMED 娑撴柨鐫樻禍搴$秼閸撳秶顫ら幋閿嬫閹靛秵娲块弬鑸偓? * 娴h法鏁ゆ稊鎰潎 WHERE 閺夆€叉鐎圭偟骞囬獮璺哄絺鐎瑰鍙忛敍鍫滅贩鐠ф牗鏆熼幑顔肩氨鐞涘瞼楠囬柨渚婄礆閵? * - * @param taskId 娴犺濮?ID - * @param userId 妫板棗褰囬悽銊﹀煕 ID - * @param companyId 瑜版挸澧犵粔鐔稿煕 - * @return 瑜板崬鎼风悰灞炬殶閿? = 娴犺濮熷鑼额潶娴犳牔姹夋0鍡楀絿閹存牔绗夌€涙ê婀敍? */ - @Update("UPDATE annotation_task " + - "SET status = 'IN_PROGRESS', claimed_by = #{userId}, claimed_at = NOW(), updated_at = NOW() " + - "WHERE id = #{taskId} AND status = 'UNCLAIMED' AND company_id = #{companyId}") - int claimTask(@Param("taskId") Long taskId, - @Param("userId") Long userId, - @Param("companyId") Long companyId); -} diff --git a/src/main/java/com/label/mapper/SysCompanyMapper.java b/src/main/java/com/label/mapper/SysCompanyMapper.java deleted file mode 100644 index 4915d34..0000000 --- a/src/main/java/com/label/mapper/SysCompanyMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.SysCompany; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Select; - -/** - * sys_company 鐞?Mapper閵? * 缂佈勫 BaseMapper 閼惧嘲绶遍弽鍥у櫙 CRUD閿涙稖鍤滅€规矮绠熼弬瑙勭《閻劍鏁炵憴?SQL閵? */ -@Mapper -public interface SysCompanyMapper extends BaseMapper { - - /** - * 閹稿鍙曢崣闀愬敩閻焦鐓$拠銏犲彆閸欓潻绱欒箛鐣屾殣婢舵氨顫ら幋鐤箖濠娿倧绱漵ys_company 閺?company_id 鐎涙顔岄敍澶堚偓? * - * @param companyCode 閸忣剙寰冩禒锝囩垳 - * @return 閸忣剙寰冪€圭偘缍嬮敍灞肩瑝鐎涙ê婀崚娆掔箲閸?null - */ - @Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}") - SysCompany selectByCompanyCode(String companyCode); -} diff --git a/src/main/java/com/label/mapper/SysUserMapper.java b/src/main/java/com/label/mapper/SysUserMapper.java deleted file mode 100644 index 2ecf9e6..0000000 --- a/src/main/java/com/label/mapper/SysUserMapper.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.annotation.InterceptorIgnore; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.SysUser; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -/** - * sys_user 鐞?Mapper閵? * 缂佈勫 BaseMapper 閼惧嘲绶遍弽鍥у櫙 CRUD閿涙稖鍤滅€规矮绠熼惂璇茬秿閺屻儴顕楅弬瑙勭《缂佹洝绻冩径姘鳖潳閹寸柉绻冨銈呮珤閿? * 閻㈣精鐨熼悽銊︽煙閺勬儳绱℃导鐘插弳 companyId閵? */ -@Mapper -public interface SysUserMapper extends BaseMapper { - - /** - * 閹稿鍙曢崣?ID + 閻劍鍩涢崥宥嗙叀鐠囥垻鏁ら幋鍑ょ礄閻ц缍嶉崷鐑樻珯娴h法鏁ら敍澶堚偓? *

- * 娴h法鏁?@InterceptorIgnore 缂佹洝绻?TenantLineInnerInterceptor閿? * 閻㈠崬寮弫?companyId 閺勬儳绱¢梽鎰暰缁夌喐鍩涢敍宀勬Щ濮濄垻娅ヨぐ鏇熸 CompanyContext 鐏忔碍婀▔銊ュ弳 - * 鐎佃壈鍤ч弻銉嚄閺夆€叉閸欐ü璐?{@code company_id = NULL}閵? *

- * - * @param companyId 閸忣剙寰?ID - * @param username 閻劍鍩涢崥? * @return 閻劍鍩涚€圭偘缍嬮敍鍫濇儓 passwordHash閿涘绱濇稉宥呯摠閸︺劌鍨潻鏂挎礀 null - */ - @InterceptorIgnore(tenantLine = "true") - @Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'") - SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId, - @Param("username") String username); -} diff --git a/src/main/java/com/label/mapper/TaskHistoryMapper.java b/src/main/java/com/label/mapper/TaskHistoryMapper.java deleted file mode 100644 index 7579f84..0000000 --- a/src/main/java/com/label/mapper/TaskHistoryMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.AnnotationTaskHistory; -import org.apache.ibatis.annotations.Mapper; - -/** - * annotation_task_history 鐞?Mapper閿涘牅绮庢潻钘夊閿涘瞼顩﹀?UPDATE/DELETE閿涘鈧? */ -@Mapper -public interface TaskHistoryMapper extends BaseMapper { - // 缂佈勫 BaseMapper 閻?insert 閻劋绨潻钘夊閸樺棗褰剁拋鏉跨秿 - // 娑撱儳顩︾拫鍐暏 update/delete 閻╃鍙ч弬瑙勭《 -} diff --git a/src/main/java/com/label/entity/AnnotationResult.java b/src/main/java/com/label/module/annotation/entity/AnnotationResult.java similarity index 58% rename from src/main/java/com/label/entity/AnnotationResult.java rename to src/main/java/com/label/module/annotation/entity/AnnotationResult.java index bf2e716..6b9dce1 100644 --- a/src/main/java/com/label/entity/AnnotationResult.java +++ b/src/main/java/com/label/module/annotation/entity/AnnotationResult.java @@ -1,30 +1,32 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 鏍囨敞缁撴灉瀹炰綋锛屽搴?annotation_result 琛ㄣ€? * resultJson 瀛樺偍 JSONB 鏍煎紡鐨勬爣娉ㄥ唴瀹癸紙鏁翠綋鏇挎崲璇箟锛夈€? */ -@Data -@TableName("annotation_result") -public class AnnotationResult { - - @TableId(type = IdType.AUTO) - private Long id; - - private Long taskId; - - /** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/ - private Long companyId; - - /** 鏍囨敞缁撴灉 JSON锛圝SONB锛屾暣浣撹鐩栵級 */ - private String resultJson; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} +package com.label.module.annotation.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 标注结果实体,对应 annotation_result 表。 + * resultJson 存储 JSONB 格式的标注内容(整体替换语义)。 + */ +@Data +@TableName("annotation_result") +public class AnnotationResult { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long taskId; + + /** 所属公司(多租户键) */ + private Long companyId; + + /** 标注结果 JSON(JSONB,整体覆盖) */ + private String resultJson; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/entity/TrainingDataset.java b/src/main/java/com/label/module/annotation/entity/TrainingDataset.java similarity index 56% rename from src/main/java/com/label/entity/TrainingDataset.java rename to src/main/java/com/label/module/annotation/entity/TrainingDataset.java index 9336846..feafa45 100644 --- a/src/main/java/com/label/entity/TrainingDataset.java +++ b/src/main/java/com/label/module/annotation/entity/TrainingDataset.java @@ -1,45 +1,46 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 璁粌鏁版嵁闆嗗疄浣擄紝瀵瑰簲 training_dataset 琛ㄣ€? * - * status 鍙栧€硷細PENDING_REVIEW / APPROVED / REJECTED - * sampleType 鍙栧€硷細TEXT / IMAGE / VIDEO_FRAME - */ -@Data -@TableName("training_dataset") -public class TrainingDataset { - - @TableId(type = IdType.AUTO) - private Long id; - - /** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/ - private Long companyId; - - private Long taskId; - - private Long sourceId; - - /** 鏍锋湰绫诲瀷锛歍EXT / IMAGE / VIDEO_FRAME */ - private String sampleType; - - /** GLM fine-tune 鏍煎紡鐨?JSON 瀛楃涓诧紙JSONB锛?*/ - private String glmFormatJson; - - /** 鐘舵€侊細PENDING_REVIEW / APPROVED / REJECTED */ - private String status; - - private Long exportBatchId; - - private LocalDateTime exportedAt; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} +package com.label.module.annotation.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 训练数据集实体,对应 training_dataset 表。 + * + * status 取值:PENDING_REVIEW / APPROVED / REJECTED + * sampleType 取值:TEXT / IMAGE / VIDEO_FRAME + */ +@Data +@TableName("training_dataset") +public class TrainingDataset { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属公司(多租户键) */ + private Long companyId; + + private Long taskId; + + private Long sourceId; + + /** 样本类型:TEXT / IMAGE / VIDEO_FRAME */ + private String sampleType; + + /** GLM fine-tune 格式的 JSON 字符串(JSONB) */ + private String glmFormatJson; + + /** 状态:PENDING_REVIEW / APPROVED / REJECTED */ + private String status; + + private Long exportBatchId; + + private LocalDateTime exportedAt; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/mapper/AnnotationResultMapper.java b/src/main/java/com/label/module/annotation/mapper/AnnotationResultMapper.java similarity index 50% rename from src/main/java/com/label/mapper/AnnotationResultMapper.java rename to src/main/java/com/label/module/annotation/mapper/AnnotationResultMapper.java index 8447f1a..4290c62 100644 --- a/src/main/java/com/label/mapper/AnnotationResultMapper.java +++ b/src/main/java/com/label/module/annotation/mapper/AnnotationResultMapper.java @@ -1,31 +1,36 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.AnnotationResult; -import org.apache.ibatis.annotations.*; - -/** - * annotation_result 鐞?Mapper閵? */ -@Mapper -public interface AnnotationResultMapper extends BaseMapper { - - /** - * 閺佺繝缍嬬憰鍡欐磰閺嶅洦鏁炵紒鎾寸亯 JSON閿涘湞SONB 鐎涙顔岄敍澶堚偓? * - * @param taskId 娴犺濮?ID - * @param resultJson 閺傛壆娈?JSON 鐎涙顑佹稉璇х礄閺佺繝缍嬮弴鎸庡床閿? * @param companyId 瑜版挸澧犵粔鐔稿煕 - * @return 瑜板崬鎼风悰灞炬殶 - */ - @Update("UPDATE annotation_result " + - "SET result_json = #{resultJson}::jsonb, updated_at = NOW() " + - "WHERE task_id = #{taskId} AND company_id = #{companyId}") - int updateResultJson(@Param("taskId") Long taskId, - @Param("resultJson") String resultJson, - @Param("companyId") Long companyId); - - /** - * 閹稿鎹㈤崝?ID 閺屻儴顕楅弽鍥ㄦ暈缂佹挻鐏夐妴? * - * @param taskId 娴犺濮?ID - * @return 閺嶅洦鏁炵紒鎾寸亯閿涘牅绗夌€涙ê婀崚娆掔箲閸?null閿? */ - @Select("SELECT * FROM annotation_result WHERE task_id = #{taskId}") - AnnotationResult selectByTaskId(@Param("taskId") Long taskId); -} +package com.label.module.annotation.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.annotation.entity.AnnotationResult; +import org.apache.ibatis.annotations.*; + +/** + * annotation_result 表 Mapper。 + */ +@Mapper +public interface AnnotationResultMapper extends BaseMapper { + + /** + * 整体覆盖标注结果 JSON(JSONB 字段)。 + * + * @param taskId 任务 ID + * @param resultJson 新的 JSON 字符串(整体替换) + * @param companyId 当前租户 + * @return 影响行数 + */ + @Update("UPDATE annotation_result " + + "SET result_json = #{resultJson}::jsonb, updated_at = NOW() " + + "WHERE task_id = #{taskId} AND company_id = #{companyId}") + int updateResultJson(@Param("taskId") Long taskId, + @Param("resultJson") String resultJson, + @Param("companyId") Long companyId); + + /** + * 按任务 ID 查询标注结果。 + * + * @param taskId 任务 ID + * @return 标注结果(不存在则返回 null) + */ + @Select("SELECT * FROM annotation_result WHERE task_id = #{taskId}") + AnnotationResult selectByTaskId(@Param("taskId") Long taskId); +} diff --git a/src/main/java/com/label/mapper/TrainingDatasetMapper.java b/src/main/java/com/label/module/annotation/mapper/TrainingDatasetMapper.java similarity index 54% rename from src/main/java/com/label/mapper/TrainingDatasetMapper.java rename to src/main/java/com/label/module/annotation/mapper/TrainingDatasetMapper.java index 74d21bf..94eefde 100644 --- a/src/main/java/com/label/mapper/TrainingDatasetMapper.java +++ b/src/main/java/com/label/module/annotation/mapper/TrainingDatasetMapper.java @@ -1,33 +1,36 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.TrainingDataset; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Update; -import org.apache.ibatis.annotations.Delete; - -/** - * training_dataset 鐞?Mapper閵? */ -@Mapper -public interface TrainingDatasetMapper extends BaseMapper { - - /** - * 閹稿鎹㈤崝?ID 鐏忓棜顔勭紒鍐╃壉閺堫剛濮搁幀浣规暭娑?APPROVED閵? * - * @param taskId 娴犺濮?ID - * @param companyId 瑜版挸澧犵粔鐔稿煕 - * @return 瑜板崬鎼风悰灞炬殶 - */ - @Update("UPDATE training_dataset SET status = 'APPROVED', updated_at = NOW() " + - "WHERE task_id = #{taskId} AND company_id = #{companyId}") - int approveByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId); - - /** - * 閹稿鎹㈤崝?ID 閸掔娀娅庣拋顓犵矊閺嶉攱婀伴敍鍫モ攺閸ョ偞妞傚〒鍛存珟閸婃瑩鈧鏆熼幑顕嗙礆閵? * - * @param taskId 娴犺濮?ID - * @param companyId 瑜版挸澧犵粔鐔稿煕 - * @return 瑜板崬鎼风悰灞炬殶 - */ - @Delete("DELETE FROM training_dataset WHERE task_id = #{taskId} AND company_id = #{companyId}") - int deleteByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId); -} +package com.label.module.annotation.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.annotation.entity.TrainingDataset; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.Delete; + +/** + * training_dataset 表 Mapper。 + */ +@Mapper +public interface TrainingDatasetMapper extends BaseMapper { + + /** + * 按任务 ID 将训练样本状态改为 APPROVED。 + * + * @param taskId 任务 ID + * @param companyId 当前租户 + * @return 影响行数 + */ + @Update("UPDATE training_dataset SET status = 'APPROVED', updated_at = NOW() " + + "WHERE task_id = #{taskId} AND company_id = #{companyId}") + int approveByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId); + + /** + * 按任务 ID 删除训练样本(驳回时清除候选数据)。 + * + * @param taskId 任务 ID + * @param companyId 当前租户 + * @return 影响行数 + */ + @Delete("DELETE FROM training_dataset WHERE task_id = #{taskId} AND company_id = #{companyId}") + int deleteByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId); +} diff --git a/src/main/java/com/label/module/annotation/service/ExtractionService.java b/src/main/java/com/label/module/annotation/service/ExtractionService.java index 7fafcd6..0bdd7b6 100644 --- a/src/main/java/com/label/module/annotation/service/ExtractionService.java +++ b/src/main/java/com/label/module/annotation/service/ExtractionService.java @@ -7,15 +7,15 @@ import com.label.common.exception.BusinessException; import com.label.common.shiro.TokenPrincipal; import com.label.common.statemachine.StateValidator; import com.label.common.statemachine.TaskStatus; -import com.label.entity.AnnotationResult; -import com.label.entity.TrainingDataset; +import com.label.module.annotation.entity.AnnotationResult; +import com.label.module.annotation.entity.TrainingDataset; import com.label.event.ExtractionApprovedEvent; -import com.label.mapper.AnnotationResultMapper; -import com.label.mapper.TrainingDatasetMapper; -import com.label.entity.SourceData; -import com.label.mapper.SourceDataMapper; -import com.label.entity.AnnotationTask; -import com.label.mapper.AnnotationTaskMapper; +import com.label.module.annotation.mapper.AnnotationResultMapper; +import com.label.module.annotation.mapper.TrainingDatasetMapper; +import com.label.module.source.entity.SourceData; +import com.label.module.source.mapper.SourceDataMapper; +import com.label.module.task.entity.AnnotationTask; +import com.label.module.task.mapper.AnnotationTaskMapper; import com.label.module.task.service.TaskClaimService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -30,8 +30,12 @@ import java.util.Collections; import java.util.Map; /** - * 鎻愬彇闃舵鏍囨敞鏈嶅姟锛欰I 棰勬爣娉ㄣ€佹洿鏂扮粨鏋溿€佹彁浜ゃ€佸鎵广€侀┏鍥炪€? * - * 鍏抽敭璁捐锛? * - approve() 鍐呯姝㈢洿鎺ヨ皟鐢?AI锛岄€氳繃 ExtractionApprovedEvent 瑙h€︼紙AFTER_COMMIT锛? * - 鎵€鏈夊啓鎿嶄綔鍖呰9鍦?@Transactional 涓紝纭繚浠诲姟鐘舵€佸拰鍘嗗彶鐨勪竴鑷存€? */ + * 提取阶段标注服务:AI 预标注、更新结果、提交、审批、驳回。 + * + * 关键设计: + * - approve() 内禁止直接调用 AI,通过 ExtractionApprovedEvent 解耦(AFTER_COMMIT) + * - 所有写操作包裹在 @Transactional 中,确保任务状态和历史的一致性 + */ @Slf4j @Service @RequiredArgsConstructor @@ -49,19 +53,22 @@ public class ExtractionService { @Value("${rustfs.bucket:label-source-data}") private String bucket; - // ------------------------------------------------------------------ AI 棰勬爣娉?-- + // ------------------------------------------------------------------ AI 预标注 -- /** - * AI 杈呭姪棰勬爣娉細璋冪敤 AI 鏈嶅姟锛屽皢缁撴灉鍐欏叆 annotation_result銆? * 娉細姝ゆ柟娉曞湪 @Transactional 澶栬皟鐢紙AI 璋冪敤涓嶅簲鍦ㄤ簨鍔″唴锛夛紝鐢辨帶鍒跺櫒鐩存帴璋冪敤銆? */ + * AI 辅助预标注:调用 AI 服务,将结果写入 annotation_result。 + * 注:此方法在 @Transactional 外调用(AI 调用不应在事务内),由控制器直接调用。 + */ public void aiPreAnnotate(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); SourceData source = sourceDataMapper.selectById(task.getSourceId()); if (source == null) { - throw new BusinessException("NOT_FOUND", "鍏宠仈璧勬枡涓嶅瓨鍦?, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "关联资料不存在", HttpStatus.NOT_FOUND); } - // 璋冪敤 AI 鏈嶅姟锛堝湪浜嬪姟澶栵紝閬垮厤闀挎椂闂存寔鏈?DB 杩炴帴锛? AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder() + // 调用 AI 服务(在事务外,避免长时间持有 DB 连接) + AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder() .sourceId(source.getId()) .filePath(source.getFilePath()) .bucket(bucket) @@ -75,35 +82,39 @@ public class ExtractionService { aiResponse = aiServiceClient.extractText(req); } } catch (Exception e) { - log.warn("AI 棰勬爣娉ㄨ皟鐢ㄥけ璐ワ紙浠诲姟 {}锛夛細{}", taskId, e.getMessage()); - // AI 澶辫触涓嶉樆濉炴祦绋嬶紝鍐欏叆绌虹粨鏋? aiResponse = new AiServiceClient.ExtractionResponse(); + log.warn("AI 预标注调用失败(任务 {}):{}", taskId, e.getMessage()); + // AI 失败不阻塞流程,写入空结果 + aiResponse = new AiServiceClient.ExtractionResponse(); aiResponse.setItems(Collections.emptyList()); } - // 灏?AI 缁撴灉鍐欏叆 annotation_result锛圲PSERT 璇箟锛? writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems()); + // 将 AI 结果写入 annotation_result(UPSERT 语义) + writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems()); } - // ------------------------------------------------------------------ 鏇存柊缁撴灉 -- + // ------------------------------------------------------------------ 更新结果 -- /** - * 浜哄伐鏇存柊鏍囨敞缁撴灉锛堟暣浣撹鐩栵紝PUT 璇箟锛夈€? * - * @param taskId 浠诲姟 ID - * @param resultJson 鏂扮殑鏍囨敞缁撴灉 JSON 瀛楃涓? * @param principal 褰撳墠鐢ㄦ埛 + * 人工更新标注结果(整体覆盖,PUT 语义)。 + * + * @param taskId 任务 ID + * @param resultJson 新的标注结果 JSON 字符串 + * @param principal 当前用户 */ @Transactional public void updateResult(Long taskId, String resultJson, TokenPrincipal principal) { validateAndGetTask(taskId, principal.getCompanyId()); - // 鏍¢獙 JSON 鏍煎紡 + // 校验 JSON 格式 try { objectMapper.readTree(resultJson); } catch (Exception e) { - throw new BusinessException("INVALID_JSON", "鏍囨敞缁撴灉 JSON 鏍煎紡涓嶅悎娉?, HttpStatus.BAD_REQUEST); + throw new BusinessException("INVALID_JSON", "标注结果 JSON 格式不合法", HttpStatus.BAD_REQUEST); } int updated = resultMapper.updateResultJson(taskId, resultJson, principal.getCompanyId()); if (updated == 0) { - // 涓嶅瓨鍦ㄥ垯鏂板缓 + // 不存在则新建 AnnotationResult result = new AnnotationResult(); result.setTaskId(taskId); result.setCompanyId(principal.getCompanyId()); @@ -112,10 +123,11 @@ public class ExtractionService { } } - // ------------------------------------------------------------------ 鎻愪氦 -- + // ------------------------------------------------------------------ 提交 -- /** - * 鎻愪氦鎻愬彇缁撴灉锛圛N_PROGRESS 鈫?SUBMITTED锛夈€? */ + * 提交提取结果(IN_PROGRESS → SUBMITTED)。 + */ @Transactional public void submit(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); @@ -133,27 +145,32 @@ public class ExtractionService { principal.getUserId(), principal.getRole(), null); } - // ------------------------------------------------------------------ 瀹℃壒閫氳繃 -- + // ------------------------------------------------------------------ 审批通过 -- /** - * 瀹℃壒閫氳繃锛圫UBMITTED 鈫?APPROVED锛夈€? * - * 涓ら樁娈碉細 - * 1. 鍚屾浜嬪姟锛歩s_final=true锛岀姸鎬佹帹杩涳紝鍐欏巻鍙? * 2. 浜嬪姟鎻愪氦鍚庯紙AFTER_COMMIT锛夛細AI 鐢熸垚闂瓟瀵?鈫?training_dataset 鈫?QA 浠诲姟 鈫?source_data 鐘舵€? * - * 娉細AI 璋冪敤涓ョ鍦ㄦ浜嬪姟鍐呮墽琛屻€? */ + * 审批通过(SUBMITTED → APPROVED)。 + * + * 两阶段: + * 1. 同步事务:is_final=true,状态推进,写历史 + * 2. 事务提交后(AFTER_COMMIT):AI 生成问答对 → training_dataset → QA 任务 → source_data 状态 + * + * 注:AI 调用严禁在此事务内执行。 + */ @Transactional public void approve(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); - // 鑷鏍¢獙 + // 自审校验 if (principal.getUserId().equals(task.getClaimedBy())) { throw new BusinessException("SELF_REVIEW_FORBIDDEN", - "涓嶅厑璁稿鎵硅嚜宸辨彁浜ょ殑浠诲姟", HttpStatus.FORBIDDEN); + "不允许审批自己提交的任务", HttpStatus.FORBIDDEN); } StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED); - // 鏍囪涓烘渶缁堢粨鏋? taskMapper.update(null, new LambdaUpdateWrapper() + // 标记为最终结果 + taskMapper.update(null, new LambdaUpdateWrapper() .eq(AnnotationTask::getId, taskId) .set(AnnotationTask::getStatus, "APPROVED") .set(AnnotationTask::getIsFinal, true) @@ -163,30 +180,33 @@ public class ExtractionService { "SUBMITTED", "APPROVED", principal.getUserId(), principal.getRole(), null); - // 鑾峰彇璧勬枡淇℃伅锛岀敤浜庝簨浠? SourceData source = sourceDataMapper.selectById(task.getSourceId()); + // 获取资料信息,用于事件 + SourceData source = sourceDataMapper.selectById(task.getSourceId()); String sourceType = source != null ? source.getDataType() : "TEXT"; - // 鍙戝竷浜嬩欢锛園TransactionalEventListener(AFTER_COMMIT) 澶勭悊 AI 璋冪敤锛? eventPublisher.publishEvent(new ExtractionApprovedEvent( + // 发布事件(@TransactionalEventListener(AFTER_COMMIT) 处理 AI 调用) + eventPublisher.publishEvent(new ExtractionApprovedEvent( this, taskId, task.getSourceId(), sourceType, principal.getCompanyId(), principal.getUserId())); } - // ------------------------------------------------------------------ 椹冲洖 -- + // ------------------------------------------------------------------ 驳回 -- /** - * 椹冲洖鎻愬彇缁撴灉锛圫UBMITTED 鈫?REJECTED锛夈€? */ + * 驳回提取结果(SUBMITTED → REJECTED)。 + */ @Transactional public void reject(Long taskId, String reason, TokenPrincipal principal) { if (reason == null || reason.isBlank()) { - throw new BusinessException("REASON_REQUIRED", "椹冲洖鍘熷洜涓嶈兘涓虹┖", HttpStatus.BAD_REQUEST); + throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST); } AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); - // 鑷鏍¢獙 + // 自审校验 if (principal.getUserId().equals(task.getClaimedBy())) { throw new BusinessException("SELF_REVIEW_FORBIDDEN", - "涓嶅厑璁搁┏鍥炶嚜宸辨彁浜ょ殑浠诲姟", HttpStatus.FORBIDDEN); + "不允许驳回自己提交的任务", HttpStatus.FORBIDDEN); } StateValidator.assertTransition(TaskStatus.TRANSITIONS, @@ -202,10 +222,11 @@ public class ExtractionService { principal.getUserId(), principal.getRole(), reason); } - // ------------------------------------------------------------------ 鏌ヨ -- + // ------------------------------------------------------------------ 查询 -- /** - * 鑾峰彇褰撳墠鏍囨敞缁撴灉銆? */ + * 获取当前标注结果。 + */ public Map getResult(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); AnnotationResult result = resultMapper.selectByTaskId(taskId); @@ -220,14 +241,15 @@ public class ExtractionService { ); } - // ------------------------------------------------------------------ 绉佹湁宸ュ叿 -- + // ------------------------------------------------------------------ 私有工具 -- /** - * 鏍¢獙浠诲姟瀛樺湪鎬э紙澶氱鎴疯嚜鍔ㄨ繃婊わ級銆? */ + * 校验任务存在性(多租户自动过滤)。 + */ private AnnotationTask validateAndGetTask(Long taskId, Long companyId) { AnnotationTask task = taskMapper.selectById(taskId); if (task == null || !companyId.equals(task.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND); } return task; } @@ -244,7 +266,7 @@ public class ExtractionService { resultMapper.insert(result); } } catch (Exception e) { - log.error("鍐欏叆 AI 棰勬爣娉ㄧ粨鏋滃け璐? taskId={}", taskId, e); + log.error("写入 AI 预标注结果失败: taskId={}", taskId, e); } } } diff --git a/src/main/java/com/label/module/annotation/service/QaService.java b/src/main/java/com/label/module/annotation/service/QaService.java index a822cd1..ca7576a 100644 --- a/src/main/java/com/label/module/annotation/service/QaService.java +++ b/src/main/java/com/label/module/annotation/service/QaService.java @@ -6,12 +6,12 @@ import com.label.common.exception.BusinessException; import com.label.common.shiro.TokenPrincipal; import com.label.common.statemachine.StateValidator; import com.label.common.statemachine.TaskStatus; -import com.label.entity.TrainingDataset; -import com.label.mapper.TrainingDatasetMapper; -import com.label.entity.SourceData; -import com.label.mapper.SourceDataMapper; -import com.label.entity.AnnotationTask; -import com.label.mapper.AnnotationTaskMapper; +import com.label.module.annotation.entity.TrainingDataset; +import com.label.module.annotation.mapper.TrainingDatasetMapper; +import com.label.module.source.entity.SourceData; +import com.label.module.source.mapper.SourceDataMapper; +import com.label.module.task.entity.AnnotationTask; +import com.label.module.task.mapper.AnnotationTaskMapper; import com.label.module.task.service.TaskClaimService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,9 +25,13 @@ import java.util.List; import java.util.Map; /** - * 闂瓟鐢熸垚闃舵鏍囨敞鏈嶅姟锛氭煡璇㈠€欓€夐棶绛斿銆佹洿鏂般€佹彁浜ゃ€佸鎵广€侀┏鍥炪€? * - * 鍏抽敭璁捐锛? * - QA 闃舵鏃?AI 璋冪敤锛堝€欓€夐棶绛斿宸茬敱 ExtractionApprovedEventListener 鐢熸垚锛? * - approve() 鍚屼竴浜嬪姟鍐呭畬鎴愶細training_dataset 鈫?APPROVED銆乼ask 鈫?APPROVED銆乻ource_data 鈫?APPROVED - * - reject() 娓呴櫎鍊欓€夐棶绛斿锛坉eleteByTaskId锛夛紝source_data 淇濇寔 QA_REVIEW 鐘舵€? */ + * 问答生成阶段标注服务:查询候选问答对、更新、提交、审批、驳回。 + * + * 关键设计: + * - QA 阶段无 AI 调用(候选问答对已由 ExtractionApprovedEventListener 生成) + * - approve() 同一事务内完成:training_dataset → APPROVED、task → APPROVED、source_data → APPROVED + * - reject() 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态 + */ @Slf4j @Service @RequiredArgsConstructor @@ -39,10 +43,11 @@ public class QaService { private final TaskClaimService taskClaimService; private final ObjectMapper objectMapper; - // ------------------------------------------------------------------ 鏌ヨ -- + // ------------------------------------------------------------------ 查询 -- /** - * 鑾峰彇鍊欓€夐棶绛斿锛堜粠 training_dataset.glm_format_json 瑙f瀽锛夈€? */ + * 获取候选问答对(从 training_dataset.glm_format_json 解析)。 + */ public Map getResult(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); TrainingDataset dataset = getDataset(taskId); @@ -60,7 +65,7 @@ public class QaService { items = (List) conversations; } } catch (Exception e) { - log.warn("瑙f瀽 QA JSON 澶辫触锛坱askId={}锛夛細{}", taskId, e.getMessage()); + log.warn("解析 QA JSON 失败(taskId={}):{}", taskId, e.getMessage()); } } @@ -71,26 +76,27 @@ public class QaService { ); } - // ------------------------------------------------------------------ 鏇存柊 -- + // ------------------------------------------------------------------ 更新 -- /** - * 鏁翠綋瑕嗙洊闂瓟瀵癸紙PUT 璇箟锛夈€? * - * @param taskId 浠诲姟 ID - * @param body 鍖呭惈 items 鏁扮粍鐨?JSON锛屾牸寮忥細{"items": [...]} - * @param principal 褰撳墠鐢ㄦ埛 + * 整体覆盖问答对(PUT 语义)。 + * + * @param taskId 任务 ID + * @param body 包含 items 数组的 JSON,格式:{"items": [...]} + * @param principal 当前用户 */ @Transactional public void updateResult(Long taskId, String body, TokenPrincipal principal) { validateAndGetTask(taskId, principal.getCompanyId()); - // 鏍¢獙 JSON 鏍煎紡 + // 校验 JSON 格式 try { objectMapper.readTree(body); } catch (Exception e) { - throw new BusinessException("INVALID_JSON", "璇锋眰浣?JSON 鏍煎紡涓嶅悎娉?, HttpStatus.BAD_REQUEST); + throw new BusinessException("INVALID_JSON", "请求体 JSON 格式不合法", HttpStatus.BAD_REQUEST); } - // 灏?items 鏍煎紡鍖呰涓?GLM 鏍煎紡锛歿"conversations": items} + // 将 items 格式包装为 GLM 格式:{"conversations": items} String glmJson; try { @SuppressWarnings("unchecked") @@ -108,7 +114,7 @@ public class QaService { .set(TrainingDataset::getGlmFormatJson, glmJson) .set(TrainingDataset::getUpdatedAt, LocalDateTime.now())); } else { - // 鑻?training_dataset 涓嶅瓨鍦紙寮傚父鎯呭喌锛夛紝鑷姩鍒涘缓 + // 若 training_dataset 不存在(异常情况),自动创建 TrainingDataset newDataset = new TrainingDataset(); newDataset.setCompanyId(principal.getCompanyId()); newDataset.setTaskId(taskId); @@ -121,10 +127,11 @@ public class QaService { } } - // ------------------------------------------------------------------ 鎻愪氦 -- + // ------------------------------------------------------------------ 提交 -- /** - * 鎻愪氦 QA 缁撴灉锛圛N_PROGRESS 鈫?SUBMITTED锛夈€? */ + * 提交 QA 结果(IN_PROGRESS → SUBMITTED)。 + */ @Transactional public void submit(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); @@ -142,71 +149,78 @@ public class QaService { principal.getUserId(), principal.getRole(), null); } - // ------------------------------------------------------------------ 瀹℃壒閫氳繃 -- + // ------------------------------------------------------------------ 审批通过 -- /** - * 瀹℃壒閫氳繃锛圫UBMITTED 鈫?APPROVED锛夈€? * - * 鍚屼竴浜嬪姟锛? * 1. 鏍¢獙浠诲姟锛堝厛浜庝竴鍒?DB 鍐欏叆锛? * 2. 鑷鏍¢獙 + * 审批通过(SUBMITTED → APPROVED)。 + * + * 同一事务: + * 1. 校验任务(先于一切 DB 写入) + * 2. 自审校验 * 3. StateValidator - * 4. training_dataset 鈫?APPROVED - * 5. annotation_task 鈫?APPROVED + is_final=true + completedAt - * 6. source_data 鈫?APPROVED锛堟暣鏉℃祦姘寸嚎瀹屾垚锛? * 7. 鍐欎换鍔″巻鍙? */ + * 4. training_dataset → APPROVED + * 5. annotation_task → APPROVED + is_final=true + completedAt + * 6. source_data → APPROVED(整条流水线完成) + * 7. 写任务历史 + */ @Transactional public void approve(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); - // 鑷鏍¢獙 + // 自审校验 if (principal.getUserId().equals(task.getClaimedBy())) { throw new BusinessException("SELF_REVIEW_FORBIDDEN", - "涓嶅厑璁稿鎵硅嚜宸辨彁浜ょ殑浠诲姟", HttpStatus.FORBIDDEN); + "不允许审批自己提交的任务", HttpStatus.FORBIDDEN); } StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED); - // training_dataset 鈫?APPROVED + // training_dataset → APPROVED datasetMapper.approveByTaskId(taskId, principal.getCompanyId()); - // annotation_task 鈫?APPROVED + is_final=true + // annotation_task → APPROVED + is_final=true taskMapper.update(null, new LambdaUpdateWrapper() .eq(AnnotationTask::getId, taskId) .set(AnnotationTask::getStatus, "APPROVED") .set(AnnotationTask::getIsFinal, true) .set(AnnotationTask::getCompletedAt, LocalDateTime.now())); - // source_data 鈫?APPROVED锛堟暣鏉℃祦姘寸嚎缁堟€侊級 + // source_data → APPROVED(整条流水线终态) sourceDataMapper.updateStatus(task.getSourceId(), "APPROVED", principal.getCompanyId()); taskClaimService.insertHistory(taskId, principal.getCompanyId(), "SUBMITTED", "APPROVED", principal.getUserId(), principal.getRole(), null); - log.info("QA 瀹℃壒閫氳繃锛屾暣鏉℃祦姘寸嚎瀹屾垚: taskId={}, sourceId={}", taskId, task.getSourceId()); + log.info("QA 审批通过,整条流水线完成: taskId={}, sourceId={}", taskId, task.getSourceId()); } - // ------------------------------------------------------------------ 椹冲洖 -- + // ------------------------------------------------------------------ 驳回 -- /** - * 椹冲洖 QA 缁撴灉锛圫UBMITTED 鈫?REJECTED锛夈€? * - * 娓呴櫎鍊欓€夐棶绛斿锛坉eleteByTaskId锛夛紝source_data 淇濇寔 QA_REVIEW 鐘舵€佷笉鍙樸€? */ + * 驳回 QA 结果(SUBMITTED → REJECTED)。 + * + * 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态不变。 + */ @Transactional public void reject(Long taskId, String reason, TokenPrincipal principal) { if (reason == null || reason.isBlank()) { - throw new BusinessException("REASON_REQUIRED", "椹冲洖鍘熷洜涓嶈兘涓虹┖", HttpStatus.BAD_REQUEST); + throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST); } AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); - // 鑷鏍¢獙 + // 自审校验 if (principal.getUserId().equals(task.getClaimedBy())) { throw new BusinessException("SELF_REVIEW_FORBIDDEN", - "涓嶅厑璁搁┏鍥炶嚜宸辨彁浜ょ殑浠诲姟", HttpStatus.FORBIDDEN); + "不允许驳回自己提交的任务", HttpStatus.FORBIDDEN); } StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.valueOf(task.getStatus()), TaskStatus.REJECTED); - // 娓呴櫎鍊欓€夐棶绛斿 + // 清除候选问答对 datasetMapper.deleteByTaskId(taskId, principal.getCompanyId()); taskMapper.update(null, new LambdaUpdateWrapper() @@ -219,12 +233,12 @@ public class QaService { principal.getUserId(), principal.getRole(), reason); } - // ------------------------------------------------------------------ 绉佹湁宸ュ叿 -- + // ------------------------------------------------------------------ 私有工具 -- private AnnotationTask validateAndGetTask(Long taskId, Long companyId) { AnnotationTask task = taskMapper.selectById(taskId); if (task == null || !companyId.equals(task.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND); } return task; } diff --git a/src/main/java/com/label/module/config/controller/SysConfigController.java b/src/main/java/com/label/module/config/controller/SysConfigController.java index f0271ab..e53aebd 100644 --- a/src/main/java/com/label/module/config/controller/SysConfigController.java +++ b/src/main/java/com/label/module/config/controller/SysConfigController.java @@ -2,7 +2,7 @@ package com.label.module.config.controller; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; -import com.label.entity.SysConfig; +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; @@ -15,9 +15,12 @@ import java.util.List; import java.util.Map; /** - * 绯荤粺閰嶇疆鎺ュ彛锛? 涓鐐癸紝鍧囬渶 ADMIN 鏉冮檺锛夈€? * - * GET /api/config 鈥?鏌ヨ褰撳墠鍏徃鎵€鏈夊彲瑙侀厤缃紙鍏徃涓撳睘 + 鍏ㄥ眬榛樿鍚堝苟锛? * PUT /api/config/{key} 鈥?鏇存柊/鍒涘缓鍏徃涓撳睘閰嶇疆锛圲PSERT锛? */ -@Tag(name = "绯荤粺閰嶇疆", description = "鍏ㄥ眬鍜屽叕鍙哥骇绯荤粺閰嶇疆绠$悊") + * 系统配置接口(2 个端点,均需 ADMIN 权限)。 + * + * GET /api/config — 查询当前公司所有可见配置(公司专属 + 全局默认合并) + * PUT /api/config/{key} — 更新/创建公司专属配置(UPSERT) + */ +@Tag(name = "系统配置", description = "全局和公司级系统配置管理") @RestController @RequiredArgsConstructor public class SysConfigController { @@ -25,10 +28,13 @@ public class SysConfigController { private final SysConfigService sysConfigService; /** - * GET /api/config 鈥?鏌ヨ鍚堝苟鍚庣殑閰嶇疆鍒楄〃銆? * - * 鍝嶅簲涓瘡鏉¢厤缃惈 scope 瀛楁锛? * - "COMPANY"锛氬綋鍓嶅叕鍙镐笓灞為厤缃紙浼樺厛鐢熸晥锛? * - "GLOBAL"锛氬叏灞€榛樿閰嶇疆锛堝叕鍙告湭瑕嗙洊鏃剁敓鏁堬級 + * GET /api/config — 查询合并后的配置列表。 + * + * 响应中每条配置含 scope 字段: + * - "COMPANY":当前公司专属配置(优先生效) + * - "GLOBAL":全局默认配置(公司未覆盖时生效) */ - @Operation(summary = "鏌ヨ鍚堝苟鍚庣殑绯荤粺閰嶇疆") + @Operation(summary = "查询合并后的系统配置") @GetMapping("/api/config") @RequiresRoles("ADMIN") public Result>> listConfig(HttpServletRequest request) { @@ -37,10 +43,11 @@ public class SysConfigController { } /** - * PUT /api/config/{key} 鈥?UPSERT 鍏徃涓撳睘閰嶇疆銆? * + * PUT /api/config/{key} — UPSERT 公司专属配置。 + * * Body: { "value": "...", "description": "..." } */ - @Operation(summary = "鏇存柊鎴栧垱寤哄叕鍙镐笓灞為厤缃?) + @Operation(summary = "更新或创建公司专属配置") @PutMapping("/api/config/{key}") @RequiresRoles("ADMIN") public Result updateConfig(@PathVariable String key, diff --git a/src/main/java/com/label/module/config/entity/SysConfig.java b/src/main/java/com/label/module/config/entity/SysConfig.java new file mode 100644 index 0000000..f28e4fb --- /dev/null +++ b/src/main/java/com/label/module/config/entity/SysConfig.java @@ -0,0 +1,41 @@ +package com.label.module.config.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 系统配置实体,对应 sys_config 表。 + * + * company_id 为 NULL 时表示全局默认配置,非 NULL 时表示租户专属配置(优先级更高)。 + * 注:sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES,不走多租户过滤器。 + */ +@Data +@TableName("sys_config") +public class SysConfig { + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 所属公司 ID(NULL = 全局默认配置;非 NULL = 租户专属配置)。 + * 注意:不能用 @TableField(exist = false) 排除,必须保留以支持 company_id IS NULL 查询。 + */ + private Long companyId; + + /** 配置键 */ + private String configKey; + + /** 配置值 */ + private String configValue; + + /** 配置说明 */ + private String description; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/mapper/SysConfigMapper.java b/src/main/java/com/label/module/config/mapper/SysConfigMapper.java similarity index 50% rename from src/main/java/com/label/mapper/SysConfigMapper.java rename to src/main/java/com/label/module/config/mapper/SysConfigMapper.java index b2c87b9..c63c5c9 100644 --- a/src/main/java/com/label/mapper/SysConfigMapper.java +++ b/src/main/java/com/label/module/config/mapper/SysConfigMapper.java @@ -1,33 +1,36 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.SysConfig; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -import java.util.List; - -/** - * sys_config 鐞?Mapper閵? * - * 濞夈劍鍓伴敍姝磞s_config 瀹告彃濮為崗?MybatisPlusConfig.IGNORED_TABLES閿涘奔绗夌挧鏉款樋缁夌喐鍩涙潻鍥ㄦ姢閸n煉绱? - * 闂団偓閹靛濮╂导鐘插弳 companyId 鏉╂稖顢戞潻鍥ㄦ姢閵? */ -@Mapper -public interface SysConfigMapper extends BaseMapper { - - /** 閺屻儴顕楅幐鍥х暰閸忣剙寰冮惃鍕帳缂冾噯绱欑粔鐔稿煕娑撴挸鐫橀敍灞肩喘閸忓牏楠囨姗堢礆 */ - @Select("SELECT * FROM sys_config WHERE company_id = #{companyId} AND config_key = #{configKey}") - SysConfig selectByCompanyAndKey(@Param("companyId") Long companyId, - @Param("configKey") String configKey); - - /** 閺屻儴顕楅崗銊ョ湰姒涙顓婚柊宥囩枂閿涘潏ompany_id IS NULL閿?*/ - @Select("SELECT * FROM sys_config WHERE company_id IS NULL AND config_key = #{configKey}") - SysConfig selectGlobalByKey(@Param("configKey") String configKey); - - /** - * 閺屻儴顕楅幐鍥х暰閸忣剙寰冮幍鈧張澶婂讲鐟欎線鍘ょ純顕嗙礄閸忣剙寰冩稉鎾崇潣 + 閸忋劌鐪妯款吇閿涘绱? - * 閹?company_id DESC NULLS LAST 閹烘帒绨敍鍫濆彆閸欓晲绗撶仦鐐扮喘閸忓牅绨崗銊ョ湰姒涙顓婚敍澶堚偓? */ - @Select("SELECT * FROM sys_config WHERE company_id = #{companyId} OR company_id IS NULL " + - "ORDER BY company_id DESC NULLS LAST") - List selectAllForCompany(@Param("companyId") Long companyId); -} +package com.label.module.config.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.config.entity.SysConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * sys_config 表 Mapper。 + * + * 注意:sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES,不走多租户过滤器, + * 需手动传入 companyId 进行过滤。 + */ +@Mapper +public interface SysConfigMapper extends BaseMapper { + + /** 查询指定公司的配置(租户专属,优先级高) */ + @Select("SELECT * FROM sys_config WHERE company_id = #{companyId} AND config_key = #{configKey}") + SysConfig selectByCompanyAndKey(@Param("companyId") Long companyId, + @Param("configKey") String configKey); + + /** 查询全局默认配置(company_id IS NULL) */ + @Select("SELECT * FROM sys_config WHERE company_id IS NULL AND config_key = #{configKey}") + SysConfig selectGlobalByKey(@Param("configKey") String configKey); + + /** + * 查询指定公司所有可见配置(公司专属 + 全局默认), + * 按 company_id DESC NULLS LAST 排序(公司专属优先于全局默认)。 + */ + @Select("SELECT * FROM sys_config WHERE company_id = #{companyId} OR company_id IS NULL " + + "ORDER BY company_id DESC NULLS LAST") + List selectAllForCompany(@Param("companyId") Long companyId); +} diff --git a/src/main/java/com/label/module/config/service/SysConfigService.java b/src/main/java/com/label/module/config/service/SysConfigService.java index 4323dcd..5e0d01e 100644 --- a/src/main/java/com/label/module/config/service/SysConfigService.java +++ b/src/main/java/com/label/module/config/service/SysConfigService.java @@ -1,8 +1,8 @@ package com.label.module.config.service; import com.label.common.exception.BusinessException; -import com.label.entity.SysConfig; -import com.label.mapper.SysConfigMapper; +import com.label.module.config.entity.SysConfig; +import com.label.module.config.mapper.SysConfigMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -14,17 +14,20 @@ import java.util.*; import java.util.stream.Collectors; /** - * 绯荤粺閰嶇疆鏈嶅姟銆? * - * 閰嶇疆鏌ユ壘浼樺厛绾э細鍏徃涓撳睘锛坈ompany_id = N锛? 鍏ㄥ眬榛樿锛坈ompany_id IS NULL锛夈€? * - * get() 鈥?鎸変紭鍏堢骇杩斿洖鍗曚釜閰嶇疆鍊? * list() 鈥?杩斿洖鍚堝苟鍚庣殑閰嶇疆鍒楄〃锛堝叕鍙镐笓灞炶鐩栧悓鍚嶅叏灞€閰嶇疆锛夛紝闄?scope 瀛楁 - * update() 鈥?浠ュ叕鍙镐笓灞為厤缃繘琛?UPSERT锛堜粎鍏佽宸茬煡閰嶇疆閿級 + * 系统配置服务。 + * + * 配置查找优先级:公司专属(company_id = N)> 全局默认(company_id IS NULL)。 + * + * get() — 按优先级返回单个配置值 + * list() — 返回合并后的配置列表(公司专属覆盖同名全局配置),附 scope 字段 + * update() — 以公司专属配置进行 UPSERT(仅允许已知配置键) */ @Slf4j @Service @RequiredArgsConstructor public class SysConfigService { - /** 绯荤粺宸茬煡閰嶇疆閿櫧鍚嶅崟锛堥槻姝㈠啓鍏ユ湭鐭ラ敭锛?*/ + /** 系统已知配置键白名单(防止写入未知键) */ private static final Set KNOWN_KEYS = Set.of( "token_ttl_seconds", "model_default", @@ -33,36 +36,42 @@ public class SysConfigService { private final SysConfigMapper configMapper; - // ------------------------------------------------------------------ 鏌ヨ鍗曞€?-- + // ------------------------------------------------------------------ 查询单值 -- /** - * 鎸変紭鍏堢骇鑾峰彇閰嶇疆鍊硷細鍏徃涓撳睘浼樺厛锛屽惁鍒欏洖閫€鍏ㄥ眬榛樿銆? * - * @param configKey 閰嶇疆閿? * @param companyId 褰撳墠鍏徃 ID - * @return 閰嶇疆鍊硷紙涓嶅瓨鍦ㄦ椂杩斿洖 null锛? */ + * 按优先级获取配置值:公司专属优先,否则回退全局默认。 + * + * @param configKey 配置键 + * @param companyId 当前公司 ID + * @return 配置值(不存在时返回 null) + */ public String get(String configKey, Long companyId) { - // 鍏堟煡鍏徃涓撳睘 + // 先查公司专属 SysConfig company = configMapper.selectByCompanyAndKey(companyId, configKey); if (company != null) { return company.getConfigValue(); } - // 鍥為€€鍏ㄥ眬榛樿 + // 回退全局默认 SysConfig global = configMapper.selectGlobalByKey(configKey); return global != null ? global.getConfigValue() : null; } - // ------------------------------------------------------------------ 鏌ヨ鍒楄〃 -- + // ------------------------------------------------------------------ 查询列表 -- /** - * 杩斿洖褰撳墠鍏徃鎵€鏈夊彲瑙侀厤缃紙鍏徃涓撳睘 + 鍏ㄥ眬榛樿鍚堝苟锛夛紝 - * 闄勫姞 scope 瀛楁锛?COMPANY" / "GLOBAL"锛夋爣璇嗘潵婧愩€? * - * @param companyId 褰撳墠鍏徃 ID - * @return 閰嶇疆鍒楄〃锛堝惈 scope锛? */ + * 返回当前公司所有可见配置(公司专属 + 全局默认合并), + * 附加 scope 字段("COMPANY" / "GLOBAL")标识来源。 + * + * @param companyId 当前公司 ID + * @return 配置列表(含 scope) + */ public List> list(Long companyId) { List all = configMapper.selectAllForCompany(companyId); - // 鎸?configKey 鍒嗙粍锛屽叕鍙镐笓灞炰紭鍏堬紙鎺掑簭淇濊瘉鍏徃涓撳睘鍦ㄥ墠锛? Map merged = new LinkedHashMap<>(); + // 按 configKey 分组,公司专属优先(排序保证公司专属在前) + Map merged = new LinkedHashMap<>(); for (SysConfig cfg : all) { - // 鐢变簬 SQL 鎸?company_id DESC NULLS LAST 鎺掑簭锛屽叕鍙镐笓灞炲厛鍑虹幇锛岀洿鎺?putIfAbsent + // 由于 SQL 按 company_id DESC NULLS LAST 排序,公司专属先出现,直接 putIfAbsent merged.putIfAbsent(cfg.getConfigKey(), cfg); } @@ -80,28 +89,33 @@ public class SysConfigService { .collect(Collectors.toList()); } - // ------------------------------------------------------------------ 鏇存柊閰嶇疆 -- + // ------------------------------------------------------------------ 更新配置 -- /** - * 鏇存柊鍏徃涓撳睘閰嶇疆锛圲PSERT锛夈€? * - * 浠呭厑璁?KNOWN_KEYS 涓殑閰嶇疆閿紝闃叉鍐欏叆鏈畾涔夌殑閰嶇疆椤广€? * - * @param configKey 閰嶇疆閿? * @param value 鏂伴厤缃€? * @param description 閰嶇疆璇存槑锛堝彲閫夛級 - * @param companyId 褰撳墠鍏徃 ID + * 更新公司专属配置(UPSERT)。 + * + * 仅允许 KNOWN_KEYS 中的配置键,防止写入未定义的配置项。 + * + * @param configKey 配置键 + * @param value 新配置值 + * @param description 配置说明(可选) + * @param companyId 当前公司 ID */ @Transactional public SysConfig update(String configKey, String value, String description, Long companyId) { if (!KNOWN_KEYS.contains(configKey)) { throw new BusinessException("UNKNOWN_CONFIG_KEY", - "鏈煡閰嶇疆閿? " + configKey, HttpStatus.BAD_REQUEST); + "未知配置键: " + configKey, HttpStatus.BAD_REQUEST); } if (value == null || value.isBlank()) { throw new BusinessException("INVALID_CONFIG_VALUE", - "閰嶇疆鍊间笉鑳戒负绌?, HttpStatus.BAD_REQUEST); + "配置值不能为空", HttpStatus.BAD_REQUEST); } - // UPSERT锛氬鍏徃涓撳睘閰嶇疆宸插瓨鍦ㄥ垯鏇存柊锛屽惁鍒欐彃鍏? SysConfig existing = configMapper.selectByCompanyAndKey(companyId, configKey); + // UPSERT:如公司专属配置已存在则更新,否则插入 + SysConfig existing = configMapper.selectByCompanyAndKey(companyId, configKey); if (existing != null) { existing.setConfigValue(value); if (description != null && !description.isBlank()) { @@ -109,7 +123,7 @@ public class SysConfigService { } existing.setUpdatedAt(LocalDateTime.now()); configMapper.updateById(existing); - log.info("鍏徃閰嶇疆宸叉洿鏂? companyId={}, key={}, value={}", companyId, configKey, value); + log.info("公司配置已更新: companyId={}, key={}, value={}", companyId, configKey, value); return existing; } else { SysConfig cfg = new SysConfig(); @@ -118,7 +132,7 @@ public class SysConfigService { cfg.setConfigValue(value); cfg.setDescription(description); configMapper.insert(cfg); - log.info("鍏徃閰嶇疆宸插垱寤? companyId={}, key={}, value={}", companyId, configKey, value); + log.info("公司配置已创建: companyId={}, key={}, value={}", companyId, configKey, value); return cfg; } } diff --git a/src/main/java/com/label/module/export/controller/ExportController.java b/src/main/java/com/label/module/export/controller/ExportController.java index 4a84c52..b60e7a5 100644 --- a/src/main/java/com/label/module/export/controller/ExportController.java +++ b/src/main/java/com/label/module/export/controller/ExportController.java @@ -3,8 +3,8 @@ 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.entity.TrainingDataset; -import com.label.entity.ExportBatch; +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; @@ -19,8 +19,9 @@ import java.util.List; import java.util.Map; /** - * 璁粌鏁版嵁瀵煎嚭涓庡井璋冩帴鍙o紙5 涓鐐癸紝鍏ㄩ儴 ADMIN 鏉冮檺锛夈€? */ -@Tag(name = "瀵煎嚭绠$悊", description = "璁粌鏍锋湰鏌ヨ銆佸鍑烘壒娆″拰寰皟浠诲姟") + * 训练数据导出与微调接口(5 个端点,全部 ADMIN 权限)。 + */ +@Tag(name = "导出管理", description = "训练样本查询、导出批次和微调任务") @RestController @RequiredArgsConstructor public class ExportController { @@ -28,8 +29,8 @@ public class ExportController { private final ExportService exportService; private final FinetuneService finetuneService; - /** GET /api/training/samples 鈥?鍒嗛〉鏌ヨ宸插鎵瑰彲瀵煎嚭鏍锋湰 */ - @Operation(summary = "鍒嗛〉鏌ヨ鍙鍑鸿缁冩牱鏈?) + /** GET /api/training/samples — 分页查询已审批可导出样本 */ + @Operation(summary = "分页查询可导出训练样本") @GetMapping("/api/training/samples") @RequiresRoles("ADMIN") public Result> listSamples( @@ -41,8 +42,8 @@ public class ExportController { return Result.success(exportService.listSamples(page, pageSize, sampleType, exported, principal(request))); } - /** POST /api/export/batch 鈥?鍒涘缓瀵煎嚭鎵规 */ - @Operation(summary = "鍒涘缓瀵煎嚭鎵规") + /** POST /api/export/batch — 创建导出批次 */ + @Operation(summary = "创建导出批次") @PostMapping("/api/export/batch") @RequiresRoles("ADMIN") @ResponseStatus(HttpStatus.CREATED) @@ -56,8 +57,8 @@ public class ExportController { return Result.success(exportService.createBatch(sampleIds, principal(request))); } - /** POST /api/export/{batchId}/finetune 鈥?鎻愪氦寰皟浠诲姟 */ - @Operation(summary = "鎻愪氦寰皟浠诲姟") + /** POST /api/export/{batchId}/finetune — 提交微调任务 */ + @Operation(summary = "提交微调任务") @PostMapping("/api/export/{batchId}/finetune") @RequiresRoles("ADMIN") public Result> triggerFinetune(@PathVariable Long batchId, @@ -65,8 +66,8 @@ public class ExportController { return Result.success(finetuneService.trigger(batchId, principal(request))); } - /** GET /api/export/{batchId}/status 鈥?鏌ヨ寰皟鐘舵€?*/ - @Operation(summary = "鏌ヨ寰皟鐘舵€?) + /** GET /api/export/{batchId}/status — 查询微调状态 */ + @Operation(summary = "查询微调状态") @GetMapping("/api/export/{batchId}/status") @RequiresRoles("ADMIN") public Result> getFinetuneStatus(@PathVariable Long batchId, @@ -74,8 +75,8 @@ public class ExportController { return Result.success(finetuneService.getStatus(batchId, principal(request))); } - /** GET /api/export/list 鈥?鍒嗛〉鏌ヨ瀵煎嚭鎵规鍒楄〃 */ - @Operation(summary = "鍒嗛〉鏌ヨ瀵煎嚭鎵规") + /** GET /api/export/list — 分页查询导出批次列表 */ + @Operation(summary = "分页查询导出批次") @GetMapping("/api/export/list") @RequiresRoles("ADMIN") public Result> listBatches( diff --git a/src/main/java/com/label/entity/ExportBatch.java b/src/main/java/com/label/module/export/entity/ExportBatch.java similarity index 50% rename from src/main/java/com/label/entity/ExportBatch.java rename to src/main/java/com/label/module/export/entity/ExportBatch.java index e0bc982..d7447b0 100644 --- a/src/main/java/com/label/entity/ExportBatch.java +++ b/src/main/java/com/label/module/export/entity/ExportBatch.java @@ -1,43 +1,44 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * 瀵煎嚭鎵规瀹炰綋锛屽搴?export_batch 琛ㄣ€? * - * finetuneStatus 鍙栧€硷細NOT_STARTED / RUNNING / COMPLETED / FAILED - */ -@Data -@TableName("export_batch") -public class ExportBatch { - - @TableId(type = IdType.AUTO) - private Long id; - - /** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/ - private Long companyId; - - /** 鎵规鍞竴鏍囪瘑锛圲UID锛孌B 榛樿 gen_random_uuid()锛?*/ - private UUID batchUuid; - - /** 鏈壒娆℃牱鏈暟閲?*/ - private Integer sampleCount; - - /** 瀵煎嚭 JSONL 鐨?RustFS 璺緞 */ - private String datasetFilePath; - - /** GLM fine-tune 浠诲姟 ID锛堟彁浜ゅ井璋冨悗濉啓锛?*/ - private String glmJobId; - - /** 寰皟浠诲姟鐘舵€侊細NOT_STARTED / RUNNING / COMPLETED / FAILED */ - private String finetuneStatus; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} +package com.label.module.export.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 导出批次实体,对应 export_batch 表。 + * + * finetuneStatus 取值:NOT_STARTED / RUNNING / COMPLETED / FAILED + */ +@Data +@TableName("export_batch") +public class ExportBatch { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属公司(多租户键) */ + private Long companyId; + + /** 批次唯一标识(UUID,DB 默认 gen_random_uuid()) */ + private UUID batchUuid; + + /** 本批次样本数量 */ + private Integer sampleCount; + + /** 导出 JSONL 的 RustFS 路径 */ + private String datasetFilePath; + + /** GLM fine-tune 任务 ID(提交微调后填写) */ + private String glmJobId; + + /** 微调任务状态:NOT_STARTED / RUNNING / COMPLETED / FAILED */ + private String finetuneStatus; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/mapper/ExportBatchMapper.java b/src/main/java/com/label/module/export/mapper/ExportBatchMapper.java similarity index 60% rename from src/main/java/com/label/mapper/ExportBatchMapper.java rename to src/main/java/com/label/module/export/mapper/ExportBatchMapper.java index 18a0da1..acbb1d8 100644 --- a/src/main/java/com/label/mapper/ExportBatchMapper.java +++ b/src/main/java/com/label/module/export/mapper/ExportBatchMapper.java @@ -1,28 +1,31 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.ExportBatch; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Update; - -/** - * export_batch 鐞?Mapper閵? */ -@Mapper -public interface ExportBatchMapper extends BaseMapper { - - /** - * 閺囧瓨鏌婂顔跨殶娴犺濮熸穱鈩冧紖閿涘潛lm_job_id + finetune_status閿涘鈧? * - * @param id 閹佃顐?ID - * @param glmJobId GLM fine-tune 娴犺濮?ID - * @param finetuneStatus 閺傛壆濮搁幀? * @param companyId 瑜版挸澧犵粔鐔稿煕 - * @return 瑜板崬鎼风悰灞炬殶 - */ - @Update("UPDATE export_batch SET glm_job_id = #{glmJobId}, " + - "finetune_status = #{finetuneStatus}, updated_at = NOW() " + - "WHERE id = #{id} AND company_id = #{companyId}") - int updateFinetuneInfo(@Param("id") Long id, - @Param("glmJobId") String glmJobId, - @Param("finetuneStatus") String finetuneStatus, - @Param("companyId") Long companyId); -} +package com.label.module.export.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.export.entity.ExportBatch; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** + * export_batch 表 Mapper。 + */ +@Mapper +public interface ExportBatchMapper extends BaseMapper { + + /** + * 更新微调任务信息(glm_job_id + finetune_status)。 + * + * @param id 批次 ID + * @param glmJobId GLM fine-tune 任务 ID + * @param finetuneStatus 新状态 + * @param companyId 当前租户 + * @return 影响行数 + */ + @Update("UPDATE export_batch SET glm_job_id = #{glmJobId}, " + + "finetune_status = #{finetuneStatus}, updated_at = NOW() " + + "WHERE id = #{id} AND company_id = #{companyId}") + int updateFinetuneInfo(@Param("id") Long id, + @Param("glmJobId") String glmJobId, + @Param("finetuneStatus") String finetuneStatus, + @Param("companyId") Long companyId); +} diff --git a/src/main/java/com/label/module/export/service/ExportService.java b/src/main/java/com/label/module/export/service/ExportService.java index 7d1c537..75361fc 100644 --- a/src/main/java/com/label/module/export/service/ExportService.java +++ b/src/main/java/com/label/module/export/service/ExportService.java @@ -7,10 +7,10 @@ import com.label.common.exception.BusinessException; import com.label.common.result.PageResult; import com.label.common.shiro.TokenPrincipal; import com.label.common.storage.RustFsClient; -import com.label.entity.TrainingDataset; -import com.label.mapper.TrainingDatasetMapper; -import com.label.entity.ExportBatch; -import com.label.mapper.ExportBatchMapper; +import com.label.module.annotation.entity.TrainingDataset; +import com.label.module.annotation.mapper.TrainingDatasetMapper; +import com.label.module.export.entity.ExportBatch; +import com.label.module.export.mapper.ExportBatchMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -26,9 +26,15 @@ import java.util.UUID; import java.util.stream.Collectors; /** - * 璁粌鏁版嵁瀵煎嚭鏈嶅姟銆? * - * createBatch() 姝ラ锛? * 1. 鏍¢獙 sampleIds 闈炵┖锛圗MPTY_SAMPLES 400锛? * 2. 鏌ヨ training_dataset锛屾牎楠屽叏閮ㄤ负 APPROVED锛圛NVALID_SAMPLES 400锛? * 3. 鐢熸垚 JSONL锛堟瘡琛屼竴涓?glm_format_json锛? * 4. 涓婁紶 RustFS锛坆ucket: finetune-export, key: export/{batchUuid}.jsonl锛? * 5. 鎻掑叆 export_batch 璁板綍 - * 6. 鎵归噺鏇存柊 training_dataset.export_batch_id + exported_at + * 训练数据导出服务。 + * + * createBatch() 步骤: + * 1. 校验 sampleIds 非空(EMPTY_SAMPLES 400) + * 2. 查询 training_dataset,校验全部为 APPROVED(INVALID_SAMPLES 400) + * 3. 生成 JSONL(每行一个 glm_format_json) + * 4. 上传 RustFS(bucket: finetune-export, key: export/{batchUuid}.jsonl) + * 5. 插入 export_batch 记录 + * 6. 批量更新 training_dataset.export_batch_id + exported_at */ @Slf4j @Service @@ -41,39 +47,42 @@ public class ExportService { private final TrainingDatasetMapper datasetMapper; private final RustFsClient rustFsClient; - // ------------------------------------------------------------------ 鍒涘缓鎵规 -- + // ------------------------------------------------------------------ 创建批次 -- /** - * 鍒涘缓瀵煎嚭鎵规銆? * - * @param sampleIds 寰呭鍑虹殑 training_dataset ID 鍒楄〃 - * @param principal 褰撳墠鐢ㄦ埛 - * @return 鏂板缓鐨?ExportBatch + * 创建导出批次。 + * + * @param sampleIds 待导出的 training_dataset ID 列表 + * @param principal 当前用户 + * @return 新建的 ExportBatch */ @Transactional public ExportBatch createBatch(List sampleIds, TokenPrincipal principal) { if (sampleIds == null || sampleIds.isEmpty()) { - throw new BusinessException("EMPTY_SAMPLES", "瀵煎嚭鏍锋湰 ID 鍒楄〃涓嶈兘涓虹┖", HttpStatus.BAD_REQUEST); + throw new BusinessException("EMPTY_SAMPLES", "导出样本 ID 列表不能为空", HttpStatus.BAD_REQUEST); } - // 鏌ヨ鏍锋湰 + // 查询样本 List samples = datasetMapper.selectList( new LambdaQueryWrapper() .in(TrainingDataset::getId, sampleIds) .eq(TrainingDataset::getCompanyId, principal.getCompanyId())); - // 鏍¢獙鍏ㄩ儴宸插鎵? boolean hasNonApproved = samples.stream() + // 校验全部已审批 + boolean hasNonApproved = samples.stream() .anyMatch(s -> !"APPROVED".equals(s.getStatus())); if (hasNonApproved || samples.size() != sampleIds.size()) { throw new BusinessException("INVALID_SAMPLES", - "閮ㄥ垎鏍锋湰涓嶅浜?APPROVED 鐘舵€佹垨涓嶅睘浜庡綋鍓嶇鎴?, HttpStatus.BAD_REQUEST); + "部分样本不处于 APPROVED 状态或不属于当前租户", HttpStatus.BAD_REQUEST); } - // 鐢熸垚 JSONL锛堟瘡琛屼竴涓?JSON 瀵硅薄锛? String jsonl = samples.stream() + // 生成 JSONL(每行一个 JSON 对象) + String jsonl = samples.stream() .map(TrainingDataset::getGlmFormatJson) .collect(Collectors.joining("\n")); byte[] jsonlBytes = jsonl.getBytes(StandardCharsets.UTF_8); - // 鐢熸垚鍞竴鎵规 UUID锛屼笂浼?RustFS + // 生成唯一批次 UUID,上传 RustFS UUID batchUuid = UUID.randomUUID(); String filePath = "export/" + batchUuid + ".jsonl"; @@ -81,7 +90,8 @@ public class ExportService { new ByteArrayInputStream(jsonlBytes), jsonlBytes.length, "application/jsonl"); - // 鎻掑叆 export_batch 璁板綍锛堣嫢 DB 鍐欏叆澶辫触锛屽皾璇曟竻鐞?RustFS 瀛ゅ効鏂囦欢锛? ExportBatch batch = new ExportBatch(); + // 插入 export_batch 记录(若 DB 写入失败,尝试清理 RustFS 孤儿文件) + ExportBatch batch = new ExportBatch(); batch.setCompanyId(principal.getCompanyId()); batch.setBatchUuid(batchUuid); batch.setSampleCount(samples.size()); @@ -90,30 +100,32 @@ public class ExportService { try { exportBatchMapper.insert(batch); } catch (Exception e) { - // DB 鎻掑叆澶辫触锛氬皾璇曞垹闄ゅ凡涓婁紶鐨?RustFS 鏂囦欢锛岄槻姝骇鐢熷鍎挎枃浠? try { + // DB 插入失败:尝试删除已上传的 RustFS 文件,防止产生孤儿文件 + try { rustFsClient.delete(EXPORT_BUCKET, filePath); } catch (Exception deleteEx) { - log.error("DB 鍐欏叆澶辫触鍚庢竻鐞?RustFS 鏂囦欢浜﹀け璐ワ紝瀛ゅ効鏂囦欢: {}/{}", EXPORT_BUCKET, filePath, deleteEx); + log.error("DB 写入失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", EXPORT_BUCKET, filePath, deleteEx); } throw e; } - // 鎵归噺鏇存柊 training_dataset.export_batch_id + exported_at + // 批量更新 training_dataset.export_batch_id + exported_at datasetMapper.update(null, new LambdaUpdateWrapper() .in(TrainingDataset::getId, sampleIds) .set(TrainingDataset::getExportBatchId, batch.getId()) .set(TrainingDataset::getExportedAt, LocalDateTime.now()) .set(TrainingDataset::getUpdatedAt, LocalDateTime.now())); - log.info("瀵煎嚭鎵规宸插垱寤? batchId={}, sampleCount={}, path={}", + log.info("导出批次已创建: batchId={}, sampleCount={}, path={}", batch.getId(), samples.size(), filePath); return batch; } - // ------------------------------------------------------------------ 鏌ヨ鏍锋湰 -- + // ------------------------------------------------------------------ 查询样本 -- /** - * 鍒嗛〉鏌ヨ宸插鎵广€佸彲瀵煎嚭鐨勮缁冩牱鏈€? */ + * 分页查询已审批、可导出的训练样本。 + */ public PageResult listSamples(int page, int pageSize, String sampleType, Boolean exported, TokenPrincipal principal) { @@ -138,10 +150,11 @@ public class ExportService { return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize); } - // ------------------------------------------------------------------ 鏌ヨ鎵规鍒楄〃 -- + // ------------------------------------------------------------------ 查询批次列表 -- /** - * 鍒嗛〉鏌ヨ瀵煎嚭鎵规銆? */ + * 分页查询导出批次。 + */ public PageResult listBatches(int page, int pageSize, TokenPrincipal principal) { pageSize = Math.min(pageSize, 100); Page result = exportBatchMapper.selectPage( @@ -152,12 +165,12 @@ public class ExportService { return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize); } - // ------------------------------------------------------------------ 鏌ヨ鎵规 -- + // ------------------------------------------------------------------ 查询批次 -- public ExportBatch getById(Long batchId, TokenPrincipal principal) { ExportBatch batch = exportBatchMapper.selectById(batchId); if (batch == null || !batch.getCompanyId().equals(principal.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "瀵煎嚭鎵规涓嶅瓨鍦? " + batchId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "导出批次不存在: " + batchId, HttpStatus.NOT_FOUND); } return batch; } diff --git a/src/main/java/com/label/module/export/service/FinetuneService.java b/src/main/java/com/label/module/export/service/FinetuneService.java index cc2558d..0359fc0 100644 --- a/src/main/java/com/label/module/export/service/FinetuneService.java +++ b/src/main/java/com/label/module/export/service/FinetuneService.java @@ -3,8 +3,8 @@ package com.label.module.export.service; import com.label.common.ai.AiServiceClient; import com.label.common.exception.BusinessException; import com.label.common.shiro.TokenPrincipal; -import com.label.entity.ExportBatch; -import com.label.mapper.ExportBatchMapper; +import com.label.module.export.entity.ExportBatch; +import com.label.module.export.mapper.ExportBatchMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -14,8 +14,11 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Map; /** - * GLM 寰皟鏈嶅姟锛氭彁浜や换鍔°€佹煡璇㈢姸鎬併€? * - * 娉ㄦ剰锛歵rigger() 鍖呭惈 AI HTTP 璋冪敤锛屼笉鍦?@Transactional 娉ㄨВ涓嬨€? * 浠呭湪 DB 鍐欏叆鏃跺紑鍚簨鍔★紙updateFinetuneInfo锛夈€? */ + * GLM 微调服务:提交任务、查询状态。 + * + * 注意:trigger() 包含 AI HTTP 调用,不在 @Transactional 注解下。 + * 仅在 DB 写入时开启事务(updateFinetuneInfo)。 + */ @Slf4j @Service @RequiredArgsConstructor @@ -25,24 +28,29 @@ public class FinetuneService { private final ExportService exportService; private final AiServiceClient aiServiceClient; - // ------------------------------------------------------------------ 鎻愪氦寰皟 -- + // ------------------------------------------------------------------ 提交微调 -- /** - * 鍚?GLM AI 鏈嶅姟鎻愪氦寰皟浠诲姟銆? * - * T074 璁捐锛欰I 璋冪敤涓嶅湪 @Transactional 鍐呮墽琛岋紝閬垮厤鎸佹湁 DB 杩炴帴鏈熼棿鍙戣捣 HTTP 璇锋眰銆? * DB 鍐欏叆锛坲pdateFinetuneInfo锛夋槸鍗曟潯 UPDATE锛屼笉闇€瑕佹樉寮忎簨鍔★紙鑷姩鎻愪氦锛夈€? * 濡傛灉 AI 璋冪敤鎴愬姛浣?DB 鍐欏叆澶辫触锛屼笅娆℃煡璇㈢姸鎬佷粛鍙€氳繃 AI 鏈嶅姟鐨?jobId 閲嶅缓鐘舵€併€? * - * @param batchId 鎵规 ID - * @param principal 褰撳墠鐢ㄦ埛 - * @return 鍖呭惈 glmJobId 鍜?finetuneStatus 鐨?Map + * 向 GLM AI 服务提交微调任务。 + * + * T074 设计:AI 调用不在 @Transactional 内执行,避免持有 DB 连接期间发起 HTTP 请求。 + * DB 写入(updateFinetuneInfo)是单条 UPDATE,不需要显式事务(自动提交)。 + * 如果 AI 调用成功但 DB 写入失败,下次查询状态仍可通过 AI 服务的 jobId 重建状态。 + * + * @param batchId 批次 ID + * @param principal 当前用户 + * @return 包含 glmJobId 和 finetuneStatus 的 Map */ public Map trigger(Long batchId, TokenPrincipal principal) { ExportBatch batch = exportService.getById(batchId, principal); if (!"NOT_STARTED".equals(batch.getFinetuneStatus())) { throw new BusinessException("FINETUNE_ALREADY_STARTED", - "寰皟浠诲姟宸叉彁浜わ紝褰撳墠鐘舵€? " + batch.getFinetuneStatus(), HttpStatus.CONFLICT); + "微调任务已提交,当前状态: " + batch.getFinetuneStatus(), HttpStatus.CONFLICT); } - // 璋冪敤 AI 鏈嶅姟锛堟棤浜嬪姟锛屼笉鎸佹湁 DB 杩炴帴锛? AiServiceClient.FinetuneRequest req = AiServiceClient.FinetuneRequest.builder() + // 调用 AI 服务(无事务,不持有 DB 连接) + AiServiceClient.FinetuneRequest req = AiServiceClient.FinetuneRequest.builder() .datasetPath(batch.getDatasetFilePath()) .model("glm-4") .batchId(batchId) @@ -53,14 +61,14 @@ public class FinetuneService { response = aiServiceClient.startFinetune(req); } catch (Exception e) { throw new BusinessException("FINETUNE_TRIGGER_FAILED", - "鎻愪氦寰皟浠诲姟澶辫触: " + e.getMessage(), HttpStatus.SERVICE_UNAVAILABLE); + "提交微调任务失败: " + e.getMessage(), HttpStatus.SERVICE_UNAVAILABLE); } - // AI 璋冪敤鎴愬姛鍚庢洿鏂版壒娆¤褰曪紙鍗曟潯 UPDATE锛岃嚜鍔ㄦ彁浜わ級 + // AI 调用成功后更新批次记录(单条 UPDATE,自动提交) exportBatchMapper.updateFinetuneInfo(batchId, response.getJobId(), "RUNNING", principal.getCompanyId()); - log.info("寰皟浠诲姟宸叉彁浜? batchId={}, glmJobId={}", batchId, response.getJobId()); + log.info("微调任务已提交: batchId={}, glmJobId={}", batchId, response.getJobId()); return Map.of( "glmJobId", response.getJobId(), @@ -68,13 +76,14 @@ public class FinetuneService { ); } - // ------------------------------------------------------------------ 鏌ヨ鐘舵€?-- + // ------------------------------------------------------------------ 查询状态 -- /** - * 鏌ヨ寰皟浠诲姟瀹炴椂鐘舵€侊紙鍚?AI 鏈嶅姟鏌ヨ锛夈€? * - * @param batchId 鎵规 ID - * @param principal 褰撳墠鐢ㄦ埛 - * @return 鐘舵€?Map + * 查询微调任务实时状态(向 AI 服务查询)。 + * + * @param batchId 批次 ID + * @param principal 当前用户 + * @return 状态 Map */ public Map getStatus(Long batchId, TokenPrincipal principal) { ExportBatch batch = exportService.getById(batchId, principal); @@ -89,18 +98,19 @@ public class FinetuneService { ); } - // 鍚?AI 鏈嶅姟瀹炴椂鏌ヨ + // 向 AI 服务实时查询 AiServiceClient.FinetuneStatusResponse statusResp; try { statusResp = aiServiceClient.getFinetuneStatus(batch.getGlmJobId()); } catch (Exception e) { - log.warn("鏌ヨ寰皟鐘舵€佸け璐ワ紙batchId={}锛夛細{}", batchId, e.getMessage()); - // 鏌ヨ澶辫触鏃惰繑鍥?DB 涓殑缂撳瓨鐘舵€? return Map.of( + log.warn("查询微调状态失败(batchId={}):{}", batchId, e.getMessage()); + // 查询失败时返回 DB 中的缓存状态 + return Map.of( "batchId", batchId, "glmJobId", batch.getGlmJobId(), "finetuneStatus", batch.getFinetuneStatus(), "progress", 0, - "errorMessage", "AI 鏈嶅姟鏌ヨ澶辫触: " + e.getMessage() + "errorMessage", "AI 服务查询失败: " + e.getMessage() ); } diff --git a/src/main/java/com/label/module/source/controller/SourceController.java b/src/main/java/com/label/module/source/controller/SourceController.java index 514ab57..379272f 100644 --- a/src/main/java/com/label/module/source/controller/SourceController.java +++ b/src/main/java/com/label/module/source/controller/SourceController.java @@ -3,7 +3,7 @@ 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.dto.SourceResponse; +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; @@ -15,10 +15,13 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; /** - * 鍘熷璧勬枡绠$悊鎺ュ彛銆? * - * 鏉冮檺璁捐锛? * - 涓婁紶 / 鍒楄〃 / 璇︽儏锛歎PLOADER 鍙婁互涓婅鑹诧紙鍚?ANNOTATOR銆丷EVIEWER銆丄DMIN锛? * - 鍒犻櫎锛氫粎 ADMIN + * 原始资料管理接口。 + * + * 权限设计: + * - 上传 / 列表 / 详情:UPLOADER 及以上角色(含 ANNOTATOR、REVIEWER、ADMIN) + * - 删除:仅 ADMIN */ -@Tag(name = "璧勬枡绠$悊", description = "鍘熷璧勬枡涓婁紶銆佹煡璇㈠拰鍒犻櫎") +@Tag(name = "资料管理", description = "原始资料上传、查询和删除") @RestController @RequestMapping("/api/source") @RequiredArgsConstructor @@ -27,8 +30,10 @@ public class SourceController { private final SourceService sourceService; /** - * 涓婁紶鏂囦欢锛坢ultipart/form-data锛夈€? * 杩斿洖 201 Created + 璧勬枡鎽樿銆? */ - @Operation(summary = "涓婁紶鍘熷璧勬枡", description = "dataType: text,image, video") + * 上传文件(multipart/form-data)。 + * 返回 201 Created + 资料摘要。 + */ + @Operation(summary = "上传原始资料", description = "dataType: text,image, video") @PostMapping("/upload") @RequiresRoles("UPLOADER") @ResponseStatus(HttpStatus.CREATED) @@ -41,8 +46,10 @@ public class SourceController { } /** - * 鍒嗛〉鏌ヨ璧勬枡鍒楄〃銆? * UPLOADER 鍙鑷繁鐨勮祫鏂欙紱ADMIN 瑙佸叏鍏徃璧勬枡銆? */ - @Operation(summary = "鍒嗛〉鏌ヨ璧勬枡鍒楄〃") + * 分页查询资料列表。 + * UPLOADER 只见自己的资料;ADMIN 见全公司资料。 + */ + @Operation(summary = "分页查询资料列表") @GetMapping("/list") @RequiresRoles("UPLOADER") public Result> list( @@ -56,8 +63,9 @@ public class SourceController { } /** - * 鏌ヨ璧勬枡璇︽儏锛堝惈 15 鍒嗛挓棰勭鍚嶄笅杞介摼鎺ワ級銆? */ - @Operation(summary = "鏌ヨ璧勬枡璇︽儏") + * 查询资料详情(含 15 分钟预签名下载链接)。 + */ + @Operation(summary = "查询资料详情") @GetMapping("/{id}") @RequiresRoles("UPLOADER") public Result findById(@PathVariable Long id) { @@ -65,8 +73,10 @@ public class SourceController { } /** - * 鍒犻櫎璧勬枡锛堜粎 PENDING 鐘舵€佸彲鍒狅級銆? * 鍚屾鍒犻櫎 RustFS 鏂囦欢鍙?DB 璁板綍銆? */ - @Operation(summary = "鍒犻櫎璧勬枡") + * 删除资料(仅 PENDING 状态可删)。 + * 同步删除 RustFS 文件及 DB 记录。 + */ + @Operation(summary = "删除资料") @DeleteMapping("/{id}") @RequiresRoles("ADMIN") public Result delete(@PathVariable Long id, HttpServletRequest request) { diff --git a/src/main/java/com/label/module/source/dto/SourceResponse.java b/src/main/java/com/label/module/source/dto/SourceResponse.java new file mode 100644 index 0000000..fa3a088 --- /dev/null +++ b/src/main/java/com/label/module/source/dto/SourceResponse.java @@ -0,0 +1,38 @@ +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 = "资料类型", example = "TEXT") + private String dataType; + @Schema(description = "文件大小(字节)") + private Long fileSize; + @Schema(description = "资料状态", example = "PENDING") + private String status; + /** 上传用户 ID(列表端点返回) */ + @Schema(description = "上传用户 ID") + private Long uploaderId; + /** 15 分钟预签名下载链接(详情端点返回) */ + @Schema(description = "预签名下载链接") + private String presignedUrl; + /** 父资料 ID(视频帧 / 文本片段;详情端点返回) */ + @Schema(description = "父资料 ID") + private Long parentSourceId; + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/label/module/source/entity/SourceData.java b/src/main/java/com/label/module/source/entity/SourceData.java new file mode 100644 index 0000000..81a342e --- /dev/null +++ b/src/main/java/com/label/module/source/entity/SourceData.java @@ -0,0 +1,56 @@ +package com.label.module.source.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 原始资料实体,对应 source_data 表。 + * + * dataType 取值:TEXT / IMAGE / VIDEO + * status 取值:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED + */ +@Data +@TableName("source_data") +public class SourceData { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属公司(多租户键) */ + private Long companyId; + + /** 上传用户 ID */ + private Long uploaderId; + + /** 资料类型:TEXT / IMAGE / VIDEO */ + private String dataType; + + /** RustFS 对象路径 */ + private String filePath; + + /** 原始文件名 */ + private String fileName; + + /** 文件大小(字节) */ + private Long fileSize; + + /** RustFS Bucket 名称 */ + private String bucketName; + + /** 父资料 ID(视频帧或文本片段的自引用外键) */ + private Long parentSourceId; + + /** 流水线状态:PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED */ + private String status; + + /** 保留字段(当前无 REJECTED 状态) */ + private String rejectReason; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/mapper/SourceDataMapper.java b/src/main/java/com/label/module/source/mapper/SourceDataMapper.java similarity index 52% rename from src/main/java/com/label/mapper/SourceDataMapper.java rename to src/main/java/com/label/module/source/mapper/SourceDataMapper.java index 0cbabe3..c6ea424 100644 --- a/src/main/java/com/label/mapper/SourceDataMapper.java +++ b/src/main/java/com/label/module/source/mapper/SourceDataMapper.java @@ -1,25 +1,28 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.SourceData; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Update; - -/** - * source_data 鐞?Mapper閵? */ -@Mapper -public interface SourceDataMapper extends BaseMapper { - - /** - * 閹?ID 閺囧瓨鏌婄挧鍕灐閻樿埖鈧緤绱欑敮?company_id 缁夌喐鍩涢梾鏃傤瀲閿涘鈧? * - * @param id 鐠у嫭鏋?ID - * @param status 閺傛壆濮搁幀? * @param companyId 瑜版挸澧犵粔鐔稿煕 - * @return 瑜板崬鎼风悰灞炬殶閿? 鐞涖劎銇氱拋鏉跨秿娑撳秴鐡ㄩ崷銊﹀灗娑撳秴鐫樻禍搴$秼閸撳秶顫ら幋鍑ょ礆 - */ - @Update("UPDATE source_data SET status = #{status}, updated_at = NOW() " + - "WHERE id = #{id} AND company_id = #{companyId}") - int updateStatus(@Param("id") Long id, - @Param("status") String status, - @Param("companyId") Long companyId); -} +package com.label.module.source.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.source.entity.SourceData; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** + * source_data 表 Mapper。 + */ +@Mapper +public interface SourceDataMapper extends BaseMapper { + + /** + * 按 ID 更新资料状态(带 company_id 租户隔离)。 + * + * @param id 资料 ID + * @param status 新状态 + * @param companyId 当前租户 + * @return 影响行数(0 表示记录不存在或不属于当前租户) + */ + @Update("UPDATE source_data SET status = #{status}, updated_at = NOW() " + + "WHERE id = #{id} AND company_id = #{companyId}") + int updateStatus(@Param("id") Long id, + @Param("status") String status, + @Param("companyId") Long companyId); +} diff --git a/src/main/java/com/label/module/source/service/SourceService.java b/src/main/java/com/label/module/source/service/SourceService.java index 58f33e9..f4bb740 100644 --- a/src/main/java/com/label/module/source/service/SourceService.java +++ b/src/main/java/com/label/module/source/service/SourceService.java @@ -7,9 +7,9 @@ import com.label.common.exception.BusinessException; import com.label.common.result.PageResult; import com.label.common.shiro.TokenPrincipal; import com.label.common.storage.RustFsClient; -import com.label.dto.SourceResponse; -import com.label.entity.SourceData; -import com.label.mapper.SourceDataMapper; +import com.label.module.source.dto.SourceResponse; +import com.label.module.source.entity.SourceData; +import com.label.module.source.mapper.SourceDataMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -24,8 +24,11 @@ import java.util.Set; import java.util.stream.Collectors; /** - * 鍘熷璧勬枡涓氬姟鏈嶅姟銆? * - * 涓婁紶娴佺▼锛氬厛 INSERT 鑾峰彇 ID 鈫?鏋勯€?RustFS 璺緞 鈫?涓婁紶鏂囦欢 鈫?UPDATE filePath銆? * 鍒犻櫎瑙勫垯锛氫粎 PENDING 鐘舵€佸彲鍒狅紙闃叉鍒犻櫎宸茶繘鍏ユ爣娉ㄦ祦姘寸嚎鐨勮祫鏂欙級銆? */ + * 原始资料业务服务。 + * + * 上传流程:先 INSERT 获取 ID → 构造 RustFS 路径 → 上传文件 → UPDATE filePath。 + * 删除规则:仅 PENDING 状态可删(防止删除已进入标注流水线的资料)。 + */ @Slf4j @Service @RequiredArgsConstructor @@ -40,25 +43,30 @@ public class SourceService { @Value("${rustfs.bucket:label-source-data}") private String bucket; - // ------------------------------------------------------------------ 涓婁紶 -- + // ------------------------------------------------------------------ 上传 -- /** - * 涓婁紶鏂囦欢骞跺垱寤?source_data 璁板綍銆? * - * @param file 涓婁紶鐨勬枃浠? * @param dataType 璧勬枡绫诲瀷锛圱EXT / IMAGE / VIDEO锛? * @param principal 褰撳墠鐧诲綍鐢ㄦ埛 - * @return 鍒涘缓鎴愬姛鐨勮祫鏂欐憳瑕? */ + * 上传文件并创建 source_data 记录。 + * + * @param file 上传的文件 + * @param dataType 资料类型(TEXT / IMAGE / VIDEO) + * @param principal 当前登录用户 + * @return 创建成功的资料摘要 + */ @Transactional public SourceResponse upload(MultipartFile file, String dataType, TokenPrincipal principal) { if (file == null || file.isEmpty()) { - throw new BusinessException("FILE_EMPTY", "涓婁紶鏂囦欢涓嶈兘涓虹┖", HttpStatus.BAD_REQUEST); + throw new BusinessException("FILE_EMPTY", "上传文件不能为空", HttpStatus.BAD_REQUEST); } if (!VALID_DATA_TYPES.contains(dataType)) { - throw new BusinessException("INVALID_TYPE", "涓嶆敮鎸佺殑璧勬枡绫诲瀷: " + dataType, HttpStatus.BAD_REQUEST); + throw new BusinessException("INVALID_TYPE", "不支持的资料类型: " + dataType, HttpStatus.BAD_REQUEST); } - // 鎻愬彇绾枃浠跺悕锛岄槻姝㈣矾寰勯亶鍘嗭紙濡?../../admin/secret.txt锛? String rawName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown"; + // 提取纯文件名,防止路径遍历(如 ../../admin/secret.txt) + String rawName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown"; String originalName = java.nio.file.Paths.get(rawName).getFileName().toString(); - // 1. 鍏堟彃鍏ュ崰浣嶈褰曪紝鎷垮埌鑷 ID + // 1. 先插入占位记录,拿到自增 ID SourceData source = new SourceData(); source.setCompanyId(principal.getCompanyId()); source.setUploaderId(principal.getUserId()); @@ -66,23 +74,25 @@ public class SourceService { source.setFileName(originalName); source.setFileSize(file.getSize()); source.setBucketName(bucket); - source.setFilePath(""); // 鍗犱綅锛屽悗闈㈡洿鏂? source.setStatus("PENDING"); + source.setFilePath(""); // 占位,后面更新 + source.setStatus("PENDING"); sourceDataMapper.insert(source); - // 2. 鏋勯€?RustFS 瀵硅薄璺緞 + // 2. 构造 RustFS 对象路径 String objectKey = String.format("%d/%s/%d/%s", principal.getCompanyId(), dataType.toLowerCase(), source.getId(), originalName); - // 3. 涓婁紶鏂囦欢鍒?RustFS + // 3. 上传文件到 RustFS try { rustFsClient.upload(bucket, objectKey, file.getInputStream(), file.getSize(), file.getContentType()); } catch (IOException e) { - log.error("鏂囦欢涓婁紶鍒?RustFS 澶辫触: bucket={}, key={}", bucket, objectKey, e); - throw new BusinessException("UPLOAD_FAILED", "鏂囦欢涓婁紶澶辫触锛岃閲嶈瘯", HttpStatus.INTERNAL_SERVER_ERROR); + log.error("文件上传到 RustFS 失败: bucket={}, key={}", bucket, objectKey, e); + throw new BusinessException("UPLOAD_FAILED", "文件上传失败,请重试", HttpStatus.INTERNAL_SERVER_ERROR); } - // 4. 鏇存柊 filePath锛堣嫢澶辫触鍒欐竻鐞?RustFS 瀛ゅ効鏂囦欢锛? try { + // 4. 更新 filePath(若失败则清理 RustFS 孤儿文件) + try { sourceDataMapper.update(null, new LambdaUpdateWrapper() .eq(SourceData::getId, source.getId()) .set(SourceData::getFilePath, objectKey)); @@ -90,19 +100,21 @@ public class SourceService { try { rustFsClient.delete(bucket, objectKey); } catch (Exception deleteEx) { - log.error("DB 鏇存柊澶辫触鍚庢竻鐞?RustFS 鏂囦欢浜﹀け璐ワ紝瀛ゅ効鏂囦欢: {}/{}", bucket, objectKey, deleteEx); + log.error("DB 更新失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", bucket, objectKey, deleteEx); } throw e; } - log.info("璧勬枡涓婁紶鎴愬姛: id={}, key={}", source.getId(), objectKey); + log.info("资料上传成功: id={}, key={}", source.getId(), objectKey); return toUploadResponse(source, objectKey); } - // ------------------------------------------------------------------ 鍒楄〃 -- + // ------------------------------------------------------------------ 列表 -- /** - * 鍒嗛〉鏌ヨ璧勬枡鍒楄〃銆? * UPLOADER 鍙鑷繁涓婁紶鐨勮祫鏂欙紱ADMIN 瑙佹湰鍏徃鍏ㄩ儴璧勬枡锛堝绉熸埛鑷姩杩囨护锛夈€? */ + * 分页查询资料列表。 + * UPLOADER 只见自己上传的资料;ADMIN 见本公司全部资料(多租户自动过滤)。 + */ public PageResult list(int page, int pageSize, String dataType, String status, TokenPrincipal principal) { @@ -111,7 +123,7 @@ public class SourceService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .orderByDesc(SourceData::getCreatedAt); - // UPLOADER 鍙兘鏌ヨ嚜宸辩殑璧勬枡 + // UPLOADER 只能查自己的资料 if ("UPLOADER".equals(principal.getRole())) { wrapper.eq(SourceData::getUploaderId, principal.getUserId()); } @@ -131,14 +143,15 @@ public class SourceService { return PageResult.of(items, pageResult.getTotal(), page, pageSize); } - // ------------------------------------------------------------------ 璇︽儏 -- + // ------------------------------------------------------------------ 详情 -- /** - * 鎸?ID 鏌ヨ璧勬枡璇︽儏锛屽惈 15 鍒嗛挓棰勭鍚嶄笅杞介摼鎺ャ€? */ + * 按 ID 查询资料详情,含 15 分钟预签名下载链接。 + */ public SourceResponse findById(Long id) { SourceData source = sourceDataMapper.selectById(id); if (source == null) { - throw new BusinessException("NOT_FOUND", "璧勬枡涓嶅瓨鍦?, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND); } String presignedUrl = null; @@ -158,37 +171,40 @@ public class SourceService { .build(); } - // ------------------------------------------------------------------ 鍒犻櫎 -- + // ------------------------------------------------------------------ 删除 -- /** - * 鍒犻櫎璧勬枡锛氫粎 PENDING 鐘舵€佸彲鍒狅紝鍚屾鍒犻櫎 RustFS 鏂囦欢銆? * - * @throws BusinessException SOURCE_IN_PIPELINE(409) 璧勬枡宸茶繘鍏ユ爣娉ㄦ祦绋? */ + * 删除资料:仅 PENDING 状态可删,同步删除 RustFS 文件。 + * + * @throws BusinessException SOURCE_IN_PIPELINE(409) 资料已进入标注流程 + */ @Transactional public void delete(Long id, Long companyId) { SourceData source = sourceDataMapper.selectById(id); if (source == null) { - throw new BusinessException("NOT_FOUND", "璧勬枡涓嶅瓨鍦?, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND); } if (!"PENDING".equals(source.getStatus())) { throw new BusinessException("SOURCE_IN_PIPELINE", - "璧勬枡宸茶繘鍏ユ爣娉ㄦ祦绋嬶紝涓嶅彲鍒犻櫎锛堝綋鍓嶇姸鎬侊細" + source.getStatus() + "锛?, + "资料已进入标注流程,不可删除(当前状态:" + source.getStatus() + ")", HttpStatus.CONFLICT); } - // 鍏堝垹 RustFS 鏂囦欢锛堝箓绛夛紝涓嶆姏寮傚父锛? if (source.getFilePath() != null && !source.getFilePath().isBlank()) { + // 先删 RustFS 文件(幂等,不抛异常) + if (source.getFilePath() != null && !source.getFilePath().isBlank()) { try { rustFsClient.delete(bucket, source.getFilePath()); } catch (Exception e) { - log.warn("RustFS 鏂囦欢鍒犻櫎澶辫触锛堢户缁垹 DB 璁板綍锛? bucket={}, key={}", bucket, source.getFilePath(), e); + log.warn("RustFS 文件删除失败(继续删 DB 记录): bucket={}, key={}", bucket, source.getFilePath(), e); } } sourceDataMapper.deleteById(id); - log.info("璧勬枡鍒犻櫎鎴愬姛: id={}", id); + log.info("资料删除成功: id={}", id); } - // ------------------------------------------------------------------ 绉佹湁宸ュ叿 -- + // ------------------------------------------------------------------ 私有工具 -- private SourceResponse toUploadResponse(SourceData source, String filePath) { return SourceResponse.builder() diff --git a/src/main/java/com/label/module/task/controller/TaskController.java b/src/main/java/com/label/module/task/controller/TaskController.java index c17cb0f..a9563c6 100644 --- a/src/main/java/com/label/module/task/controller/TaskController.java +++ b/src/main/java/com/label/module/task/controller/TaskController.java @@ -3,7 +3,7 @@ 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.dto.TaskResponse; +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; @@ -16,8 +16,9 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; /** - * 浠诲姟绠$悊鎺ュ彛锛?0 涓鐐癸級銆? */ -@Tag(name = "浠诲姟绠$悊", description = "浠诲姟姹犮€佹垜鐨勪换鍔°€佸鎵归槦鍒楀拰绠$悊鎿嶄綔") + * 任务管理接口(10 个端点)。 + */ +@Tag(name = "任务管理", description = "任务池、我的任务、审批队列和管理操作") @RestController @RequestMapping("/api/tasks") @RequiredArgsConstructor @@ -26,8 +27,8 @@ public class TaskController { private final TaskService taskService; private final TaskClaimService taskClaimService; - /** GET /api/tasks/pool 鈥?鏌ヨ鍙鍙栦换鍔℃睜锛堣鑹叉劅鐭ワ級 */ - @Operation(summary = "鏌ヨ鍙鍙栦换鍔℃睜") + /** GET /api/tasks/pool — 查询可领取任务池(角色感知) */ + @Operation(summary = "查询可领取任务池") @GetMapping("/pool") @RequiresRoles("ANNOTATOR") public Result> getPool( @@ -37,8 +38,8 @@ public class TaskController { return Result.success(taskService.getPool(page, pageSize, principal(request))); } - /** GET /api/tasks/mine 鈥?鏌ヨ鎴戠殑浠诲姟 */ - @Operation(summary = "鏌ヨ鎴戠殑浠诲姟") + /** GET /api/tasks/mine — 查询我的任务 */ + @Operation(summary = "查询我的任务") @GetMapping("/mine") @RequiresRoles("ANNOTATOR") public Result> getMine( @@ -49,8 +50,8 @@ public class TaskController { return Result.success(taskService.getMine(page, pageSize, status, principal(request))); } - /** GET /api/tasks/pending-review 鈥?寰呭鎵归槦鍒楋紙REVIEWER 涓撳睘锛?*/ - @Operation(summary = "鏌ヨ寰呭鎵逛换鍔?) + /** GET /api/tasks/pending-review — 待审批队列(REVIEWER 专属) */ + @Operation(summary = "查询待审批任务") @GetMapping("/pending-review") @RequiresRoles("REVIEWER") public Result> getPendingReview( @@ -60,8 +61,8 @@ public class TaskController { return Result.success(taskService.getPendingReview(page, pageSize, taskType)); } - /** GET /api/tasks 鈥?鏌ヨ鍏ㄩ儴浠诲姟锛圓DMIN锛?*/ - @Operation(summary = "绠$悊鍛樻煡璇㈠叏閮ㄤ换鍔?) + /** GET /api/tasks — 查询全部任务(ADMIN) */ + @Operation(summary = "管理员查询全部任务") @GetMapping @RequiresRoles("ADMIN") public Result> getAll( @@ -72,8 +73,8 @@ public class TaskController { return Result.success(taskService.getAll(page, pageSize, status, taskType)); } - /** POST /api/tasks 鈥?鍒涘缓浠诲姟锛圓DMIN锛?*/ - @Operation(summary = "绠$悊鍛樺垱寤轰换鍔?) + /** POST /api/tasks — 创建任务(ADMIN) */ + @Operation(summary = "管理员创建任务") @PostMapping @RequiresRoles("ADMIN") public Result createTask(@RequestBody Map body, @@ -85,16 +86,16 @@ public class TaskController { taskService.createTask(sourceId, taskType, principal.getCompanyId()))); } - /** GET /api/tasks/{id} 鈥?鏌ヨ浠诲姟璇︽儏 */ - @Operation(summary = "鏌ヨ浠诲姟璇︽儏") + /** GET /api/tasks/{id} — 查询任务详情 */ + @Operation(summary = "查询任务详情") @GetMapping("/{id}") @RequiresRoles("ANNOTATOR") public Result getById(@PathVariable Long id) { return Result.success(taskService.toPublicResponse(taskService.getById(id))); } - /** POST /api/tasks/{id}/claim 鈥?棰嗗彇浠诲姟 */ - @Operation(summary = "棰嗗彇浠诲姟") + /** POST /api/tasks/{id}/claim — 领取任务 */ + @Operation(summary = "领取任务") @PostMapping("/{id}/claim") @RequiresRoles("ANNOTATOR") public Result claim(@PathVariable Long id, HttpServletRequest request) { @@ -102,8 +103,8 @@ public class TaskController { return Result.success(null); } - /** POST /api/tasks/{id}/unclaim 鈥?鏀惧純浠诲姟 */ - @Operation(summary = "鏀惧純浠诲姟") + /** POST /api/tasks/{id}/unclaim — 放弃任务 */ + @Operation(summary = "放弃任务") @PostMapping("/{id}/unclaim") @RequiresRoles("ANNOTATOR") public Result unclaim(@PathVariable Long id, HttpServletRequest request) { @@ -111,8 +112,8 @@ public class TaskController { return Result.success(null); } - /** POST /api/tasks/{id}/reclaim 鈥?閲嶉琚┏鍥炵殑浠诲姟 */ - @Operation(summary = "閲嶉琚┏鍥炵殑浠诲姟") + /** POST /api/tasks/{id}/reclaim — 重领被驳回的任务 */ + @Operation(summary = "重领被驳回的任务") @PostMapping("/{id}/reclaim") @RequiresRoles("ANNOTATOR") public Result reclaim(@PathVariable Long id, HttpServletRequest request) { @@ -120,8 +121,8 @@ public class TaskController { return Result.success(null); } - /** PUT /api/tasks/{id}/reassign 鈥?ADMIN 寮哄埗鎸囨淳 */ - @Operation(summary = "绠$悊鍛樺己鍒舵寚娲句换鍔?) + /** PUT /api/tasks/{id}/reassign — ADMIN 强制指派 */ + @Operation(summary = "管理员强制指派任务") @PutMapping("/{id}/reassign") @RequiresRoles("ADMIN") public Result reassign(@PathVariable Long id, diff --git a/src/main/java/com/label/module/task/dto/TaskResponse.java b/src/main/java/com/label/module/task/dto/TaskResponse.java new file mode 100644 index 0000000..caa7397 --- /dev/null +++ b/src/main/java/com/label/module/task/dto/TaskResponse.java @@ -0,0 +1,38 @@ +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; + /** 任务类型(对应 taskType 字段):EXTRACTION / QA_GENERATION */ + @Schema(description = "任务类型", example = "EXTRACTION") + private String taskType; + @Schema(description = "任务状态", example = "UNCLAIMED") + private String status; + @Schema(description = "领取人用户 ID") + private Long claimedBy; + @Schema(description = "领取时间") + private LocalDateTime claimedAt; + @Schema(description = "提交时间") + private LocalDateTime submittedAt; + @Schema(description = "完成时间") + private LocalDateTime completedAt; + /** 驳回原因(REJECTED 状态时非空) */ + @Schema(description = "驳回原因") + private String rejectReason; + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/label/module/task/entity/AnnotationTask.java b/src/main/java/com/label/module/task/entity/AnnotationTask.java new file mode 100644 index 0000000..c8fcce7 --- /dev/null +++ b/src/main/java/com/label/module/task/entity/AnnotationTask.java @@ -0,0 +1,59 @@ +package com.label.module.task.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 标注任务实体,对应 annotation_task 表。 + * + * taskType 取值:EXTRACTION / QA_GENERATION + * status 取值:UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED + */ +@Data +@TableName("annotation_task") +public class AnnotationTask { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属公司(多租户键) */ + private Long companyId; + + /** 关联的原始资料 ID */ + private Long sourceId; + + /** 任务类型:EXTRACTION / QA_GENERATION */ + private String taskType; + + /** 任务状态 */ + private String status; + + /** 领取任务的用户 ID */ + private Long claimedBy; + + /** 领取时间 */ + private LocalDateTime claimedAt; + + /** 提交时间 */ + private LocalDateTime submittedAt; + + /** 完成时间(APPROVED 时设置) */ + private LocalDateTime completedAt; + + /** 是否最终结果(APPROVED 且无需再审)*/ + private Boolean isFinal; + + /** 使用的 AI 模型名称 */ + private String aiModel; + + /** 驳回原因 */ + private String rejectReason; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/entity/AnnotationTaskHistory.java b/src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java similarity index 57% rename from src/main/java/com/label/entity/AnnotationTaskHistory.java rename to src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java index 11259bb..6e2c638 100644 --- a/src/main/java/com/label/entity/AnnotationTaskHistory.java +++ b/src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java @@ -1,42 +1,43 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Builder; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 浠诲姟鐘舵€佸巻鍙诧紝瀵瑰簲 annotation_task_history 琛紙浠呰拷鍔狅紝鏃?UPDATE/DELETE锛夈€? */ -@Data -@Builder -@TableName("annotation_task_history") -public class AnnotationTaskHistory { - - @TableId(type = IdType.AUTO) - private Long id; - - private Long taskId; - - /** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/ - private Long companyId; - - /** 杞崲鍓嶇姸鎬侊紙棣栨鎻掑叆鏃朵负 null锛?*/ - private String fromStatus; - - /** 杞崲鍚庣姸鎬?*/ - private String toStatus; - - /** 鎿嶄綔浜?ID */ - private Long operatorId; - - /** 鎿嶄綔浜鸿鑹?*/ - private String operatorRole; - - /** 澶囨敞锛堥┏鍥炲師鍥犵瓑锛?*/ - private String comment; - - private LocalDateTime createdAt; -} +package com.label.module.task.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 任务状态历史,对应 annotation_task_history 表(仅追加,无 UPDATE/DELETE)。 + */ +@Data +@Builder +@TableName("annotation_task_history") +public class AnnotationTaskHistory { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long taskId; + + /** 所属公司(多租户键) */ + private Long companyId; + + /** 转换前状态(首次插入时为 null) */ + private String fromStatus; + + /** 转换后状态 */ + private String toStatus; + + /** 操作人 ID */ + private Long operatorId; + + /** 操作人角色 */ + private String operatorRole; + + /** 备注(驳回原因等) */ + private String comment; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/label/module/task/mapper/AnnotationTaskMapper.java b/src/main/java/com/label/module/task/mapper/AnnotationTaskMapper.java new file mode 100644 index 0000000..3159e54 --- /dev/null +++ b/src/main/java/com/label/module/task/mapper/AnnotationTaskMapper.java @@ -0,0 +1,30 @@ +package com.label.module.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.task.entity.AnnotationTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** + * annotation_task 表 Mapper。 + */ +@Mapper +public interface AnnotationTaskMapper extends BaseMapper { + + /** + * 原子性领取任务:仅当任务为 UNCLAIMED 且属于当前租户时才更新。 + * 使用乐观 WHERE 条件实现并发安全(依赖数据库行级锁)。 + * + * @param taskId 任务 ID + * @param userId 领取用户 ID + * @param companyId 当前租户 + * @return 影响行数(0 = 任务已被他人领取或不存在) + */ + @Update("UPDATE annotation_task " + + "SET status = 'IN_PROGRESS', claimed_by = #{userId}, claimed_at = NOW(), updated_at = NOW() " + + "WHERE id = #{taskId} AND status = 'UNCLAIMED' AND company_id = #{companyId}") + int claimTask(@Param("taskId") Long taskId, + @Param("userId") Long userId, + @Param("companyId") Long companyId); +} diff --git a/src/main/java/com/label/module/task/mapper/TaskHistoryMapper.java b/src/main/java/com/label/module/task/mapper/TaskHistoryMapper.java new file mode 100644 index 0000000..c4f2f2a --- /dev/null +++ b/src/main/java/com/label/module/task/mapper/TaskHistoryMapper.java @@ -0,0 +1,14 @@ +package com.label.module.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.task.entity.AnnotationTaskHistory; +import org.apache.ibatis.annotations.Mapper; + +/** + * annotation_task_history 表 Mapper(仅追加,禁止 UPDATE/DELETE)。 + */ +@Mapper +public interface TaskHistoryMapper extends BaseMapper { + // 继承 BaseMapper 的 insert 用于追加历史记录 + // 严禁调用 update/delete 相关方法 +} diff --git a/src/main/java/com/label/module/task/service/TaskClaimService.java b/src/main/java/com/label/module/task/service/TaskClaimService.java index ed3c9f3..9f8f883 100644 --- a/src/main/java/com/label/module/task/service/TaskClaimService.java +++ b/src/main/java/com/label/module/task/service/TaskClaimService.java @@ -7,10 +7,10 @@ import com.label.common.redis.RedisService; import com.label.common.shiro.TokenPrincipal; import com.label.common.statemachine.StateValidator; import com.label.common.statemachine.TaskStatus; -import com.label.entity.AnnotationTask; -import com.label.entity.AnnotationTaskHistory; -import com.label.mapper.AnnotationTaskMapper; -import com.label.mapper.TaskHistoryMapper; +import com.label.module.task.entity.AnnotationTask; +import com.label.module.task.entity.AnnotationTaskHistory; +import com.label.module.task.mapper.AnnotationTaskMapper; +import com.label.module.task.mapper.TaskHistoryMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -18,68 +18,76 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * 浠诲姟棰嗗彇/鏀惧純/閲嶉鏈嶅姟銆? * - * 骞跺彂瀹夊叏璁捐锛? * 1. Redis SET NX 浣滀负鍒嗗竷寮忛閿侊紙TTL 30s锛夛紝蹇€熸嫆缁濆苟鍙戣姹? * 2. DB UPDATE WHERE status='UNCLAIMED' 浣滀负鍏滃簳鍘熷瓙鎿嶄綔 - * 涓ゅ眰闃叉姢纭繚鍚屼竴浠诲姟鍙湁涓€浜哄彲棰嗗彇 + * 任务领取/放弃/重领服务。 + * + * 并发安全设计: + * 1. Redis SET NX 作为分布式预锁(TTL 30s),快速拒绝并发请求 + * 2. DB UPDATE WHERE status='UNCLAIMED' 作为兜底原子操作 + * 两层防护确保同一任务只有一人可领取 */ @Slf4j @Service @RequiredArgsConstructor public class TaskClaimService { - /** Redis 鍒嗗竷寮忛攣 TTL锛堢锛?*/ + /** Redis 分布式锁 TTL(秒) */ private static final long CLAIM_LOCK_TTL = 30L; private final AnnotationTaskMapper taskMapper; private final TaskHistoryMapper historyMapper; private final RedisService redisService; - // ------------------------------------------------------------------ 棰嗗彇 -- + // ------------------------------------------------------------------ 领取 -- /** - * 棰嗗彇浠诲姟锛堝弻閲嶉槻鎶わ細Redis NX + DB 鍘熷瓙鏇存柊锛夈€? * - * @param taskId 浠诲姟 ID - * @param principal 褰撳墠鐢ㄦ埛 - * @throws BusinessException TASK_CLAIMED(409) 浠诲姟宸茶浠栦汉棰嗗彇 + * 领取任务(双重防护:Redis NX + DB 原子更新)。 + * + * @param taskId 任务 ID + * @param principal 当前用户 + * @throws BusinessException TASK_CLAIMED(409) 任务已被他人领取 */ @Transactional public void claim(Long taskId, TokenPrincipal principal) { String lockKey = RedisKeyManager.taskClaimKey(taskId); - // 1. Redis SET NX 棰勯攣锛堝揩閫熷け璐ワ級 + // 1. Redis SET NX 预锁(快速失败) boolean lockAcquired = redisService.setIfAbsent( lockKey, principal.getUserId().toString(), CLAIM_LOCK_TTL); if (!lockAcquired) { - throw new BusinessException("TASK_CLAIMED", "浠诲姟宸茶浠栦汉棰嗗彇锛岃閫夋嫨鍏朵粬浠诲姟", HttpStatus.CONFLICT); + throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT); } try { - // 2. DB 鍘熷瓙鏇存柊锛圵HERE status='UNCLAIMED' 鍏滃簳锛? int affected = taskMapper.claimTask(taskId, principal.getUserId(), principal.getCompanyId()); + // 2. DB 原子更新(WHERE status='UNCLAIMED' 兜底) + int affected = taskMapper.claimTask(taskId, principal.getUserId(), principal.getCompanyId()); if (affected == 0) { - // DB 鏇存柊澶辫触璇存槑浠诲姟鐘舵€佸凡鍙橈紝娓呴櫎鍒氳缃殑閿? redisService.delete(lockKey); - throw new BusinessException("TASK_CLAIMED", "浠诲姟宸茶浠栦汉棰嗗彇锛岃閫夋嫨鍏朵粬浠诲姟", HttpStatus.CONFLICT); + // DB 更新失败说明任务状态已变,清除刚设置的锁 + redisService.delete(lockKey); + throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT); } - // 3. 鍐欏叆鐘舵€佸巻鍙? insertHistory(taskId, principal.getCompanyId(), + // 3. 写入状态历史 + insertHistory(taskId, principal.getCompanyId(), "UNCLAIMED", "IN_PROGRESS", principal.getUserId(), principal.getRole(), null); - log.info("浠诲姟棰嗗彇鎴愬姛: taskId={}, userId={}", taskId, principal.getUserId()); + log.info("任务领取成功: taskId={}, userId={}", taskId, principal.getUserId()); } catch (BusinessException e) { - throw e; // 涓氬姟寮傚父鐩存帴涓婃姏锛岄攣宸插湪涓婃柟娓呴櫎 + throw e; // 业务异常直接上抛,锁已在上方清除 } catch (Exception e) { - // DB 鍐欏叆寮傚父锛堝惈 insertHistory 澶辫触锛夛細娓呴櫎 Redis 閿侊紝浜嬪姟鍥炴粴 + // DB 写入异常(含 insertHistory 失败):清除 Redis 锁,事务回滚 redisService.delete(lockKey); throw e; } } - // ------------------------------------------------------------------ 鏀惧純 -- + // ------------------------------------------------------------------ 放弃 -- /** - * 鏀惧純浠诲姟锛圛N_PROGRESS 鈫?UNCLAIMED锛夈€? * - * @param taskId 浠诲姟 ID - * @param principal 褰撳墠鐢ㄦ埛 + * 放弃任务(IN_PROGRESS → UNCLAIMED)。 + * + * @param taskId 任务 ID + * @param principal 当前用户 */ @Transactional public void unclaim(Long taskId, TokenPrincipal principal) { @@ -95,7 +103,7 @@ public class TaskClaimService { .set(AnnotationTask::getClaimedBy, null) .set(AnnotationTask::getClaimedAt, null)); - // 娓呴櫎 Redis 鍒嗗竷寮忛攣 + // 清除 Redis 分布式锁 redisService.delete(RedisKeyManager.taskClaimKey(taskId)); insertHistory(taskId, principal.getCompanyId(), @@ -103,12 +111,13 @@ public class TaskClaimService { principal.getUserId(), principal.getRole(), null); } - // ------------------------------------------------------------------ 閲嶉 -- + // ------------------------------------------------------------------ 重领 -- /** - * 閲嶉浠诲姟锛圧EJECTED 鈫?IN_PROGRESS锛屼粎鍘熼鍙栦汉鍙噸棰嗭級銆? * - * @param taskId 浠诲姟 ID - * @param principal 褰撳墠鐢ㄦ埛 + * 重领任务(REJECTED → IN_PROGRESS,仅原领取人可重领)。 + * + * @param taskId 任务 ID + * @param principal 当前用户 */ @Transactional public void reclaim(Long taskId, TokenPrincipal principal) { @@ -117,12 +126,12 @@ public class TaskClaimService { if (!"REJECTED".equals(task.getStatus())) { throw new BusinessException("INVALID_STATE_TRANSITION", - "鍙湁 REJECTED 鐘舵€佺殑浠诲姟鍙互閲嶉", HttpStatus.CONFLICT); + "只有 REJECTED 状态的任务可以重领", HttpStatus.CONFLICT); } if (!principal.getUserId().equals(task.getClaimedBy())) { throw new BusinessException("FORBIDDEN", - "鍙湁鍘熼鍙栦汉鍙互閲嶉璇ヤ换鍔?, HttpStatus.FORBIDDEN); + "只有原领取人可以重领该任务", HttpStatus.FORBIDDEN); } StateValidator.assertTransition(TaskStatus.TRANSITIONS, @@ -134,7 +143,8 @@ public class TaskClaimService { .set(AnnotationTask::getStatus, "IN_PROGRESS") .set(AnnotationTask::getClaimedAt, java.time.LocalDateTime.now())); - // 閲嶆柊璁剧疆 Redis 閿侊紙闃叉骞跺彂鍐嶆浜夋姠锛? redisService.setIfAbsent( + // 重新设置 Redis 锁(防止并发再次争抢) + redisService.setIfAbsent( RedisKeyManager.taskClaimKey(taskId), principal.getUserId().toString(), CLAIM_LOCK_TTL); @@ -143,16 +153,17 @@ public class TaskClaimService { principal.getUserId(), principal.getRole(), null); } - // ------------------------------------------------------------------ 绉佹湁宸ュ叿 -- + // ------------------------------------------------------------------ 私有工具 -- private void validateTaskExists(AnnotationTask task, Long taskId) { if (task == null) { - throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND); } } /** - * 鍚?annotation_task_history 杩藉姞涓€鏉″巻鍙茶褰曪紙浠?INSERT锛岀姝?UPDATE/DELETE锛夈€? */ + * 向 annotation_task_history 追加一条历史记录(仅 INSERT,禁止 UPDATE/DELETE)。 + */ public void insertHistory(Long taskId, Long companyId, String fromStatus, String toStatus, Long operatorId, String operatorRole, String comment) { diff --git a/src/main/java/com/label/module/task/service/TaskService.java b/src/main/java/com/label/module/task/service/TaskService.java index def3cdf..042ee28 100644 --- a/src/main/java/com/label/module/task/service/TaskService.java +++ b/src/main/java/com/label/module/task/service/TaskService.java @@ -6,9 +6,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.label.common.exception.BusinessException; import com.label.common.result.PageResult; import com.label.common.shiro.TokenPrincipal; -import com.label.dto.TaskResponse; -import com.label.entity.AnnotationTask; -import com.label.mapper.AnnotationTaskMapper; +import com.label.module.task.dto.TaskResponse; +import com.label.module.task.entity.AnnotationTask; +import com.label.module.task.mapper.AnnotationTaskMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -19,7 +19,8 @@ import java.util.List; import java.util.stream.Collectors; /** - * 浠诲姟绠$悊鏈嶅姟锛氬垱寤恒€佹煡璇换鍔℃睜銆佹垜鐨勪换鍔°€佸緟瀹℃壒闃熷垪銆佹寚娲俱€? */ + * 任务管理服务:创建、查询任务池、我的任务、待审批队列、指派。 + */ @Slf4j @Service @RequiredArgsConstructor @@ -28,13 +29,16 @@ public class TaskService { private final AnnotationTaskMapper taskMapper; private final TaskClaimService taskClaimService; - // ------------------------------------------------------------------ 鍒涘缓 -- + // ------------------------------------------------------------------ 创建 -- /** - * 鍒涘缓鏍囨敞浠诲姟锛堝唴閮ㄨ皟鐢紝渚嬪瑙嗛澶勭悊瀹屾垚鍚庯級銆? * - * @param sourceId 璧勬枡 ID - * @param taskType 浠诲姟绫诲瀷锛圗XTRACTION / QA_GENERATION锛? * @param companyId 绉熸埛 ID - * @return 鏂颁换鍔? */ + * 创建标注任务(内部调用,例如视频处理完成后)。 + * + * @param sourceId 资料 ID + * @param taskType 任务类型(EXTRACTION / QA_GENERATION) + * @param companyId 租户 ID + * @return 新任务 + */ @Transactional public AnnotationTask createTask(Long sourceId, String taskType, Long companyId) { AnnotationTask task = new AnnotationTask(); @@ -44,14 +48,17 @@ public class TaskService { task.setStatus("UNCLAIMED"); task.setIsFinal(false); taskMapper.insert(task); - log.info("浠诲姟宸插垱寤? id={}, type={}, sourceId={}", task.getId(), taskType, sourceId); + log.info("任务已创建: id={}, type={}, sourceId={}", task.getId(), taskType, sourceId); return task; } - // ------------------------------------------------------------------ 浠诲姟姹?-- + // ------------------------------------------------------------------ 任务池 -- /** - * 鏌ヨ浠诲姟姹狅紙鎸夎鑹茶繃婊わ級锛? * - ANNOTATOR 鈫?EXTRACTION 绫诲瀷銆乁NCLAIMED 鐘舵€? * - REVIEWER/ADMIN 鈫?SUBMITTED 鐘舵€侊紙浠绘剰绫诲瀷锛? */ + * 查询任务池(按角色过滤): + * - ANNOTATOR → EXTRACTION 类型、UNCLAIMED 状态 + * - REVIEWER/ADMIN → SUBMITTED 状态(任意类型) + */ public PageResult getPool(int page, int pageSize, TokenPrincipal principal) { pageSize = Math.min(pageSize, 100); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -62,7 +69,7 @@ public class TaskService { wrapper.eq(AnnotationTask::getTaskType, "EXTRACTION") .eq(AnnotationTask::getStatus, "UNCLAIMED"); } else { - // REVIEWER / ADMIN 鐪嬪緟瀹℃壒闃熷垪 + // REVIEWER / ADMIN 看待审批队列 wrapper.eq(AnnotationTask::getStatus, "SUBMITTED"); } @@ -70,10 +77,11 @@ public class TaskService { return toPageResult(pageResult, page, pageSize); } - // ------------------------------------------------------------------ 鎴戠殑浠诲姟 -- + // ------------------------------------------------------------------ 我的任务 -- /** - * 鏌ヨ褰撳墠鐢ㄦ埛鐨勪换鍔★紙IN_PROGRESS銆丼UBMITTED銆丷EJECTED锛夈€? */ + * 查询当前用户的任务(IN_PROGRESS、SUBMITTED、REJECTED)。 + */ public PageResult getMine(int page, int pageSize, String status, TokenPrincipal principal) { pageSize = Math.min(pageSize, 100); @@ -90,10 +98,11 @@ public class TaskService { return toPageResult(pageResult, page, pageSize); } - // ------------------------------------------------------------------ 寰呭鎵?-- + // ------------------------------------------------------------------ 待审批 -- /** - * 鏌ヨ寰呭鎵逛换鍔★紙REVIEWER 涓撳睘锛宻tatus=SUBMITTED锛夈€? */ + * 查询待审批任务(REVIEWER 专属,status=SUBMITTED)。 + */ public PageResult getPendingReview(int page, int pageSize, String taskType) { pageSize = Math.min(pageSize, 100); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -108,20 +117,21 @@ public class TaskService { return toPageResult(pageResult, page, pageSize); } - // ------------------------------------------------------------------ 鏌ヨ鍗曟潯 -- + // ------------------------------------------------------------------ 查询单条 -- public AnnotationTask getById(Long id) { AnnotationTask task = taskMapper.selectById(id); if (task == null) { - throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + id, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "任务不存在: " + id, HttpStatus.NOT_FOUND); } return task; } - // ------------------------------------------------------------------ 鍏ㄩ儴浠诲姟锛圓DMIN锛?- + // ------------------------------------------------------------------ 全部任务(ADMIN)-- /** - * 鏌ヨ鍏ㄩ儴浠诲姟锛圓DMIN 涓撶敤锛夈€? */ + * 查询全部任务(ADMIN 专用)。 + */ public PageResult getAll(int page, int pageSize, String status, String taskType) { pageSize = Math.min(pageSize, 100); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -138,15 +148,16 @@ public class TaskService { return toPageResult(pageResult, page, pageSize); } - // ------------------------------------------------------------------ 鎸囨淳锛圓DMIN锛?- + // ------------------------------------------------------------------ 指派(ADMIN)-- /** - * ADMIN 寮哄埗鎸囨淳浠诲姟缁欐寚瀹氱敤鎴凤紙IN_PROGRESS 鈫?IN_PROGRESS锛夈€? */ + * ADMIN 强制指派任务给指定用户(IN_PROGRESS → IN_PROGRESS)。 + */ @Transactional public void reassign(Long taskId, Long targetUserId, TokenPrincipal principal) { AnnotationTask task = taskMapper.selectById(taskId); if (task == null || !principal.getCompanyId().equals(task.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND); } taskMapper.update(null, new LambdaUpdateWrapper() @@ -157,10 +168,10 @@ public class TaskService { taskClaimService.insertHistory(taskId, principal.getCompanyId(), task.getStatus(), "IN_PROGRESS", principal.getUserId(), principal.getRole(), - "ADMIN 寮哄埗鎸囨淳缁欑敤鎴?" + targetUserId); + "ADMIN 强制指派给用户 " + targetUserId); } - // ------------------------------------------------------------------ 绉佹湁宸ュ叿 -- + // ------------------------------------------------------------------ 私有工具 -- private PageResult toPageResult(Page pageResult, int page, int pageSize) { List items = pageResult.getRecords().stream() diff --git a/src/main/java/com/label/module/user/controller/AuthController.java b/src/main/java/com/label/module/user/controller/AuthController.java index 2257cf5..c3e8f24 100644 --- a/src/main/java/com/label/module/user/controller/AuthController.java +++ b/src/main/java/com/label/module/user/controller/AuthController.java @@ -2,9 +2,9 @@ package com.label.module.user.controller; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; -import com.label.dto.LoginRequest; -import com.label.dto.LoginResponse; -import com.label.dto.UserInfoResponse; +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; @@ -13,9 +13,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; /** - * 璁よ瘉鎺ュ彛锛氱櫥褰曘€侀€€鍑恒€佽幏鍙栧綋鍓嶇敤鎴枫€? * - * 璺敱璁捐锛? * - POST /api/auth/login 鈫?鍖垮悕锛圱okenFilter.shouldNotFilter 璺宠繃锛? * - POST /api/auth/logout 鈫?闇€瑕佹湁鏁?Token锛圱okenFilter 鏍¢獙锛? * - GET /api/auth/me 鈫?闇€瑕佹湁鏁?Token锛圱okenFilter 鏍¢獙锛? */ -@Tag(name = "璁よ瘉绠$悊", description = "鐧诲綍銆侀€€鍑哄拰褰撳墠鐢ㄦ埛淇℃伅") + * 认证接口:登录、退出、获取当前用户。 + * + * 路由设计: + * - POST /api/auth/login → 匿名(TokenFilter.shouldNotFilter 跳过) + * - POST /api/auth/logout → 需要有效 Token(TokenFilter 校验) + * - GET /api/auth/me → 需要有效 Token(TokenFilter 校验) + */ +@Tag(name = "认证管理", description = "登录、退出和当前用户信息") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -24,16 +29,18 @@ public class AuthController { private final AuthService authService; /** - * 鐧诲綍鎺ュ彛锛堝尶鍚嶏紝鏃犻渶 Token锛夈€? */ - @Operation(summary = "鐢ㄦ埛鐧诲綍锛岃繑鍥?Bearer Token") + * 登录接口(匿名,无需 Token)。 + */ + @Operation(summary = "用户登录,返回 Bearer Token") @PostMapping("/login") public Result login(@RequestBody LoginRequest request) { return Result.success(authService.login(request)); } /** - * 閫€鍑虹櫥褰曪紝绔嬪嵆鍒犻櫎 Redis Token銆? */ - @Operation(summary = "閫€鍑虹櫥褰曞苟绔嬪嵆澶辨晥褰撳墠 Token") + * 退出登录,立即删除 Redis Token。 + */ + @Operation(summary = "退出登录并立即失效当前 Token") @PostMapping("/logout") public Result logout(HttpServletRequest request) { String token = extractToken(request); @@ -42,15 +49,17 @@ public class AuthController { } /** - * 鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛淇℃伅銆? * TokenPrincipal 鐢?TokenFilter 鍐欏叆璇锋眰灞炴€?"__token_principal__"銆? */ - @Operation(summary = "鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛淇℃伅") + * 获取当前登录用户信息。 + * TokenPrincipal 由 TokenFilter 写入请求属性 "__token_principal__"。 + */ + @Operation(summary = "获取当前登录用户信息") @GetMapping("/me") public Result me(HttpServletRequest request) { TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); return Result.success(authService.me(principal)); } - /** 浠?Authorization 澶存彁鍙?Bearer token 瀛楃涓?*/ + /** 从 Authorization 头提取 Bearer token 字符串 */ private String extractToken(HttpServletRequest request) { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { diff --git a/src/main/java/com/label/module/user/controller/UserController.java b/src/main/java/com/label/module/user/controller/UserController.java index e91ff45..c31b9eb 100644 --- a/src/main/java/com/label/module/user/controller/UserController.java +++ b/src/main/java/com/label/module/user/controller/UserController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController; import com.label.common.result.PageResult; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; -import com.label.entity.SysUser; +import com.label.module.user.entity.SysUser; import com.label.module.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; @@ -24,8 +24,9 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; /** - * 鐢ㄦ埛绠$悊鎺ュ彛锛? 涓鐐癸紝鍏ㄩ儴 ADMIN 鏉冮檺锛夈€? */ -@Tag(name = "鐢ㄦ埛绠$悊", description = "绠$悊鍛樼淮鎶ゅ叕鍙哥敤鎴?) + * 用户管理接口(5 个端点,全部 ADMIN 权限)。 + */ +@Tag(name = "用户管理", description = "管理员维护公司用户") @RestController @RequestMapping("/api/users") @RequiredArgsConstructor @@ -33,8 +34,8 @@ public class UserController { private final UserService userService; - /** GET /api/users 鈥?鍒嗛〉鏌ヨ鐢ㄦ埛鍒楄〃 */ - @Operation(summary = "鍒嗛〉鏌ヨ鐢ㄦ埛鍒楄〃") + /** GET /api/users — 分页查询用户列表 */ + @Operation(summary = "分页查询用户列表") @GetMapping @RequiresRoles("ADMIN") public Result> listUsers( @@ -44,8 +45,8 @@ public class UserController { return Result.success(userService.listUsers(page, pageSize, principal(request))); } - /** POST /api/users 鈥?鍒涘缓鐢ㄦ埛 */ - @Operation(summary = "鍒涘缓鐢ㄦ埛") + /** POST /api/users — 创建用户 */ + @Operation(summary = "创建用户") @PostMapping @RequiresRoles("ADMIN") public Result createUser(@RequestBody Map body, @@ -58,8 +59,8 @@ public class UserController { principal(request))); } - /** PUT /api/users/{id} 鈥?鏇存柊鐢ㄦ埛鍩烘湰淇℃伅 */ - @Operation(summary = "鏇存柊鐢ㄦ埛鍩烘湰淇℃伅") + /** PUT /api/users/{id} — 更新用户基本信息 */ + @Operation(summary = "更新用户基本信息") @PutMapping("/{id}") @RequiresRoles("ADMIN") public Result updateUser(@PathVariable Long id, @@ -72,8 +73,8 @@ public class UserController { principal(request))); } - /** PUT /api/users/{id}/status 鈥?鍙樻洿鐢ㄦ埛鐘舵€?*/ - @Operation(summary = "鍙樻洿鐢ㄦ埛鐘舵€?, description = "status锛欰CTIVE銆丏ISABLED") + /** PUT /api/users/{id}/status — 变更用户状态 */ + @Operation(summary = "变更用户状态", description = "status:ACTIVE、DISABLED") @PutMapping("/{id}/status") @RequiresRoles("ADMIN") public Result updateStatus(@PathVariable Long id, @@ -83,8 +84,8 @@ public class UserController { return Result.success(null); } - /** PUT /api/users/{id}/role 鈥?鍙樻洿鐢ㄦ埛瑙掕壊 */ - @Operation(summary = "鍙樻洿鐢ㄦ埛瑙掕壊", description = "role锛欰DMIN銆乁PLOADER銆乂IEWER") + /** PUT /api/users/{id}/role — 变更用户角色 */ + @Operation(summary = "变更用户角色", description = "role:ADMIN、UPLOADER、VIEWER") @PutMapping("/{id}/role") @RequiresRoles("ADMIN") public Result updateRole(@PathVariable Long id, diff --git a/src/main/java/com/label/module/user/dto/LoginRequest.java b/src/main/java/com/label/module/user/dto/LoginRequest.java new file mode 100644 index 0000000..e71d4b2 --- /dev/null +++ b/src/main/java/com/label/module/user/dto/LoginRequest.java @@ -0,0 +1,21 @@ +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 = "DEMO") + private String companyCode; + /** 登录用户名 */ + @Schema(description = "登录用户名", example = "admin") + private String username; + /** 明文密码(传输层应使用 HTTPS 保护) */ + @Schema(description = "明文密码", example = "admin123") + private String password; +} diff --git a/src/main/java/com/label/module/user/dto/LoginResponse.java b/src/main/java/com/label/module/user/dto/LoginResponse.java new file mode 100644 index 0000000..6ed6a5c --- /dev/null +++ b/src/main/java/com/label/module/user/dto/LoginResponse.java @@ -0,0 +1,29 @@ +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 { + /** Bearer Token(UUID v4),后续请求放入 Authorization 头 */ + @Schema(description = "Bearer Token", example = "550e8400-e29b-41d4-a716-446655440000") + private String token; + /** 用户主键 */ + @Schema(description = "用户主键") + private Long userId; + /** 登录用户名 */ + @Schema(description = "登录用户名") + private String username; + /** 角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN */ + @Schema(description = "角色", example = "ADMIN") + private String role; + /** Token 有效期(秒) */ + @Schema(description = "Token 有效期(秒)", example = "7200") + private Long expiresIn; +} diff --git a/src/main/java/com/label/module/user/dto/UserInfoResponse.java b/src/main/java/com/label/module/user/dto/UserInfoResponse.java new file mode 100644 index 0000000..bf60ae0 --- /dev/null +++ b/src/main/java/com/label/module/user/dto/UserInfoResponse.java @@ -0,0 +1,26 @@ +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 = "角色", example = "ADMIN") + private String role; + @Schema(description = "所属公司 ID") + private Long companyId; + @Schema(description = "所属公司名称") + private String companyName; +} diff --git a/src/main/java/com/label/entity/SysCompany.java b/src/main/java/com/label/module/user/entity/SysCompany.java similarity index 56% rename from src/main/java/com/label/entity/SysCompany.java rename to src/main/java/com/label/module/user/entity/SysCompany.java index 6028594..9f79582 100644 --- a/src/main/java/com/label/entity/SysCompany.java +++ b/src/main/java/com/label/module/user/entity/SysCompany.java @@ -1,33 +1,34 @@ -package com.label.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 绉熸埛鍏徃瀹炰綋锛屽搴?sys_company 琛ㄣ€? * status 鍙栧€硷細ACTIVE / DISABLED - */ -@Data -@TableName("sys_company") -public class SysCompany { - - /** 鍏徃涓婚敭锛岃嚜澧?*/ - @TableId(type = IdType.AUTO) - private Long id; - - /** 鍏徃鍏ㄧО锛屽叏灞€鍞竴 */ - private String companyName; - - /** 鍏徃浠g爜锛堣嫳鏂囩畝鍐欙級锛屽叏灞€鍞竴 */ - private String companyCode; - - /** 鐘舵€侊細ACTIVE / DISABLED */ - private String status; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; -} +package com.label.module.user.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 租户公司实体,对应 sys_company 表。 + * status 取值:ACTIVE / DISABLED + */ +@Data +@TableName("sys_company") +public class SysCompany { + + /** 公司主键,自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 公司全称,全局唯一 */ + private String companyName; + + /** 公司代码(英文简写),全局唯一 */ + private String companyCode; + + /** 状态:ACTIVE / DISABLED */ + private String status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/module/user/entity/SysUser.java b/src/main/java/com/label/module/user/entity/SysUser.java new file mode 100644 index 0000000..95307f7 --- /dev/null +++ b/src/main/java/com/label/module/user/entity/SysUser.java @@ -0,0 +1,49 @@ +package com.label.module.user.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 系统用户实体,对应 sys_user 表。 + * role 取值:UPLOADER / ANNOTATOR / REVIEWER / ADMIN + * status 取值:ACTIVE / DISABLED + */ +@Data +@TableName("sys_user") +public class SysUser { + + /** 用户主键,自增 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属公司 ID(多租户键) */ + private Long companyId; + + /** 登录用户名(同公司内唯一) */ + private String username; + + /** + * BCrypt 哈希密码(strength ≥ 10)。 + * 序列化时排除,防止密码哈希泄漏到 API 响应。 + */ + @JsonIgnore + private String passwordHash; + + /** 真实姓名 */ + private String realName; + + /** 角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN */ + private String role; + + /** 状态:ACTIVE / DISABLED */ + private String status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/module/user/mapper/SysCompanyMapper.java b/src/main/java/com/label/module/user/mapper/SysCompanyMapper.java new file mode 100644 index 0000000..f22b053 --- /dev/null +++ b/src/main/java/com/label/module/user/mapper/SysCompanyMapper.java @@ -0,0 +1,23 @@ +package com.label.module.user.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.user.entity.SysCompany; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +/** + * sys_company 表 Mapper。 + * 继承 BaseMapper 获得标准 CRUD;自定义方法用注解 SQL。 + */ +@Mapper +public interface SysCompanyMapper extends BaseMapper { + + /** + * 按公司代码查询公司(忽略多租户过滤,sys_company 无 company_id 字段)。 + * + * @param companyCode 公司代码 + * @return 公司实体,不存在则返回 null + */ + @Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}") + SysCompany selectByCompanyCode(String companyCode); +} diff --git a/src/main/java/com/label/module/user/mapper/SysUserMapper.java b/src/main/java/com/label/module/user/mapper/SysUserMapper.java new file mode 100644 index 0000000..fae02b5 --- /dev/null +++ b/src/main/java/com/label/module/user/mapper/SysUserMapper.java @@ -0,0 +1,34 @@ +package com.label.module.user.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.user.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * sys_user 表 Mapper。 + * 继承 BaseMapper 获得标准 CRUD;自定义登录查询方法绕过多租户过滤器, + * 由调用方显式传入 companyId。 + */ +@Mapper +public interface SysUserMapper extends BaseMapper { + + /** + * 按公司 ID + 用户名查询用户(登录场景使用)。 + *

+ * 使用 @InterceptorIgnore 绕过 TenantLineInnerInterceptor, + * 由参数 companyId 显式限定租户,防止登录时 CompanyContext 尚未注入 + * 导致查询条件变为 {@code company_id = NULL}。 + *

+ * + * @param companyId 公司 ID + * @param username 用户名 + * @return 用户实体(含 passwordHash),不存在则返回 null + */ + @InterceptorIgnore(tenantLine = "true") + @Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'") + SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId, + @Param("username") String username); +} diff --git a/src/main/java/com/label/module/user/service/AuthService.java b/src/main/java/com/label/module/user/service/AuthService.java index e192a6d..cc3709a 100644 --- a/src/main/java/com/label/module/user/service/AuthService.java +++ b/src/main/java/com/label/module/user/service/AuthService.java @@ -4,13 +4,13 @@ import com.label.common.exception.BusinessException; import com.label.common.redis.RedisKeyManager; import com.label.common.redis.RedisService; import com.label.common.shiro.TokenPrincipal; -import com.label.dto.LoginRequest; -import com.label.dto.LoginResponse; -import com.label.dto.UserInfoResponse; -import com.label.entity.SysCompany; -import com.label.entity.SysUser; -import com.label.mapper.SysCompanyMapper; -import com.label.mapper.SysUserMapper; +import com.label.module.user.dto.LoginRequest; +import com.label.module.user.dto.LoginResponse; +import com.label.module.user.dto.UserInfoResponse; +import com.label.module.user.entity.SysCompany; +import com.label.module.user.entity.SysUser; +import com.label.module.user.mapper.SysCompanyMapper; +import com.label.module.user.mapper.SysUserMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -23,9 +23,11 @@ import java.util.Map; import java.util.UUID; /** - * 璁よ瘉鏈嶅姟锛氱櫥褰曘€侀€€鍑恒€佹煡璇㈠綋鍓嶇敤鎴蜂俊鎭€? * - * Token 鐢熷懡鍛ㄦ湡锛? * - 鐧诲綍鎴愬姛 鈫?UUID v4 鈫?Redis Hash token:{uuid} 鈫?TTL = token.ttl-seconds - * - 閫€鍑虹櫥褰?鈫?鐩存帴 DEL token:{uuid}锛堢珛鍗冲け鏁堬級 + * 认证服务:登录、退出、查询当前用户信息。 + * + * Token 生命周期: + * - 登录成功 → UUID v4 → Redis Hash token:{uuid} → TTL = token.ttl-seconds + * - 退出登录 → 直接 DEL token:{uuid}(立即失效) */ @Slf4j @Service @@ -36,41 +38,45 @@ public class AuthService { private final SysUserMapper userMapper; private final RedisService redisService; - /** BCryptPasswordEncoder 绾跨▼瀹夊叏锛屽彲澶嶇敤 */ + /** BCryptPasswordEncoder 线程安全,可复用 */ private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10); @Value("${token.ttl-seconds:7200}") private long tokenTtlSeconds; /** - * 鐢ㄦ埛鐧诲綍銆? * - * @param request 鍖呭惈 companyCode / username / password - * @return LoginResponse锛堝惈 token銆乽serId銆乺ole銆乪xpiresIn锛? * @throws BusinessException USER_NOT_FOUND(401) 鍑瘉閿欒 - * @throws BusinessException USER_DISABLED(403) 璐﹀彿宸茬鐢? */ + * 用户登录。 + * + * @param request 包含 companyCode / username / password + * @return LoginResponse(含 token、userId、role、expiresIn) + * @throws BusinessException USER_NOT_FOUND(401) 凭证错误 + * @throws BusinessException USER_DISABLED(403) 账号已禁用 + */ public LoginResponse login(LoginRequest request) { - // 1. 鏌ュ叕鍙革紙缁曡繃澶氱鎴疯繃婊ゅ櫒锛宻ys_company 鏃?company_id 瀛楁锛? SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode()); + // 1. 查公司(绕过多租户过滤器,sys_company 无 company_id 字段) + SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode()); if (company == null || !"ACTIVE".equals(company.getStatus())) { - // 鍏徃涓嶅瓨鍦ㄦ垨绂佺敤锛岀粺涓€鎶?USER_NOT_FOUND 闃叉淇℃伅娉勬紡 - throw new BusinessException("USER_NOT_FOUND", "鐢ㄦ埛鍚嶆垨瀵嗙爜閿欒", HttpStatus.UNAUTHORIZED); + // 公司不存在或禁用,统一报 USER_NOT_FOUND 防止信息泄漏 + throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED); } - // 2. 鏌ョ敤鎴凤紙鏄惧紡浼犲叆 companyId锛岀粫杩囧绉熸埛鎷︽埅鍣級 + // 2. 查用户(显式传入 companyId,绕过多租户拦截器) SysUser user = userMapper.selectByCompanyAndUsername(company.getId(), request.getUsername()); if (user == null) { - throw new BusinessException("USER_NOT_FOUND", "鐢ㄦ埛鍚嶆垨瀵嗙爜閿欒", HttpStatus.UNAUTHORIZED); + throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED); } - // 3. 璐﹀彿绂佺敤妫€鏌ワ紙鍏堜簬瀵嗙爜鏍¢獙锛岄槻姝㈡毚鍔涚牬瑙e凡鐭ョ敤鎴风姸鎬侊級 + // 3. 账号禁用检查(先于密码校验,防止暴力破解已知用户状态) if (!"ACTIVE".equals(user.getStatus())) { - throw new BusinessException("USER_DISABLED", "璐﹀彿宸茬鐢紝璇疯仈绯荤鐞嗗憳", HttpStatus.FORBIDDEN); + throw new BusinessException("USER_DISABLED", "账号已禁用,请联系管理员", HttpStatus.FORBIDDEN); } - // 4. BCrypt 瀵嗙爜鏍¢獙 + // 4. BCrypt 密码校验 if (!PASSWORD_ENCODER.matches(request.getPassword(), user.getPasswordHash())) { - throw new BusinessException("USER_NOT_FOUND", "鐢ㄦ埛鍚嶆垨瀵嗙爜閿欒", HttpStatus.UNAUTHORIZED); + throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED); } - // 5. 鐢熸垚 UUID v4 Token锛屽啓鍏?Redis Hash + // 5. 生成 UUID v4 Token,写入 Redis Hash String token = UUID.randomUUID().toString(); Map tokenData = new HashMap<>(); tokenData.put("userId", user.getId().toString()); @@ -79,36 +85,44 @@ public class AuthService { tokenData.put("username", user.getUsername()); redisService.hSetAll(RedisKeyManager.tokenKey(token), tokenData, tokenTtlSeconds); - // 灏?token 鍔犲叆璇ョ敤鎴风殑娲昏穬浼氳瘽闆嗗悎锛堢敤浜庤鑹插彉鏇存椂鎵归噺鏇存柊/澶辨晥锛? String sessionsKey = RedisKeyManager.userSessionsKey(user.getId()); + // 将 token 加入该用户的活跃会话集合(用于角色变更时批量更新/失效) + String sessionsKey = RedisKeyManager.userSessionsKey(user.getId()); redisService.sAdd(sessionsKey, token); - // 闃叉 Set 鏃犻檺澧為暱锛歍TL = token 鏈夋晥鏈燂紙鏈€鍚庝竴娆$櫥褰曟椂婊戝姩缁湡锛? redisService.expire(sessionsKey, tokenTtlSeconds); + // 防止 Set 无限增长:TTL = token 有效期(最后一次登录时滑动续期) + redisService.expire(sessionsKey, tokenTtlSeconds); - log.info("鐢ㄦ埛鐧诲綍鎴愬姛: companyCode={}, username={}", request.getCompanyCode(), request.getUsername()); + log.info("用户登录成功: companyCode={}, username={}", request.getCompanyCode(), request.getUsername()); return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole(), tokenTtlSeconds); } /** - * 閫€鍑虹櫥褰曪紝绔嬪嵆鍒犻櫎 Redis Token锛圱oken 绔嬪嵆澶辨晥锛夈€? * - * @param token 鏉ヨ嚜 Authorization 澶寸殑 Bearer token + * 退出登录,立即删除 Redis Token(Token 立即失效)。 + * + * @param token 来自 Authorization 头的 Bearer token */ public void logout(String token) { if (token != null && !token.isBlank()) { - // 浠庣敤鎴蜂細璇濋泦鍚堜腑绉婚櫎锛堣嫢 token 浠嶆湁鏁堝垯鍏堣鍙?userId锛? String userId = redisService.hGet(RedisKeyManager.tokenKey(token), "userId"); + // 从用户会话集合中移除(若 token 仍有效则先读取 userId) + String userId = redisService.hGet(RedisKeyManager.tokenKey(token), "userId"); redisService.delete(RedisKeyManager.tokenKey(token)); if (userId != null) { try { redisService.sRemove(RedisKeyManager.userSessionsKey(Long.parseLong(userId)), token); } catch (NumberFormatException ignored) {} } - log.info("鐢ㄦ埛閫€鍑猴紝Token 宸插垹闄? {}", token); + log.info("用户退出,Token 已删除: {}", token); } } /** - * 鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛璇︽儏锛堝惈 realName銆乧ompanyName锛夈€? * - * @param principal TokenFilter 娉ㄥ叆鐨勫綋鍓嶇敤鎴蜂富浣? * @return 鐢ㄦ埛淇℃伅鍝嶅簲浣? */ + * 获取当前登录用户详情(含 realName、companyName)。 + * + * @param principal TokenFilter 注入的当前用户主体 + * @return 用户信息响应体 + */ public UserInfoResponse me(TokenPrincipal principal) { - // 浠?DB 鑾峰彇 realName锛圱oken 涓湭瀛樺偍锛? SysUser user = userMapper.selectById(principal.getUserId()); + // 从 DB 获取 realName(Token 中未存储) + SysUser user = userMapper.selectById(principal.getUserId()); SysCompany company = companyMapper.selectById(principal.getCompanyId()); String realName = (user != null) ? user.getRealName() : principal.getUsername(); diff --git a/src/main/java/com/label/module/user/service/UserService.java b/src/main/java/com/label/module/user/service/UserService.java index 48a8150..0f0edd8 100644 --- a/src/main/java/com/label/module/user/service/UserService.java +++ b/src/main/java/com/label/module/user/service/UserService.java @@ -15,17 +15,19 @@ import com.label.common.redis.RedisKeyManager; import com.label.common.redis.RedisService; import com.label.common.result.PageResult; import com.label.common.shiro.TokenPrincipal; -import com.label.entity.SysUser; -import com.label.mapper.SysUserMapper; +import com.label.module.user.entity.SysUser; +import com.label.module.user.mapper.SysUserMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * 鐢ㄦ埛绠$悊鏈嶅姟锛圓DMIN 涓撳睘锛夈€? * - * 鍏抽敭璁捐锛? * - 瑙掕壊鍙樻洿锛欴B 鍐欏叆鍚庣珛鍗虫洿鏂版墍鏈夋椿璺?Token 涓殑 role 瀛楁锛屾棤闇€閲嶆柊鐧诲綍 - * - 鐘舵€佺鐢細DB 鍐欏叆鍚庡垹闄ょ敤鎴锋墍鏈夋椿璺?Token锛堢珛鍗冲け鏁堬級 - * - 浣跨敤 user:sessions:{userId} Set 璺熻釜娲昏穬浼氳瘽 + * 用户管理服务(ADMIN 专属)。 + * + * 关键设计: + * - 角色变更:DB 写入后立即更新所有活跃 Token 中的 role 字段,无需重新登录 + * - 状态禁用:DB 写入后删除用户所有活跃 Token(立即失效) + * - 使用 user:sessions:{userId} Set 跟踪活跃会话 */ @Slf4j @Service @@ -37,20 +39,27 @@ public class UserService { private final SysUserMapper userMapper; private final RedisService redisService; - // ------------------------------------------------------------------ 鍒涘缓鐢ㄦ埛 -- + // ------------------------------------------------------------------ 创建用户 -- /** - * 鍒涘缓鏂扮敤鎴凤紙ADMIN 鎿嶄綔锛夈€? * - * @param username 鐢ㄦ埛鍚? * @param password 鏄庢枃瀵嗙爜锛堝皢浠?BCrypt strength=10 鍝堝笇锛? * @param realName 鐪熷疄濮撳悕锛堝彲閫夛級 - * @param role 瑙掕壊锛圲PLOADER / ANNOTATOR / REVIEWER / ADMIN锛? * @param principal 褰撳墠绠$悊鍛? * @return 鏂板缓鐢ㄦ埛锛堜笉鍚?passwordHash锛? */ + * 创建新用户(ADMIN 操作)。 + * + * @param username 用户名 + * @param password 明文密码(将以 BCrypt strength=10 哈希) + * @param realName 真实姓名(可选) + * @param role 角色(UPLOADER / ANNOTATOR / REVIEWER / ADMIN) + * @param principal 当前管理员 + * @return 新建用户(不含 passwordHash) + */ @Transactional public SysUser createUser(String username, String password, String realName, String role, TokenPrincipal principal) { - // 鏍¢獙鐢ㄦ埛鍚嶅敮涓€鎬? SysUser existing = userMapper.selectByCompanyAndUsername(principal.getCompanyId(), username); + // 校验用户名唯一性 + SysUser existing = userMapper.selectByCompanyAndUsername(principal.getCompanyId(), username); if (existing != null) { throw new BusinessException("DUPLICATE_USERNAME", - "鐢ㄦ埛鍚?'" + username + "' 宸插瓨鍦?, HttpStatus.CONFLICT); + "用户名 '" + username + "' 已存在", HttpStatus.CONFLICT); } validateRole(role); @@ -64,14 +73,15 @@ public class UserService { user.setStatus("ACTIVE"); userMapper.insert(user); - log.info("鐢ㄦ埛宸插垱寤? userId={}, username={}, role={}", user.getId(), username, role); + log.info("用户已创建: userId={}, username={}, role={}", user.getId(), username, role); return user; } - // ------------------------------------------------------------------ 鏇存柊鍩烘湰淇℃伅 -- + // ------------------------------------------------------------------ 更新基本信息 -- /** - * 鏇存柊鐢ㄦ埛鍩烘湰淇℃伅锛坮ealName銆乸assword锛夈€? */ + * 更新用户基本信息(realName、password)。 + */ @Transactional public SysUser updateUser(Long userId, String realName, String password, TokenPrincipal principal) { @@ -93,68 +103,78 @@ public class UserService { return user; } - // ------------------------------------------------------------------ 鍙樻洿瑙掕壊 -- + // ------------------------------------------------------------------ 变更角色 -- /** - * 鍙樻洿鐢ㄦ埛瑙掕壊銆? * - * DB 鍐欏叆鍚庯紝绔嬪嵆鏇存柊璇ョ敤鎴锋墍鏈夋椿璺?Token 涓殑 role 瀛楁锛? * 纭繚瑙掕壊鍙樻洿瀵逛笅涓€娆¤姹傜珛鍗崇敓鏁堬紙鏃犻渶閲嶆柊鐧诲綍锛夈€? * - * @param userId 鐩爣鐢ㄦ埛 ID - * @param newRole 鏂拌鑹? * @param principal 褰撳墠绠$悊鍛? */ + * 变更用户角色。 + * + * DB 写入后,立即更新该用户所有活跃 Token 中的 role 字段, + * 确保角色变更对下一次请求立即生效(无需重新登录)。 + * + * @param userId 目标用户 ID + * @param newRole 新角色 + * @param principal 当前管理员 + */ @Transactional public void updateRole(Long userId, String newRole, TokenPrincipal principal) { getExistingUser(userId, principal.getCompanyId()); validateRole(newRole); - // 1. DB 鍐欏叆 + // 1. DB 写入 userMapper.update(null, new LambdaUpdateWrapper() .eq(SysUser::getId, userId) .eq(SysUser::getCompanyId, principal.getCompanyId()) .set(SysUser::getRole, newRole)); - // 2. 鏇存柊鎵€鏈夋椿璺?Token 涓殑 role 瀛楁锛堢珛鍗崇敓鏁堬紝鏃犻渶閲嶆柊鐧诲綍锛? Set tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId)); + // 2. 更新所有活跃 Token 中的 role 字段(立即生效,无需重新登录) + Set tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId)); tokens.forEach(token -> redisService.hPut(RedisKeyManager.tokenKey(token), "role", newRole)); - // 3. 鍒犻櫎鏉冮檺缂撳瓨锛堝 Shiro 缂撳瓨瀛樺湪锛? redisService.delete(RedisKeyManager.userPermKey(userId)); + // 3. 删除权限缓存(如 Shiro 缓存存在) + redisService.delete(RedisKeyManager.userPermKey(userId)); - log.info("鐢ㄦ埛瑙掕壊宸插彉鏇? userId={}, newRole={}, 鏇存柊 {} 涓椿璺?Token", userId, newRole, tokens.size()); + log.info("用户角色已变更: userId={}, newRole={}, 更新 {} 个活跃 Token", userId, newRole, tokens.size()); } - // ------------------------------------------------------------------ 鍙樻洿鐘舵€?-- + // ------------------------------------------------------------------ 变更状态 -- /** - * 鍙樻洿鐢ㄦ埛鐘舵€侊紙鍚敤/绂佺敤锛夈€? * - * 绂佺敤鏃讹細DB 鍐欏叆鍚庣珛鍗冲垹闄よ鐢ㄦ埛鎵€鏈夋椿璺?Token锛岀幇鏈変細璇濈珛鍗冲け鏁堛€? */ + * 变更用户状态(启用/禁用)。 + * + * 禁用时:DB 写入后立即删除该用户所有活跃 Token,现有会话立即失效。 + */ @Transactional public void updateStatus(Long userId, String newStatus, TokenPrincipal principal) { getExistingUser(userId, principal.getCompanyId()); if (!"ACTIVE".equals(newStatus) && !"DISABLED".equals(newStatus)) { throw new BusinessException("INVALID_STATUS", - "鐘舵€佸€间笉鍚堟硶锛屽簲涓?ACTIVE 鎴?DISABLED", HttpStatus.BAD_REQUEST); + "状态值不合法,应为 ACTIVE 或 DISABLED", HttpStatus.BAD_REQUEST); } - // DB 鍐欏叆 + // DB 写入 userMapper.update(null, new LambdaUpdateWrapper() .eq(SysUser::getId, userId) .eq(SysUser::getCompanyId, principal.getCompanyId()) .set(SysUser::getStatus, newStatus)); - // 绂佺敤鏃讹細鍒犻櫎鎵€鏈夋椿璺?Token锛堢珛鍗冲け鏁堬級 + // 禁用时:删除所有活跃 Token(立即失效) if ("DISABLED".equals(newStatus)) { Set tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId)); tokens.forEach(token -> redisService.delete(RedisKeyManager.tokenKey(token))); redisService.delete(RedisKeyManager.userSessionsKey(userId)); - log.info("璐﹀彿宸茬鐢紝宸插垹闄?{} 涓椿璺?Token: userId={}", tokens.size(), userId); + log.info("账号已禁用,已删除 {} 个活跃 Token: userId={}", tokens.size(), userId); } - // 鍒犻櫎鏉冮檺缂撳瓨 + // 删除权限缓存 redisService.delete(RedisKeyManager.userPermKey(userId)); } - // ------------------------------------------------------------------ 鏌ヨ -- + // ------------------------------------------------------------------ 查询 -- /** - * 鍒嗛〉鏌ヨ褰撳墠鍏徃鐢ㄦ埛鍒楄〃銆? */ + * 分页查询当前公司用户列表。 + */ public PageResult listUsers(int page, int pageSize, TokenPrincipal principal) { pageSize = Math.min(pageSize, 100); Page result = userMapper.selectPage( @@ -165,12 +185,12 @@ public class UserService { return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize); } - // ------------------------------------------------------------------ 绉佹湁宸ュ叿 -- + // ------------------------------------------------------------------ 私有工具 -- private SysUser getExistingUser(Long userId, Long companyId) { SysUser user = userMapper.selectById(userId); if (user == null || !companyId.equals(user.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "鐢ㄦ埛涓嶅瓨鍦? " + userId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "用户不存在: " + userId, HttpStatus.NOT_FOUND); } return user; } @@ -178,7 +198,7 @@ public class UserService { private void validateRole(String role) { if (!List.of("UPLOADER", "ANNOTATOR", "REVIEWER", "ADMIN").contains(role)) { throw new BusinessException("INVALID_ROLE", - "瑙掕壊鍊间笉鍚堟硶: " + role, HttpStatus.BAD_REQUEST); + "角色值不合法: " + role, HttpStatus.BAD_REQUEST); } } } diff --git a/src/main/java/com/label/module/video/controller/VideoController.java b/src/main/java/com/label/module/video/controller/VideoController.java index 17c2539..6848e5d 100644 --- a/src/main/java/com/label/module/video/controller/VideoController.java +++ b/src/main/java/com/label/module/video/controller/VideoController.java @@ -2,7 +2,7 @@ package com.label.module.video.controller; import com.label.common.result.Result; import com.label.common.shiro.TokenPrincipal; -import com.label.entity.VideoProcessJob; +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; @@ -16,10 +16,14 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; /** - * 瑙嗛澶勭悊鎺ュ彛锛? 涓鐐癸級銆? * - * POST /api/video/process 鈥?瑙﹀彂瑙嗛澶勭悊锛圓DMIN锛? * GET /api/video/jobs/{jobId} 鈥?鏌ヨ浠诲姟鐘舵€侊紙ADMIN锛? * POST /api/video/jobs/{jobId}/reset 鈥?閲嶇疆澶辫触浠诲姟锛圓DMIN锛? * POST /api/video/callback 鈥?AI 鍥炶皟鎺ュ彛锛堟棤闇€璁よ瘉锛屽凡鍦?TokenFilter 涓帓闄わ級 + * 视频处理接口(4 个端点)。 + * + * POST /api/video/process — 触发视频处理(ADMIN) + * GET /api/video/jobs/{jobId} — 查询任务状态(ADMIN) + * POST /api/video/jobs/{jobId}/reset — 重置失败任务(ADMIN) + * POST /api/video/callback — AI 回调接口(无需认证,已在 TokenFilter 中排除) */ -@Tag(name = "瑙嗛澶勭悊", description = "瑙嗛澶勭悊浠诲姟鍒涘缓銆佹煡璇€侀噸缃拰鍥炶皟") +@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调") @Slf4j @RestController @RequiredArgsConstructor @@ -30,8 +34,8 @@ public class VideoController { @Value("${video.callback-secret:}") private String callbackSecret; - /** POST /api/video/process 鈥?瑙﹀彂瑙嗛澶勭悊浠诲姟 */ - @Operation(summary = "瑙﹀彂瑙嗛澶勭悊浠诲姟") + /** POST /api/video/process — 触发视频处理任务 */ + @Operation(summary = "触发视频处理任务") @PostMapping("/api/video/process") @RequiresRoles("ADMIN") public Result createJob(@RequestBody Map body, @@ -39,7 +43,7 @@ public class VideoController { Object sourceIdVal = body.get("sourceId"); Object jobTypeVal = body.get("jobType"); if (sourceIdVal == null || jobTypeVal == null) { - return Result.failure("INVALID_PARAMS", "sourceId 鍜?jobType 涓嶈兘涓虹┖"); + return Result.failure("INVALID_PARAMS", "sourceId 和 jobType 不能为空"); } Long sourceId = Long.parseLong(sourceIdVal.toString()); String jobType = jobTypeVal.toString(); @@ -50,8 +54,8 @@ public class VideoController { videoProcessService.createJob(sourceId, jobType, params, principal.getCompanyId())); } - /** GET /api/video/jobs/{jobId} 鈥?鏌ヨ瑙嗛澶勭悊浠诲姟 */ - @Operation(summary = "鏌ヨ瑙嗛澶勭悊浠诲姟鐘舵€?) + /** GET /api/video/jobs/{jobId} — 查询视频处理任务 */ + @Operation(summary = "查询视频处理任务状态") @GetMapping("/api/video/jobs/{jobId}") @RequiresRoles("ADMIN") public Result getJob(@PathVariable Long jobId, @@ -59,8 +63,8 @@ public class VideoController { return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId())); } - /** POST /api/video/jobs/{jobId}/reset 鈥?绠$悊鍛橀噸缃け璐ヤ换鍔?*/ - @Operation(summary = "閲嶇疆澶辫触鐨勮棰戝鐞嗕换鍔?) + /** POST /api/video/jobs/{jobId}/reset — 管理员重置失败任务 */ + @Operation(summary = "重置失败的视频处理任务") @PostMapping("/api/video/jobs/{jobId}/reset") @RequiresRoles("ADMIN") public Result resetJob(@PathVariable Long jobId, @@ -69,21 +73,24 @@ public class VideoController { } /** - * POST /api/video/callback 鈥?AI 鏈嶅姟鍥炶皟锛堟棤闇€ Bearer Token锛夈€? * - * 姝ょ鐐瑰凡鍦?TokenFilter.shouldNotFilter() 涓帓闄よ璇侊紝 - * 鐢?AI 鏈嶅姟鐩存帴璋冪敤锛屾惡甯?jobId銆乻tatus銆乷utputPath 绛夊弬鏁般€? * - * Body 绀轰緥锛? * { "jobId": 123, "status": "SUCCESS", "outputPath": "processed/123/frames.zip" } + * POST /api/video/callback — AI 服务回调(无需 Bearer Token)。 + * + * 此端点已在 TokenFilter.shouldNotFilter() 中排除认证, + * 由 AI 服务直接调用,携带 jobId、status、outputPath 等参数。 + * + * Body 示例: + * { "jobId": 123, "status": "SUCCESS", "outputPath": "processed/123/frames.zip" } * { "jobId": 123, "status": "FAILED", "errorMessage": "ffmpeg error: ..." } */ - @Operation(summary = "鎺ユ敹 AI 鏈嶅姟瑙嗛澶勭悊鍥炶皟") + @Operation(summary = "接收 AI 服务视频处理回调") @PostMapping("/api/video/callback") public Result handleCallback(@RequestBody Map body, HttpServletRequest request) { - // 鍏变韩瀵嗛挜鏍¢獙锛堥厤缃簡 VIDEO_CALLBACK_SECRET 鏃跺己鍒舵牎楠岋級 + // 共享密钥校验(配置了 VIDEO_CALLBACK_SECRET 时强制校验) if (callbackSecret != null && !callbackSecret.isBlank()) { String provided = request.getHeader("X-Callback-Secret"); if (!callbackSecret.equals(provided)) { - return Result.failure("UNAUTHORIZED", "鍥炶皟瀵嗛挜鏃犳晥"); + return Result.failure("UNAUTHORIZED", "回调密钥无效"); } } @@ -92,7 +99,7 @@ public class VideoController { String outputPath = body.containsKey("outputPath") ? (String) body.get("outputPath") : null; String errorMessage = body.containsKey("errorMessage") ? (String) body.get("errorMessage") : null; - log.info("瑙嗛澶勭悊鍥炶皟锛歫obId={}, status={}", jobId, status); + log.info("视频处理回调:jobId={}, status={}", jobId, status); videoProcessService.handleCallback(jobId, status, outputPath, errorMessage); return Result.success(null); } diff --git a/src/main/java/com/label/module/video/entity/VideoProcessJob.java b/src/main/java/com/label/module/video/entity/VideoProcessJob.java new file mode 100644 index 0000000..b4833e6 --- /dev/null +++ b/src/main/java/com/label/module/video/entity/VideoProcessJob.java @@ -0,0 +1,57 @@ +package com.label.module.video.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 视频处理任务实体,对应 video_process_job 表。 + * + * jobType 取值:FRAME_EXTRACT / VIDEO_TO_TEXT + * status 取值:PENDING / RUNNING / SUCCESS / FAILED / RETRYING + */ +@Data +@TableName("video_process_job") +public class VideoProcessJob { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 所属公司(多租户键) */ + private Long companyId; + + /** 关联资料 ID */ + private Long sourceId; + + /** 任务类型:FRAME_EXTRACT / VIDEO_TO_TEXT */ + private String jobType; + + /** 任务状态:PENDING / RUNNING / SUCCESS / FAILED / RETRYING */ + private String status; + + /** 任务参数(JSONB,例如 {"frameInterval": 30}) */ + private String params; + + /** AI 处理输出路径(成功后填写) */ + private String outputPath; + + /** 已重试次数 */ + private Integer retryCount; + + /** 最大重试次数(默认 3) */ + private Integer maxRetries; + + /** 错误信息 */ + private String errorMessage; + + private LocalDateTime startedAt; + + private LocalDateTime completedAt; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/label/mapper/VideoProcessJobMapper.java b/src/main/java/com/label/module/video/mapper/VideoProcessJobMapper.java similarity index 59% rename from src/main/java/com/label/mapper/VideoProcessJobMapper.java rename to src/main/java/com/label/module/video/mapper/VideoProcessJobMapper.java index 26c3dbe..a90ea58 100644 --- a/src/main/java/com/label/mapper/VideoProcessJobMapper.java +++ b/src/main/java/com/label/module/video/mapper/VideoProcessJobMapper.java @@ -1,11 +1,12 @@ -package com.label.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.label.entity.VideoProcessJob; -import org.apache.ibatis.annotations.Mapper; - -/** - * video_process_job 鐞?Mapper閵? */ -@Mapper -public interface VideoProcessJobMapper extends BaseMapper { -} +package com.label.module.video.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.label.module.video.entity.VideoProcessJob; +import org.apache.ibatis.annotations.Mapper; + +/** + * video_process_job 表 Mapper。 + */ +@Mapper +public interface VideoProcessJobMapper extends BaseMapper { +} diff --git a/src/main/java/com/label/module/video/service/VideoProcessService.java b/src/main/java/com/label/module/video/service/VideoProcessService.java index 27596d3..86736c2 100644 --- a/src/main/java/com/label/module/video/service/VideoProcessService.java +++ b/src/main/java/com/label/module/video/service/VideoProcessService.java @@ -5,10 +5,10 @@ import com.label.common.ai.AiServiceClient; import com.label.common.exception.BusinessException; import com.label.common.statemachine.SourceStatus; import com.label.common.statemachine.StateValidator; -import com.label.entity.SourceData; -import com.label.mapper.SourceDataMapper; -import com.label.entity.VideoProcessJob; -import com.label.mapper.VideoProcessJobMapper; +import com.label.module.source.entity.SourceData; +import com.label.module.source.mapper.SourceDataMapper; +import com.label.module.video.entity.VideoProcessJob; +import com.label.module.video.mapper.VideoProcessJobMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -22,15 +22,19 @@ import java.time.LocalDateTime; import java.util.Map; /** - * 瑙嗛澶勭悊鏈嶅姟锛氬垱寤轰换鍔°€佸鐞嗗洖璋冦€佺鐞嗗憳閲嶇疆銆? * - * 鐘舵€佹祦杞細 - * - 鍒涘缓鏃讹細source_data 鈫?PREPROCESSING锛宩ob 鈫?PENDING - * - 鍥炶皟鎴愬姛锛歫ob 鈫?SUCCESS锛宻ource_data 鈫?PENDING锛堣繘鍏ユ彁鍙栭槦鍒楋級 - * - 鍥炶皟澶辫触锛堝彲閲嶈瘯锛夛細job 鈫?RETRYING锛宺etryCount++锛岄噸鏂拌Е鍙?AI - * - 鍥炶皟澶辫触锛堣秴鍑轰笂闄愶級锛歫ob 鈫?FAILED锛宻ource_data 鈫?PENDING - * - 绠$悊鍛橀噸缃細job 鈫?PENDING锛堝彲鎵嬪姩閲嶆柊瑙﹀彂锛? * - * T074 璁捐璇存槑锛? * AI 璋冪敤閫氳繃 TransactionSynchronizationManager.registerSynchronization().afterCommit() - * 寤惰繜鍒颁簨鍔℃彁浜ゅ悗鎵ц锛岄伩鍏嶅湪鎸佹湁 DB 杩炴帴鏈熼棿杩涜 HTTP 璋冪敤銆? */ + * 视频处理服务:创建任务、处理回调、管理员重置。 + * + * 状态流转: + * - 创建时:source_data → PREPROCESSING,job → PENDING + * - 回调成功:job → SUCCESS,source_data → PENDING(进入提取队列) + * - 回调失败(可重试):job → RETRYING,retryCount++,重新触发 AI + * - 回调失败(超出上限):job → FAILED,source_data → PENDING + * - 管理员重置:job → PENDING(可手动重新触发) + * + * T074 设计说明: + * AI 调用通过 TransactionSynchronizationManager.registerSynchronization().afterCommit() + * 延迟到事务提交后执行,避免在持有 DB 连接期间进行 HTTP 调用。 + */ @Slf4j @Service @RequiredArgsConstructor @@ -43,27 +47,31 @@ public class VideoProcessService { @Value("${rustfs.bucket:label-source-data}") private String bucket; - // ------------------------------------------------------------------ 鍒涘缓浠诲姟 -- + // ------------------------------------------------------------------ 创建任务 -- /** - * 鍒涘缓瑙嗛澶勭悊浠诲姟骞跺湪浜嬪姟鎻愪氦鍚庤Е鍙?AI 鏈嶅姟銆? * - * DB 鍐欏叆锛坰ource_data鈫扨REPROCESSING + 鎻掑叆 job锛夊湪 @Transactional 鍐呭畬鎴愶紱 - * AI 瑙﹀彂閫氳繃 afterCommit() 鍦ㄤ簨鍔℃彁浜ゅ悗鎵ц锛屼笉鍗犵敤 DB 杩炴帴銆? * - * @param sourceId 璧勬枡 ID - * @param jobType 浠诲姟绫诲瀷锛團RAME_EXTRACT / VIDEO_TO_TEXT锛? * @param params JSON 鍙傛暟锛堝 {"frameInterval": 30}锛? * @param companyId 绉熸埛 ID - * @return 鏂板缓鐨?VideoProcessJob + * 创建视频处理任务并在事务提交后触发 AI 服务。 + * + * DB 写入(source_data→PREPROCESSING + 插入 job)在 @Transactional 内完成; + * AI 触发通过 afterCommit() 在事务提交后执行,不占用 DB 连接。 + * + * @param sourceId 资料 ID + * @param jobType 任务类型(FRAME_EXTRACT / VIDEO_TO_TEXT) + * @param params JSON 参数(如 {"frameInterval": 30}) + * @param companyId 租户 ID + * @return 新建的 VideoProcessJob */ @Transactional public VideoProcessJob createJob(Long sourceId, String jobType, String params, Long companyId) { SourceData source = sourceDataMapper.selectById(sourceId); if (source == null || !companyId.equals(source.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "璧勬枡涓嶅瓨鍦? " + sourceId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "资料不存在: " + sourceId, HttpStatus.NOT_FOUND); } validateJobType(jobType); - // source_data 鈫?PREPROCESSING + // source_data → PREPROCESSING StateValidator.assertTransition( SourceStatus.TRANSITIONS, SourceStatus.valueOf(source.getStatus()), SourceStatus.PREPROCESSING); @@ -72,7 +80,7 @@ public class VideoProcessService { .set(SourceData::getStatus, "PREPROCESSING") .set(SourceData::getUpdatedAt, LocalDateTime.now())); - // 鎻掑叆 PENDING 浠诲姟 + // 插入 PENDING 任务 VideoProcessJob job = new VideoProcessJob(); job.setCompanyId(companyId); job.setSourceId(sourceId); @@ -83,7 +91,8 @@ public class VideoProcessService { job.setMaxRetries(3); jobMapper.insert(job); - // 浜嬪姟鎻愪氦鍚庤Е鍙?AI锛堜笉鍦ㄤ簨鍔″唴锛屼笉鍗犵敤 DB 杩炴帴锛? final Long jobId = job.getId(); + // 事务提交后触发 AI(不在事务内,不占用 DB 连接) + final Long jobId = job.getId(); final String filePath = source.getFilePath(); final String finalJobType = jobType; @@ -94,31 +103,36 @@ public class VideoProcessService { } }); - log.info("瑙嗛澶勭悊浠诲姟宸插垱寤猴紙AI 灏嗗湪浜嬪姟鎻愪氦鍚庤Е鍙戯級: jobId={}, sourceId={}", jobId, sourceId); + log.info("视频处理任务已创建(AI 将在事务提交后触发): jobId={}, sourceId={}", jobId, sourceId); return job; } - // ------------------------------------------------------------------ 澶勭悊鍥炶皟 -- + // ------------------------------------------------------------------ 处理回调 -- /** - * 澶勭悊 AI 鏈嶅姟寮傛鍥炶皟锛圥OST /api/video/callback锛屾棤闇€鐢ㄦ埛 Token锛夈€? * - * 骞傜瓑锛氳嫢 job 宸蹭负 SUCCESS锛岀洿鎺ヨ繑鍥烇紝闃叉閲嶅澶勭悊銆? * 閲嶈瘯瑙﹀彂鍚屾牱寤惰繜鍒颁簨鍔℃彁浜ゅ悗锛坅fterCommit锛夛紝涓嶅湪浜嬪姟鍐呮墽琛屻€? * - * @param jobId 浠诲姟 ID - * @param callbackStatus AI 鍥炶皟鐘舵€侊紙SUCCESS / FAILED锛? * @param outputPath 鎴愬姛鏃剁殑杈撳嚭璺緞锛堝彲閫夛級 - * @param errorMessage 澶辫触鏃剁殑閿欒淇℃伅锛堝彲閫夛級 + * 处理 AI 服务异步回调(POST /api/video/callback,无需用户 Token)。 + * + * 幂等:若 job 已为 SUCCESS,直接返回,防止重复处理。 + * 重试触发同样延迟到事务提交后(afterCommit),不在事务内执行。 + * + * @param jobId 任务 ID + * @param callbackStatus AI 回调状态(SUCCESS / FAILED) + * @param outputPath 成功时的输出路径(可选) + * @param errorMessage 失败时的错误信息(可选) */ @Transactional public void handleCallback(Long jobId, String callbackStatus, String outputPath, String errorMessage) { - // video_process_job 鍦?IGNORED_TABLES 涓紙鍥炶皟鏃?CompanyContext锛夛紝姝ゅ鏄惧紡鏍¢獙 + // video_process_job 在 IGNORED_TABLES 中(回调无 CompanyContext),此处显式校验 VideoProcessJob job = jobMapper.selectById(jobId); if (job == null || job.getCompanyId() == null) { - log.warn("瑙嗛澶勭悊鍥炶皟锛歫ob 涓嶅瓨鍦紝jobId={}", jobId); + log.warn("视频处理回调:job 不存在,jobId={}", jobId); return; } - // 骞傜瓑锛氬凡鎴愬姛鍒欏拷鐣ラ噸澶嶅洖璋? if ("SUCCESS".equals(job.getStatus())) { - log.info("瑙嗛澶勭悊鍥炶皟骞傜瓑锛歫obId={} 宸蹭负 SUCCESS锛岃烦杩?, jobId); + // 幂等:已成功则忽略重复回调 + if ("SUCCESS".equals(job.getStatus())) { + log.info("视频处理回调幂等:jobId={} 已为 SUCCESS,跳过", jobId); return; } @@ -129,24 +143,27 @@ public class VideoProcessService { } } - // ------------------------------------------------------------------ 绠$悊鍛橀噸缃?-- + // ------------------------------------------------------------------ 管理员重置 -- /** - * 绠$悊鍛樻墜鍔ㄩ噸缃け璐ヤ换鍔★紙FAILED 鈫?PENDING锛夈€? * - * 浠呭厑璁?FAILED 鐘舵€佺殑浠诲姟閲嶇疆锛岄噸缃悗 retryCount 娓呴浂锛? * 绠$悊鍛樺彲闅忓悗閲嶆柊璋冪敤 createJob 瑙﹀彂澶勭悊銆? * - * @param jobId 浠诲姟 ID - * @param companyId 绉熸埛 ID + * 管理员手动重置失败任务(FAILED → PENDING)。 + * + * 仅允许 FAILED 状态的任务重置,重置后 retryCount 清零, + * 管理员可随后重新调用 createJob 触发处理。 + * + * @param jobId 任务 ID + * @param companyId 租户 ID */ @Transactional public VideoProcessJob reset(Long jobId, Long companyId) { VideoProcessJob job = jobMapper.selectById(jobId); if (job == null || !companyId.equals(job.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "瑙嗛澶勭悊浠诲姟涓嶅瓨鍦? " + jobId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "视频处理任务不存在: " + jobId, HttpStatus.NOT_FOUND); } if (!"FAILED".equals(job.getStatus())) { throw new BusinessException("INVALID_TRANSITION", - "鍙湁 FAILED 鐘舵€佺殑浠诲姟鍙互閲嶇疆锛屽綋鍓嶇姸鎬? " + job.getStatus(), + "只有 FAILED 状态的任务可以重置,当前状态: " + job.getStatus(), HttpStatus.BAD_REQUEST); } @@ -159,24 +176,24 @@ public class VideoProcessService { job.setStatus("PENDING"); job.setRetryCount(0); - log.info("瑙嗛澶勭悊浠诲姟宸查噸缃? jobId={}", jobId); + log.info("视频处理任务已重置: jobId={}", jobId); return job; } - // ------------------------------------------------------------------ 鏌ヨ -- + // ------------------------------------------------------------------ 查询 -- public VideoProcessJob getJob(Long jobId, Long companyId) { VideoProcessJob job = jobMapper.selectById(jobId); if (job == null || !companyId.equals(job.getCompanyId())) { - throw new BusinessException("NOT_FOUND", "瑙嗛澶勭悊浠诲姟涓嶅瓨鍦? " + jobId, HttpStatus.NOT_FOUND); + throw new BusinessException("NOT_FOUND", "视频处理任务不存在: " + jobId, HttpStatus.NOT_FOUND); } return job; } - // ------------------------------------------------------------------ 绉佹湁鏂规硶 -- + // ------------------------------------------------------------------ 私有方法 -- private void handleSuccess(VideoProcessJob job, String outputPath) { - // job 鈫?SUCCESS + // job → SUCCESS jobMapper.update(null, new LambdaUpdateWrapper() .eq(VideoProcessJob::getId, job.getId()) .set(VideoProcessJob::getStatus, "SUCCESS") @@ -184,13 +201,13 @@ public class VideoProcessService { .set(VideoProcessJob::getCompletedAt, LocalDateTime.now()) .set(VideoProcessJob::getUpdatedAt, LocalDateTime.now())); - // source_data PREPROCESSING 鈫?PENDING锛堣繘鍏ユ彁鍙栭槦鍒楋級 + // source_data PREPROCESSING → PENDING(进入提取队列) sourceDataMapper.update(null, new LambdaUpdateWrapper() .eq(SourceData::getId, job.getSourceId()) .set(SourceData::getStatus, "PENDING") .set(SourceData::getUpdatedAt, LocalDateTime.now())); - log.info("瑙嗛澶勭悊鎴愬姛锛歫obId={}, sourceId={}", job.getId(), job.getSourceId()); + log.info("视频处理成功:jobId={}, sourceId={}", job.getId(), job.getSourceId()); } private void handleFailure(VideoProcessJob job, String errorMessage) { @@ -198,7 +215,7 @@ public class VideoProcessService { int maxRetries = job.getMaxRetries() != null ? job.getMaxRetries() : 3; if (newRetryCount < maxRetries) { - // 浠嶆湁閲嶈瘯娆℃暟锛歫ob 鈫?RETRYING锛屼簨鍔℃彁浜ゅ悗閲嶆柊瑙﹀彂 AI + // 仍有重试次数:job → RETRYING,事务提交后重新触发 AI jobMapper.update(null, new LambdaUpdateWrapper() .eq(VideoProcessJob::getId, job.getId()) .set(VideoProcessJob::getStatus, "RETRYING") @@ -206,10 +223,10 @@ public class VideoProcessService { .set(VideoProcessJob::getErrorMessage, errorMessage) .set(VideoProcessJob::getUpdatedAt, LocalDateTime.now())); - log.warn("瑙嗛澶勭悊澶辫触锛屽紑濮嬬 {} 娆¢噸璇曪細jobId={}, error={}", + log.warn("视频处理失败,开始第 {} 次重试:jobId={}, error={}", newRetryCount, job.getId(), errorMessage); - // 閲嶈瘯 AI 瑙﹀彂寤惰繜鍒颁簨鍔℃彁浜ゅ悗 + // 重试 AI 触发延迟到事务提交后 SourceData source = sourceDataMapper.selectById(job.getSourceId()); if (source != null) { final Long jobId = job.getId(); @@ -225,7 +242,7 @@ public class VideoProcessService { }); } } else { - // 瓒呭嚭鏈€澶ч噸璇曟鏁帮細job 鈫?FAILED锛宻ource_data 鈫?PENDING + // 超出最大重试次数:job → FAILED,source_data → PENDING jobMapper.update(null, new LambdaUpdateWrapper() .eq(VideoProcessJob::getId, job.getId()) .set(VideoProcessJob::getStatus, "FAILED") @@ -234,13 +251,13 @@ public class VideoProcessService { .set(VideoProcessJob::getCompletedAt, LocalDateTime.now()) .set(VideoProcessJob::getUpdatedAt, LocalDateTime.now())); - // source_data PREPROCESSING 鈫?PENDING锛堢鐞嗗憳鍙噸鏂板鐞嗭級 + // source_data PREPROCESSING → PENDING(管理员可重新处理) sourceDataMapper.update(null, new LambdaUpdateWrapper() .eq(SourceData::getId, job.getSourceId()) .set(SourceData::getStatus, "PENDING") .set(SourceData::getUpdatedAt, LocalDateTime.now())); - log.error("瑙嗛澶勭悊姘镐箙澶辫触锛歫obId={}, sourceId={}, error={}", + log.error("视频处理永久失败:jobId={}, sourceId={}, error={}", job.getId(), job.getSourceId(), errorMessage); } } @@ -258,16 +275,16 @@ public class VideoProcessService { } else { aiServiceClient.videoToText(req); } - log.info("AI 瑙﹀彂鎴愬姛: jobId={}", jobId); + log.info("AI 触发成功: jobId={}", jobId); } catch (Exception e) { - log.error("瑙﹀彂瑙嗛澶勭悊 AI 澶辫触锛坖obId={}锛夛細{}锛宩ob 淇濇寔褰撳墠鐘舵€侊紝闇€绠$悊鍛樻墜鍔ㄩ噸缃?, jobId, e.getMessage()); + log.error("触发视频处理 AI 失败(jobId={}):{},job 保持当前状态,需管理员手动重置", jobId, e.getMessage()); } } private void validateJobType(String jobType) { if (!"FRAME_EXTRACT".equals(jobType) && !"VIDEO_TO_TEXT".equals(jobType)) { throw new BusinessException("INVALID_JOB_TYPE", - "浠诲姟绫诲瀷涓嶅悎娉曪紝搴斾负 FRAME_EXTRACT 鎴?VIDEO_TO_TEXT", HttpStatus.BAD_REQUEST); + "任务类型不合法,应为 FRAME_EXTRACT 或 VIDEO_TO_TEXT", HttpStatus.BAD_REQUEST); } } } diff --git a/src/test/java/com/label/integration/AuthIntegrationTest.java b/src/test/java/com/label/integration/AuthIntegrationTest.java index 35fd193..9bc8b94 100644 --- a/src/test/java/com/label/integration/AuthIntegrationTest.java +++ b/src/test/java/com/label/integration/AuthIntegrationTest.java @@ -2,7 +2,7 @@ package com.label.integration; import com.label.AbstractIntegrationTest; import com.label.common.result.Result; -import com.label.dto.LoginRequest; +import com.label.module.user.dto.LoginRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -15,22 +15,26 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** - * 璁よ瘉娴佺▼闆嗘垚娴嬭瘯锛圲S1锛夈€? * - * 娴嬭瘯鍦烘櫙锛? * 1. 姝g‘瀵嗙爜鐧诲綍 鈫?杩斿洖 token - * 2. 閿欒瀵嗙爜鐧诲綍 鈫?401 - * 3. 涓嶅瓨鍦ㄧ殑鍏徃浠g爜 鈫?401 - * 4. 鏈夋晥 Token 璁块棶 /api/auth/me 鈫?200锛岃繑鍥炵敤鎴蜂俊鎭? * 5. 涓诲姩閫€鍑哄悗锛屽師 Token 璁块棶 /api/auth/me 鈫?401 + * 认证流程集成测试(US1)。 * - * 娴嬭瘯鏁版嵁鏉ヨ嚜 init.sql 绉嶅瓙锛圖EMO 鍏徃 / admin / admin123锛? */ + * 测试场景: + * 1. 正确密码登录 → 返回 token + * 2. 错误密码登录 → 401 + * 3. 不存在的公司代码 → 401 + * 4. 有效 Token 访问 /api/auth/me → 200,返回用户信息 + * 5. 主动退出后,原 Token 访问 /api/auth/me → 401 + * + * 测试数据来自 init.sql 种子(DEMO 公司 / admin / admin123) + */ public class AuthIntegrationTest extends AbstractIntegrationTest { @Autowired private TestRestTemplate restTemplate; - // ------------------------------------------------------------------ 鐧诲綍娴嬭瘯 -- + // ------------------------------------------------------------------ 登录测试 -- @Test - @DisplayName("姝g‘瀵嗙爜鐧诲綍 鈫?杩斿洖 token") + @DisplayName("正确密码登录 → 返回 token") void login_withCorrectCredentials_returnsToken() { ResponseEntity response = doLogin("DEMO", "admin", "admin123"); @@ -48,23 +52,23 @@ public class AuthIntegrationTest extends AbstractIntegrationTest { } @Test - @DisplayName("閿欒瀵嗙爜鐧诲綍 鈫?401 Unauthorized") + @DisplayName("错误密码登录 → 401 Unauthorized") void login_withWrongPassword_returns401() { ResponseEntity response = doLogin("DEMO", "admin", "wrong_password"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test - @DisplayName("涓嶅瓨鍦ㄧ殑鍏徃浠g爜 鈫?401 Unauthorized") + @DisplayName("不存在的公司代码 → 401 Unauthorized") void login_withUnknownCompany_returns401() { ResponseEntity response = doLogin("NONEXIST", "admin", "admin123"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } - // ------------------------------------------------------------------ /me 娴嬭瘯 -- + // ------------------------------------------------------------------ /me 测试 -- @Test - @DisplayName("鏈夋晥 Token 璁块棶 /api/auth/me 鈫?200锛岃繑鍥炵敤鎴蜂俊鎭?) + @DisplayName("有效 Token 访问 /api/auth/me → 200,返回用户信息") void me_withValidToken_returns200WithUserInfo() { String token = loginAndGetToken("DEMO", "admin", "admin123"); assertThat(token).isNotBlank(); @@ -85,22 +89,22 @@ public class AuthIntegrationTest extends AbstractIntegrationTest { } @Test - @DisplayName("鏃?Token 璁块棶 /api/auth/me 鈫?401") + @DisplayName("无 Token 访问 /api/auth/me → 401") void me_withNoToken_returns401() { ResponseEntity response = restTemplate.getForEntity( baseUrl("/api/auth/me"), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } - // ------------------------------------------------------------------ 閫€鍑烘祴璇?-- + // ------------------------------------------------------------------ 退出测试 -- @Test - @DisplayName("涓诲姩閫€鍑哄悗锛屽師 Token 璁块棶 /api/auth/me 鈫?401") + @DisplayName("主动退出后,原 Token 访问 /api/auth/me → 401") void logout_thenMe_returns401() { String token = loginAndGetToken("DEMO", "admin", "admin123"); assertThat(token).isNotBlank(); - // 纭鐧诲綍鏈夋晥 + // 确认登录有效 ResponseEntity meResponse = restTemplate.exchange( baseUrl("/api/auth/me"), HttpMethod.GET, @@ -108,14 +112,15 @@ public class AuthIntegrationTest extends AbstractIntegrationTest { Map.class); assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK); - // 閫€鍑? ResponseEntity logoutResponse = restTemplate.exchange( + // 退出 + ResponseEntity logoutResponse = restTemplate.exchange( baseUrl("/api/auth/logout"), HttpMethod.POST, bearerRequest(token), Map.class); assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); - // 閫€鍑哄悗鍐嶈闂?/me 鈫?401 + // 退出后再访问 /me → 401 ResponseEntity meAfterLogout = restTemplate.exchange( baseUrl("/api/auth/me"), HttpMethod.GET, @@ -124,9 +129,9 @@ public class AuthIntegrationTest extends AbstractIntegrationTest { assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } - // ------------------------------------------------------------------ 宸ュ叿鏂规硶 -- + // ------------------------------------------------------------------ 工具方法 -- - /** 鍙戣捣鐧诲綍璇锋眰锛岃繑鍥炲師濮?ResponseEntity */ + /** 发起登录请求,返回原始 ResponseEntity */ private ResponseEntity doLogin(String companyCode, String username, String password) { LoginRequest req = new LoginRequest(); req.setCompanyCode(companyCode); @@ -135,7 +140,7 @@ public class AuthIntegrationTest extends AbstractIntegrationTest { return restTemplate.postForEntity(baseUrl("/api/auth/login"), req, Map.class); } - /** 鐧诲綍骞舵彁鍙?token 瀛楃涓诧紱澶辫触鏃惰繑鍥?null */ + /** 登录并提取 token 字符串;失败时返回 null */ private String loginAndGetToken(String companyCode, String username, String password) { ResponseEntity response = doLogin(companyCode, username, password); if (!response.getStatusCode().is2xxSuccessful()) { @@ -146,7 +151,7 @@ public class AuthIntegrationTest extends AbstractIntegrationTest { return (String) data.get("token"); } - /** 鏋勯€犲甫 Bearer Token 鐨勮姹傚疄浣擄紙鏃?body锛?*/ + /** 构造带 Bearer Token 的请求实体(无 body) */ private HttpEntity bearerRequest(String token) { HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + token); diff --git a/src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java b/src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java index 6cfe709..b88379c 100644 --- a/src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java +++ b/src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java @@ -1,7 +1,7 @@ package com.label.integration; import com.label.AbstractIntegrationTest; -import com.label.dto.LoginRequest; +import com.label.module.user.dto.LoginRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,10 +14,12 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** - * 鎻愬彇闃舵瀹℃壒闆嗘垚娴嬭瘯锛圲S4锛夈€? * - * 娴嬭瘯鍦烘櫙锛? * 1. 瀹℃壒閫氳繃 鈫?QA_GENERATION 浠诲姟鑷姩鍒涘缓锛宻ource_data 鐘舵€佹洿鏂颁负 QA_REVIEW - * 2. 瀹℃壒浜轰笌鎻愪氦浜虹浉鍚岋紙鑷锛夆啋 403 SELF_REVIEW_FORBIDDEN - * 3. 椹冲洖鍚庢爣娉ㄥ憳鍙噸棰嗕换鍔″苟鍐嶆鎻愪氦 + * 提取阶段审批集成测试(US4)。 + * + * 测试场景: + * 1. 审批通过 → QA_GENERATION 任务自动创建,source_data 状态更新为 QA_REVIEW + * 2. 审批人与提交人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN + * 3. 驳回后标注员可重领任务并再次提交 */ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { @@ -31,14 +33,15 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { @BeforeEach void setup() { - // 鑾峰彇绉嶅瓙鐢ㄦ埛 ID锛坕nit.sql 涓凡鎻掑叆锛? annotatorUserId = jdbcTemplate.queryForObject( + // 获取种子用户 ID(init.sql 中已插入) + annotatorUserId = jdbcTemplate.queryForObject( "SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class); reviewerUserId = jdbcTemplate.queryForObject( "SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class); Long companyId = jdbcTemplate.queryForObject( "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - // 鎻掑叆娴嬭瘯 source_data + // 插入测试 source_data jdbcTemplate.execute( "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + "file_name, file_size, bucket_name, status) " + @@ -47,7 +50,7 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { sourceId = jdbcTemplate.queryForObject( "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - // 鎻掑叆 UNCLAIMED EXTRACTION 浠诲姟 + // 插入 UNCLAIMED EXTRACTION 任务 jdbcTemplate.execute( "INSERT INTO annotation_task (company_id, source_id, task_type, status) " + "VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')"); @@ -55,63 +58,66 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { "SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class); } - // ------------------------------------------------------------------ 娴嬭瘯 1: 瀹℃壒閫氳繃 鈫?QA 浠诲姟鑷姩鍒涘缓 -- + // ------------------------------------------------------------------ 测试 1: 审批通过 → QA 任务自动创建 -- @Test - @DisplayName("瀹℃壒閫氳繃鍚庯紝QA_GENERATION 浠诲姟鑷姩鍒涘缓锛宻ource_data 鐘舵€佸彉涓?QA_REVIEW") + @DisplayName("审批通过后,QA_GENERATION 任务自动创建,source_data 状态变为 QA_REVIEW") void approveTask_thenQaTaskAndSourceStatusUpdated() { String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - // 1. 鏍囨敞鍛橀鍙栦换鍔? ResponseEntity claimResp = restTemplate.exchange( + // 1. 标注员领取任务 + ResponseEntity claimResp = restTemplate.exchange( baseUrl("/api/tasks/" + taskId + "/claim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 2. 鏍囨敞鍛樻彁浜ゆ爣娉? ResponseEntity submitResp = restTemplate.exchange( + // 2. 标注员提交标注 + ResponseEntity submitResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 3. 瀹℃牳鍛樺鎵归€氳繃 - // 娉細ExtractionApprovedEventListener(@TransactionalEventListener AFTER_COMMIT) - // 鍦ㄥ悓涓€绾跨▼涓悓姝ユ墽琛岋紝HTTP 鍝嶅簲杩斿洖鍓嶅凡瀹屾垚鍚庣画澶勭悊 + // 3. 审核员审批通过 + // 注:ExtractionApprovedEventListener(@TransactionalEventListener AFTER_COMMIT) + // 在同一线程中同步执行,HTTP 响应返回前已完成后续处理 ResponseEntity approveResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/approve"), HttpMethod.POST, bearerRequest(reviewerToken), Map.class); assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 楠岃瘉锛氬師浠诲姟鐘舵€佸彉涓?APPROVED锛宨s_final=true + // 验证:原任务状态变为 APPROVED,is_final=true Map taskRow = jdbcTemplate.queryForMap( "SELECT status, is_final FROM annotation_task WHERE id = ?", taskId); assertThat(taskRow.get("status")).isEqualTo("APPROVED"); assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE); - // 楠岃瘉锛歈A_GENERATION 浠诲姟宸茶嚜鍔ㄥ垱寤猴紙UNCLAIMED 鐘舵€侊級 + // 验证:QA_GENERATION 任务已自动创建(UNCLAIMED 状态) Integer qaTaskCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM annotation_task " + "WHERE source_id = ? AND task_type = 'QA_GENERATION' AND status = 'UNCLAIMED'", Integer.class, sourceId); - assertThat(qaTaskCount).as("QA_GENERATION 浠诲姟搴斿凡鍒涘缓").isEqualTo(1); + assertThat(qaTaskCount).as("QA_GENERATION 任务应已创建").isEqualTo(1); - // 楠岃瘉锛歴ource_data 鐘舵€佸凡鏇存柊涓?QA_REVIEW + // 验证:source_data 状态已更新为 QA_REVIEW String sourceStatus = jdbcTemplate.queryForObject( "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus).as("source_data 鐘舵€佸簲涓?QA_REVIEW").isEqualTo("QA_REVIEW"); + assertThat(sourceStatus).as("source_data 状态应为 QA_REVIEW").isEqualTo("QA_REVIEW"); - // 楠岃瘉锛歵raining_dataset 宸蹭互 PENDING_REVIEW 鐘舵€佸垱寤? Integer datasetCount = jdbcTemplate.queryForObject( + // 验证:training_dataset 已以 PENDING_REVIEW 状态创建 + Integer datasetCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM training_dataset " + "WHERE source_id = ? AND status = 'PENDING_REVIEW'", Integer.class, sourceId); - assertThat(datasetCount).as("training_dataset 搴斿凡鍒涘缓").isEqualTo(1); + assertThat(datasetCount).as("training_dataset 应已创建").isEqualTo(1); } - // ------------------------------------------------------------------ 娴嬭瘯 2: 鑷杩斿洖 403 -- + // ------------------------------------------------------------------ 测试 2: 自审返回 403 -- @Test - @DisplayName("瀹℃壒浜轰笌浠诲姟棰嗗彇浜虹浉鍚岋紙鑷锛夆啋 403 SELF_REVIEW_FORBIDDEN") + @DisplayName("审批人与任务领取人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN") void approveOwnSubmission_returnsForbidden() { - // 鐩存帴灏嗕换鍔$疆涓?SUBMITTED 骞惰 claimed_by = reviewer01锛堟ā鎷熻嚜瀹″満鏅級 + // 直接将任务置为 SUBMITTED 并设 claimed_by = reviewer01(模拟自审场景) jdbcTemplate.execute( "UPDATE annotation_task " + "SET status = 'SUBMITTED', claimed_by = " + reviewerUserId + @@ -126,63 +132,67 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - // 楠岃瘉浠诲姟鐘舵€佹湭鍙? String status = jdbcTemplate.queryForObject( + // 验证任务状态未变 + String status = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(status).isEqualTo("SUBMITTED"); } - // ------------------------------------------------------------------ 娴嬭瘯 3: 椹冲洖 鈫?閲嶉 鈫?鍐嶆彁浜?-- + // ------------------------------------------------------------------ 测试 3: 驳回 → 重领 → 再提交 -- @Test - @DisplayName("椹冲洖鍚庢爣娉ㄥ憳鍙噸棰嗕换鍔″苟鍐嶆鎻愪氦锛屼换鍔$姸鎬佹仮澶嶄负 SUBMITTED") + @DisplayName("驳回后标注员可重领任务并再次提交,任务状态恢复为 SUBMITTED") void rejectThenReclaimAndResubmit_succeeds() { String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - // 1. 鏍囨敞鍛橀鍙栧苟鎻愪氦 + // 1. 标注员领取并提交 restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); restTemplate.exchange(baseUrl("/api/extraction/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - // 2. 瀹℃牳鍛橀┏鍥烇紙椹冲洖鍘熷洜蹇呭~锛? HttpHeaders rejectHeaders = new HttpHeaders(); + // 2. 审核员驳回(驳回原因必填) + HttpHeaders rejectHeaders = new HttpHeaders(); rejectHeaders.set("Authorization", "Bearer " + reviewerToken); rejectHeaders.setContentType(MediaType.APPLICATION_JSON); HttpEntity> rejectReq = new HttpEntity<>( - Map.of("reason", "瀹炰綋璇嗗埆鏈夎锛岃閲嶆柊鏍囨敞"), rejectHeaders); + Map.of("reason", "实体识别有误,请重新标注"), rejectHeaders); ResponseEntity rejectResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/reject"), HttpMethod.POST, rejectReq, Map.class); assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 楠岃瘉锛氫换鍔$姸鎬佸彉涓?REJECTED + // 验证:任务状态变为 REJECTED String statusAfterReject = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(statusAfterReject).isEqualTo("REJECTED"); - // 3. 鏍囨敞鍛橀噸棰嗕换鍔★紙REJECTED 鈫?IN_PROGRESS锛? ResponseEntity reclaimResp = restTemplate.exchange( + // 3. 标注员重领任务(REJECTED → IN_PROGRESS) + ResponseEntity reclaimResp = restTemplate.exchange( baseUrl("/api/tasks/" + taskId + "/reclaim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 楠岃瘉锛氫换鍔$姸鎬佹仮澶嶄负 IN_PROGRESS + // 验证:任务状态恢复为 IN_PROGRESS String statusAfterReclaim = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(statusAfterReclaim).isEqualTo("IN_PROGRESS"); - // 4. 鏍囨敞鍛樺啀娆℃彁浜わ紙IN_PROGRESS 鈫?SUBMITTED锛? ResponseEntity resubmitResp = restTemplate.exchange( + // 4. 标注员再次提交(IN_PROGRESS → SUBMITTED) + ResponseEntity resubmitResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 楠岃瘉锛氫换鍔$姸鎬佸彉涓?SUBMITTED + // 验证:任务状态变为 SUBMITTED String finalStatus = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(finalStatus).isEqualTo("SUBMITTED"); } - // ------------------------------------------------------------------ 宸ュ叿鏂规硶 -- + // ------------------------------------------------------------------ 工具方法 -- private String loginAndGetToken(String companyCode, String username, String password) { LoginRequest req = new LoginRequest(); diff --git a/src/test/java/com/label/integration/QaApprovalIntegrationTest.java b/src/test/java/com/label/integration/QaApprovalIntegrationTest.java index 916bd08..4c00970 100644 --- a/src/test/java/com/label/integration/QaApprovalIntegrationTest.java +++ b/src/test/java/com/label/integration/QaApprovalIntegrationTest.java @@ -1,7 +1,7 @@ package com.label.integration; import com.label.AbstractIntegrationTest; -import com.label.dto.LoginRequest; +import com.label.module.user.dto.LoginRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,9 +14,11 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** - * QA 闂瓟鐢熸垚闃舵瀹℃壒闆嗘垚娴嬭瘯锛圲S5锛夈€? * - * 娴嬭瘯鍦烘櫙锛? * 1. QA 瀹℃壒閫氳繃 鈫?training_dataset.status = APPROVED锛宻ource_data.status = APPROVED - * 2. QA 椹冲洖 鈫?鍊欓€夐棶绛斿琚垹闄わ紝鏍囨敞鍛樺彲閲嶉 + * QA 问答生成阶段审批集成测试(US5)。 + * + * 测试场景: + * 1. QA 审批通过 → training_dataset.status = APPROVED,source_data.status = APPROVED + * 2. QA 驳回 → 候选问答对被删除,标注员可重领 */ public class QaApprovalIntegrationTest extends AbstractIntegrationTest { @@ -38,7 +40,7 @@ public class QaApprovalIntegrationTest extends AbstractIntegrationTest { Long companyId = jdbcTemplate.queryForObject( "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - // 鎻掑叆 source_data锛圦A_REVIEW 鐘舵€侊紝妯℃嫙鎻愬彇瀹℃壒宸插畬鎴愶級 + // 插入 source_data(QA_REVIEW 状态,模拟提取审批已完成) jdbcTemplate.execute( "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + "file_name, file_size, bucket_name, status) " + @@ -47,123 +49,129 @@ public class QaApprovalIntegrationTest extends AbstractIntegrationTest { sourceId = jdbcTemplate.queryForObject( "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - // 鎻掑叆 QA_GENERATION 浠诲姟锛圲NCLAIMED 鐘舵€侊紝妯℃嫙鎻愬彇瀹℃壒閫氳繃鍚庤嚜鍔ㄥ垱寤虹殑 QA 浠诲姟锛? jdbcTemplate.execute( + // 插入 QA_GENERATION 任务(UNCLAIMED 状态,模拟提取审批通过后自动创建的 QA 任务) + jdbcTemplate.execute( "INSERT INTO annotation_task (company_id, source_id, task_type, status) " + "VALUES (" + companyId + ", " + sourceId + ", 'QA_GENERATION', 'UNCLAIMED')"); taskId = jdbcTemplate.queryForObject( "SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class); - // 鎻掑叆鍊欓€夐棶绛斿锛堟ā鎷?ExtractionApprovedEventListener 鍒涘缓锛? jdbcTemplate.execute( + // 插入候选问答对(模拟 ExtractionApprovedEventListener 创建) + jdbcTemplate.execute( "INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " + "glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId + - ", 'TEXT', '{\"conversations\":[{\"question\":\"鍖椾含鏄摢涓浗瀹剁殑棣栭兘锛焅",\"answer\":\"涓浗\"}]}'::jsonb, " + + ", 'TEXT', '{\"conversations\":[{\"question\":\"北京是哪个国家的首都?\",\"answer\":\"中国\"}]}'::jsonb, " + "'PENDING_REVIEW')"); datasetId = jdbcTemplate.queryForObject( "SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class); } - // ------------------------------------------------------------------ 娴嬭瘯 1: 瀹℃壒閫氳繃 鈫?缁堟€?-- + // ------------------------------------------------------------------ 测试 1: 审批通过 → 终态 -- @Test - @DisplayName("QA 瀹℃壒閫氳繃 鈫?training_dataset.status=APPROVED锛宻ource_data.status=APPROVED") + @DisplayName("QA 审批通过 → training_dataset.status=APPROVED,source_data.status=APPROVED") void approveQaTask_thenDatasetAndSourceApproved() { String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - // 娉ㄦ剰锛歈A 浠诲姟 claim 绔偣涓?POST /api/tasks/{id}/claim锛圓NNOTATOR 瑙掕壊锛? // 浣?TaskController.getPool 鍙粰 ANNOTATOR 鏄剧ず EXTRACTION/UNCLAIMED - // QA 浠诲姟鐢?ANNOTATOR 鐩存帴棰嗗彇锛堜笉缁忚繃浠诲姟姹狅級 + // 注意:QA 任务 claim 端点为 POST /api/tasks/{id}/claim(ANNOTATOR 角色) + // 但 TaskController.getPool 只给 ANNOTATOR 显示 EXTRACTION/UNCLAIMED + // QA 任务由 ANNOTATOR 直接领取(不经过任务池) ResponseEntity claimResp = restTemplate.exchange( baseUrl("/api/tasks/" + taskId + "/claim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 鎻愪氦 QA 缁撴灉 + // 提交 QA 结果 ResponseEntity submitResp = restTemplate.exchange( baseUrl("/api/qa/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 瀹℃壒閫氳繃 + // 审批通过 ResponseEntity approveResp = restTemplate.exchange( baseUrl("/api/qa/" + taskId + "/approve"), HttpMethod.POST, bearerRequest(reviewerToken), Map.class); assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 楠岃瘉锛歵raining_dataset 鈫?APPROVED + // 验证:training_dataset → APPROVED String datasetStatus = jdbcTemplate.queryForObject( "SELECT status FROM training_dataset WHERE id = ?", String.class, datasetId); - assertThat(datasetStatus).as("training_dataset 鐘舵€佸簲涓?APPROVED").isEqualTo("APPROVED"); + assertThat(datasetStatus).as("training_dataset 状态应为 APPROVED").isEqualTo("APPROVED"); - // 楠岃瘉锛歛nnotation_task 鈫?APPROVED锛宨s_final=true + // 验证:annotation_task → APPROVED,is_final=true Map taskRow = jdbcTemplate.queryForMap( "SELECT status, is_final FROM annotation_task WHERE id = ?", taskId); assertThat(taskRow.get("status")).isEqualTo("APPROVED"); assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE); - // 楠岃瘉锛歴ource_data 鈫?APPROVED锛堟暣鏉℃祦姘寸嚎瀹屾垚锛? String sourceStatus = jdbcTemplate.queryForObject( + // 验证:source_data → APPROVED(整条流水线完成) + String sourceStatus = jdbcTemplate.queryForObject( "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus).as("source_data 鐘舵€佸簲涓?APPROVED锛堟祦姘寸嚎缁堟€侊級").isEqualTo("APPROVED"); + assertThat(sourceStatus).as("source_data 状态应为 APPROVED(流水线终态)").isEqualTo("APPROVED"); } - // ------------------------------------------------------------------ 娴嬭瘯 2: 椹冲洖 鈫?鍊欓€夎褰曞垹闄?鈫?鍙噸棰?-- + // ------------------------------------------------------------------ 测试 2: 驳回 → 候选记录删除 → 可重领 -- @Test - @DisplayName("QA 椹冲洖 鈫?鍊欓€夐棶绛斿琚垹闄わ紝鏍囨敞鍛樺彲閲嶉骞跺啀娆℃彁浜?) + @DisplayName("QA 驳回 → 候选问答对被删除,标注员可重领并再次提交") void rejectQaTask_thenDatasetDeletedAndReclaimable() { String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - // 棰嗗彇骞舵彁浜? restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"), + // 领取并提交 + restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); restTemplate.exchange(baseUrl("/api/qa/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - // 椹冲洖锛堥┏鍥炲師鍥犲繀濉級 + // 驳回(驳回原因必填) HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + reviewerToken); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> rejectReq = new HttpEntity<>( - Map.of("reason", "闂鎻忚堪涓嶅噯纭紝璇蜂慨鏀?), headers); + Map.of("reason", "问题描述不准确,请修改"), headers); ResponseEntity rejectResp = restTemplate.exchange( baseUrl("/api/qa/" + taskId + "/reject"), HttpMethod.POST, rejectReq, Map.class); assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 楠岃瘉锛氫换鍔$姸鎬佸彉涓?REJECTED + // 验证:任务状态变为 REJECTED String statusAfterReject = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(statusAfterReject).isEqualTo("REJECTED"); - // 楠岃瘉锛氬€欓€夐棶绛斿宸茶鍒犻櫎 + // 验证:候选问答对已被删除 Integer datasetCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM training_dataset WHERE task_id = ?", Integer.class, taskId); - assertThat(datasetCount).as("椹冲洖鍚庡€欓€夐棶绛斿搴旇鍒犻櫎").isEqualTo(0); + assertThat(datasetCount).as("驳回后候选问答对应被删除").isEqualTo(0); - // 楠岃瘉锛歴ource_data 淇濇寔 QA_REVIEW锛堜笉鍙橈級 + // 验证:source_data 保持 QA_REVIEW(不变) String sourceStatus = jdbcTemplate.queryForObject( "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus).as("椹冲洖鍚?source_data 搴斾繚鎸?QA_REVIEW").isEqualTo("QA_REVIEW"); + assertThat(sourceStatus).as("驳回后 source_data 应保持 QA_REVIEW").isEqualTo("QA_REVIEW"); - // 鏍囨敞鍛橀噸棰嗕换鍔? ResponseEntity reclaimResp = restTemplate.exchange( + // 标注员重领任务 + ResponseEntity reclaimResp = restTemplate.exchange( baseUrl("/api/tasks/" + taskId + "/reclaim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 鍐嶆鎻愪氦 + // 再次提交 ResponseEntity resubmitResp = restTemplate.exchange( baseUrl("/api/qa/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 楠岃瘉锛氫换鍔$姸鎬佸彉涓?SUBMITTED + // 验证:任务状态变为 SUBMITTED String finalStatus = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(finalStatus).isEqualTo("SUBMITTED"); } - // ------------------------------------------------------------------ 宸ュ叿鏂规硶 -- + // ------------------------------------------------------------------ 工具方法 -- private String loginAndGetToken(String companyCode, String username, String password) { LoginRequest req = new LoginRequest(); diff --git a/src/test/java/com/label/integration/UserManagementIntegrationTest.java b/src/test/java/com/label/integration/UserManagementIntegrationTest.java index 9df878a..4676b14 100644 --- a/src/test/java/com/label/integration/UserManagementIntegrationTest.java +++ b/src/test/java/com/label/integration/UserManagementIntegrationTest.java @@ -1,7 +1,7 @@ package com.label.integration; import com.label.AbstractIntegrationTest; -import com.label.dto.LoginRequest; +import com.label.module.user.dto.LoginRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,8 +15,11 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; /** - * 鐢ㄦ埛绠$悊闆嗘垚娴嬭瘯锛圲S7锛夈€? * - * 娴嬭瘯鍦烘櫙锛? * 1. 鍙樻洿瑙掕壊鍚庢潈闄愪笅涓€娆¤姹傜珛鍗崇敓鏁堬紙鏃犻渶閲嶆柊鐧诲綍锛? * 2. 绂佺敤璐﹀彿鍚庣幇鏈?Token 涓嬩竴娆¤姹傜珛鍗宠繑鍥?401 + * 用户管理集成测试(US7)。 + * + * 测试场景: + * 1. 变更角色后权限下一次请求立即生效(无需重新登录) + * 2. 禁用账号后现有 Token 下一次请求立即返回 401 */ public class UserManagementIntegrationTest extends AbstractIntegrationTest { @@ -31,14 +34,14 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { assertThat(adminToken).isNotBlank(); } - // ------------------------------------------------------------------ 娴嬭瘯 1: 瑙掕壊鍙樻洿绔嬪嵆鐢熸晥 -- + // ------------------------------------------------------------------ 测试 1: 角色变更立即生效 -- @Test - @DisplayName("鍒涘缓鐢ㄦ埛涓?ANNOTATOR锛屽彉鏇翠负 REVIEWER 鍚庡悓涓€ Token 绔嬪嵆鍙闂鎵规帴鍙?) + @DisplayName("创建用户为 ANNOTATOR,变更为 REVIEWER 后同一 Token 立即可访问审批接口") void updateRole_takesEffectImmediately() { String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8); - // 1. 鍒涘缓 ANNOTATOR 鐢ㄦ埛 + // 1. 创建 ANNOTATOR 用户 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + adminToken); headers.setContentType(MediaType.APPLICATION_JSON); @@ -49,7 +52,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { new HttpEntity<>(Map.of( "username", uniqueUsername, "password", "test1234", - "realName", "娴嬭瘯鐢ㄦ埛", + "realName", "测试用户", "role", "ANNOTATOR" ), headers), Map.class); @@ -59,11 +62,11 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { Map userData = (Map) createResp.getBody().get("data"); Long newUserId = ((Number) userData.get("id")).longValue(); - // 2. 鏂扮敤鎴风櫥褰曡幏鍙?Token + // 2. 新用户登录获取 Token String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234"); assertThat(userToken).isNotBlank(); - // 3. 楠岃瘉锛欰NNOTATOR 鏃犳硶璁块棶寰呭鎵归槦鍒楋紙REVIEWER 涓撳睘锛夆啋 403 + // 3. 验证:ANNOTATOR 无法访问待审批队列(REVIEWER 专属)→ 403 ResponseEntity beforeRoleChange = restTemplate.exchange( baseUrl("/api/tasks/pending-review"), HttpMethod.GET, @@ -71,7 +74,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { Map.class); assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - // 4. ADMIN 鍙樻洿瑙掕壊涓?REVIEWER + // 4. ADMIN 变更角色为 REVIEWER ResponseEntity roleResp = restTemplate.exchange( baseUrl("/api/users/" + newUserId + "/role"), HttpMethod.PUT, @@ -79,25 +82,25 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { Map.class); assertThat(roleResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 5. 楠岃瘉锛氬悓涓€ Token 涓嬫璇锋眰绔嬪嵆鍏锋湁 REVIEWER 鏉冮檺 鈫?200 + // 5. 验证:同一 Token 下次请求立即具有 REVIEWER 权限 → 200 ResponseEntity afterRoleChange = restTemplate.exchange( baseUrl("/api/tasks/pending-review"), HttpMethod.GET, bearerRequest(userToken), Map.class); assertThat(afterRoleChange.getStatusCode()) - .as("瑙掕壊鍙樻洿鍚庡悓涓€ Token 搴旂珛鍗冲叿鏈?REVIEWER 鏉冮檺") + .as("角色变更后同一 Token 应立即具有 REVIEWER 权限") .isEqualTo(HttpStatus.OK); } - // ------------------------------------------------------------------ 娴嬭瘯 2: 绂佺敤璐﹀彿 Token 绔嬪嵆澶辨晥 -- + // ------------------------------------------------------------------ 测试 2: 禁用账号 Token 立即失效 -- @Test - @DisplayName("绂佺敤璐﹀彿鍚庯紝鐜版湁 Token 涓嬩竴娆¤姹傜珛鍗宠繑鍥?401") + @DisplayName("禁用账号后,现有 Token 下一次请求立即返回 401") void disableAccount_tokenInvalidatedImmediately() { String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8); - // 1. 鍒涘缓鐢ㄦ埛 + // 1. 创建用户 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + adminToken); headers.setContentType(MediaType.APPLICATION_JSON); @@ -108,7 +111,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { new HttpEntity<>(Map.of( "username", uniqueUsername, "password", "test1234", - "realName", "娴嬭瘯鐢ㄦ埛", + "realName", "测试用户", "role", "ANNOTATOR" ), headers), Map.class); @@ -118,11 +121,11 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { Map userData = (Map) createResp.getBody().get("data"); Long newUserId = ((Number) userData.get("id")).longValue(); - // 2. 鏂扮敤鎴风櫥褰曪紝鑾峰彇 Token + // 2. 新用户登录,获取 Token String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234"); assertThat(userToken).isNotBlank(); - // 3. 楠岃瘉 Token 鏈夋晥 + // 3. 验证 Token 有效 ResponseEntity meResp = restTemplate.exchange( baseUrl("/api/auth/me"), HttpMethod.GET, @@ -130,7 +133,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { Map.class); assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 4. ADMIN 绂佺敤璐﹀彿 + // 4. ADMIN 禁用账号 ResponseEntity disableResp = restTemplate.exchange( baseUrl("/api/users/" + newUserId + "/status"), HttpMethod.PUT, @@ -138,18 +141,18 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { Map.class); assertThat(disableResp.getStatusCode()).isEqualTo(HttpStatus.OK); - // 5. 楠岃瘉锛氱鐢ㄥ悗锛岀幇鏈?Token 绔嬪嵆澶辨晥 鈫?401 + // 5. 验证:禁用后,现有 Token 立即失效 → 401 ResponseEntity meAfterDisable = restTemplate.exchange( baseUrl("/api/auth/me"), HttpMethod.GET, bearerRequest(userToken), Map.class); assertThat(meAfterDisable.getStatusCode()) - .as("绂佺敤璐﹀彿鍚庣幇鏈?Token 搴旂珛鍗冲け鏁?) + .as("禁用账号后现有 Token 应立即失效") .isEqualTo(HttpStatus.UNAUTHORIZED); } - // ------------------------------------------------------------------ 宸ュ叿鏂规硶 -- + // ------------------------------------------------------------------ 工具方法 -- private String loginAndGetToken(String companyCode, String username, String password) { LoginRequest req = new LoginRequest(); diff --git a/src/test/java/com/label/unit/OpenApiAnnotationTest.java b/src/test/java/com/label/unit/OpenApiAnnotationTest.java index 271f3aa..5d26dcb 100644 --- a/src/test/java/com/label/unit/OpenApiAnnotationTest.java +++ b/src/test/java/com/label/unit/OpenApiAnnotationTest.java @@ -5,14 +5,14 @@ import com.label.module.annotation.controller.QaController; import com.label.module.config.controller.SysConfigController; import com.label.module.export.controller.ExportController; import com.label.module.source.controller.SourceController; -import com.label.dto.SourceResponse; +import com.label.module.source.dto.SourceResponse; import com.label.module.task.controller.TaskController; -import com.label.dto.TaskResponse; +import com.label.module.task.dto.TaskResponse; import com.label.module.user.controller.AuthController; import com.label.module.user.controller.UserController; -import com.label.dto.LoginRequest; -import com.label.dto.LoginResponse; -import com.label.dto.UserInfoResponse; +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.video.controller.VideoController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; @@ -32,7 +32,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("OpenAPI 娉ㄨВ瑕嗙洊娴嬭瘯") +@DisplayName("OpenAPI 注解覆盖测试") class OpenApiAnnotationTest { private static final List> CONTROLLERS = List.of( @@ -56,7 +56,7 @@ class OpenApiAnnotationTest { ); @Test - @DisplayName("鎵€鏈?REST Controller 閮藉0鏄?@Tag") + @DisplayName("所有 REST Controller 都声明 @Tag") void allControllersHaveTag() { assertThat(CONTROLLERS) .allSatisfy(controller -> @@ -66,7 +66,7 @@ class OpenApiAnnotationTest { } @Test - @DisplayName("鎵€鏈?REST endpoint 鏂规硶閮藉0鏄?@Operation") + @DisplayName("所有 REST endpoint 方法都声明 @Operation") void allEndpointMethodsHaveOperation() { for (Class controller : CONTROLLERS) { Arrays.stream(controller.getDeclaredMethods()) @@ -79,7 +79,7 @@ class OpenApiAnnotationTest { } @Test - @DisplayName("鏍稿績 DTO 閮藉0鏄?@Schema") + @DisplayName("核心 DTO 都声明 @Schema") void coreDtosHaveSchema() { assertThat(DTOS) .allSatisfy(dto -> diff --git a/src/test/java/com/label/unit/PackageStructureMigrationTest.java b/src/test/java/com/label/unit/PackageStructureMigrationTest.java index 3332d13..7ea8bc7 100644 --- a/src/test/java/com/label/unit/PackageStructureMigrationTest.java +++ b/src/test/java/com/label/unit/PackageStructureMigrationTest.java @@ -29,39 +29,6 @@ class PackageStructureMigrationTest { assertClassMissing("com.label.module.annotation.service.ExtractionApprovedEventListener"); } - @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); - } - } - private static void assertClassExists(String fqcn) { assertThatCode(() -> Class.forName(fqcn)).doesNotThrowAnyException(); }