feat: 初始化短剧服务端(管理后台 API + 客户端 API)

Spring Boot 3.2 / MyBatis-Plus / PostgreSQL,7 个 Maven 模块;含认证鉴权、内容与媒资管理、操作日志,以及客户端 /app/** 接口。
This commit is contained in:
Vicky committed 2026-10-14 18:02:47 +08:00
commit a4aa749cc6
152 files changed
+8027

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-module-media</artifactId>
<name>reelshort-module-media</name>
<description>媒资域:分片上传、本地存储、访问地址</description>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-framework</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,23 @@
package com.reelshort.media.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "reelshort.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;
}
@@ -0,0 +1,38 @@
package com.reelshort.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,39 @@
package com.reelshort.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;
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,33 @@
package com.reelshort.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,7 @@
package com.reelshort.media.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.media.dal.entity.MediaAsset;
public interface MediaAssetMapper extends BaseMapper<MediaAsset> {
}
@@ -0,0 +1,7 @@
package com.reelshort.media.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.media.dal.entity.MediaUploadTask;
public interface MediaUploadTaskMapper extends BaseMapper<MediaUploadTask> {
}
@@ -0,0 +1,169 @@
package com.reelshort.media.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.media.config.MediaProperties;
import com.reelshort.media.dal.entity.MediaAsset;
import com.reelshort.media.dal.entity.MediaUploadTask;
import com.reelshort.media.dal.mapper.MediaAssetMapper;
import com.reelshort.media.dal.mapper.MediaUploadTaskMapper;
import com.reelshort.media.vo.MediaAssetRespVO;
import com.reelshort.media.vo.UploadCompleteReqVO;
import com.reelshort.media.vo.UploadInitReqVO;
import com.reelshort.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.reelshort.media.service;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.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,23 @@
package com.reelshort.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.reelshort.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.reelshort.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.reelshort.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;
}