仿百度网盘项目

前言

本项目非原创,仅通过博客整理自己所学项目细节。视频来源于:
添加链接描述

项目内容

一个仿百度云盘面向C端用户的网盘项目,包括用户注册,QQ快捷登录,文件上传,分片上传,断点续传,秒传,文件在线预览,包括文本,图片,视频,音频,excel,word ,pdf 等文件在线预览,文件分享等功能。

项目细节:

用户注册,登录,QQ快捷登录,发送邮箱验证码,找回密码。
文件分片上传,秒传,新建目录,预览,文件重命名,文件移动,文件分享,删除,下载 等功能。
文件分享列表,取消分享。
回收站功能,还原文件,彻底删除。
设置模块 (1)超级管理员角色查询所有用户上传的文件,可以对文件下载,删除。 (2)超级管理员可以对用户进行管理,给用户分配空间,禁用、启用用户(3)超级管理员可以对系统进行设置,设置邮箱模板,设置用户注册初始化空间大小。
用户通过分享链接和分享码预览下载其他人分享的文件,同时也可以将文件保存到自己的网盘。
项目难点:
文件分片上传,通过文件MD5实现文件秒传,文件分片上传后,异步对文件进行合并处理,视频文件,调用ffmpeg生成视频缩略图,将文件分片成ts文件。
通过redis缓存实时计算用户上传过程中空间占用情况。
多级目录线性展示,通过递归查询,查询目录的所有父级目录。
用户上传文件,同一级目录重名文件自动重命名,文件移动,同名文件重命名。

实现

1发送邮箱验证码
注册接口中,用户可以凭借邮箱进行注册。但是我们为了防止被人恶意刷注册接口,要生成一个图片验证码,让用户输入之后,如果验证码正确才可以发送邮件进行注册。****

checkcode:首先创建了一个vCode对象来生成图片,主要设置了一下HTTP响应头信息,重点是"no_cache",禁止了浏览器缓存生成的验证码图片。至于为什么要禁用浏览器缓存,在杂项知识点中有介绍。
之后进行判断当前的验证码类型(type),如果是type=1,就是邮箱验证码的图形验证码,如果是type=0的话,就是注册账号登录时的验证码。
我们把后端生成的验证码根据type存储到不同的session属性中,后续在另外一个接口中方便与用户输入的验证码进行对比。
这个接口完之后,我们再进入判断接口,判断用户输入的验证码与生成的验证码结果是否一致:

sendEmailCode:如果用户的验证码与session中保存的不一致,那么就认为验证码输入错误,返回给前端。
如果验证码输入正确,我们就调用emailCodeService.sendEmailCode接口,发送注册验证码。
最后,无论验证码比对是否成功,我们都要在session中删掉原有的验证码,避免被重复使用。

2完成发送邮件的接口
QQ邮箱,所以要完成一些配置。
我们先判断注册的邮箱是否之前注册过,避免一个用户多次注册。之后我们利用自己写的工具类生成五位数字的验证码,之后再调用emailCode(toEmail,code)来传递目标邮箱和验证码。
最后:标记 当前用户的所有验证码均为已经使用过,并且把本次注册信息插入到emailCode表中。

3完成注册接口
再讲整个业务逻辑之前,我们要先引入一下AOP。在这个接口中我们用到了AOP。原因是因为:在用户登录这块,有大量的参数需要做非空校验以及其他校验,因此我们就顺势去做一个切面。
核心思路为:通过注解的方式标记方法,利用AOP思想创建一个切面,在切面中实现对标记方式中字段的填充,然后再运行原方法。这样就实现了在不改动原方法的前提下,实现了对代码的优化升级。

创建AOP切面:
第一步:
创建注解GlobalInerceptor来标识需要对哪些方法进行拦截参数校验
创建注解VerifyParam来标识需要对哪些参数进行校验以及校验什么内容
第二步:
开始写切面,完成切入点表达式来精准拦截方法,这里写了很多校验代码,我就不一一贴出来了。在这里插入图片描述
讲完了这个AOP之后,我们再回到业务层,看一看注册接口:
业务逻辑:
4.注册
rigster: Controller层先对传入的图片验证码进行了校验,通过之后再进入注册界面。需要注意的是:无论本次注册是否成功,这次用到的验证码都应该废弃,所以我们try finally语句。在finally部分使用session的removeAttribute方法来删除掉会话中的验证码。

