ABaseController 通用的 Controller 父类
@Slf4j
是 Lombok 提供的注解,自动帮你生成一个 log
日志对象
引入配置类 AppConfig
,可以在子类 Controller 里直接用它获取配置信息(如管理员账号、密码等)
getSuccessResponseVO(T t)
返回成功响应
getBusinessErrorResponseVO(BusinessException e, T t)
封装业务异常响应(比如用户密码错误、验证码错误这种非系统异常)
getServerErrorResponseVO(T t)
系统异常或服务器内部错误
saveToken2Cookie(HttpServletResponse response, String token)
Cookie 工具方法
把 token
保存到 Cookie 中
Cookie 名字是 Constants.TOKEN_ADMIN
setMaxAge(-1)
表示是会话 Cookie,浏览器关闭就消失
convertFileReponse2Stream(HttpServletResponse servletResponse, Response response)
文件流输出方法
这是个文件下载工具方法
参数里的 Response
可能是 okhttp 或 feign 返回的远程文件响应
它把响应里的文件流写到 Servlet 输出流中,直接下载到浏览器
登录校验功能实现:
自定义校验注解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface GlobalInterceptor {
/**
校验登录
@return
/
boolean checkLogin() default false;
}
使用AOP实现登录校验功能
@Component("operationAspect")
@Aspect
@Slf4j
public class GlobalOperationAspect {
@Resource
private RedisUtils redisUtils;
@Resource
private AppConfig appConfig;
@Before("@annotation(org.mint.annotation.GlobalInterceptor)")
public void interceptorDo(JoinPoint point) {
try {
Method method = ((MethodSignature) point.getSignature()).getMethod();
GlobalInterceptor interceptor = method.getAnnotation(GlobalInterceptor.class);
if (null == interceptor) {
return;
}
/**
校验登录
/
if (interceptor.checkLogin()) {
checkLogin();
}
} catch (BusinessException e) {
log.error("全局拦截器异常", e);
throw e;
} catch (Exception e) {
log.error("全局拦截器异常", e);
throw new BusinessException(ResponseCodeEnum.CODE_500);
} catch (Throwable e) {
log.error("全局拦截器异常", e);
throw new BusinessException(ResponseCodeEnum.CODE_500);
}
}
//校验登录
private void checkLogin() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader(Constants.TOKEN_WEB);
if (StringTools.isEmpty(token)) {
throw new BusinessException(ResponseCodeEnum.CODE_901);
}
TokenUserInfoDto tokenUserInfoDto = (TokenUserInfoDto) redisUtils.get(Constants.REDIS_KEY_TOKEN_WEB + token);
if (tokenUserInfoDto == null) {
throw new BusinessException(ResponseCodeEnum.CODE_901);
}
}
}
登录实现
使用Redis进行验证码的缓存以及删除
登录成功后生成Token并缓存到Redis以及Cookie中
删除验证码缓存后从 cookie 中取出 token
如果取到老的 token,就清除老的管理员 token 数据
@RequestMapping(value = "/login")
public ResponseVO login(HttpServletRequest request,
HttpServletResponse response,
@NotEmpty String account,
@NotEmpty String password, @NotEmpty String checkCode,
@NotEmpty String checkCodeKey) {
try {
if (!checkCode.equalsIgnoreCase((String) redisUtils.get(Constants.REDIS_KEY_CHECK_CODE + checkCodeKey))) {
throw new BusinessException("图片验证码不正确");
}
if (!account.equals(appConfig.getAdminAccount()) || !password.equals(StringTools.encodeByMD5(appConfig.getAdminPassword()))) {
throw new BusinessException("账号或者密码错误");
}
String token = redisComponent.saveTokenInfo4Admin(account);
saveToken2Cookie(response, token);
return getSuccessResponseVO(account);
} finally {
redisUtils.delete(Constants.REDIS_KEY_CHECK_CODE + checkCodeKey);
Cookie[] cookies = request.getCookies();
String token = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(Constants.TOKEN_ADMIN)) {
token = cookie.getValue();
}
}
}
if (!StringTools.isEmpty(token)) {
redisComponent.cleanToken4Admin(token);
}
}
}
验证码实现
生成算术验证码 生成一个宽 100
、高 42
的算术验证码图片
获取验证码正确答案 captcha.text()
就是答案,比如 “4”
随机生成一个 key,用来标识这次验证码 用 UUID 生成唯一的 key,避免重复或冲突
把验证码的答案存到 Redis 里,并设置过期时间 Redis 的 key 是:CHECK_CODE前缀 + 随机 key
存的 value 是验证码的结果过期时间是“10分钟”
把验证码图片转成 Base64 转成 Base64 是为了方便前端直接嵌在网页中 <img src="data:image...">
显示
封装响应 checkCode
是图片的内容,Base64 字符串 checkCodeKey
是这次验证码的标识,用来和用户输入一起回传校验
返回成功响应 返回给前端:Base64图片 + key
用户登录时会用这个 key + 用户输入的答案
去验证
@RequestMapping(value = "/checkCode")
public ResponseVO checkCode() {
ArithmeticCaptcha captcha = new ArithmeticCaptcha(100, 42);
String code = captcha.text();
String checkCodeKey = UUID.randomUUID().toString();
redisUtils.setex(Constants.REDIS_KEY_CHECK_CODE + checkCodeKey, code, Constants.REDIS_KEY_EXPIRES_ONE_MIN * 10);
String checkCodeBase64 = captcha.toBase64();
Map<String, String> result = new HashMap<>();
result.put("checkCode", checkCodeBase64);
result.put("checkCodeKey", checkCodeKey);
return getSuccessResponseVO(result);
}
注册系统实现
校验验证码,调用注册服务,返回成功,清除验证码缓存
@RequestMapping(value = "/register")
@GlobalInterceptor
public ResponseVO register(@NotEmpty @Email @Size(max = 150) String email,
@NotEmpty @Size(max = 20) String nickName,
@NotEmpty @Pattern(regexp = Constants.REGEX_PASSWORD) String registerPassword,
@NotEmpty String checkCodeKey,
@NotEmpty String checkCode) {
try {
if (!checkCode.equalsIgnoreCase(redisComponent.getCheckCode(checkCodeKey))) {
throw new BusinessException("图片验证码不正确");
}
userInfoService.register(email, nickName, registerPassword);
return getSuccessResponseVO(null);
} finally {
redisComponent.cleanCheckCode(checkCodeKey);
}
}
@GlobalTransactional
说明这个注册过程是一个分布式事务,出错就回滚。
检查邮箱是否已经注册,检查昵称是否重复,生成用户信息并加密密码,设置默认状态与奖励,插入数据库
用户在前端填邮箱昵称密码,输验证码点“注册”,后端先校验验证码(防止刷注册),后端判断邮箱、昵称是不是已经存在(防止重复),加密密码、防止泄露,填入默认值(性别、状态、注册奖励),存入数据库,完成注册!
@Override
@GlobalTransactional(rollbackFor = Exception.class)
public void register(String email, String nickName, String password) {
UserInfo userInfo = this.userInfoMapper.selectByEmail(email);
if (null != userInfo) {
throw new BusinessException("邮箱账号已经存在");
}
UserInfo nickNameUser = this.userInfoMapper.selectByNickName(nickName);
if (null != nickNameUser) {
throw new BusinessException("昵称已经存在");
}
String userId = StringTools.getRandomNumber(Constants.LENGTH_10);
userInfo = new UserInfo();
userInfo.setUserId(userId);
userInfo.setNickName(nickName);
userInfo.setEmail(email);
userInfo.setPassword(StringTools.encodeByMD5(password));
userInfo.setJoinTime(new Date());
userInfo.setStatus(UserStatusEnum.ENABLE.getStatus());
userInfo.setSex(UserSexEnum.SECRECY.getType());
userInfo.setTheme(Constants.ONE);
SysSettingDto sysSettingDto = redisComponent.getSysSettingDto();
userInfo.setTotalCoinCount(sysSettingDto.getRegisterCoinCount());
userInfo.setCurrentCoinCount(sysSettingDto.getRegisterCoinCount());
this.userInfoMapper.insert(userInfo);
}
登录系统实现
验证验证码是否正确,获取客户端 IP,调用登录方法,将登录生成的 token 写入 Cookie,加载扩展信息(不影响登录),删除验证码 & 清除老 token(同一个设备避免重复登录)
@RequestMapping(value = "/login")
public ResponseVO login(HttpServletRequest request,
HttpServletResponse response,
@NotEmpty String account,
@NotEmpty String password, @NotEmpty String checkCode,
@NotEmpty String checkCodeKey) {
try {
if (!checkCode.equalsIgnoreCase((String) redisUtils.get(Constants.REDIS_KEY_CHECK_CODE + checkCodeKey))) {
throw new BusinessException("图片验证码不正确");
}
if (!account.equals(appConfig.getAdminAccount()) || !password.equals(StringTools.encodeByMD5(appConfig.getAdminPassword()))) {
throw new BusinessException("账号或者密码错误");
}
String token = redisComponent.saveTokenInfo4Admin(account);
saveToken2Cookie(response, token);
return getSuccessResponseVO(account);
} finally {
redisUtils.delete(Constants.REDIS_KEY_CHECK_CODE + checkCodeKey);
Cookie[] cookies = request.getCookies();
String token = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(Constants.TOKEN_ADMIN)) {
token = cookie.getValue();
}
}
}
if (!StringTools.isEmpty(token)) {
redisComponent.cleanToken4Admin(token);
}
}
}
查找用户信息,验证密码是否正确,验证账号是否禁用,更新登录时间 & IP,复制用户信息生成 TokenUserInfoDto,保存 token 到 Redis,便于后续校验,返回 token 给前端
@Override
public TokenUserInfoDto login(String email, String password, String ip) {
UserInfo userInfo = this.userInfoMapper.selectByEmail(email);
if (null == userInfo || !userInfo.getPassword().equals(password)) {
throw new BusinessException("账号或者密码错误");
}
if (UserStatusEnum.DISABLE.getStatus().equals(userInfo.getStatus())) {
throw new BusinessException("账号已禁用");
}
UserInfo updateInfo = new UserInfo();
updateInfo.setLastLoginTime(new Date());
updateInfo.setLastLoginIp(ip);
this.userInfoMapper.updateByUserId(updateInfo, userInfo.getUserId());
TokenUserInfoDto tokenUserInfoDto = CopyTools.copy(userInfo, TokenUserInfoDto.class);
redisComponent.saveTokenInfo(tokenUserInfoDto);
return tokenUserInfoDto;
}
自动登录实现
获取当前用户的 Token 信息,判断是否快要过期,如果 token 距离失效时间不到 1 天,就“续命”一下,补充用户扩展信息,用来查用户的扩展信息,比如:粉丝数、关注数、当前金币数。
@RequestMapping(value = "/autoLogin")
@GlobalInterceptor
public ResponseVO autoLogin(HttpServletResponse response) {
TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
if (tokenUserInfoDto == null) {
return getSuccessResponseVO(null);
}
if (tokenUserInfoDto.getExpireAt() - System.currentTimeMillis() < Constants.REDIS_KEY_EXPIRES_DAY) {
redisComponent.saveTokenInfo(tokenUserInfoDto);
saveToken2Cookie(response, tokenUserInfoDto.getToken());
}
userInfoService.getUserExtendInfo(tokenUserInfoDto);
return getSuccessResponseVO(tokenUserInfoDto);
}
分类系统实现
设置默认排序字段是 "sort asc"
(按 sort 字段升序)
设置结果要不要转换成树形结构(多级分类会用到)
@RequestMapping("/loadCategory")
public ResponseVO loadAllCategory(CategoryInfoQuery categoryInfo) {
categoryInfo.setOrderBy("sort asc");
categoryInfo.setConvert2Tree(true);
List<CategoryInfo> categoryInfoList = categoryInfoService.findListByParam(categoryInfo);
return getSuccessResponseVO(categoryInfoList);
}
新增分类的方法实现
如果是新增(categoryId == null
),但数据库中已存在同编号 → 报错
如果是修改(categoryId != null
),但数据库中已存在同编号,并且这个不是你自己 → 报错
如果是新增(ID 为空):查询当前父分类下的最大排序值 maxSort
设置当前分类的排序值为 maxSort + 1
然后插入数据库
如果是更新(ID 不为空):根据 categoryId
执行更新操作
保存完成之后,调用 save2Redis()
,把分类信息更新到 Redis 缓存中去
@Override
public void saveCategoryInfo(CategoryInfo bean) {
CategoryInfo dbBean = this.categoryInfoMapper.selectByCategoryCode(bean.getCategoryCode());
if (bean.getCategoryId() == null && dbBean != null || bean.getCategoryId() != null && dbBean != null && !bean.getCategoryId().equals(dbBean.getCategoryId())) {
throw new BusinessException("分类编号已经存在");
}
if (bean.getCategoryId() == null) {
Integer maxSort = this.categoryInfoMapper.selectMaxSort(bean.getpCategoryId());
bean.setSort(maxSort + 1);
this.categoryInfoMapper.insert(bean);
} else {
this.categoryInfoMapper.updateByCategoryId(bean, bean.getCategoryId());
}
//刷新缓存
save2Redis();
}
分类树实现(递归)
这是一个 将扁平列表转换为树形结构 的递归方法,常用于处理 分类目录、菜单、部门 等具有父子层级关系的数据。
数据库存储的是一维表结构(每行记录通过 pCategoryId
指向父节点),但前端需要树形结构展示。
private List<CategoryInfo> convertLine2Tree(List<CategoryInfo> dataList, Integer pid) {
List<CategoryInfo> children = new ArrayList();
for (CategoryInfo m : dataList) {
if (m.getCategoryId() != null && m.getpCategoryId() != null && m.getpCategoryId().equals(pid)) {
m.setChildren(convertLine2Tree(dataList, m.getCategoryId()));
children.add(m);
}
}
return children;
}
多级分类实现
你点“新增二级分类”的时候,前端 UI 就应该把“上级分类 ID”传过来(就是一级分类的 ID)
if (bean.getCategoryId() == null) {
Integer maxSort = this.categoryInfoMapper.selectMaxSort(bean.getpCategoryId());
bean.setSort(maxSort + 1);
this.categoryInfoMapper.insert(bean);
排序修改实现(上移·下移)
changeSort(Integer pCategoryId, String categoryIds)
根据前端传来的分类 ID 顺序,更新某个父分类下所有子分类的 sort
值,实现自定义排序。
categoryIds
是前端拖拽完分类后,根据当前的显示顺序主动拼成的 ID 串
@Override
public void changeSort(Integer pCategoryId, String categoryIds) {
String[] categoryIdArray = categoryIds.split(",");
List<CategoryInfo> categoryInfoList = new ArrayList<>();
Integer sort = 1;
for (String categoryId : categoryIdArray) {
CategoryInfo categoryInfo = new CategoryInfo();
categoryInfo.setCategoryId(Integer.parseInt(categoryId));
categoryInfo.setpCategoryId(pCategoryId);
categoryInfo.setSort(++sort);
categoryInfoList.add(categoryInfo);
}
this.categoryInfoMapper.updateSortBatch(categoryInfoList);
save2Redis();
}
刷新缓存实现
把分类列表查出来 ➜ 转成树 ➜ 存到 Redis
private void save2Redis() {
CategoryInfoQuery categoryInfoQuery = new CategoryInfoQuery();
categoryInfoQuery.setOrderBy("sort asc");
List<CategoryInfo> sourceCategoryInfoList = this.categoryInfoMapper.selectList(categoryInfoQuery);
List<CategoryInfo> categoryInfoList = convertLine2Tree(sourceCategoryInfoList, 0);
redisComponent.saveCategoryList(categoryInfoList);
}
public void saveCategoryList(List<CategoryInfo> categoryInfoList) {
redisUtils.set(Constants.REDIS_KEY_CATEGORY_LIST, categoryInfoList);
}
封面上传实现
用来读取静态资源文件(比如图片),然后返回给前端的。根据资源类型设置响应头,对图片会设置缓存 30 天
response
:用来写出文件流的响应体
sourceName
:文件名或路径,必须非空(通过 @NotEmpty
验证)
防止非法路径,比如 ../../../../etc/passwd
这种恶意访问。保护文件系统不被越权访问
根据文件名判断类型
设置浏览器缓存图片的时间:30 天
设置响应的 MIME 类型,比如 image/png
, image/jpg
读取文件并输出
@RequestMapping("/getResource")
@GlobalInterceptor
public void getResource(HttpServletResponse response, @NotEmpty String sourceName) {
if (!StringTools.pathIsOk(sourceName)) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
String suffix = StringTools.getFileSuffix(sourceName);
FileTypeEnum fileTypeEnum = FileTypeEnum.getBySuffix(suffix);
if (null == fileTypeEnum) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
switch (fileTypeEnum) {
case IMAGE:
//缓存30天
response.setHeader("Cache-Control", "max-age=" + 30 24 60 * 60);
response.setContentType("image/" + suffix.replace(".", ""));
break;
}
readFile(response, sourceName);
}
文件读取实现
从服务器磁盘上读取一个文件,把它写到 HttpServletResponse
的输出流中,让前端可以直接访问这个文件
构建文件路径并检查文件是否存在
appConfig.getProjectFolder()
:项目根路径
filePath
:传入的文件名
然后检查这个文件存不存在,如果没有就直接 return,不输出任何东西
使用流读文件并写到响应
try-with-resources,自动关闭资源,避免内存泄漏
FileInputStream in
:读取磁盘上的文件
OutputStream out
:写到 HTTP 响应里,前端就能拿到
开始循环读写文件内容
捕获异常,记录错误日志
protected void readFile(HttpServletResponse response, String filePath) {
File file = new File(appConfig.getProjectFolder() + Constants.FILE_FOLDER + filePath);
if (!file.exists()) {
return;
}
try (OutputStream out = response.getOutputStream(); FileInputStream in = new FileInputStream(file)) {
byte[] byteData = new byte[1024];
int len = 0;
while ((len = in.read(byteData)) != -1) {
out.write(byteData, 0, len);
}
out.flush();
} catch (Exception e) {
log.error("读取文件异常", e);
}
}
客户端接收分类实现
@FeignClient(name = xxx)
:这表示你要调用的服务名(admin
模块对应的服务)
方法 loadAllCategory()
:发起一个 HTTP 请求,调用 admin
服务的 /inner-api/category/loadAllCategory
接口
@FeignClient(name = Constants.SERVER_NAME_ADMIN)
public interface CategoryClient {
@RequestMapping(Constants.INNER_API_PREFIX + "/category/loadAllCategory")
List<CategoryInfo> loadAllCategory();
}
Admin 模块里对应的 Controller 方法接收到请求
实际是写在API里,Constants.INNER_API_PREFIX
是 /inner-api
,防止公开暴露
@RestController
@RequestMapping(Constants.INNER_API_PREFIX + "/category")
public class CategoryApi {
@Resource
private CategoryInfoService categoryInfoService;
@RequestMapping("/loadAllCategory")
public List<CategoryInfo> loadAllCategory() {
List<CategoryInfo> categoryInfoList = categoryInfoService.getAllCategoryList();
return categoryInfoList;
}
}
先从 Redis 里拿分类,没有就刷新缓存,再从 Redis 拿一遍返回
@Override
public List<CategoryInfo> getAllCategoryList() {
List<CategoryInfo> categoryInfoList = redisComponent.getCategoryList();
if (categoryInfoList.isEmpty()) {
save2Redis();
}
return redisComponent.getCategoryList();
}
视频系统实现
用户端负责上传视频,所以视频功能系统写在用户端下
视频上传流程
肯定是“先调用一次 preUploadVideo
,然后多次调用 uploadVideo
视频上传完整流程(分片上传场景):
1️⃣ 前置准备 - preUploadVideo
【只调用一次】
客户端发起上传请求,告诉后端:
我要上传的视频名是
xxx.mp4
我把它分成了
N
个分片
后端记录上传任务信息、创建临时目录、生成
uploadId
返回给前端这个
uploadId
作为后续上传的凭证
2️⃣ 实际上传 - uploadVideo
【会调用
N
次,每次上传一个分片】
每次客户端上传一个 chunk 分片时都调用这个接口,参数包括:
当前是第几块(
chunkIndex
)上传的文件数据(
MultipartFile
)上传凭证
uploadId
后端校验分片、存文件、更新上传状态、记录进度
预处理上传文件实现
在正式上传视频分片前,先在Redis中记录一份上传任务的基础信息,并为每个用户+上传任务生成唯一的标识 uploadId
生成唯一的上传 ID,构建上传文件信息的对象,构造文件临时存储目录,按“日期+用户ID+uploadId”生成目录结构,避免重复和混乱;
filePath
就是临时文件夹的相对路径,然后拼一个完整的物理路径,创建文件夹,将上传信息写入 Redis。
@RequestMapping("/preUploadVideo")
@GlobalInterceptor(checkLogin = true)
public ResponseVO preUploadVideo(@NotEmpty String fileName, @NotNull Integer chunks) {
TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
String uploadId = redisComponent.savePreVideoFileInfo(tokenUserInfoDto.getUserId(), fileName, chunks);
return getSuccessResponseVO(uploadId);
}
public String savePreVideoFileInfo(String userId, String fileName, Integer chunks) {
String uploadId = StringTools.getRandomString(Constants.LENGTH_15);
UploadingFileDto fileDto = new UploadingFileDto();
fileDto.setChunks(chunks);
fileDto.setFileName(fileName);
fileDto.setUploadId(uploadId);
fileDto.setChunkIndex(0);
String day = DateUtil.format(new Date(), DateTimePatternEnum.YYYYMMDD.getPattern());
String filePath = day + "/" + userId + uploadId;
String folder = appConfig.getProjectFolder() + Constants.FILE_FOLDER + Constants.FILE_FOLDER_TEMP + filePath;
File folderFile = new File(folder);
if (!folderFile.exists()) {
folderFile.mkdirs();
}
fileDto.setFilePath(filePath);
redisUtils.setex(Constants.REDIS_KEY_UPLOADING_FILE + userId + uploadId, fileDto, Constants.REDIS_KEY_EXPIRES_DAY);
return uploadId;
}
分片上传实现(多次调用)
获取用户 & 上传信息,校验上传任务是否存在,如果 Redis 中找不到这个 uploadId,说明是无效上传任务,校验上传大小是否超过限制,校验分片序号是否合法,不允许乱序跳跃式上传,不允许上传超出总分片数的 index,存储分片文件,根据之前生成的临时目录 filePath
,把这个分片保存为文件,命名为 chunkIndex
,每个分片存在单独的文件中,以便后续合并。更新上传进度,记录当前传到了第几片、目前已上传的大小是多少,写回 Redis,便于下一次上传继续使用。
@RequestMapping("/uploadVideo")
@GlobalInterceptor(checkLogin = true)
public ResponseVO uploadVideo(@NotNull MultipartFile chunkFile, @NotNull Integer chunkIndex, @NotEmpty String uploadId) throws IOException {
TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
UploadingFileDto fileDto = redisComponent.getUploadingVideoFile(tokenUserInfoDto.getUserId(), uploadId);
if (fileDto == null) {
throw new BusinessException("文件不存在请重新上传");
}
SysSettingDto sysSettingDto = redisComponent.getSysSettingDto();
if (fileDto.getFileSize() > sysSettingDto.getVideoSize() * Constants.MB_SIZE) {
throw new BusinessException("文件超过最大文件限制");
}
//判断分片
if ((chunkIndex - 1) > fileDto.getChunkIndex() || chunkIndex > fileDto.getChunks() - 1) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
String folder = appConfig.getProjectFolder() + Constants.FILE_FOLDER + Constants.FILE_FOLDER_TEMP + fileDto.getFilePath();
File targetFile = new File(folder + "/" + chunkIndex);
chunkFile.transferTo(targetFile);
//记录文件上传的分片数
fileDto.setChunkIndex(chunkIndex);
fileDto.setFileSize(fileDto.getFileSize() + chunkFile.getSize());
redisComponent.updateVideoFileInfo(tokenUserInfoDto.getUserId(), fileDto);
return getSuccessResponseVO(null);
}
删除上传中的视频实现
先从登录状态中获取当前用户的信息,用于 Redis 里查数据,从 Redis 中取出用户这个上传任务的记录。里面有当前上传到了第几片、总共多少片、文件路径等信息,如果 Redis 里都没找到,说明这个任务不存在,直接抛异常。删除 Redis 和临时文件,删除 Redis 中保存的上传记录,删除本地的临时文件夹。每一个分片其实是保存在 项目目录/文件夹/temp/用户文件路径/
下,现在直接一整个删掉。
@RequestMapping("/delUploadVideo")
public ResponseVO delUploadVideo(@NotEmpty String uploadId) throws IOException {
TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
UploadingFileDto fileDto = redisComponent.getUploadingVideoFile(tokenUserInfoDto.getUserId(), uploadId);
if (fileDto == null) {
throw new BusinessException("文件不存在请重新上传");
}
redisComponent.delVideoFileInfo(tokenUserInfoDto.getUserId(), uploadId);
FileUtils.deleteDirectory(new File(appConfig.getProjectFolder() + Constants.FILE_FOLDER + Constants.FILE_FOLDER_TEMP + fileDto.getFilePath()));
return getSuccessResponseVO(uploadId);
}
视频封面上传实现
生成日期路径:
把当前日期格式化成 yyyyMMdd
,用来当文件夹名字,方便管理。
创建目录:
拼出完整的路径,如果文件夹不存在就用 mkdirs()
创建它。
生成唯一文件名:
获取原文件名的后缀,比如 .png
,然后用随机字符串拼一个新的唯一文件名,避免重复。
保存文件到磁盘:
把上传的 MultipartFile 转成实际文件存入刚刚创建的路径。
如果需要缩略图:
就调用 fFmpegUtils.createImageThumbnail(...)
,这应该是你封装的一个调用 FFmpeg 来生成缩略图的小工具。
返回路径:
把最终的保存路径返回
@RequestMapping("/uploadImage")
@GlobalInterceptor(checkLogin = true)
public ResponseVO uploadCover(@NotNull MultipartFile file, @NotNull Boolean createThumbnail) throws IOException {
return getSuccessResponseVO(uploadCoverInner(file, createThumbnail));
}
public String uploadCoverInner(MultipartFile file, Boolean createThumbnail) throws IOException {
String day = DateUtil.format(new Date(), DateTimePatternEnum.YYYYMMDD.getPattern());
String folder = appConfig.getProjectFolder() + Constants.FILE_FOLDER + Constants.FILE_COVER + day;
File folderFile = new File(folder);
if (!folderFile.exists()) {
folderFile.mkdirs();
}
String fileName = file.getOriginalFilename();
String fileSuffix = fileName.substring(fileName.lastIndexOf("."));
String realFileName = StringTools.getRandomString(Constants.LENGTH_30) + fileSuffix;
String filePath = folder + "/" + realFileName;
file.transferTo(new File(filePath));
if (createThumbnail) {
//生成缩略图
fFmpegUtils.createImageThumbnail(filePath);
}
return Constants.FILE_COVER + day + "/" + realFileName;
}
视频完成上传实现
1. 校验上传文件数是否合法
限制视频最多不能传太多文件(防刷或安全策略)
2. 判断是否是新增视频,还是对已有视频进行更新
如果是更新,要检查视频是否存在且状态允许编辑。
3. 新增 or 更新 视频信息
deleteFileList
是数据库中有但前端没传的 → 代表被删除了
addFileList
是前端有但数据库没有的 → 代表是新增的
如果标题、封面、简介等字段有变化,也需要更新状态
4. 删除被移除的视频文件(从数据库+加入 Redis 删除队列)
5. 更新每个文件信息(设置索引、绑定 videoId、生成 fileId)
6. 把新增文件加入“待转码”的 Redis 队列
这个实现比较复杂,可参考下面文章
http://codehub.byte361.com/wiki/9742100124/dEFDHM6UpX
@Override
@GlobalTransactional(rollbackFor = Exception.class)
public void saveVideoInfo(VideoInfoPost videoInfoPost, List<VideoInfoFilePost> uploadFileList) {
if (uploadFileList.size() > redisComponent.getSysSettingDto().getVideoPCount()) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
if (!StringTools.isEmpty(videoInfoPost.getVideoId())) {
VideoInfoPost videoInfoPostDb = this.videoInfoPostMapper.selectByVideoId(videoInfoPost.getVideoId());
if (videoInfoPostDb == null) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
if (ArrayUtils.contains(new Integer[]{VideoStatusEnum.STATUS0.getStatus(), VideoStatusEnum.STATUS2.getStatus()}, videoInfoPostDb.getStatus())) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
}
Date curDate = new Date();
String videoId = videoInfoPost.getVideoId();
List<VideoInfoFilePost> deleteFileList = new ArrayList();
List<VideoInfoFilePost> addFileList = uploadFileList;
if (StringTools.isEmpty(videoId)) {
videoId = StringTools.getRandomString(Constants.LENGTH_10);
videoInfoPost.setVideoId(videoId);
videoInfoPost.setCreateTime(curDate);
videoInfoPost.setLastUpdateTime(curDate);
videoInfoPost.setStatus(VideoStatusEnum.STATUS0.getStatus());
this.videoInfoPostMapper.insert(videoInfoPost);
} else {
//查询已经存在的视频
VideoInfoFilePostQuery fileQuery = new VideoInfoFilePostQuery();
fileQuery.setVideoId(videoId);
fileQuery.setUserId(videoInfoPost.getUserId());
List<VideoInfoFilePost> dbInfoFileList = this.videoInfoFilePostMapper.selectList(fileQuery);
Map<String, VideoInfoFilePost> uploadFileMap = uploadFileList.stream().collect(Collectors.toMap(item -> item.getUploadId(), Function.identity(), (data1,
data2) -> data2));
//删除的文件 -> 数据库中有,uploadFileList没有
Boolean updateFileName = false;
for (VideoInfoFilePost fileInfo : dbInfoFileList) {
VideoInfoFilePost updateFile = uploadFileMap.get(fileInfo.getUploadId());
if (updateFile == null) {
deleteFileList.add(fileInfo);
} else if (!updateFile.getFileName().equals(fileInfo.getFileName())) {
updateFileName = true;
}
}
//新增的文件 没有fileId就是新增的文件
addFileList = uploadFileList.stream().filter(item -> item.getFileId() == null).collect(Collectors.toList());
videoInfoPost.setLastUpdateTime(curDate);
//判断视频信息是否有更改
Boolean changeVideoInfo = this.changeVideoInfo(videoInfoPost);
if (!addFileList.isEmpty()) {
videoInfoPost.setStatus(VideoStatusEnum.STATUS0.getStatus());
} else if (changeVideoInfo || updateFileName) {
videoInfoPost.setStatus(VideoStatusEnum.STATUS2.getStatus());
}
this.videoInfoPostMapper.updateByVideoId(videoInfoPost, videoInfoPost.getVideoId());
}
//清除已经删除的数据
if (!deleteFileList.isEmpty()) {
List<String> delFileIdList = deleteFileList.stream().map(item -> item.getFileId()).collect(Collectors.toList());
this.videoInfoFilePostMapper.deleteBatchByFileId(delFileIdList, videoInfoPost.getUserId());
//将要删除的视频加入消息队列
List<String> delFilePathList = deleteFileList.stream().map(item -> item.getFilePath()).collect(Collectors.toList());
redisComponent.addFile2DelQueue(videoId, delFilePathList);
}
//更新视频信息
Integer index = 1;
for (VideoInfoFilePost videoInfoFile : uploadFileList) {
videoInfoFile.setFileIndex(index++);
videoInfoFile.setVideoId(videoId);
videoInfoFile.setUserId(videoInfoPost.getUserId());
if (videoInfoFile.getFileId() == null) {
videoInfoFile.setFileId(StringTools.getRandomString(Constants.LENGTH_20));
videoInfoFile.setUpdateType(VideoFileUpdateTypeEnum.UPDATE.getStatus());
videoInfoFile.setTransferResult(VideoFileTransferResultEnum.TRANSFER.getStatus());
}
}
this.videoInfoFilePostMapper.insertOrUpdateBatch(uploadFileList);
//将需要转码的视频加入队列
if (!addFileList.isEmpty()) {
for (VideoInfoFilePost file : addFileList) {
file.setUserId(videoInfoPost.getUserId());
file.setVideoId(videoId);
}
redisComponent.addFile2TransferQueue(addFileList);
}
}
private boolean changeVideoInfo(VideoInfoPost videoInfoPost) {
VideoInfoPost dbInfo = this.videoInfoPostMapper.selectByVideoId(videoInfoPost.getVideoId());
//标题,封面,标签,简介
if (!videoInfoPost.getVideoCover().equals(dbInfo.getVideoCover()) || !videoInfoPost.getVideoName().equals(dbInfo.getVideoName()) || !videoInfoPost.getTags().equals(dbInfo.getTags()) || !videoInfoPost.getIntroduction().equals(
dbInfo.getIntroduction())) {
return true;
}
return false;
}
转码功能实现
从 Redis 里拿上传中的文件信息(UploadingFileDto
)
从临时目录复制视频文件 ➜ 正式视频目录
删除临时目录文件
合并上传的视频块为完整视频文件(this.union(...)
)
获取视频时长 + 文件大小 ➜ 写入 updateFilePost
调用 FFmpeg 切割视频(生成 .ts
和 .m3u8
)
异常处理:转码失败时设置状态为 FAIL
最后统一调用远程接口保存转码结果(videoClient.transferVideoFile4Db(...)
)
public void transferVideoFile(VideoInfoFilePost videoInfoFile) {
VideoInfoFilePost updateFilePost = new VideoInfoFilePost();
try {
UploadingFileDto fileDto = redisComponent.getUploadingVideoFile(videoInfoFile.getUserId(), videoInfoFile.getUploadId());
/**
拷贝文件到正式目录
/
String tempFilePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER + Constants.FILE_FOLDER_TEMP + fileDto.getFilePath();
File tempFile = new File(tempFilePath);
String targetFilePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER + Constants.FILE_VIDEO + fileDto.getFilePath();
File taregetFile = new File(targetFilePath);
if (!taregetFile.exists()) {
taregetFile.mkdirs();
}
FileUtils.copyDirectory(tempFile, taregetFile);
/**
删除临时目录
/
FileUtils.forceDelete(tempFile);
redisComponent.delVideoFileInfo(videoInfoFile.getUserId(), videoInfoFile.getUploadId());
/**
合并文件
/
String completeVideo = targetFilePath + Constants.TEMP_VIDEO_NAME;
this.union(targetFilePath, completeVideo, true);
/**
获取播放时长
/
Integer duration = fFmpegUtils.getVideoInfoDuration(completeVideo);
updateFilePost.setDuration(duration);
updateFilePost.setFileSize(new File(completeVideo).length());
updateFilePost.setFilePath(Constants.FILE_VIDEO + fileDto.getFilePath());
updateFilePost.setTransferResult(VideoFileTransferResultEnum.SUCCESS.getStatus());
/**
ffmpeg切割文件
/
this.convertVideo2Ts(completeVideo);
} catch (Exception e) {
log.error("文件转码失败", e);
updateFilePost.setTransferResult(VideoFileTransferResultEnum.FAIL.getStatus());
} finally {
videoClient.transferVideoFile4Db(videoInfoFile.getVideoId(), videoInfoFile.getUploadId(), videoInfoFile.getUserId(), updateFilePost);
}
}
转码状态更新实现
更新某个文件的状态
查转码失败的视频文件数量
如果还有失败的:就直接把这条视频整体状态设为 失败(STATUS1),后面逻辑就不执行了
如果没有失败的,还要看转码中的
如果都转码完了:转码成功(STATUS2)+ 设置总时长
@Override
@GlobalTransactional(rollbackFor = Exception.class)
public void transferVideoFile4Db(String videoId, String uploadId, String userId, VideoInfoFilePost updateFilePost) {
//更新文件状态
videoInfoFilePostMapper.updateByUploadIdAndUserId(updateFilePost, uploadId, userId);
//更新视频信息
VideoInfoFilePostQuery fileQuery = new VideoInfoFilePostQuery();
fileQuery.setVideoId(videoId);
fileQuery.setTransferResult(VideoFileTransferResultEnum.FAIL.getStatus());
Integer failCount = videoInfoFilePostMapper.selectCount(fileQuery);
if (failCount > 0) {
VideoInfoPost videoUpdate = new VideoInfoPost();
videoUpdate.setStatus(VideoStatusEnum.STATUS1.getStatus());
videoInfoPostMapper.updateByVideoId(videoUpdate, videoId);
return;
}
fileQuery.setTransferResult(VideoFileTransferResultEnum.TRANSFER.getStatus());
Integer transferCount = videoInfoFilePostMapper.selectCount(fileQuery);
if (transferCount == 0) {
Integer duration = videoInfoFilePostMapper.sumDuration(videoId);
VideoInfoPost videoUpdate = new VideoInfoPost();
videoUpdate.setStatus(VideoStatusEnum.STATUS2.getStatus());
videoUpdate.setDuration(duration);
videoInfoPostMapper.updateByVideoId(videoUpdate, videoId);
}
}
首页推荐视频实现
获取推荐视频列表
查询被标记为推荐的视频(RECOMMEND
类型)
排序方式是按照 create_time
降序(最新的排最前)
结果是一个完整列表(findListByParam
,无分页)
返回推荐视频列表给前端展示(如首页推荐区)
@RequestMapping("/loadRecommendVideo")
@GlobalInterceptor
public ResponseVO loadRecommendVideo() {
VideoInfoQuery videoInfoQuery = new VideoInfoQuery();
videoInfoQuery.setQueryUserInfo(true);
videoInfoQuery.setOrderBy("create_time desc");
videoInfoQuery.setRecommendType(VideoRecommendTypeEnum.RECOMMEND.getType());
List<VideoInfo> recommendVideoList = videoInfoService.findListByParam(videoInfoQuery);
return getSuccessResponseVO(recommendVideoList);
}
分类页视频展示实现
按分类分页获取普通视频列表(非推荐)
根据传入的分类(一级、二级)、页码查询
类型设定为 NO_RECOMMEND
,即不推荐的视频
支持分页(findListByPage
)
返回分页的视频数据,用于分类浏览页
@RequestMapping("/loadVideo")
@GlobalInterceptor
public ResponseVO postVideo(Integer pCategoryId, Integer categoryId, Integer pageNo) {
VideoInfoQuery videoInfoQuery = new VideoInfoQuery();
videoInfoQuery.setCategoryId(categoryId);
videoInfoQuery.setpCategoryId(pCategoryId);
videoInfoQuery.setPageNo(pageNo);
videoInfoQuery.setQueryUserInfo(true);
videoInfoQuery.setOrderBy("create_time desc");
videoInfoQuery.setRecommendType(VideoRecommendTypeEnum.NO_RECOMMEND.getType());
PaginationResultVO resultVO = videoInfoService.findListByPage(videoInfoQuery);
return getSuccessResponseVO(resultVO);
}
视频详细页实现
作用:获取某个视频的详细信息 + 当前用户对这个视频的互动记录(比如点赞、投币、收藏)
根据 videoId
查询视频基本信息
若查不到,直接抛 404 异常
如果当前有登录用户,再去查这个用户对该视频的行为(3种互动:点赞、收藏、投币)
构建返回对象 VideoInfoResultVo
,包含视频信息 和 用户行为列表
返回给前端,用于视频播放页的数据展示(比如按钮点没点)
@RequestMapping("/getVideoInfo")
@GlobalInterceptor
public ResponseVO getVideoInfo(@NotEmpty String videoId) {
VideoInfo videoInfo = videoInfoService.getVideoInfoByVideoId(videoId);
if (null == videoInfo) {
throw new BusinessException(ResponseCodeEnum.CODE_404);
}
TokenUserInfoDto userInfoDto = getTokenUserInfoDto();
List<UserAction> userActionList = new ArrayList<>();
if (userInfoDto != null) {
UserActionQuery actionQuery = new UserActionQuery();
actionQuery.setVideoId(videoId);
actionQuery.setUserId(userInfoDto.getUserId());
actionQuery.setActionTypeArray(new Integer[]{UserActionTypeEnum.VIDEO_LIKE.getType(), UserActionTypeEnum.VIDEO_COLLECT.getType(), UserActionTypeEnum.VIDEO_COIN.getType(),});
userActionList = interactClient.getUserActionList(actionQuery);
}
VideoInfoResultVo resultVo = new VideoInfoResultVo();
resultVo.setVideoInfo(CopyTools.copy(videoInfo, VideoInfoVo.class));
resultVo.setUserActionList(userActionList);
return getSuccessResponseVO(resultVo);
}
视频选集实现
作用:加载某个视频的所有分片文件(通常是 .ts
或 .mp4
)
根据 videoId
查询视频分片信息
排序方式是 file_index
升序(播放顺序)
返回给前端,便于播放器依次加载播放分段视频文件
@RequestMapping("/loadVideoPList")
@GlobalInterceptor
public ResponseVO loadVideoPList(String videoId) {
VideoInfoFileQuery videoInfoQuery = new VideoInfoFileQuery();
videoInfoQuery.setVideoId(videoId);
videoInfoQuery.setOrderBy("file_index asc");
List<VideoInfoFile> fileList = videoInfoFileService.findListByParam(videoInfoQuery);
return getSuccessResponseVO(fileList);
}
视频播放实现
@RequestMapping("/videoResource/{fileId}")
@GlobalInterceptor
public void getVideoResource(HttpServletResponse response, @PathVariable @NotEmpty String fileId) {
VideoInfoFile videoInfoFile = videoClient.getVideoInfoFileByFileId(fileId);
if (videoInfoFile == null) {
return;
}
String filePath = videoInfoFile.getFilePath();
readFile(response, filePath + "/" + Constants.M3U8_NAME);
VideoPlayInfoDto videoPlayInfoDto = new VideoPlayInfoDto();
videoPlayInfoDto.setVideoId(videoInfoFile.getVideoId());
videoPlayInfoDto.setFileIndex(videoInfoFile.getFileIndex());
TokenUserInfoDto tokenUserInfoDto = getTokenInfoFromCookie();
if (tokenUserInfoDto != null) {
videoPlayInfoDto.setUserId(tokenUserInfoDto.getUserId());
}
redisComponent.addVideoPlay(videoPlayInfoDto);
}
审核系统实现
查看视频状态实现
拦截器校验是否登录
拿到当前登录用户的信息(比如 userId)
查询视频信息
校验:视频不存在 或不是当前用户发布的 → 直接抛 404 异常!
构造查询参数,查询这个视频的文件列表,并按顺序排列
去查这个视频下的所有分片文件(上传的那几个 .ts
或 .mp4
)
把视频主信息 + 文件信息 封装进返回 VO
返回成功,前端收到的是一个视频详情和文件列表,用于编辑或查看视频时的数据填充
@RequestMapping("/getVideoByVideoId")
@GlobalInterceptor(checkLogin = true)
public ResponseVO getVideoByVideoId(@NotEmpty String videoId) {
TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
VideoInfoPost videoInfoPost = this.videoInfoPostService.getVideoInfoPostByVideoId(videoId);
if (videoInfoPost == null || !videoInfoPost.getUserId().equals(tokenUserInfoDto.getUserId())) {
throw new BusinessException(ResponseCodeEnum.CODE_404);
}
VideoInfoFilePostQuery videoInfoFilePostQuery = new VideoInfoFilePostQuery();
videoInfoFilePostQuery.setVideoId(videoId);
videoInfoFilePostQuery.setOrderBy("file_index asc");
List<VideoInfoFilePost> videoInfoFilePostList = this.videoInfoFilePostService.findListByParam(videoInfoFilePostQuery);
VideoPostEditInfoVo vo = new VideoPostEditInfoVo();
vo.setVideoInfo(videoInfoPost);
vo.setVideoInfoFileList(videoInfoFilePostList);
return getSuccessResponseVO(vo);
}
获取视频列表实现
截器校验是否登录
@GlobalInterceptor(checkLogin = true)
:确保登录,没登录直接禁止访问。
获取当前登录用户信息
通过 getTokenUserInfoDto()
拿到当前用户的 userId
。
构造查询参数 videoInfoQuery
设置用户ID:只查当前用户自己的视频。
设置排序方式:create_time desc
(按创建时间倒序)。
设置分页:传入 pageNo
。
设置模糊搜索:如果传了 videoNameFuzzy
,加上标题模糊匹配。
设置状态过滤:
如果传了 status == -1
,表示排除状态为“已删除、已下架”的视频。
其他正常状态则直接过滤该状态。
调用服务层分页查询
videoInfoPostService.findListByPage(videoInfoQuery)
:
查出分页结果 + 总数量。
统一返回封装数据
getSuccessResponseVO(resultVO)
返回分页结果 VO,前端用于展示。
@RequestMapping("/loadVideoList")
@GlobalInterceptor(checkLogin = true)
public ResponseVO loadVideoList(Integer status, Integer pageNo, String videoNameFuzzy) {
TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
VideoInfoPostQuery videoInfoQuery = new VideoInfoPostQuery();
videoInfoQuery.setUserId(tokenUserInfoDto.getUserId());
videoInfoQuery.setOrderBy("v.create_time desc");
videoInfoQuery.setPageNo(pageNo);
if (status != null) {
if (status == -1) {
videoInfoQuery.setExcludeStatusArray(new Integer[]{VideoStatusEnum.STATUS3.getStatus(), VideoStatusEnum.STATUS4.getStatus()});
} else {
videoInfoQuery.setStatus(status);
}
}
videoInfoQuery.setVideoNameFuzzy(videoNameFuzzy);
videoInfoQuery.setQueryCountInfo(true);
PaginationResultVO resultVO = videoInfoPostService.findListByPage(videoInfoQuery);
return getSuccessResponseVO(resultVO);
}
审核视频实现
校验审核状态是否合法
如果传入的 status
无效,直接抛异常。
更新审核表状态
用 videoId
和状态=审核中 来更新审核表 video_info_post
的记录。更新成功说明当前视频确实处于待审核状态。
更新视频文件表中各个文件的状态
设置每个文件的 updateType=NO_UPDATE
,意思是审核完之后文件不再允许改动。
如果是“审核失败”,就直接返回,不往下执行
如果审核通过,继续处理:
第一次发布加积分
如果正式表 video_info
没这条记录,说明这是首次发布,奖励积分。
将审核通过的数据拷贝到正式视频表
用工具类 CopyTools
把 video_info_post
拷贝成 video_info
,写入正式库。
同步视频文件信息
删除正式表中该视频的旧文件记录(可能有重新上传)。
查询审核表中的文件列表(video_info_file_post
)。
拷贝成正式表的 video_info_file
,批量插入。
删除已标记的临时文件目录
从 Redis 拿到要删的路径,删本地文件夹(安全起见,逐个删)。
同步到 ES 索引库中
把视频信息保存到 Elasticsearch,方便后续搜索。
@RequestMapping("/auditVideo")
@RecordUserMessage(messageType = MessageTypeEnum.SYS)
public ResponseVO auditVideo(@NotEmpty String videoId, @NotNull Integer status, String reason) {
webClient.auditVideo(videoId, status, reason);
return getSuccessResponseVO(null);
}
@Override
@GlobalTransactional(rollbackFor = Exception.class)
public void auditVideo(String videoId, Integer status, String reason) {
VideoStatusEnum videoStatusEnum = VideoStatusEnum.getByStatus(status);
if (videoStatusEnum == null) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
VideoInfoPost videoInfoPost = new VideoInfoPost();
videoInfoPost.setStatus(status);
VideoInfoPostQuery videoInfoPostQuery = new VideoInfoPostQuery();
videoInfoPostQuery.setStatus(VideoStatusEnum.STATUS2.getStatus());
videoInfoPostQuery.setVideoId(videoId);
Integer audioCount = this.videoInfoPostMapper.updateByParam(videoInfoPost, videoInfoPostQuery);
if (audioCount == 0) {
throw new BusinessException("审核失败,请稍后重试");
}
/**
更新视频状态
*/
VideoInfoFilePost videoInfoFilePost = new VideoInfoFilePost();
videoInfoFilePost.setUpdateType(VideoFileUpdateTypeEnum.NO_UPDATE.getStatus());
VideoInfoFilePostQuery filePostQuery = new VideoInfoFilePostQuery();
filePostQuery.setVideoId(videoId);
this.videoInfoFilePostMapper.updateByParam(videoInfoFilePost, filePostQuery);
if (VideoStatusEnum.STATUS4 == videoStatusEnum) {
return;
}
VideoInfoPost infoPost = this.videoInfoPostMapper.selectByVideoId(videoId);
/**
第一次发布增加用户积分
*/
VideoInfo dbVideoInfo = this.videoInfoMapper.selectByVideoId(videoId);
if (dbVideoInfo == null) {
SysSettingDto sysSettingDto = redisComponent.getSysSettingDto();
userInfoMapper.updateCoinCountInfo(infoPost.getUserId(), sysSettingDto.getPostVideoCoinCount());
}
/**
将发布信息复制到正式表信息
*/
VideoInfo videoInfo = CopyTools.copy(infoPost, VideoInfo.class);
this.videoInfoMapper.insertOrUpdate(videoInfo);
/**
更新视频信息 先删除再添加
*/
VideoInfoFileQuery videoInfoFileQuery = new VideoInfoFileQuery();
videoInfoFileQuery.setVideoId(videoId);
this.videoInfoFileMapper.deleteByParam(videoInfoFileQuery);
/**
查询发布表中的视频信息
*/
VideoInfoFilePostQuery videoInfoFilePostQuery = new VideoInfoFilePostQuery();
videoInfoFilePostQuery.setVideoId(videoId);
List<VideoInfoFilePost> videoInfoFilePostList = this.videoInfoFilePostMapper.selectList(videoInfoFilePostQuery);
List<VideoInfoFile> videoInfoFileList = CopyTools.copyList(videoInfoFilePostList, VideoInfoFile.class);
this.videoInfoFileMapper.insertBatch(videoInfoFileList);
/**
删除文件
*/
List<String> filePathList = redisComponent.getDelFileList(videoId);
if (filePathList != null) {
for (String path : filePathList) {
File file = new File(appConfig.getProjectFolder() + Constants.FILE_FOLDER + path);
if (file.exists()) {
try {
FileUtils.deleteDirectory(file);
} catch (IOException e) {
log.error("删除文件失败", e);
}
}
}
}
redisComponent.cleanDelFileList(videoId);
/**
保存信息到es
*/
esSearchComponent.saveDoc(videoInfo);
}
工具类
FFmpeg 视频转码工具
createImageThumbnail
作用:压缩图片文件,生成缩略图。
使用 -vf scale=200:-1,将宽缩放为 200px,高度自适应。
输出路径是原路径加上 _thumbnail.jpg 之类的后缀。
getVideoCodec
作用:获取视频流编码类型,如 h264、hevc。
用 FFprobe 提取流信息中的 codec_name。
截取字符串提取值,不太鲁棒但能用(建议改为 JSON 输出更保险)。
convertHevc2Mp4
作用:将 HEVC (H.265) 转换为 H.264,提升兼容性。
使用命令中的 -c:v libx264 -crf 20,是常见的高质量压缩方式。
convertVideo2Ts
作用:用于将视频转为 TS 文件并生成 HLS 播放清单(.m3u8)
第一步:把原视频转换为 TS(可被切片)
第二步:使用 -f segment 切片,每段 10 秒,生成多段 .ts 以及 index.m3u8。
主要用于在线播放方案(HLS)
getVideoInfoDuration
作用:获取视频总时长。
使用 FFprobe 获取 duration
,返回秒数整数。
@Component
public class FFmpegUtils {
@Resource
private AppConfig appConfig;
/**
生成图片缩略图
@param filePath
@return
/
public void createImageThumbnail(String filePath) {
final String CMD_CREATE_IMAGE_THUMBNAIL = "ffmpeg -i \"%s\" -vf scale=200:-1 \"%s\"";
String cmd = String.format(CMD_CREATE_IMAGE_THUMBNAIL, filePath, filePath + Constants.IMAGE_THUMBNAIL_SUFFIX);
ProcessUtils.executeCommand(cmd, appConfig.getShowFFmpegLog());
}
/*
获取视频编码
@param videoFilePath
@return
*/
public String getVideoCodec(String videoFilePath) {
final String CMD_GET_CODE = "ffprobe -v error -select_streams v:0 -show_entries stream=codec_name \"%s\"";
String cmd = String.format(CMD_GET_CODE, videoFilePath);
String result = ProcessUtils.executeCommand(cmd, appConfig.getShowFFmpegLog());
result = result.replace("\n", "");
result = result.substring(result.indexOf("=") + 1);
String codec = result.substring(0, result.indexOf("["));
return codec;
}
public void convertHevc2Mp4(String newFileName, String videoFilePath) {
String CMD_HEVC_264 = "ffmpeg -i %s -c:v libx264 -crf 20 %s";
String cmd = String.format(CMD_HEVC_264, newFileName, videoFilePath);
ProcessUtils.executeCommand(cmd, appConfig.getShowFFmpegLog());
}
public void convertVideo2Ts(File tsFolder, String videoFilePath) {
final String CMD_TRANSFER_2TS = "ffmpeg -y -i \"%s\" -vcodec copy -acodec copy -vbsf h264_mp4toannexb \"%s\"";
final String CMD_CUT_TS = "ffmpeg -i \"%s\" -c copy -map 0 -f segment -segment_list \"%s\" -segment_time 10 %s/%%4d.ts";
String tsPath = tsFolder + "/" + Constants.TS_NAME;
//生成.ts
String cmd = String.format(CMD_TRANSFER_2TS, videoFilePath, tsPath);
ProcessUtils.executeCommand(cmd, appConfig.getShowFFmpegLog());
//生成索引文件.m3u8 和切片.ts
cmd = String.format(CMD_CUT_TS, tsPath, tsFolder.getPath() + "/" + Constants.M3U8_NAME, tsFolder.getPath());
ProcessUtils.executeCommand(cmd, appConfig.getShowFFmpegLog());
//删除index.ts
new File(tsPath).delete();
}
public Integer getVideoInfoDuration(String completeVideo) {
final String CMD_GET_CODE = "ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"%s\"";
String cmd = String.format(CMD_GET_CODE, completeVideo);
String result = ProcessUtils.executeCommand(cmd, appConfig.getShowFFmpegLog());
if (StringTools.isEmpty(result)) {
return 0;
}
result = result.replace("\n", "");
return new BigDecimal(result).intValue();
}
}