Revert "refactor: flatten dto entity and mapper packages"

This reverts commit 29766ebd28.
This commit is contained in:
wh
2026-04-14 13:31:50 +08:00
parent 29766ebd28
commit 3e33398dd2
64 changed files with 1780 additions and 1524 deletions

View File

@@ -1,20 +0,0 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 鐧诲綍璇锋眰浣撱€? */
@Data
@Schema(description = "鐧诲綍璇锋眰")
public class LoginRequest {
/** 鍏徃浠g爜锛堣嫳鏂囩畝鍐欙級锛岀敤浜庣‘瀹氱鎴?*/
@Schema(description = "鍏徃浠g爜锛堣嫳鏂囩畝鍐欙級", example = "DEMO")
private String companyCode;
/** 鐧诲綍鐢ㄦ埛鍚?*/
@Schema(description = "鐧诲綍鐢ㄦ埛鍚?, example = "admin")
private String username;
/** 鏄庢枃瀵嗙爜锛堜紶杈撳眰搴斾娇鐢?HTTPS 淇濇姢锛?*/
@Schema(description = "鏄庢枃瀵嗙爜", example = "admin123")
private String password;
}

View File

@@ -1,28 +0,0 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 鐧诲綍鎴愬姛鍝嶅簲浣撱€? */
@Data
@AllArgsConstructor
@Schema(description = "鐧诲綍鍝嶅簲")
public class LoginResponse {
/** Bearer Token锛圲UID v4锛夛紝鍚庣画璇锋眰鏀惧叆 Authorization 澶?*/
@Schema(description = "Bearer Token", example = "550e8400-e29b-41d4-a716-446655440000")
private String token;
/** 鐢ㄦ埛涓婚敭 */
@Schema(description = "鐢ㄦ埛涓婚敭")
private Long userId;
/** 鐧诲綍鐢ㄦ埛鍚?*/
@Schema(description = "鐧诲綍鐢ㄦ埛鍚?)
private String username;
/** 瑙掕壊锛歎PLOADER / ANNOTATOR / REVIEWER / ADMIN */
@Schema(description = "瑙掕壊", example = "ADMIN")
private String role;
/** Token 鏈夋晥鏈燂紙绉掞級 */
@Schema(description = "Token 鏈夋晥鏈燂紙绉掞級", example = "7200")
private Long expiresIn;
}

View File

@@ -1,36 +0,0 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 璧勬枡鎺ュ彛缁熶竴鍝嶅簲浣擄紙涓婁紶銆佸垪琛ㄣ€佽鎯呭潎澶嶇敤姝ょ被锛夈€? * 鍚勭鐐规寜闇€濉厖瀛楁锛屾湭濉厖瀛楁搴忓垪鍖栨椂鍥?jackson non_null 閰嶇疆鑷姩鐪佺暐銆? */
@Data
@Builder
@Schema(description = "鍘熷璧勬枡鍝嶅簲")
public class SourceResponse {
@Schema(description = "璧勬枡涓婚敭")
private Long id;
@Schema(description = "鏂囦欢鍚?)
private String fileName;
@Schema(description = "璧勬枡绫诲瀷", example = "TEXT")
private String dataType;
@Schema(description = "鏂囦欢澶у皬锛堝瓧鑺傦級")
private Long fileSize;
@Schema(description = "璧勬枡鐘舵?, example = "PENDING")
private String status;
/** 涓婁紶鐢ㄦ埛 ID锛堝垪琛ㄧ鐐硅繑鍥烇級 */
@Schema(description = "涓婁紶鐢ㄦ埛 ID")
private Long uploaderId;
/** 15 鍒嗛挓棰勭鍚嶄笅杞介摼鎺ワ紙璇︽儏绔偣杩斿洖锛?*/
@Schema(description = "棰勭鍚嶄笅杞介摼鎺?)
private String presignedUrl;
/** 鐖惰祫鏂?ID锛堣棰戝抚 / 鏂囨湰鐗囨锛涜鎯呯鐐硅繑鍥烇級 */
@Schema(description = "鐖惰祫鏂?ID")
private Long parentSourceId;
@Schema(description = "鍒涘缓鏃堕棿")
private LocalDateTime createdAt;
}

View File

@@ -1,37 +0,0 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 浠诲姟鎺ュ彛缁熶竴鍝嶅簲浣擄紙浠诲姟姹犮€佹垜鐨勪换鍔°€佷换鍔¤鎯呭潎澶嶇敤锛夈€? */
@Data
@Builder
@Schema(description = "鏍囨敞浠诲姟鍝嶅簲")
public class TaskResponse {
@Schema(description = "浠诲姟涓婚敭")
private Long id;
@Schema(description = "鍏宠仈璧勬枡 ID")
private Long sourceId;
/** 浠诲姟绫诲瀷锛堝搴?taskType 瀛楁锛夛細EXTRACTION / QA_GENERATION */
@Schema(description = "浠诲姟绫诲瀷", example = "EXTRACTION")
private String taskType;
@Schema(description = "浠诲姟鐘舵€?, example = "UNCLAIMED")
private String status;
@Schema(description = "棰嗗彇浜虹敤鎴?ID")
private Long claimedBy;
@Schema(description = "棰嗗彇鏃堕棿")
private LocalDateTime claimedAt;
@Schema(description = "鎻愪氦鏃堕棿")
private LocalDateTime submittedAt;
@Schema(description = "瀹屾垚鏃堕棿")
private LocalDateTime completedAt;
/** 椹冲洖鍘熷洜锛圧EJECTED 鐘舵€佹椂闈炵┖锛?*/
@Schema(description = "椹冲洖鍘熷洜")
private String rejectReason;
@Schema(description = "鍒涘缓鏃堕棿")
private LocalDateTime createdAt;
}

View File

@@ -1,25 +0,0 @@
package com.label.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* GET /api/auth/me 鍝嶅簲浣擄紝鍖呭惈褰撳墠鐧诲綍鐢ㄦ埛鐨勮缁嗕俊鎭€? */
@Data
@AllArgsConstructor
@Schema(description = "褰撳墠鐧诲綍鐢ㄦ埛淇℃伅")
public class UserInfoResponse {
@Schema(description = "鐢ㄦ埛涓婚敭")
private Long id;
@Schema(description = "鐢ㄦ埛鍚?)
private String username;
@Schema(description = "鐪熷疄濮撳悕")
private String realName;
@Schema(description = "瑙掕壊", example = "ADMIN")
private String role;
@Schema(description = "灞炲叕鍙?ID")
private Long companyId;
@Schema(description = "灞炲叕鍙稿悕绉?)
private String companyName;
}

View File

@@ -1,58 +0,0 @@
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 鏍囨敞浠诲姟瀹炰綋锛屽搴?annotation_task 琛ㄣ€? *
* taskType 鍙栧€硷細EXTRACTION / QA_GENERATION
* status 鍙栧€硷細UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED
*/
@Data
@TableName("annotation_task")
public class AnnotationTask {
@TableId(type = IdType.AUTO)
private Long id;
/** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/
private Long companyId;
/** 鍏宠仈鐨勫師濮嬭祫鏂?ID */
private Long sourceId;
/** 浠诲姟绫诲瀷锛欵XTRACTION / QA_GENERATION */
private String taskType;
/** 浠诲姟鐘舵€?*/
private String status;
/** 棰嗗彇浠诲姟鐨勭敤鎴?ID */
private Long claimedBy;
/** 棰嗗彇鏃堕棿 */
private LocalDateTime claimedAt;
/** 鎻愪氦鏃堕棿 */
private LocalDateTime submittedAt;
/** 瀹屾垚鏃堕棿锛圓PPROVED 鏃惰缃級 */
private LocalDateTime completedAt;
/** 鏄惁鏈€缁堢粨鏋滐紙APPROVED 涓旀棤闇€鍐嶅锛?/
private Boolean isFinal;
/** 浣跨敤鐨?AI 妯″瀷鍚嶇О */
private String aiModel;
/** 椹冲洖鍘熷洜 */
private String rejectReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -1,55 +0,0 @@
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 鍘熷璧勬枡瀹炰綋锛屽搴?source_data 琛ㄣ€? *
* dataType 鍙栧€硷細TEXT / IMAGE / VIDEO
* status 鍙栧€硷細PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED
*/
@Data
@TableName("source_data")
public class SourceData {
@TableId(type = IdType.AUTO)
private Long id;
/** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/
private Long companyId;
/** 涓婁紶鐢ㄦ埛 ID */
private Long uploaderId;
/** 璧勬枡绫诲瀷锛歍EXT / IMAGE / VIDEO */
private String dataType;
/** RustFS 瀵硅薄璺緞 */
private String filePath;
/** 鍘熷鏂囦欢鍚?*/
private String fileName;
/** 鏂囦欢澶у皬锛堝瓧鑺傦級 */
private Long fileSize;
/** RustFS Bucket 鍚嶇О */
private String bucketName;
/** 鐖惰祫鏂?ID锛堣棰戝抚鎴栨枃鏈墖娈电殑鑷紩鐢ㄥ閿級 */
private Long parentSourceId;
/** 娴佹按绾跨姸鎬侊細PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED */
private String status;
/** 淇濈暀瀛楁锛堝綋鍓嶆棤 REJECTED 鐘舵€侊級 */
private String rejectReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -1,36 +0,0 @@
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 绯荤粺閰嶇疆瀹炰綋锛屽搴?sys_config 琛ㄣ€? *
* company_id 涓?NULL 鏃惰〃绀哄叏灞€榛樿閰嶇疆锛岄潪 NULL 鏃惰〃绀虹鎴蜂笓灞為厤缃紙浼樺厛绾ф洿楂橈級銆? * 娉細sys_config 宸插姞鍏?MybatisPlusConfig.IGNORED_TABLES锛屼笉璧板绉熸埛杩囨护鍣ㄣ€? */
@Data
@TableName("sys_config")
public class SysConfig {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 鎵€灞炲叕鍙?ID锛圢ULL = 鍏ㄥ眬榛樿閰嶇疆锛涢潪 NULL = 绉熸埛涓撳睘閰嶇疆锛夈€? * 娉ㄦ剰锛氫笉鑳界敤 @TableField(exist = false) 鎺掗櫎锛屽繀椤讳繚鐣欎互鏀寔 company_id IS NULL 鏌ヨ銆? */
private Long companyId;
/** 閰嶇疆閿?*/
private String configKey;
/** 閰嶇疆鍊?*/
private String configValue;
/** 閰嶇疆璇存槑 */
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -1,46 +0,0 @@
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 绯荤粺鐢ㄦ埛瀹炰綋锛屽搴?sys_user 琛ㄣ€? * role 鍙栧€硷細UPLOADER / ANNOTATOR / REVIEWER / ADMIN
* status 鍙栧€硷細ACTIVE / DISABLED
*/
@Data
@TableName("sys_user")
public class SysUser {
/** 鐢ㄦ埛涓婚敭锛岃嚜澧?*/
@TableId(type = IdType.AUTO)
private Long id;
/** 鎵€灞炲叕鍙?ID锛堝绉熸埛閿級 */
private Long companyId;
/** 鐧诲綍鐢ㄦ埛鍚嶏紙鍚屽叕鍙稿唴鍞竴锛?*/
private String username;
/**
* BCrypt 鍝堝笇瀵嗙爜锛坰trength 鈮?10锛夈€? * 搴忓垪鍖栨椂鎺掗櫎锛岄槻姝㈠瘑鐮佸搱甯屾硠婕忓埌 API 鍝嶅簲銆? */
@JsonIgnore
private String passwordHash;
/** 鐪熷疄濮撳悕 */
private String realName;
/** 瑙掕壊锛歎PLOADER / ANNOTATOR / REVIEWER / ADMIN */
private String role;
/** 鐘舵€侊細ACTIVE / DISABLED */
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -1,56 +0,0 @@
package com.label.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 瑙嗛澶勭悊浠诲姟瀹炰綋锛屽搴?video_process_job 琛ㄣ€? *
* jobType 鍙栧€硷細FRAME_EXTRACT / VIDEO_TO_TEXT
* status 鍙栧€硷細PENDING / RUNNING / SUCCESS / FAILED / RETRYING
*/
@Data
@TableName("video_process_job")
public class VideoProcessJob {
@TableId(type = IdType.AUTO)
private Long id;
/** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/
private Long companyId;
/** 鍏宠仈璧勬枡 ID */
private Long sourceId;
/** 浠诲姟绫诲瀷锛欶RAME_EXTRACT / VIDEO_TO_TEXT */
private String jobType;
/** 浠诲姟鐘舵€侊細PENDING / RUNNING / SUCCESS / FAILED / RETRYING */
private String status;
/** 浠诲姟鍙傛暟锛圝SONB锛屼緥濡?{"frameInterval": 30}锛?*/
private String params;
/** AI 澶勭悊杈撳嚭璺緞锛堟垚鍔熷悗濉啓锛?*/
private String outputPath;
/** 宸查噸璇曟鏁?*/
private Integer retryCount;
/** 鏈€澶ч噸璇曟鏁帮紙榛樿 3锛?*/
private Integer maxRetries;
/** 閿欒淇℃伅 */
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -3,11 +3,11 @@ package com.label.listener;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.label.common.ai.AiServiceClient;
import com.label.common.context.CompanyContext;
import com.label.entity.TrainingDataset;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.module.annotation.entity.TrainingDataset;
import com.label.module.annotation.mapper.AnnotationResultMapper;
import com.label.module.annotation.mapper.TrainingDatasetMapper;
import com.label.module.source.entity.SourceData;
import com.label.module.source.mapper.SourceDataMapper;
import com.label.module.task.service.TaskClaimService;
import com.label.module.task.service.TaskService;
import com.label.event.ExtractionApprovedEvent;
@@ -25,13 +25,18 @@ import java.util.List;
import java.util.Map;
/**
* 鎻愬彇瀹℃壒閫氳繃鍚庣殑寮傛澶勭悊鍣ㄣ€? *
* 璁捐绾︽潫锛堝叧閿級锛? * - @TransactionalEventListener(AFTER_COMMIT)锛氱‘淇濆湪瀹℃壒浜嬪姟鎻愪氦鍚庢墠瑙﹀彂 AI 璋冪敤
* - @Transactional(REQUIRES_NEW)锛氬湪鐙珛鏂颁簨鍔′腑鍐?DB锛屼笌瀹℃壒浜嬪姟瀹屽叏闅旂
* - 寮傚父涓嶄細鍥炴粴瀹℃壒浜嬪姟锛堝凡鎻愪氦锛夛紝浣嗕細鍦ㄦ棩蹇椾腑璁板綍
* 提取审批通过后的异步处理器。
*
* 澶勭悊娴佺▼锛? * 1. 璋冪敤 AI 鐢熸垚鍊欓€夐棶绛斿锛圱ext/Image 璧颁笉鍚岀鐐癸級
* 2. 鍐欏叆 training_dataset锛坰tatus=PENDING_REVIEW锛? * 3. 鍒涘缓 QA_GENERATION 浠诲姟锛坰tatus=UNCLAIMED锛? * 4. 鏇存柊 source_data 鐘舵€佷负 QA_REVIEW
* 设计约束(关键):
* - @TransactionalEventListener(AFTER_COMMIT):确保在审批事务提交后才触发 AI 调用
* - @Transactional(REQUIRES_NEW):在独立新事务中写 DB与审批事务完全隔离
* - 异常不会回滚审批事务(已提交),但会在日志中记录
*
* 处理流程:
* 1. 调用 AI 生成候选问答对Text/Image 走不同端点)
* 2. 写入 training_datasetstatus=PENDING_REVIEW
* 3. 创建 QA_GENERATION 任务status=UNCLAIMED
* 4. 更新 source_data 状态为 QA_REVIEW
*/
@Slf4j
@Component
@@ -50,15 +55,16 @@ public class ExtractionApprovedEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onExtractionApproved(ExtractionApprovedEvent event) {
log.info("澶勭悊鎻愬彇瀹℃壒閫氳繃浜嬩欢: taskId={}, sourceId={}", event.getTaskId(), event.getSourceId());
log.info("处理提取审批通过事件: taskId={}, sourceId={}", event.getTaskId(), event.getSourceId());
// 璁剧疆澶氱鎴蜂笂涓嬫枃锛堟柊浜嬪姟涓?ThreadLocal 宸叉竻闄わ級
// 设置多租户上下文(新事务中 ThreadLocal 已清除)
CompanyContext.set(event.getCompanyId());
try {
processEvent(event);
} catch (Exception e) {
log.error("澶勭悊瀹℃壒閫氳繃浜嬩欢澶辫触锛坱askId={}锛夛細{}", event.getTaskId(), e.getMessage(), e);
// 涓嶅悜涓婃姏鍑猴紝瀹℃壒鎿嶄綔宸叉彁浜わ紝姝ゅ澶辫触涓嶅洖婊氬鎵? } finally {
log.error("处理审批通过事件失败taskId={}{}", event.getTaskId(), e.getMessage(), e);
// 不向上抛出,审批操作已提交,此处失败不回滚审批
} finally {
CompanyContext.clear();
}
}
@@ -66,11 +72,11 @@ public class ExtractionApprovedEventListener {
private void processEvent(ExtractionApprovedEvent event) {
SourceData source = sourceDataMapper.selectById(event.getSourceId());
if (source == null) {
log.warn("璧勬枡涓嶅瓨鍦紝璺宠繃鍚庣画澶勭悊: sourceId={}", event.getSourceId());
log.warn("资料不存在,跳过后续处理: sourceId={}", event.getSourceId());
return;
}
// 1. 璋冪敤 AI 鐢熸垚鍊欓€夐棶绛斿
// 1. 调用 AI 生成候选问答对
AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder()
.sourceId(source.getId())
.filePath(source.getFilePath())
@@ -85,11 +91,12 @@ public class ExtractionApprovedEventListener {
qaPairs = response != null && response.getQaPairs() != null
? response.getQaPairs() : Collections.emptyList();
} catch (Exception e) {
log.warn("AI 闂瓟鐢熸垚澶辫触锛坱askId={}锛夛細{}锛屽皢浣跨敤绌洪棶绛斿", event.getTaskId(), e.getMessage());
log.warn("AI 问答生成失败taskId={}{},将使用空问答对", event.getTaskId(), e.getMessage());
qaPairs = Collections.emptyList();
}
// 2. 鍐欏叆 training_dataset锛圥ENDING_REVIEW锛? String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT";
// 2. 写入 training_datasetPENDING_REVIEW
String sampleType = "IMAGE".equals(source.getDataType()) ? "IMAGE" : "TEXT";
String glmJson = buildGlmJson(qaPairs);
TrainingDataset dataset = new TrainingDataset();
@@ -101,21 +108,23 @@ public class ExtractionApprovedEventListener {
dataset.setStatus("PENDING_REVIEW");
datasetMapper.insert(dataset);
// 3. 鍒涘缓 QA_GENERATION 浠诲姟锛圲NCLAIMED锛? taskService.createTask(event.getSourceId(), "QA_GENERATION", event.getCompanyId());
// 3. 创建 QA_GENERATION 任务UNCLAIMED
taskService.createTask(event.getSourceId(), "QA_GENERATION", event.getCompanyId());
// 4. 鏇存柊 source_data 鐘舵€佷负 QA_REVIEW
// 4. 更新 source_data 状态为 QA_REVIEW
sourceDataMapper.updateStatus(event.getSourceId(), "QA_REVIEW", event.getCompanyId());
log.info("瀹℃壒閫氳繃鍚庣画澶勭悊瀹屾垚: taskId={}, 鏂?QA 浠诲姟宸插垱寤?, event.getTaskId());
log.info("审批通过后续处理完成: taskId={}, QA 任务已创建", event.getTaskId());
}
/**
* 灏?AI 鐢熸垚鐨勯棶绛斿鍒楄〃杞崲涓?GLM fine-tune 鏍煎紡 JSON銆? */
* AI 生成的问答对列表转换为 GLM fine-tune 格式 JSON
*/
private String buildGlmJson(List<Map<String, Object>> qaPairs) {
try {
return objectMapper.writeValueAsString(Map.of("conversations", qaPairs));
} catch (Exception e) {
log.error("鏋勫缓 GLM JSON 澶辫触", e);
log.error("构建 GLM JSON 失败", e);
return "{\"conversations\":[]}";
}
}

View File

@@ -1,26 +0,0 @@
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.AnnotationTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* annotation_task 鐞?Mapper閵? */
@Mapper
public interface AnnotationTaskMapper extends BaseMapper<AnnotationTask> {
/**
* 閸樼喎鐡欓幀褔顣崣鏍︽崲閸斺槄绱版禒鍛秼娴犺濮熸稉?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);
}

View File

@@ -1,20 +0,0 @@
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.SysCompany;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
/**
* sys_company 鐞?Mapper閵? * 缂佈勫 BaseMapper 閼惧嘲绶遍弽鍥у櫙 CRUD閿涙稖鍤滅€规矮绠熼弬瑙勭《閻劍鏁炵憴?SQL閵? */
@Mapper
public interface SysCompanyMapper extends BaseMapper<SysCompany> {
/**
* 閹稿鍙曢崣闀愬敩閻焦鐓拠銏犲彆閸欓潻绱欒箛鐣屾殣婢舵氨顫ら幋鐤箖濠娿倧绱漵ys_company 閺?company_id 鐎涙顔岄敍澶堚偓? *
* @param companyCode 閸忣剙寰冩禒锝囩垳
* @return 閸忣剙寰冪€圭偘缍嬮敍灞肩瑝鐎涙ê婀崚娆掔箲閸?null
*/
@Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}")
SysCompany selectByCompanyCode(String companyCode);
}

View File

@@ -1,27 +0,0 @@
package com.label.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* sys_user 鐞?Mapper閵? * 缂佈勫 BaseMapper 閼惧嘲绶遍弽鍥у櫙 CRUD閿涙稖鍤滅€规矮绠熼惂璇茬秿閺屻儴顕楅弬瑙勭《缂佹洝绻冩径姘鳖潳閹寸柉绻冨銈呮珤閿? * 閻㈣精鐨熼悽銊︽煙閺勬儳绱℃导鐘插弳 companyId閵? */
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 閹稿鍙曢崣?ID + 閻劍鍩涢崥宥嗙叀鐠囥垻鏁ら幋鍑ょ礄閻ц缍嶉崷鐑樻珯娴h法鏁ら敍澶堚偓? * <p>
* 娴h法鏁?@InterceptorIgnore 缂佹洝绻?TenantLineInnerInterceptor閿? * 閻㈠崬寮弫?companyId 閺勬儳绱¢梽鎰暰缁夌喐鍩涢敍宀勬Щ濮濄垻娅ヨぐ鏇熸 CompanyContext 鐏忔碍婀▔銊ュ弳
* 鐎佃壈鍤ч弻銉嚄閺夆€叉閸欐ü璐?{@code company_id = NULL}閵? * </p>
*
* @param companyId 閸忣剙寰?ID
* @param username 閻劍鍩涢崥? * @return 閻劍鍩涚€圭偘缍嬮敍鍫濇儓 passwordHash閿涘绱濇稉宥呯摠閸︺劌鍨潻鏂挎礀 null
*/
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'")
SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId,
@Param("username") String username);
}

View File

@@ -1,13 +0,0 @@
package com.label.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.AnnotationTaskHistory;
import org.apache.ibatis.annotations.Mapper;
/**
* annotation_task_history 鐞?Mapper閿涘牅绮庢潻钘夊閿涘瞼顩﹀?UPDATE/DELETE閿涘鈧? */
@Mapper
public interface TaskHistoryMapper extends BaseMapper<AnnotationTaskHistory> {
// 缂佈勫 BaseMapper 閻?insert 閻劋绨潻钘夊閸樺棗褰剁拋鏉跨秿
// 娑撱儳顩︾拫鍐暏 update/delete 閻╃鍙ч弬瑙勭《
}

View File

@@ -1,4 +1,4 @@
package com.label.entity;
package com.label.module.annotation.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -8,7 +8,9 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 鏍囨敞缁撴灉瀹炰綋锛屽?annotation_result 琛ㄣ? * resultJson 瀛樺偍 JSONB 鏍煎紡鐨勬爣娉ㄥ唴瀹癸紙鏁翠綋鏇挎崲璇箟锛夈? */
* 标注结果实体对应 annotation_result
* resultJson 存储 JSONB 格式的标注内容整体替换语义
*/
@Data
@TableName("annotation_result")
public class AnnotationResult {
@@ -18,10 +20,10 @@ public class AnnotationResult {
private Long taskId;
/** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/
/** 所属公司(多租户键) */
private Long companyId;
/** 鏍囨敞缁撴灉 JSON锛圝SONB锛屾暣浣撹鐩栵級 */
/** 标注结果 JSONJSONB,整体覆盖) */
private String resultJson;
private LocalDateTime createdAt;

View File

@@ -1,4 +1,4 @@
package com.label.entity;
package com.label.module.annotation.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -8,9 +8,10 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 粌鏁版嵁闆嗗疄浣擄紝瀵瑰簲 training_dataset 琛ㄣ? *
* status 鍙栧硷細PENDING_REVIEW / APPROVED / REJECTED
* sampleType 鍙栧硷細TEXT / IMAGE / VIDEO_FRAME
* 训练数据集实体对应 training_dataset
*
* status 取值PENDING_REVIEW / APPROVED / REJECTED
* sampleType 取值TEXT / IMAGE / VIDEO_FRAME
*/
@Data
@TableName("training_dataset")
@@ -19,20 +20,20 @@ public class TrainingDataset {
@TableId(type = IdType.AUTO)
private Long id;
/** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/
/** 所属公司(多租户键) */
private Long companyId;
private Long taskId;
private Long sourceId;
/** 鏍锋湰绫诲瀷锛歍EXT / IMAGE / VIDEO_FRAME */
/** 样本类型TEXT / IMAGE / VIDEO_FRAME */
private String sampleType;
/** GLM fine-tune 鏍煎紡鐨?JSON 瀛楃涓诧紙JSONB锛?*/
/** GLM fine-tune 格式的 JSON 字符串(JSONB */
private String glmFormatJson;
/** 鐘舵€侊細PENDING_REVIEW / APPROVED / REJECTED */
/** 状态:PENDING_REVIEW / APPROVED / REJECTED */
private String status;
private Long exportBatchId;

View File

@@ -1,19 +1,22 @@
package com.label.mapper;
package com.label.module.annotation.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.AnnotationResult;
import com.label.module.annotation.entity.AnnotationResult;
import org.apache.ibatis.annotations.*;
/**
* annotation_result ?Mapper? */
* annotation_result Mapper
*/
@Mapper
public interface AnnotationResultMapper extends BaseMapper<AnnotationResult> {
/**
* 閺佺繝缍嬬憰鍡欐磰閺嶅洦鏁炵紒鎾寸亯 JSON閿涘湞SONB 鐎涙顔岄敍澶堚偓? *
* @param taskId 娴犺?ID
* @param resultJson 閺傛壆娈?JSON 鐎涙顑佹稉璇х礄閺佺繝缍嬮弴鎸庡床閿? * @param companyId 瑜版挸澧犵粔鐔稿煕
* @return 瑜板崬鎼风悰灞炬殶
* 整体覆盖标注结果 JSONJSONB 字段
*
* @param taskId 任务 ID
* @param resultJson 新的 JSON 字符串整体替换
* @param companyId 当前租户
* @return 影响行数
*/
@Update("UPDATE annotation_result " +
"SET result_json = #{resultJson}::jsonb, updated_at = NOW() " +
@@ -23,9 +26,11 @@ public interface AnnotationResultMapper extends BaseMapper<AnnotationResult> {
@Param("companyId") Long companyId);
/**
* 閹稿?ID 閺屻儴顕楅弽鍥ㄦ暈缂佹挻鐏夐妴? *
* @param taskId 娴犺?ID
* @return 閺嶅洦鏁炵紒鎾寸亯閿涘牅绗夌涙ê婀崚娆掔箲閸?null閿? */
* 按任务 ID 查询标注结果
*
* @param taskId 任务 ID
* @return 标注结果不存在则返回 null
*/
@Select("SELECT * FROM annotation_result WHERE task_id = #{taskId}")
AnnotationResult selectByTaskId(@Param("taskId") Long taskId);
}

View File

@@ -1,32 +1,35 @@
package com.label.mapper;
package com.label.module.annotation.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.TrainingDataset;
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? */
* training_dataset Mapper
*/
@Mapper
public interface TrainingDatasetMapper extends BaseMapper<TrainingDataset> {
/**
* 閹稿?ID 鐏忓棜顔勭紒鍐壉閺堫剛濮搁幀浣规暭娑?APPROVED閵? *
* @param taskId 娴犺?ID
* @param companyId 瑜版挸澧犵粔鐔稿煕
* @return 瑜板崬鎼风悰灞炬殶
* 按任务 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 瑜板崬鎼风悰灞炬殶
* 按任务 ID 删除训练样本驳回时清除候选数据
*
* @param taskId 任务 ID
* @param companyId 当前租户
* @return 影响行数
*/
@Delete("DELETE FROM training_dataset WHERE task_id = #{taskId} AND company_id = #{companyId}")
int deleteByTaskId(@Param("taskId") Long taskId, @Param("companyId") Long companyId);

View File

@@ -7,15 +7,15 @@ import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.AnnotationResult;
import com.label.entity.TrainingDataset;
import com.label.module.annotation.entity.AnnotationResult;
import com.label.module.annotation.entity.TrainingDataset;
import com.label.event.ExtractionApprovedEvent;
import com.label.mapper.AnnotationResultMapper;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;
import com.label.module.annotation.mapper.AnnotationResultMapper;
import com.label.module.annotation.mapper.TrainingDatasetMapper;
import com.label.module.source.entity.SourceData;
import com.label.module.source.mapper.SourceDataMapper;
import com.label.module.task.entity.AnnotationTask;
import com.label.module.task.mapper.AnnotationTaskMapper;
import com.label.module.task.service.TaskClaimService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -30,8 +30,12 @@ import java.util.Collections;
import java.util.Map;
/**
* 鎻愬彇闃舵鏍囨敞鏈嶅姟锛欰I 棰勬爣娉ㄣ€佹洿鏂扮粨鏋溿€佹彁浜ゃ€佸鎵广€侀┏鍥炪€? *
* 鍏抽敭璁捐锛? * - approve() 鍐呯姝㈢洿鎺ヨ皟鐢?AI锛岄€氳繃 ExtractionApprovedEvent 瑙€︼紙AFTER_COMMIT锛? * - 鎵€鏈夊啓鎿嶄綔鍖呰9鍦?@Transactional 涓紝纭繚浠诲姟鐘舵€佸拰鍘嗗彶鐨勪竴鑷存€? */
* 提取阶段标注服务AI 预标注、更新结果、提交、审批、驳回。
*
* 关键设计:
* - approve() 内禁止直接调用 AI通过 ExtractionApprovedEvent 解耦AFTER_COMMIT
* - 所有写操作包裹在 @Transactional 中,确保任务状态和历史的一致性
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -49,19 +53,22 @@ public class ExtractionService {
@Value("${rustfs.bucket:label-source-data}")
private String bucket;
// ------------------------------------------------------------------ AI 棰勬爣娉?--
// ------------------------------------------------------------------ AI 预标注 --
/**
* AI 杈呭姪棰勬爣娉細璋冪敤 AI 鏈嶅姟锛屽皢缁撴灉鍐欏叆 annotation_result銆? * 娉細姝ゆ柟娉曞湪 @Transactional 澶栬皟鐢紙AI 璋冪敤涓嶅簲鍦ㄤ簨鍔″唴锛夛紝鐢辨帶鍒跺櫒鐩存帴璋冪敤銆? */
* AI 辅助预标注:调用 AI 服务,将结果写入 annotation_result。
* 注:此方法在 @Transactional 外调用AI 调用不应在事务内),由控制器直接调用。
*/
public void aiPreAnnotate(Long taskId, TokenPrincipal principal) {
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
SourceData source = sourceDataMapper.selectById(task.getSourceId());
if (source == null) {
throw new BusinessException("NOT_FOUND", "鍏宠仈璧勬枡涓嶅瓨鍦?, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "关联资料不存在", HttpStatus.NOT_FOUND);
}
// 璋冪敤 AI 鏈嶅姟锛堝湪浜嬪姟澶栵紝閬垮厤闀挎椂闂存寔鏈?DB 杩炴帴锛? AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder()
// 调用 AI 服务(在事务外,避免长时间持有 DB 连接)
AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder()
.sourceId(source.getId())
.filePath(source.getFilePath())
.bucket(bucket)
@@ -75,35 +82,39 @@ public class ExtractionService {
aiResponse = aiServiceClient.extractText(req);
}
} catch (Exception e) {
log.warn("AI 棰勬爣娉ㄨ皟鐢ㄥけ璐ワ紙浠诲姟 {}锛夛細{}", taskId, e.getMessage());
// AI 澶辫触涓嶉樆濉炴祦绋嬶紝鍐欏叆绌虹粨鏋? aiResponse = new AiServiceClient.ExtractionResponse();
log.warn("AI 预标注调用失败(任务 {}{}", taskId, e.getMessage());
// AI 失败不阻塞流程,写入空结果
aiResponse = new AiServiceClient.ExtractionResponse();
aiResponse.setItems(Collections.emptyList());
}
// 灏?AI 缁撴灉鍐欏叆 annotation_result锛圲PSERT 璇箟锛? writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems());
// AI 结果写入 annotation_resultUPSERT 语义)
writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems());
}
// ------------------------------------------------------------------ 鏇存柊缁撴灉 --
// ------------------------------------------------------------------ 更新结果 --
/**
* 浜哄伐鏇存柊鏍囨敞缁撴灉锛堟暣浣撹鐩栵紝PUT 璇箟锛夈€? *
* @param taskId 浠诲姟 ID
* @param resultJson 鏂扮殑鏍囨敞缁撴灉 JSON 瀛楃涓? * @param principal 褰撳墠鐢ㄦ埛
* 人工更新标注结果整体覆盖PUT 语义)。
*
* @param taskId 任务 ID
* @param resultJson 新的标注结果 JSON 字符串
* @param principal 当前用户
*/
@Transactional
public void updateResult(Long taskId, String resultJson, TokenPrincipal principal) {
validateAndGetTask(taskId, principal.getCompanyId());
// 鏍¢獙 JSON 鏍煎紡
// 校验 JSON 格式
try {
objectMapper.readTree(resultJson);
} catch (Exception e) {
throw new BusinessException("INVALID_JSON", "鏍囨敞缁撴灉 JSON 鏍煎紡涓嶅悎娉?, HttpStatus.BAD_REQUEST);
throw new BusinessException("INVALID_JSON", "标注结果 JSON 格式不合法", HttpStatus.BAD_REQUEST);
}
int updated = resultMapper.updateResultJson(taskId, resultJson, principal.getCompanyId());
if (updated == 0) {
// 涓嶅瓨鍦ㄥ垯鏂板缓
// 不存在则新建
AnnotationResult result = new AnnotationResult();
result.setTaskId(taskId);
result.setCompanyId(principal.getCompanyId());
@@ -112,10 +123,11 @@ public class ExtractionService {
}
}
// ------------------------------------------------------------------ 鎻愪氦 --
// ------------------------------------------------------------------ 提交 --
/**
* 鎻愪氦鎻愬彇缁撴灉锛圛N_PROGRESS 鈫?SUBMITTED锛夈€? */
* 提交提取结果IN_PROGRESS SUBMITTED)。
*/
@Transactional
public void submit(Long taskId, TokenPrincipal principal) {
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
@@ -133,27 +145,32 @@ public class ExtractionService {
principal.getUserId(), principal.getRole(), null);
}
// ------------------------------------------------------------------ 瀹℃壒閫氳繃 --
// ------------------------------------------------------------------ 审批通过 --
/**
* 瀹℃壒閫氳繃锛圫UBMITTED 鈫?APPROVED锛夈€? *
* 涓ら樁娈碉細
* 1. 鍚屾浜嬪姟锛歩s_final=true锛岀姸鎬佹帹杩涳紝鍐欏巻鍙? * 2. 浜嬪姟鎻愪氦鍚庯紙AFTER_COMMIT锛夛細AI 鐢熸垚闂瓟瀵?鈫?training_dataset 鈫?QA 浠诲姟 鈫?source_data 鐘舵€? *
* 娉細AI 璋冪敤涓ョ鍦ㄦ浜嬪姟鍐呮墽琛屻€? */
* 审批通过SUBMITTED APPROVED)。
*
* 两阶段:
* 1. 同步事务is_final=true状态推进写历史
* 2. 事务提交后AFTER_COMMITAI 生成问答对 → training_dataset → QA 任务 → source_data 状态
*
* 注AI 调用严禁在此事务内执行。
*/
@Transactional
public void approve(Long taskId, TokenPrincipal principal) {
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
// 鑷鏍¢獙
// 自审校验
if (principal.getUserId().equals(task.getClaimedBy())) {
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
"涓嶅厑璁稿鎵硅嚜宸辨彁浜ょ殑浠诲姟", HttpStatus.FORBIDDEN);
"不允许审批自己提交的任务", HttpStatus.FORBIDDEN);
}
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
// 鏍囪涓烘渶缁堢粨鏋? taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
// 标记为最终结果
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
.eq(AnnotationTask::getId, taskId)
.set(AnnotationTask::getStatus, "APPROVED")
.set(AnnotationTask::getIsFinal, true)
@@ -163,30 +180,33 @@ public class ExtractionService {
"SUBMITTED", "APPROVED",
principal.getUserId(), principal.getRole(), null);
// 鑾峰彇璧勬枡淇℃伅锛岀敤浜庝簨浠? SourceData source = sourceDataMapper.selectById(task.getSourceId());
// 获取资料信息,用于事件
SourceData source = sourceDataMapper.selectById(task.getSourceId());
String sourceType = source != null ? source.getDataType() : "TEXT";
// 鍙戝竷浜嬩欢锛園TransactionalEventListener(AFTER_COMMIT) 澶勭悊 AI 璋冪敤锛? eventPublisher.publishEvent(new ExtractionApprovedEvent(
// 发布事件(@TransactionalEventListener(AFTER_COMMIT) 处理 AI 调用)
eventPublisher.publishEvent(new ExtractionApprovedEvent(
this, taskId, task.getSourceId(), sourceType,
principal.getCompanyId(), principal.getUserId()));
}
// ------------------------------------------------------------------ 椹冲洖 --
// ------------------------------------------------------------------ 驳回 --
/**
* 椹冲洖鎻愬彇缁撴灉锛圫UBMITTED 鈫?REJECTED锛夈€? */
* 驳回提取结果SUBMITTED REJECTED)。
*/
@Transactional
public void reject(Long taskId, String reason, TokenPrincipal principal) {
if (reason == null || reason.isBlank()) {
throw new BusinessException("REASON_REQUIRED", "椹冲洖鍘熷洜涓嶈兘涓虹┖", HttpStatus.BAD_REQUEST);
throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST);
}
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
// 鑷鏍¢獙
// 自审校验
if (principal.getUserId().equals(task.getClaimedBy())) {
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
"涓嶅厑璁搁┏鍥炶嚜宸辨彁浜ょ殑浠诲姟", HttpStatus.FORBIDDEN);
"不允许驳回自己提交的任务", HttpStatus.FORBIDDEN);
}
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
@@ -202,10 +222,11 @@ public class ExtractionService {
principal.getUserId(), principal.getRole(), reason);
}
// ------------------------------------------------------------------ 鏌ヨ --
// ------------------------------------------------------------------ 查询 --
/**
* 鑾峰彇褰撳墠鏍囨敞缁撴灉銆? */
* 获取当前标注结果。
*/
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
AnnotationResult result = resultMapper.selectByTaskId(taskId);
@@ -220,14 +241,15 @@ public class ExtractionService {
);
}
// ------------------------------------------------------------------ 绉佹湁宸ュ叿 --
// ------------------------------------------------------------------ 私有工具 --
/**
* 鏍¢獙浠诲姟瀛樺湪鎬э紙澶氱鎴疯嚜鍔ㄨ繃婊わ級銆? */
* 校验任务存在性(多租户自动过滤)。
*/
private AnnotationTask validateAndGetTask(Long taskId, Long companyId) {
AnnotationTask task = taskMapper.selectById(taskId);
if (task == null || !companyId.equals(task.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
}
return task;
}
@@ -244,7 +266,7 @@ public class ExtractionService {
resultMapper.insert(result);
}
} catch (Exception e) {
log.error("鍐欏叆 AI 棰勬爣娉ㄧ粨鏋滃け璐? taskId={}", taskId, e);
log.error("写入 AI 预标注结果失败: taskId={}", taskId, e);
}
}
}

View File

@@ -6,12 +6,12 @@ import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.TrainingDataset;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;
import com.label.module.annotation.entity.TrainingDataset;
import com.label.module.annotation.mapper.TrainingDatasetMapper;
import com.label.module.source.entity.SourceData;
import com.label.module.source.mapper.SourceDataMapper;
import com.label.module.task.entity.AnnotationTask;
import com.label.module.task.mapper.AnnotationTaskMapper;
import com.label.module.task.service.TaskClaimService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -25,9 +25,13 @@ import java.util.List;
import java.util.Map;
/**
* 闂瓟鐢熸垚闃舵鏍囨敞鏈嶅姟锛氭煡璇㈠€欓€夐棶绛斿銆佹洿鏂般€佹彁浜ゃ€佸鎵广€侀┏鍥炪€? *
* 鍏抽敭璁捐锛? * - QA 闃舵鏃?AI 璋冪敤锛堝€欓€夐棶绛斿宸茬敱 ExtractionApprovedEventListener 鐢熸垚锛? * - approve() 鍚屼竴浜嬪姟鍐呭畬鎴愶細training_dataset 鈫?APPROVED銆乼ask 鈫?APPROVED銆乻ource_data 鈫?APPROVED
* - reject() 娓呴櫎鍊欓€夐棶绛斿锛坉eleteByTaskId锛夛紝source_data 淇濇寔 QA_REVIEW 鐘舵€? */
* 问答生成阶段标注服务:查询候选问答对、更新、提交、审批、驳回。
*
* 关键设计:
* - QA 阶段无 AI 调用(候选问答对已由 ExtractionApprovedEventListener 生成)
* - approve() 同一事务内完成training_dataset → APPROVED、task → APPROVED、source_data → APPROVED
* - reject() 清除候选问答对deleteByTaskIdsource_data 保持 QA_REVIEW 状态
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -39,10 +43,11 @@ public class QaService {
private final TaskClaimService taskClaimService;
private final ObjectMapper objectMapper;
// ------------------------------------------------------------------ 鏌ヨ --
// ------------------------------------------------------------------ 查询 --
/**
* 鑾峰彇鍊欓€夐棶绛斿锛堜粠 training_dataset.glm_format_json 瑙f瀽锛夈€? */
* 获取候选问答对(从 training_dataset.glm_format_json 解析)。
*/
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
TrainingDataset dataset = getDataset(taskId);
@@ -60,7 +65,7 @@ public class QaService {
items = (List<?>) conversations;
}
} catch (Exception e) {
log.warn("瑙f瀽 QA JSON 澶辫触锛坱askId={}锛夛細{}", taskId, e.getMessage());
log.warn("解析 QA JSON 失败taskId={}{}", taskId, e.getMessage());
}
}
@@ -71,26 +76,27 @@ public class QaService {
);
}
// ------------------------------------------------------------------ 鏇存柊 --
// ------------------------------------------------------------------ 更新 --
/**
* 鏁翠綋瑕嗙洊闂瓟瀵癸紙PUT 璇箟锛夈€? *
* @param taskId 浠诲姟 ID
* @param body 鍖呭惈 items 鏁扮粍鐨?JSON锛屾牸寮忥細{"items": [...]}
* @param principal 褰撳墠鐢ㄦ埛
* 整体覆盖问答对PUT 语义)。
*
* @param taskId 任务 ID
* @param body 包含 items 数组的 JSON格式{"items": [...]}
* @param principal 当前用户
*/
@Transactional
public void updateResult(Long taskId, String body, TokenPrincipal principal) {
validateAndGetTask(taskId, principal.getCompanyId());
// 鏍¢獙 JSON 鏍煎紡
// 校验 JSON 格式
try {
objectMapper.readTree(body);
} catch (Exception e) {
throw new BusinessException("INVALID_JSON", "璇锋眰浣?JSON 鏍煎紡涓嶅悎娉?, HttpStatus.BAD_REQUEST);
throw new BusinessException("INVALID_JSON", "请求体 JSON 格式不合法", HttpStatus.BAD_REQUEST);
}
// 灏?items 鏍煎紡鍖呰涓?GLM 鏍煎紡锛歿"conversations": items}
// items 格式包装为 GLM 格式:{"conversations": items}
String glmJson;
try {
@SuppressWarnings("unchecked")
@@ -108,7 +114,7 @@ public class QaService {
.set(TrainingDataset::getGlmFormatJson, glmJson)
.set(TrainingDataset::getUpdatedAt, LocalDateTime.now()));
} else {
// 鑻?training_dataset 涓嶅瓨鍦紙寮傚父鎯呭喌锛夛紝鑷姩鍒涘缓
// training_dataset 不存在(异常情况),自动创建
TrainingDataset newDataset = new TrainingDataset();
newDataset.setCompanyId(principal.getCompanyId());
newDataset.setTaskId(taskId);
@@ -121,10 +127,11 @@ public class QaService {
}
}
// ------------------------------------------------------------------ 鎻愪氦 --
// ------------------------------------------------------------------ 提交 --
/**
* 鎻愪氦 QA 缁撴灉锛圛N_PROGRESS 鈫?SUBMITTED锛夈€? */
* 提交 QA 结果IN_PROGRESS SUBMITTED)。
*/
@Transactional
public void submit(Long taskId, TokenPrincipal principal) {
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
@@ -142,71 +149,78 @@ public class QaService {
principal.getUserId(), principal.getRole(), null);
}
// ------------------------------------------------------------------ 瀹℃壒閫氳繃 --
// ------------------------------------------------------------------ 审批通过 --
/**
* 瀹℃壒閫氳繃锛圫UBMITTED 鈫?APPROVED锛夈€? *
* 鍚屼竴浜嬪姟锛? * 1. 鏍¢獙浠诲姟锛堝厛浜庝竴鍒?DB 鍐欏叆锛? * 2. 鑷鏍¢獙
* 审批通过SUBMITTED APPROVED)。
*
* 同一事务:
* 1. 校验任务(先于一切 DB 写入)
* 2. 自审校验
* 3. StateValidator
* 4. training_dataset 鈫?APPROVED
* 5. annotation_task 鈫?APPROVED + is_final=true + completedAt
* 6. source_data 鈫?APPROVED锛堟暣鏉℃祦姘寸嚎瀹屾垚锛? * 7. 鍐欎换鍔″巻鍙? */
* 4. training_dataset APPROVED
* 5. annotation_task APPROVED + is_final=true + completedAt
* 6. source_data APPROVED(整条流水线完成)
* 7. 写任务历史
*/
@Transactional
public void approve(Long taskId, TokenPrincipal principal) {
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
// 鑷鏍¢獙
// 自审校验
if (principal.getUserId().equals(task.getClaimedBy())) {
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
"涓嶅厑璁稿鎵硅嚜宸辨彁浜ょ殑浠诲姟", HttpStatus.FORBIDDEN);
"不允许审批自己提交的任务", HttpStatus.FORBIDDEN);
}
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
// training_dataset 鈫?APPROVED
// training_dataset APPROVED
datasetMapper.approveByTaskId(taskId, principal.getCompanyId());
// annotation_task 鈫?APPROVED + is_final=true
// annotation_task APPROVED + is_final=true
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
.eq(AnnotationTask::getId, taskId)
.set(AnnotationTask::getStatus, "APPROVED")
.set(AnnotationTask::getIsFinal, true)
.set(AnnotationTask::getCompletedAt, LocalDateTime.now()));
// source_data 鈫?APPROVED锛堟暣鏉℃祦姘寸嚎缁堟€侊級
// source_data APPROVED(整条流水线终态)
sourceDataMapper.updateStatus(task.getSourceId(), "APPROVED", principal.getCompanyId());
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
"SUBMITTED", "APPROVED",
principal.getUserId(), principal.getRole(), null);
log.info("QA 瀹℃壒閫氳繃锛屾暣鏉℃祦姘寸嚎瀹屾垚: taskId={}, sourceId={}", taskId, task.getSourceId());
log.info("QA 审批通过,整条流水线完成: taskId={}, sourceId={}", taskId, task.getSourceId());
}
// ------------------------------------------------------------------ 椹冲洖 --
// ------------------------------------------------------------------ 驳回 --
/**
* 椹冲洖 QA 缁撴灉锛圫UBMITTED 鈫?REJECTED锛夈€? *
* 娓呴櫎鍊欓€夐棶绛斿锛坉eleteByTaskId锛夛紝source_data 淇濇寔 QA_REVIEW 鐘舵€佷笉鍙樸€? */
* 驳回 QA 结果SUBMITTED REJECTED)。
*
* 清除候选问答对deleteByTaskIdsource_data 保持 QA_REVIEW 状态不变。
*/
@Transactional
public void reject(Long taskId, String reason, TokenPrincipal principal) {
if (reason == null || reason.isBlank()) {
throw new BusinessException("REASON_REQUIRED", "椹冲洖鍘熷洜涓嶈兘涓虹┖", HttpStatus.BAD_REQUEST);
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<AnnotationTask>()
@@ -219,12 +233,12 @@ public class QaService {
principal.getUserId(), principal.getRole(), reason);
}
// ------------------------------------------------------------------ 绉佹湁宸ュ叿 --
// ------------------------------------------------------------------ 私有工具 --
private AnnotationTask validateAndGetTask(Long taskId, Long companyId) {
AnnotationTask task = taskMapper.selectById(taskId);
if (task == null || !companyId.equals(task.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
}
return task;
}

View File

@@ -2,7 +2,7 @@ package com.label.module.config.controller;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.SysConfig;
import com.label.module.config.entity.SysConfig;
import com.label.module.config.service.SysConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,9 +15,12 @@ import java.util.List;
import java.util.Map;
/**
* 绯荤粺閰嶇疆鎺ュ彛锛? 涓鐐癸紝鍧囬渶 ADMIN 鏉冮檺锛夈€? *
* GET /api/config 鈥?鏌ヨ褰撳墠鍏徃鎵€鏈夊彲瑙侀厤缃紙鍏徃涓撳睘 + 鍏ㄥ眬榛樿鍚堝苟锛? * PUT /api/config/{key} 鈥?鏇存柊/鍒涘缓鍏徃涓撳睘閰嶇疆锛圲PSERT锛? */
@Tag(name = "绯荤粺閰嶇疆", description = "鍏ㄥ眬鍜屽叕鍙哥骇绯荤粺閰嶇疆绠$悊")
* 系统配置接口2 个端点,均需 ADMIN 权限)。
*
* GET /api/config — 查询当前公司所有可见配置(公司专属 + 全局默认合并)
* PUT /api/config/{key} — 更新/创建公司专属配置UPSERT
*/
@Tag(name = "系统配置", description = "全局和公司级系统配置管理")
@RestController
@RequiredArgsConstructor
public class SysConfigController {
@@ -25,10 +28,13 @@ public class SysConfigController {
private final SysConfigService sysConfigService;
/**
* GET /api/config 鈥?鏌ヨ鍚堝苟鍚庣殑閰嶇疆鍒楄〃銆? *
* 鍝嶅簲涓瘡鏉¢厤缃惈 scope 瀛楁锛? * - "COMPANY"锛氬綋鍓嶅叕鍙镐笓灞為厤缃紙浼樺厛鐢熸晥锛? * - "GLOBAL"锛氬叏灞€榛樿閰嶇疆锛堝叕鍙告湭瑕嗙洊鏃剁敓鏁堬級
* GET /api/config — 查询合并后的配置列表。
*
* 响应中每条配置含 scope 字段:
* - "COMPANY":当前公司专属配置(优先生效)
* - "GLOBAL":全局默认配置(公司未覆盖时生效)
*/
@Operation(summary = "鏌ヨ鍚堝苟鍚庣殑绯荤粺閰嶇疆")
@Operation(summary = "查询合并后的系统配置")
@GetMapping("/api/config")
@RequiresRoles("ADMIN")
public Result<List<Map<String, Object>>> listConfig(HttpServletRequest request) {
@@ -37,10 +43,11 @@ public class SysConfigController {
}
/**
* PUT /api/config/{key} 鈥?UPSERT 鍏徃涓撳睘閰嶇疆銆? *
* PUT /api/config/{key} UPSERT 公司专属配置。
*
* Body: { "value": "...", "description": "..." }
*/
@Operation(summary = "鏇存柊鎴栧垱寤哄叕鍙镐笓灞為厤缃?)
@Operation(summary = "更新或创建公司专属配置")
@PutMapping("/api/config/{key}")
@RequiresRoles("ADMIN")
public Result<SysConfig> updateConfig(@PathVariable String key,

View File

@@ -0,0 +1,41 @@
package com.label.module.config.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统配置实体,对应 sys_config 表。
*
* company_id 为 NULL 时表示全局默认配置,非 NULL 时表示租户专属配置(优先级更高)。
* 注sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES不走多租户过滤器。
*/
@Data
@TableName("sys_config")
public class SysConfig {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 所属公司 IDNULL = 全局默认配置;非 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;
}

View File

@@ -1,7 +1,7 @@
package com.label.mapper;
package com.label.module.config.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.SysConfig;
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;
@@ -9,24 +9,27 @@ import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* sys_config ?Mapper? *
* 濞夈劍鍓伴敍姝磞s_config 瀹告彃濮為崗?MybatisPlusConfig.IGNORED_TABLES閿涘奔绗夌挧鏉款樋缁夌喐鍩涙潻鍥ㄦ姢閸煉绱?
* 闂団偓閹靛导鐘插弳 companyId 稖顢戞潻鍥ㄦ姢閵? */
* sys_config Mapper
*
* 注意sys_config 已加入 MybatisPlusConfig.IGNORED_TABLES不走多租户过滤器
* 需手动传入 companyId 进行过滤
*/
@Mapper
public interface SysConfigMapper extends BaseMapper<SysConfig> {
/** 閺屻儴顕楅幐鍥х暰閸忣剙寰冮惃鍕帳缂冾噯绱欑粔鐔稿煕娑撴挸鐫橀敍灞肩喘閸忓牏楠囨姗堢礆 */
/** 查询指定公司的配置(租户专属,优先级高) */
@Select("SELECT * FROM sys_config WHERE company_id = #{companyId} AND config_key = #{configKey}")
SysConfig selectByCompanyAndKey(@Param("companyId") Long companyId,
@Param("configKey") String configKey);
/** 閺屻儴顕楅崗銊ョ湰姒涙顓婚柊宥囩枂閿涘潏ompany_id IS NULL閿?*/
/** 查询全局默认配置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 閹烘帒绨敍鍫濆彆閸欓晲绗撶仦鐐扮喘閸忓牅绨崗銊ョ湰姒涙顓婚敍澶堚偓? */
* 查询指定公司所有可见配置公司专属 + 全局默认
* company_id DESC NULLS LAST 排序公司专属优先于全局默认
*/
@Select("SELECT * FROM sys_config WHERE company_id = #{companyId} OR company_id IS NULL " +
"ORDER BY company_id DESC NULLS LAST")
List<SysConfig> selectAllForCompany(@Param("companyId") Long companyId);

View File

@@ -1,8 +1,8 @@
package com.label.module.config.service;
import com.label.common.exception.BusinessException;
import com.label.entity.SysConfig;
import com.label.mapper.SysConfigMapper;
import com.label.module.config.entity.SysConfig;
import com.label.module.config.mapper.SysConfigMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -14,17 +14,20 @@ import java.util.*;
import java.util.stream.Collectors;
/**
* 绯荤粺閰嶇疆鏈嶅姟銆? *
* 閰嶇疆鏌ユ壘浼樺厛绾э細鍏徃涓撳睘锛坈ompany_id = N锛? 鍏ㄥ眬榛樿锛坈ompany_id IS NULL锛夈€? *
* get() 鈥?鎸変紭鍏堢骇杩斿洖鍗曚釜閰嶇疆鍊? * list() 鈥?杩斿洖鍚堝苟鍚庣殑閰嶇疆鍒楄〃锛堝叕鍙镐笓灞炶鐩栧悓鍚嶅叏灞€閰嶇疆锛夛紝闄?scope 瀛楁
* update() 鈥?浠ュ叕鍙镐笓灞為厤缃繘琛?UPSERT锛堜粎鍏佽宸茬煡閰嶇疆閿級
* 系统配置服务。
*
* 配置查找优先级公司专属company_id = N> 全局默认company_id IS NULL
*
* get() — 按优先级返回单个配置值
* list() — 返回合并后的配置列表(公司专属覆盖同名全局配置),附 scope 字段
* update() — 以公司专属配置进行 UPSERT仅允许已知配置键
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysConfigService {
/** 绯荤粺宸茬煡閰嶇疆閿櫧鍚嶅崟锛堥槻姝㈠啓鍏ユ湭鐭ラ敭锛?*/
/** 系统已知配置键白名单(防止写入未知键) */
private static final Set<String> KNOWN_KEYS = Set.of(
"token_ttl_seconds",
"model_default",
@@ -33,36 +36,42 @@ public class SysConfigService {
private final SysConfigMapper configMapper;
// ------------------------------------------------------------------ 鏌ヨ鍗曞€?--
// ------------------------------------------------------------------ 查询单值 --
/**
* 鎸変紭鍏堢骇鑾峰彇閰嶇疆鍊硷細鍏徃涓撳睘浼樺厛锛屽惁鍒欏洖閫€鍏ㄥ眬榛樿銆? *
* @param configKey 閰嶇疆閿? * @param companyId 褰撳墠鍏徃 ID
* @return 閰嶇疆鍊硷紙涓嶅瓨鍦ㄦ椂杩斿洖 null锛? */
* 按优先级获取配置值:公司专属优先,否则回退全局默认。
*
* @param configKey 配置键
* @param companyId 当前公司 ID
* @return 配置值(不存在时返回 null
*/
public String get(String configKey, Long companyId) {
// 鍏堟煡鍏徃涓撳睘
// 先查公司专属
SysConfig company = configMapper.selectByCompanyAndKey(companyId, configKey);
if (company != null) {
return company.getConfigValue();
}
// 鍥為€€鍏ㄥ眬榛樿
// 回退全局默认
SysConfig global = configMapper.selectGlobalByKey(configKey);
return global != null ? global.getConfigValue() : null;
}
// ------------------------------------------------------------------ 鏌ヨ鍒楄〃 --
// ------------------------------------------------------------------ 查询列表 --
/**
* 杩斿洖褰撳墠鍏徃鎵€鏈夊彲瑙侀厤缃紙鍏徃涓撳睘 + 鍏ㄥ眬榛樿鍚堝苟锛夛紝
* 闄勫姞 scope 瀛楁锛?COMPANY" / "GLOBAL"锛夋爣璇嗘潵婧愩€? *
* @param companyId 褰撳墠鍏徃 ID
* @return 閰嶇疆鍒楄〃锛堝惈 scope锛? */
* 返回当前公司所有可见配置(公司专属 + 全局默认合并),
* 附加 scope 字段("COMPANY" / "GLOBAL")标识来源。
*
* @param companyId 当前公司 ID
* @return 配置列表(含 scope
*/
public List<Map<String, Object>> list(Long companyId) {
List<SysConfig> all = configMapper.selectAllForCompany(companyId);
// 鎸?configKey 鍒嗙粍锛屽叕鍙镐笓灞炰紭鍏堬紙鎺掑簭淇濊瘉鍏徃涓撳睘鍦ㄥ墠锛? Map<String, SysConfig> merged = new LinkedHashMap<>();
// configKey 分组,公司专属优先(排序保证公司专属在前)
Map<String, SysConfig> merged = new LinkedHashMap<>();
for (SysConfig cfg : all) {
// 鐢变簬 SQL 鎸?company_id DESC NULLS LAST 鎺掑簭锛屽叕鍙镐笓灞炲厛鍑虹幇锛岀洿鎺?putIfAbsent
// 由于 SQL company_id DESC NULLS LAST 排序,公司专属先出现,直接 putIfAbsent
merged.putIfAbsent(cfg.getConfigKey(), cfg);
}
@@ -80,28 +89,33 @@ public class SysConfigService {
.collect(Collectors.toList());
}
// ------------------------------------------------------------------ 鏇存柊閰嶇疆 --
// ------------------------------------------------------------------ 更新配置 --
/**
* 鏇存柊鍏徃涓撳睘閰嶇疆锛圲PSERT锛夈€? *
* 浠呭厑璁?KNOWN_KEYS 涓殑閰嶇疆閿紝闃叉鍐欏叆鏈畾涔夌殑閰嶇疆椤广€? *
* @param configKey 閰嶇疆閿? * @param value 鏂伴厤缃€? * @param description 閰嶇疆璇存槑锛堝彲閫夛級
* @param companyId 褰撳墠鍏徃 ID
* 更新公司专属配置UPSERT
*
* 仅允许 KNOWN_KEYS 中的配置键,防止写入未定义的配置项。
*
* @param configKey 配置键
* @param value 新配置值
* @param description 配置说明(可选)
* @param companyId 当前公司 ID
*/
@Transactional
public SysConfig update(String configKey, String value,
String description, Long companyId) {
if (!KNOWN_KEYS.contains(configKey)) {
throw new BusinessException("UNKNOWN_CONFIG_KEY",
"鏈煡閰嶇疆閿? " + configKey, HttpStatus.BAD_REQUEST);
"未知配置键: " + configKey, HttpStatus.BAD_REQUEST);
}
if (value == null || value.isBlank()) {
throw new BusinessException("INVALID_CONFIG_VALUE",
"閰嶇疆鍊间笉鑳戒负绌?, HttpStatus.BAD_REQUEST);
"配置值不能为空", HttpStatus.BAD_REQUEST);
}
// UPSERT锛氬鍏徃涓撳睘閰嶇疆宸插瓨鍦ㄥ垯鏇存柊锛屽惁鍒欐彃鍏? SysConfig existing = configMapper.selectByCompanyAndKey(companyId, configKey);
// UPSERT:如公司专属配置已存在则更新,否则插入
SysConfig existing = configMapper.selectByCompanyAndKey(companyId, configKey);
if (existing != null) {
existing.setConfigValue(value);
if (description != null && !description.isBlank()) {
@@ -109,7 +123,7 @@ public class SysConfigService {
}
existing.setUpdatedAt(LocalDateTime.now());
configMapper.updateById(existing);
log.info("徃閰嶇疆宸叉洿鏂? companyId={}, key={}, value={}", companyId, configKey, value);
log.info("公司配置已更新: companyId={}, key={}, value={}", companyId, configKey, value);
return existing;
} else {
SysConfig cfg = new SysConfig();
@@ -118,7 +132,7 @@ public class SysConfigService {
cfg.setConfigValue(value);
cfg.setDescription(description);
configMapper.insert(cfg);
log.info("徃閰嶇疆宸插垱寤? companyId={}, key={}, value={}", companyId, configKey, value);
log.info("公司配置已创建: companyId={}, key={}, value={}", companyId, configKey, value);
return cfg;
}
}

View File

@@ -3,8 +3,8 @@ package com.label.module.export.controller;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.TrainingDataset;
import com.label.entity.ExportBatch;
import com.label.module.annotation.entity.TrainingDataset;
import com.label.module.export.entity.ExportBatch;
import com.label.module.export.service.ExportService;
import com.label.module.export.service.FinetuneService;
import io.swagger.v3.oas.annotations.Operation;
@@ -19,8 +19,9 @@ import java.util.List;
import java.util.Map;
/**
* 璁粌鏁版嵁瀵煎嚭涓庡井璋冩帴鍙紙5 涓鐐癸紝鍏ㄩ儴 ADMIN 鏉冮檺锛夈€? */
@Tag(name = "瀵煎嚭绠$悊", description = "璁粌鏍锋湰鏌ヨ銆佸鍑烘壒娆″拰寰皟浠诲姟")
* 训练数据导出与微调接口5 个端点,全部 ADMIN 权限)。
*/
@Tag(name = "导出管理", description = "训练样本查询、导出批次和微调任务")
@RestController
@RequiredArgsConstructor
public class ExportController {
@@ -28,8 +29,8 @@ public class ExportController {
private final ExportService exportService;
private final FinetuneService finetuneService;
/** GET /api/training/samples 鈥?鍒嗛〉鏌ヨ宸插鎵瑰彲瀵煎嚭鏍锋湰 */
@Operation(summary = "鍒嗛〉鏌ヨ鍙鍑鸿缁冩牱鏈?)
/** GET /api/training/samples — 分页查询已审批可导出样本 */
@Operation(summary = "分页查询可导出训练样本")
@GetMapping("/api/training/samples")
@RequiresRoles("ADMIN")
public Result<PageResult<TrainingDataset>> listSamples(
@@ -41,8 +42,8 @@ public class ExportController {
return Result.success(exportService.listSamples(page, pageSize, sampleType, exported, principal(request)));
}
/** POST /api/export/batch 鈥?鍒涘缓瀵煎嚭鎵规 */
@Operation(summary = "鍒涘缓瀵煎嚭鎵规")
/** POST /api/export/batch — 创建导出批次 */
@Operation(summary = "创建导出批次")
@PostMapping("/api/export/batch")
@RequiresRoles("ADMIN")
@ResponseStatus(HttpStatus.CREATED)
@@ -56,8 +57,8 @@ public class ExportController {
return Result.success(exportService.createBatch(sampleIds, principal(request)));
}
/** POST /api/export/{batchId}/finetune 鈥?鎻愪氦寰皟浠诲姟 */
@Operation(summary = "鎻愪氦寰皟浠诲姟")
/** POST /api/export/{batchId}/finetune — 提交微调任务 */
@Operation(summary = "提交微调任务")
@PostMapping("/api/export/{batchId}/finetune")
@RequiresRoles("ADMIN")
public Result<Map<String, Object>> triggerFinetune(@PathVariable Long batchId,
@@ -65,8 +66,8 @@ public class ExportController {
return Result.success(finetuneService.trigger(batchId, principal(request)));
}
/** GET /api/export/{batchId}/status 鈥?鏌ヨ寰皟鐘舵€?*/
@Operation(summary = "鏌ヨ皟鐘舵?)
/** GET /api/export/{batchId}/status — 查询微调状态 */
@Operation(summary = "查询微调状态")
@GetMapping("/api/export/{batchId}/status")
@RequiresRoles("ADMIN")
public Result<Map<String, Object>> getFinetuneStatus(@PathVariable Long batchId,
@@ -74,8 +75,8 @@ public class ExportController {
return Result.success(finetuneService.getStatus(batchId, principal(request)));
}
/** GET /api/export/list 鈥?鍒嗛〉鏌ヨ瀵煎嚭鎵规鍒楄〃 */
@Operation(summary = "鍒嗛〉鏌ヨ瀵煎嚭鎵规")
/** GET /api/export/list — 分页查询导出批次列表 */
@Operation(summary = "分页查询导出批次")
@GetMapping("/api/export/list")
@RequiresRoles("ADMIN")
public Result<PageResult<ExportBatch>> listBatches(

View File

@@ -1,4 +1,4 @@
package com.label.entity;
package com.label.module.export.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -9,8 +9,9 @@ import java.time.LocalDateTime;
import java.util.UUID;
/**
* 瀵煎嚭鎵规瀹炰綋锛屽?export_batch 琛ㄣ? *
* finetuneStatus 鍙栧硷細NOT_STARTED / RUNNING / COMPLETED / FAILED
* 导出批次实体对应 export_batch
*
* finetuneStatus 取值NOT_STARTED / RUNNING / COMPLETED / FAILED
*/
@Data
@TableName("export_batch")
@@ -19,22 +20,22 @@ public class ExportBatch {
@TableId(type = IdType.AUTO)
private Long id;
/** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/
/** 所属公司(多租户键) */
private Long companyId;
/** 鎵规鍞竴鏍囪瘑锛圲UID锛孌B 榛樿 gen_random_uuid()锛?*/
/** 批次唯一标识UUIDDB 默认 gen_random_uuid() */
private UUID batchUuid;
/** 鏈壒娆℃牱鏈暟閲?*/
/** 本批次样本数量 */
private Integer sampleCount;
/** 瀵煎嚭 JSONL 鐨?RustFS 璺緞 */
/** 导出 JSONL RustFS 路径 */
private String datasetFilePath;
/** GLM fine-tune 浠诲姟 ID锛堟彁浜ゅ井璋冨悗濉啓锛?*/
/** GLM fine-tune 任务 ID提交微调后填写 */
private String glmJobId;
/** 寰皟浠诲姟鐘舵€侊細NOT_STARTED / RUNNING / COMPLETED / FAILED */
/** 微调任务状态:NOT_STARTED / RUNNING / COMPLETED / FAILED */
private String finetuneStatus;
private LocalDateTime createdAt;

View File

@@ -1,22 +1,25 @@
package com.label.mapper;
package com.label.module.export.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.ExportBatch;
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? */
* export_batch Mapper
*/
@Mapper
public interface ExportBatchMapper extends BaseMapper<ExportBatch> {
/**
* 閺囧瓨鏌婂顔跨殶娴犺濮熸穱鈩冧紖閿涘潛lm_job_id + finetune_status閿涘? *
* @param id 閹佃?ID
* @param glmJobId GLM fine-tune 娴犺?ID
* @param finetuneStatus 閺傛壆濮搁幀? * @param companyId 瑜版挸澧犵粔鐔稿煕
* @return 瑜板崬鎼风悰灞炬殶
* 更新微调任务信息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() " +

View File

@@ -7,10 +7,10 @@ import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.storage.RustFsClient;
import com.label.entity.TrainingDataset;
import com.label.mapper.TrainingDatasetMapper;
import com.label.entity.ExportBatch;
import com.label.mapper.ExportBatchMapper;
import com.label.module.annotation.entity.TrainingDataset;
import com.label.module.annotation.mapper.TrainingDatasetMapper;
import com.label.module.export.entity.ExportBatch;
import com.label.module.export.mapper.ExportBatchMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -26,9 +26,15 @@ import java.util.UUID;
import java.util.stream.Collectors;
/**
* 璁粌鏁版嵁瀵煎嚭鏈嶅姟銆? *
* createBatch() 姝ラ锛? * 1. 鏍¢獙 sampleIds 闈炵┖锛圗MPTY_SAMPLES 400锛? * 2. 鏌ヨ training_dataset锛屾牎楠屽叏閮ㄤ负 APPROVED锛圛NVALID_SAMPLES 400锛? * 3. 鐢熸垚 JSONL锛堟瘡琛屼竴涓?glm_format_json锛? * 4. 涓婁紶 RustFS锛坆ucket: finetune-export, key: export/{batchUuid}.jsonl锛? * 5. 鎻掑叆 export_batch 璁板綍
* 6. 鎵归噺鏇存柊 training_dataset.export_batch_id + exported_at
* 训练数据导出服务。
*
* createBatch() 步骤:
* 1. 校验 sampleIds 非空EMPTY_SAMPLES 400
* 2. 查询 training_dataset校验全部为 APPROVEDINVALID_SAMPLES 400
* 3. 生成 JSONL每行一个 glm_format_json
* 4. 上传 RustFSbucket: finetune-export, key: export/{batchUuid}.jsonl
* 5. 插入 export_batch 记录
* 6. 批量更新 training_dataset.export_batch_id + exported_at
*/
@Slf4j
@Service
@@ -41,39 +47,42 @@ public class ExportService {
private final TrainingDatasetMapper datasetMapper;
private final RustFsClient rustFsClient;
// ------------------------------------------------------------------ 鍒涘缓鎵规 --
// ------------------------------------------------------------------ 创建批次 --
/**
* 鍒涘缓瀵煎嚭鎵规銆? *
* @param sampleIds 寰呭鍑虹殑 training_dataset ID 鍒楄〃
* @param principal 褰撳墠鐢ㄦ埛
* @return 鏂板缓鐨?ExportBatch
* 创建导出批次。
*
* @param sampleIds 待导出的 training_dataset ID 列表
* @param principal 当前用户
* @return 新建的 ExportBatch
*/
@Transactional
public ExportBatch createBatch(List<Long> 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<TrainingDataset> samples = datasetMapper.selectList(
new LambdaQueryWrapper<TrainingDataset>()
.in(TrainingDataset::getId, sampleIds)
.eq(TrainingDataset::getCompanyId, principal.getCompanyId()));
// 鏍¢獙鍏ㄩ儴宸插鎵? boolean hasNonApproved = samples.stream()
// 校验全部已审批
boolean hasNonApproved = samples.stream()
.anyMatch(s -> !"APPROVED".equals(s.getStatus()));
if (hasNonApproved || samples.size() != sampleIds.size()) {
throw new BusinessException("INVALID_SAMPLES",
"閮ㄥ垎鏍锋湰涓嶅浜?APPROVED 鐘舵€佹垨涓嶅睘浜庡綋鍓嶇鎴?, HttpStatus.BAD_REQUEST);
"部分样本不处于 APPROVED 状态或不属于当前租户", HttpStatus.BAD_REQUEST);
}
// 鐢熸垚 JSONL锛堟瘡琛屼竴涓?JSON 瀵硅薄锛? String jsonl = samples.stream()
// 生成 JSONL(每行一个 JSON 对象)
String jsonl = samples.stream()
.map(TrainingDataset::getGlmFormatJson)
.collect(Collectors.joining("\n"));
byte[] jsonlBytes = jsonl.getBytes(StandardCharsets.UTF_8);
// 鐢熸垚鍞竴鎵规 UUID锛屼笂浼?RustFS
// 生成唯一批次 UUID上传 RustFS
UUID batchUuid = UUID.randomUUID();
String filePath = "export/" + batchUuid + ".jsonl";
@@ -81,7 +90,8 @@ public class ExportService {
new ByteArrayInputStream(jsonlBytes), jsonlBytes.length,
"application/jsonl");
// 鎻掑叆 export_batch 璁板綍锛堣嫢 DB 鍐欏叆澶辫触锛屽皾璇曟竻鐞?RustFS 瀛ゅ効鏂囦欢锛? ExportBatch batch = new ExportBatch();
// 插入 export_batch 记录(若 DB 写入失败,尝试清理 RustFS 孤儿文件)
ExportBatch batch = new ExportBatch();
batch.setCompanyId(principal.getCompanyId());
batch.setBatchUuid(batchUuid);
batch.setSampleCount(samples.size());
@@ -90,30 +100,32 @@ public class ExportService {
try {
exportBatchMapper.insert(batch);
} catch (Exception e) {
// DB 鎻掑叆澶辫触锛氬皾璇曞垹闄ゅ凡涓婁紶鐨?RustFS 鏂囦欢锛岄槻姝骇鐢熷鍎挎枃浠? try {
// DB 插入失败:尝试删除已上传的 RustFS 文件,防止产生孤儿文件
try {
rustFsClient.delete(EXPORT_BUCKET, filePath);
} catch (Exception deleteEx) {
log.error("DB 鍐欏叆澶辫触鍚庢竻鐞?RustFS 鏂囦欢浜け璐ワ紝瀛ゅ効鏂囦欢: {}/{}", EXPORT_BUCKET, filePath, deleteEx);
log.error("DB 写入失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", EXPORT_BUCKET, filePath, deleteEx);
}
throw e;
}
// 鎵归噺鏇存柊 training_dataset.export_batch_id + exported_at
// 批量更新 training_dataset.export_batch_id + exported_at
datasetMapper.update(null, new LambdaUpdateWrapper<TrainingDataset>()
.in(TrainingDataset::getId, sampleIds)
.set(TrainingDataset::getExportBatchId, batch.getId())
.set(TrainingDataset::getExportedAt, LocalDateTime.now())
.set(TrainingDataset::getUpdatedAt, LocalDateTime.now()));
log.info("瀵煎嚭鎵规宸插垱寤? batchId={}, sampleCount={}, path={}",
log.info("导出批次已创建: batchId={}, sampleCount={}, path={}",
batch.getId(), samples.size(), filePath);
return batch;
}
// ------------------------------------------------------------------ 鏌ヨ鏍锋湰 --
// ------------------------------------------------------------------ 查询样本 --
/**
* 鍒嗛〉鏌ヨ宸插鎵广€佸彲瀵煎嚭鐨勮缁冩牱鏈€? */
* 分页查询已审批、可导出的训练样本。
*/
public PageResult<TrainingDataset> listSamples(int page, int pageSize,
String sampleType, Boolean exported,
TokenPrincipal principal) {
@@ -138,10 +150,11 @@ public class ExportService {
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
}
// ------------------------------------------------------------------ 鏌ヨ鎵规鍒楄〃 --
// ------------------------------------------------------------------ 查询批次列表 --
/**
* 鍒嗛〉鏌ヨ瀵煎嚭鎵规銆? */
* 分页查询导出批次。
*/
public PageResult<ExportBatch> listBatches(int page, int pageSize, TokenPrincipal principal) {
pageSize = Math.min(pageSize, 100);
Page<ExportBatch> result = exportBatchMapper.selectPage(
@@ -152,12 +165,12 @@ public class ExportService {
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
}
// ------------------------------------------------------------------ 鏌ヨ鎵规 --
// ------------------------------------------------------------------ 查询批次 --
public ExportBatch getById(Long batchId, TokenPrincipal principal) {
ExportBatch batch = exportBatchMapper.selectById(batchId);
if (batch == null || !batch.getCompanyId().equals(principal.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "瀵煎嚭鎵规涓嶅瓨鍦? " + batchId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "导出批次不存在: " + batchId, HttpStatus.NOT_FOUND);
}
return batch;
}

View File

@@ -3,8 +3,8 @@ package com.label.module.export.service;
import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.ExportBatch;
import com.label.mapper.ExportBatchMapper;
import com.label.module.export.entity.ExportBatch;
import com.label.module.export.mapper.ExportBatchMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -14,8 +14,11 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
/**
* GLM 寰皟鏈嶅姟锛氭彁浜や换鍔°€佹煡璇㈢姸鎬併€? *
* 娉ㄦ剰锛歵rigger() 鍖呭惈 AI HTTP 璋冪敤锛屼笉鍦?@Transactional 娉ㄨВ涓嬨€? * 浠呭湪 DB 鍐欏叆鏃跺紑鍚簨鍔★紙updateFinetuneInfo锛夈€? */
* GLM 微调服务:提交任务、查询状态。
*
* 注意trigger() 包含 AI HTTP 调用,不在 @Transactional 注解下。
* 仅在 DB 写入时开启事务updateFinetuneInfo
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -25,24 +28,29 @@ public class FinetuneService {
private final ExportService exportService;
private final AiServiceClient aiServiceClient;
// ------------------------------------------------------------------ 鎻愪氦寰皟 --
// ------------------------------------------------------------------ 提交微调 --
/**
* 鍚?GLM AI 鏈嶅姟鎻愪氦寰皟浠诲姟銆? *
* T074 璁捐锛欰I 璋冪敤涓嶅湪 @Transactional 鍐呮墽琛岋紝閬垮厤鎸佹湁 DB 杩炴帴鏈熼棿鍙戣捣 HTTP 璇锋眰銆? * DB 鍐欏叆锛坲pdateFinetuneInfo锛夋槸鍗曟潯 UPDATE锛屼笉闇€瑕佹樉寮忎簨鍔★紙鑷姩鎻愪氦锛夈€? * 濡傛灉 AI 璋冪敤鎴愬姛浣?DB 鍐欏叆澶辫触锛屼笅娆℃煡璇㈢姸鎬佷粛鍙€氳繃 AI 鏈嶅姟鐨?jobId 閲嶅缓鐘舵€併€? *
* @param batchId 鎵规 ID
* @param principal 褰撳墠鐢ㄦ埛
* @return 鍖呭惈 glmJobId 鍜?finetuneStatus 鐨?Map
* GLM AI 服务提交微调任务。
*
* T074 设计AI 调用不在 @Transactional 内执行,避免持有 DB 连接期间发起 HTTP 请求。
* DB 写入updateFinetuneInfo是单条 UPDATE不需要显式事务自动提交
* 如果 AI 调用成功但 DB 写入失败,下次查询状态仍可通过 AI 服务的 jobId 重建状态。
*
* @param batchId 批次 ID
* @param principal 当前用户
* @return 包含 glmJobId 和 finetuneStatus 的 Map
*/
public Map<String, Object> trigger(Long batchId, TokenPrincipal principal) {
ExportBatch batch = exportService.getById(batchId, principal);
if (!"NOT_STARTED".equals(batch.getFinetuneStatus())) {
throw new BusinessException("FINETUNE_ALREADY_STARTED",
"寰皟浠诲姟宸叉彁浜わ紝褰撳墠鐘舵€? " + batch.getFinetuneStatus(), HttpStatus.CONFLICT);
"微调任务已提交,当前状态: " + batch.getFinetuneStatus(), HttpStatus.CONFLICT);
}
// 璋冪敤 AI 鏈嶅姟锛堟棤浜嬪姟锛屼笉鎸佹湁 DB 杩炴帴锛? AiServiceClient.FinetuneRequest req = AiServiceClient.FinetuneRequest.builder()
// 调用 AI 服务(无事务,不持有 DB 连接)
AiServiceClient.FinetuneRequest req = AiServiceClient.FinetuneRequest.builder()
.datasetPath(batch.getDatasetFilePath())
.model("glm-4")
.batchId(batchId)
@@ -53,14 +61,14 @@ public class FinetuneService {
response = aiServiceClient.startFinetune(req);
} catch (Exception e) {
throw new BusinessException("FINETUNE_TRIGGER_FAILED",
"鎻愪氦寰皟浠诲姟澶辫触: " + e.getMessage(), HttpStatus.SERVICE_UNAVAILABLE);
"提交微调任务失败: " + e.getMessage(), HttpStatus.SERVICE_UNAVAILABLE);
}
// AI 璋冪敤鎴愬姛鍚庢洿鏂版壒娆¤褰曪紙鍗曟潯 UPDATE锛岃嚜鍔ㄦ彁浜わ級
// AI 调用成功后更新批次记录(单条 UPDATE自动提交
exportBatchMapper.updateFinetuneInfo(batchId,
response.getJobId(), "RUNNING", principal.getCompanyId());
log.info("寰皟浠诲姟宸叉彁浜? batchId={}, glmJobId={}", batchId, response.getJobId());
log.info("微调任务已提交: batchId={}, glmJobId={}", batchId, response.getJobId());
return Map.of(
"glmJobId", response.getJobId(),
@@ -68,13 +76,14 @@ public class FinetuneService {
);
}
// ------------------------------------------------------------------ 鏌ヨ鐘舵€?--
// ------------------------------------------------------------------ 查询状态 --
/**
* 鏌ヨ寰皟浠诲姟瀹炴椂鐘舵€侊紙鍚?AI 鏈嶅姟鏌ヨ锛夈€? *
* @param batchId 鎵规 ID
* @param principal 褰撳墠鐢ㄦ埛
* @return 鐘舵€?Map
* 查询微调任务实时状态(向 AI 服务查询)。
*
* @param batchId 批次 ID
* @param principal 当前用户
* @return 状态 Map
*/
public Map<String, Object> getStatus(Long batchId, TokenPrincipal principal) {
ExportBatch batch = exportService.getById(batchId, principal);
@@ -89,18 +98,19 @@ public class FinetuneService {
);
}
// 鍚?AI 鏈嶅姟瀹炴椂鏌ヨ
// AI 服务实时查询
AiServiceClient.FinetuneStatusResponse statusResp;
try {
statusResp = aiServiceClient.getFinetuneStatus(batch.getGlmJobId());
} catch (Exception e) {
log.warn("鏌ヨ寰皟鐘舵€佸け璐ワ紙batchId={}锛夛細{}", batchId, e.getMessage());
// 鏌ヨ澶辫触鏃惰繑鍥?DB 涓殑缂撳瓨鐘舵€? return Map.of(
log.warn("查询微调状态失败(batchId={}{}", batchId, e.getMessage());
// 查询失败时返回 DB 中的缓存状态
return Map.of(
"batchId", batchId,
"glmJobId", batch.getGlmJobId(),
"finetuneStatus", batch.getFinetuneStatus(),
"progress", 0,
"errorMessage", "AI 鏈嶅姟鏌ヨ澶辫触: " + e.getMessage()
"errorMessage", "AI 服务查询失败: " + e.getMessage()
);
}

View File

@@ -3,7 +3,7 @@ package com.label.module.source.controller;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.SourceResponse;
import com.label.module.source.dto.SourceResponse;
import com.label.module.source.service.SourceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,10 +15,13 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 鍘熷璧勬枡绠$悊鎺ュ彛銆? *
* 鏉冮檺璁捐锛? * - 涓婁紶 / 鍒楄〃 / 璇︽儏锛歎PLOADER 鍙婁互涓婅鑹诧紙鍚?ANNOTATOR銆丷EVIEWER銆丄DMIN锛? * - 鍒犻櫎锛氫粎 ADMIN
* 原始资料管理接口。
*
* 权限设计:
* - 上传 / 列表 / 详情UPLOADER 及以上角色(含 ANNOTATOR、REVIEWER、ADMIN
* - 删除:仅 ADMIN
*/
@Tag(name = "璧勬枡绠$悊", description = "鍘熷璧勬枡涓婁紶銆佹煡璇㈠拰鍒犻櫎")
@Tag(name = "资料管理", description = "原始资料上传、查询和删除")
@RestController
@RequestMapping("/api/source")
@RequiredArgsConstructor
@@ -27,8 +30,10 @@ public class SourceController {
private final SourceService sourceService;
/**
* 涓婁紶鏂囦欢锛坢ultipart/form-data锛夈€? * 杩斿洖 201 Created + 璧勬枡鎽樿銆? */
@Operation(summary = "涓婁紶鍘熷璧勬枡", description = "dataType: text,image, video")
* 上传文件multipart/form-data)。
* 返回 201 Created + 资料摘要。
*/
@Operation(summary = "上传原始资料", description = "dataType: text,image, video")
@PostMapping("/upload")
@RequiresRoles("UPLOADER")
@ResponseStatus(HttpStatus.CREATED)
@@ -41,8 +46,10 @@ public class SourceController {
}
/**
* 鍒嗛〉鏌ヨ璧勬枡鍒楄〃銆? * UPLOADER 鍙鑷繁鐨勮祫鏂欙紱ADMIN 瑙佸叏鍏徃璧勬枡銆? */
@Operation(summary = "鍒嗛〉鏌ヨ璧勬枡鍒楄〃")
* 分页查询资料列表。
* UPLOADER 只见自己的资料ADMIN 见全公司资料。
*/
@Operation(summary = "分页查询资料列表")
@GetMapping("/list")
@RequiresRoles("UPLOADER")
public Result<PageResult<SourceResponse>> list(
@@ -56,8 +63,9 @@ public class SourceController {
}
/**
* 鏌ヨ璧勬枡璇︽儏锛堝惈 15 鍒嗛挓棰勭鍚嶄笅杞介摼鎺ワ級銆? */
@Operation(summary = "鏌ヨ璧勬枡璇︽儏")
* 查询资料详情(含 15 分钟预签名下载链接)。
*/
@Operation(summary = "查询资料详情")
@GetMapping("/{id}")
@RequiresRoles("UPLOADER")
public Result<SourceResponse> findById(@PathVariable Long id) {
@@ -65,8 +73,10 @@ public class SourceController {
}
/**
* 鍒犻櫎璧勬枡锛堜粎 PENDING 鐘舵€佸彲鍒狅級銆? * 鍚屾鍒犻櫎 RustFS 鏂囦欢鍙?DB 璁板綍銆? */
@Operation(summary = "鍒犻櫎璧勬枡")
* 删除资料(仅 PENDING 状态可删)。
* 同步删除 RustFS 文件及 DB 记录。
*/
@Operation(summary = "删除资料")
@DeleteMapping("/{id}")
@RequiresRoles("ADMIN")
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {

View File

@@ -0,0 +1,38 @@
package com.label.module.source.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 资料接口统一响应体(上传、列表、详情均复用此类)。
* 各端点按需填充字段,未填充字段序列化时因 jackson non_null 配置自动省略。
*/
@Data
@Builder
@Schema(description = "原始资料响应")
public class SourceResponse {
@Schema(description = "资料主键")
private Long id;
@Schema(description = "文件名")
private String fileName;
@Schema(description = "资料类型", example = "TEXT")
private String dataType;
@Schema(description = "文件大小(字节)")
private Long fileSize;
@Schema(description = "资料状态", example = "PENDING")
private String status;
/** 上传用户 ID列表端点返回 */
@Schema(description = "上传用户 ID")
private Long uploaderId;
/** 15 分钟预签名下载链接(详情端点返回) */
@Schema(description = "预签名下载链接")
private String presignedUrl;
/** 父资料 ID视频帧 / 文本片段;详情端点返回) */
@Schema(description = "父资料 ID")
private Long parentSourceId;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,56 @@
package com.label.module.source.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 原始资料实体,对应 source_data 表。
*
* dataType 取值TEXT / IMAGE / VIDEO
* status 取值PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED
*/
@Data
@TableName("source_data")
public class SourceData {
@TableId(type = IdType.AUTO)
private Long id;
/** 所属公司(多租户键) */
private Long companyId;
/** 上传用户 ID */
private Long uploaderId;
/** 资料类型TEXT / IMAGE / VIDEO */
private String dataType;
/** RustFS 对象路径 */
private String filePath;
/** 原始文件名 */
private String fileName;
/** 文件大小(字节) */
private Long fileSize;
/** RustFS Bucket 名称 */
private String bucketName;
/** 父资料 ID视频帧或文本片段的自引用外键 */
private Long parentSourceId;
/** 流水线状态PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED */
private String status;
/** 保留字段(当前无 REJECTED 状态) */
private String rejectReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -1,21 +1,24 @@
package com.label.mapper;
package com.label.module.source.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.SourceData;
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? */
* source_data Mapper
*/
@Mapper
public interface SourceDataMapper extends BaseMapper<SourceData> {
/**
* ?ID 閺囧瓨鏌婄挧鍕灐閻樿埖鈧緤绱欑敮?company_id 缁夌喐鍩涢梾鏃傤瀲閿涘? *
* @param id 鐠у嫭鏋?ID
* @param status 閺傛壆濮搁幀? * @param companyId 瑜版挸澧犵粔鐔稿煕
* @return 瑜板崬鎼风悰灞炬殶閿? 鐞涖劎銇氱拋鏉跨秿娑撳秴鐡ㄩ崷銊灗娑撳秴鐫樻禍搴秼閸撳秶顫ら幋鍑ょ礆
* 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}")

View File

@@ -7,9 +7,9 @@ import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.storage.RustFsClient;
import com.label.dto.SourceResponse;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.module.source.dto.SourceResponse;
import com.label.module.source.entity.SourceData;
import com.label.module.source.mapper.SourceDataMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -24,8 +24,11 @@ import java.util.Set;
import java.util.stream.Collectors;
/**
* 鍘熷璧勬枡涓氬姟鏈嶅姟銆? *
* 涓婁紶娴佺▼锛氬厛 INSERT 鑾峰彇 ID 鈫?鏋勯€?RustFS 璺緞 鈫?涓婁紶鏂囦欢 鈫?UPDATE filePath銆? * 鍒犻櫎瑙勫垯锛氫粎 PENDING 鐘舵€佸彲鍒狅紙闃叉鍒犻櫎宸茶繘鍏ユ爣娉ㄦ祦姘寸嚎鐨勮祫鏂欙級銆? */
* 原始资料业务服务。
*
* 上传流程:先 INSERT 获取 ID → 构造 RustFS 路径 → 上传文件 → UPDATE filePath。
* 删除规则:仅 PENDING 状态可删(防止删除已进入标注流水线的资料)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -40,25 +43,30 @@ public class SourceService {
@Value("${rustfs.bucket:label-source-data}")
private String bucket;
// ------------------------------------------------------------------ 涓婁紶 --
// ------------------------------------------------------------------ 上传 --
/**
* 涓婁紶鏂囦欢骞跺垱寤?source_data 璁板綍銆? *
* @param file 涓婁紶鐨勬枃浠? * @param dataType 璧勬枡绫诲瀷锛圱EXT / IMAGE / VIDEO锛? * @param principal 褰撳墠鐧诲綍鐢ㄦ埛
* @return 鍒涘缓鎴愬姛鐨勮祫鏂欐憳瑕? */
* 上传文件并创建 source_data 记录。
*
* @param file 上传的文件
* @param dataType 资料类型TEXT / IMAGE / VIDEO
* @param principal 当前登录用户
* @return 创建成功的资料摘要
*/
@Transactional
public SourceResponse upload(MultipartFile file, String dataType, TokenPrincipal principal) {
if (file == null || file.isEmpty()) {
throw new BusinessException("FILE_EMPTY", "涓婁紶鏂囦欢涓嶈兘涓虹┖", HttpStatus.BAD_REQUEST);
throw new BusinessException("FILE_EMPTY", "上传文件不能为空", HttpStatus.BAD_REQUEST);
}
if (!VALID_DATA_TYPES.contains(dataType)) {
throw new BusinessException("INVALID_TYPE", "涓嶆敮鎸佺殑璧勬枡绫诲瀷: " + dataType, HttpStatus.BAD_REQUEST);
throw new BusinessException("INVALID_TYPE", "不支持的资料类型: " + dataType, HttpStatus.BAD_REQUEST);
}
// 鎻愬彇绾枃浠跺悕锛岄槻姝㈣矾寰勯亶鍘嗭紙濡?../../admin/secret.txt锛? String rawName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown";
// 提取纯文件名,防止路径遍历(如 ../../admin/secret.txt
String rawName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown";
String originalName = java.nio.file.Paths.get(rawName).getFileName().toString();
// 1. 鍏堟彃鍏ュ崰浣嶈褰曪紝鎷垮埌鑷 ID
// 1. 先插入占位记录,拿到自增 ID
SourceData source = new SourceData();
source.setCompanyId(principal.getCompanyId());
source.setUploaderId(principal.getUserId());
@@ -66,23 +74,25 @@ public class SourceService {
source.setFileName(originalName);
source.setFileSize(file.getSize());
source.setBucketName(bucket);
source.setFilePath(""); // 鍗犱綅锛屽悗闈㈡洿鏂? source.setStatus("PENDING");
source.setFilePath(""); // 占位,后面更新
source.setStatus("PENDING");
sourceDataMapper.insert(source);
// 2. 鏋勯€?RustFS 瀵硅薄璺緞
// 2. 构造 RustFS 对象路径
String objectKey = String.format("%d/%s/%d/%s",
principal.getCompanyId(), dataType.toLowerCase(), source.getId(), originalName);
// 3. 涓婁紶鏂囦欢鍒?RustFS
// 3. 上传文件到 RustFS
try {
rustFsClient.upload(bucket, objectKey, file.getInputStream(),
file.getSize(), file.getContentType());
} catch (IOException e) {
log.error("鏂囦欢涓婁紶鍒?RustFS 澶辫触: bucket={}, key={}", bucket, objectKey, e);
throw new BusinessException("UPLOAD_FAILED", "鏂囦欢涓婁紶澶辫触锛岃閲嶈瘯", HttpStatus.INTERNAL_SERVER_ERROR);
log.error("文件上传到 RustFS 失败: bucket={}, key={}", bucket, objectKey, e);
throw new BusinessException("UPLOAD_FAILED", "文件上传失败,请重试", HttpStatus.INTERNAL_SERVER_ERROR);
}
// 4. 鏇存柊 filePath锛堣嫢澶辫触鍒欐竻鐞?RustFS 瀛ゅ効鏂囦欢锛? try {
// 4. 更新 filePath(若失败则清理 RustFS 孤儿文件)
try {
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
.eq(SourceData::getId, source.getId())
.set(SourceData::getFilePath, objectKey));
@@ -90,19 +100,21 @@ public class SourceService {
try {
rustFsClient.delete(bucket, objectKey);
} catch (Exception deleteEx) {
log.error("DB 鏇存柊澶辫触鍚庢竻鐞?RustFS 鏂囦欢浜﹀け璐ワ紝瀛ゅ効鏂囦欢: {}/{}", bucket, objectKey, deleteEx);
log.error("DB 更新失败后清理 RustFS 文件亦失败,孤儿文件: {}/{}", bucket, objectKey, deleteEx);
}
throw e;
}
log.info("璧勬枡涓婁紶鎴愬姛: id={}, key={}", source.getId(), objectKey);
log.info("资料上传成功: id={}, key={}", source.getId(), objectKey);
return toUploadResponse(source, objectKey);
}
// ------------------------------------------------------------------ 鍒楄〃 --
// ------------------------------------------------------------------ 列表 --
/**
* 鍒嗛〉鏌ヨ璧勬枡鍒楄〃銆? * UPLOADER 鍙鑷繁涓婁紶鐨勮祫鏂欙紱ADMIN 瑙佹湰鍏徃鍏ㄩ儴璧勬枡锛堝绉熸埛鑷姩杩囨护锛夈€? */
* 分页查询资料列表。
* UPLOADER 只见自己上传的资料ADMIN 见本公司全部资料(多租户自动过滤)。
*/
public PageResult<SourceResponse> list(int page, int pageSize,
String dataType, String status,
TokenPrincipal principal) {
@@ -111,7 +123,7 @@ public class SourceService {
LambdaQueryWrapper<SourceData> wrapper = new LambdaQueryWrapper<SourceData>()
.orderByDesc(SourceData::getCreatedAt);
// UPLOADER 鍙兘鏌ヨ嚜宸辩殑璧勬枡
// UPLOADER 只能查自己的资料
if ("UPLOADER".equals(principal.getRole())) {
wrapper.eq(SourceData::getUploaderId, principal.getUserId());
}
@@ -131,14 +143,15 @@ public class SourceService {
return PageResult.of(items, pageResult.getTotal(), page, pageSize);
}
// ------------------------------------------------------------------ 璇︽儏 --
// ------------------------------------------------------------------ 详情 --
/**
* 鎸?ID 鏌ヨ璧勬枡璇︽儏锛屽惈 15 鍒嗛挓棰勭鍚嶄笅杞介摼鎺ャ€? */
* ID 查询资料详情,含 15 分钟预签名下载链接。
*/
public SourceResponse findById(Long id) {
SourceData source = sourceDataMapper.selectById(id);
if (source == null) {
throw new BusinessException("NOT_FOUND", "璧勬枡涓嶅瓨鍦?, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND);
}
String presignedUrl = null;
@@ -158,37 +171,40 @@ public class SourceService {
.build();
}
// ------------------------------------------------------------------ 鍒犻櫎 --
// ------------------------------------------------------------------ 删除 --
/**
* 鍒犻櫎璧勬枡锛氫粎 PENDING 鐘舵€佸彲鍒狅紝鍚屾鍒犻櫎 RustFS 鏂囦欢銆? *
* @throws BusinessException SOURCE_IN_PIPELINE(409) 璧勬枡宸茶繘鍏ユ爣娉ㄦ祦绋? */
* 删除资料:仅 PENDING 状态可删,同步删除 RustFS 文件。
*
* @throws BusinessException SOURCE_IN_PIPELINE(409) 资料已进入标注流程
*/
@Transactional
public void delete(Long id, Long companyId) {
SourceData source = sourceDataMapper.selectById(id);
if (source == null) {
throw new BusinessException("NOT_FOUND", "璧勬枡涓嶅瓨鍦?, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "资料不存在", HttpStatus.NOT_FOUND);
}
if (!"PENDING".equals(source.getStatus())) {
throw new BusinessException("SOURCE_IN_PIPELINE",
"璧勬枡宸茶繘鍏ユ爣娉ㄦ祦绋嬶紝涓嶅彲鍒犻櫎锛堝綋鍓嶇姸鎬侊細" + source.getStatus() + "锛?,
"资料已进入标注流程,不可删除(当前状态:" + source.getStatus() + "",
HttpStatus.CONFLICT);
}
// 鍏堝垹 RustFS 鏂囦欢锛堝箓绛夛紝涓嶆姏寮傚父锛? if (source.getFilePath() != null && !source.getFilePath().isBlank()) {
// 先删 RustFS 文件(幂等,不抛异常)
if (source.getFilePath() != null && !source.getFilePath().isBlank()) {
try {
rustFsClient.delete(bucket, source.getFilePath());
} catch (Exception e) {
log.warn("RustFS 鏂囦欢鍒犻櫎澶辫触锛堢户缁 DB 璁板綍锛? bucket={}, key={}", bucket, source.getFilePath(), e);
log.warn("RustFS 文件删除失败(继续删 DB 记录): bucket={}, key={}", bucket, source.getFilePath(), e);
}
}
sourceDataMapper.deleteById(id);
log.info("璧勬枡鍒犻櫎鎴愬姛: id={}", id);
log.info("资料删除成功: id={}", id);
}
// ------------------------------------------------------------------ 绉佹湁宸ュ叿 --
// ------------------------------------------------------------------ 私有工具 --
private SourceResponse toUploadResponse(SourceData source, String filePath) {
return SourceResponse.builder()

View File

@@ -3,7 +3,7 @@ package com.label.module.task.controller;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.TaskResponse;
import com.label.module.task.dto.TaskResponse;
import com.label.module.task.service.TaskClaimService;
import com.label.module.task.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
@@ -16,8 +16,9 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 浠诲姟绠$悊鎺ュ彛锛?0 涓鐐癸級銆? */
@Tag(name = "浠诲姟绠$悊", description = "浠诲姟姹犮€佹垜鐨勪换鍔°€佸鎵归槦鍒楀拰绠$悊鎿嶄綔")
* 任务管理接口10 个端点)。
*/
@Tag(name = "任务管理", description = "任务池、我的任务、审批队列和管理操作")
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
@@ -26,8 +27,8 @@ public class TaskController {
private final TaskService taskService;
private final TaskClaimService taskClaimService;
/** GET /api/tasks/pool 鈥?鏌ヨ鍙鍙栦换鍔℃睜锛堣鑹叉劅鐭ワ級 */
@Operation(summary = "鏌ヨ鍙鍙栦换鍔℃睜")
/** GET /api/tasks/pool — 查询可领取任务池(角色感知) */
@Operation(summary = "查询可领取任务池")
@GetMapping("/pool")
@RequiresRoles("ANNOTATOR")
public Result<PageResult<TaskResponse>> getPool(
@@ -37,8 +38,8 @@ public class TaskController {
return Result.success(taskService.getPool(page, pageSize, principal(request)));
}
/** GET /api/tasks/mine 鈥?鏌ヨ鎴戠殑浠诲姟 */
@Operation(summary = "鏌ヨ鎴戠殑浠诲姟")
/** GET /api/tasks/mine — 查询我的任务 */
@Operation(summary = "查询我的任务")
@GetMapping("/mine")
@RequiresRoles("ANNOTATOR")
public Result<PageResult<TaskResponse>> getMine(
@@ -49,8 +50,8 @@ public class TaskController {
return Result.success(taskService.getMine(page, pageSize, status, principal(request)));
}
/** GET /api/tasks/pending-review 鈥?寰呭鎵归槦鍒楋紙REVIEWER 涓撳睘锛?*/
@Operation(summary = "鏌ヨ寰呭鎵逛换鍔?)
/** GET /api/tasks/pending-review — 待审批队列(REVIEWER 专属) */
@Operation(summary = "查询待审批任务")
@GetMapping("/pending-review")
@RequiresRoles("REVIEWER")
public Result<PageResult<TaskResponse>> getPendingReview(
@@ -60,8 +61,8 @@ public class TaskController {
return Result.success(taskService.getPendingReview(page, pageSize, taskType));
}
/** GET /api/tasks 鈥?鏌ヨ鍏ㄩ儴浠诲姟锛圓DMIN锛?*/
@Operation(summary = "悊鍛樻煡璇叏閮ㄤ换鍔?)
/** GET /api/tasks — 查询全部任务ADMIN */
@Operation(summary = "管理员查询全部任务")
@GetMapping
@RequiresRoles("ADMIN")
public Result<PageResult<TaskResponse>> getAll(
@@ -72,8 +73,8 @@ public class TaskController {
return Result.success(taskService.getAll(page, pageSize, status, taskType));
}
/** POST /api/tasks 鈥?鍒涘缓浠诲姟锛圓DMIN锛?*/
@Operation(summary = "绠$悊鍛樺垱寤轰换鍔?)
/** POST /api/tasks — 创建任务ADMIN */
@Operation(summary = "管理员创建任务")
@PostMapping
@RequiresRoles("ADMIN")
public Result<TaskResponse> createTask(@RequestBody Map<String, Object> body,
@@ -85,16 +86,16 @@ public class TaskController {
taskService.createTask(sourceId, taskType, principal.getCompanyId())));
}
/** GET /api/tasks/{id} 鈥?鏌ヨ浠诲姟璇︽儏 */
@Operation(summary = "鏌ヨ浠诲姟璇")
/** GET /api/tasks/{id} — 查询任务详情 */
@Operation(summary = "查询任务详情")
@GetMapping("/{id}")
@RequiresRoles("ANNOTATOR")
public Result<TaskResponse> 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<Void> claim(@PathVariable Long id, HttpServletRequest request) {
@@ -102,8 +103,8 @@ public class TaskController {
return Result.success(null);
}
/** POST /api/tasks/{id}/unclaim 鈥?鏀惧純浠诲姟 */
@Operation(summary = "鏀惧純浠诲姟")
/** POST /api/tasks/{id}/unclaim — 放弃任务 */
@Operation(summary = "放弃任务")
@PostMapping("/{id}/unclaim")
@RequiresRoles("ANNOTATOR")
public Result<Void> unclaim(@PathVariable Long id, HttpServletRequest request) {
@@ -111,8 +112,8 @@ public class TaskController {
return Result.success(null);
}
/** POST /api/tasks/{id}/reclaim 鈥?閲嶉琚┏鍥炵殑浠诲姟 */
@Operation(summary = "閲嶉鍥炵殑浠诲姟")
/** POST /api/tasks/{id}/reclaim — 重领被驳回的任务 */
@Operation(summary = "重领被驳回的任务")
@PostMapping("/{id}/reclaim")
@RequiresRoles("ANNOTATOR")
public Result<Void> reclaim(@PathVariable Long id, HttpServletRequest request) {
@@ -120,8 +121,8 @@ public class TaskController {
return Result.success(null);
}
/** PUT /api/tasks/{id}/reassign 鈥?ADMIN 寮哄埗鎸囨淳 */
@Operation(summary = "悊鍛樺己鍒舵寚娲句换鍔?)
/** PUT /api/tasks/{id}/reassign ADMIN 强制指派 */
@Operation(summary = "管理员强制指派任务")
@PutMapping("/{id}/reassign")
@RequiresRoles("ADMIN")
public Result<Void> reassign(@PathVariable Long id,

View File

@@ -0,0 +1,38 @@
package com.label.module.task.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 任务接口统一响应体(任务池、我的任务、任务详情均复用)。
*/
@Data
@Builder
@Schema(description = "标注任务响应")
public class TaskResponse {
@Schema(description = "任务主键")
private Long id;
@Schema(description = "关联资料 ID")
private Long sourceId;
/** 任务类型(对应 taskType 字段EXTRACTION / QA_GENERATION */
@Schema(description = "任务类型", example = "EXTRACTION")
private String taskType;
@Schema(description = "任务状态", example = "UNCLAIMED")
private String status;
@Schema(description = "领取人用户 ID")
private Long claimedBy;
@Schema(description = "领取时间")
private LocalDateTime claimedAt;
@Schema(description = "提交时间")
private LocalDateTime submittedAt;
@Schema(description = "完成时间")
private LocalDateTime completedAt;
/** 驳回原因REJECTED 状态时非空) */
@Schema(description = "驳回原因")
private String rejectReason;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,59 @@
package com.label.module.task.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 标注任务实体,对应 annotation_task 表。
*
* taskType 取值EXTRACTION / QA_GENERATION
* status 取值UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED
*/
@Data
@TableName("annotation_task")
public class AnnotationTask {
@TableId(type = IdType.AUTO)
private Long id;
/** 所属公司(多租户键) */
private Long companyId;
/** 关联的原始资料 ID */
private Long sourceId;
/** 任务类型EXTRACTION / QA_GENERATION */
private String taskType;
/** 任务状态 */
private String status;
/** 领取任务的用户 ID */
private Long claimedBy;
/** 领取时间 */
private LocalDateTime claimedAt;
/** 提交时间 */
private LocalDateTime submittedAt;
/** 完成时间APPROVED 时设置) */
private LocalDateTime completedAt;
/** 是否最终结果APPROVED 且无需再审)*/
private Boolean isFinal;
/** 使用的 AI 模型名称 */
private String aiModel;
/** 驳回原因 */
private String rejectReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -1,4 +1,4 @@
package com.label.entity;
package com.label.module.task.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -9,7 +9,8 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 浠诲姟鐘舵佸巻鍙诧紝瀵瑰簲 annotation_task_history 紙浠呰拷鍔狅紝鏃?UPDATE/DELETE锛夈? */
* 任务状态历史对应 annotation_task_history 仅追加 UPDATE/DELETE
*/
@Data
@Builder
@TableName("annotation_task_history")
@@ -20,22 +21,22 @@ public class AnnotationTaskHistory {
private Long taskId;
/** 鎵€灞炲叕鍙革紙澶氱鎴烽敭锛?*/
/** 所属公司(多租户键) */
private Long companyId;
/** 杞崲鍓嶇姸鎬侊紙棣栨鎻掑叆鏃朵负 null锛?*/
/** 转换前状态(首次插入时为 null */
private String fromStatus;
/** 杞崲鍚庣姸鎬?*/
/** 转换后状态 */
private String toStatus;
/** 鎿嶄綔浜?ID */
/** 操作人 ID */
private Long operatorId;
/** 鎿嶄綔浜鸿鑹?*/
/** 操作人角色 */
private String operatorRole;
/** 澶囨敞锛堥┏鍥炲師鍥犵瓑锛?*/
/** 备注(驳回原因等) */
private String comment;
private LocalDateTime createdAt;

View File

@@ -0,0 +1,30 @@
package com.label.module.task.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.task.entity.AnnotationTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* annotation_task 表 Mapper。
*/
@Mapper
public interface AnnotationTaskMapper extends BaseMapper<AnnotationTask> {
/**
* 原子性领取任务:仅当任务为 UNCLAIMED 且属于当前租户时才更新。
* 使用乐观 WHERE 条件实现并发安全(依赖数据库行级锁)。
*
* @param taskId 任务 ID
* @param userId 领取用户 ID
* @param companyId 当前租户
* @return 影响行数0 = 任务已被他人领取或不存在)
*/
@Update("UPDATE annotation_task " +
"SET status = 'IN_PROGRESS', claimed_by = #{userId}, claimed_at = NOW(), updated_at = NOW() " +
"WHERE id = #{taskId} AND status = 'UNCLAIMED' AND company_id = #{companyId}")
int claimTask(@Param("taskId") Long taskId,
@Param("userId") Long userId,
@Param("companyId") Long companyId);
}

View File

@@ -0,0 +1,14 @@
package com.label.module.task.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.task.entity.AnnotationTaskHistory;
import org.apache.ibatis.annotations.Mapper;
/**
* annotation_task_history 表 Mapper仅追加禁止 UPDATE/DELETE
*/
@Mapper
public interface TaskHistoryMapper extends BaseMapper<AnnotationTaskHistory> {
// 继承 BaseMapper 的 insert 用于追加历史记录
// 严禁调用 update/delete 相关方法
}

View File

@@ -7,10 +7,10 @@ import com.label.common.redis.RedisService;
import com.label.common.shiro.TokenPrincipal;
import com.label.common.statemachine.StateValidator;
import com.label.common.statemachine.TaskStatus;
import com.label.entity.AnnotationTask;
import com.label.entity.AnnotationTaskHistory;
import com.label.mapper.AnnotationTaskMapper;
import com.label.mapper.TaskHistoryMapper;
import com.label.module.task.entity.AnnotationTask;
import com.label.module.task.entity.AnnotationTaskHistory;
import com.label.module.task.mapper.AnnotationTaskMapper;
import com.label.module.task.mapper.TaskHistoryMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -18,68 +18,76 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 浠诲姟棰嗗彇/鏀惧純/閲嶉鏈嶅姟銆? *
* 骞跺彂瀹夊叏璁捐锛? * 1. Redis SET NX 浣滀负鍒嗗竷寮忛閿侊紙TTL 30s锛夛紝蹇€熸嫆缁濆苟鍙戣姹? * 2. DB UPDATE WHERE status='UNCLAIMED' 浣滀负鍏滃簳鍘熷瓙鎿嶄綔
* 涓ゅ眰闃叉姢纭繚鍚屼竴浠诲姟鍙湁涓€浜哄彲棰嗗彇
* 任务领取/放弃/重领服务。
*
* 并发安全设计:
* 1. Redis SET NX 作为分布式预锁TTL 30s快速拒绝并发请求
* 2. DB UPDATE WHERE status='UNCLAIMED' 作为兜底原子操作
* 两层防护确保同一任务只有一人可领取
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskClaimService {
/** Redis 鍒嗗竷寮忛攣 TTL锛堢锛?*/
/** Redis 分布式锁 TTL */
private static final long CLAIM_LOCK_TTL = 30L;
private final AnnotationTaskMapper taskMapper;
private final TaskHistoryMapper historyMapper;
private final RedisService redisService;
// ------------------------------------------------------------------ 棰嗗彇 --
// ------------------------------------------------------------------ 领取 --
/**
* 棰嗗彇浠诲姟锛堝弻閲嶉槻鎶わ細Redis NX + DB 鍘熷瓙鏇存柊锛夈€? *
* @param taskId 浠诲姟 ID
* @param principal 褰撳墠鐢ㄦ埛
* @throws BusinessException TASK_CLAIMED(409) 浠诲姟宸茶浠栦汉棰嗗彇
* 领取任务双重防护Redis NX + DB 原子更新)。
*
* @param taskId 任务 ID
* @param principal 当前用户
* @throws BusinessException TASK_CLAIMED(409) 任务已被他人领取
*/
@Transactional
public void claim(Long taskId, TokenPrincipal principal) {
String lockKey = RedisKeyManager.taskClaimKey(taskId);
// 1. Redis SET NX 棰勯攣锛堝揩閫熷け璐ワ級
// 1. Redis SET NX 预锁(快速失败)
boolean lockAcquired = redisService.setIfAbsent(
lockKey, principal.getUserId().toString(), CLAIM_LOCK_TTL);
if (!lockAcquired) {
throw new BusinessException("TASK_CLAIMED", "浠诲姟宸茶浠栦汉棰嗗彇锛岃閫夋嫨鍏朵粬浠诲姟", HttpStatus.CONFLICT);
throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT);
}
try {
// 2. DB 鍘熷瓙鏇存柊锛圵HERE status='UNCLAIMED' 鍏滃簳锛? int affected = taskMapper.claimTask(taskId, principal.getUserId(), principal.getCompanyId());
// 2. DB 原子更新WHERE status='UNCLAIMED' 兜底)
int affected = taskMapper.claimTask(taskId, principal.getUserId(), principal.getCompanyId());
if (affected == 0) {
// DB 鏇存柊澶辫触璇存槑浠诲姟鐘舵€佸凡鍙橈紝娓呴櫎鍒氳缃殑閿? redisService.delete(lockKey);
throw new BusinessException("TASK_CLAIMED", "浠诲姟宸茶浠栦汉棰嗗彇锛岃閫夋嫨鍏朵粬浠诲姟", HttpStatus.CONFLICT);
// DB 更新失败说明任务状态已变,清除刚设置的锁
redisService.delete(lockKey);
throw new BusinessException("TASK_CLAIMED", "任务已被他人领取,请选择其他任务", HttpStatus.CONFLICT);
}
// 3. 鍐欏叆鐘舵€佸巻鍙? insertHistory(taskId, principal.getCompanyId(),
// 3. 写入状态历史
insertHistory(taskId, principal.getCompanyId(),
"UNCLAIMED", "IN_PROGRESS",
principal.getUserId(), principal.getRole(), null);
log.info("浠诲姟棰嗗彇鎴愬姛: taskId={}, userId={}", taskId, principal.getUserId());
log.info("任务领取成功: taskId={}, userId={}", taskId, principal.getUserId());
} catch (BusinessException e) {
throw e; // 涓氬姟寮傚父鐩存帴涓婃姏锛岄攣宸插湪涓婃柟娓呴櫎
throw e; // 业务异常直接上抛,锁已在上方清除
} catch (Exception e) {
// DB 鍐欏叆寮傚父锛堝惈 insertHistory 澶辫触锛夛細娓呴櫎 Redis 閿侊紝浜嬪姟鍥炴粴
// DB 写入异常(含 insertHistory 失败):清除 Redis 锁,事务回滚
redisService.delete(lockKey);
throw e;
}
}
// ------------------------------------------------------------------ 鏀惧純 --
// ------------------------------------------------------------------ 放弃 --
/**
* 鏀惧純浠诲姟锛圛N_PROGRESS 鈫?UNCLAIMED锛夈€? *
* @param taskId 浠诲姟 ID
* @param principal 褰撳墠鐢ㄦ埛
* 放弃任务IN_PROGRESS UNCLAIMED)。
*
* @param taskId 任务 ID
* @param principal 当前用户
*/
@Transactional
public void unclaim(Long taskId, TokenPrincipal principal) {
@@ -95,7 +103,7 @@ public class TaskClaimService {
.set(AnnotationTask::getClaimedBy, null)
.set(AnnotationTask::getClaimedAt, null));
// 娓呴櫎 Redis 鍒嗗竷寮忛攣
// 清除 Redis 分布式锁
redisService.delete(RedisKeyManager.taskClaimKey(taskId));
insertHistory(taskId, principal.getCompanyId(),
@@ -103,12 +111,13 @@ public class TaskClaimService {
principal.getUserId(), principal.getRole(), null);
}
// ------------------------------------------------------------------ 閲嶉 --
// ------------------------------------------------------------------ 重领 --
/**
* 閲嶉浠诲姟锛圧EJECTED 鈫?IN_PROGRESS锛屼粎鍘熼鍙栦汉鍙噸棰嗭級銆? *
* @param taskId 浠诲姟 ID
* @param principal 褰撳墠鐢ㄦ埛
* 重领任务REJECTED IN_PROGRESS,仅原领取人可重领)。
*
* @param taskId 任务 ID
* @param principal 当前用户
*/
@Transactional
public void reclaim(Long taskId, TokenPrincipal principal) {
@@ -117,12 +126,12 @@ public class TaskClaimService {
if (!"REJECTED".equals(task.getStatus())) {
throw new BusinessException("INVALID_STATE_TRANSITION",
"鍙湁 REJECTED 鐘舵€佺殑浠诲姟鍙互閲嶉", HttpStatus.CONFLICT);
"只有 REJECTED 状态的任务可以重领", HttpStatus.CONFLICT);
}
if (!principal.getUserId().equals(task.getClaimedBy())) {
throw new BusinessException("FORBIDDEN",
"鍙湁鍘熼鍙栦汉鍙互閲嶉璇ヤ换鍔?, HttpStatus.FORBIDDEN);
"只有原领取人可以重领该任务", HttpStatus.FORBIDDEN);
}
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
@@ -134,7 +143,8 @@ public class TaskClaimService {
.set(AnnotationTask::getStatus, "IN_PROGRESS")
.set(AnnotationTask::getClaimedAt, java.time.LocalDateTime.now()));
// 閲嶆柊璁剧疆 Redis 閿侊紙闃叉骞跺彂鍐嶆浜夋姠锛? redisService.setIfAbsent(
// 重新设置 Redis 锁(防止并发再次争抢)
redisService.setIfAbsent(
RedisKeyManager.taskClaimKey(taskId),
principal.getUserId().toString(), CLAIM_LOCK_TTL);
@@ -143,16 +153,17 @@ public class TaskClaimService {
principal.getUserId(), principal.getRole(), null);
}
// ------------------------------------------------------------------ 绉佹湁宸ュ叿 --
// ------------------------------------------------------------------ 私有工具 --
private void validateTaskExists(AnnotationTask task, Long taskId) {
if (task == null) {
throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
}
}
/**
* 鍚?annotation_task_history 杩藉姞涓€鏉″巻鍙茶褰曪紙浠?INSERT锛岀姝?UPDATE/DELETE锛夈€? */
* annotation_task_history 追加一条历史记录(仅 INSERT,禁止 UPDATE/DELETE)。
*/
public void insertHistory(Long taskId, Long companyId,
String fromStatus, String toStatus,
Long operatorId, String operatorRole, String comment) {

View File

@@ -6,9 +6,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.TaskResponse;
import com.label.entity.AnnotationTask;
import com.label.mapper.AnnotationTaskMapper;
import com.label.module.task.dto.TaskResponse;
import com.label.module.task.entity.AnnotationTask;
import com.label.module.task.mapper.AnnotationTaskMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -19,7 +19,8 @@ import java.util.List;
import java.util.stream.Collectors;
/**
* 浠诲姟绠$悊鏈嶅姟锛氬垱寤恒€佹煡璇换鍔℃睜銆佹垜鐨勪换鍔°€佸緟瀹℃壒闃熷垪銆佹寚娲俱€? */
* 任务管理服务:创建、查询任务池、我的任务、待审批队列、指派。
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -28,13 +29,16 @@ public class TaskService {
private final AnnotationTaskMapper taskMapper;
private final TaskClaimService taskClaimService;
// ------------------------------------------------------------------ 鍒涘缓 --
// ------------------------------------------------------------------ 创建 --
/**
* 鍒涘缓鏍囨敞浠诲姟锛堝唴閮ㄨ皟鐢紝渚嬪瑙嗛澶勭悊瀹屾垚鍚庯級銆? *
* @param sourceId 璧勬枡 ID
* @param taskType 浠诲姟绫诲瀷锛圗XTRACTION / QA_GENERATION锛? * @param companyId 绉熸埛 ID
* @return 鏂颁换鍔? */
* 创建标注任务(内部调用,例如视频处理完成后)。
*
* @param sourceId 资料 ID
* @param taskType 任务类型EXTRACTION / QA_GENERATION
* @param companyId 租户 ID
* @return 新任务
*/
@Transactional
public AnnotationTask createTask(Long sourceId, String taskType, Long companyId) {
AnnotationTask task = new AnnotationTask();
@@ -44,14 +48,17 @@ public class TaskService {
task.setStatus("UNCLAIMED");
task.setIsFinal(false);
taskMapper.insert(task);
log.info("浠诲姟宸插垱寤? id={}, type={}, sourceId={}", task.getId(), taskType, sourceId);
log.info("任务已创建: id={}, type={}, sourceId={}", task.getId(), taskType, sourceId);
return task;
}
// ------------------------------------------------------------------ 浠诲姟姹?--
// ------------------------------------------------------------------ 任务池 --
/**
* 鏌ヨ浠诲姟姹狅紙鎸夎鑹茶繃婊わ級锛? * - ANNOTATOR 鈫?EXTRACTION 绫诲瀷銆乁NCLAIMED 鐘舵€? * - REVIEWER/ADMIN 鈫?SUBMITTED 鐘舵€侊紙浠绘剰绫诲瀷锛? */
* 查询任务池(按角色过滤):
* - ANNOTATOR → EXTRACTION 类型、UNCLAIMED 状态
* - REVIEWER/ADMIN → SUBMITTED 状态(任意类型)
*/
public PageResult<TaskResponse> getPool(int page, int pageSize, TokenPrincipal principal) {
pageSize = Math.min(pageSize, 100);
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
@@ -62,7 +69,7 @@ public class TaskService {
wrapper.eq(AnnotationTask::getTaskType, "EXTRACTION")
.eq(AnnotationTask::getStatus, "UNCLAIMED");
} else {
// REVIEWER / ADMIN 鐪嬪緟瀹℃壒闃熷垪
// REVIEWER / ADMIN 看待审批队列
wrapper.eq(AnnotationTask::getStatus, "SUBMITTED");
}
@@ -70,10 +77,11 @@ public class TaskService {
return toPageResult(pageResult, page, pageSize);
}
// ------------------------------------------------------------------ 鎴戠殑浠诲姟 --
// ------------------------------------------------------------------ 我的任务 --
/**
* 鏌ヨ褰撳墠鐢ㄦ埛鐨勪换鍔★紙IN_PROGRESS銆丼UBMITTED銆丷EJECTED锛夈€? */
* 查询当前用户的任务(IN_PROGRESS、SUBMITTED、REJECTED)。
*/
public PageResult<TaskResponse> getMine(int page, int pageSize,
String status, TokenPrincipal principal) {
pageSize = Math.min(pageSize, 100);
@@ -90,10 +98,11 @@ public class TaskService {
return toPageResult(pageResult, page, pageSize);
}
// ------------------------------------------------------------------ 寰呭鎵?--
// ------------------------------------------------------------------ 待审批 --
/**
* 鏌ヨ寰呭鎵逛换鍔★紙REVIEWER 涓撳睘锛宻tatus=SUBMITTED锛夈€? */
* 查询待审批任务(REVIEWER 专属status=SUBMITTED)。
*/
public PageResult<TaskResponse> getPendingReview(int page, int pageSize, String taskType) {
pageSize = Math.min(pageSize, 100);
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
@@ -108,20 +117,21 @@ public class TaskService {
return toPageResult(pageResult, page, pageSize);
}
// ------------------------------------------------------------------ 鏌ヨ鍗曟潯 --
// ------------------------------------------------------------------ 查询单条 --
public AnnotationTask getById(Long id) {
AnnotationTask task = taskMapper.selectById(id);
if (task == null) {
throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + id, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "任务不存在: " + id, HttpStatus.NOT_FOUND);
}
return task;
}
// ------------------------------------------------------------------ 鍏ㄩ儴浠诲姟锛圓DMIN锛?-
// ------------------------------------------------------------------ 全部任务ADMIN--
/**
* 鏌ヨ鍏ㄩ儴浠诲姟锛圓DMIN 涓撶敤锛夈€? */
* 查询全部任务ADMIN 专用)。
*/
public PageResult<TaskResponse> getAll(int page, int pageSize, String status, String taskType) {
pageSize = Math.min(pageSize, 100);
LambdaQueryWrapper<AnnotationTask> wrapper = new LambdaQueryWrapper<AnnotationTask>()
@@ -138,15 +148,16 @@ public class TaskService {
return toPageResult(pageResult, page, pageSize);
}
// ------------------------------------------------------------------ 鎸囨淳锛圓DMIN锛?-
// ------------------------------------------------------------------ 指派ADMIN--
/**
* ADMIN 寮哄埗鎸囨淳浠诲姟缁欐寚瀹氱敤鎴凤紙IN_PROGRESS 鈫?IN_PROGRESS锛夈€? */
* ADMIN 强制指派任务给指定用户(IN_PROGRESS IN_PROGRESS)。
*/
@Transactional
public void reassign(Long taskId, Long targetUserId, TokenPrincipal principal) {
AnnotationTask task = taskMapper.selectById(taskId);
if (task == null || !principal.getCompanyId().equals(task.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "浠诲姟涓嶅瓨鍦? " + taskId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
}
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
@@ -157,10 +168,10 @@ public class TaskService {
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
task.getStatus(), "IN_PROGRESS",
principal.getUserId(), principal.getRole(),
"ADMIN 寮哄埗鎸囨淳缁欑敤鎴?" + targetUserId);
"ADMIN 强制指派给用户 " + targetUserId);
}
// ------------------------------------------------------------------ 绉佹湁宸ュ叿 --
// ------------------------------------------------------------------ 私有工具 --
private PageResult<TaskResponse> toPageResult(Page<AnnotationTask> pageResult, int page, int pageSize) {
List<TaskResponse> items = pageResult.getRecords().stream()

View File

@@ -2,9 +2,9 @@ package com.label.module.user.controller;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
import com.label.module.user.dto.LoginRequest;
import com.label.module.user.dto.LoginResponse;
import com.label.module.user.dto.UserInfoResponse;
import com.label.module.user.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -13,9 +13,14 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 璁よ瘉鎺ュ彛锛氱櫥褰曘€侀€€鍑恒€佽幏鍙栧綋鍓嶇敤鎴枫€? *
* 璺敱璁捐锛? * - POST /api/auth/login 鈫?鍖垮悕锛圱okenFilter.shouldNotFilter 璺宠繃锛? * - POST /api/auth/logout 鈫?闇€瑕佹湁鏁?Token锛圱okenFilter 鏍¢獙锛? * - GET /api/auth/me 鈫?闇€瑕佹湁鏁?Token锛圱okenFilter 鏍¢獙锛? */
@Tag(name = "璁よ瘉绠$悊", description = "鐧诲綍銆侀€€鍑哄拰褰撳墠鐢ㄦ埛淇℃伅")
* 认证接口:登录、退出、获取当前用户。
*
* 路由设计:
* - POST /api/auth/login → 匿名TokenFilter.shouldNotFilter 跳过)
* - POST /api/auth/logout → 需要有效 TokenTokenFilter 校验)
* - GET /api/auth/me → 需要有效 TokenTokenFilter 校验)
*/
@Tag(name = "认证管理", description = "登录、退出和当前用户信息")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@@ -24,16 +29,18 @@ public class AuthController {
private final AuthService authService;
/**
* 鐧诲綍鎺ュ彛锛堝尶鍚嶏紝鏃犻渶 Token锛夈€? */
@Operation(summary = "鐢ㄦ埛鐧诲綍锛岃繑鍥?Bearer Token")
* 登录接口(匿名,无需 Token
*/
@Operation(summary = "用户登录,返回 Bearer Token")
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
return Result.success(authService.login(request));
}
/**
* 閫€鍑虹櫥褰曪紝绔嬪嵆鍒犻櫎 Redis Token銆? */
@Operation(summary = "閫€鍑虹櫥褰曞苟绔嬪嵆澶辨晥褰撳墠 Token")
* 退出登录,立即删除 Redis Token
*/
@Operation(summary = "退出登录并立即失效当前 Token")
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
String token = extractToken(request);
@@ -42,15 +49,17 @@ public class AuthController {
}
/**
* 鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛淇℃伅銆? * TokenPrincipal 鐢?TokenFilter 鍐欏叆璇锋眰灞炴€?"__token_principal__"銆? */
@Operation(summary = "鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛淇℃伅")
* 获取当前登录用户信息。
* TokenPrincipal 由 TokenFilter 写入请求属性 "__token_principal__"。
*/
@Operation(summary = "获取当前登录用户信息")
@GetMapping("/me")
public Result<UserInfoResponse> 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 ")) {

View File

@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.SysUser;
import com.label.module.user.entity.SysUser;
import com.label.module.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
@@ -24,8 +24,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
/**
* 鐢ㄦ埛绠$悊鎺ュ彛锛? 涓鐐癸紝鍏ㄩ儴 ADMIN 鏉冮檺锛夈€? */
@Tag(name = "鐢ㄦ埛绠$悊", description = "绠$悊鍛樼淮鎶ゅ叕鍙哥敤鎴?)
* 用户管理接口5 个端点,全部 ADMIN 权限)。
*/
@Tag(name = "用户管理", description = "管理员维护公司用户")
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@@ -33,8 +34,8 @@ public class UserController {
private final UserService userService;
/** GET /api/users 鈥?鍒嗛〉鏌ヨ鐢ㄦ埛鍒楄〃 */
@Operation(summary = "鍒嗛鏌ヨ鐢ㄦ埛鍒楄")
/** GET /api/users — 分页查询用户列表 */
@Operation(summary = "分页查询用户列表")
@GetMapping
@RequiresRoles("ADMIN")
public Result<PageResult<SysUser>> listUsers(
@@ -44,8 +45,8 @@ public class UserController {
return Result.success(userService.listUsers(page, pageSize, principal(request)));
}
/** POST /api/users 鈥?鍒涘缓鐢ㄦ埛 */
@Operation(summary = "鍒涘缓鐢ㄦ埛")
/** POST /api/users — 创建用户 */
@Operation(summary = "创建用户")
@PostMapping
@RequiresRoles("ADMIN")
public Result<SysUser> createUser(@RequestBody Map<String, String> body,
@@ -58,8 +59,8 @@ public class UserController {
principal(request)));
}
/** PUT /api/users/{id} 鈥?鏇存柊鐢ㄦ埛鍩烘湰淇℃伅 */
@Operation(summary = "鏇存柊鐢ㄦ埛鍩烘湰淇")
/** PUT /api/users/{id} — 更新用户基本信息 */
@Operation(summary = "更新用户基本信息")
@PutMapping("/{id}")
@RequiresRoles("ADMIN")
public Result<SysUser> updateUser(@PathVariable Long id,
@@ -72,8 +73,8 @@ public class UserController {
principal(request)));
}
/** PUT /api/users/{id}/status 鈥?鍙樻洿鐢ㄦ埛鐘舵€?*/
@Operation(summary = "鍙樻洿鐢ㄦ埛鐘舵?, description = "status锛欰CTIVE銆丏ISABLED")
/** PUT /api/users/{id}/status — 变更用户状态 */
@Operation(summary = "变更用户状态", description = "statusACTIVE、DISABLED")
@PutMapping("/{id}/status")
@RequiresRoles("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id,
@@ -83,8 +84,8 @@ public class UserController {
return Result.success(null);
}
/** PUT /api/users/{id}/role 鈥?鍙樻洿鐢ㄦ埛瑙掕壊 */
@Operation(summary = "鍙樻洿鐢ㄦ埛瑙掕壊", description = "role锛欰DMIN銆乁PLOADER銆乂IEWER")
/** PUT /api/users/{id}/role — 变更用户角色 */
@Operation(summary = "变更用户角色", description = "roleADMIN、UPLOADER、VIEWER")
@PutMapping("/{id}/role")
@RequiresRoles("ADMIN")
public Result<Void> updateRole(@PathVariable Long id,

View File

@@ -0,0 +1,21 @@
package com.label.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 登录请求体。
*/
@Data
@Schema(description = "登录请求")
public class LoginRequest {
/** 公司代码(英文简写),用于确定租户 */
@Schema(description = "公司代码(英文简写)", example = "DEMO")
private String companyCode;
/** 登录用户名 */
@Schema(description = "登录用户名", example = "admin")
private String username;
/** 明文密码(传输层应使用 HTTPS 保护) */
@Schema(description = "明文密码", example = "admin123")
private String password;
}

View File

@@ -0,0 +1,29 @@
package com.label.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 登录成功响应体。
*/
@Data
@AllArgsConstructor
@Schema(description = "登录响应")
public class LoginResponse {
/** Bearer TokenUUID 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;
}

View File

@@ -0,0 +1,26 @@
package com.label.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* GET /api/auth/me 响应体,包含当前登录用户的详细信息。
*/
@Data
@AllArgsConstructor
@Schema(description = "当前登录用户信息")
public class UserInfoResponse {
@Schema(description = "用户主键")
private Long id;
@Schema(description = "用户名")
private String username;
@Schema(description = "真实姓名")
private String realName;
@Schema(description = "角色", example = "ADMIN")
private String role;
@Schema(description = "所属公司 ID")
private Long companyId;
@Schema(description = "所属公司名称")
private String companyName;
}

View File

@@ -1,4 +1,4 @@
package com.label.entity;
package com.label.module.user.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -8,23 +8,24 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 绉熸埛鍏徃瀹炰綋锛屽?sys_company 琛ㄣ? * status 鍙栧硷細ACTIVE / DISABLED
* 租户公司实体对应 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 */
/** 状态:ACTIVE / DISABLED */
private String status;
private LocalDateTime createdAt;

View File

@@ -0,0 +1,49 @@
package com.label.module.user.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统用户实体,对应 sys_user 表。
* role 取值UPLOADER / ANNOTATOR / REVIEWER / ADMIN
* status 取值ACTIVE / DISABLED
*/
@Data
@TableName("sys_user")
public class SysUser {
/** 用户主键,自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属公司 ID多租户键 */
private Long companyId;
/** 登录用户名(同公司内唯一) */
private String username;
/**
* BCrypt 哈希密码strength ≥ 10
* 序列化时排除,防止密码哈希泄漏到 API 响应。
*/
@JsonIgnore
private String passwordHash;
/** 真实姓名 */
private String realName;
/** 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
private String role;
/** 状态ACTIVE / DISABLED */
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,23 @@
package com.label.module.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysCompany;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
/**
* sys_company 表 Mapper。
* 继承 BaseMapper 获得标准 CRUD自定义方法用注解 SQL。
*/
@Mapper
public interface SysCompanyMapper extends BaseMapper<SysCompany> {
/**
* 按公司代码查询公司忽略多租户过滤sys_company 无 company_id 字段)。
*
* @param companyCode 公司代码
* @return 公司实体,不存在则返回 null
*/
@Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}")
SysCompany selectByCompanyCode(String companyCode);
}

View File

@@ -0,0 +1,34 @@
package com.label.module.user.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* sys_user 表 Mapper。
* 继承 BaseMapper 获得标准 CRUD自定义登录查询方法绕过多租户过滤器
* 由调用方显式传入 companyId。
*/
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 按公司 ID + 用户名查询用户(登录场景使用)。
* <p>
* 使用 @InterceptorIgnore 绕过 TenantLineInnerInterceptor
* 由参数 companyId 显式限定租户,防止登录时 CompanyContext 尚未注入
* 导致查询条件变为 {@code company_id = NULL}。
* </p>
*
* @param companyId 公司 ID
* @param username 用户名
* @return 用户实体(含 passwordHash不存在则返回 null
*/
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'")
SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId,
@Param("username") String username);
}

View File

@@ -4,13 +4,13 @@ import com.label.common.exception.BusinessException;
import com.label.common.redis.RedisKeyManager;
import com.label.common.redis.RedisService;
import com.label.common.shiro.TokenPrincipal;
import com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
import com.label.entity.SysCompany;
import com.label.entity.SysUser;
import com.label.mapper.SysCompanyMapper;
import com.label.mapper.SysUserMapper;
import com.label.module.user.dto.LoginRequest;
import com.label.module.user.dto.LoginResponse;
import com.label.module.user.dto.UserInfoResponse;
import com.label.module.user.entity.SysCompany;
import com.label.module.user.entity.SysUser;
import com.label.module.user.mapper.SysCompanyMapper;
import com.label.module.user.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -23,9 +23,11 @@ import java.util.Map;
import java.util.UUID;
/**
* 璁よ瘉鏈嶅姟锛氱櫥褰曘€侀€€鍑恒€佹煡璇㈠綋鍓嶇敤鎴蜂俊鎭€? *
* Token 鐢熷懡鍛ㄦ湡锛? * - 鐧诲綍鎴愬姛 鈫?UUID v4 鈫?Redis Hash token:{uuid} 鈫?TTL = token.ttl-seconds
* - 閫€鍑虹櫥褰?鈫?鐩存帴 DEL token:{uuid}锛堢珛鍗冲け鏁堬級
* 认证服务:登录、退出、查询当前用户信息。
*
* Token 生命周期:
* - 登录成功 → UUID v4 → Redis Hash token:{uuid} → TTL = token.ttl-seconds
* - 退出登录 → 直接 DEL token:{uuid}(立即失效)
*/
@Slf4j
@Service
@@ -36,41 +38,45 @@ public class AuthService {
private final SysUserMapper userMapper;
private final RedisService redisService;
/** BCryptPasswordEncoder 绾跨▼瀹夊叏锛屽彲澶嶇敤 */
/** BCryptPasswordEncoder 线程安全,可复用 */
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
@Value("${token.ttl-seconds:7200}")
private long tokenTtlSeconds;
/**
* 鐢ㄦ埛鐧诲綍銆? *
* @param request 鍖呭惈 companyCode / username / password
* @return LoginResponse锛堝惈 token銆乽serId銆乺ole銆乪xpiresIn锛? * @throws BusinessException USER_NOT_FOUND(401) 鍑瘉閿欒
* @throws BusinessException USER_DISABLED(403) 璐﹀彿宸茬鐢? */
* 用户登录。
*
* @param request 包含 companyCode / username / password
* @return LoginResponse含 token、userId、role、expiresIn
* @throws BusinessException USER_NOT_FOUND(401) 凭证错误
* @throws BusinessException USER_DISABLED(403) 账号已禁用
*/
public LoginResponse login(LoginRequest request) {
// 1. 鏌ュ叕鍙革紙缁曡繃澶氱鎴疯繃婊ゅ櫒锛宻ys_company 鏃?company_id 瀛楁锛? SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode());
// 1. 查公司绕过多租户过滤器sys_company company_id 字段)
SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode());
if (company == null || !"ACTIVE".equals(company.getStatus())) {
// 鍏徃涓嶅瓨鍦ㄦ垨绂佺敤锛岀粺涓€鎶?USER_NOT_FOUND 闃叉淇℃伅娉勬紡
throw new BusinessException("USER_NOT_FOUND", "鐢ㄦ埛鍚嶆垨瀵嗙爜閿欒", HttpStatus.UNAUTHORIZED);
// 公司不存在或禁用,统一报 USER_NOT_FOUND 防止信息泄漏
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 2. 鏌ョ敤鎴凤紙鏄惧紡浼犲叆 companyId锛岀粫杩囧绉熸埛鎷︽埅鍣級
// 2. 查用户(显式传入 companyId绕过多租户拦截器
SysUser user = userMapper.selectByCompanyAndUsername(company.getId(), request.getUsername());
if (user == null) {
throw new BusinessException("USER_NOT_FOUND", "鐢ㄦ埛鍚嶆垨瀵嗙爜閿欒", HttpStatus.UNAUTHORIZED);
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 3. 璐﹀彿绂佺敤妫€鏌ワ紙鍏堜簬瀵嗙爜鏍¢獙锛岄槻姝㈡毚鍔涚牬瑙e凡鐭ョ敤鎴风姸鎬侊級
// 3. 账号禁用检查(先于密码校验,防止暴力破解已知用户状态)
if (!"ACTIVE".equals(user.getStatus())) {
throw new BusinessException("USER_DISABLED", "璐﹀彿宸茬鐢紝璇疯仈绯荤鐞嗗憳", HttpStatus.FORBIDDEN);
throw new BusinessException("USER_DISABLED", "账号已禁用,请联系管理员", HttpStatus.FORBIDDEN);
}
// 4. BCrypt 瀵嗙爜鏍¢獙
// 4. BCrypt 密码校验
if (!PASSWORD_ENCODER.matches(request.getPassword(), user.getPasswordHash())) {
throw new BusinessException("USER_NOT_FOUND", "鐢ㄦ埛鍚嶆垨瀵嗙爜閿欒", HttpStatus.UNAUTHORIZED);
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 5. 鐢熸垚 UUID v4 Token锛屽啓鍏?Redis Hash
// 5. 生成 UUID v4 Token,写入 Redis Hash
String token = UUID.randomUUID().toString();
Map<String, String> tokenData = new HashMap<>();
tokenData.put("userId", user.getId().toString());
@@ -79,36 +85,44 @@ public class AuthService {
tokenData.put("username", user.getUsername());
redisService.hSetAll(RedisKeyManager.tokenKey(token), tokenData, tokenTtlSeconds);
// 灏?token 鍔犲叆璇ョ敤鎴风殑娲昏穬浼氳瘽闆嗗悎锛堢敤浜庤鑹插彉鏇存椂鎵归噺鏇存柊/澶辨晥锛? String sessionsKey = RedisKeyManager.userSessionsKey(user.getId());
// token 加入该用户的活跃会话集合(用于角色变更时批量更新/失效)
String sessionsKey = RedisKeyManager.userSessionsKey(user.getId());
redisService.sAdd(sessionsKey, token);
// 闃叉 Set 鏃犻檺澧為暱锛歍TL = token 鏈夋晥鏈燂紙鏈€鍚庝竴娆$櫥褰曟椂婊戝姩缁湡锛? redisService.expire(sessionsKey, tokenTtlSeconds);
// 防止 Set 无限增长TTL = token 有效期(最后一次登录时滑动续期)
redisService.expire(sessionsKey, tokenTtlSeconds);
log.info("鐢ㄦ埛鐧诲綍鎴愬姛: companyCode={}, username={}", request.getCompanyCode(), request.getUsername());
log.info("用户登录成功: companyCode={}, username={}", request.getCompanyCode(), request.getUsername());
return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole(), tokenTtlSeconds);
}
/**
* 閫€鍑虹櫥褰曪紝绔嬪嵆鍒犻櫎 Redis Token锛圱oken 绔嬪嵆澶辨晥锛夈€? *
* @param token 鏉ヨ嚜 Authorization 澶寸殑 Bearer token
* 退出登录,立即删除 Redis TokenToken 立即失效)。
*
* @param token 来自 Authorization 头的 Bearer token
*/
public void logout(String token) {
if (token != null && !token.isBlank()) {
// 浠庣敤鎴蜂細璇濋泦鍚堜腑绉婚櫎锛堣嫢 token 浠嶆湁鏁堝垯鍏堣鍙?userId锛? String userId = redisService.hGet(RedisKeyManager.tokenKey(token), "userId");
// 从用户会话集合中移除(若 token 仍有效则先读取 userId
String userId = redisService.hGet(RedisKeyManager.tokenKey(token), "userId");
redisService.delete(RedisKeyManager.tokenKey(token));
if (userId != null) {
try {
redisService.sRemove(RedisKeyManager.userSessionsKey(Long.parseLong(userId)), token);
} catch (NumberFormatException ignored) {}
}
log.info("鐢ㄦ埛閫€鍑猴紝Token 宸插垹闄? {}", token);
log.info("用户退出,Token 已删除: {}", token);
}
}
/**
* 鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛璇︽儏锛堝惈 realName銆乧ompanyName锛夈€? *
* @param principal TokenFilter 娉ㄥ叆鐨勫綋鍓嶇敤鎴蜂富浣? * @return 鐢ㄦ埛淇℃伅鍝嶅簲浣? */
* 获取当前登录用户详情(含 realName、companyName)。
*
* @param principal TokenFilter 注入的当前用户主体
* @return 用户信息响应体
*/
public UserInfoResponse me(TokenPrincipal principal) {
// 浠?DB 鑾峰彇 realName锛圱oken 涓湭瀛樺偍锛? SysUser user = userMapper.selectById(principal.getUserId());
// DB 获取 realNameToken 中未存储)
SysUser user = userMapper.selectById(principal.getUserId());
SysCompany company = companyMapper.selectById(principal.getCompanyId());
String realName = (user != null) ? user.getRealName() : principal.getUsername();

View File

@@ -15,17 +15,19 @@ import com.label.common.redis.RedisKeyManager;
import com.label.common.redis.RedisService;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.SysUser;
import com.label.mapper.SysUserMapper;
import com.label.module.user.entity.SysUser;
import com.label.module.user.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 鐢ㄦ埛绠悊鏈嶅姟锛圓DMIN 涓撳睘锛夈€? *
* 鍏抽敭璁捐锛? * - 瑙掕壊鍙樻洿锛欴B 鍐欏叆鍚庣珛鍗虫洿鏂版墍鏈夋椿璺?Token 涓殑 role 瀛楁锛屾棤闇€閲嶆柊鐧诲綍
* - 鐘舵€佺鐢細DB 鍐欏叆鍚庡垹闄ょ敤鎴锋墍鏈夋椿璺?Token锛堢珛鍗冲け鏁堬級
* - 浣跨敤 user:sessions:{userId} Set 璺熻釜娲昏穬浼氳瘽
* 用户管理服务ADMIN 专属)。
*
* 关键设计:
* - 角色变更DB 写入后立即更新所有活跃 Token 中的 role 字段,无需重新登录
* - 状态禁用DB 写入后删除用户所有活跃 Token立即失效
* - 使用 user:sessions:{userId} Set 跟踪活跃会话
*/
@Slf4j
@Service
@@ -37,20 +39,27 @@ public class UserService {
private final SysUserMapper userMapper;
private final RedisService redisService;
// ------------------------------------------------------------------ 鍒涘缓鐢ㄦ埛 --
// ------------------------------------------------------------------ 创建用户 --
/**
* 鍒涘缓鏂扮敤鎴凤紙ADMIN 鎿嶄綔锛夈€? *
* @param username 鐢ㄦ埛鍚? * @param password 鏄庢枃瀵嗙爜锛堝皢浠?BCrypt strength=10 鍝堝笇锛? * @param realName 鐪熷疄濮撳悕锛堝彲閫夛級
* @param role 瑙掕壊锛圲PLOADER / ANNOTATOR / REVIEWER / ADMIN锛? * @param principal 褰撳墠绠$悊鍛? * @return 鏂板缓鐢ㄦ埛锛堜笉鍚?passwordHash锛? */
* 创建新用户ADMIN 操作)。
*
* @param username 用户名
* @param password 明文密码(将以 BCrypt strength=10 哈希)
* @param realName 真实姓名(可选)
* @param role 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN
* @param principal 当前管理员
* @return 新建用户(不含 passwordHash
*/
@Transactional
public SysUser createUser(String username, String password,
String realName, String role,
TokenPrincipal principal) {
// 鏍¢獙鐢ㄦ埛鍚嶅敮涓€鎬? SysUser existing = userMapper.selectByCompanyAndUsername(principal.getCompanyId(), username);
// 校验用户名唯一性
SysUser existing = userMapper.selectByCompanyAndUsername(principal.getCompanyId(), username);
if (existing != null) {
throw new BusinessException("DUPLICATE_USERNAME",
"鐢ㄦ埛鍚?'" + username + "' 宸插瓨鍦?, HttpStatus.CONFLICT);
"用户名 '" + username + "' 已存在", HttpStatus.CONFLICT);
}
validateRole(role);
@@ -64,14 +73,15 @@ public class UserService {
user.setStatus("ACTIVE");
userMapper.insert(user);
log.info("鐢ㄦ埛宸插垱寤? userId={}, username={}, role={}", user.getId(), username, role);
log.info("用户已创建: userId={}, username={}, role={}", user.getId(), username, role);
return user;
}
// ------------------------------------------------------------------ 鏇存柊鍩烘湰淇℃伅 --
// ------------------------------------------------------------------ 更新基本信息 --
/**
* 鏇存柊鐢ㄦ埛鍩烘湰淇℃伅锛坮ealName銆乸assword锛夈€? */
* 更新用户基本信息realName、password)。
*/
@Transactional
public SysUser updateUser(Long userId, String realName, String password,
TokenPrincipal principal) {
@@ -93,68 +103,78 @@ public class UserService {
return user;
}
// ------------------------------------------------------------------ 鍙樻洿瑙掕壊 --
// ------------------------------------------------------------------ 变更角色 --
/**
* 鍙樻洿鐢ㄦ埛瑙掕壊銆? *
* DB 鍐欏叆鍚庯紝绔嬪嵆鏇存柊璇ョ敤鎴锋墍鏈夋椿璺?Token 涓殑 role 瀛楁锛? * 纭繚瑙掕壊鍙樻洿瀵逛笅涓€娆¤姹傜珛鍗崇敓鏁堬紙鏃犻渶閲嶆柊鐧诲綍锛夈€? *
* @param userId 鐩爣鐢ㄦ埛 ID
* @param newRole 鏂拌鑹? * @param principal 褰撳墠绠$悊鍛? */
* 变更用户角色。
*
* DB 写入后,立即更新该用户所有活跃 Token 中的 role 字段,
* 确保角色变更对下一次请求立即生效(无需重新登录)。
*
* @param userId 目标用户 ID
* @param newRole 新角色
* @param principal 当前管理员
*/
@Transactional
public void updateRole(Long userId, String newRole, TokenPrincipal principal) {
getExistingUser(userId, principal.getCompanyId());
validateRole(newRole);
// 1. DB 鍐欏叆
// 1. DB 写入
userMapper.update(null, new LambdaUpdateWrapper<SysUser>()
.eq(SysUser::getId, userId)
.eq(SysUser::getCompanyId, principal.getCompanyId())
.set(SysUser::getRole, newRole));
// 2. 鏇存柊鎵€鏈夋椿璺?Token 涓殑 role 瀛楁锛堢珛鍗崇敓鏁堬紝鏃犻渶閲嶆柊鐧诲綍锛? Set<String> tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId));
// 2. 更新所有活跃 Token 中的 role 字段(立即生效,无需重新登录)
Set<String> 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<SysUser>()
.eq(SysUser::getId, userId)
.eq(SysUser::getCompanyId, principal.getCompanyId())
.set(SysUser::getStatus, newStatus));
// 绂佺敤鏃讹細鍒犻櫎鎵€鏈夋椿璺?Token锛堢珛鍗冲け鏁堬級
// 禁用时:删除所有活跃 Token立即失效
if ("DISABLED".equals(newStatus)) {
Set<String> 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<SysUser> listUsers(int page, int pageSize, TokenPrincipal principal) {
pageSize = Math.min(pageSize, 100);
Page<SysUser> result = userMapper.selectPage(
@@ -165,12 +185,12 @@ public class UserService {
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
}
// ------------------------------------------------------------------ 绉佹湁宸ュ叿 --
// ------------------------------------------------------------------ 私有工具 --
private SysUser getExistingUser(Long userId, Long companyId) {
SysUser user = userMapper.selectById(userId);
if (user == null || !companyId.equals(user.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "鐢ㄦ埛涓嶅瓨鍦? " + userId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "用户不存在: " + userId, HttpStatus.NOT_FOUND);
}
return user;
}
@@ -178,7 +198,7 @@ public class UserService {
private void validateRole(String role) {
if (!List.of("UPLOADER", "ANNOTATOR", "REVIEWER", "ADMIN").contains(role)) {
throw new BusinessException("INVALID_ROLE",
"瑙掕壊鍊间笉鍚堟硶: " + role, HttpStatus.BAD_REQUEST);
"角色值不合法: " + role, HttpStatus.BAD_REQUEST);
}
}
}

View File

@@ -2,7 +2,7 @@ package com.label.module.video.controller;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.entity.VideoProcessJob;
import com.label.module.video.entity.VideoProcessJob;
import com.label.module.video.service.VideoProcessService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -16,10 +16,14 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 瑙嗛澶勭悊鎺ュ彛锛? 涓鐐癸級銆? *
* POST /api/video/process 鈥?瑙﹀彂瑙嗛澶勭悊锛圓DMIN锛? * GET /api/video/jobs/{jobId} 鈥?鏌ヨ浠诲姟鐘舵€侊紙ADMIN锛? * POST /api/video/jobs/{jobId}/reset 鈥?閲嶇疆澶辫触浠诲姟锛圓DMIN锛? * POST /api/video/callback 鈥?AI 鍥炶皟鎺ュ彛锛堟棤闇€璁よ瘉锛屽凡鍦?TokenFilter 涓帓闄わ級
* 视频处理接口4 个端点)。
*
* POST /api/video/process — 触发视频处理ADMIN
* GET /api/video/jobs/{jobId} — 查询任务状态ADMIN
* POST /api/video/jobs/{jobId}/reset — 重置失败任务ADMIN
* POST /api/video/callback — AI 回调接口(无需认证,已在 TokenFilter 中排除)
*/
@Tag(name = "瑙嗛澶勭悊", description = "瑙嗛澶勭悊浠诲姟鍒涘缓銆佹煡璇€侀噸缃拰鍥炶皟")
@Tag(name = "视频处理", description = "视频处理任务创建、查询、重置和回调")
@Slf4j
@RestController
@RequiredArgsConstructor
@@ -30,8 +34,8 @@ public class VideoController {
@Value("${video.callback-secret:}")
private String callbackSecret;
/** POST /api/video/process 鈥?瑙﹀彂瑙嗛澶勭悊浠诲姟 */
@Operation(summary = "瑙﹀彂瑙嗛澶勭悊浠诲姟")
/** POST /api/video/process — 触发视频处理任务 */
@Operation(summary = "触发视频处理任务")
@PostMapping("/api/video/process")
@RequiresRoles("ADMIN")
public Result<VideoProcessJob> createJob(@RequestBody Map<String, Object> body,
@@ -39,7 +43,7 @@ public class VideoController {
Object sourceIdVal = body.get("sourceId");
Object jobTypeVal = body.get("jobType");
if (sourceIdVal == null || jobTypeVal == null) {
return Result.failure("INVALID_PARAMS", "sourceId 鍜?jobType 涓嶈兘涓虹┖");
return Result.failure("INVALID_PARAMS", "sourceId jobType 不能为空");
}
Long sourceId = Long.parseLong(sourceIdVal.toString());
String jobType = jobTypeVal.toString();
@@ -50,8 +54,8 @@ public class VideoController {
videoProcessService.createJob(sourceId, jobType, params, principal.getCompanyId()));
}
/** GET /api/video/jobs/{jobId} 鈥?鏌ヨ瑙嗛澶勭悊浠诲姟 */
@Operation(summary = "鏌ヨ瑙嗛澶勭悊浠诲姟鐘舵€?)
/** GET /api/video/jobs/{jobId} — 查询视频处理任务 */
@Operation(summary = "查询视频处理任务状态")
@GetMapping("/api/video/jobs/{jobId}")
@RequiresRoles("ADMIN")
public Result<VideoProcessJob> getJob(@PathVariable Long jobId,
@@ -59,8 +63,8 @@ public class VideoController {
return Result.success(videoProcessService.getJob(jobId, principal(request).getCompanyId()));
}
/** POST /api/video/jobs/{jobId}/reset 鈥?绠$悊鍛橀噸缃け璐ヤ换鍔?*/
@Operation(summary = "閲嶇疆澶辫触鐨勮棰戝鐞嗕换鍔?)
/** POST /api/video/jobs/{jobId}/reset — 管理员重置失败任务 */
@Operation(summary = "重置失败的视频处理任务")
@PostMapping("/api/video/jobs/{jobId}/reset")
@RequiresRoles("ADMIN")
public Result<VideoProcessJob> resetJob(@PathVariable Long jobId,
@@ -69,21 +73,24 @@ public class VideoController {
}
/**
* POST /api/video/callback 鈥?AI 鏈嶅姟鍥炶皟锛堟棤闇€ Bearer Token锛夈€? *
* 姝ょ鐐瑰凡鍦?TokenFilter.shouldNotFilter() 涓帓闄よ璇侊紝
* 鐢?AI 鏈嶅姟鐩存帴璋冪敤锛屾惡甯?jobId銆乻tatus銆乷utputPath 绛夊弬鏁般€? *
* Body 绀轰緥锛? * { "jobId": 123, "status": "SUCCESS", "outputPath": "processed/123/frames.zip" }
* POST /api/video/callback AI 服务回调(无需 Bearer Token)。
*
* 此端点已在 TokenFilter.shouldNotFilter() 中排除认证,
* 由 AI 服务直接调用,携带 jobId、status、outputPath 等参数。
*
* Body 示例:
* { "jobId": 123, "status": "SUCCESS", "outputPath": "processed/123/frames.zip" }
* { "jobId": 123, "status": "FAILED", "errorMessage": "ffmpeg error: ..." }
*/
@Operation(summary = "鎺ユ敹 AI 鏈嶅姟瑙嗛澶勭悊鍥炶皟")
@Operation(summary = "接收 AI 服务视频处理回调")
@PostMapping("/api/video/callback")
public Result<Void> handleCallback(@RequestBody Map<String, Object> body,
HttpServletRequest request) {
// 鍏变韩瀵嗛挜鏍¢獙锛堥厤缃簡 VIDEO_CALLBACK_SECRET 鏃跺己鍒舵牎楠岋級
// 共享密钥校验(配置了 VIDEO_CALLBACK_SECRET 时强制校验)
if (callbackSecret != null && !callbackSecret.isBlank()) {
String provided = request.getHeader("X-Callback-Secret");
if (!callbackSecret.equals(provided)) {
return Result.failure("UNAUTHORIZED", "鍥炶皟瀵嗛挜鏃犳晥");
return Result.failure("UNAUTHORIZED", "回调密钥无效");
}
}
@@ -92,7 +99,7 @@ public class VideoController {
String outputPath = body.containsKey("outputPath") ? (String) body.get("outputPath") : null;
String errorMessage = body.containsKey("errorMessage") ? (String) body.get("errorMessage") : null;
log.info("瑙嗛澶勭悊鍥炶皟锛歫obId={}, status={}", jobId, status);
log.info("视频处理回调jobId={}, status={}", jobId, status);
videoProcessService.handleCallback(jobId, status, outputPath, errorMessage);
return Result.success(null);
}

View File

@@ -0,0 +1,57 @@
package com.label.module.video.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 视频处理任务实体,对应 video_process_job 表。
*
* jobType 取值FRAME_EXTRACT / VIDEO_TO_TEXT
* status 取值PENDING / RUNNING / SUCCESS / FAILED / RETRYING
*/
@Data
@TableName("video_process_job")
public class VideoProcessJob {
@TableId(type = IdType.AUTO)
private Long id;
/** 所属公司(多租户键) */
private Long companyId;
/** 关联资料 ID */
private Long sourceId;
/** 任务类型FRAME_EXTRACT / VIDEO_TO_TEXT */
private String jobType;
/** 任务状态PENDING / RUNNING / SUCCESS / FAILED / RETRYING */
private String status;
/** 任务参数JSONB例如 {"frameInterval": 30} */
private String params;
/** AI 处理输出路径(成功后填写) */
private String outputPath;
/** 已重试次数 */
private Integer retryCount;
/** 最大重试次数(默认 3 */
private Integer maxRetries;
/** 错误信息 */
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -1,11 +1,12 @@
package com.label.mapper;
package com.label.module.video.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.entity.VideoProcessJob;
import com.label.module.video.entity.VideoProcessJob;
import org.apache.ibatis.annotations.Mapper;
/**
* video_process_job ?Mapper? */
* video_process_job Mapper
*/
@Mapper
public interface VideoProcessJobMapper extends BaseMapper<VideoProcessJob> {
}

View File

@@ -5,10 +5,10 @@ import com.label.common.ai.AiServiceClient;
import com.label.common.exception.BusinessException;
import com.label.common.statemachine.SourceStatus;
import com.label.common.statemachine.StateValidator;
import com.label.entity.SourceData;
import com.label.mapper.SourceDataMapper;
import com.label.entity.VideoProcessJob;
import com.label.mapper.VideoProcessJobMapper;
import com.label.module.source.entity.SourceData;
import com.label.module.source.mapper.SourceDataMapper;
import com.label.module.video.entity.VideoProcessJob;
import com.label.module.video.mapper.VideoProcessJobMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -22,15 +22,19 @@ import java.time.LocalDateTime;
import java.util.Map;
/**
* 瑙嗛澶勭悊鏈嶅姟锛氬垱寤轰换鍔°€佸鐞嗗洖璋冦€佺鐞嗗憳閲嶇疆銆? *
* 鐘舵€佹祦杞細
* - 鍒涘缓鏃讹細source_data 鈫?PREPROCESSING锛宩ob 鈫?PENDING
* - 鍥炶皟鎴愬姛锛歫ob 鈫?SUCCESS锛宻ource_data 鈫?PENDING锛堣繘鍏ユ彁鍙栭槦鍒楋級
* - 鍥炶皟澶辫触锛堝彲閲嶈瘯锛夛細job 鈫?RETRYING锛宺etryCount++锛岄噸鏂拌Е鍙?AI
* - 鍥炶皟澶辫触锛堣秴鍑轰笂闄愶級锛歫ob 鈫?FAILED锛宻ource_data 鈫?PENDING
* - 悊鍛橀噸缃細job 鈫?PENDING锛堝彲鎵嬪姩閲嶆柊瑙﹀彂锛? *
* T074 璁捐璇存槑锛? * AI 璋冪敤閫氳繃 TransactionSynchronizationManager.registerSynchronization().afterCommit()
* 寤惰繜鍒颁簨鍔℃彁浜ゅ悗鎵ц锛岄伩鍏嶅湪鎸佹湁 DB 杩炴帴鏈熼棿杩涜 HTTP 璋冪敤銆? */
* 视频处理服务:创建任务、处理回调、管理员重置。
*
* 状态流转:
* - 创建时source_data → PREPROCESSINGjob → PENDING
* - 回调成功job → SUCCESSsource_data → PENDING进入提取队列
* - 回调失败可重试job → RETRYINGretryCount++,重新触发 AI
* - 回调失败超出上限job → FAILEDsource_data → PENDING
* - 管理员重置job → PENDING可手动重新触发
*
* T074 设计说明:
* AI 调用通过 TransactionSynchronizationManager.registerSynchronization().afterCommit()
* 延迟到事务提交后执行,避免在持有 DB 连接期间进行 HTTP 调用。
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -43,27 +47,31 @@ public class VideoProcessService {
@Value("${rustfs.bucket:label-source-data}")
private String bucket;
// ------------------------------------------------------------------ 鍒涘缓浠诲姟 --
// ------------------------------------------------------------------ 创建任务 --
/**
* 鍒涘缓瑙嗛澶勭悊浠诲姟骞跺湪浜嬪姟鎻愪氦鍚庤Е鍙?AI 鏈嶅姟銆? *
* DB 鍐欏叆锛坰ource_data鈫扨REPROCESSING + 鎻掑叆 job锛夊湪 @Transactional 鍐呭畬鎴愶紱
* AI 瑙﹀彂閫氳繃 afterCommit() 鍦ㄤ簨鍔℃彁浜ゅ悗鎵ц锛屼笉鍗犵敤 DB 杩炴帴銆? *
* @param sourceId 璧勬枡 ID
* @param jobType 浠诲姟绫诲瀷锛團RAME_EXTRACT / VIDEO_TO_TEXT锛? * @param params JSON 鍙傛暟锛堝 {"frameInterval": 30}锛? * @param companyId 绉熸埛 ID
* @return 鏂板缓鐨?VideoProcessJob
* 创建视频处理任务并在事务提交后触发 AI 服务。
*
* DB 写入source_data→PREPROCESSING + 插入 job在 @Transactional 内完成;
* AI 触发通过 afterCommit() 在事务提交后执行,不占用 DB 连接。
*
* @param sourceId 资料 ID
* @param jobType 任务类型FRAME_EXTRACT / VIDEO_TO_TEXT
* @param params JSON 参数(如 {"frameInterval": 30}
* @param companyId 租户 ID
* @return 新建的 VideoProcessJob
*/
@Transactional
public VideoProcessJob createJob(Long sourceId, String jobType,
String params, Long companyId) {
SourceData source = sourceDataMapper.selectById(sourceId);
if (source == null || !companyId.equals(source.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "璧勬枡涓嶅瓨鍦? " + sourceId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "资料不存在: " + sourceId, HttpStatus.NOT_FOUND);
}
validateJobType(jobType);
// source_data 鈫?PREPROCESSING
// source_data PREPROCESSING
StateValidator.assertTransition(
SourceStatus.TRANSITIONS,
SourceStatus.valueOf(source.getStatus()), SourceStatus.PREPROCESSING);
@@ -72,7 +80,7 @@ public class VideoProcessService {
.set(SourceData::getStatus, "PREPROCESSING")
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
// 鎻掑叆 PENDING 浠诲姟
// 插入 PENDING 任务
VideoProcessJob job = new VideoProcessJob();
job.setCompanyId(companyId);
job.setSourceId(sourceId);
@@ -83,7 +91,8 @@ public class VideoProcessService {
job.setMaxRetries(3);
jobMapper.insert(job);
// 浜嬪姟鎻愪氦鍚庤Е鍙?AI锛堜笉鍦ㄤ簨鍔″唴锛屼笉鍗犵敤 DB 杩炴帴锛? final Long jobId = job.getId();
// 事务提交后触发 AI不在事务内不占用 DB 连接)
final Long jobId = job.getId();
final String filePath = source.getFilePath();
final String finalJobType = jobType;
@@ -94,31 +103,36 @@ public class VideoProcessService {
}
});
log.info("瑙嗛澶勭悊浠诲姟宸插垱寤猴紙AI 灏嗗湪浜嬪姟鎻愪氦鍚庤Е鍙戯級: jobId={}, sourceId={}", jobId, sourceId);
log.info("视频处理任务已创建AI 将在事务提交后触发): jobId={}, sourceId={}", jobId, sourceId);
return job;
}
// ------------------------------------------------------------------ 澶勭悊鍥炶皟 --
// ------------------------------------------------------------------ 处理回调 --
/**
* 澶勭悊 AI 鏈嶅姟寮傛鍥炶皟锛圥OST /api/video/callback锛屾棤闇€鐢ㄦ埛 Token锛夈€? *
* 骞傜瓑锛氳嫢 job 宸蹭负 SUCCESS锛岀洿鎺ヨ繑鍥烇紝闃叉閲嶅澶勭悊銆? * 閲嶈瘯瑙﹀彂鍚屾牱寤惰繜鍒颁簨鍔℃彁浜ゅ悗锛坅fterCommit锛夛紝涓嶅湪浜嬪姟鍐呮墽琛屻€? *
* @param jobId 浠诲姟 ID
* @param callbackStatus AI 鍥炶皟鐘舵€侊紙SUCCESS / FAILED锛? * @param outputPath 鎴愬姛鏃剁殑杈撳嚭璺緞锛堝彲閫夛級
* @param errorMessage 澶辫触鏃剁殑閿欒淇℃伅锛堝彲閫夛級
* 处理 AI 服务异步回调POST /api/video/callback,无需用户 Token
*
* 幂等:若 job 已为 SUCCESS直接返回防止重复处理。
* 重试触发同样延迟到事务提交后afterCommit不在事务内执行。
*
* @param jobId 任务 ID
* @param callbackStatus AI 回调状态SUCCESS / FAILED
* @param outputPath 成功时的输出路径(可选)
* @param errorMessage 失败时的错误信息(可选)
*/
@Transactional
public void handleCallback(Long jobId, String callbackStatus,
String outputPath, String errorMessage) {
// video_process_job 鍦?IGNORED_TABLES 涓紙鍥炶皟鏃?CompanyContext锛夛紝姝ゅ鏄惧紡鏍¢獙
// video_process_job IGNORED_TABLES 中(回调无 CompanyContext),此处显式校验
VideoProcessJob job = jobMapper.selectById(jobId);
if (job == null || job.getCompanyId() == null) {
log.warn("瑙嗛澶勭悊鍥炶皟锛歫ob 涓嶅瓨鍦紝jobId={}", jobId);
log.warn("视频处理回调job 不存在,jobId={}", jobId);
return;
}
// 骞傜瓑锛氬凡鎴愬姛鍒欏拷鐣ラ噸澶嶅洖璋? if ("SUCCESS".equals(job.getStatus())) {
log.info("瑙嗛澶勭悊鍥炶皟骞傜瓑锛歫obId={} 宸蹭负 SUCCESS锛岃烦杩?, jobId);
// 幂等:已成功则忽略重复回调
if ("SUCCESS".equals(job.getStatus())) {
log.info("视频处理回调幂等jobId={} 已为 SUCCESS跳过", jobId);
return;
}
@@ -129,24 +143,27 @@ public class VideoProcessService {
}
}
// ------------------------------------------------------------------ 绠$悊鍛橀噸缃?--
// ------------------------------------------------------------------ 管理员重置 --
/**
* 绠$悊鍛樻墜鍔ㄩ噸缃け璐ヤ换鍔★紙FAILED 鈫?PENDING锛夈€? *
* 浠呭厑璁?FAILED 鐘舵€佺殑浠诲姟閲嶇疆锛岄噸缃悗 retryCount 娓呴浂锛? * 绠$悊鍛樺彲闅忓悗閲嶆柊璋冪敤 createJob 瑙﹀彂澶勭悊銆? *
* @param jobId 浠诲姟 ID
* @param companyId 绉熸埛 ID
* 管理员手动重置失败任务(FAILED PENDING)。
*
* 仅允许 FAILED 状态的任务重置,重置后 retryCount 清零,
* 管理员可随后重新调用 createJob 触发处理。
*
* @param jobId 任务 ID
* @param companyId 租户 ID
*/
@Transactional
public VideoProcessJob reset(Long jobId, Long companyId) {
VideoProcessJob job = jobMapper.selectById(jobId);
if (job == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "瑙嗛澶勭悊浠诲姟涓嶅瓨鍦? " + jobId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "视频处理任务不存在: " + jobId, HttpStatus.NOT_FOUND);
}
if (!"FAILED".equals(job.getStatus())) {
throw new BusinessException("INVALID_TRANSITION",
" FAILED 鐘舵佺殑浠诲姟鍙互閲嶇疆锛屽綋鍓嶇姸鎬? " + job.getStatus(),
"只有 FAILED 状态的任务可以重置,当前状态: " + job.getStatus(),
HttpStatus.BAD_REQUEST);
}
@@ -159,24 +176,24 @@ public class VideoProcessService {
job.setStatus("PENDING");
job.setRetryCount(0);
log.info("瑙嗛澶勭悊浠诲姟宸查噸缃? jobId={}", jobId);
log.info("视频处理任务已重置: jobId={}", jobId);
return job;
}
// ------------------------------------------------------------------ 鏌ヨ --
// ------------------------------------------------------------------ 查询 --
public VideoProcessJob getJob(Long jobId, Long companyId) {
VideoProcessJob job = jobMapper.selectById(jobId);
if (job == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "瑙嗛澶勭悊浠诲姟涓嶅瓨鍦? " + jobId, HttpStatus.NOT_FOUND);
throw new BusinessException("NOT_FOUND", "视频处理任务不存在: " + jobId, HttpStatus.NOT_FOUND);
}
return job;
}
// ------------------------------------------------------------------ 绉佹湁鏂规硶 --
// ------------------------------------------------------------------ 私有方法 --
private void handleSuccess(VideoProcessJob job, String outputPath) {
// job 鈫?SUCCESS
// job SUCCESS
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
.eq(VideoProcessJob::getId, job.getId())
.set(VideoProcessJob::getStatus, "SUCCESS")
@@ -184,13 +201,13 @@ public class VideoProcessService {
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
// source_data PREPROCESSING 鈫?PENDING锛堣繘鍏ユ彁鍙栭槦鍒楋級
// source_data PREPROCESSING PENDING(进入提取队列)
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
.eq(SourceData::getId, job.getSourceId())
.set(SourceData::getStatus, "PENDING")
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
log.info("瑙嗛澶勭悊鎴愬姛锛歫obId={}, sourceId={}", job.getId(), job.getSourceId());
log.info("视频处理成功jobId={}, sourceId={}", job.getId(), job.getSourceId());
}
private void handleFailure(VideoProcessJob job, String errorMessage) {
@@ -198,7 +215,7 @@ public class VideoProcessService {
int maxRetries = job.getMaxRetries() != null ? job.getMaxRetries() : 3;
if (newRetryCount < maxRetries) {
// 浠嶆湁閲嶈瘯娆℃暟锛歫ob 鈫?RETRYING锛屼簨鍔℃彁浜ゅ悗閲嶆柊瑙﹀彂 AI
// 仍有重试次数job RETRYING,事务提交后重新触发 AI
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
.eq(VideoProcessJob::getId, job.getId())
.set(VideoProcessJob::getStatus, "RETRYING")
@@ -206,10 +223,10 @@ public class VideoProcessService {
.set(VideoProcessJob::getErrorMessage, errorMessage)
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
log.warn("瑙嗛澶勭悊澶辫触锛屽紑濮嬬 {} 噸璇曪細jobId={}, error={}",
log.warn("视频处理失败,开始第 {} 次重试jobId={}, error={}",
newRetryCount, job.getId(), errorMessage);
// 閲嶈瘯 AI 瑙﹀彂寤惰繜鍒颁簨鍔℃彁浜ゅ悗
// 重试 AI 触发延迟到事务提交后
SourceData source = sourceDataMapper.selectById(job.getSourceId());
if (source != null) {
final Long jobId = job.getId();
@@ -225,7 +242,7 @@ public class VideoProcessService {
});
}
} else {
// 瓒呭嚭鏈€澶ч噸璇曟鏁帮細job 鈫?FAILED锛宻ource_data 鈫?PENDING
// 超出最大重试次数:job FAILEDsource_data PENDING
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
.eq(VideoProcessJob::getId, job.getId())
.set(VideoProcessJob::getStatus, "FAILED")
@@ -234,13 +251,13 @@ public class VideoProcessService {
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
// source_data PREPROCESSING 鈫?PENDING锛堢鐞嗗憳鍙噸鏂板鐞嗭級
// source_data PREPROCESSING PENDING(管理员可重新处理)
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
.eq(SourceData::getId, job.getSourceId())
.set(SourceData::getStatus, "PENDING")
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
log.error("瑙嗛澶勭悊姘镐箙澶辫触锛歫obId={}, sourceId={}, error={}",
log.error("视频处理永久失败jobId={}, sourceId={}, error={}",
job.getId(), job.getSourceId(), errorMessage);
}
}
@@ -258,16 +275,16 @@ public class VideoProcessService {
} else {
aiServiceClient.videoToText(req);
}
log.info("AI 彂鎴愬姛: jobId={}", jobId);
log.info("AI 触发成功: jobId={}", jobId);
} catch (Exception e) {
log.error("彂瑙嗛澶勭悊 AI 澶辫触锛坖obId={}锛夛細{}锛宩ob 淇濇寔褰撳墠鐘舵侊紝闇悊鍛樻墜鍔ㄩ噸缃?, jobId, e.getMessage());
log.error("触发视频处理 AI 失败jobId={}{}job 保持当前状态,需管理员手动重置", jobId, e.getMessage());
}
}
private void validateJobType(String jobType) {
if (!"FRAME_EXTRACT".equals(jobType) && !"VIDEO_TO_TEXT".equals(jobType)) {
throw new BusinessException("INVALID_JOB_TYPE",
"浠诲姟绫诲瀷涓嶅悎娉曪紝搴斾负 FRAME_EXTRACT 鎴?VIDEO_TO_TEXT", HttpStatus.BAD_REQUEST);
"任务类型不合法,应为 FRAME_EXTRACT VIDEO_TO_TEXT", HttpStatus.BAD_REQUEST);
}
}
}

View File

@@ -2,7 +2,7 @@ package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.common.result.Result;
import com.label.dto.LoginRequest;
import com.label.module.user.dto.LoginRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -15,22 +15,26 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 璁よ瘉娴佺▼闆嗘垚娴嬭瘯锛圲S1锛夈€? *
* 娴嬭瘯鍦烘櫙锛? * 1. 姝g‘瀵嗙爜鐧诲綍 鈫?杩斿洖 token
* 2. 閿欒瀵嗙爜鐧诲綍 鈫?401
* 3. 涓嶅瓨鍦ㄧ殑鍏徃浠g爜 鈫?401
* 4. 鏈夋晥 Token 璁块棶 /api/auth/me 鈫?200锛岃繑鍥炵敤鎴蜂俊鎭? * 5. 涓诲姩閫€鍑哄悗锛屽師 Token 璁块棶 /api/auth/me 鈫?401
* 认证流程集成测试US1
*
* 娴嬭瘯鏁版嵁鏉ヨ嚜 init.sql 绉嶅瓙锛圖EMO 鍏徃 / admin / admin123锛? */
* 测试场景:
* 1. 正确密码登录 → 返回 token
* 2. 错误密码登录 → 401
* 3. 不存在的公司代码 → 401
* 4. 有效 Token 访问 /api/auth/me → 200返回用户信息
* 5. 主动退出后,原 Token 访问 /api/auth/me → 401
*
* 测试数据来自 init.sql 种子DEMO 公司 / admin / admin123
*/
public class AuthIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
// ------------------------------------------------------------------ 鐧诲綍娴嬭瘯 --
// ------------------------------------------------------------------ 登录测试 --
@Test
@DisplayName("姝g‘瀵嗙爜鐧诲綍 鈫?杩斿洖 token")
@DisplayName("正确密码登录 → 返回 token")
void login_withCorrectCredentials_returnsToken() {
ResponseEntity<Map> response = doLogin("DEMO", "admin", "admin123");
@@ -48,23 +52,23 @@ public class AuthIntegrationTest extends AbstractIntegrationTest {
}
@Test
@DisplayName("閿欒瀵嗙爜鐧诲綍 鈫?401 Unauthorized")
@DisplayName("错误密码登录 → 401 Unauthorized")
void login_withWrongPassword_returns401() {
ResponseEntity<Map> response = doLogin("DEMO", "admin", "wrong_password");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("涓嶅瓨鍦ㄧ殑鍏徃浠g爜 鈫?401 Unauthorized")
@DisplayName("不存在的公司代码 → 401 Unauthorized")
void login_withUnknownCompany_returns401() {
ResponseEntity<Map> response = doLogin("NONEXIST", "admin", "admin123");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ /me 娴嬭瘯 --
// ------------------------------------------------------------------ /me 测试 --
@Test
@DisplayName("鏈夋晥 Token 璁块棶 /api/auth/me 鈫?200锛岃繑鍥炵敤鎴蜂俊鎭?)
@DisplayName("有效 Token 访问 /api/auth/me 200,返回用户信息")
void me_withValidToken_returns200WithUserInfo() {
String token = loginAndGetToken("DEMO", "admin", "admin123");
assertThat(token).isNotBlank();
@@ -85,22 +89,22 @@ public class AuthIntegrationTest extends AbstractIntegrationTest {
}
@Test
@DisplayName("?Token 璁块棶 /api/auth/me ?401")
@DisplayName("无 Token 访问 /api/auth/me → 401")
void me_withNoToken_returns401() {
ResponseEntity<String> 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<Map> meResponse = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
@@ -108,14 +112,15 @@ public class AuthIntegrationTest extends AbstractIntegrationTest {
Map.class);
assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 閫€鍑? ResponseEntity<Map> logoutResponse = restTemplate.exchange(
// 退出
ResponseEntity<Map> logoutResponse = restTemplate.exchange(
baseUrl("/api/auth/logout"),
HttpMethod.POST,
bearerRequest(token),
Map.class);
assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 閫€鍑哄悗鍐嶈闂?/me 鈫?401
// 退出后再访问 /me 401
ResponseEntity<Map> meAfterLogout = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
@@ -124,9 +129,9 @@ public class AuthIntegrationTest extends AbstractIntegrationTest {
assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ 宸ュ叿鏂规硶 --
// ------------------------------------------------------------------ 工具方法 --
/** 鍙戣捣鐧诲綍璇锋眰锛岃繑鍥炲師濮?ResponseEntity */
/** 发起登录请求,返回原始 ResponseEntity */
private ResponseEntity<Map> doLogin(String companyCode, String username, String password) {
LoginRequest req = new LoginRequest();
req.setCompanyCode(companyCode);
@@ -135,7 +140,7 @@ public class AuthIntegrationTest extends AbstractIntegrationTest {
return restTemplate.postForEntity(baseUrl("/api/auth/login"), req, Map.class);
}
/** 鐧诲綍骞舵彁鍙?token 瀛楃涓诧紱澶辫触鏃惰繑鍥?null */
/** 登录并提取 token 字符串;失败时返回 null */
private String loginAndGetToken(String companyCode, String username, String password) {
ResponseEntity<Map> response = doLogin(companyCode, username, password);
if (!response.getStatusCode().is2xxSuccessful()) {
@@ -146,7 +151,7 @@ public class AuthIntegrationTest extends AbstractIntegrationTest {
return (String) data.get("token");
}
/** 鏋勯€犲甫 Bearer Token 鐨勮姹傚疄浣擄紙鏃?body锛?*/
/** 构造带 Bearer Token 的请求实体(无 body */
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);

View File

@@ -1,7 +1,7 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.dto.LoginRequest;
import com.label.module.user.dto.LoginRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -14,10 +14,12 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 鎻愬彇闃舵瀹℃壒闆嗘垚娴嬭瘯锛圲S4锛夈€? *
* 娴嬭瘯鍦烘櫙锛? * 1. 瀹℃壒閫氳繃 鈫?QA_GENERATION 浠诲姟鑷姩鍒涘缓锛宻ource_data 鐘舵€佹洿鏂颁负 QA_REVIEW
* 2. 瀹℃壒浜轰笌鎻愪氦浜虹浉鍚岋紙鑷锛夆啋 403 SELF_REVIEW_FORBIDDEN
* 3. 椹冲洖鍚庢爣娉ㄥ憳鍙噸棰嗕换鍔″苟鍐嶆鎻愪氦
* 提取阶段审批集成测试US4
*
* 测试场景:
* 1. 审批通过 → QA_GENERATION 任务自动创建source_data 状态更新为 QA_REVIEW
* 2. 审批人与提交人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN
* 3. 驳回后标注员可重领任务并再次提交
*/
public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest {
@@ -31,14 +33,15 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest {
@BeforeEach
void setup() {
// 鑾峰彇绉嶅瓙鐢ㄦ埛 ID锛坕nit.sql 涓凡鎻掑叆锛? annotatorUserId = jdbcTemplate.queryForObject(
// 获取种子用户 IDinit.sql 中已插入)
annotatorUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class);
reviewerUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class);
Long companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
// 鎻掑叆娴嬭瘯 source_data
// 插入测试 source_data
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
@@ -47,7 +50,7 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest {
sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
// 鎻掑叆 UNCLAIMED EXTRACTION 浠诲姟
// 插入 UNCLAIMED EXTRACTION 任务
jdbcTemplate.execute(
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
"VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')");
@@ -55,63 +58,66 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest {
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
}
// ------------------------------------------------------------------ 娴嬭瘯 1: 瀹℃壒閫氳繃 鈫?QA 浠诲姟鑷姩鍒涘缓 --
// ------------------------------------------------------------------ 测试 1: 审批通过 → QA 任务自动创建 --
@Test
@DisplayName("瀹℃壒閫氳繃鍚庯紝QA_GENERATION 浠诲姟鑷姩鍒涘缓锛宻ource_data 鐘舵€佸彉涓?QA_REVIEW")
@DisplayName("审批通过后,QA_GENERATION 任务自动创建source_data 状态变为 QA_REVIEW")
void approveTask_thenQaTaskAndSourceStatusUpdated() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 1. 鏍囨敞鍛橀鍙栦换鍔? ResponseEntity<Map> claimResp = restTemplate.exchange(
// 1. 标注员领取任务
ResponseEntity<Map> claimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 2. 鏍囨敞鍛樻彁浜ゆ爣娉? ResponseEntity<Map> submitResp = restTemplate.exchange(
// 2. 标注员提交标注
ResponseEntity<Map> 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<Map> approveResp = restTemplate.exchange(
baseUrl("/api/extraction/" + taskId + "/approve"),
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 楠岃瘉锛氬師浠诲姟鐘舵€佸彉涓?APPROVED锛宨s_final=true
// 验证:原任务状态变为 APPROVEDis_final=true
Map<String, Object> taskRow = jdbcTemplate.queryForMap(
"SELECT status, is_final FROM annotation_task WHERE id = ?", taskId);
assertThat(taskRow.get("status")).isEqualTo("APPROVED");
assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE);
// 楠岃瘉锛歈A_GENERATION 浠诲姟宸茶嚜鍔ㄥ垱寤猴紙UNCLAIMED 鐘舵€侊級
// 验证QA_GENERATION 任务已自动创建(UNCLAIMED 状态)
Integer qaTaskCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM annotation_task " +
"WHERE source_id = ? AND task_type = 'QA_GENERATION' AND status = 'UNCLAIMED'",
Integer.class, sourceId);
assertThat(qaTaskCount).as("QA_GENERATION 浠诲姟搴斿凡鍒涘缓").isEqualTo(1);
assertThat(qaTaskCount).as("QA_GENERATION 任务应已创建").isEqualTo(1);
// 楠岃瘉锛歴ource_data 鐘舵€佸凡鏇存柊涓?QA_REVIEW
// 验证source_data 状态已更新为 QA_REVIEW
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("source_data 鐘舵€佸簲涓?QA_REVIEW").isEqualTo("QA_REVIEW");
assertThat(sourceStatus).as("source_data 状态应为 QA_REVIEW").isEqualTo("QA_REVIEW");
// 楠岃瘉锛歵raining_dataset 宸蹭互 PENDING_REVIEW 鐘舵€佸垱寤? Integer datasetCount = jdbcTemplate.queryForObject(
// 验证training_dataset 已以 PENDING_REVIEW 状态创建
Integer datasetCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM training_dataset " +
"WHERE source_id = ? AND status = 'PENDING_REVIEW'",
Integer.class, sourceId);
assertThat(datasetCount).as("training_dataset 搴斿凡鍒涘缓").isEqualTo(1);
assertThat(datasetCount).as("training_dataset 应已创建").isEqualTo(1);
}
// ------------------------------------------------------------------ 娴嬭瘯 2: 鑷杩斿洖 403 --
// ------------------------------------------------------------------ 测试 2: 自审返回 403 --
@Test
@DisplayName("瀹℃壒浜轰笌浠诲姟棰嗗彇浜虹浉鍚岋紙鑷锛夆啋 403 SELF_REVIEW_FORBIDDEN")
@DisplayName("审批人与任务领取人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN")
void approveOwnSubmission_returnsForbidden() {
// 鐩存帴灏嗕换鍔$疆涓?SUBMITTED 骞惰 claimed_by = reviewer01锛堟ā鎷熻嚜瀹″満鏅級
// 直接将任务置为 SUBMITTED 并设 claimed_by = reviewer01(模拟自审场景)
jdbcTemplate.execute(
"UPDATE annotation_task " +
"SET status = 'SUBMITTED', claimed_by = " + reviewerUserId +
@@ -126,63 +132,67 @@ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest {
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
// 楠岃瘉浠诲姟鐘舵€佹湭鍙? String status = jdbcTemplate.queryForObject(
// 验证任务状态未变
String status = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(status).isEqualTo("SUBMITTED");
}
// ------------------------------------------------------------------ 娴嬭瘯 3: 椹冲洖 鈫?閲嶉 鈫?鍐嶆彁浜?--
// ------------------------------------------------------------------ 测试 3: 驳回 → 重领 → 再提交 --
@Test
@DisplayName("椹冲洖鍚庢爣娉ㄥ憳鍙噸棰嗕换鍔″苟鍐嶆鎻愪氦锛屼换鍔$姸鎬佹仮澶嶄负 SUBMITTED")
@DisplayName("驳回后标注员可重领任务并再次提交,任务状态恢复为 SUBMITTED")
void rejectThenReclaimAndResubmit_succeeds() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 1. 鏍囨敞鍛橀鍙栧苟鎻愪氦
// 1. 标注员领取并提交
restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
restTemplate.exchange(baseUrl("/api/extraction/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
// 2. 瀹℃牳鍛橀┏鍥烇紙椹冲洖鍘熷洜蹇呭~锛? HttpHeaders rejectHeaders = new HttpHeaders();
// 2. 审核员驳回(驳回原因必填)
HttpHeaders rejectHeaders = new HttpHeaders();
rejectHeaders.set("Authorization", "Bearer " + reviewerToken);
rejectHeaders.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> rejectReq = new HttpEntity<>(
Map.of("reason", "瀹炰綋璇嗗埆鏈夎锛岃閲嶆柊鏍囨敞"), rejectHeaders);
Map.of("reason", "实体识别有误,请重新标注"), rejectHeaders);
ResponseEntity<Map> 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<Map> reclaimResp = restTemplate.exchange(
// 3. 标注员重领任务(REJECTED IN_PROGRESS
ResponseEntity<Map> 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<Map> resubmitResp = restTemplate.exchange(
// 4. 标注员再次提交(IN_PROGRESS SUBMITTED
ResponseEntity<Map> 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();

View File

@@ -1,7 +1,7 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.dto.LoginRequest;
import com.label.module.user.dto.LoginRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -14,9 +14,11 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* QA 闂瓟鐢熸垚闃舵瀹℃壒闆嗘垚娴嬭瘯锛圲S5锛夈€? *
* 娴嬭瘯鍦烘櫙锛? * 1. QA 瀹℃壒閫氳繃 鈫?training_dataset.status = APPROVED锛宻ource_data.status = APPROVED
* 2. QA 椹冲洖 鈫?鍊欓€夐棶绛斿琚垹闄わ紝鏍囨敞鍛樺彲閲嶉
* QA 问答生成阶段审批集成测试US5
*
* 测试场景:
* 1. QA 审批通过 → training_dataset.status = APPROVEDsource_data.status = APPROVED
* 2. QA 驳回 → 候选问答对被删除,标注员可重领
*/
public class QaApprovalIntegrationTest extends AbstractIntegrationTest {
@@ -38,7 +40,7 @@ public class QaApprovalIntegrationTest extends AbstractIntegrationTest {
Long companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
// 鎻掑叆 source_data锛圦A_REVIEW 鐘舵€侊紝妯℃嫙鎻愬彇瀹℃壒宸插畬鎴愶級
// 插入 source_dataQA_REVIEW 状态,模拟提取审批已完成)
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
@@ -47,123 +49,129 @@ public class QaApprovalIntegrationTest extends AbstractIntegrationTest {
sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
// 鎻掑叆 QA_GENERATION 浠诲姟锛圲NCLAIMED 鐘舵€侊紝妯℃嫙鎻愬彇瀹℃壒閫氳繃鍚庤嚜鍔ㄥ垱寤虹殑 QA 浠诲姟锛? jdbcTemplate.execute(
// 插入 QA_GENERATION 任务UNCLAIMED 状态,模拟提取审批通过后自动创建的 QA 任务)
jdbcTemplate.execute(
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
"VALUES (" + companyId + ", " + sourceId + ", 'QA_GENERATION', 'UNCLAIMED')");
taskId = jdbcTemplate.queryForObject(
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
// 鎻掑叆鍊欓€夐棶绛斿锛堟ā鎷?ExtractionApprovedEventListener 鍒涘缓锛? jdbcTemplate.execute(
// 插入候选问答对(模拟 ExtractionApprovedEventListener 创建)
jdbcTemplate.execute(
"INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " +
"glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId +
", 'TEXT', '{\"conversations\":[{\"question\":\"鍖椾含鏄摢涓浗瀹剁殑棣栭兘锛焅",\"answer\":\"涓浗\"}]}'::jsonb, " +
", 'TEXT', '{\"conversations\":[{\"question\":\"北京是哪个国家的首都?\",\"answer\":\"中国\"}]}'::jsonb, " +
"'PENDING_REVIEW')");
datasetId = jdbcTemplate.queryForObject(
"SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class);
}
// ------------------------------------------------------------------ 娴嬭瘯 1: 瀹℃壒閫氳繃 鈫?缁堟€?--
// ------------------------------------------------------------------ 测试 1: 审批通过 → 终态 --
@Test
@DisplayName("QA 瀹℃壒閫氳繃 鈫?training_dataset.status=APPROVED锛宻ource_data.status=APPROVED")
@DisplayName("QA 审批通过 → training_dataset.status=APPROVEDsource_data.status=APPROVED")
void approveQaTask_thenDatasetAndSourceApproved() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 娉ㄦ剰锛歈A 浠诲姟 claim 绔偣涓?POST /api/tasks/{id}/claim锛圓NNOTATOR 瑙掕壊锛? // 浣?TaskController.getPool 鍙粰 ANNOTATOR 鏄剧ず EXTRACTION/UNCLAIMED
// QA 浠诲姟鐢?ANNOTATOR 鐩存帴棰嗗彇锛堜笉缁忚繃浠诲姟姹狅級
// 注意QA 任务 claim 端点为 POST /api/tasks/{id}/claimANNOTATOR 角色)
// 但 TaskController.getPool 只给 ANNOTATOR 显示 EXTRACTION/UNCLAIMED
// QA 任务由 ANNOTATOR 直接领取(不经过任务池)
ResponseEntity<Map> claimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 鎻愪氦 QA 缁撴灉
// 提交 QA 结果
ResponseEntity<Map> submitResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 瀹℃壒閫氳繃
// 审批通过
ResponseEntity<Map> approveResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/approve"),
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 楠岃瘉锛歵raining_dataset 鈫?APPROVED
// 验证training_dataset APPROVED
String datasetStatus = jdbcTemplate.queryForObject(
"SELECT status FROM training_dataset WHERE id = ?", String.class, datasetId);
assertThat(datasetStatus).as("training_dataset 鐘舵€佸簲涓?APPROVED").isEqualTo("APPROVED");
assertThat(datasetStatus).as("training_dataset 状态应为 APPROVED").isEqualTo("APPROVED");
// 楠岃瘉锛歛nnotation_task 鈫?APPROVED锛宨s_final=true
// 验证annotation_task APPROVEDis_final=true
Map<String, Object> taskRow = jdbcTemplate.queryForMap(
"SELECT status, is_final FROM annotation_task WHERE id = ?", taskId);
assertThat(taskRow.get("status")).isEqualTo("APPROVED");
assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE);
// 楠岃瘉锛歴ource_data 鈫?APPROVED锛堟暣鏉℃祦姘寸嚎瀹屾垚锛? String sourceStatus = jdbcTemplate.queryForObject(
// 验证source_data APPROVED(整条流水线完成)
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("source_data 鐘舵€佸簲涓?APPROVED锛堟祦姘寸嚎缁堟€侊級").isEqualTo("APPROVED");
assertThat(sourceStatus).as("source_data 状态应为 APPROVED(流水线终态)").isEqualTo("APPROVED");
}
// ------------------------------------------------------------------ 娴嬭瘯 2: 椹冲洖 鈫?鍊欓€夎褰曞垹闄?鈫?鍙噸棰?--
// ------------------------------------------------------------------ 测试 2: 驳回 → 候选记录删除 → 可重领 --
@Test
@DisplayName("QA 椹冲洖 鈫?鍊欓€夐棶绛斿琚垹闄わ紝鏍囨敞鍛樺彲閲嶉骞跺啀娆℃彁浜?)
@DisplayName("QA 驳回 → 候选问答对被删除,标注员可重领并再次提交")
void rejectQaTask_thenDatasetDeletedAndReclaimable() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 棰嗗彇骞舵彁浜? restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"),
// 领取并提交
restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
restTemplate.exchange(baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
// 椹冲洖锛堥┏鍥炲師鍥犲繀濉級
// 驳回(驳回原因必填)
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + reviewerToken);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> rejectReq = new HttpEntity<>(
Map.of("reason", "鎻忚堪涓嶅噯纭紝璇蜂慨鏀?), headers);
Map.of("reason", "问题描述不准确,请修改"), headers);
ResponseEntity<Map> rejectResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/reject"),
HttpMethod.POST, rejectReq, Map.class);
assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 楠岃瘉锛氫换鍔$姸鎬佸彉涓?REJECTED
// 验证:任务状态变为 REJECTED
String statusAfterReject = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(statusAfterReject).isEqualTo("REJECTED");
// 楠岃瘉锛氬€欓€夐棶绛斿宸茶鍒犻櫎
// 验证:候选问答对已被删除
Integer datasetCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM training_dataset WHERE task_id = ?",
Integer.class, taskId);
assertThat(datasetCount).as("椹冲洖鍚庡€欓€夐棶绛斿搴旇鍒犻櫎").isEqualTo(0);
assertThat(datasetCount).as("驳回后候选问答对应被删除").isEqualTo(0);
// 楠岃瘉锛歴ource_data 淇濇寔 QA_REVIEW锛堜笉鍙橈級
// 验证source_data 保持 QA_REVIEW(不变)
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("椹冲洖鍚?source_data 搴斾繚鎸?QA_REVIEW").isEqualTo("QA_REVIEW");
assertThat(sourceStatus).as("驳回后 source_data 应保持 QA_REVIEW").isEqualTo("QA_REVIEW");
// 鏍囨敞鍛橀噸棰嗕换鍔? ResponseEntity<Map> reclaimResp = restTemplate.exchange(
// 标注员重领任务
ResponseEntity<Map> reclaimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/reclaim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 鍐嶆鎻愪氦
// 再次提交
ResponseEntity<Map> 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();

View File

@@ -1,7 +1,7 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.dto.LoginRequest;
import com.label.module.user.dto.LoginRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -15,8 +15,11 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 鐢ㄦ埛绠悊闆嗘垚娴嬭瘯锛圲S7锛夈€? *
* 娴嬭瘯鍦烘櫙锛? * 1. 鍙樻洿瑙掕壊鍚庢潈闄愪笅涓€娆¤姹傜珛鍗崇敓鏁堬紙鏃犻渶閲嶆柊鐧诲綍锛? * 2. 绂佺敤璐﹀彿鍚庣幇鏈?Token 涓嬩竴娆¤姹傜珛鍗宠繑鍥?401
* 用户管理集成测试US7
*
* 测试场景:
* 1. 变更角色后权限下一次请求立即生效(无需重新登录)
* 2. 禁用账号后现有 Token 下一次请求立即返回 401
*/
public class UserManagementIntegrationTest extends AbstractIntegrationTest {
@@ -31,14 +34,14 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
assertThat(adminToken).isNotBlank();
}
// ------------------------------------------------------------------ 娴嬭瘯 1: 瑙掕壊鍙樻洿绔嬪嵆鐢熸晥 --
// ------------------------------------------------------------------ 测试 1: 角色变更立即生效 --
@Test
@DisplayName("鍒涘缓鐢ㄦ埛涓?ANNOTATOR锛屽彉鏇翠负 REVIEWER 鍚庡悓涓€ Token 绔嬪嵆鍙闂鎵规帴鍙?)
@DisplayName("创建用户为 ANNOTATOR,变更为 REVIEWER 后同一 Token 立即可访问审批接口")
void updateRole_takesEffectImmediately() {
String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8);
// 1. 鍒涘缓 ANNOTATOR 鐢ㄦ埛
// 1. 创建 ANNOTATOR 用户
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + adminToken);
headers.setContentType(MediaType.APPLICATION_JSON);
@@ -49,7 +52,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
new HttpEntity<>(Map.of(
"username", uniqueUsername,
"password", "test1234",
"realName", "娴嬭瘯鐢ㄦ埛",
"realName", "测试用户",
"role", "ANNOTATOR"
), headers),
Map.class);
@@ -59,11 +62,11 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
Map<String, Object> userData = (Map<String, Object>) createResp.getBody().get("data");
Long newUserId = ((Number) userData.get("id")).longValue();
// 2. 鏂扮敤鎴风櫥褰曡幏鍙?Token
// 2. 新用户登录获取 Token
String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234");
assertThat(userToken).isNotBlank();
// 3. 楠岃瘉锛欰NNOTATOR 鏃犳硶璁块棶寰呭鎵归槦鍒楋紙REVIEWER 涓撳睘锛夆啋 403
// 3. 验证ANNOTATOR 无法访问待审批队列(REVIEWER 专属)→ 403
ResponseEntity<Map> beforeRoleChange = restTemplate.exchange(
baseUrl("/api/tasks/pending-review"),
HttpMethod.GET,
@@ -71,7 +74,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
Map.class);
assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
// 4. ADMIN 鍙樻洿瑙掕壊涓?REVIEWER
// 4. ADMIN 变更角色为 REVIEWER
ResponseEntity<Map> roleResp = restTemplate.exchange(
baseUrl("/api/users/" + newUserId + "/role"),
HttpMethod.PUT,
@@ -79,25 +82,25 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
Map.class);
assertThat(roleResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 5. 楠岃瘉锛氬悓涓€ Token 涓嬫璇锋眰绔嬪嵆鍏锋湁 REVIEWER 鏉冮檺 鈫?200
// 5. 验证:同一 Token 下次请求立即具有 REVIEWER 权限 → 200
ResponseEntity<Map> afterRoleChange = restTemplate.exchange(
baseUrl("/api/tasks/pending-review"),
HttpMethod.GET,
bearerRequest(userToken),
Map.class);
assertThat(afterRoleChange.getStatusCode())
.as("瑙掕壊鍙樻洿鍚庡悓涓 Token 搴旂珛鍗冲叿鏈?REVIEWER 鏉冮檺")
.as("角色变更后同一 Token 应立即具有 REVIEWER 权限")
.isEqualTo(HttpStatus.OK);
}
// ------------------------------------------------------------------ 娴嬭瘯 2: 绂佺敤璐﹀彿 Token 绔嬪嵆澶辨晥 --
// ------------------------------------------------------------------ 测试 2: 禁用账号 Token 立即失效 --
@Test
@DisplayName("绂佺敤璐彿鍚庯紝鐜版湁 Token 涓嬩竴娆¤姹傜珛鍗宠繑鍥?401")
@DisplayName("禁用账号后,现有 Token 下一次请求立即返回 401")
void disableAccount_tokenInvalidatedImmediately() {
String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8);
// 1. 鍒涘缓鐢ㄦ埛
// 1. 创建用户
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + adminToken);
headers.setContentType(MediaType.APPLICATION_JSON);
@@ -108,7 +111,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
new HttpEntity<>(Map.of(
"username", uniqueUsername,
"password", "test1234",
"realName", "娴嬭瘯鐢ㄦ埛",
"realName", "测试用户",
"role", "ANNOTATOR"
), headers),
Map.class);
@@ -118,11 +121,11 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
Map<String, Object> userData = (Map<String, Object>) 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<Map> meResp = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
@@ -130,7 +133,7 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
Map.class);
assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 4. ADMIN 绂佺敤璐﹀彿
// 4. ADMIN 禁用账号
ResponseEntity<Map> disableResp = restTemplate.exchange(
baseUrl("/api/users/" + newUserId + "/status"),
HttpMethod.PUT,
@@ -138,18 +141,18 @@ public class UserManagementIntegrationTest extends AbstractIntegrationTest {
Map.class);
assertThat(disableResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 5. 楠岃瘉锛氱鐢ㄥ悗锛岀幇鏈?Token 绔嬪嵆澶辨晥 鈫?401
// 5. 验证:禁用后,现有 Token 立即失效 → 401
ResponseEntity<Map> 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();

View File

@@ -5,14 +5,14 @@ import com.label.module.annotation.controller.QaController;
import com.label.module.config.controller.SysConfigController;
import com.label.module.export.controller.ExportController;
import com.label.module.source.controller.SourceController;
import com.label.dto.SourceResponse;
import com.label.module.source.dto.SourceResponse;
import com.label.module.task.controller.TaskController;
import com.label.dto.TaskResponse;
import com.label.module.task.dto.TaskResponse;
import com.label.module.user.controller.AuthController;
import com.label.module.user.controller.UserController;
import com.label.dto.LoginRequest;
import com.label.dto.LoginResponse;
import com.label.dto.UserInfoResponse;
import com.label.module.user.dto.LoginRequest;
import com.label.module.user.dto.LoginResponse;
import com.label.module.user.dto.UserInfoResponse;
import com.label.module.video.controller.VideoController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -32,7 +32,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("OpenAPI 娉ㄨВ瑕嗙洊娴嬭瘯")
@DisplayName("OpenAPI 注解覆盖测试")
class OpenApiAnnotationTest {
private static final List<Class<?>> CONTROLLERS = List.of(
@@ -56,7 +56,7 @@ class OpenApiAnnotationTest {
);
@Test
@DisplayName("鎵€鏈?REST Controller 閮藉0鏄?@Tag")
@DisplayName("所有 REST Controller 都声明 @Tag")
void allControllersHaveTag() {
assertThat(CONTROLLERS)
.allSatisfy(controller ->
@@ -66,7 +66,7 @@ class OpenApiAnnotationTest {
}
@Test
@DisplayName("鎵€鏈?REST endpoint 鏂规硶閮藉0鏄?@Operation")
@DisplayName("所有 REST endpoint 方法都声明 @Operation")
void allEndpointMethodsHaveOperation() {
for (Class<?> controller : CONTROLLERS) {
Arrays.stream(controller.getDeclaredMethods())
@@ -79,7 +79,7 @@ class OpenApiAnnotationTest {
}
@Test
@DisplayName("鏍稿績 DTO 閮藉0鏄?@Schema")
@DisplayName("核心 DTO 都声明 @Schema")
void coreDtosHaveSchema() {
assertThat(DTOS)
.allSatisfy(dto ->

View File

@@ -29,39 +29,6 @@ class PackageStructureMigrationTest {
assertClassMissing("com.label.module.annotation.service.ExtractionApprovedEventListener");
}
@Test
@DisplayName("DTO、实体、Mapper 已迁移到扁平数据层")
void dataTypesMoved() {
for (String fqcn : java.util.List.of(
"com.label.dto.LoginRequest",
"com.label.dto.LoginResponse",
"com.label.dto.UserInfoResponse",
"com.label.dto.TaskResponse",
"com.label.dto.SourceResponse",
"com.label.entity.AnnotationResult",
"com.label.entity.TrainingDataset",
"com.label.entity.SysConfig",
"com.label.entity.ExportBatch",
"com.label.entity.SourceData",
"com.label.entity.AnnotationTask",
"com.label.entity.AnnotationTaskHistory",
"com.label.entity.SysCompany",
"com.label.entity.SysUser",
"com.label.entity.VideoProcessJob",
"com.label.mapper.AnnotationResultMapper",
"com.label.mapper.TrainingDatasetMapper",
"com.label.mapper.SysConfigMapper",
"com.label.mapper.ExportBatchMapper",
"com.label.mapper.SourceDataMapper",
"com.label.mapper.AnnotationTaskMapper",
"com.label.mapper.TaskHistoryMapper",
"com.label.mapper.SysCompanyMapper",
"com.label.mapper.SysUserMapper",
"com.label.mapper.VideoProcessJobMapper")) {
assertClassExists(fqcn);
}
}
private static void assertClassExists(String fqcn) {
assertThatCode(() -> Class.forName(fqcn)).doesNotThrowAnyException();
}