然后到service层
这里先使用select查询当前邮箱是否已经被注册过。之后校验邮箱验证码,校验成功之后生成userInfo对象写入用户表中。需要注意的是我们不明文存储用户密码,而是存储MD5加密后的密码。在比较的时候,我们也是用用户输入的密码的MD5加密字符与数据库中用户真正密码的MD5加密字符进行对比。

5.完成了登录接口:
登录接口的Controller层还是和注册接口一样:判断验证码是否正确并且销毁验证吗,最后进入severl层执行真正逻辑。

然后转到Server层来看一看业务逻辑
整个登录就接口一共就五个板块:
1校验用户密码和用户状态
2更新用户最后一次登录时间
3将当前用户状态写入session
4判断当前用户是否为管理员
5更新当前用户已经使用内存的空间
这里在比较密码的时候,直接就用传递到后端的密码和数据库中MD5加密后的字符串 。这是因为我们的密码在前端就已经进行MD5加密了,发送的并不是明文密码,而是MD5加密。

6完成用户找回密码,退出登录接口
用户找回密码Controller层:resetPwd
逻辑为:先对参数进行校验。之后先判断传入的图片验证码是否正确,如果正确的话就进入server层来执行更新密码的逻辑。
另外无论验证码是否正确,都要删除本次验证码,避免被重复使用。

server层的逻辑也很简单:先判断用户的邮箱是否存在 ,如果不存在就抛出异常,如果存在就校验邮箱验证码,如果邮箱验证码校验正确,就更新数据库中的用户密码,实现用户忘记密码后的重置密码。
此外由于修改密码属于敏感操作,因此我们要把它封装成为一个事务,避免多线程之间的被打断情况,所以我们使用了@Transactional注解来封装当前方法为一个事务。

7修改密码:
先对用户输入的密码进行MD5加密,然后调用userInfoService的updateUserInfoByUserId方法来对用户密码进行修改。

8获取用户头像:
存储到本地
这段代码的逻辑为:构造存储用户头像的文件地址,如果不存在,先构造文件夹,如果存在,就读取当中头像。如果读取不到,就调用printNoDefaultImage方法来向头像问价存储地址中写入默认头像。

最终调用response.setContentType方法来告知响应内容为jpg格式的图片,同时调用readFile方法把当前头像写入响应相应输入流。这个方法可以可以复用.

9获取用户使用内存大小:
从Redis中去读用户使用内存大小。根据用户ID来查询用户在redis中的空间对象。如果没有查询到,就从数据库中去获取用户已经使用的空间大小。构建出一个新的spaceDTO来存储到Redis中。设置键的生存日期为1天。

10更新用户头像:
先从session中获取用户信息,然后构造文件夹地址。之后尝试把头像写入文件夹地址当中。如果写失败就打日志:“上传头像失败”。
最后清空一下之前的会话旧数据,把新数据先写入数据库中,再更新会话。

11登录拦截
主要就是判断当前会话中的有没有User信息。如果没有就进行拦截。
之后,我们设置拦截所有被GlobalInterceptor注释的方法, 在执行requestInterceptor方法之前,使用前置通知,执行interceptor方法,而在这个方法中,我们就做了登录校验。


文件部分
1创建完文件的表结构

2.完成根据条件分页查询用户文件接口
一句话概括查询条件为:需要查询指定分类的指定用户的指定类型文件。
所以我们先构建一个FileinfoQuery类来统一存储查询条件FileInfoQuery

controller层:LoaddataList
整个controller层的逻辑为:先构造好查询条件类,然后携带查询条件类进入sever层进行查询。

3.文件的分片上传
简单的讲一下文件分片上传的思路:当我们前端接收到一个大文件的时候,前端会把这个文件进行切分。告知后端一共切了几个分片以及当前上传的是哪一个分片。
接口层:uploadFile
服务层:1.后端接收目标文件的MD5值,凭借MD5值在数据库中查找是否已经存在当前文件,如果存在的话,不需要接收分片上传,直接从数据库中拷贝一份即可(秒传)。
2.如果当前文件不存在的话,就开始接收分片文件

