项目搭建笔记:Easy云盘资料
这是基于springboot+vue3、mybatis和redis 实现的一个仿制各大网盘的一个网页版云盘项目,实现的功能有qq登录、文件上传、删除文件、分享文件、预览文件,可以播放视频,听歌,看文档这些功能这个项目都有,有兴趣大家一起来学习吧!
后端项目搭建
使用代码生成器,生成初始目录,这里我提前创建好了数据库的四张表,所以生成的类会比较齐全
前端环境启动
开启nginx服务
start ./nginx.exe
重启nginx服务
./nginx.exe -s reload
登录功能业务代码开发
1、获取验证码
业务分析
访问登录页面后,客户端就会向服务端发送获取验证码的请求如下,咱直接通过工具类生成验证码图片,然后通过数据流响应给前端
type:表示验证码的类型,0是请求登录注册验证码,1是请求邮箱验证码;这里我们发送的type值为 0
根据这个type值后端会将验证验证码图片是否正确的code值存储在不同的sessionKey中,所以这里发送的type值为0,说明验证码图片的code信息保存在登录注册的sessionKey中
业务接口
代码实现
在AccountController编写以下代码
调用工具类的write()方法将服务端工具类生成的验证码图片通过二进制流响应给客户端
@RestController
public class AccountController {
/**
* 验证码
*
* @param response
* @param session
* @param type
* @throws IOException
*/
@RequestMapping(value = "/checkCode")
public void checkCode(HttpServletResponse response, HttpSession session, Integer type) throws
IOException {
CreateImageCode vCode = new CreateImageCode(130, 38, 5, 10);
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/jpeg");
String code = vCode.getCode();
if (type == null || type == 0) {
session.setAttribute(Constants.CHECK_CODE_KEY, code);
} else {
session.setAttribute(Constants.CHECK_CODE_KEY_EMAIL, code);
}
vCode.write(response.getOutputStream());
}
}
生成验证码的 CreateImageCode 类
package com.easypan.entity.dto;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Random;
public class CreateImageCode {
// 图片的宽度。
private int width = 160;
// 图片的高度。
private int height = 40;
// 验证码字符个数
private int codeCount = 4;
// 验证码干扰线数
private int lineCount = 20;
// 验证码
private String code = null;
// 验证码图片Buffer
private BufferedImage buffImg = null;
Random random = new Random();
public CreateImageCode() {
creatImage();
}
public CreateImageCode(int width, int height) {
this.width = width;
this.height = height;
creatImage();
}
public CreateImageCode(int width, int height, int codeCount) {
this.width = width;
this.height = height;
this.codeCount = codeCount;
creatImage();
}
public CreateImageCode(int width, int height, int codeCount, int lineCount) {
this.width = width;
this.height = height;
this.codeCount = codeCount;
this.lineCount = lineCount;
creatImage();
}
// 生成图片
private void creatImage() {
int fontWidth = width / codeCount;// 字体的宽度
int fontHeight = height - 5;// 字体的高度
int codeY = height - 8;
// 图像buffer
buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = buffImg.getGraphics();
//Graphics2D g = buffImg.createGraphics();
// 设置背景色
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
// 设置字体
//Font font1 = getFont(fontHeight);
Font font = new Font("Fixedsys", Font.BOLD, fontHeight);
g.setFont(font);
// 设置干扰线
for (int i = 0; i < lineCount; i++) {
int xs = random.nextInt(width);
int ys = random.nextInt(height);
int xe = xs + random.nextInt(width);
int ye = ys + random.nextInt(height);
g.setColor(getRandColor(1, 255));
g.drawLine(xs, ys, xe, ye);
}
// 添加噪点
float yawpRate = 0.01f;// 噪声率
int area = (int) (yawpRate * width * height);
for (int i = 0; i < area; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
buffImg.setRGB(x, y, random.nextInt(255));
}
String str1 = randomStr(codeCount);// 得到随机字符
this.code = str1;
for (int i = 0; i < codeCount; i++) {
String strRand = str1.substring(i, i + 1);
g.setColor(getRandColor(1, 255));
// g.drawString(a,x,y);
// a为要画出来的东西,x和y表示要画的东西最左侧字符的基线位于此图形上下文坐标系的 (x, y) 位置处
g.drawString(strRand, i * fontWidth + 3, codeY);
}
}
// 得到随机字符
private String randomStr(int n) {
String str1 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
String str2 = "";
int len = str1.length() - 1;
double r;
for (int i = 0; i < n; i++) {
r = (Math.random()) * len;
str2 = str2 + str1.charAt((int) r);
}
return str2;
}
// 得到随机颜色
private Color getRandColor(int fc, int bc) {// 给定范围获得随机颜色
if (fc > 255) fc = 255;
if (bc > 255) bc = 255;
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
/**
* 产生随机字体
*/
private Font getFont(int size) {
Random random = new Random();
Font font[] = new Font[5];
font[0] = new Font("Ravie", Font.PLAIN, size);
font[1] = new Font("Antique Olive Compact", Font.PLAIN, size);
font[2] = new Font("Fixedsys", Font.PLAIN, size);
font[3] = new Font("Wide Latin", Font.PLAIN, size);
font[4] = new Font("Gill Sans Ultra Bold", Font.PLAIN, size);
return font[random.nextInt(5)];
}
// 扭曲方法
private void shear(Graphics g, int w1, int h1, Color color) {
shearX(g, w1, h1, color);
shearY(g, w1, h1, color);
}
private void shearX(Graphics g, int w1, int h1, Color color) {
int period = random.nextInt(2);
boolean borderGap = true;
int frames = 1;
int phase = random.nextInt(2);
for (int i = 0; i < h1; i++) {
double d = (double) (period >> 1) * Math.sin((double) i / (double) period + (6.2831853071795862D * (double) phase) / (double) frames);
g.copyArea(0, i, w1, 1, (int) d, 0);
if (borderGap) {
g.setColor(color);
g.drawLine((int) d, i, 0, i);
g.drawLine((int) d + w1, i, w1, i);
}
}
}
private void shearY(Graphics g, int w1, int h1, Color color) {
int period = random.nextInt(40) + 10; // 50;
boolean borderGap = true;
int frames = 20;
int phase = 7;
for (int i = 0; i < w1; i++) {
double d = (double) (period >> 1) * Math.sin((double) i / (double) period + (6.2831853071795862D * (double) phase) / (double) frames);
g.copyArea(i, 0, 1, h1, 0, (int) d);
if (borderGap) {
g.setColor(color);
g.drawLine(i, (int) d, i, 0);
g.drawLine(i, (int) d + h1, i, h1);
}
}
}
public void write(OutputStream sos) throws IOException {
ImageIO.write(buffImg, "png", sos);
sos.close();
}
public BufferedImage getBuffImg() {
return buffImg;
}
public String getCode() {
return code.toLowerCase();
}
}
常量类:用不同的sessionKey存储验证码code
package com.easypan.entity.contants;
public class Constants {
public static final String CHECK_CODE_KEY = "check_code_key";
public static final String CHECK_CODE_KEY_EMAIL = "check_code_key_email";
}
2、发送邮箱验证码(注册)
业务分析
通过调用spring的发送邮件Api发送验证码到注册者邮箱
步骤 :
- 校验验证码是否正确
- 发送邮箱验证码的俩中情况
- 找回密码:直接发送验证码
- 注册:校验邮箱是否存在,如果没有再发送邮箱验证码(已经注册的邮箱不能再进行注册)
- 发送邮箱验证码后,保存邮箱验证码信息到数据库
账号注册时,获取邮箱验证码前需要验证码验证获取。
填入正确的验证码,然后点击按钮发送了如下请求
- 携带的参数有3个
- email:email是上面输入的注册邮箱
- checkCode:checkCode就是输入框中输入的验证码
- type:type为0表示注册,1表示找回密码
业务接口
代码实现
controller层
在AccountControlle类中添加
/**
* @Description: 发送邮箱验证码
* @auther: laoluo
* @date: 20:39 2023/4/1
* @param: [session, email, checkCode, type]
* @return: com.easypan.entity.vo.ResponseVO
*/
@RequestMapping("/sendEmailCode")
public ResponseVO sendEmailCode(HttpSession session, String email,String checkCode,Integer type) {
try {
if (!checkCode.equalsIgnoreCase((String) session.getAttribute(Constants.CHECK_CODE_KEY_EMAIL))) {
throw new BusinessException("图片验证码不正确");
}
emailCodeService.sendEmailCode(email, type);
return getSuccessResponseVO(null);//基础Controller中的方法
} finally {
session.removeAttribute(Constants.CHECK_CODE_KEY_EMAIL);
}
}
sercice层
sendEmailCode:就是发送邮箱验证码最核心的方法
@Resource
private JavaMailSender javaMailSender;
@Resource
private AppConfig appConfig;
@Resource
private RedisComponent redisComponent;
@Override
@Transactional(rollbackFor = Exception.class)
public void sendEmailCode(String email, Integer type) {
//如果是注册,校验邮箱是否已存在
if (type == Constants.ZERO) {
UserInfo userInfo = (UserInfo) userInfoMapper.selectByEmail(email);
if (null != userInfo) {
throw new BusinessException("邮箱已经存在");
}
}
String code = StringTools.getRandomNumber(Constants.LENGTH_5);//String工具类
//TODO 发送邮件
sendEmailCode(email, code);
emailCodeMapper.disableEmailCode(email);
//将验证码保存到数据库
EmailCode emailCode = new EmailCode();
emailCode.setCode(code);
emailCode.setEmail(email);
emailCode.setStatus(Constants.ZERO);
emailCode.setCreateTime(new Date());
emailCodeMapper.insert(emailCode);
}
/**
*
* @param toEmail 发送到的邮箱
* @param code 邮件内容(验证码)
*/
private void sendEmailCode(String toEmail, String code) {
try {
MimeMessage message = javaMailSender.createMimeMessage();//javaM...上面注入就行
MimeMessageHelper helper = new MimeMessageHelper(message, true);
//邮件发件人
helper.setFrom(appConfig.getSendUserName());//拿到发送人邮箱: 服务端邮箱@qq.com
//邮件收件人 1或多个
helper.setTo(toEmail);//发送到收件人邮箱
SysSettingsDto sysSettingsDto = redisComponent.getSysSettingsDto();
//邮件主题
helper.setSubject(sysSettingsDto.getRegisterEmailTitle());//标题:邮箱验证码
//邮件内容
helper.setText(String.format(sysSettingsDto.getRegisterEmailContent(), code));//内容:你好,您的邮箱验证码是:%code,15分钟有效
//邮件发送时间
helper.setSentDate(new Date());
javaMailSender.send(message);
} catch (Exception e) {
logger.error("邮件发送失败", e);
throw new BusinessException("邮件发送失败");
}
}
mapper层
public interface EmailCodeMapper<T,P> extends BaseMapper<T,P> {
void disableEmailCode(String email);
}
<!--void disableEmailCode(String toEmail);-->
<update id="disableEmailCode">
update email_code set status=1 where email=#{email}
</update>
实现参数校验功能
了解aop
定义 标记切入点的注解
package com.easypan.annotation;
import org.springframework.web.bind.annotation.Mapping;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface GlobalInterceptor {
/**
* 校验登录
*
* @return
*/
boolean checkLogin() default true;
/**
* 校验参数
*
* @return
*/
boolean checkParams() default false;
/**
* 校验管理员
* @return
*/
boolean checkAdmin() default false;
}
定义 切面类
@Component("globalOperationAspect")
@Aspect
public class GlobalOperationAspect {
@Pointcut("@annotation(com.easypan.annotation.GlobalInterceptor)")
private void requestInterceptor(){
}
@Before("requestInterceptor()")
public void interceptorDo(JoinPoint point) throws BusinessException {
System.out.println("前置通知:进入切面");
Object target = point.getTarget();
}
}
在AccountController 类中的 sendEmailCode()方法上标记@GlobalInterceptor注解
/**
* @Description: 发送邮箱验证码
* @auther: laoluo
* @date: 20:39 2023/4/1
* @param: [session, email, checkCode, type]
* @return: com.easypan.entity.vo.ResponseVO
*/
@RequestMapping("/sendEmailCode")
@GlobalInterceptor
public ResponseVO sendEmailCode(HttpSession session, String email,String checkCode,Integer type) {
try {
if (!checkCode.equalsIgnoreCase((String) session.getAttribute(Constants.CHECK_CODE_KEY_EMAIL))) {
throw new BusinessException("图片验证码不正确");
}
emailCodeService.sendEmailCode(email, type);
return getSuccessResponseVO(null);
} finally {
session.removeAttribute(Constants.CHECK_CODE_KEY_EMAIL);
}
}
测试结果
标记断点调试,发送请求 (j记得提前标记断点)
发现程序进入了 切面,测试成功
利用aop实现参数校验
将切面类修改成以下代码
package com.easypan.aspect;
@Component("globalOperationAspect")
@Aspect
public class GlobalOperationAspect {
private static Logger logger = LoggerFactory.getLogger(GlobalOperationAspect.class);
private static final String TYPE_STRING = "java.lang.String";
private static final String TYPE_INTEGER = "java.lang.Integer";
private static final String TYPE_LONG = "java.lang.Long";
@Resource
private AppConfig appConfig;
@Resource
private UserInfoService userInfoService;
@Pointcut("@annotation(com.easypan.annotation.GlobalInterceptor)")
private void requestInterceptor(){
}
@Before("requestInterceptor()")
public void interceptorDo(JoinPoint point) throws BusinessException {
try {
Object target = point.getTarget();
Object[] arguments = point.getArgs();
String methodName = point.getSignature().getName();
Class<?>[] parameterTypes = ((MethodSignature) point.getSignature()).getMethod().getParameterTypes();
Method method = target.getClass().getMethod(methodName, parameterTypes);
GlobalInterceptor interceptor = method.getAnnotation(GlobalInterceptor.class);
if (null == interceptor) {
return;
}
/**
* 校验登录
*/
if (interceptor.checkLogin() || interceptor.checkAdmin()) {
checkLogin(interceptor.checkAdmin());
}
/**
* 校验参数
*/
if (interceptor.checkParams()) {
validateParams(method, arguments);
}
} catch (BusinessException e) {
logger.error("全局拦截器异常", e);
throw e;
} catch (Exception e) {
logger.error("全局拦截器异常", e);
throw new BusinessException(ResponseCodeEnum.CODE_500);
} catch (Throwable e) {
logger.error("全局拦截器异常", e);
throw new BusinessException(ResponseCodeEnum.CODE_500);
}
}
private void validateParams(Method m, Object[] arguments) throws BusinessException {
Parameter[] parameters = m.getParameters();
for (int i = 0; i < parameters.length; i++) {
Parameter parameter = parameters[i];
Object value = arguments[i];
VerifyParam verifyParam = parameter.getAnnotation(VerifyParam.class);
if (verifyParam == null) {
continue;
}
//基本数据类型
if (TYPE_STRING.equals(parameter.getParameterizedType().getTypeName()) || TYPE_LONG.equals(parameter.getParameterizedType().getTypeName()) || TYPE_INTEGER.equals(parameter.getParameterizedType().getTypeName())) {
checkValue(value, verifyParam);
//如果传递的是对象
} else {
checkObjValue(parameter, value);
}
}
}
/**
* 校验参数
*
* @param value
* @param verifyParam
* @throws BusinessException
*/
private void checkValue(Object value, VerifyParam verifyParam) throws BusinessException {
Boolean isEmpty = value == null || StringTools.isEmpty(value.toString());
Integer length = value == null ? 0 : value.toString().length();
/**
* 校验空
*/
if (isEmpty && verifyParam.required()) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
/**
* 校验长度
*/
if (!isEmpty && (verifyParam.max() != -1 && verifyParam.max() < length || verifyParam.min() != -1 && verifyParam.min() > length)) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
/**
* 校验正则-VerifyUtils类在其他代码博客文章中
*/
if (!isEmpty && !StringTools.isEmpty(verifyParam.regex().getRegex()) && !VerifyUtils.verify(verifyParam.regex(), String.valueOf(value))) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
}
private void checkObjValue(Parameter parameter, Object value) {
try {
String typeName = parameter.getParameterizedType().getTypeName();
Class classz = Class.forName(typeName);
Field[] fields = classz.getDeclaredFields();
for (Field field : fields) {
VerifyParam fieldVerifyParam = field.getAnnotation(VerifyParam.class);
if (fieldVerifyParam == null) {
continue;
}
field.setAccessible(true);
Object resultValue = field.get(value);
checkValue(resultValue, fieldVerifyParam);
}
} catch (BusinessException e) {
logger.error("校验参数失败", e);
throw e;
} catch (Exception e) {
logger.error("校验参数失败", e);
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
}
private void checkLogin(Boolean checkAdmin) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
HttpSession session = request.getSession();
SessionWebUserDto sessionUser = (SessionWebUserDto) session.getAttribute(Constants.SESSION_KEY);
if (sessionUser == null && appConfig.getDev() != null && appConfig.getDev()) {
List<UserInfo> userInfoList = userInfoService.findListByParam(new UserInfoQuery());
if (!userInfoList.isEmpty()) {
UserInfo userInfo = userInfoList.get(0);
sessionUser = new SessionWebUserDto();
sessionUser.setUserId(userInfo.getUserId());
sessionUser.setNickName(userInfo.getNickName());
sessionUser.setAdmin(true);
session.setAttribute(Constants.SESSION_KEY, sessionUser);
}
}
if (null == sessionUser) {
throw new BusinessException(ResponseCodeEnum.CODE_901);
}
if (checkAdmin && !sessionUser.getAdmin()) {
throw new BusinessException(ResponseCodeEnum.CODE_404);
}
}
}
定义校验参数注解类
package com.easypan.annotation;
import com.easypan.entity.enums.VerifyRegexEnum;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER, ElementType.FIELD})
public @interface VerifyParam {
/**
* 校验正则
*
* @return
*/
VerifyRegexEnum regex() default VerifyRegexEnum.NO;
/**
* 最小长度
*
* @return
*/
int min() default -1;
/**
* 最大长度
*
* @return
*/
int max() default -1;
boolean required() default false;
}
测试
在浏览器地址栏直接发送请求,不携带任何参数
这里需要给给注解设置 checkParams的值为true(上面忘记说了),设置后上面的请求首先会被切面拦截
最后,查看切面中断点处抛出异常,后面被捕获,打印异常日志请求就结束了;并没有进入处理器方法
浏览器返回数据
3、注册
业务分析
将所填写信息插入用户表,这里的参数前端页面会进行校验格式是否正确,但是总有小人会不走界面,所以后端为保证安全性必须再一次进行验证,这里就可以使用利用aop实现的参数验证功能来解决。
步骤 :
- 校验图片验证码
- 校验邮箱是否存在
- 校验昵称 是否存在(昵称也是唯一的)
- 校验邮箱验证(校验后改变邮箱验证码状态)
- 检查验证码是否匹配(数据库 和 所填写的邮箱验证码是否相同)
- 邮箱验证码是否过期(有时效性)
- 完成注册功能(新增用户)
-
SysSettingsDto(系统设置)设置系统给用户默认分配的初始空间大小5M,和 已使用空间0M
- 插入数据库
-
业务接口
代码实现
controller层
在AccountController类中定义如下方法
/**
* @Description: 注册
* @auther: laoluo
* @date: 20:39 2023/4/1
* @param: [session, email, nickName, password, checkCode, emailCode]
* @return: com.easypan.entity.vo.ResponseVO
*/
@RequestMapping("/register")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO register(HttpSession session,
@VerifyParam(required = true, regex = VerifyRegexEnum.EMAIL, max = 150) String email,
@VerifyParam(required = true, max = 20) String nickName,
@VerifyParam(required = true, regex = VerifyRegexEnum.PASSWORD, min = 8, max = 18) String password,
@VerifyParam(required = true) String checkCode,
@VerifyParam(required = true) String emailCode) {
try {
if (!checkCode.equalsIgnoreCase((String) session.getAttribute(Constants.CHECK_CODE_KEY))) {
throw new BusinessException("图片验证码不正确");
}
userInfoService.register(email, nickName, password, emailCode);
return getSuccessResponseVO(null);
} finally {
session.removeAttribute(Constants.CHECK_CODE_KEY);
}
}
service层
因为在校验验证码成功后会 更改验证码的状态为已使用,如果插入失败则会导致验证码就不可以使用了,所以需要添加事务管理
@Override
@Transactional(rollbackFor = Exception.class)
public void register(String email, String nickName, String password, String emailCode) {
UserInfo userInfo = this.userInfoMapper.selectByEmail(email);
if (null != userInfo) {
throw new BusinessException("邮箱账号已经存在");
}
UserInfo nickNameUser = this.userInfoMapper.selectByNickName(nickName);
if (null != nickNameUser) {
throw new BusinessException("昵称已经存在");
}
//校验邮箱验证码
emailCodeService.checkCode(email, emailCode);
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());
SysSettingsDto sysSettingsDto = redisComponent.getSysSettingsDto();
userInfo.setTotalSpace(sysSettingsDto.getUserInitUseSpace() * Constants.MB);//总空间
// userInfo.setUseSpace(0L);
this.userInfoMapper.insert(userInfo);
}
校验邮箱验证码的方法
@Override
public void checkCode(String email, String code) {
EmailCode emailCode = emailCodeMapper.selectByEmailAndCode(email, code);
if (null == emailCode) {
throw new BusinessException("邮箱验证码不正确");
}
if (emailCode.getStatus() == 1 || System.currentTimeMillis() - emailCode.getCreateTime().getTime() > Constants.LENGTH_15 * 1000 * 60) {
throw new BusinessException("邮箱验证码已失效");
}
emailCodeMapper.disableEmailCode(email);//设置验证码状态为已使用
}
测试
填写表单数据,点击注册按钮,测试结果如下:
4、账号登录
业务分析
校验账号密码是否正确,正确的话把用户信息存到 session中
步骤 :
- 校验图片验证码
- 查询账号是否存在 && 校验密码是否正确
- 查询账号状态是否禁用
- 判断是否是 超级管理员
- 将用户的 使用空间信息 保存到redis
- 使用空间大小
- 总空间大小
- 存储用户登录信息(session)
- 完成登录功能返回 前端需要的数据信息(记录最后登录时间)
点击登录按钮向服务端发送登录请求
业务接口
代码实现
controller层
在AccountController类中定义如下方法
/**
* @Description: 登录
* @auther: laoluo
* @date: 20:39 2023/4/1
* @param: [session, request, email, password, checkCode]
* @return: com.easypan.entity.vo.ResponseVO
*/
@RequestMapping("/login")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO login(HttpSession session, HttpServletRequest request,
@VerifyParam(required = true) String email,
@VerifyParam(required = true) String password,
@VerifyParam(required = true) String checkCode) {
try {
if (!checkCode.equalsIgnoreCase((String) session.getAttribute(Constants.CHECK_CODE_KEY))) {
throw new BusinessException("图片验证码不正确");
}
//SessionWebUserDto在其他业务代码文章中
SessionWebUserDto sessionWebUserDto = userInfoService.login(email, password);
session.setAttribute(Constants.SESSION_KEY, sessionWebUserDto);
return getSuccessResponseVO(sessionWebUserDto);
} finally {
session.removeAttribute(Constants.CHECK_CODE_KEY);
}
}
service层
@Override
public SessionWebUserDto login(String email, String password) {
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());
this.userInfoMapper.updateByUserId(updateInfo, userInfo.getUserId());
SessionWebUserDto sessionWebUserDto = new SessionWebUserDto();
sessionWebUserDto.setNickName(userInfo.getNickName());
sessionWebUserDto.setUserId(userInfo.getUserId());
if (ArrayUtils.contains(appConfig.getAdminEmails().split(","), email)) {
sessionWebUserDto.setAdmin(true);
} else {
sessionWebUserDto.setAdmin(false);
}
//用户空间:UserSpaceDto类在其他业务代码文章中
UserSpaceDto userSpaceDto = new UserSpaceDto();
userSpaceDto.setUseSpace(fileInfoService.getUserUseSpace(userInfo.getUserId()));
userSpaceDto.setTotalSpace(userInfo.getTotalSpace());
redisComponent.saveUserSpaceUse(userInfo.getUserId(), userSpaceDto);
return sessionWebUserDto;
}
在 FileInfoServiceImpl 类中添加以下方法
@Override
public Long getUserUseSpace(String userId) {
return this.fileInfoMapper.selectUseSpace(userId);
}
在redisComponent类中添加一下方法
/**
* 保存已使用的空间
*
* @param userId
*/
public void saveUserSpaceUse(String userId, UserSpaceDto userSpaceDto) {
redisUtils.setex(Constants.REDIS_KEY_USER_SPACE_USE + userId, userSpaceDto, Constants.REDIS_KEY_EXPIRES_DAY);
}
mapper层
Long selectUseSpace(@Param("userId") String userId);
<select id="selectUseSpace" resultType="java.lang.Long">
select IFNULL(sum(file_size), 0)
from file_info
where user_id = #{userId} and del_flag !=-1
</select>
测试
填写表单信息后,点击登录
登录成功后,会进入到主页面
5、重置密码(忘记密码)
业务分析
点击重置密码按钮,修改用户的密码
步骤 :
- 校验图片验证码
- 校验邮箱是否存在
- 校验邮箱验证码(校验后改变邮箱验证码状态)
- 是否匹配
- 是否过期
- 更改用户密码
查看请求
业务接口
代码实现
在AccountController类中添加如下方法
@RequestMapping("/resetPwd")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO resetPwd(HttpSession session,
@VerifyParam(required = true, regex = VerifyRegexEnum.EMAIL, max = 150) String email,
@VerifyParam(required = true, regex = VerifyRegexEnum.PASSWORD, min = 8, max = 18) String password,
@VerifyParam(required = true) String checkCode,
@VerifyParam(required = true) String emailCode) {
try {
if (!checkCode.equalsIgnoreCase((String) session.getAttribute(Constants.CHECK_CODE_KEY))) {
throw new BusinessException("图片验证码不正确");
}
userInfoService.resetPwd(email, password, emailCode);
return getSuccessResponseVO(null);
} finally {
session.removeAttribute(Constants.CHECK_CODE_KEY);
}
}
在 UserInfoServiceImpl类中 添加如下方法
@Override
@Transactional(rollbackFor = Exception.class)
public void resetPwd(String email, String password, String emailCode) {
UserInfo userInfo = this.userInfoMapper.selectByEmail(email);
if (null == userInfo) {
throw new BusinessException("邮箱账号不存在");
}
//校验邮箱验证码
emailCodeService.checkCode(email, emailCode);
UserInfo updateInfo = new UserInfo();
updateInfo.setPassword(StringTools.encodeByMD5(password));
this.userInfoMapper.updateByEmail(updateInfo, email);
}
在EmailCodeServiceImpl类中添加 如下方法:
@Override
public void checkCode(String email, String code) {
EmailCode emailCode = emailCodeMapper.selectByEmailAndCode(email, code);
if (null == emailCode) {
throw new BusinessException("邮箱验证码不正确");
}
if (emailCode.getStatus() == 1 || System.currentTimeMillis() - emailCode.getCreateTime().getTime() > Constants.LENGTH_15 * 1000 * 60) {
throw new BusinessException("邮箱验证码已失效");
}
emailCodeMapper.disableEmailCode(email);//设置验证码状态为已使用
}
测试
填写完表单数据后,点击重置密码按钮
查看请求
空色框是之前的密码,绿色框是更改后的密码,重置密码成功
6、获取用户头像
业务分析
注册时用户没有上传自己的头像信息的,所以登录成功后会请求显示默认头像,如果用户修改头像以后就会显示自己更改的头像
步骤 :
- 是否存在 目录+userId + 后缀 的文件信息
- 存在:响应用户自定义的头像
- 不存在:响应默认头像
当用户登录成功后,客户端就会向服务端发送获取头像的请求
业务接口
代码实现
controller层
在AccountController类中加入以下代码,因为这里使用的常量太多,在笔记中笔记不好体现代码的意思,所以笔者将这里的常量全部改成了字符串方便阅读
private static final Logger logger = LoggerFactory.getLogger(AccountController.class);
private static final String CONTENT_TYPE = "Content-Type";
private static final String CONTENT_TYPE_VALUE = "application/json;charset=UTF-8";
@RequestMapping("/getAvatar/{userId}")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public void getAvatar(HttpServletResponse response, @VerifyParam(required = true) @PathVariable("userId") String userId) {
String avatarFolderName = "/file" + "/avatar/";
File folder = new File("e:/webser/web_app/easypan/" + avatarFolderName);
if (!folder.exists()) {
folder.mkdirs();
}
String avatarPath = "e:/webser/web_app/easypan/" + avatarFolderName + userId + ".jpg";
File file = new File(avatarPath);
if (!file.exists()) {
if (!new File("e:/webser/web_app/easypan/" + avatarFolderName + "default_avatar.jpg").exists()) {
printNoDefaultImage(response);
return;
}
avatarPath = "e:/webser/web_app/easypan/" + avatarFolderName + "default_avatar.jpg";
}
response.setContentType("image/jpg");
readFile(response, avatarPath);//ABaseController类中的方法,在其他代码文章中有
}
private void printNoDefaultImage(HttpServletResponse response) {
response.setHeader(CONTENT_TYPE, CONTENT_TYPE_VALUE);
response.setStatus(HttpStatus.OK.value());
PrintWriter writer = null;
try {
writer = response.getWriter();
writer.print("请在头像目录下放置默认头像default_avatar.jpg");
writer.close();
} catch (Exception e) {
logger.error("输出无默认图失败", e);
} finally {
writer.close();
}
}
测试
刷新页面,头像显示成功
7、获取用户空间
业务分析
用户总空间大小在注册用户时会指定为系统默认容量大小5M,使用空间大小0M,用户容量大小后期可以通过管理员重新分配,而使用空间大小,在用户上传文件 和 保存他人分享的文件到网盘时,需要重新修改数据库和redis中的数据。
这里直接读取redis中保存的用户信息。
步骤 :
- 向redis里面取用户空间数据,返回给前端
业务接口
在session中取出userId,然后直接在redis中读取用户使用空间
代码实现
controller层
AccountController中添加以下代码
@RequestMapping("/getUseSpace")
@GlobalInterceptor
public ResponseVO getUseSpace(HttpSession session) {
SessionWebUserDto sessionWebUserDto = getUserInfoFromSession(session);
//getUserSpaceUse方法在其他代码笔记中有
return getSuccessResponseVO(redisComponent.getUserSpaceUse(sessionWebUserDto.getUserId()));
}
测试
测试连接:http://easypan.wuhancoder.com/api/getUseSpace
8、退出登录
业务分析
清除session域中的数据(invalidate方法),清除session数据后前端会跳转到登录页面,用户想要在进入主页面时,就不能获取到sesion中的用户信息,所以前端不会让请求跳转成功。
业务接口
代码实现
在AccountController中添加如下代码
@RequestMapping("/logout")
public ResponseVO logout(HttpSession session) {
session.invalidate();
return getSuccessResponseVO(null);
}
9、更新用户头像
业务分析
这里更新用户头像是通过上传一个和原始头像名称一样的图片到服务端覆盖之前的头像来完成更新头像操作,不需要更改数据库
步骤 :
- 上传头像 到指定目录(头像名:userID+后缀)
- 读取头像时,就是按userId+fileId+后缀读取头像数据的
业务接口
代码实现
AccountController类中添加以下代码
@RequestMapping("/updateUserAvatar")
@GlobalInterceptor
public ResponseVO updateUserAvatar(HttpSession session, MultipartFile avatar) {
SessionWebUserDto webUserDto = getUserInfoFromSession(session);
String baseFolder = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE;
File targetFileFolder = new File(baseFolder + Constants.FILE_FOLDER_AVATAR_NAME);
if (!targetFileFolder.exists()) {
targetFileFolder.mkdirs();
}
File targetFile = new File(targetFileFolder.getPath() + "/" + webUserDto.getUserId() + Constants.AVATAR_SUFFIX);
try {
avatar.transferTo(targetFile);
} catch (Exception e) {
logger.error("上传头像失败", e);
}
UserInfo userInfo = new UserInfo();
userInfo.setQqAvatar("");
userInfoService.updateUserInfoByUserId(userInfo, webUserDto.getUserId());
webUserDto.setAvatar(null);
session.setAttribute(Constants.SESSION_KEY, webUserDto);
return getSuccessResponseVO(null);
}
10、修改密码
业务分析
对数据库用户的密码信息进行修改
业务接口
代码实现
AccountController类中添加以下代码
@RequestMapping("/updatePassword")
@GlobalInterceptor(checkParams = true)
public ResponseVO updatePassword(HttpSession session,
@VerifyParam(required = true, regex = VerifyRegexEnum.PASSWORD, min = 8, max = 18) String password) {
SessionWebUserDto sessionWebUserDto = getUserInfoFromSession(session);
UserInfo userInfo = new UserInfo();
userInfo.setPassword(StringTools.encodeByMD5(password));
userInfoService.updateUserInfoByUserId(userInfo, sessionWebUserDto.getUserId());
return getSuccessResponseVO(null);
}
11、QQ登录
业务分析
首页模块业务代码开发
1、文件列表
业务分析
查询首页的文件列表数据,主要是通过文件filePid向数据库查询处于这个父目录下的子文件和子目录信息。这里还需要处理分类查询的情况和名称搜索的需求
登录成功后,客户端向服务的发送查询文件列表的请求
具体分析如何实现 :
- (1)、通过文件父Id(也就是文件的目录id)查询出该目录下的所有文件和文件夹,并把查询到的文件列表返回
- (2)、根目录没有id怎么查询呢?
- 系统文件上传时规定在根目录上传的文件 父id 设置为0
- (3)、分类查询怎么实现?
- 类别:全部、视频、音频、图片、文档、其他
- 后端通过定义枚举类来判断设置类别
- (4)、最后使用分页查询,返回分页数据——这里是Service层代码
-
@Override public PaginationResultVO<FileInfo> findListByPage(FileInfoQuery param) { int count = this.findCountByParam(param); int pageSize = param.getPageSize() == null ? PageSize.SIZE15.getSize() : param.getPageSize(); SimplePage page = new SimplePage(param.getPageNo(), count, pageSize); param.setSimplePage(page); List<FileInfo> list = this.findListByParam(param); PaginationResultVO<FileInfo> result = new PaginationResultVO(count, page.getPageSize(), page.getPageNo(), page.getPageTotal(), list); return result; }
-
业务接口
代码实现
在 FileInfoController 中添加 查询文件列表的方法 loadDataList()
@RestController("fileInfoController")
@RequestMapping("/file")
public class FileInfoController extends CommonFileController {
/**
* 根据条件分页查询
*/
@RequestMapping("/loadDataList")
@GlobalInterceptor(checkParams = true)
public ResponseVO loadDataList(HttpSession session, FileInfoQuery query, String category) {
FileCategoryEnums categoryEnum = FileCategoryEnums.getByCode(category);
if (null != categoryEnum) {
query.setFileCategory(categoryEnum.getCategory());
}
query.setUserId(getUserInfoFromSession(session).getUserId());
query.setOrderBy("last_update_time desc");
query.setDelFlag(FileDelFlagEnums.USING.getFlag());
PaginationResultVO result = fileInfoService.findListByPage(query);
return getSuccessResponseVO(convert2PaginationVO(result, FileInfoVO.class));
}
}
2、文件分片上传
业务分析
步骤 :
- 判断秒传(只有第一片分片需要判断)
- 通过上传文件的MD5值,判断服务端是否有该文件
- 有:秒传
- 没有:分片上传
- 通过上传文件的MD5值,判断服务端是否有该文件
- 分片上传
- 不是最后一个分片,设置状态位上传中,继续上传
- 最后一个分片:合并分片 并且 上传文件
具体分析如何实现:
- 想要弄明白这里就要清楚前端的上传逻辑
- 计算文件的MD5值
- md5:文件的 MD5 值是通过对文件内容进行特定的哈希运算得到的一个 128 位(16 字节)的二进制数值,通过这个可以实现文件秒传功能,俩个文件的md5值相同代表这俩个文件是一样的。
- 核心实现:
-
const computeMD5 = (fileItem) => { let file = fileItem.file; let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; let chunks = Math.ceil(file.size / chunkSize); let currentChunk = 0; let spark = new SparkMD5.ArrayBuffer(); let fileReader = new FileReader(); let time = new Date().getTime(); //file.cmd5 = true; /* loadNext() 是一个函数,用于分片读取文件内容并触发下一次读取操作。 它的执行依赖于 FileReader 的异步事件驱动机制。 当前分片读取完成后,fileReader.onload 会被触发 */ let loadNext = () => { let start = currentChunk * chunkSize; let end = start + chunkSize >= file.size ? file.size : start + chunkSize; /* 使用 blobSlice.call(file, start, end) 提取当前分片的内容。 调用 fileReader.readAsArrayBuffer() 将分片内容读取为 ArrayBuffer 格式。 */ fileReader.readAsArrayBuffer(blobSlice.call(file, start, end)); }; loadNext(); return new Promise((resolve, reject) => { let resultFile = getFileByUid(file.uid); /* 在 onload 回调中,代码会检查是否还有未处理的分片(currentChunk < chunks)。 如果还有未处理的分片,则调用 loadNext() 继续读取下一个分片。 */ fileReader.onload = (e) => { spark.append(e.target.result); // Append array buffer currentChunk++; if (currentChunk < chunks) { let percent = Math.floor((currentChunk / chunks) * 100); resultFile.md5Progress = percent; loadNext(); } else { let md5 = spark.end(); spark.destroy(); //释放缓存 resultFile.md5Progress = 100; resultFile.status = STATUS.uploading.value; resultFile.md5 = md5; resolve(fileItem.uid); } }; fileReader.onerror = () => { resultFile.md5Progress = -1; resultFile.status = STATUS.fail.value; resolve(fileItem.uid); }; }).catch((error) => { return null; }); };
-
- 分片上传
-
const uploadFile = async (uid, chunkIndex) => { chunkIndex = chunkIndex ? chunkIndex : 0; //分片上传 let currentFile = getFileByUid(uid); const file = currentFile.file; const fileSize = currentFile.totalSize; const chunks = Math.ceil(fileSize / chunkSize); for (let i = chunkIndex; i < chunks; i++) { let delIndex = delList.value.indexOf(uid); if (delIndex != -1) { delList.value.splice(delIndex, 1); // console.log(delList.value); break; } currentFile = getFileByUid(uid); if (currentFile.pause) { break; } let start = i * chunkSize; let end = start + chunkSize >= fileSize ? fileSize : start + chunkSize; //核心方法 let chunkFile = file.slice(start, end);//核心方法 let uploadResult = await proxy.Request({ url: api.upload, showLoading: false, dataType: "file", params: { file: chunkFile, fileName: file.name, fileMd5: currentFile.md5, chunkIndex: i, chunks: chunks, fileId: currentFile.fileId, filePid: currentFile.filePid, }, showError: false, errorCallback: (errorMsg) => { currentFile.status = STATUS.fail.value; currentFile.errorMsg = errorMsg; }, uploadProgressCallback: (event) => { let loaded = event.loaded; if (loaded > fileSize) { loaded = fileSize; } currentFile.uploadSize = i * chunkSize + loaded; currentFile.uploadProgress = Math.floor( (currentFile.uploadSize / fileSize) * 100 ); }, }); if (uploadResult == null) { break; } currentFile.fileId = uploadResult.data.fileId; currentFile.status = STATUS[uploadResult.data.status].value; currentFile.chunkIndex = i; if ( uploadResult.data.status == STATUS.upload_seconds.value || uploadResult.data.status == STATUS.upload_finish.value ) { currentFile.uploadProgress = 100; emit("uploadCallback"); break; } } };
-
- 计算文件的MD5值
前端上传请求:分片逻辑主要是因为 file.slice(start, end);的逻辑将整个文件分割成了多个分片,然后发送多个小分片文件上传请求,所以前端只会上传一个个小分片,并不会直接将整个文件一次上传(除非上传的文件很小)
- (1)上传文件
- 判断是否秒传:秒传就是通过前端md5参数值到数据库中查询有没有和此md5值相同的文件,如果有,说明服务端已经有这个文件了,所以直接向数据库添加文件信息。
- 分片上传
- 每个小分片上传前,判断用户空间是否充足
- 空间足够就上传到分片的临时目录
- 不够抛异常
- 所有小分片上传到临时目录后,将文件信息插入到数据库表中,设置此时文件状态为“转码中”,更新用户空间(原来的占用空间+此次所有小分片的大小)
- 文件上传状态一共三种:0:转码中 1转码失败 2:转码成功
- 所有分片大小计算是通过redis保存每次分片的累加大小
- 项目难点-文件合并 视频切割
-
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { //这里必须将fileInfoService注入到事务中,否则事务提交后,fileInfoService为空,无法调用方法 // 这样还有循环依赖的问题(在fileInfoService类注入fileInfoService属性),所以还需要再属性上加@lazy fileInfoService.transferFile(fileInfo.getFileId(), webUserDto); } });
- 文件合并(所有文件都需要),通过下面这个方法实现,具体看代码;最后结果就是将临时目录中的所有分片文件合并成一个文件,文件名就是传入的fileName的值,将合并文件存放在目标目录
-
- 每个小分片上传前,判断用户空间是否充足
/**
*
* @param dirPath 分片文件存放的临时目录
* @param toFilePath 合并文件存放的 最后目录
* @param fileName 文件名
* @param delSource true
* @throws BusinessException
*/
public static void union(String dirPath, String toFilePath, String fileName, boolean delSource) throws BusinessException {
File dir = new File(dirPath);
if (!dir.exists()) {
throw new BusinessException("目录不存在");
}
File fileList[] = dir.listFiles();
File targetFile = new File(toFilePath);
RandomAccessFile writeFile = null;
try {
writeFile = new RandomAccessFile(targetFile, "rw");
byte[] b = new byte[1024 * 10];
for (int i = 0; i < fileList.length; i++) {
int len = -1;
//创建读块文件的对象
File chunkFile = new File(dirPath + File.separator + i);
RandomAccessFile readFile = null;
try {
readFile = new RandomAccessFile(chunkFile, "r");
while ((len = readFile.read(b)) != -1) {
writeFile.write(b, 0, len);
}
} catch (Exception e) {
logger.error("合并分片失败", e);
throw new BusinessException("合并文件失败");
} finally {
readFile.close();
}
}
} catch (Exception e) {
logger.error("合并文件:{}失败", fileName, e);
throw new BusinessException("合并文件" + fileName + "出错了");
} finally {
try {
if (null != writeFile) {
writeFile.close();
}
} catch (IOException e) {
logger.error("关闭流失败", e);
}
if (delSource) {
if (dir.exists()) {
try {
FileUtils.deleteDirectory(dir);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
-
- 视频切割——项目最难点(视频文件需要)
- 在视频文件目录下创建一个文件目录(tsFolder目录为:去掉文件后缀的文件名)
- 执行下面这条命令获取视频编码信息,s为视频文件路径
-
ffprobe -v error -select_streams v:0 -show_entries stream=codec_name %s
-
对返回的信息进行截取,最后截取到想要的编码信息
-
如果视频编码是“hevc”就进行转码为“h264”
-
编码格式为libx264,提高兼容性和播放性能。如果检测到视频文件使用的是hevc编码,则会通过FFmpeg工具将其转换为h264编码
-
- 将视频文件转化成为.ts格式,会在tsFolder目录下生成一个index.ts文件,并保留原始编码
-
"ffmpeg -y -i %s -vcodec copy -acodec copy -vbsf h264_mp4toannexb %s"
- first%s:视频文件路径
- second%s:生成ts文件(index.ts)的路径
-
- 执行以下命令将index.ts文件分割成一个个小片段文件,并生成一个.m3u8索引文件,最终生成的片段文件和索引文件将位于tsFolder目录下
-
ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 30 %s/%s_%%4d.ts
- 其中参数就不讲解了后面直接贴代码,有详细注释
-
- 最后删除index.ts文件,因为已经生成了多个片段文件这个文件删除避免浪费空间
- 视频切割——项目最难点(视频文件需要)
- 最后下面是视频切割的详细代码
/**
*
* @param fileId
* @param videoFilePath 合并文件后的路径(路径目标就是这个视频文件)
*/
private void cutFile4Video(String fileId, String videoFilePath) {
//创建同名切片目录
File tsFolder = new File(videoFilePath.substring(0, videoFilePath.lastIndexOf(".")));//去掉后缀生成目录
if (!tsFolder.exists()) {
tsFolder.mkdirs();//视频文件额外目录: "file/202412 / userid+fileid
}
//CMD_GET_CODE:这条命令是用于获取视频文件的编码信息
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, false);//执行命令,获取视频编码信息,不打印日志
result = result.replace("\n", "");//处理返回的结果字符串,去除换行符
result = result.substring(result.indexOf("=") + 1);
String codec = result.substring(0, result.indexOf("["));//提取编码信息,通过查找等号和方括号的位置来截取编码名称
//转码:hevc——>libx264
//这段代码的主要目的是确保视频文件的编码格式为libx264,以提高兼容性和播放性能。如果检测到视频文件使用的是hevc编码,则会通过FFmpeg工具将其转换为libx264编码
if ("hevc".equals(codec)) {
String newFileName = videoFilePath.substring(0, videoFilePath.lastIndexOf(".")) + "_" + videoFilePath.substring(videoFilePath.lastIndexOf("."));//文件名_.
new File(videoFilePath).renameTo(new File(newFileName));//重命名视频文件
String CMD_HEVC_264 = "ffmpeg -i %s -c:v libx264 -crf 20 %s";
cmd = String.format(CMD_HEVC_264, newFileName, videoFilePath);//使用 FFmpeg 工具将视频从 HEVC 编码转换为 libx264 编码,并将转码后的文件保存为原始文件名(videoFilePath)
ProcessUtils.executeCommand(cmd, false);//进行转码,不打印日志
new File(newFileName).delete();//删除重命名后的文件(用重命名文件 生成一个 libx264格式文件)
}
/*
.m3u8:
.m3u8 文件是一种基于 M3U 的扩展格式,主要用于定义媒体播放列表。
它是一个文本文件,包含指向多个 .ts 文件的链接以及一些元数据信息
.m3u8 文件使得客户端可以按需加载视频片段,而不是一次性下载整个视频文件。
这提高了视频的加载速度和播放流畅度,特别是在网络不稳定的情况下。
.ts:
.ts 文件是 MPEG-TS(MPEG Transport Stream)格式的视频片段文件。每个 .ts 文件包含一小段视频内容,通常是几秒钟的长度。
配合 .m3u8 文件,可以根据网络状况动态调整视频质量,选择合适码率的片段进行播放
*/
//用于将视频文件转换为 .ts 格式,会生成一个index.ts文件,并保留原始编码:
final String CMD_TRANSFER_2TS = "ffmpeg -y -i %s -vcodec copy -acodec copy -vbsf h264_mp4toannexb %s";
//用于将 .ts 文件进一步分割成多个小片段,并生成一个 .m3u8 索引文件
/*
%s_%%4d.ts 表示生成的每个切片文件的命名格式,其中 %4d 是四位数字的占位符,表示片段编号。
例如,%s_0001.ts、%s_0002.ts 等。
因此,最终生成的切片文件将位于 tsFolder 目录下,文件名格式为 视频文件_0001.ts、视频文件_0002.ts 等。
*/
final String CMD_CUT_TS = "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 30 %s/%s_%%4d.ts";
String tsPath = tsFolder + File.separator + Constants.TS_NAME;//tsPath:"file/202412 / userid+fileid + /index.ts“
//使用 FFmpeg 将视频文件转换为 .ts 格式,保留原始编码
cmd = String.format(CMD_TRANSFER_2TS, videoFilePath, tsPath);//videoFilePath:视频文件的路径("file/202412/视频文件.mp4")
ProcessUtils.executeCommand(cmd, false);
//生成索引文件.m3u8 和切片.ts
cmd = String.format(
CMD_CUT_TS, tsPath,
tsFolder.getPath() + File.separator + Constants.M3U8_NAME,
tsFolder.getPath(),
fileId);
ProcessUtils.executeCommand(cmd, false);
//删除index.ts
new File(tsPath).delete();
}
- 别急学到这里还没结束
- 咱们接着解决上面没有解决的视频和图片缩略图的生成
-
/** *视频缩略图方法 * @param sourceFile 视频文件的路径 * @param width 宽度 * @param targetFile 生成缩略图的路径 */ public static void createCover4Video(File sourceFile, Integer width, File targetFile) { try { String cmd = "ffmpeg -i %s -y -vframes 1 -vf scale=%d:%d/a %s"; //sourceFile(视频文件)生成一张缩略图 图片路径为:targetFile ProcessUtils.executeCommand(String.format(cmd, sourceFile.getAbsoluteFile(), width, width, targetFile.getAbsoluteFile()), false); } catch (Exception e) { logger.error("生成视频封面失败", e); } }
/** *生成图片缩略图 * @param file 图片文件 * @param thumbnailWidth 图片宽度 * @param targetFile 生成缩略图的文件路径 * @param delSource 是否删除源文件 * @return */ public static Boolean createThumbnailWidthFFmpeg(File file, int thumbnailWidth, File targetFile, Boolean delSource) { try { BufferedImage src = ImageIO.read(file); //thumbnailWidth 缩略图的宽度 thumbnailHeight 缩略图的高度 int sorceW = src.getWidth(); int sorceH = src.getHeight(); //小于 指定高宽不压缩 if (sorceW <= thumbnailWidth) { return false; } compressImage(file, thumbnailWidth, targetFile, delSource); return true; } catch (Exception e) { e.printStackTrace(); } return false; } /** *上面方法中调用的compressImage()方法 * @param sourceFile 图片文件的路径 * @param width 缩略图的宽度 * @param targetFile 生成缩略图的路径 * @param delSource 是否删除源文件 */ public static void compressImage(File sourceFile, Integer width, File targetFile, Boolean delSource) { try { String cmd = "ffmpeg -i %s -vf scale=%d:-1 %s -y"; ProcessUtils.executeCommand(String.format(cmd, sourceFile.getAbsoluteFile(), width, targetFile.getAbsoluteFile()), false); if (delSource) { FileUtils.forceDelete(sourceFile); } } catch (Exception e) { logger.error("压缩图片失败"); } }
ok到这里就已经完成文件上传的所有功能了,学累了吧,我也是!!!
-
- 咱们接着解决上面没有解决的视频和图片缩略图的生成
业务接口
fileMd5:通过MD5值来查询服务端是否有该文件
代码实现
controller层
在FileInfoController类中添加以下方法
@RequestMapping("/uploadFile")
@GlobalInterceptor(checkParams = true)
public ResponseVO uploadFile(HttpSession session,
String fileId,
MultipartFile file,
@VerifyParam(required = true) String fileName,
@VerifyParam(required = true) String filePid,
@VerifyParam(required = true) String fileMd5,
@VerifyParam(required = true) Integer chunkIndex,
@VerifyParam(required = true) Integer chunks) {
SessionWebUserDto webUserDto = getUserInfoFromSession(session);
//TODO 文件上传
UploadResultDto resultDto = fileInfoService.uploadFile(webUserDto, fileId, file, fileName, filePid, fileMd5, chunkIndex, chunks);
return getSuccessResponseVO(resultDto);
}
service层
UploadResultDto uploadFile(SessionWebUserDto webUserDto, String fileId, MultipartFile file, String fileName, String filePid, String fileMd5, Integer chunkIndex, Integer chunks);
@Override
@Transactional(rollbackFor = Exception.class)
public UploadResultDto uploadFile(SessionWebUserDto webUserDto, String fileId, MultipartFile file, String fileName, String filePid, String fileMd5,
Integer chunkIndex, Integer chunks) {
File tempFileFolder = null;
Boolean uploadSuccess = true;
try {
UploadResultDto resultDto = new UploadResultDto();
if (StringTools.isEmpty(fileId)) {
fileId = StringTools.getRandomString(Constants.LENGTH_10);
}
resultDto.setFileId(fileId);
Date curDate = new Date();
UserSpaceDto spaceDto = redisComponent.getUserSpaceUse(webUserDto.getUserId());
if (chunkIndex == 0) {
FileInfoQuery infoQuery = new FileInfoQuery();
infoQuery.setFileMd5(fileMd5);
infoQuery.setSimplePage(new SimplePage(0, 1));
infoQuery.setStatus(FileStatusEnums.USING.getStatus());
List<FileInfo> dbFileList = this.fileInfoMapper.selectList(infoQuery);
//秒传
if (!dbFileList.isEmpty()) {
FileInfo dbFile = dbFileList.get(0);
//判断文件状态
if (dbFile.getFileSize() + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {//本次文件大小+已使用的空间大小 > 总空间
throw new BusinessException(ResponseCodeEnum.CODE_904);
}
dbFile.setFileId(fileId);
dbFile.setFilePid(filePid);
dbFile.setUserId(webUserDto.getUserId());
dbFile.setFileMd5(null);
dbFile.setCreateTime(curDate);
dbFile.setLastUpdateTime(curDate);
dbFile.setStatus(FileStatusEnums.USING.getStatus());
dbFile.setDelFlag(FileDelFlagEnums.USING.getFlag());
dbFile.setFileMd5(fileMd5);
//文件重命名
fileName = autoRename(filePid, webUserDto.getUserId(), fileName);
dbFile.setFileName(fileName);
this.fileInfoMapper.insert(dbFile);
resultDto.setStatus(UploadStatusEnums.UPLOAD_SECONDS.getCode());
//更新用户空间使用
updateUserSpace(webUserDto, dbFile.getFileSize());
return resultDto;
}
}
//暂存在临时目录
String tempFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_TEMP;
String currentUserFolderName = webUserDto.getUserId() + fileId;
//创建临时目录
tempFileFolder = new File(tempFolderName + currentUserFolderName);// temp/userId/fileId
if (!tempFileFolder.exists()) {
tempFileFolder.mkdirs();
}
//判断磁盘空间
Long currentTempSize = redisComponent.getFileTempSize(webUserDto.getUserId(), fileId);
if (file.getSize() + currentTempSize + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {
throw new BusinessException(ResponseCodeEnum.CODE_904);
}
File newFile = new File(tempFileFolder.getPath() + "/" + chunkIndex);
file.transferTo(newFile);
//保存临时大小
redisComponent.saveFileTempSize(webUserDto.getUserId(), fileId, file.getSize());
//不是最后一个分片,直接返回
if (chunkIndex < chunks - 1) {
resultDto.setStatus(UploadStatusEnums.UPLOADING.getCode());
return resultDto;
}
//最后一个分片上传完成,记录数据库,异步合并分片
String month = DateUtil.format(curDate, DateTimePatternEnum.YYYYMM.getPattern());
String fileSuffix = StringTools.getFileSuffix(fileName);
//真实文件名
String realFileName = currentUserFolderName + fileSuffix;
FileTypeEnums fileTypeEnum = FileTypeEnums.getFileTypeBySuffix(fileSuffix);
//自动重命名
fileName = autoRename(filePid, webUserDto.getUserId(), fileName);
FileInfo fileInfo = new FileInfo();
fileInfo.setFileId(fileId);
fileInfo.setUserId(webUserDto.getUserId());
fileInfo.setFileMd5(fileMd5);
fileInfo.setFileName(fileName);
fileInfo.setFilePath(month + "/" + realFileName);
fileInfo.setFilePid(filePid);
fileInfo.setCreateTime(curDate);
fileInfo.setLastUpdateTime(curDate);
fileInfo.setFileCategory(fileTypeEnum.getCategory().getCategory());
fileInfo.setFileType(fileTypeEnum.getType());
fileInfo.setStatus(FileStatusEnums.TRANSFER.getStatus());
fileInfo.setFolderType(FileFolderTypeEnums.FILE.getType());
fileInfo.setDelFlag(FileDelFlagEnums.USING.getFlag());
this.fileInfoMapper.insert(fileInfo);
Long totalSize = redisComponent.getFileTempSize(webUserDto.getUserId(), fileId);
updateUserSpace(webUserDto, totalSize);
resultDto.setStatus(UploadStatusEnums.UPLOAD_FINISH.getCode());
//事务提交后调用异步方法
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
fileInfoService.transferFile(fileInfo.getFileId(), webUserDto);
}
});
return resultDto;
} catch (BusinessException e) {
uploadSuccess = false;
logger.error("文件上传失败", e);
throw e;
} catch (Exception e) {
uploadSuccess = false;
logger.error("文件上传失败", e);
throw new BusinessException("文件上传失败");
} finally {
//如果上传失败,清除临时目录
if (tempFileFolder != null && !uploadSuccess) {
try {
FileUtils.deleteDirectory(tempFileFolder);
} catch (IOException e) {
logger.error("删除临时目录失败");
}
}
}
}
3、显示封面
业务分析
服务端直接找到对应的缩略图然后通过数据流响应回来数据。这里没有其他逻辑
- 响应缩略图
业务接口
代码实现
@RequestMapping("/getImage/{imageFolder}/{imageName}")
public void getImage(HttpServletResponse response,
@PathVariable("imageFolder") String imageFolder,
@PathVariable("imageName") String imageName) {
super.getImage(response, imageFolder, imageName);
}
4、获取文件信息
业务分析
当你查看的文件类型为视频文件时,客户端会向服务端发送请求,响应回来一个index.m3u8文件,读取这个文件信息客户端就可以得到此视频文件的总时长,以及在哪个时间读取哪个对应的.ts文件,所以有了这个文件的信息,客户端随着视频播放时间就能知道用户播放视频到什么时间来发生请求,让后端响应对应时间段的.ts文件(所以用户直接拖动进度条到中后部分,也只会加载这个部分的ts文件,响应速度就很快)
根据视频播放时长 请求对应的.ts文件
如果是其他文件类型,则会直接响应文件信息,请求的接口也不一样(这里是前端处理的逻辑)
经过上面的讲解,我们可以了解到我们需要实现俩个请求
- ①获取视频文件信息
- 判断是否带“.ts”后缀(如果不带后缀说明是请求index.m3u8文件),根据查询到的文件信息,获得文件路径然后响应给客户端
- 参数带“.ts”后缀,说明是请求切片文件,需要将fileId从参数中分割出来,然后再进行查询文件信息,通过文件信息得到文件路径返回给客户端
- 这里读取分享视频接口时不需要分割出fileId,他是直接传递的,但是通过这个分割逻辑之后其实也没影响
- ②获取非视频文件信息(也就是获取普通文件信息)
- 根据fileId和userId查询到文件信息
- 根据文件信息得到文件存储路径,直接响应给客户端
业务接口
获取视频文件信息的接口如下
代码实现
@RequestMapping("/getFile/{fileId}")
public void getFile(HttpServletResponse response, HttpSession session, @PathVariable("fileId") @VerifyParam(required = true) String fileId) {
SessionWebUserDto webUserDto = getUserInfoFromSession(session);
super.getFile(response, fileId, webUserDto.getUserId());
}
@RequestMapping("/ts/getVideoInfo/{fileId}")
public void getVideoInfo(HttpServletResponse response, HttpSession session, @PathVariable("fileId") @VerifyParam(required = true) String fileId) {
SessionWebUserDto webUserDto = getUserInfoFromSession(session);
super.getFile(response, fileId, webUserDto.getUserId());
}
获取文件信息的核心内容如下:
protected void getFile(HttpServletResponse response, String fileId, String userId) {
String filePath = null;
if (fileId.endsWith(".ts")) {
String[] tsAarray = fileId.split("_");//这里的fileId是BC3964suEj_0000.ts
String realFileId = tsAarray[0];
//根据原文件的id和userid查询出一个文件信息
FileInfo fileInfo = fileInfoService.getFileInfoByFileIdAndUserId(realFileId, userId);
if (fileInfo == null) {
//如果为空就是分享的视频,ts路径记录的是原视频的id,这里通过id直接取出原视频
FileInfoQuery fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setFileId(realFileId);
List<FileInfo> fileInfoList = fileInfoService.findListByParam(fileInfoQuery);
fileInfo = fileInfoList.get(0);
if (fileInfo == null) {
return;
}
//更具当前用户id和路径去查询当前用户是否有该文件,如果没有直接返回
fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setFilePath(fileInfo.getFilePath());
fileInfoQuery.setUserId(userId);
Integer count = fileInfoService.findCountByParam(fileInfoQuery);
if (count == 0) {
return;
}
}
String fileName = fileInfo.getFilePath();
fileName = StringTools.getFileNameNoSuffix(fileName) + "/" + fileId;//202412/2260286088Aamg0R4vRu/Aamg0R4vRu
filePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + fileName;
} else {
FileInfo fileInfo = fileInfoService.getFileInfoByFileIdAndUserId(fileId, userId);
if (fileInfo == null || FileDelFlagEnums.DEL_REAL.getFlag().equals(fileInfo.getDelFlag())) {
return;
}
//视频文件需要读取.m3u8文件
if (FileCategoryEnums.VIDEO.getCategory().equals(fileInfo.getFileCategory())) {
//重新设置文件路径(.m3u8文件路径)
String fileNameNoSuffix = StringTools.getFileNameNoSuffix(fileInfo.getFilePath());//2024/12 / userid+fileid
filePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + fileNameNoSuffix + File.separator + Constants.M3U8_NAME;
} else {
//不是视频文件就不需要读取.m3u8文件,直接读取文件即可
filePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + fileInfo.getFilePath();
}
}
File file = new File(filePath);
if (!file.exists()) {
return;
}
readFile(response, filePath);//调用readFile方法将文件内容写入HTTP响应
}
5、新建目录
业务分析
新建一个目录首先对于数据库肯定是插入文件数据的操作,需要传入的参数有文件的父目录Id,这样目录就有了层级关系。这里目录的显示是前端的事情,我们只需要在查询目录列表的时候,返回数据让前端来渲染就行。
步骤 :
- 检查当前目录下是否有重名目录
- 有就报错
- 没有继续下一项
- 插入文件目录信息到数据库,这里设置好文件父目录Id(根目录就设置为0,这是前端的传递的)
- 返回新建的目录信息
业务接口
代码实现
/**
*这里返回值为空也没问题,因为接口也不需要返回数据
*/
@RequestMapping("/newFoloder")
@GlobalInterceptor(checkParams = true)
public ResponseVO newFoloder(HttpSession session,
@VerifyParam(required = true) String filePid,
@VerifyParam(required = true) String fileName) {
SessionWebUserDto webUserDto = getUserInfoFromSession(session);
FileInfo fileInfo = fileInfoService.newFolder(filePid, webUserDto.getUserId(), fileName);
return getSuccessResponseVO(null);
}
6、获取当前目录
业务分析
查询当前目录信息,需要查询该目录的一整个的层级目录,所以前端会传递该目录所有层级目录的父id和本级目录的id作为查询参数,我们需要按前端给的id顺序返回对应顺序的目录信息。
步骤 :
- 将路径参数分割成id数组,使用 in 查询
- 使用 in 查询的时候是没有顺序的,需要使用到field关键字来对查询结构进行排序
- 将查询结果返回
打开上面新建的目录,就会发送获取当前目录的请求
业务接口
代码实现
/**
*
* @param path 多级目录中每个目录的id串
* @param userId
* @return
*/
public ResponseVO getFolderInfo(String path, String userId) {
String[] pathArray = path.split("/");
FileInfoQuery infoQuery = new FileInfoQuery();
infoQuery.setUserId(userId);
infoQuery.setFolderType(FileFolderTypeEnums.FOLDER.getType());
infoQuery.setFileIdArray(pathArray);
//field(file_id,"folder1","folder2","folder3")
String orderBy = "field(file_id,\"" + StringUtils.join(pathArray, "\",\"") + "\")";
infoQuery.setOrderBy(orderBy);
List<FileInfo> fileInfoList = fileInfoService.findListByParam(infoQuery);
//按路径顺序排序返回FolderVOList对象
return getSuccessResponseVO(CopyTools.copyList(fileInfoList, FolderVO.class));
}
附上排序sql
7、文件重命名
业务分析
这里直接通过id查询然后修改文件名称为传递的名称,这样一般情况下可以完成修改,但是根据此处的业务逻辑这样考虑是不行的,因为同一个目录中不能有俩个相同名称的文件或目录。具体解决看下面详细步骤。
步骤 :
- 查询文件信息:根据文件ID和用户ID从数据库中查询文件信息。
- 检查文件是否存在:如果文件不存在,则抛出异常。
- 检查文件名是否相同:如果新文件名与旧文件名相同,则不做处理直接结束方法(不需要修改)。
- 检查文件名冲突:调用checkFileName方法,确保同目录下没有同名文件。
- 如果冲突就报错提示当前目录下已有重名文件
- 处理文件后缀
- 文件:截取旧的文件后缀,然后拼接新的文件名。
- 目录:目录的名称可以不做处理,直接修改为新的目录名称
- 更新文件信息:设置新的文件名和最后更新时间,并更新数据库。
- 再次检查文件名冲突:确保更新后的文件名在同目录下唯一。
- 这个应该是防止并发情况
- 返回更新后的文件信息。
业务接口
代码实现
/**
*
* @param fileId
* @param userId
* @param fileName 需要修改成的文件名
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public FileInfo rename(String fileId, String userId, String fileName) {
FileInfo fileInfo = this.fileInfoMapper.selectByFileIdAndUserId(fileId, userId);
if (fileInfo == null) {
throw new BusinessException("文件不存在");
}
if (fileInfo.getFileName().equals(fileName)) {
return fileInfo;
}
String filePid = fileInfo.getFilePid();
checkFileName(filePid, userId, fileName, fileInfo.getFolderType());
//文件获取后缀
if (FileFolderTypeEnums.FILE.getType().equals(fileInfo.getFolderType())) {
fileName = fileName + StringTools.getFileSuffix(fileInfo.getFileName());
}
Date curDate = new Date();
FileInfo dbInfo = new FileInfo();
dbInfo.setFileName(fileName);
dbInfo.setLastUpdateTime(curDate);
this.fileInfoMapper.updateByFileIdAndUserId(dbInfo, fileId, userId);
FileInfoQuery fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setFilePid(filePid);
fileInfoQuery.setUserId(userId);
fileInfoQuery.setFileName(fileName);
fileInfoQuery.setDelFlag(FileDelFlagEnums.USING.getFlag());
Integer count = this.fileInfoMapper.selectCount(fileInfoQuery);
if (count > 1) {
throw new BusinessException("文件名" + fileName + "已经存在");
}
fileInfo.setFileName(fileName);
fileInfo.setLastUpdateTime(curDate);
return fileInfo;
}
/**
*
* @param filePid
* @param userId
* @param fileName 重命名后的文件名
* @param folderType 文件类型:文件夹:1,文件:0
*/
private void checkFileName(String filePid, String userId, String fileName, Integer folderType) {
FileInfoQuery fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setFolderType(folderType);
fileInfoQuery.setFileName(fileName);
fileInfoQuery.setFilePid(filePid);
fileInfoQuery.setUserId(userId);
fileInfoQuery.setDelFlag(FileDelFlagEnums.USING.getFlag());
Integer count = this.fileInfoMapper.selectCount(fileInfoQuery);
if (count > 0) {
throw new BusinessException("此目录下已存在同名文件,请修改名称");
}
}
8、获取所有目录
业务分析
这个接口是在实现移动文件的其中一个接口,在选择移动到哪个目录时,我们需要查询出当前目录下的所有目录信息(仅仅是目录信息,不包含文件),但需要排除自己文件所在目录的信息(因为不能移动到自己所在目录),想要实现这个查询,要求前端每次选择移动到的目录列表信息后,给我们传递此目录的id作为我们查询的父目录Id。
步骤 :
- 初始化查询条件:
- userId
- filePid:需要通过这个字段查询该目录下的文件目录列表
- folderType:文件的类型,需要通过这个字段查询文件类型为目录的数据
- currentFileIds:当前目录Pid
- 单个文件或目录移动:你选中要移动的文件的Pid,需要通过这个字段排除 自己(不能移动到自己目录下)
- 多个文件或目录的移动:
- 你选中要移动的文件的Pid,因为选择了多个文件或目录,所以会有多个Pid(这里一般移动同目录文件的话,所以多个Pid都是相同的),所以说就传递一个Pid就好了
- 想要选择不同目录下的多个文件进行批量移动,这个好像不行,因为这个业务需求确实也没必要
获取所有目录是在移动文件时,查询当前目录下有哪些目录,如果是自己所在目录,需要将自己排除
currentFileIds是要移动的文件的ids,如果其中有目录id就需要将其排除,因为不能把自己移动到自己文件目录中
业务接口
代码实现
@RequestMapping("/loadAllFolder")
@GlobalInterceptor(checkParams = true)
public ResponseVO loadAllFolder(HttpSession session, @VerifyParam(required = true) String filePid, String currentFileIds) {
FileInfoQuery query = new FileInfoQuery();
query.setUserId(getUserInfoFromSession(session).getUserId());
query.setFilePid(filePid);
query.setFolderType(FileFolderTypeEnums.FOLDER.getType());
if (!StringTools.isEmpty(currentFileIds)) {
query.setExcludeFileIdArray(currentFileIds.split(","));//排除移动文件所在目录
}
query.setDelFlag(FileDelFlagEnums.USING.getFlag());
query.setOrderBy("create_time desc");
List<FileInfo> fileInfoList = fileInfoService.findListByParam(query);
return getSuccessResponseVO(CopyTools.copyList(fileInfoList, FileInfoVO.class));
}
9、移动文件目录、移动文件
业务分析
移动文件或文件目录的逻辑,其实就是修改选择的移动文件的Pid的数据为移动到 目录的 fileId
步骤 :
- 参数校验:
- 检查目标文件夹是否为自身(不能移动到自身目录)
- 前端页面其实已经处理了,移动时不会渲染出自身所在目录,但是这里后端还是再次校验处理一下
- 如果目标文件夹不是根目录,检查其是否存在且未被删除(不存在目录更不行)
- 只要获取所有目录接口逻辑没问题(查询的目录列表都是正常状态),这里不会出现这个情况。
- 检查目标文件夹是否为自身(不能移动到自身目录)
- 查询目标文件夹中的文件:
- 获取目标文件夹中所有文件,并构建文件名映射表,就是将该目录下的文件信息存储在map集合中,可以文件名作为key,文件信息作为值。
- 查询并处理选中的文件:
- 查询需要移动的文件信息。
- 对重名文件进行重命名(如果目标文件夹中已存在同名文件)。
- 更新文件的父文件夹ID(最核心的步骤)。
业务接口
代码实现
/**
* @param fileIds 需要移动的文件id,多个用逗号隔开
* @param filePid 移动到的目录
* @param userId
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void changeFileFolder(String fileIds, String filePid, String userId) {
if (fileIds.contains(filePid)) {
throw new BusinessException(ResponseCodeEnum.CODE_600);//不能移动到自己所在的目录里面(保险)
}
if (!Constants.ZERO_STR.equals(filePid)) {//如果不是在根目录
FileInfo fileInfo = fileInfoService.getFileInfoByFileIdAndUserId(filePid, userId);//移动到的目录
if (fileInfo == null || !FileDelFlagEnums.USING.getFlag().equals(fileInfo.getDelFlag())) {
throw new BusinessException(ResponseCodeEnum.CODE_600);
}
}
String[] fileIdArray = fileIds.split(",");
FileInfoQuery query = new FileInfoQuery();
query.setFilePid(filePid);
query.setUserId(userId);
List<FileInfo> dbFileList = fileInfoService.findListByParam(query);//dbFileList-移动到的目录下的文件列表
//文件名为key,fileInfo为值(文件列表map)
Map<String, FileInfo> dbFileNameMap = dbFileList.stream().collect(Collectors.toMap(FileInfo::getFileName, Function.identity(), (file1, file2) -> file2));
//查询选中的文件
query = new FileInfoQuery();
query.setUserId(userId);
query.setFileIdArray(fileIdArray);
List<FileInfo> selectFileList = fileInfoService.findListByParam(query);//selectFileList-选择文件列表
//将所选文件重命名
for (FileInfo item : selectFileList) {
FileInfo rootFileInfo = dbFileNameMap.get(item.getFileName());//选择的文件 是否和 文件列表 重名
//文件名已经存在,重命名被还原的文件名
FileInfo updateInfo = new FileInfo();
if (rootFileInfo != null) {//表示和文件列表中某个文件重名
String fileName = StringTools.rename(item.getFileName());
updateInfo.setFileName(fileName);
}
updateInfo.setFilePid(filePid);//更改文件 所在目录
this.fileInfoMapper.updateByFileIdAndUserId(updateInfo, item.getFileId(), userId);
}
}
10、创建下载链接
业务分析
步骤 :
- 获取文件信息:通过 fileId 和 userId 获取文件信息。
- 验证文件存在性:如果文件不存在或为文件夹(不能下载文件夹),抛出异常。
- 生成随机码:使用 StringTools.getRandomString 生成50位的随机字符串code作为下载码。
- 创建下载信息对象:将下载码、文件路径和文件名封装到 DowDownloadFileDto 对象中。
- 保存下载信息:将下载信息保存到 Redis 中,以code作为key,DowDownloadFileDto作为值
- 返回成功响应:返回包含下载码的成功响应。
点击下载就会发送创建下载链接的请求,并且会传递fileId给服务端,需要服务端根据fileId创建的文件的下载链接返回给客户端
业务接口
代码实现
11、下载文件
业务分析
步骤 :
- 获取下载信息:通过Redis组件根据下载码code获取DownloadFileDto对象。
- 检查下载信息是否存在:如果downloadFileDto为null,直接返回。
- 构建文件路径:使用appConfig中的项目根目录和常量拼接出完整的文件路径。
- 设置响应头:根据浏览器类型对文件名进行编码,并设置响应头以触发文件下载。
- 读取并输出文件:调用readFile方法将文件内容写入HTTP响应。
业务接口
代码实现
/**
*
* @param request
* @param response
* @param code 调用创建下载链接接口返回的code,通过这个code可以从redis中获取下载链接信息(文件路径和文件名称)
* @throws Exception
*/
protected void download(HttpServletRequest request, HttpServletResponse response, String code) throws Exception {
DownloadFileDto downloadFileDto = redisComponent.getDownloadCode(code);
if (null == downloadFileDto) {
return;
}
String filePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + downloadFileDto.getFilePath();
String fileName = downloadFileDto.getFileName();
response.setContentType("application/x-msdownload; charset=UTF-8");
if (request.getHeader("User-Agent").toLowerCase().indexOf("msie") > 0) {//IE浏览器
fileName = URLEncoder.encode(fileName, "UTF-8");
} else {
fileName = new String(fileName.getBytes("UTF-8"), "ISO8859-1");
}
response.setHeader("Content-Disposition", "attachment;filename=\"" + fileName + "\"");
readFile(response, filePath);
}
12、删除文件
业务分析
通过fileId来更改文件的状态为回收站,这里只是逻辑删除,但是删除的如果是目录的话,需要将里面所有的子目录和子文件的状态都进行修改
步骤 :
- 解析文件ID:将传入的文件ID字符串按逗号分割成数组(有批量删除 和 删除单个)
- 查询选中的文件信息:根据用户ID和文件ID数组查询处于“正常”状态需要 被删除的文件列表
- 如果没查询到直接返回(小人没走前端情况)
- 将查询到的文件列表信息中每个文件的 id 添加到 删除集合delFilePidList中
- 如果文件类型是 目录类型,则使用递归 添加目录列表里的文件到集合中
- 设置查询条件为filePid=file_id,就可以查询到目录下的文件列表信息;
- 如果文件类型是 目录类型,则使用递归 添加目录列表里的文件到集合中
- 更新删除集合的del_flag状态为删除
- 更新选中文件del_flag状态为回收站,和回收时间
下面是发送删除文件请求信息
业务接口
代码实现
@Override
@Transactional(rollbackFor = Exception.class)
public void removeFile2RecycleBatch(String userId, String fileIds) {
String[] fileIdArray = fileIds.split(",");
FileInfoQuery query = new FileInfoQuery();
query.setUserId(userId);
query.setFileIdArray(fileIdArray);
query.setDelFlag(FileDelFlagEnums.USING.getFlag());
List<FileInfo> fileInfoList = this.fileInfoMapper.selectList(query);//你选中要删除的文件列表
if (fileInfoList.isEmpty()) {
return;
}
List<String> delFilePidList = new ArrayList<>();//选中的文件以及子目录下的文件id集合
for (FileInfo fileInfo : fileInfoList) {
//将选中的文件,和选中的目录及其子目录下的所有文件(包含目录) 添加到 delFilePidList中
findAllSubFolderFileIdList(delFilePidList, userId, fileInfo.getFileId(), FileDelFlagEnums.USING.getFlag());
}
//将目录下的所有文件更新为已删除
if (!delFilePidList.isEmpty()) {
FileInfo updateInfo = new FileInfo();
updateInfo.setDelFlag(FileDelFlagEnums.DEL.getFlag());
this.fileInfoMapper.updateFileDelFlagBatch(updateInfo, userId, delFilePidList, null, FileDelFlagEnums.USING.getFlag());
}
//将选中的文件更新为回收站
List<String> delFileIdList = Arrays.asList(fileIdArray);
FileInfo fileInfo = new FileInfo();
fileInfo.setRecoveryTime(new Date());
fileInfo.setDelFlag(FileDelFlagEnums.RECYCLE.getFlag());//后面在回收站中可以通过这个找到
this.fileInfoMapper.updateFileDelFlagBatch(fileInfo, userId, null, delFileIdList, FileDelFlagEnums.USING.getFlag());
}
/**
*
* @param fileIdList 存储需要删除的文件id集合
* @param userId
* @param fileId 需要删除的文件id
* @param delFlag 正常状态的文件,后面进行修改
*/
private void findAllSubFolderFileIdList(List<String> fileIdList, String userId, String fileId, Integer delFlag) {
fileIdList.add(fileId);
FileInfoQuery query = new FileInfoQuery();
query.setUserId(userId);
query.setFilePid(fileId);
query.setDelFlag(delFlag);
query.setFolderType(FileFolderTypeEnums.FOLDER.getType());
//如果是目录,则继续递归查询;文件会查询查询不到数据,就不会进入递归查询
List<FileInfo> fileInfoList = this.fileInfoMapper.selectList(query);
for (FileInfo fileInfo : fileInfoList) {
findAllSubFolderFileIdList(fileIdList, userId, fileInfo.getFileId(), delFlag);
}
}
回收站
1、获取回收站文件列表
业务分析
分页查询,详细步骤看下面
步骤 :
- 创建查询对象:初始化 FileInfoQuery 对象。
- 设置查询参数:
- 设置分页大小和页码。
- 获取当前用户的 userId。
- 设置排序规则为按 recovery_time 降序排列。
- 设置删除标志为回收站状态。
- 设置查询参数:
- 调用服务层方法:通过 fileInfoService.findListByPage(query) 查询分页结果。
- 转换并返回响应:将查询结果转换为 PaginationResultVO<FileInfoVO>,并封装到 ResponseVO 中返回。
点击回收站图标,需要加载回收站中的文件列表
接口分析
{
"status": "success",
"code": 200,
"info": "请求成功",
"data": {
"totalCount": 2,
"pageSize": 15,
"pageNo": 1,
"pageTotal": 1,
"list": [
{
"fileId": "FBXdDvhLHg",
"filePid": "0",
"fileSize": 15175,
"fileName": "xx212222.jpg",
"fileCover": "202304/102522FBXdDvhLHg_.jpg",
"lastUpdateTime": "2023-04-12 09:30:21",
"folderType": 0,
"fileCategory": 3,
"fileType": 3,
"status": 2
},
{
"fileId": "vWVCc9FxVf",
"filePid": "0",
"fileSize": 28480,
"fileName": "xx3.jpg",
"fileCover": "202304/102522vWVCc9FxVf_.jpg",
"lastUpdateTime": "2023-04-12 09:30:29",
"folderType": 0,
"fileCategory": 3,
"fileType": 3,
"status": 2
}
]
}
}
代码实现
/**
*
* @param session
* @param pageNo 页码
* @param pageSize 每页显示条数
* @return
*/
@RequestMapping("/loadRecycleList")
@GlobalInterceptor(checkParams = true)
public ResponseVO loadRecycleList(HttpSession session, Integer pageNo, Integer pageSize) {
FileInfoQuery query = new FileInfoQuery();
query.setPageSize(pageSize);
query.setPageNo(pageNo);
query.setUserId(getUserInfoFromSession(session).getUserId());
query.setOrderBy("recovery_time desc");
query.setDelFlag(FileDelFlagEnums.RECYCLE.getFlag());
PaginationResultVO result = fileInfoService.findListByPage(query);
return getSuccessResponseVO(convert2PaginationVO(result, FileInfoVO.class));
}
2、恢复文件
业务分析
更改文件或目录状态字段为正常,这样是不是太简单了;小小的老子还是太年轻了,这样做如果是目录的话只会恢复目录的状态为正常,但是里面子文件及其子目录都没有改变,这下子麻烦了。请看下面大佬如何解决。
步骤 :
- 解析文件ID:将传入需要恢复的文件ID字符串分割成数组。
- 查询回收站中的文件:
- 根据用户ID
- 文件ID数组
- 处于回收站状态
- 将选中需要被恢复的文件目录 和 此文件目录下的所有目录及文件 的id添加到 恢复集合delFileSubFolderFileIdList中,但是这个集合中不包含选中的文件Id
- 选中需要被恢复的文件目录 和 此文件目录下的所有目录及文件 的id添加到集合的实现是通过递归完成,下面会贴出代码和详细注释
- delFileSubFolderFileIdList集合只保存目录id是因为后面通过这个集合中的id作为查询的父Id,来更改这些位于集合目录下的文件状态,所以第一级的文件状态这里是没有修改到的。
- 查询根目录文件 将文件信息保存在rootFileMap集合中(将选中文件恢复到根目录是,为防止和根目录文件重名)
- map根据文件名为key,文件信息为value
- 把delFileSubFolderFileIdList集合中对应的子文件状态为 “正常”,此时选择恢复的文件父级目录还是以前的
- 将选择的所有文件状态更新为正常使用,并将其父级目录设置为根目录。
- 重命名文件:如果恢复的文件名与根目录中已有的文件名冲突,则重命名恢复的文件
接口分析
代码实现
@Override
@Transactional(rollbackFor = Exception.class)
public void recoverFileBatch(String userId, String fileIds) {
String[] fileIdArray = fileIds.split(",");
FileInfoQuery query = new FileInfoQuery();
query.setUserId(userId);
query.setFileIdArray(fileIdArray);
query.setDelFlag(FileDelFlagEnums.RECYCLE.getFlag());
List<FileInfo> fileInfoList = this.fileInfoMapper.selectList(query);
List<String> delFileSubFolderFileIdList = new ArrayList<>();
//将第一级所有选择恢复的文件目录的id添加到delFileSubFolderFileIdList中,而且通过递归将所有子目录及其子目录下的所有文件id添加到delFileSubFolderFileIdList中
//那第一级中选中要恢复的文件怎么办呢?
for (FileInfo fileInfo : fileInfoList) {
if (FileFolderTypeEnums.FOLDER.getType().equals(fileInfo.getFolderType())) {
findAllSubFolderFileIdList(delFileSubFolderFileIdList, userId, fileInfo.getFileId(), FileDelFlagEnums.DEL.getFlag());
}
}
//查询所有根目录的文件
query = new FileInfoQuery();
query.setUserId(userId);
query.setDelFlag(FileDelFlagEnums.USING.getFlag());
query.setFilePid(Constants.ZERO_STR);
List<FileInfo> allRootFileList = this.fileInfoMapper.selectList(query);
Map<String, FileInfo> rootFileMap = allRootFileList
.stream()
/**
* FileInfo::getFileName:作为键,提取每个FileInfo对象的文件名。
* Function.identity():作为值,直接使用FileInfo对象本身。
* (file1, file2) -> file2:当遇到重复键时,保留后者(即覆盖前者)。【同级目录里面一般应该没有想象名字的文件】
*/
.collect(Collectors.toMap(FileInfo::getFileName, Function.identity(), (file1, file2) -> file2));
//将delFileSubFolderFileIdList中的所有文件更新为正常,此时还没有设置恢复文件的父级目录0
//delFileSubFolderFileIdList中存储的是回收站列表第一级中 选中的所有目录文件 和 选中的所有目录下的所有文件(包括子目录,但没有第一级选中的文件)
if (!delFileSubFolderFileIdList.isEmpty()) {
FileInfo fileInfo = new FileInfo();
fileInfo.setDelFlag(FileDelFlagEnums.USING.getFlag());
this.fileInfoMapper.updateFileDelFlagBatch(fileInfo, userId, delFileSubFolderFileIdList, null, FileDelFlagEnums.DEL.getFlag());
}
//将选中的文件更新为正常,且父级目录到跟目录
List<String> delFileIdList = Arrays.asList(fileIdArray);
FileInfo fileInfo = new FileInfo();
fileInfo.setDelFlag(FileDelFlagEnums.USING.getFlag());
fileInfo.setFilePid(Constants.ZERO_STR);//恢复后,父级目录到跟目录(不设置的话就是恢复到之前删除的目录,但这样容易出现问题)
fileInfo.setLastUpdateTime(new Date());
this.fileInfoMapper.updateFileDelFlagBatch(fileInfo, userId, null, delFileIdList, FileDelFlagEnums.RECYCLE.getFlag());
//将所选文件重命名
for (FileInfo item : fileInfoList) {
FileInfo rootFileInfo = rootFileMap.get(item.getFileName());
//文件名已经存在,重命名被还原的文件名
if (rootFileInfo != null) {
String fileName = StringTools.rename(item.getFileName());
FileInfo updateInfo = new FileInfo();
updateInfo.setFileName(fileName);
this.fileInfoMapper.updateByFileIdAndUserId(updateInfo, item.getFileId(), userId);
}
}
}
/**
*
* @param fileIdList 存储需要删除的文件id集合
* @param userId
* @param fileId 需要删除的文件id
* @param delFlag 正常状态的文件,后面进行修改
*/
private void findAllSubFolderFileIdList(List<String> fileIdList, String userId, String fileId, Integer delFlag) {
fileIdList.add(fileId);
FileInfoQuery query = new FileInfoQuery();
query.setUserId(userId);
query.setFilePid(fileId);
query.setDelFlag(delFlag);
query.setFolderType(FileFolderTypeEnums.FOLDER.getType());
//如果是目录,则继续递归查询;文件会查询查询不到数据,就不会进入递归查询
List<FileInfo> fileInfoList = this.fileInfoMapper.selectList(query);
for (FileInfo fileInfo : fileInfoList) {
findAllSubFolderFileIdList(fileIdList, userId, fileInfo.getFileId(), delFlag);
}
}
3、彻底删除文件
业务分析
更改文件的状态字段为删除,这里还是需要使用递归将选择的目录下所有子目录和文件进行递归更改,因为前面已经详细说明几次了,这里就不详细介绍了。
步骤 :
- 偷懒略过,各位自己看代码
接口分析
代码实现
@Override
@Transactional(rollbackFor = Exception.class)
public void delFileBatch(String userId, String fileIds, Boolean adminOp) {
String[] fileIdArray = fileIds.split(",");
FileInfoQuery query = new FileInfoQuery();
query.setUserId(userId);
query.setFileIdArray(fileIdArray);
if (!adminOp) {
query.setDelFlag(FileDelFlagEnums.RECYCLE.getFlag());
}
List<FileInfo> fileInfoList = this.fileInfoMapper.selectList(query);
List<String> delFileSubFolderFileIdList = new ArrayList<>();
//找到所选文件子目录文件ID
for (FileInfo fileInfo : fileInfoList) {
if (FileFolderTypeEnums.FOLDER.getType().equals(fileInfo.getFolderType())) {
findAllSubFolderFileIdList(delFileSubFolderFileIdList, userId, fileInfo.getFileId(), FileDelFlagEnums.DEL.getFlag());
}
}
//删除所选文件,子目录中的文件
if (!delFileSubFolderFileIdList.isEmpty()) {
this.fileInfoMapper.delFileBatch(userId, delFileSubFolderFileIdList, null, adminOp ? null : FileDelFlagEnums.DEL.getFlag());
}
//删除所选文件
this.fileInfoMapper.delFileBatch(userId, null, Arrays.asList(fileIdArray), adminOp ? null : FileDelFlagEnums.RECYCLE.getFlag());
Long useSpace = this.fileInfoMapper.selectUseSpace(userId);
UserInfo userInfo = new UserInfo();
userInfo.setUseSpace(useSpace);
this.userInfoMapper.updateByUserId(userInfo, userId);
//设置缓存
UserSpaceDto userSpaceDto = redisComponent.getUserSpaceUse(userId);
userSpaceDto.setUseSpace(useSpace);
redisComponent.saveUserSpaceUse(userId, userSpaceDto);
}
分享
1、获取分享文件列表
业务分析
分页查询file_share表
步骤 :
- 设置user_id和排序顺序
接口分析
代码实现
@RequestMapping("/loadShareList")
@GlobalInterceptor(checkParams = true)
public ResponseVO loadShareList(HttpSession session, FileShareQuery query) {
query.setOrderBy("share_time desc");
SessionWebUserDto userDto = getUserInfoFromSession(session);
query.setUserId(userDto.getUserId());
query.setQueryFileName(true);
PaginationResultVO resultVO = this.fileShareService.findListByPage(query);
return getSuccessResponseVO(resultVO);
}
2、分享文件
业务分析
将文件信息添加到file_share表
接口分析
代码实现
@RequestMapping("/shareFile")
@GlobalInterceptor(checkParams = true)
public ResponseVO shareFile(HttpSession session,
@VerifyParam(required = true) String fileId,
@VerifyParam(required = true) Integer validType,
String code) {
SessionWebUserDto userDto = getUserInfoFromSession(session);
FileShare share = new FileShare();
share.setFileId(fileId);
share.setValidType(validType);
share.setCode(code);//yyds6
share.setUserId(userDto.getUserId());
fileShareService.saveShare(share);
return getSuccessResponseVO(share);
}
3、取消分享
业务分析
取消分享是将 数据库表中的分享信息直接删除(真实的删除)
接口分析
代码实现
@RequestMapping("/cancelShare")
@GlobalInterceptor(checkParams = true)
public ResponseVO cancelShare(HttpSession session, @VerifyParam(required = true) String shareIds) {
SessionWebUserDto userDto = getUserInfoFromSession(session);
fileShareService.deleteFileShareBatch(shareIds.split(","), userDto.getUserId());
return getSuccessResponseVO(null);
}
设置
1、获取系统设置
业务分析
系统信息是保存在redis中,直接通过redis那到返回
步骤 :
接口分析
代码实现
@RequestMapping("/getSysSettings")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO getSysSettings() {
return getSuccessResponseVO(redisComponent.getSysSettingsDto());
}
2、保存系统设置
业务分析
将信息提交然后保存到redis中
接口分析
代码实现
@RequestMapping("/saveSysSettings")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO saveSysSettings(
@VerifyParam(required = true) String registerEmailTitle,
@VerifyParam(required = true) String registerEmailContent,
@VerifyParam(required = true) Integer userInitUseSpace) {
SysSettingsDto sysSettingsDto = new SysSettingsDto();
sysSettingsDto.setRegisterEmailTitle(registerEmailTitle);
sysSettingsDto.setRegisterEmailContent(registerEmailContent);
sysSettingsDto.setUserInitUseSpace(userInitUseSpace);
redisComponent.saveSysSettingsDto(sysSettingsDto);
return getSuccessResponseVO(null);
}
3、获取用户列表
业务分析
进行分页查询,实现用户名模糊匹配查询和对用户账户状态筛选查询
接口分析
代码实现
@RequestMapping("/loadUserList")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO loadUser(UserInfoQuery userInfoQuery) {
userInfoQuery.setOrderBy("join_time desc");
PaginationResultVO resultVO = userInfoService.findListByPage(userInfoQuery);
return getSuccessResponseVO(convert2PaginationVO(resultVO, UserInfoVO.class));
}
4、修改用户状态
业务分析
用户状态有俩种,0-禁用;1-正常
接口分析
代码实现
@RequestMapping("/updateUserStatus")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO updateUserStatus(@VerifyParam(required = true) String userId, @VerifyParam(required = true) Integer status) {
userInfoService.updateUserStatus(userId, status);
return getSuccessResponseVO(null);
}
5、修改用户空间
业务分析
修改用户可以使用的总空间大小
接口分析
代码实现
@RequestMapping("/updateUserSpace")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO updateUserSpace(@VerifyParam(required = true) String userId, @VerifyParam(required = true) Integer changeSpace) {
userInfoService.changeUserSpace(userId, changeSpace);
return getSuccessResponseVO(null);
}
6、获取所有文件(文件列表)
业务分析
一个分页查询接口,业务需求需要可以通过文件名称进行模糊搜索
接口分析
代码实现
/**
* 查询所有文件
*
* @param query
* @return
*/
@RequestMapping("/loadFileList")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO loadDataList(FileInfoQuery query) {
query.setOrderBy("last_update_time desc");
query.setQueryNickName(true);
query.setDelFlag(FileDelFlagEnums.USING.getFlag());
PaginationResultVO resultVO = fileInfoService.findListByPage(query);
return getSuccessResponseVO(resultVO);
}
7、获取当前目录信息
业务分析
和用户获取当前目录接口一样,需要通过path参数查询到 当前目录信息(也就是当前目录的层级信息)
接口分析
返回示例数据如下:需要返回一个path的文件目录信息的list集合
代码实现
@RequestMapping("/getFolderInfo")
@GlobalInterceptor(checkLogin = false, checkAdmin = true, checkParams = true)
public ResponseVO getFolderInfo(@VerifyParam(required = true) String path) {
return super.getFolderInfo(path, null);
}
8、获取文件信息
业务分析
和用户获取文件接口功能一样,但是需要前端传递userId,因为这里是管理查看个人的文件信息,管理员这边无法从session中拿到userId,这个userId前端发送请求时会传递给我们所以不用担心,其他无区别,不再分析。
接口分析
响应的数据就是文件流,无返回值
代码实现
@RequestMapping("/getFile/{userId}/{fileId}")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public void getFile(HttpServletResponse response,
@PathVariable("userId") @VerifyParam(required = true) String userId,
@PathVariable("fileId") @VerifyParam(required = true) String fileId) {
super.getFile(response, fileId, userId);
}
protected void getFile(HttpServletResponse response, String fileId, String userId) {
String filePath = null;
if (fileId.endsWith(".ts")) {
String[] tsAarray = fileId.split("_");//这里的fileId是BC3964suEj_0000.ts
String realFileId = tsAarray[0];
//根据原文件的id和userid查询出一个文件信息
FileInfo fileInfo = fileInfoService.getFileInfoByFileIdAndUserId(realFileId, userId);
if (fileInfo == null) {
//如果为空就是分享的视频,ts路径记录的是原视频的id,这里通过id直接取出原视频
FileInfoQuery fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setFileId(realFileId);
List<FileInfo> fileInfoList = fileInfoService.findListByParam(fileInfoQuery);
fileInfo = fileInfoList.get(0);
if (fileInfo == null) {
return;
}
//更具当前用户id和路径去查询当前用户是否有该文件,如果没有直接返回
fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setFilePath(fileInfo.getFilePath());
fileInfoQuery.setUserId(userId);
Integer count = fileInfoService.findCountByParam(fileInfoQuery);
if (count == 0) {
return;
}
}
String fileName = fileInfo.getFilePath();
fileName = StringTools.getFileNameNoSuffix(fileName) + "/" + fileId;//202412/2260286088Aamg0R4vRu/Aamg0R4vRu
filePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + fileName;
} else {
FileInfo fileInfo = fileInfoService.getFileInfoByFileIdAndUserId(fileId, userId);
if (fileInfo == null || FileDelFlagEnums.DEL_REAL.getFlag().equals(fileInfo.getDelFlag())) {
return;
}
//视频文件需要读取.m3u8文件
if (FileCategoryEnums.VIDEO.getCategory().equals(fileInfo.getFileCategory())) {
//重新设置文件路径(.m3u8文件路径)
String fileNameNoSuffix = StringTools.getFileNameNoSuffix(fileInfo.getFilePath());//2024/12 / userid+fileid
filePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + fileNameNoSuffix + File.separator + Constants.M3U8_NAME;
} else {
//不是视频文件就不需要读取.m3u8文件,直接读取文件即可
filePath = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE + fileInfo.getFilePath();
}
}
File file = new File(filePath);
if (!file.exists()) {
return;
}
readFile(response, filePath);//调用readFile方法将文件内容写入HTTP响应
}
9、获取视频文件信息
业务分析
请求视频文件预览的接口和其他文件不同,但是上面用户那边也已经讲解,这里的区别就是前端会传递userId给我们,因为这里从sessoin中拿到的userId其实是管理员自己的userid,但是我们读的是用户上传的视频。
10、创建下载链接
业务分析
和用户那边一样,但是userId同样是参数传递给我们,session中的是管理员自己的userId,这里不再分析
接口分析
返回示例
代码实现
RequestMapping("/createDownloadUrl/{userId}/{fileId}")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO createDownloadUrl(@PathVariable("userId") @VerifyParam(required = true) String userId,
@PathVariable("fileId") @VerifyParam(required = true) String fileId) {
return super.createDownloadUrl(fileId, userId);
}
11、下载文件
业务分析
和用户那边一模一样,略。
接口分析
代码实现
/**
* 下载
*
* @param request
* @param response
* @throws Exception
*/
@RequestMapping("/download/{code}")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public void download(HttpServletRequest request, HttpServletResponse response,
@PathVariable("code") @VerifyParam(required = true) String code) throws Exception {
super.download(request, response, code);
}
12、删除文件
业务分析
这里的删除逻辑也是需要前端传递fileId和userId才能删除这边的文件,但是这里删除文件是可以批量删除的,在批量删除的时候 可以选择不同用户的文件进行删除,所以传递的fileId和userId要对应好。
我们参数传递的策略是 文件ID和用户ID为一组用_隔开,多个组用 逗号隔开(一组对应着选中的一个文件对象),所以下面图片会传递俩组数据
步骤:
- 代码注释写的很详细了,我这里就不分析了
接口分析
代码实现
@RequestMapping("/delFile")
@GlobalInterceptor(checkParams = true, checkAdmin = true)
public ResponseVO delFile(@VerifyParam(required = true) String fileIdAndUserIds) {
String[] fileIdAndUserIdArray = fileIdAndUserIds.split(",");
for (String fileIdAndUserId : fileIdAndUserIdArray) {
String[] itemArray = fileIdAndUserId.split("_");
fileInfoService.delFileBatch(itemArray[0], itemArray[1], true);
}
return getSuccessResponseVO(null);
}
/**
*
* @param userId
* @param fileId
* @param adminOp 是否是管理员删除他人文件操作,否就是删除回收站中的文件
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void delFileBatch(String userId, String fileId, Boolean adminOp) {
String[] fileIdArray = fileId.split(",");//管理员删除 这里其实不用继续分割了,因为这里传递进来的就是单个fileId
FileInfoQuery query = new FileInfoQuery();
query.setUserId(userId);
// query.setFileIdArray(fileIdArray);
query.setFileId(fileId);
if (!adminOp) {
query.setDelFlag(FileDelFlagEnums.RECYCLE.getFlag());
}
List<FileInfo> fileInfoList = this.fileInfoMapper.selectList(query);//管理员删除 这里集合只能查到一条数据,因为fileId是单个的
List<String> delFileSubFolderFileIdList = new ArrayList<>();
//处理选中要删除的目录
for (FileInfo fileInfo : fileInfoList) {
if (FileFolderTypeEnums.FOLDER.getType().equals(fileInfo.getFolderType())) {
//将第一级所有选择删除的文件目录的id添加到delFileSubFolderFileIdList中,
// 而且通过递归将所有子目录及其子目录下的所有文件id添加到delFileSubFolderFileIdList中
findAllSubFolderFileIdList(delFileSubFolderFileIdList, userId, fileInfo.getFileId(), FileDelFlagEnums.DEL.getFlag());
}
}
/*
* 这里说明一下:
* 文件的状态有:-1-彻底删除 0-删除 1-回收站 2-正常
*
* 当文件删除后,放入回收站,此时第一级选中的文件和目录是这个 “1-回收站”状态,但是目录中的子目录和文件是设置成 “0-删除”状态.
* 因为在回收站查询列表中只需要显示 “1-回收站”状态的文件,所以目录中的子文件和子目录状态设置为 “0-删除”状态,这样回收站文件列表查询时就
* 不会查询到这些数据了
*
* */
//删除目录中的所有东西(不包含第一级选择的目录),删除状态为:0-删除 或 1-回收站
if (!delFileSubFolderFileIdList.isEmpty()) {
//这里面的逻辑是通过 delFileSubFolderFileIdList 中目录的id 去作为筛选条件,然后把所有子目录下的所有文件的状态更改成“0-删除”
this.fileInfoMapper.delFileBatch(userId, delFileSubFolderFileIdList,
null,
adminOp ? null : FileDelFlagEnums.DEL.getFlag());
}
//删除第一级所选的文件 和 目录
//删除逻辑是根据 所选文件Id作为筛选条件,将文件状态设置“1-回收站”
this.fileInfoMapper.delFileBatch(userId,
null, Arrays.asList(fileIdArray),
adminOp ? null : FileDelFlagEnums.RECYCLE.getFlag());
Long useSpace = this.fileInfoMapper.selectUseSpace(userId);
UserInfo userInfo = new UserInfo();
userInfo.setUseSpace(useSpace);
this.userInfoMapper.updateByUserId(userInfo, userId);
//设置缓存
UserSpaceDto userSpaceDto = redisComponent.getUserSpaceUse(userId);
userSpaceDto.setUseSpace(useSpace);
redisComponent.saveUserSpaceUse(userId, userSpaceDto);
}
外部分享
1、获取用户登录信息
业务分析
通过sesion去取出当前登录用户信息和分享文件信息,分享文件信息在校验分享code通过之后就会存进session中,如果session中没有分享文件信息,说明code验证没通过,直接返回空值给前端。
如果登录信息为空说明不是当前用户分享的文件 或者 登录信息和分享文件信息 中的userId不一致,都说明当前用户不是分享文件的用户(这里是为了其他用户保存分享文件时使用,自己不能保存自己分享的文件)
接口分析
代码实现
/**
* 获取分享登录信息
* @param session
* @param shareId
* @return
*/
@RequestMapping("/getShareLoginInfo")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO getShareLoginInfo(HttpSession session, @VerifyParam(required = true) String shareId) {
SessionShareDto shareSessionDto = getSessionShareFromSession(session, shareId);
if (shareSessionDto == null) {
return getSuccessResponseVO(null);
}
ShareInfoVO shareInfoVO = getShareInfoCommon(shareId);
//判断是否是当前用户分享的文件
SessionWebUserDto userDto = getUserInfoFromSession(session);
if (userDto != null && userDto.getUserId().equals(shareSessionDto.getShareUserId())) {
shareInfoVO.setCurrentUser(true);
} else {
shareInfoVO.setCurrentUser(false);
}
return getSuccessResponseVO(shareInfoVO);
}
2、获取分享信息
业务分析
根据接口给出的响应信息,去分析怎样查询到这些数据-自己分析然后查询返回
接口分析
代码实现
/**
* 获取分享信息
*
* @param shareId
* @return
*/
@RequestMapping("/getShareInfo")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO getShareInfo(@VerifyParam(required = true) String shareId) {
return getSuccessResponseVO(getShareInfoCommon(shareId));
}
private ShareInfoVO getShareInfoCommon(String shareId) {
FileShare share = fileShareService.getFileShareByShareId(shareId);
if (null == share || (share.getExpireTime() != null && new Date().after(share.getExpireTime()))) {
throw new BusinessException(ResponseCodeEnum.CODE_902.getMsg());
}
ShareInfoVO shareInfoVO = CopyTools.copy(share, ShareInfoVO.class);
FileInfo fileInfo = fileInfoService.getFileInfoByFileIdAndUserId(share.getFileId(), share.getUserId());
if (fileInfo == null || !FileDelFlagEnums.USING.getFlag().equals(fileInfo.getDelFlag())) {
throw new BusinessException(ResponseCodeEnum.CODE_902.getMsg());
}
shareInfoVO.setFileName(fileInfo.getFileName());
UserInfo userInfo = userInfoService.getUserInfoByUserId(share.getUserId());
shareInfoVO.setNickName(userInfo.getNickName());
shareInfoVO.setAvatar(userInfo.getQqAvatar());
shareInfoVO.setUserId(userInfo.getUserId());
return shareInfoVO;
}
3、校验分享码
业务分析
提取文件时,需要校验提取码是否正确,如果正确,查询分享文件信息,然后分享文件信息存入session中。
接口分析
代码实现
/**
* 校验分享码
*
* @param session
* @param shareId
* @param code
* @return
*/
@RequestMapping("/checkShareCode")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO checkShareCode(HttpSession session,
@VerifyParam(required = true) String shareId,
@VerifyParam(required = true) String code) {
SessionShareDto shareSessionDto = fileShareService.checkShareCode(shareId, code);
session.setAttribute(Constants.SESSION_SHARE_KEY + shareId, shareSessionDto);
return getSuccessResponseVO(null);
}
4、获取分享文件列表
业务分析
分页查询,通过shareId查询share_file表中信息,得到对应的userId,然后正常查询分页信息,然后封装返回数据返回即可。
接口分析
代码实现
/**
* 获取文件列表
*
* @param session
* @param shareId
* @return
*/
@RequestMapping("/loadFileList")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO loadFileList(HttpSession session,
@VerifyParam(required = true) String shareId, String filePid) {
SessionShareDto shareSessionDto = checkShare(session, shareId);
FileInfoQuery query = new FileInfoQuery();
if (!StringTools.isEmpty(filePid) && !Constants.ZERO_STR.equals(filePid)) {
fileInfoService.checkRootFilePid(shareSessionDto.getFileId(), shareSessionDto.getShareUserId(), filePid);
query.setFilePid(filePid);
} else {
query.setFileId(shareSessionDto.getFileId());
}
query.setUserId(shareSessionDto.getShareUserId());
query.setOrderBy("last_update_time desc");
query.setDelFlag(FileDelFlagEnums.USING.getFlag());
PaginationResultVO resultVO = fileInfoService.findListByPage(query);
return getSuccessResponseVO(convert2PaginationVO(resultVO, FileInfoVO.class));
}
5、获取目录信息
业务分析
这个接口和之前的首页业务模块的接口是大差不差的,但是前端传递参数不同,我们同样是通过shareId获取用户id,然后其他部分基本一样,可以看上面用户接口部分的讲解。
接口分析
代码实现
/**
* 获取目录信息
*
* @param session
* @param shareId
* @param path
* @return
*/
@RequestMapping("/getFolderInfo")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO getFolderInfo(HttpSession session,
@VerifyParam(required = true) String shareId,
@VerifyParam(required = true) String path) {
SessionShareDto shareSessionDto = checkShare(session, shareId);
return super.getFolderInfo(path, shareSessionDto.getShareUserId());
}
6、创建下载链接
业务分析
同样通过shareId获取到userId,其他部分就和用户模块一样(这里的创建下载链接不需要校验登录)
接口分析
代码实现
@RequestMapping("/createDownloadUrl/{shareId}/{fileId}")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public ResponseVO createDownloadUrl(HttpSession session,
@PathVariable("shareId") @VerifyParam(required = true) String shareId,
@PathVariable("fileId") @VerifyParam(required = true) String fileId) {
SessionShareDto shareSessionDto = checkShare(session, shareId);
return super.createDownloadUrl(fileId, shareSessionDto.getShareUserId());
}
7、下载文件
业务分析
和上面用户接口没有一点点差别哈,所以略过
接口分析
代码实现
/**
* 下载
*
* @param request
* @param response
* @throws Exception
*/
@RequestMapping("/download/{code}")
@GlobalInterceptor(checkLogin = false, checkParams = true)
public void download(HttpServletRequest request, HttpServletResponse response,
@PathVariable("code") @VerifyParam(required = true) String code) throws Exception {
super.download(request, response, code);
}
8、保存到我的网盘
业务分析
自己不能保存自己分享的文件,然后将选中分享的文件信息保存到自己的网盘中,这里不能是对文件父id进行直接修改,不然分享人网盘中的文件会消失。所以执行的是新增操作,需要重新再file_info表中新增一条文件信息,而文件路径信息是和分享的文件是一样的,其他大都需要重新设置(举例:file_id、userId、file_pid,create_time、update_time等),最后新增成功还需要更新用户的使用空间大小。
接口分析
代码实现
/**
*
* @param shareRootFilePid 分享文件的file_id
* @param shareFileIds 选中保存到网盘的fileIds-可以是一个或多个
* @param myFolderId 网盘目录id(0)
* @param shareUserId 分享人id
* @param cureentUserId 当前用户的userid
*/
@Override
@Transactional
public void saveShare(String shareRootFilePid, String shareFileIds, String myFolderId, String shareUserId, String cureentUserId) {
String[] shareFileIdArray = shareFileIds.split(",");
//目标目录文件列表
FileInfoQuery fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setUserId(cureentUserId);
fileInfoQuery.setFilePid(myFolderId);
List<FileInfo> currentFileList = this.fileInfoMapper.selectList(fileInfoQuery);//当前用户网盘根目录的文件列表信息
//文件名为key,文件信息为value
Map<String, FileInfo> currentFileMap = currentFileList
.stream()
.collect(Collectors.toMap(FileInfo::getFileName, Function.identity(), (file1, file2) -> file2));
//查询 选择保存的文件信息集合
fileInfoQuery = new FileInfoQuery();
fileInfoQuery.setUserId(shareUserId);
fileInfoQuery.setFileIdArray(shareFileIdArray);
List<FileInfo> shareFileList = this.fileInfoMapper.selectList(fileInfoQuery);
//重命名选择的文件
List<FileInfo> copyFileList = new ArrayList<>();//空集合
Date curDate = new Date();
for (FileInfo item : shareFileList) {
FileInfo haveFile = currentFileMap.get(item.getFileName());
if (haveFile != null) {
//如果有和自己网盘根目录重名文件,则重命名
item.setFileName(StringTools.rename(item.getFileName()));
}
//将所有选择的文件信息添加到copyFileList中(如果是目录包含子目录中的所有信息)
findAllSubFile(copyFileList, item, shareUserId, cureentUserId, curDate, myFolderId);
}
this.fileInfoMapper.insertBatch(copyFileList);
//更新空间
Long useSpace = this.fileInfoMapper.selectUseSpace(cureentUserId);
UserInfo dbUserInfo = this.userInfoMapper.selectByUserId(cureentUserId);
if (useSpace > dbUserInfo.getTotalSpace()) {
throw new BusinessException(ResponseCodeEnum.CODE_904);
}
UserInfo userInfo = new UserInfo();
userInfo.setUseSpace(useSpace);
this.userInfoMapper.updateByUserId(userInfo, cureentUserId);
//设置缓存
UserSpaceDto userSpaceDto = redisComponent.getUserSpaceUse(cureentUserId);
userSpaceDto.setUseSpace(useSpace);
redisComponent.saveUserSpaceUse(cureentUserId, userSpaceDto);
}
/**
*
* @param copyFileList 存放所有需要新增到自己网盘中的文件信息,这些文件信息有新的文件Id 并且 还没有更改之前文件存储的路径
* @param fileInfo 要保存到自己网盘的 分享文件
* @param sourceUserId 分享人id
* @param currentUserId 当前用户id
* @param curDate 当前日期
* @param newFilePid 保存到我们的网盘的目录id(我的网盘根目录)
*/
private void findAllSubFile(List<FileInfo> copyFileList, FileInfo fileInfo, String sourceUserId, String currentUserId, Date curDate, String newFilePid) {
String sourceFileId = fileInfo.getFileId();
fileInfo.setCreateTime(curDate);
fileInfo.setLastUpdateTime(curDate);
fileInfo.setFilePid(newFilePid);
fileInfo.setUserId(currentUserId);
String newFileId = StringTools.getRandomString(Constants.LENGTH_10);
fileInfo.setFileId(newFileId);
copyFileList.add(fileInfo);
//如果是文件夹,则递归查询子文件
if (FileFolderTypeEnums.FOLDER.getType().equals(fileInfo.getFolderType())) {
FileInfoQuery query = new FileInfoQuery();
query.setFilePid(sourceFileId);
query.setUserId(sourceUserId);
List<FileInfo> sourceFileList = this.fileInfoMapper.selectList(query);
for (FileInfo item : sourceFileList) {
findAllSubFile(copyFileList, item, sourceUserId, currentUserId, curDate, newFileId);
}
}
}