refactor: 后端标识统一为 FruitShorts,并落地 05 离线下载
为什么: 1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、 模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*, 这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts, 数据库连接名上一轮已改名,保持不变。 2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、 下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。 3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引, 表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就 按分区表定义,并验证脚本可重复执行。
This commit is contained in:
1 parent
a4aa749cc6
commit
b3179fc836
251 files changed
+7261
-767
No files matched your search
+38
@@ -0,0 +1,38 @@
|
||||
package com.fruitshorts.media.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.media")
|
||||
public class MediaProperties {
|
||||
|
||||
/** 本地存储根目录,可以是相对路径(相对应用启动目录)。 */
|
||||
private String rootDir = "./data/uploads";
|
||||
|
||||
/** 对外访问前缀,与静态资源映射保持一致。 */
|
||||
private String publicPrefix = "/files";
|
||||
|
||||
/** 分片大小(字节),默认 5MB。 */
|
||||
private long chunkSize = 5L * 1024 * 1024;
|
||||
|
||||
/** 单个文件大小上限(字节),默认 2GB。 */
|
||||
private long maxFileSize = 2L * 1024 * 1024 * 1024;
|
||||
|
||||
/** 播放地址签名密钥(不少于 32 字节)。生产环境必须用环境变量注入。 */
|
||||
private String signatureSecret = "fruitshorts-media-dev-secret-change-me-in-production-32b";
|
||||
|
||||
/** 播放签名有效期(分钟)。默认 15 分钟,足够播完一集且不易被长期复用。 */
|
||||
private int signatureTtlMinutes = 15;
|
||||
|
||||
/**
|
||||
* 是否强制校验播放地址签名。
|
||||
* 本地开发默认关闭(客户端还在用裸地址),生产环境必须开启。
|
||||
*/
|
||||
private boolean signatureRequired = false;
|
||||
|
||||
/** 需要签名的路径前缀,逗号分隔;封面与轮播图不受影响。 */
|
||||
private String protectedPrefixes = "/files/episode/";
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.fruitshorts.media.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Files;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 把本地媒资目录映射成静态资源,前端直接用 /files/** 访问。
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class MediaWebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final MediaProperties properties;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize();
|
||||
// Spring 6 会跳过启动时还不存在的静态资源目录,这里先确保目录存在
|
||||
try {
|
||||
Files.createDirectories(root);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("无法创建媒资目录: " + root, ex);
|
||||
}
|
||||
String prefix = properties.getPublicPrefix();
|
||||
if (!prefix.endsWith("/")) {
|
||||
prefix = prefix + "/";
|
||||
}
|
||||
registry.addResourceHandler(prefix + "**")
|
||||
.addResourceLocations(root.toUri().toString());
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.fruitshorts.media.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("media_asset")
|
||||
public class MediaAsset {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String bizType;
|
||||
private String fileName;
|
||||
private String objectKey;
|
||||
private String sourceUrl;
|
||||
private Long sizeBytes;
|
||||
private String md5;
|
||||
private Integer durationSec;
|
||||
private Integer width;
|
||||
private Integer height;
|
||||
private String status;
|
||||
private String hlsUrl;
|
||||
private String mp4Url;
|
||||
private String posterUrl;
|
||||
/** CDN 地址,接入对象存储后替代源站直出 */
|
||||
private String cdnUrl;
|
||||
private String transcodeStatus;
|
||||
/** LOCAL/OSS,预留对象存储切换 */
|
||||
private String storageType;
|
||||
private Long createBy;
|
||||
private LocalDateTime createdAt;
|
||||
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.fruitshorts.media.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 多码率产物:一部剧集对应多行,播放时组装成 master playlist。 */
|
||||
@Data
|
||||
@TableName("media_rendition")
|
||||
public class MediaRendition {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long mediaAssetId;
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
/** 360P/480P/720P/1080P */
|
||||
private String quality;
|
||||
private String format;
|
||||
private String playUrl;
|
||||
private String objectKey;
|
||||
private Integer width;
|
||||
private Integer height;
|
||||
private Integer videoBitrate;
|
||||
private Integer audioBitrate;
|
||||
private Long sizeBytes;
|
||||
private Integer durationSec;
|
||||
private Integer segmentCount;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.fruitshorts.media.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("media_upload_task")
|
||||
public class MediaUploadTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String uploadId;
|
||||
private String bizType;
|
||||
private String fileName;
|
||||
private Long fileSize;
|
||||
private Integer chunkSize;
|
||||
private Integer totalChunks;
|
||||
private Integer receivedChunks;
|
||||
private String objectKey;
|
||||
private String status;
|
||||
private Long assetId;
|
||||
private Long createBy;
|
||||
private LocalDateTime createdAt;
|
||||
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.fruitshorts.media.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 转码任务:上传完成后入队,由 worker 或云转码消费。 */
|
||||
@Data
|
||||
@TableName("transcode_task")
|
||||
public class TranscodeTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long mediaAssetId;
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
private String sourceUrl;
|
||||
/** 转码模板,对应码率阶梯组合 */
|
||||
private String profile;
|
||||
/** QUEUED/RUNNING/SUCCESS/FAILED/CANCELED */
|
||||
private String status;
|
||||
private Integer progress;
|
||||
private Integer retryCount;
|
||||
private String errorMsg;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.media.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.media.dal.entity.MediaAsset;
|
||||
|
||||
public interface MediaAssetMapper extends BaseMapper<MediaAsset> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.media.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||
|
||||
public interface MediaRenditionMapper extends BaseMapper<MediaRendition> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.media.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.media.dal.entity.MediaUploadTask;
|
||||
|
||||
public interface MediaUploadTaskMapper extends BaseMapper<MediaUploadTask> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.media.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
|
||||
public interface TranscodeTaskMapper extends BaseMapper<TranscodeTask> {
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.media.config.MediaProperties;
|
||||
import com.fruitshorts.media.dal.entity.MediaAsset;
|
||||
import com.fruitshorts.media.dal.entity.MediaUploadTask;
|
||||
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
|
||||
import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper;
|
||||
import com.fruitshorts.media.vo.MediaAssetRespVO;
|
||||
import com.fruitshorts.media.vo.UploadCompleteReqVO;
|
||||
import com.fruitshorts.media.vo.UploadInitReqVO;
|
||||
import com.fruitshorts.media.vo.UploadInitRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MediaService {
|
||||
|
||||
private static final String STATUS_READY = "ready";
|
||||
private static final String STATUS_UPLOADED = "uploaded";
|
||||
private static final String TASK_UPLOADING = "uploading";
|
||||
private static final String TASK_COMPLETED = "completed";
|
||||
private static final Set<String> IMAGE_EXTENSIONS =
|
||||
Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif");
|
||||
|
||||
private final MediaProperties properties;
|
||||
private final MediaStorageService storageService;
|
||||
private final MediaAssetMapper mediaAssetMapper;
|
||||
private final MediaUploadTaskMapper mediaUploadTaskMapper;
|
||||
|
||||
public UploadInitRespVO init(UploadInitReqVO req, Long userId) {
|
||||
if (req.getFileSize() > properties.getMaxFileSize()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"文件超过上限 " + (properties.getMaxFileSize() / 1024 / 1024) + "MB");
|
||||
}
|
||||
long chunkSize = req.getChunkSize() != null && req.getChunkSize() > 0
|
||||
? Math.min(req.getChunkSize(), properties.getChunkSize())
|
||||
: properties.getChunkSize();
|
||||
int totalChunks = (int) Math.ceil((double) req.getFileSize() / chunkSize);
|
||||
|
||||
MediaUploadTask task = new MediaUploadTask();
|
||||
task.setUploadId(UUID.randomUUID().toString().replace("-", ""));
|
||||
task.setBizType(normalizeBizType(req.getBizType()));
|
||||
task.setFileName(req.getFileName());
|
||||
task.setFileSize(req.getFileSize());
|
||||
task.setChunkSize((int) chunkSize);
|
||||
task.setTotalChunks(totalChunks);
|
||||
task.setReceivedChunks(0);
|
||||
task.setObjectKey(storageService.buildObjectKey(task.getBizType(), req.getFileName()));
|
||||
task.setStatus(TASK_UPLOADING);
|
||||
task.setAssetId(0L);
|
||||
task.setCreateBy(userId == null ? 0L : userId);
|
||||
mediaUploadTaskMapper.insert(task);
|
||||
|
||||
return new UploadInitRespVO(task.getUploadId(), chunkSize, totalChunks, task.getObjectKey());
|
||||
}
|
||||
|
||||
public void saveChunk(String uploadId, int index, MultipartFile file) {
|
||||
MediaUploadTask task = getTask(uploadId);
|
||||
if (index < 0 || index >= task.getTotalChunks()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "分片序号越界: " + index);
|
||||
}
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "分片内容为空");
|
||||
}
|
||||
storageService.saveChunk(uploadId, index, file);
|
||||
task.setReceivedChunks(storageService.countChunks(uploadId));
|
||||
mediaUploadTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MediaAssetRespVO complete(UploadCompleteReqVO req) {
|
||||
MediaUploadTask task = getTask(req.getUploadId());
|
||||
if (TASK_COMPLETED.equals(task.getStatus()) && task.getAssetId() > 0) {
|
||||
return get(task.getAssetId());
|
||||
}
|
||||
int chunks = storageService.countChunks(req.getUploadId());
|
||||
if (chunks < task.getTotalChunks()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"分片未传完(" + chunks + "/" + task.getTotalChunks() + ")");
|
||||
}
|
||||
|
||||
MediaStorageService.MergeResult merged =
|
||||
storageService.merge(req.getUploadId(), task.getObjectKey(), task.getTotalChunks());
|
||||
String publicUrl = storageService.toPublicUrl(task.getObjectKey());
|
||||
|
||||
MediaAsset asset = new MediaAsset();
|
||||
asset.setBizType(task.getBizType());
|
||||
asset.setFileName(task.getFileName());
|
||||
asset.setObjectKey(task.getObjectKey());
|
||||
asset.setSourceUrl(publicUrl);
|
||||
asset.setSizeBytes(merged.size());
|
||||
asset.setMd5(merged.md5());
|
||||
asset.setDurationSec(req.getDurationSec() == null ? 0 : req.getDurationSec());
|
||||
asset.setWidth(0);
|
||||
asset.setHeight(0);
|
||||
asset.setStatus(isImage(task.getFileName()) ? STATUS_READY : STATUS_UPLOADED);
|
||||
asset.setHlsUrl("");
|
||||
// 没有转码前,mp4_url 直接指向原文件,播放器可以先跑通
|
||||
asset.setMp4Url(publicUrl);
|
||||
asset.setPosterUrl("");
|
||||
asset.setCreateBy(task.getCreateBy());
|
||||
mediaAssetMapper.insert(asset);
|
||||
|
||||
task.setStatus(TASK_COMPLETED);
|
||||
task.setAssetId(asset.getId());
|
||||
task.setReceivedChunks(chunks);
|
||||
mediaUploadTaskMapper.updateById(task);
|
||||
storageService.cleanChunkDir(req.getUploadId());
|
||||
|
||||
// 重新查一次,拿到数据库生成的 created_at
|
||||
return toResp(mediaAssetMapper.selectById(asset.getId()));
|
||||
}
|
||||
|
||||
public MediaAssetRespVO get(Long id) {
|
||||
MediaAsset asset = id == null ? null : mediaAssetMapper.selectById(id);
|
||||
if (asset == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在");
|
||||
}
|
||||
return toResp(asset);
|
||||
}
|
||||
|
||||
private MediaUploadTask getTask(String uploadId) {
|
||||
MediaUploadTask task = mediaUploadTaskMapper.selectOne(
|
||||
Wrappers.<MediaUploadTask>lambdaQuery().eq(MediaUploadTask::getUploadId, uploadId));
|
||||
if (task == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "上传会话不存在或已过期");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private String normalizeBizType(String bizType) {
|
||||
if (bizType == null || bizType.isBlank()) {
|
||||
return "EPISODE";
|
||||
}
|
||||
return bizType.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private boolean isImage(String fileName) {
|
||||
if (fileName == null) {
|
||||
return false;
|
||||
}
|
||||
String lower = fileName.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.stream().anyMatch(lower::endsWith);
|
||||
}
|
||||
|
||||
private MediaAssetRespVO toResp(MediaAsset asset) {
|
||||
MediaAssetRespVO vo = new MediaAssetRespVO();
|
||||
vo.setId(asset.getId());
|
||||
vo.setBizType(asset.getBizType());
|
||||
vo.setFileName(asset.getFileName());
|
||||
vo.setObjectKey(asset.getObjectKey());
|
||||
vo.setUrl(asset.getSourceUrl());
|
||||
vo.setSizeBytes(asset.getSizeBytes());
|
||||
vo.setMd5(asset.getMd5());
|
||||
vo.setDurationSec(asset.getDurationSec());
|
||||
vo.setStatus(asset.getStatus());
|
||||
vo.setCreatedAt(asset.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.media.config.MediaProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 本地磁盘媒资存储:分片写临时目录,完成后合并成正式文件。
|
||||
* 之后要换成对象存储时,只需替换本类实现。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MediaStorageService {
|
||||
|
||||
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
|
||||
|
||||
private final MediaProperties properties;
|
||||
|
||||
public record MergeResult(Path path, long size, String md5) {
|
||||
}
|
||||
|
||||
public Path rootDir() {
|
||||
Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(root);
|
||||
Files.createDirectories(root.resolve("tmp"));
|
||||
} catch (IOException ex) {
|
||||
throw new ServiceException("无法创建媒资目录: " + root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
public Path chunkDir(String uploadId) {
|
||||
return rootDir().resolve("tmp").resolve(uploadId);
|
||||
}
|
||||
|
||||
public void saveChunk(String uploadId, int index, MultipartFile file) {
|
||||
Path dir = chunkDir(uploadId);
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Path target = dir.resolve(index + ".part");
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
throw new ServiceException("写入分片失败: " + index);
|
||||
}
|
||||
}
|
||||
|
||||
public int countChunks(String uploadId) {
|
||||
Path dir = chunkDir(uploadId);
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return 0;
|
||||
}
|
||||
try (var stream = Files.list(dir)) {
|
||||
return (int) stream.filter(path -> path.getFileName().toString().endsWith(".part")).count();
|
||||
} catch (IOException ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按分片序号顺序合并,同时计算 MD5。 */
|
||||
public MergeResult merge(String uploadId, String objectKey, int totalChunks) {
|
||||
Path target = rootDir().resolve(objectKey).normalize();
|
||||
if (!target.startsWith(rootDir())) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "非法的存储路径");
|
||||
}
|
||||
MessageDigest digest = digest();
|
||||
long size = 0;
|
||||
try {
|
||||
Files.createDirectories(target.getParent());
|
||||
try (OutputStream fileOut = Files.newOutputStream(target,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
DigestOutputStream out = new DigestOutputStream(fileOut, digest)) {
|
||||
for (int index = 0; index < totalChunks; index++) {
|
||||
Path part = chunkDir(uploadId).resolve(index + ".part");
|
||||
if (!Files.exists(part)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "分片 " + index + " 缺失");
|
||||
}
|
||||
size += Files.copy(part, out);
|
||||
}
|
||||
}
|
||||
} catch (ServiceException ex) {
|
||||
throw ex;
|
||||
} catch (IOException ex) {
|
||||
throw new ServiceException("合并分片失败");
|
||||
}
|
||||
return new MergeResult(target, size, HexFormat.of().formatHex(digest.digest()));
|
||||
}
|
||||
|
||||
public void cleanChunkDir(String uploadId) {
|
||||
Path dir = chunkDir(uploadId);
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return;
|
||||
}
|
||||
try (var stream = Files.walk(dir)) {
|
||||
stream.sorted((a, b) -> b.getNameCount() - a.getNameCount())
|
||||
.forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// 临时文件清理失败不影响主流程
|
||||
}
|
||||
});
|
||||
} catch (IOException ex) {
|
||||
log.warn("清理分片目录失败: {}", dir, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(String objectKey) {
|
||||
try {
|
||||
Files.deleteIfExists(rootDir().resolve(objectKey).normalize());
|
||||
} catch (IOException ex) {
|
||||
log.warn("删除媒资文件失败: {}", objectKey, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成对象键:bizType/yyyyMM/uuid.ext */
|
||||
public String buildObjectKey(String bizType, String originalName) {
|
||||
String type = (bizType == null || bizType.isBlank() ? "misc" : bizType).toLowerCase();
|
||||
return type + "/" + LocalDate.now().format(MONTH_DIR) + "/"
|
||||
+ UUID.randomUUID().toString().replace("-", "") + extension(originalName);
|
||||
}
|
||||
|
||||
public String toPublicUrl(String objectKey) {
|
||||
String prefix = properties.getPublicPrefix();
|
||||
if (prefix.endsWith("/")) {
|
||||
prefix = prefix.substring(0, prefix.length() - 1);
|
||||
}
|
||||
return prefix + "/" + objectKey;
|
||||
}
|
||||
|
||||
private String extension(String fileName) {
|
||||
if (fileName == null) {
|
||||
return "";
|
||||
}
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
if (dot < 0 || dot == fileName.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
String ext = fileName.substring(dot).toLowerCase();
|
||||
return ext.length() > 12 ? "" : ext;
|
||||
}
|
||||
|
||||
private MessageDigest digest() {
|
||||
try {
|
||||
return MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new ServiceException("当前 JVM 不支持 MD5");
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.fruitshorts.media.config.MediaProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* 播放地址签名:把「过期时间 + 路径」用 HMAC-SHA256 签成不可伪造的 query 参数。
|
||||
*
|
||||
* <p>设计要点:签名只覆盖路径与过期时间,不绑定用户,这样 CDN 可以做纯 URL 缓存;
|
||||
* 需要按用户吊销时走票据表(playback_ticket)与并发设备限制,而不是把用户信息塞进 URL。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PlaybackSigner {
|
||||
|
||||
public static final String PARAM_EXPIRE = "e";
|
||||
public static final String PARAM_SIGNATURE = "s";
|
||||
|
||||
private final MediaProperties properties;
|
||||
|
||||
/** 给相对路径补上 e / s 参数,返回可直接播放的地址。 */
|
||||
public String sign(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
long expireAt = Instant.now().plusSeconds(properties.getSignatureTtlMinutes() * 60L).getEpochSecond();
|
||||
String signature = hmac(path, expireAt);
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
return path + separator + PARAM_EXPIRE + "=" + expireAt + "&" + PARAM_SIGNATURE + "=" + signature;
|
||||
}
|
||||
|
||||
/** 校验签名。签名缺失、过期或被篡改都返回 false。 */
|
||||
public boolean verify(String path, String expire, String signature) {
|
||||
if (path == null || path.isBlank() || expire == null || signature == null || signature.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
long expireAt;
|
||||
try {
|
||||
expireAt = Long.parseLong(expire);
|
||||
} catch (NumberFormatException ex) {
|
||||
return false;
|
||||
}
|
||||
if (expireAt < Instant.now().getEpochSecond()) {
|
||||
return false;
|
||||
}
|
||||
byte[] expected = hmac(path, expireAt).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] actual = signature.getBytes(StandardCharsets.UTF_8);
|
||||
return MessageDigest.isEqual(expected, actual);
|
||||
}
|
||||
|
||||
private String hmac(String path, long expireAt) {
|
||||
try {
|
||||
byte[] key = properties.getSignatureSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (key.length < 32) {
|
||||
throw new IllegalStateException("fruitshorts.media.signature-secret 长度必须不少于 32 字节");
|
||||
}
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
mac.update(path.getBytes(StandardCharsets.UTF_8));
|
||||
mac.update((byte) '|');
|
||||
mac.update(String.valueOf(expireAt).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(mac.doFinal());
|
||||
} catch (IllegalStateException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("播放地址签名失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
|
||||
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 转码任务编排。
|
||||
*
|
||||
* <p>第一阶段只做任务与产物的记账:真正的 FFmpeg / 云转码执行放在独立的 worker 里,
|
||||
* 通过 transcode_task 表认领任务(QUEUED → RUNNING → SUCCESS/FAILED),
|
||||
* 与在线服务物理隔离,避免转码抢占播放链路的 CPU。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TranscodeService {
|
||||
|
||||
public static final String STATUS_QUEUED = "QUEUED";
|
||||
public static final String STATUS_RUNNING = "RUNNING";
|
||||
public static final String STATUS_SUCCESS = "SUCCESS";
|
||||
public static final String STATUS_FAILED = "FAILED";
|
||||
private static final int MAX_RETRY = 3;
|
||||
|
||||
private final TranscodeTaskMapper transcodeTaskMapper;
|
||||
private final MediaRenditionMapper mediaRenditionMapper;
|
||||
|
||||
/** 上传完成后入队;同一剧集重复入队时复用未完成的任务,避免重复转码。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long enqueue(Long mediaAssetId, Long dramaId, Long episodeId, String sourceUrl) {
|
||||
TranscodeTask existing = transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getEpisodeId, episodeId)
|
||||
.in(TranscodeTask::getStatus, List.of(STATUS_QUEUED, STATUS_RUNNING))
|
||||
.last("limit 1"));
|
||||
if (existing != null) {
|
||||
return existing.getId();
|
||||
}
|
||||
TranscodeTask task = new TranscodeTask();
|
||||
task.setMediaAssetId(mediaAssetId == null ? 0L : mediaAssetId);
|
||||
task.setDramaId(dramaId == null ? 0L : dramaId);
|
||||
task.setEpisodeId(episodeId == null ? 0L : episodeId);
|
||||
task.setSourceUrl(sourceUrl == null ? "" : sourceUrl);
|
||||
task.setProfile("default");
|
||||
task.setStatus(STATUS_QUEUED);
|
||||
task.setProgress(0);
|
||||
task.setRetryCount(0);
|
||||
task.setErrorMsg("");
|
||||
transcodeTaskMapper.insert(task);
|
||||
return task.getId();
|
||||
}
|
||||
|
||||
/** worker 认领任务。并发下依赖状态条件更新,抢不到返回 null。 */
|
||||
public TranscodeTask claimNext() {
|
||||
TranscodeTask candidate = transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
||||
.orderByAsc(TranscodeTask::getId)
|
||||
.last("limit 1"));
|
||||
if (candidate == null) {
|
||||
return null;
|
||||
}
|
||||
int updated = transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
|
||||
.eq(TranscodeTask::getId, candidate.getId())
|
||||
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
||||
.set(TranscodeTask::getStatus, STATUS_RUNNING)
|
||||
.set(TranscodeTask::getStartedAt, LocalDateTime.now()));
|
||||
return updated == 1 ? candidate : null;
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void succeed(Long taskId, List<MediaRendition> renditions) {
|
||||
TranscodeTask task = getExisting(taskId);
|
||||
for (MediaRendition rendition : renditions) {
|
||||
rendition.setMediaAssetId(task.getMediaAssetId());
|
||||
rendition.setDramaId(task.getDramaId());
|
||||
rendition.setEpisodeId(task.getEpisodeId());
|
||||
rendition.setStatus("ready");
|
||||
mediaRenditionMapper.insert(rendition);
|
||||
}
|
||||
task.setStatus(STATUS_SUCCESS);
|
||||
task.setProgress(100);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
transcodeTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
/** 失败重试最多 3 次,超过则停在 FAILED,由运营在后台看到并处理。 */
|
||||
public void fail(Long taskId, String errorMessage) {
|
||||
TranscodeTask task = getExisting(taskId);
|
||||
int retry = task.getRetryCount() == null ? 0 : task.getRetryCount();
|
||||
if (retry + 1 >= MAX_RETRY) {
|
||||
task.setStatus(STATUS_FAILED);
|
||||
} else {
|
||||
task.setStatus(STATUS_QUEUED);
|
||||
}
|
||||
task.setRetryCount(retry + 1);
|
||||
task.setErrorMsg(errorMessage == null ? "" : errorMessage);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
transcodeTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
public List<MediaRendition> listRenditions(Long episodeId) {
|
||||
return mediaRenditionMapper.selectList(Wrappers.<MediaRendition>lambdaQuery()
|
||||
.eq(MediaRendition::getEpisodeId, episodeId)
|
||||
.orderByAsc(MediaRendition::getVideoBitrate));
|
||||
}
|
||||
|
||||
private TranscodeTask getExisting(Long id) {
|
||||
TranscodeTask task = id == null ? null : transcodeTaskMapper.selectById(id);
|
||||
if (task == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "转码任务不存在");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class MediaAssetRespVO {
|
||||
|
||||
private Long id;
|
||||
private String bizType;
|
||||
private String fileName;
|
||||
private String objectKey;
|
||||
private String url;
|
||||
private Long sizeBytes;
|
||||
private String md5;
|
||||
private Integer durationSec;
|
||||
private String status;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UploadCompleteReqVO {
|
||||
|
||||
@NotBlank(message = "缺少 uploadId")
|
||||
private String uploadId;
|
||||
|
||||
/** 客户端探测到的时长(视频用) */
|
||||
private Integer durationSec;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UploadInitReqVO {
|
||||
|
||||
@NotBlank(message = "缺少文件名")
|
||||
private String fileName;
|
||||
|
||||
@Positive(message = "文件大小不合法")
|
||||
private long fileSize;
|
||||
|
||||
/** DRAMA_COVER / EPISODE / AVATAR */
|
||||
private String bizType = "EPISODE";
|
||||
|
||||
/** 客户端期望的分片大小,留空则用服务端配置 */
|
||||
private Long chunkSize;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class UploadInitRespVO {
|
||||
|
||||
private String uploadId;
|
||||
private long chunkSize;
|
||||
private int totalChunks;
|
||||
private String objectKey;
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.fruitshorts.media.web;
|
||||
|
||||
import com.fruitshorts.media.config.MediaProperties;
|
||||
import com.fruitshorts.media.service.PlaybackSigner;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 片源防盗链:开启 fruitshorts.media.signature-required 后,
|
||||
* /files/episode/** 必须携带有效的 e / s 参数,否则 403。
|
||||
* 封面、轮播图等运营素材不受影响。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MediaSignatureFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final AntPathMatcher MATCHER = new AntPathMatcher();
|
||||
|
||||
private final MediaProperties properties;
|
||||
private final PlaybackSigner signer;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!properties.isSignatureRequired() || !isProtected(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
boolean valid = signer.verify(
|
||||
request.getRequestURI(),
|
||||
request.getParameter(PlaybackSigner.PARAM_EXPIRE),
|
||||
request.getParameter(PlaybackSigner.PARAM_SIGNATURE));
|
||||
if (!valid) {
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write("{\"code\":403,\"message\":\"播放地址无效或已过期\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean isProtected(String uri) {
|
||||
String prefixes = properties.getProtectedPrefixes();
|
||||
if (prefixes == null || prefixes.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (String prefix : prefixes.split(",")) {
|
||||
String trimmed = prefix.trim();
|
||||
if (!trimmed.isEmpty()
|
||||
&& (MATCHER.match(trimmed + "**", uri) || uri.startsWith(trimmed))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user