**1.秒传业务:**如果【拷贝数据大小 + 用户当前已使用的大小 >
在这里插入图片描述

用户云盘总空间】 则抛出异常,不允许拷贝数据。
如果满足条件的话,就开始拷贝数据
在这里插入图片描述
2.分片上传业务
在这里插入图片描述
接下来开始保存分片文件

在这里插入图片描述
当我们接收到最后一个分片文件的时候,需要异步合并所有的分片文件。

在这里插入图片描述
这里还有一个需要注意的地方,因为这个方法涉及到了修改大量的数据库字段,所以我们添加了给这个方法添加了事务。
在这里插入图片描述
而我们又使用了这个方法,注册了一个同步器,表示在当前事务提交之后,异步执行合并文件的方法(transferFile) ,但是如果我们直接使用这个transferFile方法的话,异步是不会生效的,因为他没有交给Spring 管理。在这里插入图片描述
所以我们需要在FileInfoService中去依赖注入一个FileInfoService,将他交给Spring 容器进行管理。但如果只是这样简单写的话,就会造成循环依赖了:所以我们要给我们这个注解手动添加一个@Lazy注解,形成懒加载。在这里插入图片描述

因为文件的分片会很多,所以我们是把文件的大小,用户空间大小,用户已使用的空间大小放到Redis中去存储的。这样可以避免频繁的查询数据库字段。

Nginx和Spring boot自身就对上传和接收的文件大小限制有限制,因此如果我们的分片文件大小大于Nginx和Spring boot的本身限制,我们就要修改这两个的接收文件大小限制或者修改分片大小

4实现了合并文件的接口
上一篇我们讲到了大文件的分片上传,今天我们来讲一下大文件的合并。在这里插入图片描述
在接收分片文件的末尾,我们调用了一个异步方法来实现 分片文件 的 合并在这里插入图片描述
我们来看看这个方法(transferFile)的具体实现,这个方法可以分为三大模块:
1.先对分片文件进行合并:
2.针对视频文件进行切割:
3.保存文件信息:

而分片文件合并和针对视频文件切割的具体代码我们是自己封装了一个方法,我们来看一看:
合并分片文件:其实就是io不断的读取所有的分片文件,把所有的数据都存储到目标文件中。

切割视频文件: 这里我们使用的是ffmpeg来实现切割视频文件,其实代码不难,背后的逻辑就是:我们把一个大的视频文件切割成为小的ts文件和一个m3u8索引文件。
这样其实有很多的优点,例如:
流媒体播放支持: 将视频切割成小的 TS 文件和一个 M3U8 索引文件是为了实现流媒体播放。M3U8 索引文件是一种描述多媒体播放列表的格式,其中包含了各个 TS 文件的地址和播放顺序,可以通过这个索引文件实现视频的分段加载和播放,适应不同网络环境下的动态码率调整。
实现边下边播: 将视频文件切割成小的 TS 文件和一个 M3U8 索引文件可以实现边下边播的功能,即用户可以在下载视频的同时开始播放已下载的部分,而不必等待整个视频完全下载完毕。
方便管理和传输: 将视频切割成小的 TS 文件可以方便管理和传输。相比于一个大的视频文件,多个小的 TS 文件更容易分发和存储,也更容易实现断点续传和加速下载。

5生成视频缩略图createCover4Video

6生成图片缩略图 compressImage

7.文件的预览
文件的预览其实就是把文件从存储磁盘中取出来,发送给前端。而在这个过程中,我们要区分当前预览的是视频还是其他文件。因为:如果是文件的话,我们就需要先发送m3u8文件,然后根据需要选择发送哪一个ts视频文件。

我们来看看代码,一共可以分为两部分:预览视频的处理方案和预览其他文件的处理方案在这里插入图片描述

上面判断主要是针对用户分享做的鉴权。当我们鉴权做完了之后,就需要根据给出信息,拼接所需要的ts文件地址filePath。在这里插入图片描述
之后返回当前视频段所需要的filePath就好了。
如果是第一次发送视频文件,我们就要先发送m3u8文件,在这里插入图片描述
拼接m3u8地址给前端发送。
如果不是视频文件,那我们就是简单的读取全部文件,然后发回给前端

