package com.doumee.service.business.impl.collection; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.doumee.biz.system.SystemDictDataBiz; import com.doumee.core.constants.ResponseStatus; import com.doumee.core.exception.BusinessException; import com.doumee.core.utils.Constants; import com.doumee.core.utils.DateUtil; import com.doumee.core.utils.FtpUtil; import com.doumee.service.business.collection.CollectionMediaConstants; import com.doumee.service.business.collection.MediaFrameUtil; import com.doumee.dao.admin.request.DeliverySnapshotManualRequest; import com.doumee.dao.business.CollectionMediaMapper; import com.doumee.dao.business.DeliveryMediaSnapshotFeedbackMapper; import com.doumee.dao.business.DeliveryMediaSnapshotMapper; import com.doumee.dao.business.model.CollectionMedia; import com.doumee.dao.business.model.DeliveryMediaSnapshot; import com.doumee.dao.business.model.DeliveryMediaSnapshotFeedback; import com.doumee.service.business.DeliverySnapshotService; import com.doumee.service.business.snapshot.SnapshotAnalyzeRequest; import com.doumee.service.business.snapshot.SnapshotAnalyzeResponse; import com.doumee.service.business.snapshot.SnapshotInferClient; import com.doumee.service.business.snapshot.SnapshotInferProperties; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Date; import java.util.List; import java.util.UUID; import java.util.concurrent.Executor; @Slf4j @Service public class DeliverySnapshotServiceImpl implements DeliverySnapshotService { @Autowired private CollectionMediaMapper collectionMediaMapper; @Autowired private DeliveryMediaSnapshotMapper deliveryMediaSnapshotMapper; @Autowired private DeliveryMediaSnapshotFeedbackMapper deliveryMediaSnapshotFeedbackMapper; @Autowired private SystemDictDataBiz systemDictDataBiz; @Autowired private SnapshotInferClient snapshotInferClient; @Autowired private SnapshotInferProperties snapshotInferProperties; @Resource(name = "asyncExecutor") private Executor asyncExecutor; @Override public String submitAnalyze(Integer mediaId) { CollectionMedia media = requireDownloadedMedia(mediaId); if (Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING)) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "快照分析进行中,请稍后刷新"); } markSnapshotProcessing(mediaId); asyncExecutor.execute(() -> executeAnalyze(mediaId)); return "已提交快照分析任务,请稍后刷新查看"; } @Override public void submitAnalyzeAsync(Integer mediaId) { if (!snapshotInferProperties.isAutoOnDownload()) { return; } try { CollectionMedia media = collectionMediaMapper.selectById(mediaId); if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) { return; } if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE) || StringUtils.isBlank(media.getFilePathLocal())) { return; } if (Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING) || Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_DONE)) { return; } if (media.getMediaType() != null && media.getMediaType() != 0) { return; } markSnapshotProcessing(mediaId); asyncExecutor.execute(() -> executeAnalyze(mediaId)); } catch (Exception e) { log.warn("自动提交快照分析失败 mediaId={}: {}", mediaId, e.getMessage()); } } @Override public List listByMediaId(Integer mediaId) { List list = deliveryMediaSnapshotMapper.selectList(new QueryWrapper().lambda() .eq(DeliveryMediaSnapshot::getMediaId, mediaId) .eq(DeliveryMediaSnapshot::getIsdeleted, Constants.ZERO) .orderByAsc(DeliveryMediaSnapshot::getSnapshotType)); list.forEach(this::fillSnapshotUrl); return list; } @Override public String saveManual(DeliverySnapshotManualRequest request) { if (request == null || request.getMediaId() == null || request.getSnapshotType() == null || request.getTimestampSec() == null) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "参数不完整"); } if (!Constants.equalsInteger(request.getSnapshotType(), CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT) && !Constants.equalsInteger(request.getSnapshotType(), CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER)) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "快照类型无效"); } CollectionMedia media = requireDownloadedMedia(request.getMediaId()); File videoFile = null; File frameFile = null; try { videoFile = downloadMediaToTemp(media); frameFile = File.createTempFile("hk_snapshot_", ".jpg"); if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, frameFile, request.getTimestampSec().doubleValue())) { throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "截帧失败"); } String relativePath = uploadSnapshot(frameFile, media.getId(), request.getSnapshotType()); saveFeedback(media.getId(), request.getSnapshotType(), request.getTimestampSec()); upsertSnapshot(media.getId(), request.getSnapshotType(), request.getTimestampSec(), relativePath, null, "manual", null); CollectionMedia update = new CollectionMedia(); update.setId(media.getId()); update.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_DONE); update.setSnapshotTime(new Date()); update.setSnapshotMessage(null); collectionMediaMapper.updateById(update); return "保存成功"; } catch (BusinessException e) { throw e; } catch (Exception e) { log.error("手动保存快照失败 mediaId={}: {}", request.getMediaId(), e.getMessage(), e); throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "保存快照失败"); } finally { deleteQuietly(videoFile); deleteQuietly(frameFile); } } private void executeAnalyze(Integer mediaId) { CollectionMedia media = collectionMediaMapper.selectById(mediaId); if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) { return; } File videoFile = null; File storefrontFrame = null; File handoverFrame = null; try { videoFile = downloadMediaToTemp(media); double duration = MediaFrameUtil.probeDurationSec(getFfmpegPath(), videoFile); if (duration <= 0) { duration = estimateDuration(media); } SnapshotAnalyzeRequest analyzeRequest = new SnapshotAnalyzeRequest(); analyzeRequest.setMediaId(mediaId); analyzeRequest.setVideoUrl(buildVideoUrl(media)); analyzeRequest.setSampleFps(snapshotInferProperties.getSampleFps()); analyzeRequest.setEnableAsr(snapshotInferProperties.isEnableAsr()); analyzeRequest.setDurationSec(duration); SnapshotAnalyzeResponse analyzeResponse = snapshotInferClient.analyze(analyzeRequest); if (Boolean.FALSE.equals(analyzeResponse.getSuccess())) { throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), StringUtils.defaultIfBlank(analyzeResponse.getMessage(), "快照推理失败")); } double storefrontSec = analyzeResponse.getStorefront().getTimeSec(); double handoverSec = analyzeResponse.getHandover().getTimeSec(); if (handoverSec <= storefrontSec) { handoverSec = Math.min(duration > 0 ? duration - 1 : storefrontSec + 60, storefrontSec + 60); } storefrontFrame = File.createTempFile("hk_storefront_", ".jpg"); handoverFrame = File.createTempFile("hk_handover_", ".jpg"); if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, storefrontFrame, storefrontSec)) { throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "门头图截帧失败"); } if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, handoverFrame, handoverSec)) { throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "交付图截帧失败"); } String storefrontPath = uploadSnapshot(storefrontFrame, mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT); String handoverPath = uploadSnapshot(handoverFrame, mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER); upsertSnapshot(mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT, BigDecimal.valueOf(storefrontSec).setScale(2, RoundingMode.HALF_UP), storefrontPath, analyzeResponse.getStorefront().getConfidence(), StringUtils.defaultIfBlank(analyzeResponse.getStorefront().getSource(), "ai"), analyzeResponse.getModelVersion()); upsertSnapshot(mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER, BigDecimal.valueOf(handoverSec).setScale(2, RoundingMode.HALF_UP), handoverPath, analyzeResponse.getHandover().getConfidence(), StringUtils.defaultIfBlank(analyzeResponse.getHandover().getSource(), "ai"), analyzeResponse.getModelVersion()); CollectionMedia done = new CollectionMedia(); done.setId(mediaId); done.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_DONE); done.setSnapshotTime(new Date()); done.setSnapshotMessage(null); collectionMediaMapper.updateById(done); log.info("快照分析完成 mediaId={} storefrontSec={} handoverSec={}", mediaId, storefrontSec, handoverSec); } catch (Exception e) { log.error("快照分析失败 mediaId={}: {}", mediaId, e.getMessage(), e); CollectionMedia fail = new CollectionMedia(); fail.setId(mediaId); fail.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_FAILED); fail.setSnapshotMessage(StringUtils.left(e.getMessage(), 500)); collectionMediaMapper.updateById(fail); } finally { deleteQuietly(videoFile); deleteQuietly(storefrontFrame); deleteQuietly(handoverFrame); } } private void markSnapshotProcessing(Integer mediaId) { CollectionMedia processing = new CollectionMedia(); processing.setId(mediaId); processing.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING); processing.setSnapshotMessage(null); collectionMediaMapper.updateById(processing); } private CollectionMedia requireDownloadedMedia(Integer mediaId) { CollectionMedia media = collectionMediaMapper.selectById(mediaId); if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) { throw new BusinessException(ResponseStatus.DATA_EMPTY); } if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE) || StringUtils.isBlank(media.getFilePathLocal())) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "请先下载媒体文件"); } return media; } private File downloadMediaToTemp(CollectionMedia media) throws IOException { FtpUtil ftp = createFtpClient(); File temp = File.createTempFile("hk_media_snap_", resolveSuffix(media)); String remote = getMediaFolder() + media.getFilePathLocal(); String local = ftp.download(remote, temp.getAbsolutePath()); if (StringUtils.isBlank(local)) { ftp.disconnect(); throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "下载媒体文件失败"); } ftp.disconnect(); return temp; } private String uploadSnapshot(File frameFile, Integer mediaId, int snapshotType) throws IOException { FtpUtil ftp = createFtpClient(); try { String folder = getSnapshotFolder(); String suffix = snapshotType == CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT ? "_storefront.jpg" : "_handover.jpg"; String relative = DateUtil.getNowShortDate() + "/" + mediaId + suffix; String remote = folder + relative; try (FileInputStream in = new FileInputStream(frameFile)) { if (!ftp.uploadInputstream(in, remote)) { throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "上传快照失败"); } } return relative; } finally { ftp.disconnect(); } } private void upsertSnapshot(Integer mediaId, int snapshotType, BigDecimal timestampSec, String filePath, Double confidence, String source, String modelVersion) { deliveryMediaSnapshotMapper.update(null, new UpdateWrapper().lambda() .eq(DeliveryMediaSnapshot::getMediaId, mediaId) .eq(DeliveryMediaSnapshot::getSnapshotType, snapshotType) .set(DeliveryMediaSnapshot::getIsdeleted, Constants.ONE)); DeliveryMediaSnapshot row = new DeliveryMediaSnapshot(); row.setMediaId(mediaId); row.setSnapshotType(snapshotType); row.setTimestampSec(timestampSec); row.setFilePath(filePath); if (confidence != null) { row.setConfidence(BigDecimal.valueOf(confidence).setScale(4, RoundingMode.HALF_UP)); } row.setSource(source); row.setModelVersion(modelVersion); row.setCreateDate(new Date()); row.setIsdeleted(Constants.ZERO); deliveryMediaSnapshotMapper.insert(row); } private void fillSnapshotUrl(DeliveryMediaSnapshot snapshot) { if (StringUtils.isBlank(snapshot.getFilePath())) { return; } try { String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode(); snapshot.setFileUrlFull(prefix + getSnapshotFolder() + snapshot.getFilePath()); } catch (Exception e) { log.warn("构建快照URL失败 id={}: {}", snapshot.getId(), e.getMessage()); } } private String buildVideoUrl(CollectionMedia media) { try { String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode(); return prefix + getMediaFolder() + media.getFilePathLocal(); } catch (Exception e) { return null; } } private double estimateDuration(CollectionMedia media) { if (media.getStartTime() != null && media.getEndTime() != null) { long ms = media.getEndTime().getTime() - media.getStartTime().getTime(); if (ms > 0) { return ms / 1000.0; } } return 1200.0; } private FtpUtil createFtpClient() throws IOException { return new FtpUtil( systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_HOST).getCode(), Integer.parseInt(systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PORT).getCode()), systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_USERNAME).getCode(), systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PWD).getCode()); } private String getMediaFolder() { try { return systemDictDataBiz.queryByCode(Constants.FTP, Constants.COLLECTION_MEDIA_FOLDER).getCode(); } catch (Exception e) { return "/collection_media/"; } } private String getSnapshotFolder() { try { return systemDictDataBiz.queryByCode(Constants.FTP, CollectionMediaConstants.COLLECTION_SNAPSHOT_FOLDER).getCode(); } catch (Exception e) { return "/collection_snapshot/"; } } private String getFfmpegPath() { try { return systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_FFMPEG_PATH).getCode(); } catch (Exception e) { return "ffmpeg"; } } private String resolveSuffix(CollectionMedia media) { if (StringUtils.isNotBlank(media.getFileName()) && media.getFileName().contains(".")) { return media.getFileName().substring(media.getFileName().lastIndexOf('.')).toLowerCase(); } return ".mp4"; } private void saveFeedback(Integer mediaId, int snapshotType, BigDecimal manualTimeSec) { DeliveryMediaSnapshot existing = deliveryMediaSnapshotMapper.selectOne(new QueryWrapper().lambda() .eq(DeliveryMediaSnapshot::getMediaId, mediaId) .eq(DeliveryMediaSnapshot::getSnapshotType, snapshotType) .eq(DeliveryMediaSnapshot::getIsdeleted, Constants.ZERO) .orderByDesc(DeliveryMediaSnapshot::getId) .last("LIMIT 1")); DeliveryMediaSnapshotFeedback feedback = new DeliveryMediaSnapshotFeedback(); feedback.setMediaId(mediaId); feedback.setSnapshotType(snapshotType); if (existing != null && existing.getTimestampSec() != null) { feedback.setAiTimeSec(existing.getTimestampSec()); feedback.setModelVersion(existing.getModelVersion()); } feedback.setManualTimeSec(manualTimeSec); feedback.setCreateDate(new Date()); feedback.setIsdeleted(Constants.ZERO); deliveryMediaSnapshotFeedbackMapper.insert(feedback); } private void deleteQuietly(File file) { if (file != null && file.exists() && !file.delete()) { log.warn("临时文件删除失败: {}", file.getAbsolutePath()); } } }