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 没这条记录,说明这是首次发布,奖励积分。

将审核通过的数据拷贝到正式视频表
用工具类 CopyToolsvideo_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();
}
}

青い空