需要注意的是:
我们这里只是获取获取到了目标文件的地址,而我们需要把文件转为 字节流发送给前端,因此我们在返回的时候,还需要读取目标地址的文件。我们调用的就是下面这个方法。
readFile(通过文件输入输出流FileInputStream )

8创建目录:
新建文件夹的总体逻辑为:传递用户id和父级文件夹。然后在表中创建就可以
我们的文件和文件夹存放在一张表里面,就是之前说的file_info表。只不过我们添加了一个字段来标识当前列是文件还是文件夹
我们看看接口层:newFolder

接口层获取到用户信息之后,进入service层中,调用newFolder方法创建文件夹,整个newFolder可以分为三个板块
查询是否有同名文件夹。sql
构造文件夹类,插入数据库。在这一板块我们开始构造 文件夹类,并且将其插入到数据库中。
检查当前文件夹是否重名,如果重名就回滚 。第三部分我们再次构造分页查询条件,检查该名称的文件夹在表中是否只有一个。
其实这里我们连续检查了两次是否存在同名,就是为了防止并发条件下多线程会创建相同名称的文件夹

9文件重命名
文件重命名也是可以分为三部分:
检查当前文件是否存在以及文件要更新的名称是否就是文件当前名称。书写拦截部分
构造文件类来在表中更新文件名称。构造fileinfo类,在表中更新文件名
检查是否有重名文件。检查是否有重名文件出现,如果有出现就抛出异常回滚。

10移动文件
移动文件是指让文件或文件夹可以在不同的层级中移动。而移动文件的逻辑可以分为四部分

检查当前文件如何移动(自己不能移动到自己的文件夹里面),并且不能往已经被删除的文件夹中移动
检查当前待移动的文件夹或者文件是否与目标文件夹中的文件夹或文件命名重复。
如果发生重复的话,在重复的两个文件中选择一个重命名。如果rootFileInfo不为空,也就是说出现同名文件了,那么我们调用rename方法重新设置一个文件,然后将其更新到updateInfo类中。
更新待移动的文件夹或文件的父级文件夹。我们把待移动的文件或者文件夹的父级id更改为目标文件夹的id

rename是我们自己写的一个方法,如果两个文件名重复,他会把一个文件重命名为原始文件名加上一个下划线和一个长度为5的随机字符串。

11完成文件的下载接口

在这个项目中,我们是没有做下载文件的登录鉴权的。原因如下:
部分浏览器可能会在下载模块集成第三方下载插件,比如迅雷。如果是通过迅雷进行下载,迅雷是拿不到我们的session的,也就是说第三方插件下载没有办法做接口鉴权。如果我们给下载文件接口做下载鉴权,可能会导致部分用户一直被拦截(迅雷无法传递session)。
我们这个项目支持用户分享下载链接,也就是是说其他用户可以通过用户分享链接下载指定文件。如果我们要做登录鉴权,那么就必须想办法内下载连接中内置session id,存在安全性问题。

controller层:如果简单的传递文件的地址就可以下载的话,就会出现安全性问题:用户只要知道一个文件的下载地址,就可以直接下载这个文件。为了解决这个问题,我们把用户下载文件拆分成了两步:
前端传递文件id之后 后端查询该文件的地址,并且尝试构造下载URL。
后端解析下载URL,查询是否存在该文件,如果存在就下载。构造下载URL的时候,我们也不会把文件的真实地址传过去,虽然这样最简单,但是存在安全性问题,因为暴漏了文件的真实地址。
我们在构造下载链接的时候,是要进行鉴权的。@GlobalInterceptor注解我们定义的默认对登录进行鉴权。

serve层中构造下载URL链接一共分为两个板块:
查询当前文件是否存在。
构造一个50长度的字符串code,将其作为键,将真实文件地址和名称作为value存储到Redis中。这样我们就在不暴漏真实地址的前提下构造好了一个下载URL。

那么通过这样封装下载URL,我们在下载的时候只需要根据code从Redis中就能拿到文件的地址,从而进行下载。
而且我们的code是有时效的,我们在redis中设置了其存活时间是五分钟:
这样操作之后,即使恶意用户想要暴力破解code,五分钟破解50位数字+字符也是有很大的难度的,避免了用户的越权下载。
我们来看看真正的下载接口download
这里我们就规定不对登录进行鉴权(checkLogin=false),避开了下载的时候需要鉴权。

