diff --git a/src/main/java/com/label/dto/LoginRequest.java b/src/main/java/com/label/dto/LoginRequest.java new file mode 100644 index 0000000..1e70171 --- /dev/null +++ b/src/main/java/com/label/dto/LoginRequest.java @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..e188693 --- /dev/null +++ b/src/main/java/com/label/dto/LoginResponse.java @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..c3a5eec --- /dev/null +++ b/src/main/java/com/label/dto/SourceResponse.java @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..478866b --- /dev/null +++ b/src/main/java/com/label/dto/TaskResponse.java @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..af3fa9d --- /dev/null +++ b/src/main/java/com/label/dto/UserInfoResponse.java @@ -0,0 +1,25 @@ +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/module/annotation/entity/AnnotationResult.java b/src/main/java/com/label/entity/AnnotationResult.java similarity index 58% rename from src/main/java/com/label/module/annotation/entity/AnnotationResult.java rename to src/main/java/com/label/entity/AnnotationResult.java index 6b9dce1..bf2e716 100644 --- a/src/main/java/com/label/module/annotation/entity/AnnotationResult.java +++ b/src/main/java/com/label/entity/AnnotationResult.java @@ -1,32 +1,30 @@ -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; -} +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; +} diff --git a/src/main/java/com/label/entity/AnnotationTask.java b/src/main/java/com/label/entity/AnnotationTask.java new file mode 100644 index 0000000..1378899 --- /dev/null +++ b/src/main/java/com/label/entity/AnnotationTask.java @@ -0,0 +1,58 @@ +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/module/task/entity/AnnotationTaskHistory.java b/src/main/java/com/label/entity/AnnotationTaskHistory.java similarity index 57% rename from src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java rename to src/main/java/com/label/entity/AnnotationTaskHistory.java index 6e2c638..11259bb 100644 --- a/src/main/java/com/label/module/task/entity/AnnotationTaskHistory.java +++ b/src/main/java/com/label/entity/AnnotationTaskHistory.java @@ -1,43 +1,42 @@ -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; -} +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; +} diff --git a/src/main/java/com/label/module/export/entity/ExportBatch.java b/src/main/java/com/label/entity/ExportBatch.java similarity index 50% rename from src/main/java/com/label/module/export/entity/ExportBatch.java rename to src/main/java/com/label/entity/ExportBatch.java index d7447b0..e0bc982 100644 --- a/src/main/java/com/label/module/export/entity/ExportBatch.java +++ b/src/main/java/com/label/entity/ExportBatch.java @@ -1,44 +1,43 @@ -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; -} +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; +} diff --git a/src/main/java/com/label/entity/SourceData.java b/src/main/java/com/label/entity/SourceData.java new file mode 100644 index 0000000..f311f81 --- /dev/null +++ b/src/main/java/com/label/entity/SourceData.java @@ -0,0 +1,55 @@ +package com.label.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import 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/module/user/entity/SysCompany.java b/src/main/java/com/label/entity/SysCompany.java similarity index 56% rename from src/main/java/com/label/module/user/entity/SysCompany.java rename to src/main/java/com/label/entity/SysCompany.java index 9f79582..6028594 100644 --- a/src/main/java/com/label/module/user/entity/SysCompany.java +++ b/src/main/java/com/label/entity/SysCompany.java @@ -1,34 +1,33 @@ -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; -} +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; +} diff --git a/src/main/java/com/label/entity/SysConfig.java b/src/main/java/com/label/entity/SysConfig.java new file mode 100644 index 0000000..221824a --- /dev/null +++ b/src/main/java/com/label/entity/SysConfig.java @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..64ec61c --- /dev/null +++ b/src/main/java/com/label/entity/SysUser.java @@ -0,0 +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 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/module/annotation/entity/TrainingDataset.java b/src/main/java/com/label/entity/TrainingDataset.java similarity index 56% rename from src/main/java/com/label/module/annotation/entity/TrainingDataset.java rename to src/main/java/com/label/entity/TrainingDataset.java index feafa45..9336846 100644 --- a/src/main/java/com/label/module/annotation/entity/TrainingDataset.java +++ b/src/main/java/com/label/entity/TrainingDataset.java @@ -1,46 +1,45 @@ -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; -} +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; +} diff --git a/src/main/java/com/label/entity/VideoProcessJob.java b/src/main/java/com/label/entity/VideoProcessJob.java new file mode 100644 index 0000000..445b6d6 --- /dev/null +++ b/src/main/java/com/label/entity/VideoProcessJob.java @@ -0,0 +1,56 @@ +package com.label.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 瑙嗛澶勭悊浠诲姟瀹炰綋锛屽搴?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 6e222ae..e9bc6d8 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.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.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.task.service.TaskClaimService; import com.label.module.task.service.TaskService; import com.label.event.ExtractionApprovedEvent; @@ -25,18 +25,13 @@ import java.util.List; import java.util.Map; /** - * 提取审批通过后的异步处理器。 + * 鎻愬彇瀹℃壒閫氳繃鍚庣殑寮傛澶勭悊鍣ㄣ€? * + * 璁捐绾︽潫锛堝叧閿級锛? * - @TransactionalEventListener(AFTER_COMMIT)锛氱‘淇濆湪瀹℃壒浜嬪姟鎻愪氦鍚庢墠瑙﹀彂 AI 璋冪敤 + * - @Transactional(REQUIRES_NEW)锛氬湪鐙珛鏂颁簨鍔′腑鍐?DB锛屼笌瀹℃壒浜嬪姟瀹屽叏闅旂 + * - 寮傚父涓嶄細鍥炴粴瀹℃壒浜嬪姟锛堝凡鎻愪氦锛夛紝浣嗕細鍦ㄦ棩蹇椾腑璁板綍 * - * 设计约束(关键): - * - @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 + * 澶勭悊娴佺▼锛? * 1. 璋冪敤 AI 鐢熸垚鍊欓€夐棶绛斿锛圱ext/Image 璧颁笉鍚岀鐐癸級 + * 2. 鍐欏叆 training_dataset锛坰tatus=PENDING_REVIEW锛? * 3. 鍒涘缓 QA_GENERATION 浠诲姟锛坰tatus=UNCLAIMED锛? * 4. 鏇存柊 source_data 鐘舵€佷负 QA_REVIEW */ @Slf4j @Component @@ -55,16 +50,15 @@ 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("处理审批通过事件失败(taskId={}):{}", event.getTaskId(), e.getMessage(), e); - // 不向上抛出,审批操作已提交,此处失败不回滚审批 - } finally { + log.error("澶勭悊瀹℃壒閫氳繃浜嬩欢澶辫触锛坱askId={}锛夛細{}", event.getTaskId(), e.getMessage(), e); + // 涓嶅悜涓婃姏鍑猴紝瀹℃壒鎿嶄綔宸叉彁浜わ紝姝ゅ澶辫触涓嶅洖婊氬鎵? } finally { CompanyContext.clear(); } } @@ -72,11 +66,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()) @@ -91,12 +85,11 @@ public class ExtractionApprovedEventListener { qaPairs = response != null && response.getQaPairs() != null ? response.getQaPairs() : Collections.emptyList(); } catch (Exception e) { - log.warn("AI 问答生成失败(taskId={}):{},将使用空问答对", event.getTaskId(), e.getMessage()); + log.warn("AI 闂瓟鐢熸垚澶辫触锛坱askId={}锛夛細{}锛屽皢浣跨敤绌洪棶绛斿", event.getTaskId(), e.getMessage()); qaPairs = Collections.emptyList(); } - // 2. 写入 training_dataset(PENDING_REVIEW) - String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT"; + // 2. 鍐欏叆 training_dataset锛圥ENDING_REVIEW锛? String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT"; String glmJson = buildGlmJson(qaPairs); TrainingDataset dataset = new TrainingDataset(); @@ -108,23 +101,21 @@ public class ExtractionApprovedEventListener { dataset.setStatus("PENDING_REVIEW"); datasetMapper.insert(dataset); - // 3. 创建 QA_GENERATION 任务(UNCLAIMED) - taskService.createTask(event.getSourceId(), "QA_GENERATION", event.getCompanyId()); + // 3. 鍒涘缓 QA_GENERATION 浠诲姟锛圲NCLAIMED锛? 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/module/annotation/mapper/AnnotationResultMapper.java b/src/main/java/com/label/mapper/AnnotationResultMapper.java similarity index 50% rename from src/main/java/com/label/module/annotation/mapper/AnnotationResultMapper.java rename to src/main/java/com/label/mapper/AnnotationResultMapper.java index 4290c62..8447f1a 100644 --- a/src/main/java/com/label/module/annotation/mapper/AnnotationResultMapper.java +++ b/src/main/java/com/label/mapper/AnnotationResultMapper.java @@ -1,36 +1,31 @@ -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); -} +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); +} diff --git a/src/main/java/com/label/mapper/AnnotationTaskMapper.java b/src/main/java/com/label/mapper/AnnotationTaskMapper.java new file mode 100644 index 0000000..c3248d1 --- /dev/null +++ b/src/main/java/com/label/mapper/AnnotationTaskMapper.java @@ -0,0 +1,26 @@ +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/module/export/mapper/ExportBatchMapper.java b/src/main/java/com/label/mapper/ExportBatchMapper.java similarity index 60% rename from src/main/java/com/label/module/export/mapper/ExportBatchMapper.java rename to src/main/java/com/label/mapper/ExportBatchMapper.java index acbb1d8..18a0da1 100644 --- a/src/main/java/com/label/module/export/mapper/ExportBatchMapper.java +++ b/src/main/java/com/label/mapper/ExportBatchMapper.java @@ -1,31 +1,28 @@ -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); -} +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); +} diff --git a/src/main/java/com/label/module/source/mapper/SourceDataMapper.java b/src/main/java/com/label/mapper/SourceDataMapper.java similarity index 52% rename from src/main/java/com/label/module/source/mapper/SourceDataMapper.java rename to src/main/java/com/label/mapper/SourceDataMapper.java index c6ea424..0cbabe3 100644 --- a/src/main/java/com/label/module/source/mapper/SourceDataMapper.java +++ b/src/main/java/com/label/mapper/SourceDataMapper.java @@ -1,28 +1,25 @@ -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); -} +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); +} diff --git a/src/main/java/com/label/mapper/SysCompanyMapper.java b/src/main/java/com/label/mapper/SysCompanyMapper.java new file mode 100644 index 0000000..4915d34 --- /dev/null +++ b/src/main/java/com/label/mapper/SysCompanyMapper.java @@ -0,0 +1,20 @@ +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/module/config/mapper/SysConfigMapper.java b/src/main/java/com/label/mapper/SysConfigMapper.java similarity index 50% rename from src/main/java/com/label/module/config/mapper/SysConfigMapper.java rename to src/main/java/com/label/mapper/SysConfigMapper.java index c63c5c9..b2c87b9 100644 --- a/src/main/java/com/label/module/config/mapper/SysConfigMapper.java +++ b/src/main/java/com/label/mapper/SysConfigMapper.java @@ -1,36 +1,33 @@ -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); -} +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); +} diff --git a/src/main/java/com/label/mapper/SysUserMapper.java b/src/main/java/com/label/mapper/SysUserMapper.java new file mode 100644 index 0000000..2ecf9e6 --- /dev/null +++ b/src/main/java/com/label/mapper/SysUserMapper.java @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..7579f84 --- /dev/null +++ b/src/main/java/com/label/mapper/TaskHistoryMapper.java @@ -0,0 +1,13 @@ +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/module/annotation/mapper/TrainingDatasetMapper.java b/src/main/java/com/label/mapper/TrainingDatasetMapper.java similarity index 54% rename from src/main/java/com/label/module/annotation/mapper/TrainingDatasetMapper.java rename to src/main/java/com/label/mapper/TrainingDatasetMapper.java index 94eefde..74d21bf 100644 --- a/src/main/java/com/label/module/annotation/mapper/TrainingDatasetMapper.java +++ b/src/main/java/com/label/mapper/TrainingDatasetMapper.java @@ -1,36 +1,33 @@ -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); -} +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); +} diff --git a/src/main/java/com/label/module/video/mapper/VideoProcessJobMapper.java b/src/main/java/com/label/mapper/VideoProcessJobMapper.java similarity index 59% rename from src/main/java/com/label/module/video/mapper/VideoProcessJobMapper.java rename to src/main/java/com/label/mapper/VideoProcessJobMapper.java index a90ea58..26c3dbe 100644 --- a/src/main/java/com/label/module/video/mapper/VideoProcessJobMapper.java +++ b/src/main/java/com/label/mapper/VideoProcessJobMapper.java @@ -1,12 +1,11 @@ -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 { -} +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 { +} 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 0bdd7b6..7fafcd6 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.module.annotation.entity.AnnotationResult; -import com.label.module.annotation.entity.TrainingDataset; +import com.label.entity.AnnotationResult; +import com.label.entity.TrainingDataset; import com.label.event.ExtractionApprovedEvent; -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.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.task.service.TaskClaimService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -30,12 +30,8 @@ import java.util.Collections; import java.util.Map; /** - * 提取阶段标注服务:AI 预标注、更新结果、提交、审批、驳回。 - * - * 关键设计: - * - approve() 内禁止直接调用 AI,通过 ExtractionApprovedEvent 解耦(AFTER_COMMIT) - * - 所有写操作包裹在 @Transactional 中,确保任务状态和历史的一致性 - */ + * 鎻愬彇闃舵鏍囨敞鏈嶅姟锛欰I 棰勬爣娉ㄣ€佹洿鏂扮粨鏋溿€佹彁浜ゃ€佸鎵广€侀┏鍥炪€? * + * 鍏抽敭璁捐锛? * - approve() 鍐呯姝㈢洿鎺ヨ皟鐢?AI锛岄€氳繃 ExtractionApprovedEvent 瑙h€︼紙AFTER_COMMIT锛? * - 鎵€鏈夊啓鎿嶄綔鍖呰9鍦?@Transactional 涓紝纭繚浠诲姟鐘舵€佸拰鍘嗗彶鐨勪竴鑷存€? */ @Slf4j @Service @RequiredArgsConstructor @@ -53,22 +49,19 @@ 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) @@ -82,39 +75,35 @@ 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(UPSERT 语义) - writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems()); + // 灏?AI 缁撴灉鍐欏叆 annotation_result锛圲PSERT 璇箟锛? 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()); @@ -123,11 +112,10 @@ public class ExtractionService { } } - // ------------------------------------------------------------------ 提交 -- + // ------------------------------------------------------------------ 鎻愪氦 -- /** - * 提交提取结果(IN_PROGRESS → SUBMITTED)。 - */ + * 鎻愪氦鎻愬彇缁撴灉锛圛N_PROGRESS 鈫?SUBMITTED锛夈€? */ @Transactional public void submit(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); @@ -145,32 +133,27 @@ public class ExtractionService { principal.getUserId(), principal.getRole(), null); } - // ------------------------------------------------------------------ 审批通过 -- + // ------------------------------------------------------------------ 瀹℃壒閫氳繃 -- /** - * 审批通过(SUBMITTED → APPROVED)。 - * - * 两阶段: - * 1. 同步事务:is_final=true,状态推进,写历史 - * 2. 事务提交后(AFTER_COMMIT):AI 生成问答对 → training_dataset → QA 任务 → source_data 状态 - * - * 注:AI 调用严禁在此事务内执行。 - */ + * 瀹℃壒閫氳繃锛圫UBMITTED 鈫?APPROVED锛夈€? * + * 涓ら樁娈碉細 + * 1. 鍚屾浜嬪姟锛歩s_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) @@ -180,33 +163,30 @@ 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())); } - // ------------------------------------------------------------------ 驳回 -- + // ------------------------------------------------------------------ 椹冲洖 -- /** - * 驳回提取结果(SUBMITTED → REJECTED)。 - */ + * 椹冲洖鎻愬彇缁撴灉锛圫UBMITTED 鈫?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, @@ -222,11 +202,10 @@ 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); @@ -241,15 +220,14 @@ 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; } @@ -266,7 +244,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 ca7576a..a822cd1 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.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.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.task.service.TaskClaimService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,13 +25,9 @@ import java.util.List; import java.util.Map; /** - * 问答生成阶段标注服务:查询候选问答对、更新、提交、审批、驳回。 - * - * 关键设计: - * - QA 阶段无 AI 调用(候选问答对已由 ExtractionApprovedEventListener 生成) - * - approve() 同一事务内完成:training_dataset → APPROVED、task → APPROVED、source_data → APPROVED - * - reject() 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态 - */ + * 闂瓟鐢熸垚闃舵鏍囨敞鏈嶅姟锛氭煡璇㈠€欓€夐棶绛斿銆佹洿鏂般€佹彁浜ゃ€佸鎵广€侀┏鍥炪€? * + * 鍏抽敭璁捐锛? * - QA 闃舵鏃?AI 璋冪敤锛堝€欓€夐棶绛斿宸茬敱 ExtractionApprovedEventListener 鐢熸垚锛? * - approve() 鍚屼竴浜嬪姟鍐呭畬鎴愶細training_dataset 鈫?APPROVED銆乼ask 鈫?APPROVED銆乻ource_data 鈫?APPROVED + * - reject() 娓呴櫎鍊欓€夐棶绛斿锛坉eleteByTaskId锛夛紝source_data 淇濇寔 QA_REVIEW 鐘舵€? */ @Slf4j @Service @RequiredArgsConstructor @@ -43,11 +39,10 @@ public class QaService { private final TaskClaimService taskClaimService; private final ObjectMapper objectMapper; - // ------------------------------------------------------------------ 查询 -- + // ------------------------------------------------------------------ 鏌ヨ -- /** - * 获取候选问答对(从 training_dataset.glm_format_json 解析)。 - */ + * 鑾峰彇鍊欓€夐棶绛斿锛堜粠 training_dataset.glm_format_json 瑙f瀽锛夈€? */ public Map getResult(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); TrainingDataset dataset = getDataset(taskId); @@ -65,7 +60,7 @@ public class QaService { items = (List) conversations; } } catch (Exception e) { - log.warn("解析 QA JSON 失败(taskId={}):{}", taskId, e.getMessage()); + log.warn("瑙f瀽 QA JSON 澶辫触锛坱askId={}锛夛細{}", taskId, e.getMessage()); } } @@ -76,27 +71,26 @@ 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") @@ -114,7 +108,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); @@ -127,11 +121,10 @@ public class QaService { } } - // ------------------------------------------------------------------ 提交 -- + // ------------------------------------------------------------------ 鎻愪氦 -- /** - * 提交 QA 结果(IN_PROGRESS → SUBMITTED)。 - */ + * 鎻愪氦 QA 缁撴灉锛圛N_PROGRESS 鈫?SUBMITTED锛夈€? */ @Transactional public void submit(Long taskId, TokenPrincipal principal) { AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId()); @@ -149,78 +142,71 @@ public class QaService { principal.getUserId(), principal.getRole(), null); } - // ------------------------------------------------------------------ 审批通过 -- + // ------------------------------------------------------------------ 瀹℃壒閫氳繃 -- /** - * 审批通过(SUBMITTED → APPROVED)。 - * - * 同一事务: - * 1. 校验任务(先于一切 DB 写入) - * 2. 自审校验 + * 瀹℃壒閫氳繃锛圫UBMITTED 鈫?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 结果(SUBMITTED → REJECTED)。 - * - * 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态不变。 - */ + * 椹冲洖 QA 缁撴灉锛圫UBMITTED 鈫?REJECTED锛夈€? * + * 娓呴櫎鍊欓€夐棶绛斿锛坉eleteByTaskId锛夛紝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() @@ -233,12 +219,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 e53aebd..f0271ab 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.module.config.entity.SysConfig; +import com.label.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,12 +15,9 @@ import java.util.List; import java.util.Map; /** - * 系统配置接口(2 个端点,均需 ADMIN 权限)。 - * - * GET /api/config — 查询当前公司所有可见配置(公司专属 + 全局默认合并) - * PUT /api/config/{key} — 更新/创建公司专属配置(UPSERT) - */ -@Tag(name = "系统配置", description = "全局和公司级系统配置管理") + * 绯荤粺閰嶇疆鎺ュ彛锛? 涓鐐癸紝鍧囬渶 ADMIN 鏉冮檺锛夈€? * + * GET /api/config 鈥?鏌ヨ褰撳墠鍏徃鎵€鏈夊彲瑙侀厤缃紙鍏徃涓撳睘 + 鍏ㄥ眬榛樿鍚堝苟锛? * PUT /api/config/{key} 鈥?鏇存柊/鍒涘缓鍏徃涓撳睘閰嶇疆锛圲PSERT锛? */ +@Tag(name = "绯荤粺閰嶇疆", description = "鍏ㄥ眬鍜屽叕鍙哥骇绯荤粺閰嶇疆绠$悊") @RestController @RequiredArgsConstructor public class SysConfigController { @@ -28,13 +25,10 @@ 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) { @@ -43,11 +37,10 @@ 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 deleted file mode 100644 index f28e4fb..0000000 --- a/src/main/java/com/label/module/config/entity/SysConfig.java +++ /dev/null @@ -1,41 +0,0 @@ -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/module/config/service/SysConfigService.java b/src/main/java/com/label/module/config/service/SysConfigService.java index 5e0d01e..4323dcd 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.module.config.entity.SysConfig; -import com.label.module.config.mapper.SysConfigMapper; +import com.label.entity.SysConfig; +import com.label.mapper.SysConfigMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -14,20 +14,17 @@ import java.util.*; import java.util.stream.Collectors; /** - * 系统配置服务。 - * - * 配置查找优先级:公司专属(company_id = N)> 全局默认(company_id IS NULL)。 - * - * get() — 按优先级返回单个配置值 - * list() — 返回合并后的配置列表(公司专属覆盖同名全局配置),附 scope 字段 - * update() — 以公司专属配置进行 UPSERT(仅允许已知配置键) + * 绯荤粺閰嶇疆鏈嶅姟銆? * + * 閰嶇疆鏌ユ壘浼樺厛绾э細鍏徃涓撳睘锛坈ompany_id = N锛? 鍏ㄥ眬榛樿锛坈ompany_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", @@ -36,42 +33,36 @@ 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); } @@ -89,33 +80,28 @@ public class SysConfigService { .collect(Collectors.toList()); } - // ------------------------------------------------------------------ 更新配置 -- + // ------------------------------------------------------------------ 鏇存柊閰嶇疆 -- /** - * 更新公司专属配置(UPSERT)。 - * - * 仅允许 KNOWN_KEYS 中的配置键,防止写入未定义的配置项。 - * - * @param configKey 配置键 - * @param value 新配置值 - * @param description 配置说明(可选) - * @param companyId 当前公司 ID + * 鏇存柊鍏徃涓撳睘閰嶇疆锛圲PSERT锛夈€? * + * 浠呭厑璁?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()) { @@ -123,7 +109,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(); @@ -132,7 +118,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 b60e7a5..4a84c52 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.module.annotation.entity.TrainingDataset; -import com.label.module.export.entity.ExportBatch; +import com.label.entity.TrainingDataset; +import com.label.entity.ExportBatch; import com.label.module.export.service.ExportService; import com.label.module.export.service.FinetuneService; import io.swagger.v3.oas.annotations.Operation; @@ -19,9 +19,8 @@ import java.util.List; import java.util.Map; /** - * 训练数据导出与微调接口(5 个端点,全部 ADMIN 权限)。 - */ -@Tag(name = "导出管理", description = "训练样本查询、导出批次和微调任务") + * 璁粌鏁版嵁瀵煎嚭涓庡井璋冩帴鍙o紙5 涓鐐癸紝鍏ㄩ儴 ADMIN 鏉冮檺锛夈€? */ +@Tag(name = "瀵煎嚭绠$悊", description = "璁粌鏍锋湰鏌ヨ銆佸鍑烘壒娆″拰寰皟浠诲姟") @RestController @RequiredArgsConstructor public class ExportController { @@ -29,8 +28,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( @@ -42,8 +41,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) @@ -57,8 +56,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, @@ -66,8 +65,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, @@ -75,8 +74,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/module/export/service/ExportService.java b/src/main/java/com/label/module/export/service/ExportService.java index 75361fc..7d1c537 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.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 com.label.entity.TrainingDataset; +import com.label.mapper.TrainingDatasetMapper; +import com.label.entity.ExportBatch; +import com.label.mapper.ExportBatchMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -26,15 +26,9 @@ import java.util.UUID; import java.util.stream.Collectors; /** - * 训练数据导出服务。 - * - * createBatch() 步骤: - * 1. 校验 sampleIds 非空(EMPTY_SAMPLES 400) - * 2. 查询 training_dataset,校验全部为 APPROVED(INVALID_SAMPLES 400) - * 3. 生成 JSONL(每行一个 glm_format_json) - * 4. 上传 RustFS(bucket: finetune-export, key: export/{batchUuid}.jsonl) - * 5. 插入 export_batch 记录 - * 6. 批量更新 training_dataset.export_batch_id + exported_at + * 璁粌鏁版嵁瀵煎嚭鏈嶅姟銆? * + * 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 */ @Slf4j @Service @@ -47,42 +41,39 @@ 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"; @@ -90,8 +81,7 @@ 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()); @@ -100,32 +90,30 @@ 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) { @@ -150,11 +138,10 @@ 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( @@ -165,12 +152,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 0359fc0..cc2558d 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.module.export.entity.ExportBatch; -import com.label.module.export.mapper.ExportBatchMapper; +import com.label.entity.ExportBatch; +import com.label.mapper.ExportBatchMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -14,11 +14,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Map; /** - * GLM 微调服务:提交任务、查询状态。 - * - * 注意:trigger() 包含 AI HTTP 调用,不在 @Transactional 注解下。 - * 仅在 DB 写入时开启事务(updateFinetuneInfo)。 - */ + * GLM 寰皟鏈嶅姟锛氭彁浜や换鍔°€佹煡璇㈢姸鎬併€? * + * 娉ㄦ剰锛歵rigger() 鍖呭惈 AI HTTP 璋冪敤锛屼笉鍦?@Transactional 娉ㄨВ涓嬨€? * 浠呭湪 DB 鍐欏叆鏃跺紑鍚簨鍔★紙updateFinetuneInfo锛夈€? */ @Slf4j @Service @RequiredArgsConstructor @@ -28,29 +25,24 @@ public class FinetuneService { private final ExportService exportService; private final AiServiceClient aiServiceClient; - // ------------------------------------------------------------------ 提交微调 -- + // ------------------------------------------------------------------ 鎻愪氦寰皟 -- /** - * 向 GLM AI 服务提交微调任务。 - * - * T074 设计:AI 调用不在 @Transactional 内执行,避免持有 DB 连接期间发起 HTTP 请求。 - * DB 写入(updateFinetuneInfo)是单条 UPDATE,不需要显式事务(自动提交)。 - * 如果 AI 调用成功但 DB 写入失败,下次查询状态仍可通过 AI 服务的 jobId 重建状态。 - * - * @param batchId 批次 ID - * @param principal 当前用户 - * @return 包含 glmJobId 和 finetuneStatus 的 Map + * 鍚?GLM AI 鏈嶅姟鎻愪氦寰皟浠诲姟銆? * + * T074 璁捐锛欰I 璋冪敤涓嶅湪 @Transactional 鍐呮墽琛岋紝閬垮厤鎸佹湁 DB 杩炴帴鏈熼棿鍙戣捣 HTTP 璇锋眰銆? * DB 鍐欏叆锛坲pdateFinetuneInfo锛夋槸鍗曟潯 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) @@ -61,14 +53,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(), @@ -76,14 +68,13 @@ 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); @@ -98,19 +89,18 @@ 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 379272f..514ab57 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.module.source.dto.SourceResponse; +import com.label.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,13 +15,10 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; /** - * 原始资料管理接口。 - * - * 权限设计: - * - 上传 / 列表 / 详情:UPLOADER 及以上角色(含 ANNOTATOR、REVIEWER、ADMIN) - * - 删除:仅 ADMIN + * 鍘熷璧勬枡绠$悊鎺ュ彛銆? * + * 鏉冮檺璁捐锛? * - 涓婁紶 / 鍒楄〃 / 璇︽儏锛歎PLOADER 鍙婁互涓婅鑹诧紙鍚?ANNOTATOR銆丷EVIEWER銆丄DMIN锛? * - 鍒犻櫎锛氫粎 ADMIN */ -@Tag(name = "资料管理", description = "原始资料上传、查询和删除") +@Tag(name = "璧勬枡绠$悊", description = "鍘熷璧勬枡涓婁紶銆佹煡璇㈠拰鍒犻櫎") @RestController @RequestMapping("/api/source") @RequiredArgsConstructor @@ -30,10 +27,8 @@ public class SourceController { private final SourceService sourceService; /** - * 上传文件(multipart/form-data)。 - * 返回 201 Created + 资料摘要。 - */ - @Operation(summary = "上传原始资料", description = "dataType: text,image, video") + * 涓婁紶鏂囦欢锛坢ultipart/form-data锛夈€? * 杩斿洖 201 Created + 璧勬枡鎽樿銆? */ + @Operation(summary = "涓婁紶鍘熷璧勬枡", description = "dataType: text,image, video") @PostMapping("/upload") @RequiresRoles("UPLOADER") @ResponseStatus(HttpStatus.CREATED) @@ -46,10 +41,8 @@ public class SourceController { } /** - * 分页查询资料列表。 - * UPLOADER 只见自己的资料;ADMIN 见全公司资料。 - */ - @Operation(summary = "分页查询资料列表") + * 鍒嗛〉鏌ヨ璧勬枡鍒楄〃銆? * UPLOADER 鍙鑷繁鐨勮祫鏂欙紱ADMIN 瑙佸叏鍏徃璧勬枡銆? */ + @Operation(summary = "鍒嗛〉鏌ヨ璧勬枡鍒楄〃") @GetMapping("/list") @RequiresRoles("UPLOADER") public Result> list( @@ -63,9 +56,8 @@ public class SourceController { } /** - * 查询资料详情(含 15 分钟预签名下载链接)。 - */ - @Operation(summary = "查询资料详情") + * 鏌ヨ璧勬枡璇︽儏锛堝惈 15 鍒嗛挓棰勭鍚嶄笅杞介摼鎺ワ級銆? */ + @Operation(summary = "鏌ヨ璧勬枡璇︽儏") @GetMapping("/{id}") @RequiresRoles("UPLOADER") public Result findById(@PathVariable Long id) { @@ -73,10 +65,8 @@ 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 deleted file mode 100644 index fa3a088..0000000 --- a/src/main/java/com/label/module/source/dto/SourceResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 81a342e..0000000 --- a/src/main/java/com/label/module/source/entity/SourceData.java +++ /dev/null @@ -1,56 +0,0 @@ -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/module/source/service/SourceService.java b/src/main/java/com/label/module/source/service/SourceService.java index f4bb740..58f33e9 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.module.source.dto.SourceResponse; -import com.label.module.source.entity.SourceData; -import com.label.module.source.mapper.SourceDataMapper; +import com.label.dto.SourceResponse; +import com.label.entity.SourceData; +import com.label.mapper.SourceDataMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -24,11 +24,8 @@ import java.util.Set; import java.util.stream.Collectors; /** - * 原始资料业务服务。 - * - * 上传流程:先 INSERT 获取 ID → 构造 RustFS 路径 → 上传文件 → UPDATE filePath。 - * 删除规则:仅 PENDING 状态可删(防止删除已进入标注流水线的资料)。 - */ + * 鍘熷璧勬枡涓氬姟鏈嶅姟銆? * + * 涓婁紶娴佺▼锛氬厛 INSERT 鑾峰彇 ID 鈫?鏋勯€?RustFS 璺緞 鈫?涓婁紶鏂囦欢 鈫?UPDATE filePath銆? * 鍒犻櫎瑙勫垯锛氫粎 PENDING 鐘舵€佸彲鍒狅紙闃叉鍒犻櫎宸茶繘鍏ユ爣娉ㄦ祦姘寸嚎鐨勮祫鏂欙級銆? */ @Slf4j @Service @RequiredArgsConstructor @@ -43,30 +40,25 @@ public class SourceService { @Value("${rustfs.bucket:label-source-data}") private String bucket; - // ------------------------------------------------------------------ 上传 -- + // ------------------------------------------------------------------ 涓婁紶 -- /** - * 上传文件并创建 source_data 记录。 - * - * @param file 上传的文件 - * @param dataType 资料类型(TEXT / IMAGE / VIDEO) - * @param principal 当前登录用户 - * @return 创建成功的资料摘要 - */ + * 涓婁紶鏂囦欢骞跺垱寤?source_data 璁板綍銆? * + * @param file 涓婁紶鐨勬枃浠? * @param dataType 璧勬枡绫诲瀷锛圱EXT / 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()); @@ -74,25 +66,23 @@ 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)); @@ -100,21 +90,19 @@ 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) { @@ -123,7 +111,7 @@ public class SourceService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .orderByDesc(SourceData::getCreatedAt); - // UPLOADER 只能查自己的资料 + // UPLOADER 鍙兘鏌ヨ嚜宸辩殑璧勬枡 if ("UPLOADER".equals(principal.getRole())) { wrapper.eq(SourceData::getUploaderId, principal.getUserId()); } @@ -143,15 +131,14 @@ 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; @@ -171,40 +158,37 @@ 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 a9563c6..c17cb0f 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.module.task.dto.TaskResponse; +import com.label.dto.TaskResponse; import com.label.module.task.service.TaskClaimService; import com.label.module.task.service.TaskService; import io.swagger.v3.oas.annotations.Operation; @@ -16,9 +16,8 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; /** - * 任务管理接口(10 个端点)。 - */ -@Tag(name = "任务管理", description = "任务池、我的任务、审批队列和管理操作") + * 浠诲姟绠$悊鎺ュ彛锛?0 涓鐐癸級銆? */ +@Tag(name = "浠诲姟绠$悊", description = "浠诲姟姹犮€佹垜鐨勪换鍔°€佸鎵归槦鍒楀拰绠$悊鎿嶄綔") @RestController @RequestMapping("/api/tasks") @RequiredArgsConstructor @@ -27,8 +26,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( @@ -38,8 +37,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( @@ -50,8 +49,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( @@ -61,8 +60,8 @@ public class TaskController { return Result.success(taskService.getPendingReview(page, pageSize, taskType)); } - /** GET /api/tasks — 查询全部任务(ADMIN) */ - @Operation(summary = "管理员查询全部任务") + /** GET /api/tasks 鈥?鏌ヨ鍏ㄩ儴浠诲姟锛圓DMIN锛?*/ + @Operation(summary = "绠$悊鍛樻煡璇㈠叏閮ㄤ换鍔?) @GetMapping @RequiresRoles("ADMIN") public Result> getAll( @@ -73,8 +72,8 @@ public class TaskController { return Result.success(taskService.getAll(page, pageSize, status, taskType)); } - /** POST /api/tasks — 创建任务(ADMIN) */ - @Operation(summary = "管理员创建任务") + /** POST /api/tasks 鈥?鍒涘缓浠诲姟锛圓DMIN锛?*/ + @Operation(summary = "绠$悊鍛樺垱寤轰换鍔?) @PostMapping @RequiresRoles("ADMIN") public Result createTask(@RequestBody Map body, @@ -86,16 +85,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) { @@ -103,8 +102,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) { @@ -112,8 +111,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) { @@ -121,8 +120,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 deleted file mode 100644 index caa7397..0000000 --- a/src/main/java/com/label/module/task/dto/TaskResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index c8fcce7..0000000 --- a/src/main/java/com/label/module/task/entity/AnnotationTask.java +++ /dev/null @@ -1,59 +0,0 @@ -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/module/task/mapper/AnnotationTaskMapper.java b/src/main/java/com/label/module/task/mapper/AnnotationTaskMapper.java deleted file mode 100644 index 3159e54..0000000 --- a/src/main/java/com/label/module/task/mapper/AnnotationTaskMapper.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index c4f2f2a..0000000 --- a/src/main/java/com/label/module/task/mapper/TaskHistoryMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -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 9f8f883..ed3c9f3 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.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 com.label.entity.AnnotationTask; +import com.label.entity.AnnotationTaskHistory; +import com.label.mapper.AnnotationTaskMapper; +import com.label.mapper.TaskHistoryMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -18,76 +18,68 @@ 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 原子更新(WHERE status='UNCLAIMED' 兜底) - int affected = taskMapper.claimTask(taskId, principal.getUserId(), principal.getCompanyId()); + // 2. DB 鍘熷瓙鏇存柊锛圵HERE 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; } } - // ------------------------------------------------------------------ 放弃 -- + // ------------------------------------------------------------------ 鏀惧純 -- /** - * 放弃任务(IN_PROGRESS → UNCLAIMED)。 - * - * @param taskId 任务 ID - * @param principal 当前用户 + * 鏀惧純浠诲姟锛圛N_PROGRESS 鈫?UNCLAIMED锛夈€? * + * @param taskId 浠诲姟 ID + * @param principal 褰撳墠鐢ㄦ埛 */ @Transactional public void unclaim(Long taskId, TokenPrincipal principal) { @@ -103,7 +95,7 @@ public class TaskClaimService { .set(AnnotationTask::getClaimedBy, null) .set(AnnotationTask::getClaimedAt, null)); - // 清除 Redis 分布式锁 + // 娓呴櫎 Redis 鍒嗗竷寮忛攣 redisService.delete(RedisKeyManager.taskClaimKey(taskId)); insertHistory(taskId, principal.getCompanyId(), @@ -111,13 +103,12 @@ public class TaskClaimService { principal.getUserId(), principal.getRole(), null); } - // ------------------------------------------------------------------ 重领 -- + // ------------------------------------------------------------------ 閲嶉 -- /** - * 重领任务(REJECTED → IN_PROGRESS,仅原领取人可重领)。 - * - * @param taskId 任务 ID - * @param principal 当前用户 + * 閲嶉浠诲姟锛圧EJECTED 鈫?IN_PROGRESS锛屼粎鍘熼鍙栦汉鍙噸棰嗭級銆? * + * @param taskId 浠诲姟 ID + * @param principal 褰撳墠鐢ㄦ埛 */ @Transactional public void reclaim(Long taskId, TokenPrincipal principal) { @@ -126,12 +117,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, @@ -143,8 +134,7 @@ 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); @@ -153,17 +143,16 @@ 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 042ee28..def3cdf 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.module.task.dto.TaskResponse; -import com.label.module.task.entity.AnnotationTask; -import com.label.module.task.mapper.AnnotationTaskMapper; +import com.label.dto.TaskResponse; +import com.label.entity.AnnotationTask; +import com.label.mapper.AnnotationTaskMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -19,8 +19,7 @@ import java.util.List; import java.util.stream.Collectors; /** - * 任务管理服务:创建、查询任务池、我的任务、待审批队列、指派。 - */ + * 浠诲姟绠$悊鏈嶅姟锛氬垱寤恒€佹煡璇换鍔℃睜銆佹垜鐨勪换鍔°€佸緟瀹℃壒闃熷垪銆佹寚娲俱€? */ @Slf4j @Service @RequiredArgsConstructor @@ -29,16 +28,13 @@ public class TaskService { private final AnnotationTaskMapper taskMapper; private final TaskClaimService taskClaimService; - // ------------------------------------------------------------------ 创建 -- + // ------------------------------------------------------------------ 鍒涘缓 -- /** - * 创建标注任务(内部调用,例如视频处理完成后)。 - * - * @param sourceId 资料 ID - * @param taskType 任务类型(EXTRACTION / QA_GENERATION) - * @param companyId 租户 ID - * @return 新任务 - */ + * 鍒涘缓鏍囨敞浠诲姟锛堝唴閮ㄨ皟鐢紝渚嬪瑙嗛澶勭悊瀹屾垚鍚庯級銆? * + * @param sourceId 璧勬枡 ID + * @param taskType 浠诲姟绫诲瀷锛圗XTRACTION / QA_GENERATION锛? * @param companyId 绉熸埛 ID + * @return 鏂颁换鍔? */ @Transactional public AnnotationTask createTask(Long sourceId, String taskType, Long companyId) { AnnotationTask task = new AnnotationTask(); @@ -48,17 +44,14 @@ 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 类型、UNCLAIMED 状态 - * - REVIEWER/ADMIN → SUBMITTED 状态(任意类型) - */ + * 鏌ヨ浠诲姟姹狅紙鎸夎鑹茶繃婊わ級锛? * - ANNOTATOR 鈫?EXTRACTION 绫诲瀷銆乁NCLAIMED 鐘舵€? * - REVIEWER/ADMIN 鈫?SUBMITTED 鐘舵€侊紙浠绘剰绫诲瀷锛? */ public PageResult getPool(int page, int pageSize, TokenPrincipal principal) { pageSize = Math.min(pageSize, 100); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -69,7 +62,7 @@ public class TaskService { wrapper.eq(AnnotationTask::getTaskType, "EXTRACTION") .eq(AnnotationTask::getStatus, "UNCLAIMED"); } else { - // REVIEWER / ADMIN 看待审批队列 + // REVIEWER / ADMIN 鐪嬪緟瀹℃壒闃熷垪 wrapper.eq(AnnotationTask::getStatus, "SUBMITTED"); } @@ -77,11 +70,10 @@ public class TaskService { return toPageResult(pageResult, page, pageSize); } - // ------------------------------------------------------------------ 我的任务 -- + // ------------------------------------------------------------------ 鎴戠殑浠诲姟 -- /** - * 查询当前用户的任务(IN_PROGRESS、SUBMITTED、REJECTED)。 - */ + * 鏌ヨ褰撳墠鐢ㄦ埛鐨勪换鍔★紙IN_PROGRESS銆丼UBMITTED銆丷EJECTED锛夈€? */ public PageResult getMine(int page, int pageSize, String status, TokenPrincipal principal) { pageSize = Math.min(pageSize, 100); @@ -98,11 +90,10 @@ public class TaskService { return toPageResult(pageResult, page, pageSize); } - // ------------------------------------------------------------------ 待审批 -- + // ------------------------------------------------------------------ 寰呭鎵?-- /** - * 查询待审批任务(REVIEWER 专属,status=SUBMITTED)。 - */ + * 鏌ヨ寰呭鎵逛换鍔★紙REVIEWER 涓撳睘锛宻tatus=SUBMITTED锛夈€? */ public PageResult getPendingReview(int page, int pageSize, String taskType) { pageSize = Math.min(pageSize, 100); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -117,21 +108,20 @@ 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; } - // ------------------------------------------------------------------ 全部任务(ADMIN)-- + // ------------------------------------------------------------------ 鍏ㄩ儴浠诲姟锛圓DMIN锛?- /** - * 查询全部任务(ADMIN 专用)。 - */ + * 鏌ヨ鍏ㄩ儴浠诲姟锛圓DMIN 涓撶敤锛夈€? */ public PageResult getAll(int page, int pageSize, String status, String taskType) { pageSize = Math.min(pageSize, 100); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -148,16 +138,15 @@ public class TaskService { return toPageResult(pageResult, page, pageSize); } - // ------------------------------------------------------------------ 指派(ADMIN)-- + // ------------------------------------------------------------------ 鎸囨淳锛圓DMIN锛?- /** - * 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() @@ -168,10 +157,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 c3e8f24..2257cf5 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.module.user.dto.LoginRequest; -import com.label.module.user.dto.LoginResponse; -import com.label.module.user.dto.UserInfoResponse; +import com.label.dto.LoginRequest; +import com.label.dto.LoginResponse; +import com.label.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,14 +13,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; /** - * 认证接口:登录、退出、获取当前用户。 - * - * 路由设计: - * - POST /api/auth/login → 匿名(TokenFilter.shouldNotFilter 跳过) - * - POST /api/auth/logout → 需要有效 Token(TokenFilter 校验) - * - GET /api/auth/me → 需要有效 Token(TokenFilter 校验) - */ -@Tag(name = "认证管理", description = "登录、退出和当前用户信息") + * 璁よ瘉鎺ュ彛锛氱櫥褰曘€侀€€鍑恒€佽幏鍙栧綋鍓嶇敤鎴枫€? * + * 璺敱璁捐锛? * - POST /api/auth/login 鈫?鍖垮悕锛圱okenFilter.shouldNotFilter 璺宠繃锛? * - POST /api/auth/logout 鈫?闇€瑕佹湁鏁?Token锛圱okenFilter 鏍¢獙锛? * - GET /api/auth/me 鈫?闇€瑕佹湁鏁?Token锛圱okenFilter 鏍¢獙锛? */ +@Tag(name = "璁よ瘉绠$悊", description = "鐧诲綍銆侀€€鍑哄拰褰撳墠鐢ㄦ埛淇℃伅") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -29,18 +24,16 @@ 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); @@ -49,17 +42,15 @@ 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 c31b9eb..e91ff45 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.module.user.entity.SysUser; +import com.label.entity.SysUser; import com.label.module.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; @@ -24,9 +24,8 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; /** - * 用户管理接口(5 个端点,全部 ADMIN 权限)。 - */ -@Tag(name = "用户管理", description = "管理员维护公司用户") + * 鐢ㄦ埛绠$悊鎺ュ彛锛? 涓鐐癸紝鍏ㄩ儴 ADMIN 鏉冮檺锛夈€? */ +@Tag(name = "鐢ㄦ埛绠$悊", description = "绠$悊鍛樼淮鎶ゅ叕鍙哥敤鎴?) @RestController @RequestMapping("/api/users") @RequiredArgsConstructor @@ -34,8 +33,8 @@ public class UserController { private final UserService userService; - /** GET /api/users — 分页查询用户列表 */ - @Operation(summary = "分页查询用户列表") + /** GET /api/users 鈥?鍒嗛〉鏌ヨ鐢ㄦ埛鍒楄〃 */ + @Operation(summary = "鍒嗛〉鏌ヨ鐢ㄦ埛鍒楄〃") @GetMapping @RequiresRoles("ADMIN") public Result> listUsers( @@ -45,8 +44,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, @@ -59,8 +58,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, @@ -73,8 +72,8 @@ public class UserController { principal(request))); } - /** PUT /api/users/{id}/status — 变更用户状态 */ - @Operation(summary = "变更用户状态", description = "status:ACTIVE、DISABLED") + /** PUT /api/users/{id}/status 鈥?鍙樻洿鐢ㄦ埛鐘舵€?*/ + @Operation(summary = "鍙樻洿鐢ㄦ埛鐘舵€?, description = "status锛欰CTIVE銆丏ISABLED") @PutMapping("/{id}/status") @RequiresRoles("ADMIN") public Result updateStatus(@PathVariable Long id, @@ -84,8 +83,8 @@ public class UserController { return Result.success(null); } - /** PUT /api/users/{id}/role — 变更用户角色 */ - @Operation(summary = "变更用户角色", description = "role:ADMIN、UPLOADER、VIEWER") + /** PUT /api/users/{id}/role 鈥?鍙樻洿鐢ㄦ埛瑙掕壊 */ + @Operation(summary = "鍙樻洿鐢ㄦ埛瑙掕壊", description = "role锛欰DMIN銆乁PLOADER銆乂IEWER") @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 deleted file mode 100644 index e71d4b2..0000000 --- a/src/main/java/com/label/module/user/dto/LoginRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 6ed6a5c..0000000 --- a/src/main/java/com/label/module/user/dto/LoginResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index bf60ae0..0000000 --- a/src/main/java/com/label/module/user/dto/UserInfoResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -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/module/user/entity/SysUser.java b/src/main/java/com/label/module/user/entity/SysUser.java deleted file mode 100644 index 95307f7..0000000 --- a/src/main/java/com/label/module/user/entity/SysUser.java +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index f22b053..0000000 --- a/src/main/java/com/label/module/user/mapper/SysCompanyMapper.java +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index fae02b5..0000000 --- a/src/main/java/com/label/module/user/mapper/SysUserMapper.java +++ /dev/null @@ -1,34 +0,0 @@ -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 cc3709a..e192a6d 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.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 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -23,11 +23,9 @@ 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 @@ -38,45 +36,41 @@ 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、userId、role、expiresIn) - * @throws BusinessException USER_NOT_FOUND(401) 凭证错误 - * @throws BusinessException USER_DISABLED(403) 账号已禁用 - */ + * 鐢ㄦ埛鐧诲綍銆? * + * @param request 鍖呭惈 companyCode / username / password + * @return LoginResponse锛堝惈 token銆乽serId銆乺ole銆乪xpiresIn锛? * @throws BusinessException USER_NOT_FOUND(401) 鍑瘉閿欒 + * @throws BusinessException USER_DISABLED(403) 璐﹀彿宸茬鐢? */ public LoginResponse login(LoginRequest request) { - // 1. 查公司(绕过多租户过滤器,sys_company 无 company_id 字段) - SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode()); + // 1. 鏌ュ叕鍙革紙缁曡繃澶氱鎴疯繃婊ゅ櫒锛宻ys_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. 账号禁用检查(先于密码校验,防止暴力破解已知用户状态) + // 3. 璐﹀彿绂佺敤妫€鏌ワ紙鍏堜簬瀵嗙爜鏍¢獙锛岄槻姝㈡毚鍔涚牬瑙e凡鐭ョ敤鎴风姸鎬侊級 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()); @@ -85,44 +79,36 @@ 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 无限增长:TTL = token 有效期(最后一次登录时滑动续期) - redisService.expire(sessionsKey, tokenTtlSeconds); + // 闃叉 Set 鏃犻檺澧為暱锛歍TL = 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(Token 立即失效)。 - * - * @param token 来自 Authorization 头的 Bearer token + * 閫€鍑虹櫥褰曪紝绔嬪嵆鍒犻櫎 Redis Token锛圱oken 绔嬪嵆澶辨晥锛夈€? * + * @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、companyName)。 - * - * @param principal TokenFilter 注入的当前用户主体 - * @return 用户信息响应体 - */ + * 鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛璇︽儏锛堝惈 realName銆乧ompanyName锛夈€? * + * @param principal TokenFilter 娉ㄥ叆鐨勫綋鍓嶇敤鎴蜂富浣? * @return 鐢ㄦ埛淇℃伅鍝嶅簲浣? */ public UserInfoResponse me(TokenPrincipal principal) { - // 从 DB 获取 realName(Token 中未存储) - SysUser user = userMapper.selectById(principal.getUserId()); + // 浠?DB 鑾峰彇 realName锛圱oken 涓湭瀛樺偍锛? 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 0f0edd8..48a8150 100644 --- a/src/main/java/com/label/module/user/service/UserService.java +++ b/src/main/java/com/label/module/user/service/UserService.java @@ -15,19 +15,17 @@ 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.module.user.entity.SysUser; -import com.label.module.user.mapper.SysUserMapper; +import com.label.entity.SysUser; +import com.label.mapper.SysUserMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * 用户管理服务(ADMIN 专属)。 - * - * 关键设计: - * - 角色变更:DB 写入后立即更新所有活跃 Token 中的 role 字段,无需重新登录 - * - 状态禁用:DB 写入后删除用户所有活跃 Token(立即失效) - * - 使用 user:sessions:{userId} Set 跟踪活跃会话 + * 鐢ㄦ埛绠$悊鏈嶅姟锛圓DMIN 涓撳睘锛夈€? * + * 鍏抽敭璁捐锛? * - 瑙掕壊鍙樻洿锛欴B 鍐欏叆鍚庣珛鍗虫洿鏂版墍鏈夋椿璺?Token 涓殑 role 瀛楁锛屾棤闇€閲嶆柊鐧诲綍 + * - 鐘舵€佺鐢細DB 鍐欏叆鍚庡垹闄ょ敤鎴锋墍鏈夋椿璺?Token锛堢珛鍗冲け鏁堬級 + * - 浣跨敤 user:sessions:{userId} Set 璺熻釜娲昏穬浼氳瘽 */ @Slf4j @Service @@ -39,27 +37,20 @@ public class UserService { private final SysUserMapper userMapper; private final RedisService redisService; - // ------------------------------------------------------------------ 创建用户 -- + // ------------------------------------------------------------------ 鍒涘缓鐢ㄦ埛 -- /** - * 创建新用户(ADMIN 操作)。 - * - * @param username 用户名 - * @param password 明文密码(将以 BCrypt strength=10 哈希) - * @param realName 真实姓名(可选) - * @param role 角色(UPLOADER / ANNOTATOR / REVIEWER / ADMIN) - * @param principal 当前管理员 - * @return 新建用户(不含 passwordHash) - */ + * 鍒涘缓鏂扮敤鎴凤紙ADMIN 鎿嶄綔锛夈€? * + * @param username 鐢ㄦ埛鍚? * @param password 鏄庢枃瀵嗙爜锛堝皢浠?BCrypt strength=10 鍝堝笇锛? * @param realName 鐪熷疄濮撳悕锛堝彲閫夛級 + * @param role 瑙掕壊锛圲PLOADER / 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); @@ -73,15 +64,14 @@ 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; } - // ------------------------------------------------------------------ 更新基本信息 -- + // ------------------------------------------------------------------ 鏇存柊鍩烘湰淇℃伅 -- /** - * 更新用户基本信息(realName、password)。 - */ + * 鏇存柊鐢ㄦ埛鍩烘湰淇℃伅锛坮ealName銆乸assword锛夈€? */ @Transactional public SysUser updateUser(Long userId, String realName, String password, TokenPrincipal principal) { @@ -103,78 +93,68 @@ 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( @@ -185,12 +165,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; } @@ -198,7 +178,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 6848e5d..17c2539 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.module.video.entity.VideoProcessJob; +import com.label.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,14 +16,10 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; /** - * 视频处理接口(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 中排除) + * 瑙嗛澶勭悊鎺ュ彛锛? 涓鐐癸級銆? * + * POST /api/video/process 鈥?瑙﹀彂瑙嗛澶勭悊锛圓DMIN锛? * GET /api/video/jobs/{jobId} 鈥?鏌ヨ浠诲姟鐘舵€侊紙ADMIN锛? * POST /api/video/jobs/{jobId}/reset 鈥?閲嶇疆澶辫触浠诲姟锛圓DMIN锛? * POST /api/video/callback 鈥?AI 鍥炶皟鎺ュ彛锛堟棤闇€璁よ瘉锛屽凡鍦?TokenFilter 涓帓闄わ級 */ -@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调") +@Tag(name = "瑙嗛澶勭悊", description = "瑙嗛澶勭悊浠诲姟鍒涘缓銆佹煡璇€侀噸缃拰鍥炶皟") @Slf4j @RestController @RequiredArgsConstructor @@ -34,8 +30,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, @@ -43,7 +39,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(); @@ -54,8 +50,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, @@ -63,8 +59,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, @@ -73,24 +69,21 @@ public class VideoController { } /** - * POST /api/video/callback — AI 服务回调(无需 Bearer Token)。 - * - * 此端点已在 TokenFilter.shouldNotFilter() 中排除认证, - * 由 AI 服务直接调用,携带 jobId、status、outputPath 等参数。 - * - * Body 示例: - * { "jobId": 123, "status": "SUCCESS", "outputPath": "processed/123/frames.zip" } + * POST /api/video/callback 鈥?AI 鏈嶅姟鍥炶皟锛堟棤闇€ Bearer Token锛夈€? * + * 姝ょ鐐瑰凡鍦?TokenFilter.shouldNotFilter() 涓帓闄よ璇侊紝 + * 鐢?AI 鏈嶅姟鐩存帴璋冪敤锛屾惡甯?jobId銆乻tatus銆乷utputPath 绛夊弬鏁般€? * + * 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", "鍥炶皟瀵嗛挜鏃犳晥"); } } @@ -99,7 +92,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("视频处理回调:jobId={}, status={}", jobId, status); + log.info("瑙嗛澶勭悊鍥炶皟锛歫obId={}, 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 deleted file mode 100644 index b4833e6..0000000 --- a/src/main/java/com/label/module/video/entity/VideoProcessJob.java +++ /dev/null @@ -1,57 +0,0 @@ -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/module/video/service/VideoProcessService.java b/src/main/java/com/label/module/video/service/VideoProcessService.java index 86736c2..27596d3 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.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 com.label.entity.SourceData; +import com.label.mapper.SourceDataMapper; +import com.label.entity.VideoProcessJob; +import com.label.mapper.VideoProcessJobMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -22,19 +22,15 @@ import java.time.LocalDateTime; import java.util.Map; /** - * 视频处理服务:创建任务、处理回调、管理员重置。 - * - * 状态流转: - * - 创建时: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 调用。 - */ + * 瑙嗛澶勭悊鏈嶅姟锛氬垱寤轰换鍔°€佸鐞嗗洖璋冦€佺鐞嗗憳閲嶇疆銆? * + * 鐘舵€佹祦杞細 + * - 鍒涘缓鏃讹細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 璋冪敤銆? */ @Slf4j @Service @RequiredArgsConstructor @@ -47,31 +43,27 @@ public class VideoProcessService { @Value("${rustfs.bucket:label-source-data}") private String bucket; - // ------------------------------------------------------------------ 创建任务 -- + // ------------------------------------------------------------------ 鍒涘缓浠诲姟 -- /** - * 创建视频处理任务并在事务提交后触发 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 + * 鍒涘缓瑙嗛澶勭悊浠诲姟骞跺湪浜嬪姟鎻愪氦鍚庤Е鍙?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 */ @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); @@ -80,7 +72,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); @@ -91,8 +83,7 @@ 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; @@ -103,36 +94,31 @@ public class VideoProcessService { } }); - log.info("视频处理任务已创建(AI 将在事务提交后触发): jobId={}, sourceId={}", jobId, sourceId); + log.info("瑙嗛澶勭悊浠诲姟宸插垱寤猴紙AI 灏嗗湪浜嬪姟鎻愪氦鍚庤Е鍙戯級: jobId={}, sourceId={}", jobId, sourceId); return job; } - // ------------------------------------------------------------------ 处理回调 -- + // ------------------------------------------------------------------ 澶勭悊鍥炶皟 -- /** - * 处理 AI 服务异步回调(POST /api/video/callback,无需用户 Token)。 - * - * 幂等:若 job 已为 SUCCESS,直接返回,防止重复处理。 - * 重试触发同样延迟到事务提交后(afterCommit),不在事务内执行。 - * - * @param jobId 任务 ID - * @param callbackStatus AI 回调状态(SUCCESS / FAILED) - * @param outputPath 成功时的输出路径(可选) - * @param errorMessage 失败时的错误信息(可选) + * 澶勭悊 AI 鏈嶅姟寮傛鍥炶皟锛圥OST /api/video/callback锛屾棤闇€鐢ㄦ埛 Token锛夈€? * + * 骞傜瓑锛氳嫢 job 宸蹭负 SUCCESS锛岀洿鎺ヨ繑鍥烇紝闃叉閲嶅澶勭悊銆? * 閲嶈瘯瑙﹀彂鍚屾牱寤惰繜鍒颁簨鍔℃彁浜ゅ悗锛坅fterCommit锛夛紝涓嶅湪浜嬪姟鍐呮墽琛屻€? * + * @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("视频处理回调:job 不存在,jobId={}", jobId); + log.warn("瑙嗛澶勭悊鍥炶皟锛歫ob 涓嶅瓨鍦紝jobId={}", jobId); return; } - // 幂等:已成功则忽略重复回调 - if ("SUCCESS".equals(job.getStatus())) { - log.info("视频处理回调幂等:jobId={} 已为 SUCCESS,跳过", jobId); + // 骞傜瓑锛氬凡鎴愬姛鍒欏拷鐣ラ噸澶嶅洖璋? if ("SUCCESS".equals(job.getStatus())) { + log.info("瑙嗛澶勭悊鍥炶皟骞傜瓑锛歫obId={} 宸蹭负 SUCCESS锛岃烦杩?, jobId); return; } @@ -143,27 +129,24 @@ 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); } @@ -176,24 +159,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") @@ -201,13 +184,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("视频处理成功:jobId={}, sourceId={}", job.getId(), job.getSourceId()); + log.info("瑙嗛澶勭悊鎴愬姛锛歫obId={}, sourceId={}", job.getId(), job.getSourceId()); } private void handleFailure(VideoProcessJob job, String errorMessage) { @@ -215,7 +198,7 @@ public class VideoProcessService { int maxRetries = job.getMaxRetries() != null ? job.getMaxRetries() : 3; if (newRetryCount < maxRetries) { - // 仍有重试次数:job → RETRYING,事务提交后重新触发 AI + // 浠嶆湁閲嶈瘯娆℃暟锛歫ob 鈫?RETRYING锛屼簨鍔℃彁浜ゅ悗閲嶆柊瑙﹀彂 AI jobMapper.update(null, new LambdaUpdateWrapper() .eq(VideoProcessJob::getId, job.getId()) .set(VideoProcessJob::getStatus, "RETRYING") @@ -223,10 +206,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(); @@ -242,7 +225,7 @@ public class VideoProcessService { }); } } else { - // 超出最大重试次数:job → FAILED,source_data → PENDING + // 瓒呭嚭鏈€澶ч噸璇曟鏁帮細job 鈫?FAILED锛宻ource_data 鈫?PENDING jobMapper.update(null, new LambdaUpdateWrapper() .eq(VideoProcessJob::getId, job.getId()) .set(VideoProcessJob::getStatus, "FAILED") @@ -251,13 +234,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("视频处理永久失败:jobId={}, sourceId={}, error={}", + log.error("瑙嗛澶勭悊姘镐箙澶辫触锛歫obId={}, sourceId={}, error={}", job.getId(), job.getSourceId(), errorMessage); } } @@ -275,16 +258,16 @@ public class VideoProcessService { } else { aiServiceClient.videoToText(req); } - log.info("AI 触发成功: jobId={}", jobId); + log.info("AI 瑙﹀彂鎴愬姛: jobId={}", jobId); } catch (Exception e) { - log.error("触发视频处理 AI 失败(jobId={}):{},job 保持当前状态,需管理员手动重置", jobId, e.getMessage()); + log.error("瑙﹀彂瑙嗛澶勭悊 AI 澶辫触锛坖obId={}锛夛細{}锛宩ob 淇濇寔褰撳墠鐘舵€侊紝闇€绠$悊鍛樻墜鍔ㄩ噸缃?, 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 9bc8b94..35fd193 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.module.user.dto.LoginRequest; +import com.label.dto.LoginRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -15,26 +15,22 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** - * 认证流程集成测试(US1)。 + * 璁よ瘉娴佺▼闆嗘垚娴嬭瘯锛圲S1锛夈€? * + * 娴嬭瘯鍦烘櫙锛? * 1. 姝g‘瀵嗙爜鐧诲綍 鈫?杩斿洖 token + * 2. 閿欒瀵嗙爜鐧诲綍 鈫?401 + * 3. 涓嶅瓨鍦ㄧ殑鍏徃浠g爜 鈫?401 + * 4. 鏈夋晥 Token 璁块棶 /api/auth/me 鈫?200锛岃繑鍥炵敤鎴蜂俊鎭? * 5. 涓诲姩閫€鍑哄悗锛屽師 Token 璁块棶 /api/auth/me 鈫?401 * - * 测试场景: - * 1. 正确密码登录 → 返回 token - * 2. 错误密码登录 → 401 - * 3. 不存在的公司代码 → 401 - * 4. 有效 Token 访问 /api/auth/me → 200,返回用户信息 - * 5. 主动退出后,原 Token 访问 /api/auth/me → 401 - * - * 测试数据来自 init.sql 种子(DEMO 公司 / admin / admin123) - */ + * 娴嬭瘯鏁版嵁鏉ヨ嚜 init.sql 绉嶅瓙锛圖EMO 鍏徃 / admin / admin123锛? */ public class AuthIntegrationTest extends AbstractIntegrationTest { @Autowired private TestRestTemplate restTemplate; - // ------------------------------------------------------------------ 登录测试 -- + // ------------------------------------------------------------------ 鐧诲綍娴嬭瘯 -- @Test - @DisplayName("正确密码登录 → 返回 token") + @DisplayName("姝g‘瀵嗙爜鐧诲綍 鈫?杩斿洖 token") void login_withCorrectCredentials_returnsToken() { ResponseEntity response = doLogin("DEMO", "admin", "admin123"); @@ -52,23 +48,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("不存在的公司代码 → 401 Unauthorized") + @DisplayName("涓嶅瓨鍦ㄧ殑鍏徃浠g爜 鈫?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(); @@ -89,22 +85,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, @@ -112,15 +108,14 @@ 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, @@ -129,9 +124,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); @@ -140,7 +135,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()) { @@ -151,7 +146,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 b88379c..6cfe709 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.module.user.dto.LoginRequest; +import com.label.dto.LoginRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,12 +14,10 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** - * 提取阶段审批集成测试(US4)。 - * - * 测试场景: - * 1. 审批通过 → QA_GENERATION 任务自动创建,source_data 状态更新为 QA_REVIEW - * 2. 审批人与提交人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN - * 3. 驳回后标注员可重领任务并再次提交 + * 鎻愬彇闃舵瀹℃壒闆嗘垚娴嬭瘯锛圲S4锛夈€? * + * 娴嬭瘯鍦烘櫙锛? * 1. 瀹℃壒閫氳繃 鈫?QA_GENERATION 浠诲姟鑷姩鍒涘缓锛宻ource_data 鐘舵€佹洿鏂颁负 QA_REVIEW + * 2. 瀹℃壒浜轰笌鎻愪氦浜虹浉鍚岋紙鑷锛夆啋 403 SELF_REVIEW_FORBIDDEN + * 3. 椹冲洖鍚庢爣娉ㄥ憳鍙噸棰嗕换鍔″苟鍐嶆鎻愪氦 */ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { @@ -33,15 +31,14 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { @BeforeEach void setup() { - // 获取种子用户 ID(init.sql 中已插入) - annotatorUserId = jdbcTemplate.queryForObject( + // 鑾峰彇绉嶅瓙鐢ㄦ埛 ID锛坕nit.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) " + @@ -50,7 +47,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')"); @@ -58,66 +55,63 @@ 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 任务自动创建,source_data 状态变为 QA_REVIEW") + @DisplayName("瀹℃壒閫氳繃鍚庯紝QA_GENERATION 浠诲姟鑷姩鍒涘缓锛宻ource_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,is_final=true + // 楠岃瘉锛氬師浠诲姟鐘舵€佸彉涓?APPROVED锛宨s_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); - // 验证:QA_GENERATION 任务已自动创建(UNCLAIMED 状态) + // 楠岃瘉锛歈A_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); - // 验证:source_data 状态已更新为 QA_REVIEW + // 楠岃瘉锛歴ource_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"); - // 验证:training_dataset 已以 PENDING_REVIEW 状态创建 - Integer datasetCount = jdbcTemplate.queryForObject( + // 楠岃瘉锛歵raining_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 + @@ -132,67 +126,63 @@ 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 4c00970..916bd08 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.module.user.dto.LoginRequest; +import com.label.dto.LoginRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,11 +14,9 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** - * QA 问答生成阶段审批集成测试(US5)。 - * - * 测试场景: - * 1. QA 审批通过 → training_dataset.status = APPROVED,source_data.status = APPROVED - * 2. QA 驳回 → 候选问答对被删除,标注员可重领 + * QA 闂瓟鐢熸垚闃舵瀹℃壒闆嗘垚娴嬭瘯锛圲S5锛夈€? * + * 娴嬭瘯鍦烘櫙锛? * 1. QA 瀹℃壒閫氳繃 鈫?training_dataset.status = APPROVED锛宻ource_data.status = APPROVED + * 2. QA 椹冲洖 鈫?鍊欓€夐棶绛斿琚垹闄わ紝鏍囨敞鍛樺彲閲嶉 */ public class QaApprovalIntegrationTest extends AbstractIntegrationTest { @@ -40,7 +38,7 @@ public class QaApprovalIntegrationTest extends AbstractIntegrationTest { Long companyId = jdbcTemplate.queryForObject( "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - // 插入 source_data(QA_REVIEW 状态,模拟提取审批已完成) + // 鎻掑叆 source_data锛圦A_REVIEW 鐘舵€侊紝妯℃嫙鎻愬彇瀹℃壒宸插畬鎴愶級 jdbcTemplate.execute( "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + "file_name, file_size, bucket_name, status) " + @@ -49,129 +47,123 @@ public class QaApprovalIntegrationTest extends AbstractIntegrationTest { sourceId = jdbcTemplate.queryForObject( "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - // 插入 QA_GENERATION 任务(UNCLAIMED 状态,模拟提取审批通过后自动创建的 QA 任务) - jdbcTemplate.execute( + // 鎻掑叆 QA_GENERATION 浠诲姟锛圲NCLAIMED 鐘舵€侊紝妯℃嫙鎻愬彇瀹℃壒閫氳繃鍚庤嚜鍔ㄥ垱寤虹殑 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,source_data.status=APPROVED") + @DisplayName("QA 瀹℃壒閫氳繃 鈫?training_dataset.status=APPROVED锛宻ource_data.status=APPROVED") void approveQaTask_thenDatasetAndSourceApproved() { String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - // 注意:QA 任务 claim 端点为 POST /api/tasks/{id}/claim(ANNOTATOR 角色) - // 但 TaskController.getPool 只给 ANNOTATOR 显示 EXTRACTION/UNCLAIMED - // QA 任务由 ANNOTATOR 直接领取(不经过任务池) + // 娉ㄦ剰锛歈A 浠诲姟 claim 绔偣涓?POST /api/tasks/{id}/claim锛圓NNOTATOR 瑙掕壊锛? // 浣?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); - // 验证:training_dataset → APPROVED + // 楠岃瘉锛歵raining_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"); - // 验证:annotation_task → APPROVED,is_final=true + // 楠岃瘉锛歛nnotation_task 鈫?APPROVED锛宨s_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); - // 验证:source_data → APPROVED(整条流水线完成) - String sourceStatus = jdbcTemplate.queryForObject( + // 楠岃瘉锛歴ource_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); - // 验证:source_data 保持 QA_REVIEW(不变) + // 楠岃瘉锛歴ource_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 4676b14..9df878a 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.module.user.dto.LoginRequest; +import com.label.dto.LoginRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,11 +15,8 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; /** - * 用户管理集成测试(US7)。 - * - * 测试场景: - * 1. 变更角色后权限下一次请求立即生效(无需重新登录) - * 2. 禁用账号后现有 Token 下一次请求立即返回 401 + * 鐢ㄦ埛绠$悊闆嗘垚娴嬭瘯锛圲S7锛夈€? * + * 娴嬭瘯鍦烘櫙锛? * 1. 鍙樻洿瑙掕壊鍚庢潈闄愪笅涓€娆¤姹傜珛鍗崇敓鏁堬紙鏃犻渶閲嶆柊鐧诲綍锛? * 2. 绂佺敤璐﹀彿鍚庣幇鏈?Token 涓嬩竴娆¤姹傜珛鍗宠繑鍥?401 */ public class UserManagementIntegrationTest extends AbstractIntegrationTest { @@ -34,14 +31,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); @@ -52,7 +49,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { new HttpEntity<>(Map.of( "username", uniqueUsername, "password", "test1234", - "realName", "测试用户", + "realName", "娴嬭瘯鐢ㄦ埛", "role", "ANNOTATOR" ), headers), Map.class); @@ -62,11 +59,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. 验证:ANNOTATOR 无法访问待审批队列(REVIEWER 专属)→ 403 + // 3. 楠岃瘉锛欰NNOTATOR 鏃犳硶璁块棶寰呭鎵归槦鍒楋紙REVIEWER 涓撳睘锛夆啋 403 ResponseEntity beforeRoleChange = restTemplate.exchange( baseUrl("/api/tasks/pending-review"), HttpMethod.GET, @@ -74,7 +71,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, @@ -82,25 +79,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); @@ -111,7 +108,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest { new HttpEntity<>(Map.of( "username", uniqueUsername, "password", "test1234", - "realName", "测试用户", + "realName", "娴嬭瘯鐢ㄦ埛", "role", "ANNOTATOR" ), headers), Map.class); @@ -121,11 +118,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, @@ -133,7 +130,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, @@ -141,18 +138,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 5d26dcb..271f3aa 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.module.source.dto.SourceResponse; +import com.label.dto.SourceResponse; import com.label.module.task.controller.TaskController; -import com.label.module.task.dto.TaskResponse; +import com.label.dto.TaskResponse; import com.label.module.user.controller.AuthController; import com.label.module.user.controller.UserController; -import com.label.module.user.dto.LoginRequest; -import com.label.module.user.dto.LoginResponse; -import com.label.module.user.dto.UserInfoResponse; +import com.label.dto.LoginRequest; +import com.label.dto.LoginResponse; +import com.label.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 都声明 @Tag") + @DisplayName("鎵€鏈?REST Controller 閮藉0鏄?@Tag") void allControllersHaveTag() { assertThat(CONTROLLERS) .allSatisfy(controller -> @@ -66,7 +66,7 @@ class OpenApiAnnotationTest { } @Test - @DisplayName("所有 REST endpoint 方法都声明 @Operation") + @DisplayName("鎵€鏈?REST endpoint 鏂规硶閮藉0鏄?@Operation") void allEndpointMethodsHaveOperation() { for (Class controller : CONTROLLERS) { Arrays.stream(controller.getDeclaredMethods()) @@ -79,7 +79,7 @@ class OpenApiAnnotationTest { } @Test - @DisplayName("核心 DTO 都声明 @Schema") + @DisplayName("鏍稿績 DTO 閮藉0鏄?@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 7ea8bc7..3332d13 100644 --- a/src/test/java/com/label/unit/PackageStructureMigrationTest.java +++ b/src/test/java/com/label/unit/PackageStructureMigrationTest.java @@ -29,6 +29,39 @@ 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(); }