2021-11-10 谷粒学院技术总结-后台
项目描述:在线教育系统,分为前台网站系统和后台运营平台。前台用户系统包括:首页、课程、名师、问答、文章。后台管理系统包括:讲师管理、课程分类管理、课程管理、统计分析、Banner管理、订单管理、权限管理等功能。后端的主要技术架构是:SpringBoot + SpringCloud + MyBatis-Plus前端的架构是: Vue.js +element-ui其他涉及到的中间件包括Redis、阿里
目录
项目描述:
在线教育系统,分为前台网站系统和后台运营平台。前台用户系统包括:首页、课程、名师、问答、文章。后台管理系统包括:讲师管理、课程分类管理、课程管理、统计分析、Banner管理、订单管理、权限管理等功能。后端的主要技术架构是:SpringBoot + SpringCloud + MyBatis-Plus前端的架构是: Vue.js +element-ui其他涉及到的中间件包括Redis、阿里云OSS、阿里云视频点播、业务中使用了ECharts做图表展示,使用EasyExcel完成分类批量添加、注册分布式单点登录使用了JWT+token
一、主键生成策略
1、自动增长
优缺点如下:
最常见的方式。利用数据库,全数据库唯一。
优点:
1)简单,代码方便,性能可以接受。
2)数字ID天然排序,对分页或者需要排序的结果很有帮助。
缺点:
1)不同数据库语法和实现不同,数据库迁移的时候或多数据库版本支持的时候需要处理。
2)在单个数据库或读写分离或一主多从的情况下,只有一个主库可以生成。有单点故障的风险。
3)如果遇见多个系统需要合并或者涉及到数据迁移会相当痛苦。
4)分表分库的时候会有麻烦。
5)并非一定连续,类似MySQL,当生成新ID的事务回滚,那么后续的事务也不会再用这个ID了。这个在性能和连续性的折中。如果为了保证连续,必须要在事务结束后才能生成ID,那性能就会出现问题。
2、UUID
常见的方式。可以利用数据库也可以利用程序生成,一般来说全球唯一。UUID是由32个的16进制数字组成,所以每个UUID的长度是128位(16^32 = 2^128)。UUID作为一种广泛使用标准,有多个实现版本,影响它的因素包括时间、网卡MAC地址、自定义Namesapce等等。
优点:
1)简单,代码方便。
2)生成ID性能非常好,基本不会有性能问题。
3)全球唯一,在遇见数据迁移,系统数据合并,或者数据库变更等情况下,可以从容应对。
缺点:
1)没有排序,无法保证趋势递增。
2)UUID往往是使用字符串存储,查询的效率比较低。
3)存储空间比较大,如果是海量数据库,就需要考虑存储量的问题。
4)传输数据量大
5)不可读。
3、Redis
当使用数据库来生成ID性能不够要求的时候,我们可以尝试使用Redis来生成ID。这主要依赖于Redis是单线程的,所以也可以用生成全局唯一的ID。可以用Redis的原子操作 INCR和INCRBY来实现。
可以使用Redis集群来获取更高的吞吐量。假如一个集群中有5台Redis。可以初始化每台Redis的值分别是1,2,3,4,5,然后步长都是5。各个Redis生成的ID为:
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
这个,随便负载到哪个机确定好,未来很难做修改。但是3-5台服务器基本能够满足器上,都可以获得不同的ID。但是步长和初始值一定需要事先需要了。使用Redis集群也可以方式单点故障的问题。
另外,比较适合使用Redis来生成每天从0开始的流水号。比如订单号=日期+当日自增长号。可以每天在Redis中生成一个Key,使用INCR进行累加。
优点:
1)不依赖于数据库,灵活方便,且性能优于数据库。
2)数字ID天然排序,对分页或者需要排序的结果很有帮助。
缺点:
1)如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。
2)需要编码和配置的工作量比较大。
4、snowflake算法(雪花算法)
MP自带策略,在id上添加@TableId(type = ID_WORKER_STR/ID_WORKER)分别是string和long型主键,19位,采用雪花算法
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。
snowflake算法可以根据自身项目的需要进行一定的修改。比如估算未来的数据中心个数,每个数据中心的机器数以及统一毫秒可以能的并发数来调整在算法中所需要的bit数。
优点:
1)不依赖于数据库,灵活方便,且性能优于数据库。
2)ID按照时间在单机上是递增的。
缺点:
1)在单机上是递增的,但是由于涉及到分布式环境,每台机器上的时钟不可能完全同步,在算法上要解决时间回拨的问题。
二、项目分页
MyBatis Plus自带分页插件,只要简单的配置即可实现分页功能
1、创建配置类
/** * 分页插件 */ @Bean public PaginationInterceptor paginationInterceptor() { return new PaginationInterceptor(); }
配置分页插件,测试,最终通过page对象获取分页信息
@Test public void testSelectPage() { //传入当前页及每页记录数 Page<User> page = new Page<>(1,5); userMapper.selectPage(page, null); page.getRecords().forEach(System.out::println); System.out.println(page.getCurrent()); System.out.println(page.getPages()); System.out.println(page.getSize()); System.out.println(page.getTotal()); System.out.println(page.hasNext()); System.out.println(page.hasPrevious()); }
teacherService.page(pageTeacher,wrapper);在service层调用分页方法控制台sql语句打印:
SELECT id,name,age,email,create_time,update_time FROM user LIMIT 0,5
三、统一异常、日志处理
1、统一异常处理
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
//指定出现什么异常执行该方法
@ExceptionHandler(Exception.class)
@ResponseBody//为了返回数据
public R error(Exception e){
e.printStackTrace();
return R.error().message("执行了全局异常处理");
}
//指定出现什么异常执行该方法
@ExceptionHandler(ArithmeticException .class)
@ResponseBody//为了返回数据
public R error(ArithmeticException e){
e.printStackTrace();
return R.error().message("执行了ArithmeticException 异常处理");
}
//指定出现什么异常执行该方法
@ExceptionHandler(GuliException.class)
@ResponseBody//为了返回数据
public R error(GuliException e){
e.printStackTrace();
log.error(e.getMessage());
return R.error().code(e.getCode()).message(e.getMsg());
}
}
也可以自定义异常
以上三种方式可以实现自定义异常
@AllArgsConstructor
@Data
@NoArgsConstructor
public class GuliException extends RuntimeException{
private Integer code;
private String msg;
}
2、统一日志处理
1、配置日志级别
日志记录器(Logger)的行为是分等级的。如下表所示:
分为:OFF、FATAL、ERROR、WARN、INFO、DEBUG、ALL
默认情况下,spring boot从控制台打印出来的日志级别只有INFO及以上级别,在application.properties中可以配置日志级别,输出到控制台
# 设置日志级别
logging.level.root=WARN
2、Logback日志
spring boot内部使用Logback作为日志实现的框架。
Logback和log4j非常相似,如果你对log4j很熟悉,那对logback很快就会得心应手。
resources 中创建 logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="10 seconds">
<!-- 日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,如果设置为WARN,则低于WARN的信息都不会输出 -->
<!-- scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true -->
<!-- scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。当scan为true时,此属性生效。默认的时间间隔为1分钟。 -->
<!-- debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。 -->
<contextName>logback</contextName>
<!-- name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义变量后,可以使“${}”来使用变量。 -->
<property name="log.path" value="D:/guli_log/edu" />
<!-- 彩色日志 -->
<!-- 配置格式变量:CONSOLE_LOG_PATTERN 彩色日志格式 -->
<!-- magenta:洋红 -->
<!-- boldMagenta:粗红-->
<!-- cyan:青色 -->
<!-- white:白色 -->
<!-- magenta:洋红 -->
<property name="CONSOLE_LOG_PATTERN"
value="%yellow(%date{yyyy-MM-dd HH:mm:ss}) |%highlight(%-5level) |%blue(%thread) |%blue(%file:%line) |%green(%logger) |%cyan(%msg%n)"/>
<!--输出到控制台-->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
<!-- 例如:如果此处配置了INFO级别,则后面其他位置即使配置了DEBUG级别的日志,也不会被输出 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<encoder>
<Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
<!-- 设置字符集 -->
<charset>UTF-8</charset>
</encoder>
</appender>
<!--输出到文件-->
<!-- 时间滚动输出 level为 INFO 日志 -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>${log.path}/log_info.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 每天日志归档路径以及格式 -->
<fileNamePattern>${log.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!--日志文件保留天数-->
<maxHistory>15</maxHistory>
</rollingPolicy>
<!-- 此日志文件只记录info级别的 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- 时间滚动输出 level为 WARN 日志 -->
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>${log.path}/log_warn.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset> <!-- 此处设置字符集 -->
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log.path}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!--日志文件保留天数-->
<maxHistory>15</maxHistory>
</rollingPolicy>
<!-- 此日志文件只记录warn级别的 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>warn</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- 时间滚动输出 level为 ERROR 日志 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>${log.path}/log_error.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset> <!-- 此处设置字符集 -->
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log.path}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!--日志文件保留天数-->
<maxHistory>15</maxHistory>
</rollingPolicy>
<!-- 此日志文件只记录ERROR级别的 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!--
<logger>用来设置某一个包或者具体的某一个类的日志打印级别、以及指定<appender>。
<logger>仅有一个name属性,
一个可选的level和一个可选的addtivity属性。
name:用来指定受此logger约束的某一个包或者具体的某一个类。
level:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,
如果未设置此属性,那么当前logger将会继承上级的级别。
-->
<!--
使用mybatis的时候,sql语句是debug下才会打印,而这里我们只配置了info,所以想要查看sql语句的话,有以下两种操作:
第一种把<root level="INFO">改成<root level="DEBUG">这样就会打印sql,不过这样日志那边会出现很多其他消息
第二种就是单独给mapper下目录配置DEBUG模式,代码如下,这样配置sql语句会打印,其他还是正常DEBUG级别:
-->
<!--开发环境:打印控制台-->
<springProfile name="dev">
<!--可以输出项目中的debug日志,包括mybatis的sql日志-->
<logger name="com.guli" level="INFO" />
<!--
root节点是必选节点,用来指定最基础的日志输出级别,只有一个level属性
level:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,默认是DEBUG
可以包含零个或多个appender元素。
-->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="WARN_FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
</springProfile>
<!--生产环境:输出到文件-->
<springProfile name="pro">
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="DEBUG_FILE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="ERROR_FILE" />
<appender-ref ref="WARN_FILE" />
</root>
</springProfile>
</configuration>
3、将错误日志输出到文件中
四、Vue
1、Vue.js 是什么
Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的渐进式框架。
Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。另一方面,当与现代化的工具链以及各种支持类库结合使用时,Vue 也完全能够为复杂的单页应用提供驱动。
2、Vue生命周期
我们常用的有created()和mounted()
页面渲染之前和页面渲染之后
3、Vue的路由
Vue.js 路由允许我们通过不同的 URL 访问不同的内容。
通过 Vue.js 可以实现多视图的单页Web应用(single page web application,SPA)。
Vue.js 路由需要载入 vue-router 库
<script src="vue.min.js"></script>
<script src="vue-router.min.js"></script>
1、编写html
<div id="app">
<h1>Hello App!</h1>
<p>
<!-- 使用 router-link 组件来导航. -->
<!-- 通过传入 `to` 属性指定链接. -->
<!-- <router-link> 默认会被渲染成一个 `<a>` 标签 -->
<router-link to="/">首页</router-link>
<router-link to="/student">会员管理</router-link>
<router-link to="/teacher">讲师管理</router-link>
</p>
<!-- 路由出口 -->
<!-- 路由匹配到的组件将渲染在这里 -->
<router-view></router-view>
</div>
2、编写js
<script>
// 1. 定义(路由)组件。
// 可以从其他文件 import 进来
const Welcome = { template: '<div>欢迎</div>' }
const Student = { template: '<div>student list</div>' }
const Teacher = { template: '<div>teacher list</div>' }
// 2. 定义路由
// 每个路由应该映射一个组件。
const routes = [
{ path: '/', redirect: '/welcome' }, //设置默认指向的路径
{ path: '/welcome', component: Welcome },
{ path: '/student', component: Student },
{ path: '/teacher', component: Teacher }
]
// 3. 创建 router 实例,然后传 `routes` 配置
const router = new VueRouter({
routes // (缩写)相当于 routes: routes
})
// 4. 创建和挂载根实例。
// 从而让整个应用都有路由功能
const app = new Vue({
el: '#app',
router
})
// 现在,应用已经启动了!
</script>
4、axios
axios是独立于vue的一个项目,基于promise用于浏览器和node.js的http客户端
- 在浏览器中可以帮助我们完成 ajax请求的发送
- 在node.js中可以向远程接口发送请求
五、阿里云OSS讲师头像上传
@Service
public class OssServiceImpl implements OssService {
/**
* 文件上传的方法
* @param file 要上传的文件
* @return 返回oss中文件路径
*/
@Override
public String uploadFileAvatar(MultipartFile file) {
//通过工具类获取阿里云存储相关常量
String endPoint = ConstantPropertiesUtil.END_POINT;
String accessKeyId = ConstantPropertiesUtil.ACCESS_KEY_ID;
String accessKeySecret = ConstantPropertiesUtil.ACCESS_KEY_SECRET;
String bucketName = ConstantPropertiesUtil.BUCKET_NAME;
//上传成功后存储在阿里云中的地址
String uploadUrl = null;
//在文件名中添加uuid以防止大量图片重名问题。
String uuid = UUID.randomUUID().toString().replace("-", "");
try {
//判断oss实例是否存在:如果不存在则创建,如果存在则获取
OSSClient ossClient = new OSSClient(endPoint, accessKeyId, accessKeySecret);
if (!ossClient.doesBucketExist(bucketName)) {
//创建bucket
ossClient.createBucket(bucketName);
//设置oss实例的访问权限:公共读
ossClient.setBucketAcl(bucketName, CannedAccessControlList.PublicRead);
}
//获取上传文件流
InputStream inputStream = file.getInputStream();
//优化1:文件名加上uuid
String fileUrl = uuid + file.getOriginalFilename();
//优化2:把文件按照日期分类存储
//2021/11/11/avatar.jpb
//获取当前日期,借助joda-time工具类来简单实现转换日期为指定格式
String datePath = new DateTime().toString("yyyy/MM/dd");
//拼接
fileUrl = datePath + "/" + fileUrl;
//文件上传至阿里云
/*
* 第一个参数 Bucket名称
* 第二个参数 上传到oss的路径和文件名
* 第三个参数 上传文件输入流
*/
ossClient.putObject(bucketName, fileUrl, inputStream);
// 关闭OSSClient。
ossClient.shutdown();
//获取url地址
uploadUrl = "http://" + bucketName + "." + endPoint + "/" + fileUrl;
} catch (IOException e) {
throw new GuliException();
}
return uploadUrl;
}
}
六、使用EasyExcel实现读文件操作
1、简介
Java解析、生成Excel比较有名的框架有Apache poi、jxl。但他们都存在一个严重的问题就是非常的耗内存,poi有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。easyexcel重写了poi对07版Excel的解析,一个3M的excel用POI sax解析依然需要100M左右内存,改用easyexcel可以降低到几M,并且再大的excel也不会出现内存溢出;03版依赖POI的sax模式,在上层做了模型转换的封装,让使用者更加简单方便
2、EasyExcel特点
- Java领域解析、生成Excel比较有名的框架有Apache poi、jxl等。但他们都存在一个严重的问题就是非常的耗内存。如果你的系统并发量不大的话可能还行,但是一旦并发上来后一定会OOM或者JVM频繁的full gc。
- EasyExcel是阿里巴巴开源的一个excel处理框架,以使用简单、节省内存著称。EasyExcel能大大减少占用内存的主要原因是在解析Excel时没有将文件数据一次性全部加载到内存中,而是从磁盘上一行行读取数据,逐个解析。
- EasyExcel采用一行一行的解析模式,并将一行的解析结果以观察者的模式通知处理(AnalysisEventListener)
3、EasyExcel写
先引入依赖,由于底层是对poi进行重写,因此使用时还需要引入poi的依赖
<!-- https://mvnrepository.com/artifact/com.alibaba/easyexcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>2.1.1</version>
</dependency>
前端代码
<el-form-item label="选择Excel">
<el-upload
ref="upload"
:auto-upload="false"
:on-success="fileUploadSuccess"
:on-error="fileUploadError"
:disabled="importBtnDisabled"
:limit="1"
:action="BASE_API + '/eduservice/subject/addSubject'"
name="file"
accept="application/vnd.ms-excel"
>
<el-button slot="trigger" size="small" type="primary"
>选取文件</el-button
>
<el-button
:loading="loading"
style="margin-left: 10px"
size="small"
type="success"
@click="submitUpload"
>上传到服务器</el-button
>
</el-upload>
</el-form-item>
脚本
//点击上传到接口中
submitUpload() {
this.importBtnDisabled = true
this.loading = true
//js:document.getElementById("upload").submit()
this.$refs.upload.submit()
},
//上传成功
fileUploadSuccess() {
//提示信息
this.loading = false
this.$message({
type: 'success',
message: '添加课程分类成功'
})
//跳转到课程分类的列表中
this.$router.push({path: '/subject/list'})
},
后端接口代码
/**
* 添加课程方法
* @param file 要添加的excel文件
*/
@PostMapping("addSubject")
private R addSubject(MultipartFile file){
subjectService.saveSubject(file,subjectService);
return R.ok();
}
接口中传入一个subjectService操作数据库,因为saveSubject方法中需要用到监听器,而监听器并没有交给Spring管理,因此不能注入subjectService,需要我们传入
接口实现类
//添加课程
@Override
public void saveSubject(MultipartFile file,EduSubjectService subjectService) {
try {
InputStream is = file.getInputStream();
EasyExcel.read(is, SubjectData.class,new SubjectExcelListener(subjectService)).sheet().doRead();
} catch (IOException e) {
e.printStackTrace();
}
}
监听器SubjectExcelListener,继承阿里巴巴easyexcel的AnalysisEventListener类,重写里面方法
public class SubjectExcelListener extends AnalysisEventListener<SubjectData> {
//因为SubjectExcelListener不能交给spring管理,需要自己new,不能注入对象
//不能实现数据库操作,利用有参构造器传入,EduSubjectService,以便操作
public EduSubjectService subjectService;
public SubjectExcelListener(){}
//利用构造器接收接口传来的service类操作数据库。
public SubjectExcelListener(EduSubjectService subjectService){
this.subjectService = subjectService;
}
@Override
public void invoke(SubjectData subjectData, AnalysisContext analysisContext) {
if (subjectData == null) {
throw new GuliException(20001,"表中数据为空");
}
//一行一行读取数据,每次读取两个值,第一个值一级分类,第二个二级分类
//判断一级分类是否为空,为空则不存在,添加
EduSubject existOneSubject = this.existOneSubject(subjectService, subjectData.getOneSubjectName());
if (existOneSubject == null){
existOneSubject = new EduSubject();
existOneSubject.setParentId("0");
existOneSubject.setTitle(subjectData.getOneSubjectName());
subjectService.save(existOneSubject);
}
//判断二级分类是否为空,为空则不存在,添加
// String pid = existOneSubject.getId();
EduSubject existTwoSubject = this.existTwoSubject(subjectService,subjectData.getTwoSubjectName(),existOneSubject.getId());
if (existTwoSubject == null){
existTwoSubject = new EduSubject();
existTwoSubject.setParentId(existOneSubject.getId());
existTwoSubject.setTitle(subjectData.getTwoSubjectName());
subjectService.save(existTwoSubject);
}
}
//判断一级分类是否存在
private EduSubject existOneSubject(EduSubjectService subjectService,String name){
QueryWrapper<EduSubject> wrapper = new QueryWrapper<>();
wrapper.eq("title",name);
wrapper.eq("parent_id",0);
return subjectService.getOne(wrapper);
}
//判断二级分类是否存在
private EduSubject existTwoSubject(EduSubjectService subjectService,String name,String pid){
QueryWrapper<EduSubject> wrapper = new QueryWrapper<>();
wrapper.eq("title",name);
wrapper.eq("parent_id",pid);
return subjectService.getOne(wrapper);
}
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
}
}
此处问题如下
比如当我们第一次上传一级分类Vue
改为小写vue上传,并不会改变,这一点需要明确。
因为,在判断二级分类是否存在时,方法中传入了一级分类的id,自然,得到的方法返回对象为空,因为同父id下的二级分类title并不与传递过来的相同,所以反回了空值。
六、课程分类列表模块树形控件
1、展示课程分类列表
接口如下:
/**
* 课程分类列表(树形)
* @return 查询到的一级二级分类对象
*/
@GetMapping("getAllSubject")
public R getAllSubject(){
List<OneSubject> list = subjectService.getAllOneTwoSubject();
return R.ok().data("list",list);
}
实现方法如下:
//课程分类
@Override
public List<OneSubject> getAllOneTwoSubject() {
QueryWrapper<EduSubject> wrapperOne = new QueryWrapper<>();
QueryWrapper<EduSubject> wrapperTwo = new QueryWrapper<>();
//1 查出所有的一级分类
wrapperOne.eq("parent_id", '0');
List<EduSubject> oneSubjectList = this.list(wrapperOne);
//2 查出所有的二级分类
wrapperTwo.ne("parent_id", '0');
List<EduSubject> twoSubjectList = this.list(wrapperTwo);
//创建一个集合封装数据
List<OneSubject> finalSubjectList = new ArrayList<>();
//3 封装一级分类
//遍历得到的一级分类集合,然后抽取OneSubject对象封装
for (EduSubject eduSubject : oneSubjectList) {
OneSubject oneSubject = new OneSubject();
// oneSubject.setId(eduSubject.getId());
// oneSubject.setTitle(eduSubject.getTitle());
//上面注释的方法太过复杂,利用spring中的BeanUtils工具类进行优化
//方法作用为将eduSubject中属性自动复制到oneSubject
BeanUtils.copyProperties(eduSubject, oneSubject);
finalSubjectList.add(oneSubject);
}
//4 封装二级分类
for (OneSubject oneSubject : finalSubjectList) {
List<TwoSubject> twoList = new ArrayList<>();
for (EduSubject subject : twoSubjectList) {
if (subject.getParentId().equals(oneSubject.getId())) {
TwoSubject twoSubject = new TwoSubject();
BeanUtils.copyProperties(subject, twoSubject);
twoList.add(twoSubject);
}
}
oneSubject.setChildren(twoList);
}
return finalSubjectList;
}
思路:
- 用eq和nq方法,查找父id为0及不为0的两个集合,得到了所有的一级分类集合和二级分类集合
- 我们自定义的一级分类和二级分类vo用于封装要显示的信息:id和title,一级分类vo中有二级分类集合
- 建立一个以一级分类为泛型的集合finalList用于最终封装
- 对一级分类集合进行遍历,将属性拷贝进一级分类vo,然后添加到finalList
- 对finalList进行遍历,即对每一个一级分类进行遍历,内部对二级分类进行遍历,把每一个二级分类的父id等于一级分类id的对象拷贝进二级分类vo,拷贝完添加进这个一级分类的chirldren集合。
- 整个循环结束后,对应的一级分类和二级分类封装完成,返回这个finalList。
2、对课程分类列表进行搜索
该搜索由前端实现,对搜索文本双向绑定
<el-input
v-model="filterText"
placeholder="请输入过滤条件..."
style="margin-bottom: 30px"
/>
对该文本框的值的变化进行监听
watch: {
filterText(val) {
this.$refs.tree2.filter(val);
},
},
当值变化时,会传入tree2的过滤器方法
<el-tree
ref="tree2"
:data="data2"
:props="defaultProps"
:filter-node-method="filterNode"
class="filter-tree"
default-expand-all
/>
方法如下:
filterNode(value, data) {
if (!value) return true;
let lowerVal = value.toLowerCase();
return data.title.toLowerCase().indexOf(lowerVal) !== -1;
}
如果文本框为空,全部显示
data代表树形控件的各个节点,否则就将文本值转为小写,标题转为小写,显示各个节点标题包含文本值的节点
七、课程信息管理
一共有三个步骤,填写课程基本信息-》创建课程大纲-》最终发布
1、填写课程基本信息
有两个关键点
实现思路:
在created()中先获取所有的课程分类和讲师信息。
1、课程分类
使用v-for输出一级分类,绑定一个value,即分类的id
<!-- 一级分类 -->
<el-select
v-model="courseInfo.subjectParentId"
placeholder="一级分类"
@change="change"
>
<el-option
v-for="subject in subjectOneList"
:key="subject.id"
:label="subject.title"
:value="subject.id"
/>
</el-select>
绑定change事件,参数传入绑定的value,遍历一级分类,如果传入的课程分类id等于这个一级分类id,那么将这个一级分类的子集合,也就是二级分类集合赋给初始化的二级分类集合。
//点击选择一级分类后触发change事件获取二级分类
change(value) {
//one:遍历所有一级分类得到
this.subjectOneList.forEach((one) => {
//遍历所有一级分类的子元素
console.log(one.children);
if (value === one.id) {
this.subjectTwoList = one.children;
}
});
this.courseInfo.subjectId = "";
}
二级分类集合显示
<!-- 二级分类 -->
<el-select v-model="courseInfo.subjectId" placeholder="二级分类">
<el-option
v-for="subject in subjectTwoList"
:key="subject.id"
:label="subject.title"
:value="subject.id"
/>
</el-select>
2、创建课程大纲
这一步的路由跳转时由课程基本信息页面携带课程id跳转,created()方法会根据课程id查询所有的章节和小节信息显示,添加时查询到的信息为空,修改时传递查询到的信息回显。
1、编辑删除操作
每个章节或者小节都对应绑定相应的id,编辑删除方法根据这个id进行操作
2、阿里云视频点播服务上传小节视频
前端代码
<el-form-item label="上传视频">
<el-upload
:on-success="handleVodUploadSuccess"
:on-remove="handleVodRemove"
:before-remove="beforeVodRemove"
:on-exceed="handleUploadExceed"
:file-list="fileList"
:action="BASE_API + '/eduvideo/vod/uploadAliyunVideo'"
:limit="1"
class="upload-demo"
>
<el-button size="small" type="primary">上传视频</el-button>
<el-tooltip placement="right-end">
<div slot="content">
最大支持1G,<br />
支持3GP、ASF、AVI、DAT、DV、FLV、F4V、<br />
GIF、M2T、M4V、MJ2、MJPEG、MKV、MOV、MP4、<br />
MPE、MPG、MPEG、MTS、OGG、QT、RM、RMVB、<br />
SWF、TS、VOB、WMV、WEBM 等视频格式上传
</div>
<i class="el-icon-question" />
</el-tooltip>
</el-upload>
</el-form-item>
选中要上传的视频后发送如下请求,执行了action中的请求,上传到阿里云
上传流程
/**
* 上传视频到阿里云
* 流式上传接口
*/
@Override
public String uploadVideoAly(MultipartFile file) {
String accessKeyId = VodUtils.ACCESS_KEY_ID;
String accessKeySecret = VodUtils.ACCESS_KEY_SECRET;
//上传文件原始名
String fileName = file.getOriginalFilename();
//上传后文件名
//先得到.的位置,然后用subString截取
String title = "";
if (fileName != null){
//利用去掉文件后缀名做上传的文件名称
title = fileName.substring(0,fileName.lastIndexOf('.'));
}
InputStream inputStream = null;
try {
inputStream = file.getInputStream();
} catch (IOException e) {
e.printStackTrace();
return null;
}
//创建一个上传流请求,传入密钥和文件标题,文件名称,输入流
UploadStreamRequest request = new UploadStreamRequest(accessKeyId, accessKeySecret, title, fileName, inputStream);
/* 点播服务接入点 */
request.setApiRegionId("cn-shanghai");
//创建一个上传实例
UploadVideoImpl uploader = new UploadVideoImpl();
//执行上传请求,返回值是一个上传流响应
UploadStreamResponse response = uploader.uploadStream(request);
System.out.print("RequestId=" + response.getRequestId() + "\n"); //请求视频点播服务的请求ID
String videoId = "";
if (response.isSuccess()) {
//通过响应可以拿到阿里云文件的存储id返回
videoId = response.getVideoId();
} else { //如果设置回调URL无效,不影响视频上传,可以返回VideoId同时会返回错误码。其他情况上传失败时,VideoId为空,此时需要根据返回错误码分析具体错误原因
videoId = response.getVideoId();
}
return videoId;
}
视频上传成功后执行成功回调,绑定文件名和视频id
//成功回调
//file参数是这个文件对象
handleVodUploadSuccess(response, file, fileList) {
this.video.videoSourceId = response.data.videoId;
this.video.videoOriginalName = file.name
},
3、发布课程
这一系列三步操作核心都是路由跳转时携带课程id,操作这个id对数据库进行修改
这个回显通过课程id,得到我们自定义vo的对象进行回显
@Data
public class CoursePublishVo {
private String id;
private String title;
private String cover;
private Integer lessonNum;
private String subjectLevelOne;
private String subjectLevelTwo;
private String teacherName;
private String price;
}
问题:当用户未发布课程即退出,那么会发生什么
答:我们三个步骤每次进入下一个步骤,实际都保存了对应的信息,第一次保存了基本信息,第二次保存了章节小节信息,如果退出,课程列表仍然可以查到,只不过有一个关键状态:课程发布状态字段为未发布,而发布页即是修改这个状态的操作
4、在课程列表删除课程信息
这一操作的思路是
/**
* 根据id删除课程以及下面的信息
*/
@Override
public void removeCourse(String courseId) {
//1 根据课程id删除课程里面的小节
videoService.removeVideoByCourseId(courseId);
//2 根据课程id删除课程里面的章节
chapterService.removeChapterByCourseId(courseId);
//3 根据课程id删除课程里面的描述
courseDescriptionService.removeById(courseId);
//4 根据课程id删除课程本身(逻辑删除)
this.removeById(courseId);
}
删除小节
/*
* 根据课程id删除小节
*/
@Override
public void removeVideoByCourseId(String courseId) {
// 删除小节时一起删除视频
//在video表中仅查出video_source_id字段即可
QueryWrapper<EduVideo> videoQueryWrapper = new QueryWrapper<>();
videoQueryWrapper.eq("course_id",courseId);
videoQueryWrapper.select("video_source_id");
//根据课程id,查出对应的视频id的集合
List<EduVideo> eduVideos = this.list(videoQueryWrapper);
List<String> videoIdLists = new ArrayList<>();
for (EduVideo eduVideo : eduVideos) {
if (eduVideo != null){
videoIdLists.add(eduVideo.getVideoSourceId());
}
}
//如果视频id集合不为空,那么远程调用视频模块的根据视频id批量删除视频方法
if (videoIdLists.size() > 0){
vodClient.delVodBatch(videoIdLists);
}
//删除小节信息
QueryWrapper<EduVideo> wrapper = new QueryWrapper<>();
wrapper.eq("course_id",courseId);
this.remove(wrapper);
}
批量删除方法如下
/**
* 根据视频id批量删除多个阿里云视频
*/
@Override
public void removeVideos(List<String> videoIdList) {
//此方法为自己封装,内含有地区,id及key
DefaultAcsClient client = InitVodClient.initVodClient();
DeleteVideoRequest request = new DeleteVideoRequest();
String videoIds = StringUtils.join(videoIdList.toArray(), ",");
//支持传入多个视频ID,多个用逗号分隔
request.setVideoIds(videoIds);
try {
client.getAcsResponse(request);
} catch (Exception e) {
System.out.print("ErrorMessage = " + e.getLocalizedMessage());
throw new GuliException(20001,"删除视频失败");
}
}
这个方法是远程调用
八、统计分析模块
1、生成数据
选择一个日期,传入接口,接口统计注册、登录、新增课程以及视频播放数。
@Override
public void registerCount(String date) {
// 先删除相同日期的统计记录
QueryWrapper<StatisticsDaily> wrapper = new QueryWrapper<>();
wrapper.eq("date_calculated",date);
this.remove(wrapper);
//远程调用获取注册人数
R r = ucenterClient.countRegister(date);
Integer countReg = (Integer) r.getData().get("countReg");
//将数据添加到数据库
StatisticsDaily sta = new StatisticsDaily();
sta.setRegisterNum(countReg);//注册人数
sta.setDateCalculated(date);//统计日期
//TODO 这三个数据统计暂未实现,用随机数代替
sta.setCourseNum(RandomUtils.nextInt(100, 200));//新增课程数
sta.setLoginNum(RandomUtils.nextInt(100, 200));//登录统计
sta.setVideoViewNum(RandomUtils.nextInt(100, 200));//视频播放
this.save(sta);
}
- 先删除要统计日期的统计数,因为我们要确保拿到最新的数据
- 统计完成后将这些数据保存到数据库中,进行更新
- 注意:如果直接保存,那么相当于插入,这样我们如果之前统计的有数据的话就会出现多个相同日期的行,这里远程调用了用户模块,使用count对数据库进行统计
2、Echarts图表
简介
1、前端
该图标工具采用折线图类型,需要执行图的x轴和y轴,x为日期,y为人数
// x轴是类目轴(离散数据),必须通过data设置类目数据
xAxis: {
type: "category",
data: this.xData,
},
// y轴是数据轴(连续数据)
yAxis: {
type: "value",
},
// 系列列表。每个系列通过 type 决定自己的图表类型
series: [
{
// 系列中的数据内容数组
data: this.yData,
// 折线图
type: "line",
},
2、接口
@GetMapping("showData/{type}/{begin}/{end}")
public R showData(@PathVariable String type,
@PathVariable String begin,
@PathVariable String end){
Map<String,Object> map = staService.showData(type,begin,end);
return R.ok().data(map);
}
实现类
/**
* 根据筛选条件展示统计图表信息
*/
@Override
public Map<String, Object> showData(String type, String begin, String end) {
//根据条件查询数据
QueryWrapper<StatisticsDaily> wrapper = new QueryWrapper<>();
//拼接日期的筛选条件
wrapper.between("date_calculated",begin,end);
//参数是可变形参,查出统计日期和统计类型两列
wrapper.select("date_calculated",type);
List<StatisticsDaily> staList = this.list(wrapper);
//因为返回有两部分数据日期和时间对应的数量
//前端要求json数组结构,对应后端是list集合
//创建两个list集合,一个日期list,一个数量list
List<String> dateList = new ArrayList<>();
List<Integer> countList = new ArrayList<>();
for (StatisticsDaily sta : staList) {
dateList.add(sta.getDateCalculated());
//判断查询的是哪个类型的数剧
switch(type){
case "login_num": countList.add(sta.getLoginNum());
break;
case "register_num": countList.add(sta.getRegisterNum());
break;
case "video_view_num": countList.add(sta.getVideoViewNum());
break;
case "course_num": countList.add(sta.getCourseNum());
break;
default:
break;
}
}
Map<String,Object> map = new HashMap<>();
map.put("dateList",dateList);
map.put("countList",countList);
return map;
}
以map形式返回,封装日期和人数进行显示
更多推荐
所有评论(0)