我们前端传递我们封装好的code,进入serve层进行解析下载,而serve层主要分为三个模块
第一模块:先从redis中根据code查询对应的文件地址和名称。
第二模块:从dowmloadFileDto中读取文件名称,并且对浏览器进行一系列设置(编码格式,响应格式)
第三模块:根据downloadFile中的文件地址,构造文件在服务器中的真实地址,调用readFile方法。 readFile方法,他是把文件内容写入HTTP响应的一个方法,这里我们粘贴一下方法。通过这个方法,我们就把目标文件写入到了HTTP响应当中,实现了文件的下载。

我们总结一下文件下载的逻辑:
为了避免第三方下载和分享资源链接无法通过登录鉴权,我们拆分了下载文件这个接口。
把下载文件接口拆分成为:生成下载URL和根据URL下载指定文件两个接口。并且在生成下载URL的时候进行登录鉴权。
为了避免URL过于简单,可以被用户暴力破解,我们构造了一个50长度的字符串,以这个字符串为Key,文件地址和文件名称为value存储在Redis中,并且设置有效期为五分钟。
当用户下载的时候,我们根据URL传递的字符串在Redis中读取指定的文件地址,进行下载。

这就是整个文件下载的逻辑。

12.完成文件的逻辑删除(移动到回收站)
其实这块的逻辑有点复杂,因为我们的云盘是允许层级目录的出现的,也就是说:我们删除一个目录的时候,是删除这个目录以及其所有子目录和子文件。
在这个过程中,其实是对于一个目录的所有子目录和子文件是需要递归的查找,那么我们先来看一看递归这块的逻辑:这段递归逻辑其实还是比较好理解的,我们一共传递四个参数(存放结果的List(fileIdList),用户id(userId),文件id(fileId),要搜索的文件状态(delFlag))
先把当前这个文件夹添加到fileIdList中,作为存储结果
我们构造一个查询条件,查询所有父级id是文件id,删除标记为delFlag的所有文件夹。将查询结果放入到FileInfoList中,再遍历所有的查询结果,对每一个文件夹在进行反复操作。

在了解递归查找一个文件的所有子文件和子文件夹之后,让我们回归整个方法看一看:
其实整个方法一共可以分为三部分:
获得要删除的所有文件id,构造查询条件查询相关所有文件
查询这些文件的所有子文件和子文件夹
把目录下的所有文件更新为删除,把目录更新为回收站

13完成文件的物理删除 (从数据库中删除)
在这块我们要先说明:这里的删除其实只是把文件的地址从数据库中删除,但真正的文件还在服务器上保存的。主要原因如下:
我们在做秒传的时候,通过MD5值判断是否数据库中已有这个文件,如果已经有这个文件,就复制一份地址给这个用户,不用再上传当前用户的文件了。
这就会导致一份文件被多个用户实际共享。
如果删除是在服务器层面的话,那么A的删除文件会导致B对该文件的不可用。当然我们也有自己的解决策略:

其实就是在删除的时候在数据库层面搜索一下当前文件是否有被多个人共享,如果没有被多个人共享,就可以放心从服务器层面删除。

但我们这个删除逻辑只是从数据库层面删除,并没有从服务器层面删除。

整体的逻辑也不难,一共可以分为三部分
搜索待删除目录的所有子文件和子文件夹
删除选中的所有文件
更新用户的空间。要解释一下这里的逻辑:因为我们已经删除了当前用户的部分文件,所以之前旧的空间大小数据已经不可以信了。所以我们要重新计算用户已使用空间大小。在删除的时候,我们也不需要进行每删除一次文件,就从用户已使用的空间大小中减去这个值这种做法。采用的做法是:统计数据库中所有id为当前用户的文件的大小,这就是用户当前使用的真实空间总大小。经过这样的操作,我们就是实现了真实的删除。

14完成回收站文件的恢复(移动到用户区)
其实这个后端接口的处理逻辑和文件的逻辑删除没有什么区别
逻辑删除是:找到当前目录的所有子文件和子文件夹,将其更新为“已删除”。
回收站恢复文件时:找到当前目录的所有子文件和子文件夹,将其更新为“正在使用”

