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:
Vicky committed 2026-10-15 11:44:26 +08:00
1 parent a4aa749cc6
commit b3179fc836
251 files changed
+7261 -767

No files matched your search

@@ -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/";
}
@@ -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());
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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);
}
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
}