2026-04-14 13:45:15 +08:00
|
|
|
package com.label.service;
|
2026-04-09 16:18:39 +08:00
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
2026-04-17 01:20:27 +08:00
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
2026-04-09 16:18:39 +08:00
|
|
|
import com.label.common.ai.AiServiceClient;
|
|
|
|
|
import com.label.common.exception.BusinessException;
|
|
|
|
|
import com.label.common.statemachine.StateValidator;
|
2026-04-17 01:20:27 +08:00
|
|
|
import com.label.common.statemachine.VideoSourceStatus;
|
2026-04-14 13:39:24 +08:00
|
|
|
import com.label.entity.SourceData;
|
|
|
|
|
import com.label.entity.VideoProcessJob;
|
2026-04-17 01:20:27 +08:00
|
|
|
import com.label.mapper.SourceDataMapper;
|
2026-04-14 13:39:24 +08:00
|
|
|
import com.label.mapper.VideoProcessJobMapper;
|
2026-04-09 16:18:39 +08:00
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
import org.springframework.http.HttpStatus;
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
import org.springframework.transaction.support.TransactionSynchronization;
|
|
|
|
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
|
|
|
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
|
|
|
|
|
@Slf4j
|
|
|
|
|
@Service
|
|
|
|
|
@RequiredArgsConstructor
|
|
|
|
|
public class VideoProcessService {
|
|
|
|
|
|
|
|
|
|
private final VideoProcessJobMapper jobMapper;
|
|
|
|
|
private final SourceDataMapper sourceDataMapper;
|
|
|
|
|
private final AiServiceClient aiServiceClient;
|
2026-04-17 01:20:27 +08:00
|
|
|
private final ObjectMapper objectMapper;
|
2026-04-09 16:18:39 +08:00
|
|
|
|
|
|
|
|
@Transactional
|
2026-04-17 01:20:27 +08:00
|
|
|
public VideoProcessJob createJob(Long sourceId, String jobType, String params, Long companyId) {
|
2026-04-09 16:18:39 +08:00
|
|
|
SourceData source = sourceDataMapper.selectById(sourceId);
|
|
|
|
|
if (source == null || !companyId.equals(source.getCompanyId())) {
|
2026-04-17 01:20:27 +08:00
|
|
|
throw new BusinessException("NOT_FOUND", "资料不存在 " + sourceId, HttpStatus.NOT_FOUND);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateJobType(jobType);
|
|
|
|
|
|
|
|
|
|
StateValidator.assertTransition(
|
2026-04-17 01:20:27 +08:00
|
|
|
VideoSourceStatus.TRANSITIONS,
|
|
|
|
|
VideoSourceStatus.valueOf(source.getStatus()),
|
|
|
|
|
VideoSourceStatus.PREPROCESSING
|
|
|
|
|
);
|
2026-04-09 16:18:39 +08:00
|
|
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
|
|
|
|
.eq(SourceData::getId, sourceId)
|
|
|
|
|
.set(SourceData::getStatus, "PREPROCESSING")
|
|
|
|
|
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
|
|
|
VideoProcessJob job = new VideoProcessJob();
|
|
|
|
|
job.setCompanyId(companyId);
|
|
|
|
|
job.setSourceId(sourceId);
|
|
|
|
|
job.setJobType(jobType);
|
|
|
|
|
job.setStatus("PENDING");
|
|
|
|
|
job.setParams(params != null ? params : "{}");
|
|
|
|
|
job.setRetryCount(0);
|
|
|
|
|
job.setMaxRetries(3);
|
|
|
|
|
jobMapper.insert(job);
|
|
|
|
|
|
2026-04-17 01:20:27 +08:00
|
|
|
final Long jobId = job.getId();
|
2026-04-09 16:18:39 +08:00
|
|
|
final String filePath = source.getFilePath();
|
|
|
|
|
final String finalJobType = jobType;
|
2026-04-17 01:20:27 +08:00
|
|
|
final String finalParams = job.getParams();
|
2026-04-09 16:18:39 +08:00
|
|
|
|
|
|
|
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
|
|
|
@Override
|
|
|
|
|
public void afterCommit() {
|
2026-04-17 01:20:27 +08:00
|
|
|
triggerAi(jobId, sourceId, filePath, finalJobType, finalParams);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 01:20:27 +08:00
|
|
|
log.info("视频处理任务已创建: jobId={}, sourceId={}", jobId, sourceId);
|
2026-04-09 16:18:39 +08:00
|
|
|
return job;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Transactional
|
2026-04-17 01:20:27 +08:00
|
|
|
public void handleCallback(Long jobId, String callbackStatus, String outputPath, String errorMessage) {
|
2026-04-09 16:18:39 +08:00
|
|
|
VideoProcessJob job = jobMapper.selectById(jobId);
|
2026-04-09 19:42:20 +08:00
|
|
|
if (job == null || job.getCompanyId() == null) {
|
2026-04-17 01:20:27 +08:00
|
|
|
log.warn("视频处理回调时 job 不存在: jobId={}", jobId);
|
2026-04-09 16:18:39 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
if ("SUCCESS".equals(job.getStatus())) {
|
2026-04-17 01:20:27 +08:00
|
|
|
log.info("视频处理回调幂等跳过: jobId={}", jobId);
|
2026-04-09 16:18:39 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ("SUCCESS".equals(callbackStatus)) {
|
|
|
|
|
handleSuccess(job, outputPath);
|
|
|
|
|
} else {
|
|
|
|
|
handleFailure(job, errorMessage);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public VideoProcessJob reset(Long jobId, Long companyId) {
|
|
|
|
|
VideoProcessJob job = jobMapper.selectById(jobId);
|
|
|
|
|
if (job == null || !companyId.equals(job.getCompanyId())) {
|
2026-04-17 01:20:27 +08:00
|
|
|
throw new BusinessException("NOT_FOUND", "视频处理任务不存在 " + jobId, HttpStatus.NOT_FOUND);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!"FAILED".equals(job.getStatus())) {
|
2026-04-17 01:20:27 +08:00
|
|
|
throw new BusinessException(
|
|
|
|
|
"INVALID_TRANSITION",
|
|
|
|
|
"只有 FAILED 状态的任务可以重置,当前状态 " + job.getStatus(),
|
|
|
|
|
HttpStatus.BAD_REQUEST
|
|
|
|
|
);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
|
|
|
|
.eq(VideoProcessJob::getId, jobId)
|
|
|
|
|
.set(VideoProcessJob::getStatus, "PENDING")
|
|
|
|
|
.set(VideoProcessJob::getRetryCount, 0)
|
|
|
|
|
.set(VideoProcessJob::getErrorMessage, null)
|
|
|
|
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
|
|
|
job.setStatus("PENDING");
|
|
|
|
|
job.setRetryCount(0);
|
|
|
|
|
return job;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public VideoProcessJob getJob(Long jobId, Long companyId) {
|
|
|
|
|
VideoProcessJob job = jobMapper.selectById(jobId);
|
|
|
|
|
if (job == null || !companyId.equals(job.getCompanyId())) {
|
2026-04-17 01:20:27 +08:00
|
|
|
throw new BusinessException("NOT_FOUND", "视频处理任务不存在 " + jobId, HttpStatus.NOT_FOUND);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
return job;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void handleSuccess(VideoProcessJob job, String outputPath) {
|
|
|
|
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
|
|
|
|
.eq(VideoProcessJob::getId, job.getId())
|
|
|
|
|
.set(VideoProcessJob::getStatus, "SUCCESS")
|
|
|
|
|
.set(VideoProcessJob::getOutputPath, outputPath)
|
|
|
|
|
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
|
|
|
|
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
|
|
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
|
|
|
|
.eq(SourceData::getId, job.getSourceId())
|
|
|
|
|
.set(SourceData::getStatus, "PENDING")
|
|
|
|
|
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void handleFailure(VideoProcessJob job, String errorMessage) {
|
|
|
|
|
int newRetryCount = job.getRetryCount() + 1;
|
|
|
|
|
int maxRetries = job.getMaxRetries() != null ? job.getMaxRetries() : 3;
|
|
|
|
|
|
|
|
|
|
if (newRetryCount < maxRetries) {
|
|
|
|
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
|
|
|
|
.eq(VideoProcessJob::getId, job.getId())
|
|
|
|
|
.set(VideoProcessJob::getStatus, "RETRYING")
|
|
|
|
|
.set(VideoProcessJob::getRetryCount, newRetryCount)
|
|
|
|
|
.set(VideoProcessJob::getErrorMessage, errorMessage)
|
|
|
|
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
|
|
|
SourceData source = sourceDataMapper.selectById(job.getSourceId());
|
|
|
|
|
if (source != null) {
|
2026-04-17 01:20:27 +08:00
|
|
|
final Long jobId = job.getId();
|
|
|
|
|
final Long sourceId = job.getSourceId();
|
2026-04-09 16:18:39 +08:00
|
|
|
final String filePath = source.getFilePath();
|
2026-04-17 01:20:27 +08:00
|
|
|
final String jobType = job.getJobType();
|
|
|
|
|
final String params = job.getParams();
|
2026-04-09 16:18:39 +08:00
|
|
|
|
|
|
|
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
|
|
|
@Override
|
|
|
|
|
public void afterCommit() {
|
2026-04-17 01:20:27 +08:00
|
|
|
triggerAi(jobId, sourceId, filePath, jobType, params);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
jobMapper.update(null, new LambdaUpdateWrapper<VideoProcessJob>()
|
|
|
|
|
.eq(VideoProcessJob::getId, job.getId())
|
|
|
|
|
.set(VideoProcessJob::getStatus, "FAILED")
|
|
|
|
|
.set(VideoProcessJob::getRetryCount, newRetryCount)
|
|
|
|
|
.set(VideoProcessJob::getErrorMessage, errorMessage)
|
|
|
|
|
.set(VideoProcessJob::getCompletedAt, LocalDateTime.now())
|
|
|
|
|
.set(VideoProcessJob::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
|
|
|
sourceDataMapper.update(null, new LambdaUpdateWrapper<SourceData>()
|
|
|
|
|
.eq(SourceData::getId, job.getSourceId())
|
|
|
|
|
.set(SourceData::getStatus, "PENDING")
|
|
|
|
|
.set(SourceData::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 01:20:27 +08:00
|
|
|
private void triggerAi(Long jobId, Long sourceId, String filePath, String jobType, String paramsJson) {
|
|
|
|
|
Map<String, Object> params = parseParams(paramsJson);
|
2026-04-09 16:18:39 +08:00
|
|
|
try {
|
|
|
|
|
if ("FRAME_EXTRACT".equals(jobType)) {
|
2026-04-17 01:20:27 +08:00
|
|
|
aiServiceClient.extractFrames(AiServiceClient.ExtractFramesRequest.builder()
|
|
|
|
|
.filePath(filePath)
|
|
|
|
|
.sourceId(sourceId)
|
|
|
|
|
.jobId(jobId)
|
|
|
|
|
.mode(stringParam(params, "mode", "interval"))
|
|
|
|
|
.frameInterval(intParam(params, "frameInterval", 30))
|
|
|
|
|
.build());
|
2026-04-09 16:18:39 +08:00
|
|
|
} else {
|
2026-04-17 01:20:27 +08:00
|
|
|
aiServiceClient.videoToText(AiServiceClient.VideoToTextRequest.builder()
|
|
|
|
|
.filePath(filePath)
|
|
|
|
|
.sourceId(sourceId)
|
|
|
|
|
.jobId(jobId)
|
|
|
|
|
.startSec(doubleParam(params, "startSec", 0.0))
|
|
|
|
|
.endSec(doubleParam(params, "endSec", 120.0))
|
|
|
|
|
.model(stringParam(params, "model", null))
|
|
|
|
|
.promptTemplate(stringParam(params, "promptTemplate", null))
|
|
|
|
|
.build());
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
2026-04-17 01:20:27 +08:00
|
|
|
log.info("AI 视频任务已触发: jobId={}", jobId);
|
2026-04-09 16:18:39 +08:00
|
|
|
} catch (Exception e) {
|
2026-04-17 01:20:27 +08:00
|
|
|
log.error("触发视频处理 AI 失败(jobId={}): {}", jobId, e.getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Map<String, Object> parseParams(String paramsJson) {
|
|
|
|
|
if (paramsJson == null || paramsJson.isBlank()) {
|
|
|
|
|
return Map.of();
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return objectMapper.readValue(paramsJson, new TypeReference<>() {});
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
log.warn("解析视频处理参数失败,将使用默认值: {}", e.getMessage());
|
|
|
|
|
return Map.of();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String stringParam(Map<String, Object> params, String key, String defaultValue) {
|
|
|
|
|
Object value = params.get(key);
|
|
|
|
|
return value == null ? defaultValue : String.valueOf(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Integer intParam(Map<String, Object> params, String key, Integer defaultValue) {
|
|
|
|
|
Object value = params.get(key);
|
|
|
|
|
if (value instanceof Number number) {
|
|
|
|
|
return number.intValue();
|
|
|
|
|
}
|
|
|
|
|
if (value instanceof String text && !text.isBlank()) {
|
|
|
|
|
return Integer.parseInt(text);
|
|
|
|
|
}
|
|
|
|
|
return defaultValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Double doubleParam(Map<String, Object> params, String key, Double defaultValue) {
|
|
|
|
|
Object value = params.get(key);
|
|
|
|
|
if (value instanceof Number number) {
|
|
|
|
|
return number.doubleValue();
|
|
|
|
|
}
|
|
|
|
|
if (value instanceof String text && !text.isBlank()) {
|
|
|
|
|
return Double.parseDouble(text);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
2026-04-17 01:20:27 +08:00
|
|
|
return defaultValue;
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void validateJobType(String jobType) {
|
|
|
|
|
if (!"FRAME_EXTRACT".equals(jobType) && !"VIDEO_TO_TEXT".equals(jobType)) {
|
2026-04-17 01:20:27 +08:00
|
|
|
throw new BusinessException(
|
|
|
|
|
"INVALID_JOB_TYPE",
|
|
|
|
|
"任务类型不合法,应为 FRAME_EXTRACT 或 VIDEO_TO_TEXT",
|
|
|
|
|
HttpStatus.BAD_REQUEST
|
|
|
|
|
);
|
2026-04-09 16:18:39 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|