我们还需要注意:在恢复文件的时候,还需要检查待恢复文件与正在使用的文件是否存在同名冲突,如果有就要改名。


1完成管理员部分的全部接口
这部分其实没什么可以讲的,全都是CURD。管理员的权限主要有以下:
设置用户可使用空间大小。
设置所有的用户状态
查询所有的文件
查询所有的用户
删除指定用户的指定文件
下载任意文件
在这里我就不写这部分的代码了。

2完成外端分享接口
用户的外端分享其实和我们的用户下载接口的基本逻辑一样。但需要注意的是:这里的外端分享一共要实现三个大功能:
允许所有用户直接通过分享链接进行下载
允许非当前文件分享的所有用户保存当前文件到自己的云盘中
要实现以下功能,我们需要写的核心接口有:

1.创建分享文件接口:
这个接口的核心功能就是:当用户选择分享哪一个文件的时候,这个接口就需要快速的存储这些信息。
创建一个Fileshare类,用来存储当前用户分享的文件。这里的code就是校验码,是由前端自己进行生成的。
我们在接口层构建完这个类之后,就需要把这个类交给serve层进行插入,再插入之前,我们还要根据用户要求构造过期时间,之后再进行插入。当我们构造好待分享的文件之后,下一步就是构造分享链接。

2.构造分享链接:
这里我们沿用之前文件下载的思路,还是构造一个code来存储文件id和文件名称。到时候按照用户的需求保存到云盘或者下载。所以这里其实调用的是createDownLoadUrl方法
当我们的构造分享链接构造好时候,我们从这个链接点击进去,应该是先让我们展示分享的文件基本信息和校验验证码,我们来看一看前端界面

3.根据shareid查询分享文件简要信息
接口层:调用了内部方法:getshareInfoCommon方法。
内部类方法:根据shareid读取关键信息。

4.校验验证码:
这个逻辑也很简单,其实就是接收用户输入的code,将其与我们之前存储在fileshare表中的对应shareid的code进行比较就可以。当校验验证码通过之后,说明用户目前操作合法,那么我们就给用户提供两个选择:
保存文件到自己的云盘
直接下载当前文件

5.1 保存文件到自己的云盘
其实就是查找文件,拷贝地址给当前用户。唯一比较烦的就是:如果有层级关系,要拷贝层级关系。
我们看一看serve层,整层逻辑分为三部分:
保存文件到自己的云盘
如果有命名冲突就重命名
更新当前用户云盘空间
到这里其实整个外端分享接口就已经写完了。还有一些其他的接口我没有在这里面写,都是一些 检查性的接口。比如检查分享是否过期,用户取消分享当前文件之类的接口,没有什么讲的价值。

6完成对回收站文件的优化:
当文件进入回收站之后,每隔三分钟我们就会对回收站中的已经过期的文件进行一次删除。这里的逻辑也很简单,其实就是构造一个查询条件:查询出所有标记为“已回收”的文件。之后根据用户分类构造出一个Map。直接传入Mapper层做删除就可以。

这里我们不要每检测到一个废弃文件就进行删除,而是进行批量删除。这样也是有一定的好处的。
性能优化: 批量删除可以减少数据库的交互次数,从而减少了系统开销,提高了删除操作的执行效率。这对于大量数据的删除操作尤其重要,可以显著减少删除操作的时间。
减少日志记录: 在数据库执行删除操作时,会生成相应的事务日志或者 redo 日志。批量删除可以减少生成的日志数量,降低了系统的存储压力,同时也减少了数据库的负载。
减少锁竞争: 在数据库中,执行删除操作时会对相应的记录进行加锁,防止其他事务对其进行修改。批量删除可以减少对表的锁定时间,减少了锁竞争,提高了数据库的并发性能。
事务控制: 批量删除可以更好地控制事务的边界,将多个删除操作组合在一个事务中执行,确保了数据的一致性和完整性。

所以在这个项目中,对文件的删除只是从数据库层面进行删除,并没有从服务器上进行物理删除。
其实我们可以在文件数据库中新增一个引用计数器。每当有一个用户存储这个文件的时候,引用计数器就加1,用户删除这个文件的话,引用计数器就减一。
最后写一个定时任务检查数据库中引用计数器为0的文件 ,定期对这些文件进行删除就好了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值