feat(system-maintenance): 新增媒体资源元数据修复功能

实现完整的媒体资源元数据修复业务模块,提供提交修复任务与查询任务状态的REST接口,支持修复媒体资源的大小和时长信息,适配普通文件和HLS流媒体,内置域名白名单、下载限制及任务缓存清理能力。
This commit is contained in:
2026-06-05 12:35:19 +08:00
parent 5b87e2f7ca
commit 98a83bc79c
7 changed files with 1128 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
package com.hotwj.platform.system.maintenance.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.io.File;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 媒体资源元数据修复配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "maintenance.media-resource-repair")
public class MediaResourceMetadataRepairProperties {
/**
* 任务缓存key前缀
*/
private String taskKeyPrefix = "maintenance:media-resource-repair:";
/**
* 临时目录根路径
*/
private String workDir = System.getProperty("java.io.tmpdir") + File.separator + "media-resource-repair";
/**
* ffprobe 命令
*/
private String ffprobeCommand = "ffprobe";
/**
* ffmpeg 命令
*/
private String ffmpegCommand = "ffmpeg";
/**
* 任务状态保留小时数
*/
private Integer taskExpireHours = 24;
/**
* 默认单次处理上限
*/
private Integer defaultLimit = 200;
/**
* 最大单次处理上限
*/
private Integer maxLimit = 500;
/**
* 默认批处理条数
*/
private Integer defaultBatchSize = 20;
/**
* 最大批处理条数
*/
private Integer maxBatchSize = 50;
/**
* 连接超时秒数
*/
private Integer connectTimeoutSeconds = 10;
/**
* 读取超时秒数
*/
private Integer readTimeoutSeconds = 60;
/**
* 单文件最大大小
*/
private Long maxFileSizeBytes = 524288000L;
/**
* HLS 最大分片数
*/
private Integer maxHlsSegments = 500;
/**
* HLS 最大总下载大小
*/
private Long maxHlsTotalSizeBytes = 1073741824L;
/**
* 采样错误条数上限
*/
private Integer maxErrorSamples = 20;
/**
* 播放列表最大大小
*/
private Long maxPlaylistSizeBytes = 2097152L;
/**
* 允许的域名白名单,留空则不限制
*/
private List<String> allowedHosts = new ArrayList<>();
public Duration taskExpireDuration() {
return Duration.ofHours(taskExpireHours == null ? 24L : taskExpireHours.longValue());
}
public boolean isHostAllowed(String host) {
if (host == null || host.isBlank()) {
return false;
}
if (allowedHosts == null || allowedHosts.isEmpty()) {
return true;
}
String normalizedHost = host.toLowerCase(Locale.ROOT);
for (String allowedHost : allowedHosts) {
if (allowedHost == null || allowedHost.isBlank()) {
continue;
}
String normalizedAllowed = allowedHost.toLowerCase(Locale.ROOT).trim();
if (normalizedHost.equals(normalizedAllowed) || normalizedHost.endsWith("." + normalizedAllowed)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
package com.hotwj.platform.system.maintenance.controller;
import com.hotwj.platform.system.maintenance.domain.bo.MediaResourceMetadataRepairRequest;
import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairSubmitVo;
import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairTaskStatusVo;
import com.hotwj.platform.system.maintenance.service.IMediaResourceMetadataRepairService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.web.core.BaseController;
import org.springframework.web.bind.annotation.*;
/**
* 系统维护-媒体资源元数据修复
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/system/maintenance/media-resource")
public class MediaResourceMaintenanceController extends BaseController {
private final IMediaResourceMetadataRepairService mediaResourceMetadataRepairService;
@PostMapping("/repair-metadata")
public R<MediaResourceMetadataRepairSubmitVo> repairMetadata(@Valid @RequestBody MediaResourceMetadataRepairRequest request) {
return R.ok(mediaResourceMetadataRepairService.submitRepairTask(request));
}
@GetMapping("/repair-metadata/{taskId}")
public R<MediaResourceMetadataRepairTaskStatusVo> getTaskStatus(@PathVariable String taskId) {
return R.ok(mediaResourceMetadataRepairService.getTaskStatus(taskId));
}
}

View File

@@ -0,0 +1,72 @@
package com.hotwj.platform.system.maintenance.domain.bo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* 媒体资源元数据修复请求
*/
@Data
public class MediaResourceMetadataRepairRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 资源类型1=视频 2=音频
*/
private Long mediaType;
/**
* 分类编码
*/
private String categoryCode;
/**
* 公司ID
*/
private Long companyId;
/**
* 指定资源ID列表
*/
private List<Long> resourceIds;
/**
* 是否修复大小
*/
private Boolean repairSize;
/**
* 是否修复时长
*/
private Boolean repairDuration;
/**
* 是否覆盖已有有效值
*/
private Boolean overwriteExisting;
/**
* 是否只处理无效数据
*/
private Boolean onlyInvalid;
/**
* 单次处理上限
*/
private Integer limit;
/**
* 每批处理条数
*/
private Integer batchSize;
/**
* 是否跳过无法解析URL的记录
*/
private Boolean skipUnresolvedUrl;
}

View File

@@ -0,0 +1,26 @@
package com.hotwj.platform.system.maintenance.domain.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 提交媒体资源元数据修复任务响应
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MediaResourceMetadataRepairSubmitVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String taskId;
private Boolean accepted;
private String message;
}

View File

@@ -0,0 +1,63 @@
package com.hotwj.platform.system.maintenance.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 媒体资源元数据修复任务状态
*/
@Data
public class MediaResourceMetadataRepairTaskStatusVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String taskId;
private String status;
private Integer total = 0;
private Integer processedCount = 0;
private Integer successCount = 0;
private Integer skipCount = 0;
private Integer failCount = 0;
private Long startTime;
private Long endTime;
private Long durationMillis;
private String operatorName;
private String requestSummary;
private String message;
private List<Long> updatedIds = new ArrayList<>();
private List<ErrorSample> errorSamples = new ArrayList<>();
@Data
public static class ErrorSample implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long resourceId;
private String fileUrl;
private String resolvedUrl;
private String reason;
}
}

View File

@@ -0,0 +1,21 @@
package com.hotwj.platform.system.maintenance.service;
import com.hotwj.platform.system.maintenance.domain.bo.MediaResourceMetadataRepairRequest;
import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairSubmitVo;
import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairTaskStatusVo;
/**
* 媒体资源元数据修复服务
*/
public interface IMediaResourceMetadataRepairService {
/**
* 提交修复任务
*/
MediaResourceMetadataRepairSubmitVo submitRepairTask(MediaResourceMetadataRepairRequest request);
/**
* 查询任务状态
*/
MediaResourceMetadataRepairTaskStatusVo getTaskStatus(String taskId);
}

View File

@@ -0,0 +1,785 @@
package com.hotwj.platform.system.maintenance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.hotwj.platform.config.mediaResource.domain.HotMediaResource;
import com.hotwj.platform.config.mediaResource.mapper.HotMediaResourceMapper;
import com.hotwj.platform.system.maintenance.config.MediaResourceMetadataRepairProperties;
import com.hotwj.platform.system.maintenance.domain.bo.MediaResourceMetadataRepairRequest;
import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairSubmitVo;
import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairTaskStatusVo;
import com.hotwj.platform.system.maintenance.service.IMediaResourceMetadataRepairService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.service.OssService;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serial;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 媒体资源元数据修复服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MediaResourceMetadataRepairServiceImpl implements IMediaResourceMetadataRepairService {
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_PARTIAL_SUCCESS = "PARTIAL_SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final Pattern PURE_NUMBER = Pattern.compile("^\\d+$");
private static final Pattern KEY_URI_PATTERN = Pattern.compile("URI=\"([^\"]+)\"");
private static final Pattern FFMPEG_DURATION_PATTERN = Pattern.compile("Duration: (\\d{2}):(\\d{2}):(\\d{2}(?:\\.\\d+)?)");
private final HotMediaResourceMapper hotMediaResourceMapper;
private final OssService ossService;
private final ScheduledExecutorService scheduledExecutorService;
private final MediaResourceMetadataRepairProperties properties;
@Override
public MediaResourceMetadataRepairSubmitVo submitRepairTask(MediaResourceMetadataRepairRequest request) {
MediaResourceMetadataRepairRequest normalized = normalizeRequest(request);
String taskId = UUID.randomUUID().toString().replace("-", "");
MediaResourceMetadataRepairTaskStatusVo taskStatus = new MediaResourceMetadataRepairTaskStatusVo();
taskStatus.setTaskId(taskId);
taskStatus.setStatus(STATUS_PENDING);
taskStatus.setMessage("任务已提交,等待执行");
taskStatus.setOperatorName(resolveOperatorName());
taskStatus.setRequestSummary(buildRequestSummary(normalized));
saveTask(taskStatus);
scheduledExecutorService.execute(() -> runTask(taskId, normalized));
return new MediaResourceMetadataRepairSubmitVo(taskId, Boolean.TRUE, "媒体资源元数据修复任务已提交");
}
@Override
public MediaResourceMetadataRepairTaskStatusVo getTaskStatus(String taskId) {
MediaResourceMetadataRepairTaskStatusVo taskStatus = loadTask(taskId);
if (taskStatus == null) {
throw new ServiceException("任务不存在或已过期");
}
return taskStatus;
}
private void runTask(String taskId, MediaResourceMetadataRepairRequest request) {
MediaResourceMetadataRepairTaskStatusVo taskStatus = requireTask(taskId);
long taskStart = System.currentTimeMillis();
Path taskWorkDir = getTaskWorkDir(taskId);
try {
taskStatus.setStatus(STATUS_RUNNING);
taskStatus.setStartTime(taskStart);
taskStatus.setMessage("任务执行中");
long total = countMatchingResources(request);
taskStatus.setTotal((int) Math.min(total, request.getLimit()));
saveTask(taskStatus);
if (taskStatus.getTotal() <= 0) {
taskStatus.setStatus(STATUS_SUCCESS);
taskStatus.setEndTime(System.currentTimeMillis());
taskStatus.setDurationMillis(taskStatus.getEndTime() - taskStart);
taskStatus.setMessage("未找到符合条件的媒体资源");
saveTask(taskStatus);
return;
}
long lastId = 0L;
while (taskStatus.getProcessedCount() < taskStatus.getTotal()) {
int fetchSize = Math.min(request.getBatchSize(), taskStatus.getTotal() - taskStatus.getProcessedCount());
List<HotMediaResource> resources = fetchResources(request, lastId, fetchSize);
if (resources.isEmpty()) {
break;
}
for (HotMediaResource resource : resources) {
lastId = resource.getId();
try {
ProcessResult result = processResource(taskId, request, resource);
if (result.type == ProcessResultType.SUCCESS) {
taskStatus.setSuccessCount(taskStatus.getSuccessCount() + 1);
taskStatus.getUpdatedIds().add(resource.getId());
} else if (result.type == ProcessResultType.SKIP) {
taskStatus.setSkipCount(taskStatus.getSkipCount() + 1);
} else {
recordFailure(taskStatus, resource, result.resolvedUrl, result.message);
}
} catch (Exception ex) {
log.error("媒体资源元数据修复失败, resourceId={}", resource.getId(), ex);
recordFailure(taskStatus, resource, null, abbreviate(ex.getMessage(), 300));
} finally {
taskStatus.setProcessedCount(taskStatus.getProcessedCount() + 1);
saveTask(taskStatus);
}
if (taskStatus.getProcessedCount() >= taskStatus.getTotal()) {
break;
}
}
}
taskStatus.setEndTime(System.currentTimeMillis());
taskStatus.setDurationMillis(taskStatus.getEndTime() - taskStart);
taskStatus.setStatus(determineFinalStatus(taskStatus));
taskStatus.setMessage(buildFinishMessage(taskStatus));
saveTask(taskStatus);
} catch (Exception ex) {
log.error("媒体资源元数据修复任务执行失败, taskId={}", taskId, ex);
taskStatus.setStatus(STATUS_FAILED);
taskStatus.setEndTime(System.currentTimeMillis());
taskStatus.setDurationMillis(taskStatus.getEndTime() - taskStart);
taskStatus.setMessage("任务执行失败: " + abbreviate(ex.getMessage(), 300));
saveTask(taskStatus);
} finally {
deleteQuietly(taskWorkDir);
}
}
private ProcessResult processResource(String taskId, MediaResourceMetadataRepairRequest request, HotMediaResource resource) throws IOException, InterruptedException {
ResolvedUrl resolvedUrl = resolveDownloadUrl(resource.getFileUrl());
if (resolvedUrl == null || StringUtils.isBlank(resolvedUrl.url)) {
String reason = "未解析到可用下载地址";
if (Boolean.TRUE.equals(request.getSkipUnresolvedUrl())) {
return ProcessResult.skip(reason, null);
}
return ProcessResult.fail(reason, null);
}
Path resourceDir = getResourceWorkDir(taskId, resource.getId());
try {
Files.createDirectories(resourceDir);
DownloadedResource downloadedResource = resolvedUrl.hls
? downloadHlsResource(resolvedUrl.url, resourceDir)
: downloadFileResource(resolvedUrl.url, resourceDir);
Long sizeBytes = downloadedResource.totalSizeBytes;
Long durationSeconds = probeDuration(downloadedResource.probePath, downloadedResource.extinfDuration);
HotMediaResource update = new HotMediaResource();
update.setId(resource.getId());
boolean hasUpdate = false;
if (Boolean.TRUE.equals(request.getRepairSize())) {
if (sizeBytes == null || sizeBytes <= 0) {
return ProcessResult.fail("未获取到有效资源大小", resolvedUrl.url);
}
if (Boolean.TRUE.equals(request.getOverwriteExisting()) || isInvalid(resource.getSizeBytes())) {
update.setSizeBytes(sizeBytes);
hasUpdate = true;
}
}
if (Boolean.TRUE.equals(request.getRepairDuration())) {
if (durationSeconds == null || durationSeconds <= 0) {
return ProcessResult.fail("未获取到有效媒体时长", resolvedUrl.url);
}
if (Boolean.TRUE.equals(request.getOverwriteExisting()) || isInvalid(resource.getDurationSeconds())) {
update.setDurationSeconds(durationSeconds);
hasUpdate = true;
}
}
if (!hasUpdate) {
return ProcessResult.skip("未命中可覆盖字段", resolvedUrl.url);
}
if (hotMediaResourceMapper.updateById(update) <= 0) {
return ProcessResult.fail("数据库更新失败", resolvedUrl.url);
}
return ProcessResult.success("修复成功", resolvedUrl.url);
} finally {
deleteQuietly(resourceDir);
}
}
private ResolvedUrl resolveDownloadUrl(String fileUrl) {
String candidate = firstToken(fileUrl);
if (StringUtils.isBlank(candidate)) {
return null;
}
String resolvedUrl = candidate;
if (PURE_NUMBER.matcher(candidate).matches()) {
resolvedUrl = firstToken(ossService.selectUrlByIds(candidate));
}
if (StringUtils.isBlank(resolvedUrl)) {
return null;
}
URI uri = toHttpUri(resolvedUrl);
if (!properties.isHostAllowed(uri.getHost())) {
throw new ServiceException("下载地址不在允许的域名白名单中: " + uri.getHost());
}
String path = StringUtils.defaultString(uri.getPath()).toLowerCase(Locale.ROOT);
return new ResolvedUrl(uri.toString(), path.endsWith(".m3u8"));
}
private DownloadedResource downloadFileResource(String url, Path resourceDir) throws IOException, InterruptedException {
URI uri = toHttpUri(url);
String extension = extensionFromPath(uri.getPath(), ".bin");
Path targetFile = resourceDir.resolve("resource" + extension);
long size = downloadToFile(uri, targetFile, properties.getMaxFileSizeBytes());
if (size <= 0) {
throw new ServiceException("下载文件大小为0");
}
return new DownloadedResource(targetFile, size, null);
}
private DownloadedResource downloadHlsResource(String url, Path resourceDir) throws IOException, InterruptedException {
HlsContext context = new HlsContext();
HlsPlaylistResult playlistResult = localizePlaylist(toHttpUri(url), resourceDir.resolve("root.m3u8"), context);
if (context.totalSize <= 0) {
throw new ServiceException("HLS 下载结果为空");
}
return new DownloadedResource(playlistResult.probePath, context.totalSize, playlistResult.extinfDuration);
}
private HlsPlaylistResult localizePlaylist(URI playlistUri, Path localPlaylistPath, HlsContext context) throws IOException, InterruptedException {
byte[] contentBytes = downloadToBytes(playlistUri, properties.getMaxPlaylistSizeBytes());
context.addBytes(contentBytes.length);
Files.createDirectories(localPlaylistPath.getParent());
String content = new String(contentBytes, StandardCharsets.UTF_8);
if (content.contains("#EXT-X-STREAM-INF")) {
Files.write(localPlaylistPath, contentBytes);
URI variantUri = findFirstVariantUri(playlistUri, content);
if (variantUri == null) {
throw new ServiceException("未找到可用的 HLS 变体播放列表");
}
return localizePlaylist(variantUri, localPlaylistPath.getParent().resolve("variant_0.m3u8"), context);
}
List<String> outputLines = new ArrayList<>();
long extinfDuration = 0L;
String[] lines = content.split("\\r?\\n", -1);
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.startsWith("#EXTINF:")) {
extinfDuration += parseExtinfDuration(trimmed);
outputLines.add(line);
continue;
}
if (trimmed.startsWith("#EXT-X-KEY") || trimmed.startsWith("#EXT-X-MAP")) {
outputLines.add(rewriteAttributeUriLine(playlistUri, line, localPlaylistPath.getParent(), context));
continue;
}
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
outputLines.add(line);
continue;
}
URI resourceUri = playlistUri.resolve(trimmed);
String lowerPath = StringUtils.defaultString(resourceUri.getPath()).toLowerCase(Locale.ROOT);
if (lowerPath.endsWith(".m3u8")) {
String childName = "nested_" + context.nextFileIndex() + ".m3u8";
localizePlaylist(resourceUri, localPlaylistPath.getParent().resolve(childName), context);
outputLines.add(childName);
} else {
String fileName = buildHlsAssetName(resourceUri, "segment_" + context.nextFileIndex());
Path target = localPlaylistPath.getParent().resolve(fileName);
downloadToFile(resourceUri, target, properties.getMaxHlsTotalSizeBytes());
context.addSegment();
context.addBytes(Files.size(target));
outputLines.add(fileName);
}
}
Files.writeString(localPlaylistPath, String.join(System.lineSeparator(), outputLines), StandardCharsets.UTF_8);
return new HlsPlaylistResult(localPlaylistPath, extinfDuration > 0 ? extinfDuration : null);
}
private String rewriteAttributeUriLine(URI baseUri, String line, Path localDir, HlsContext context) throws IOException, InterruptedException {
Matcher matcher = KEY_URI_PATTERN.matcher(line);
if (!matcher.find()) {
return line;
}
URI assetUri = baseUri.resolve(matcher.group(1));
String fileName = buildHlsAssetName(assetUri, "asset_" + context.nextFileIndex());
Path target = localDir.resolve(fileName);
long fileSize = downloadToFile(assetUri, target, properties.getMaxHlsTotalSizeBytes());
context.addBytes(fileSize);
return matcher.replaceFirst("URI=\"" + fileName.replace("\\", "/") + "\"");
}
private URI findFirstVariantUri(URI playlistUri, String content) {
String[] lines = content.split("\\r?\\n");
for (int i = 0; i < lines.length; i++) {
if (!lines[i].trim().startsWith("#EXT-X-STREAM-INF")) {
continue;
}
for (int j = i + 1; j < lines.length; j++) {
String candidate = lines[j].trim();
if (candidate.isEmpty()) {
continue;
}
if (candidate.startsWith("#")) {
break;
}
return playlistUri.resolve(candidate);
}
}
return null;
}
private Long probeDuration(Path probePath, Long extinfDuration) throws IOException, InterruptedException {
Long duration = probeByFfprobe(probePath);
if (duration != null && duration > 0) {
return duration;
}
duration = probeByFfmpeg(probePath);
if (duration != null && duration > 0) {
return duration;
}
return extinfDuration;
}
private Long probeByFfprobe(Path probePath) throws IOException, InterruptedException {
List<String> command = List.of(
properties.getFfprobeCommand(),
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
probePath.toAbsolutePath().toString()
);
CommandResult commandResult = runCommand(command, Duration.ofSeconds(properties.getReadTimeoutSeconds()));
if (commandResult.exitCode != 0) {
return null;
}
String output = StringUtils.trim(commandResult.output);
if (StringUtils.isBlank(output)) {
return null;
}
try {
double seconds = Double.parseDouble(output);
long rounded = Math.round(seconds);
return rounded > 0 ? rounded : null;
} catch (NumberFormatException ignored) {
return null;
}
}
private Long probeByFfmpeg(Path probePath) throws IOException, InterruptedException {
List<String> command = List.of(
properties.getFfmpegCommand(),
"-i",
probePath.toAbsolutePath().toString()
);
CommandResult commandResult = runCommand(command, Duration.ofSeconds(properties.getReadTimeoutSeconds()));
Matcher matcher = FFMPEG_DURATION_PATTERN.matcher(commandResult.output);
if (!matcher.find()) {
return null;
}
int hours = Integer.parseInt(matcher.group(1));
int minutes = Integer.parseInt(matcher.group(2));
double seconds = Double.parseDouble(matcher.group(3));
long duration = Math.round(hours * 3600L + minutes * 60L + seconds);
return duration > 0 ? duration : null;
}
private CommandResult runCommand(List<String> command, Duration timeout) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.start();
boolean finished = process.waitFor(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS);
if (!finished) {
process.destroyForcibly();
throw new ServiceException("媒体探测命令执行超时: " + command.get(0));
}
String output;
try (InputStream inputStream = process.getInputStream()) {
output = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
return new CommandResult(process.exitValue(), output);
}
private long countMatchingResources(MediaResourceMetadataRepairRequest request) {
return hotMediaResourceMapper.selectCount(buildBaseQuery(request));
}
private List<HotMediaResource> fetchResources(MediaResourceMetadataRepairRequest request, long lastId, int fetchSize) {
LambdaQueryWrapper<HotMediaResource> queryWrapper = buildBaseQuery(request);
queryWrapper.gt(lastId > 0, HotMediaResource::getId, lastId);
queryWrapper.orderByAsc(HotMediaResource::getId);
queryWrapper.last("limit " + fetchSize);
return hotMediaResourceMapper.selectList(queryWrapper);
}
private LambdaQueryWrapper<HotMediaResource> buildBaseQuery(MediaResourceMetadataRepairRequest request) {
LambdaQueryWrapper<HotMediaResource> queryWrapper = Wrappers.lambdaQuery();
queryWrapper.eq(request.getCompanyId() != null, HotMediaResource::getCompanyId, request.getCompanyId());
queryWrapper.eq(request.getMediaType() != null, HotMediaResource::getMediaType, request.getMediaType());
queryWrapper.eq(StringUtils.isNotBlank(request.getCategoryCode()), HotMediaResource::getCategoryCode, request.getCategoryCode());
queryWrapper.in(request.getResourceIds() != null && !request.getResourceIds().isEmpty(), HotMediaResource::getId, request.getResourceIds());
if (Boolean.TRUE.equals(request.getOnlyInvalid())) {
if (Boolean.TRUE.equals(request.getRepairSize()) && Boolean.TRUE.equals(request.getRepairDuration())) {
queryWrapper.and(wrapper -> wrapper
.nested(size -> size.isNull(HotMediaResource::getSizeBytes).or().le(HotMediaResource::getSizeBytes, 0))
.or()
.nested(duration -> duration.isNull(HotMediaResource::getDurationSeconds).or().le(HotMediaResource::getDurationSeconds, 0))
);
} else if (Boolean.TRUE.equals(request.getRepairSize())) {
queryWrapper.and(wrapper -> wrapper.isNull(HotMediaResource::getSizeBytes).or().le(HotMediaResource::getSizeBytes, 0));
} else if (Boolean.TRUE.equals(request.getRepairDuration())) {
queryWrapper.and(wrapper -> wrapper.isNull(HotMediaResource::getDurationSeconds).or().le(HotMediaResource::getDurationSeconds, 0));
}
}
return queryWrapper;
}
private MediaResourceMetadataRepairRequest normalizeRequest(MediaResourceMetadataRepairRequest request) {
if (request == null) {
throw new ServiceException("请求参数不能为空");
}
request.setRepairSize(request.getRepairSize() == null ? Boolean.TRUE : request.getRepairSize());
request.setRepairDuration(request.getRepairDuration() == null ? Boolean.TRUE : request.getRepairDuration());
if (!Boolean.TRUE.equals(request.getRepairSize()) && !Boolean.TRUE.equals(request.getRepairDuration())) {
throw new ServiceException("至少需要开启一个修复项");
}
request.setOverwriteExisting(request.getOverwriteExisting() == null ? Boolean.FALSE : request.getOverwriteExisting());
request.setOnlyInvalid(request.getOnlyInvalid() == null ? Boolean.TRUE : request.getOnlyInvalid());
request.setSkipUnresolvedUrl(request.getSkipUnresolvedUrl() == null ? Boolean.TRUE : request.getSkipUnresolvedUrl());
int limit = request.getLimit() == null || request.getLimit() <= 0 ? properties.getDefaultLimit() : request.getLimit();
request.setLimit(Math.min(limit, properties.getMaxLimit()));
int batchSize = request.getBatchSize() == null || request.getBatchSize() <= 0 ? properties.getDefaultBatchSize() : request.getBatchSize();
request.setBatchSize(Math.min(batchSize, properties.getMaxBatchSize()));
return request;
}
private void saveTask(MediaResourceMetadataRepairTaskStatusVo taskStatus) {
RedisUtils.setCacheObject(properties.getTaskKeyPrefix() + taskStatus.getTaskId(), taskStatus, properties.taskExpireDuration());
}
private MediaResourceMetadataRepairTaskStatusVo loadTask(String taskId) {
if (StringUtils.isBlank(taskId)) {
return null;
}
return RedisUtils.getCacheObject(properties.getTaskKeyPrefix() + taskId);
}
private MediaResourceMetadataRepairTaskStatusVo requireTask(String taskId) {
MediaResourceMetadataRepairTaskStatusVo taskStatus = loadTask(taskId);
if (taskStatus == null) {
throw new ServiceException("任务不存在或已过期");
}
return taskStatus;
}
private void recordFailure(MediaResourceMetadataRepairTaskStatusVo taskStatus, HotMediaResource resource, String resolvedUrl, String reason) {
taskStatus.setFailCount(taskStatus.getFailCount() + 1);
if (taskStatus.getErrorSamples().size() >= properties.getMaxErrorSamples()) {
return;
}
MediaResourceMetadataRepairTaskStatusVo.ErrorSample errorSample = new MediaResourceMetadataRepairTaskStatusVo.ErrorSample();
errorSample.setResourceId(resource.getId());
errorSample.setFileUrl(resource.getFileUrl());
errorSample.setResolvedUrl(resolvedUrl);
errorSample.setReason(reason);
taskStatus.getErrorSamples().add(errorSample);
}
private String determineFinalStatus(MediaResourceMetadataRepairTaskStatusVo taskStatus) {
if (taskStatus.getFailCount() > 0 && taskStatus.getSuccessCount() > 0) {
return STATUS_PARTIAL_SUCCESS;
}
if (taskStatus.getFailCount() > 0 && taskStatus.getSuccessCount() <= 0) {
return STATUS_FAILED;
}
return STATUS_SUCCESS;
}
private String buildFinishMessage(MediaResourceMetadataRepairTaskStatusVo taskStatus) {
return String.format(
Locale.ROOT,
"任务完成:成功 %d跳过 %d失败 %d",
taskStatus.getSuccessCount(),
taskStatus.getSkipCount(),
taskStatus.getFailCount()
);
}
private String buildRequestSummary(MediaResourceMetadataRepairRequest request) {
List<String> parts = new ArrayList<>();
parts.add("mediaType=" + (request.getMediaType() == null ? "ALL" : request.getMediaType()));
parts.add("categoryCode=" + StringUtils.defaultIfBlank(request.getCategoryCode(), "ALL"));
parts.add("companyId=" + (request.getCompanyId() == null ? "ALL" : request.getCompanyId()));
parts.add("repairSize=" + request.getRepairSize());
parts.add("repairDuration=" + request.getRepairDuration());
parts.add("overwriteExisting=" + request.getOverwriteExisting());
parts.add("onlyInvalid=" + request.getOnlyInvalid());
parts.add("limit=" + request.getLimit());
parts.add("batchSize=" + request.getBatchSize());
if (request.getResourceIds() != null && !request.getResourceIds().isEmpty()) {
parts.add("resourceIds=" + request.getResourceIds().size());
}
return String.join(", ", parts);
}
private String resolveOperatorName() {
try {
return LoginHelper.getUsername();
} catch (Exception ignored) {
return "unknown";
}
}
private Path getTaskWorkDir(String taskId) {
return Path.of(properties.getWorkDir(), taskId);
}
private Path getResourceWorkDir(String taskId, Long resourceId) {
return getTaskWorkDir(taskId).resolve(resourceId + "_" + System.currentTimeMillis());
}
private long downloadToFile(URI uri, Path targetFile, long maxSizeBytes) throws IOException, InterruptedException {
HttpResponse<InputStream> response = httpClient().send(buildRequest(uri), HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ServiceException("下载失败, HTTP状态码: " + response.statusCode());
}
Files.createDirectories(targetFile.getParent());
long total = 0L;
try (InputStream inputStream = response.body()) {
byte[] buffer = new byte[8192];
int read;
try (var outputStream = Files.newOutputStream(targetFile)) {
while ((read = inputStream.read(buffer)) != -1) {
total += read;
if (maxSizeBytes > 0 && total > maxSizeBytes) {
throw new ServiceException("下载文件超过大小限制");
}
outputStream.write(buffer, 0, read);
}
}
}
return total;
}
private byte[] downloadToBytes(URI uri, long maxSizeBytes) throws IOException, InterruptedException {
HttpResponse<InputStream> response = httpClient().send(buildRequest(uri), HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ServiceException("下载失败, HTTP状态码: " + response.statusCode());
}
try (InputStream inputStream = response.body()) {
byte[] buffer = new byte[8192];
int read;
int total = 0;
var outputStream = new java.io.ByteArrayOutputStream();
while ((read = inputStream.read(buffer)) != -1) {
total += read;
if (maxSizeBytes > 0 && total > maxSizeBytes) {
throw new ServiceException("下载内容超过大小限制");
}
outputStream.write(buffer, 0, read);
}
return outputStream.toByteArray();
}
}
private HttpRequest buildRequest(URI uri) {
return HttpRequest.newBuilder(uri)
.GET()
.timeout(Duration.ofSeconds(properties.getReadTimeoutSeconds()))
.header("User-Agent", "hotwj-media-resource-repair")
.build();
}
private HttpClient httpClient() {
return HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(properties.getConnectTimeoutSeconds()))
.build();
}
private URI toHttpUri(String url) {
try {
URI uri = new URI(StringUtils.trim(url));
String scheme = StringUtils.defaultString(uri.getScheme()).toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
throw new ServiceException("仅支持 http/https 下载地址");
}
return uri;
} catch (URISyntaxException ex) {
throw new ServiceException("URL 不合法: " + abbreviate(url, 200));
}
}
private String firstToken(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String[] values = raw.split("[,\\r\\n]");
for (String value : values) {
if (StringUtils.isNotBlank(value)) {
return value.trim();
}
}
return null;
}
private boolean isInvalid(Long value) {
return value == null || value <= 0;
}
private String buildHlsAssetName(URI uri, String defaultName) {
return defaultName + extensionFromPath(uri.getPath(), ".bin");
}
private String extensionFromPath(String path, String defaultExtension) {
if (StringUtils.isBlank(path)) {
return defaultExtension;
}
int index = path.lastIndexOf('.');
if (index < 0 || index == path.length() - 1) {
return defaultExtension;
}
String extension = path.substring(index);
return extension.length() > 12 ? defaultExtension : extension;
}
private long parseExtinfDuration(String line) {
String value = line.substring("#EXTINF:".length());
int commaIndex = value.indexOf(',');
if (commaIndex >= 0) {
value = value.substring(0, commaIndex);
}
try {
return Math.round(Double.parseDouble(value.trim()));
} catch (NumberFormatException ignored) {
return 0L;
}
}
private String abbreviate(String value, int maxLength) {
if (value == null) {
return "";
}
String normalized = value.replaceAll("\\s+", " ").trim();
if (normalized.length() <= maxLength) {
return normalized;
}
return normalized.substring(0, Math.max(maxLength - 3, 0)) + "...";
}
private void deleteQuietly(Path path) {
if (path == null || !Files.exists(path)) {
return;
}
try {
if (Files.isDirectory(path)) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(path)) {
for (Path child : stream) {
deleteQuietly(child);
}
}
}
Files.deleteIfExists(path);
} catch (Exception ex) {
log.warn("删除临时文件失败: {}", path, ex);
}
}
private enum ProcessResultType {
SUCCESS,
SKIP,
FAIL
}
private static class ProcessResult {
private final ProcessResultType type;
private final String message;
private final String resolvedUrl;
private ProcessResult(ProcessResultType type, String message, String resolvedUrl) {
this.type = type;
this.message = message;
this.resolvedUrl = resolvedUrl;
}
private static ProcessResult success(String message, String resolvedUrl) {
return new ProcessResult(ProcessResultType.SUCCESS, message, resolvedUrl);
}
private static ProcessResult skip(String message, String resolvedUrl) {
return new ProcessResult(ProcessResultType.SKIP, message, resolvedUrl);
}
private static ProcessResult fail(String message, String resolvedUrl) {
return new ProcessResult(ProcessResultType.FAIL, message, resolvedUrl);
}
}
private static class ResolvedUrl {
private final String url;
private final boolean hls;
private ResolvedUrl(String url, boolean hls) {
this.url = url;
this.hls = hls;
}
}
private static class DownloadedResource {
private final Path probePath;
private final Long totalSizeBytes;
private final Long extinfDuration;
private DownloadedResource(Path probePath, Long totalSizeBytes, Long extinfDuration) {
this.probePath = probePath;
this.totalSizeBytes = totalSizeBytes;
this.extinfDuration = extinfDuration;
}
}
private static class HlsPlaylistResult {
private final Path probePath;
private final Long extinfDuration;
private HlsPlaylistResult(Path probePath, Long extinfDuration) {
this.probePath = probePath;
this.extinfDuration = extinfDuration;
}
}
private static class CommandResult {
private final int exitCode;
private final String output;
private CommandResult(int exitCode, String output) {
this.exitCode = exitCode;
this.output = output;
}
}
private class HlsContext implements java.io.Serializable {
@Serial
private static final long serialVersionUID = 1L;
private long totalSize = 0L;
private int segmentCount = 0;
private int fileIndex = 0;
private void addBytes(long size) {
totalSize += size;
if (properties.getMaxHlsTotalSizeBytes() != null && properties.getMaxHlsTotalSizeBytes() > 0 && totalSize > properties.getMaxHlsTotalSizeBytes()) {
throw new ServiceException("HLS 总下载大小超过限制");
}
}
private void addSegment() {
segmentCount++;
if (properties.getMaxHlsSegments() != null && properties.getMaxHlsSegments() > 0 && segmentCount > properties.getMaxHlsSegments()) {
throw new ServiceException("HLS 分片数超过限制");
}
}
private int nextFileIndex() {
fileIndex++;
return fileIndex;
}
}
}