序言 由于在开发该系统时,并未有书写开发文档的经验,而仅仅是记录了每一天的开发日志,但开发日志多达 21 篇,因此把所有开发日志放在博客当中是影响读者观感的,因此本篇文章仅仅是作为总结,如果有具体需要,请联系我,或者访问我的 GitHub 仓库。
项目简介 在线教育平台采用 B2C 模式,Spring Cloud 搭建整个微服务架构,后台采用 Spring Boot+MySQL+MyBatis-Plus+Redis,并且结合 Vue 前端框架,采用 Nuxt 服务端渲染技术来优化前端页面,运用阿里云视频点播技术。在管理系统的后台中,运用 Spring Security 进行用户认证和授权,以确保对不同用户权限的细致划分。在用户的登录系统方面,则采纳了手机验证码注册和登录方式,并运用 JWT 生成 Token 以实现便捷的单点登录。此外,用户通过微信支付来进行课程购买。
技术栈 后端
Spring Boot
Spring Cloud
MySQL
MyBatis-Plus
Redis
Spring Security
EasyExcel
前端
Vue
Nuxt
ElementUI
Axios
ECharts
后台管理系统 在线教育平台后台管理系统的前端使用的是 vue-admin-template 模板
讲师管理 对讲师进行增删改查操作,后端集成了阿里云 OSS,用于讲师头像的上传。开发中值得一提的: vue-router 导航切换 时,如果两个路由都渲染同个组件,组件会重(chong)用, 组件的生命周期钩子(created)不会再被调用, 使得组件的一些数据无法根据 path 的改变得到更新 因此:
我们可以在 watch 中监听路由的变化,当路由变化时,重新调用 created 中的内容;
在 init 方法中我们判断路由的变化,如果是修改路由,则从 api 获取表单数据。 如果是新增路由,则重新初始化表单数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 watch : { $route(to, from ) { this .init () } }, created ( ) { this .init () }, methods : { init ( ) { if (this .$route .params && this .$route .params .id ) { const id = this .$route .params .id this .getInfo (id) } else { this .teacher = {} } },
课程分类管理 前端上传课程 Excel 表格,后端通过 EasyExcel 来处理表格并将其持久化存储于数据库中。
课程管理 可以查看课程详细信息并管理课程,如果是发布课程需要进行三个步骤,分别是“填写课程基本信息”、“创建课程大纲”、“最终发布”,需要按照该执行顺序去操作才能完整发布课程。值得一提的是课程视频上传的实现
引入依赖 引入依赖存在问题 mvn 需要配置环境变量,这样才能在命令行中使用 mvn 命令 上传视频 参考官网压缩包里面的 sample 示例代码改造
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public static void main (String[] args) { String accessKeyId = "" ; String accessKeySecret = "" ; String title = "6 - How Does Project Submission Work - upload by sdk" ; String fileName = "E:\\6 - What If I Want to Move Faster.mp4" ; UploadVideoRequest request = new UploadVideoRequest (accessKeyId, accessKeySecret, title, fileName); request.setPartSize(2 * 1024 * 1024L ); request.setTaskNum(1 ); UploadVideoImpl uploader = new UploadVideoImpl (); UploadVideoResponse response = uploader.uploadVideo(request); if (response.isSuccess()) { System.out.print("VideoId=" + response.getVideoId() + "\n" ); } else { System.out.print("VideoId=" + response.getVideoId() + "\n" ); System.out.print("ErrorCode=" + response.getCode() + "\n" ); System.out.print("ErrorMessage=" + response.getMessage() + "\n" ); } }
配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 server: port: 8082 spring: application: name: service-vod profiles: active: dev servlet: multipart: max-file-size: 1024MB max-request-size: 1024MB aliyun: vod: file: keyid: keysecret:
VodApplication
1 2 3 4 5 6 7 8 @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) @ComponentScan(basePackages = {"com.invictusqiu"}) public class VodApplication { public static void main (String[] args) { SpringApplication.run(VodApplication.class, args); } }
工具类 常量读取工具类,读取配置文件的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Component public class ConstantVodUtils implements InitializingBean { @Value("${aliyun.vod.file.keyid}") private String keyid; @Value("${aliyun.vod.file.keysecret}") private String keysecret; public static String ACCESS_KEY_ID; public static String ACCESS_KEY_SECRET; @Override public void afterPropertiesSet () throws Exception { ACCESS_KEY_ID = keyid; ACCESS_KEY_SECRET = keysecret; } }
控制器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @RestController @RequestMapping("/eduvod/video") @CrossOrigin public class VodController { @Autowired private VodService vodService; @PostMapping("uploadAlyVideo") public Result uploadAlyVideo (MultipartFile file) { String videoId = vodService.uploadVideoAly(file); return Result.ok().data("videoId" ,videoId); } }
服务实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Service public class VodServiceImpl implements VodService { @Override public String uploadVideoAly (MultipartFile file) { try { String fileName = file.getOriginalFilename(); String title = fileName.substring(0 , fileName.lastIndexOf("." )); InputStream inputStream = file.getInputStream(); UploadStreamRequest request = new UploadStreamRequest (ConstantVodUtils.ACCESS_KEY_ID, ConstantVodUtils.ACCESS_KEY_SECRET, title, fileName, inputStream); UploadVideoImpl uploader = new UploadVideoImpl (); UploadStreamResponse response = uploader.uploadStream(request); String videoId = null ; if (response.isSuccess()) { videoId = response.getVideoId(); } else { videoId = response.getVideoId(); } return videoId; } catch (Exception e) { e.printStackTrace(); return null ; } } }
前端 chapter.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <el-form-item label ="上传视频" > <el-upload :on-success ="handleVodUploadSuccess" :on-remove ="handleVodRemove" :before-remove ="beforeVodRemove" :on-exceed ="handleUploadExceed" :file-list ="fileList" :action ="BASE_API + '/eduvod/video/uploadAlyVideo'" :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 >
1 2 3 4 5 6 7 8 9 10 11 fileList : [], BASE_API : process.env .BASE_API handleVodUploadSuccess (response, file, fileList ) { this .video .videoSourceId = response.data .videoId }, handleUploadExceed ( ) { this .$message .warning ('想要重新上传视频,请先删除已上传的视频' ) },
nginx 配置
1 2 3 location ~ /eduvod/ { proxy_pass http://localhost:8082; }
配置 nginx 上传文件大小,否则上传时会有 413 (Request Entity Too Large) 异常 打开 nginx 主配置文件 nginx.conf,找到 http{},添加
1 client_max_body_size 1024m;
如果数据库没有视频名称 修改前端
1 2 3 4 5 6 7 handleVodUploadSuccess (response, file, fileList ) { this .video .videoSourceId = response.data .videoId this .video .videoOriginalName = file.name },
统计分析 统计分析页面,前端页面使用 Echarts 组件库实现图表展示,用户可以选择指定日期范围生成统计数据,包括范围内的用户登录数和注册数,以及课程播放数等数据。 该模块使用了 Feign 远程调用 比如调用接口 UcenterClient
1 2 3 4 5 6 7 8 @Component @FeignClient("service-ucenter") public interface UcenterClient { @GetMapping("/educenter/member/countRegister/{day}") public Result countRegister (@PathVariable("day") String day) ; }
StatisticsDailyServiceImpl 服务实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Autowired private UcenterClient ucenterClient;@Override public void registerCount (String day) { QueryWrapper<StatisticsDaily> wrapper = new QueryWrapper <>(); wrapper.eq("date_calculated" , day); baseMapper.delete(wrapper); Result registerResult = ucenterClient.countRegister(day); Integer countRegister = (Integer)registerResult.getData().get("countRegister" ); StatisticsDaily sta = new StatisticsDaily (); sta.setRegisterNum(countRegister); sta.setDateCalculated(day); sta.setVideoViewNum(RandomUtils.nextInt(100 ,200 )); sta.setLoginNum(RandomUtils.nextInt(100 ,200 )); sta.setCourseNum(RandomUtils.nextInt(100 ,200 )); baseMapper.insert(sta); }
除此之外,启用定时任务实现每天统计 启动类添加注释
创建 ScheduleTask 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Component public class ScheduleTask { @Autowired private StatisticsDailyService staService; @Scheduled(cron = "0 0 1 * * ?") public void task2 () { staService.registerCount(DateUtil.formatDate(DateUtil.addDays(new Date (),-1 ))); } }
前台用户系统 前端框架 Nuxt.js 是一个基于 Vue.js 的轻量级应用框架,可用来创建服务端渲染 (SSR) 应用,也可充当静态站点引擎生成静态站点应用,具有优雅的代码结构分层和热加载等特性。官方网站 幻灯片插件:vue-awesome-swiper
首页 展示轮播图、热门课程等信息,然后对用户展示网站幻灯片、热门课程、名师等内容,为了提高访问速度使用了 Redis 缓存首页数据。
注册和登录 注册功能需要用户通过填写昵称、手机号,然后接收验证码的方式进行注册。如果使用手机号码注册,系统会通过阿里云短信服务向该用户发送短信验证码,后端保存该验证码来和用户输入的验证码进行比对。如果用户是以扫描微信二维码的方式进行注册,后端接收到该请求后会将页面重定向至二维码页面,扫码之后获得微信官方返回的临时票据,使用票据可以获得该用户微信账号的访问凭证和唯一标识,然后请求微信官方的接口地址得到该用户的账号信息,并将其持久化存储于数据库中,实现微信扫码注册功能。 值得一提的是使用 Redis 解决验证码有效时间问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @Autowired private RedisTemplate<String,String> redisTemplate;@GetMapping("send/{phone}") public Result sendMsm (@PathVariable String phone) { String code = redisTemplate.opsForValue().get(phone); if (!StringUtils.isEmpty(code)) { return Result.ok(); } code = RandomUtil.getFourBitRandom(); Map<String,Object> param = new HashMap <>(); param.put("code" ,code); boolean isSend = msmService.send(param,phone); if (isSend) { redisTemplate.opsForValue().set(phone,code,5 , TimeUnit.MINUTES); return Result.ok(); } else { return Result.error().message("短信发送失败" ); } }
课程列表 课程列表,展示上架课程,对不同种类的课程进行了分类,可以按照销量、发布时间、售价来对课程列表进行排序。 后端处理条件分页
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Override public Map<String, Object> getCourseFrontList (Page<EduCourse> pageCourse, CourseFrontVo courseFrontVo) { QueryWrapper<EduCourse> wrapper = new QueryWrapper <>(); if (!StringUtils.isEmpty(courseFrontVo.getSubjectParentId())) { wrapper.eq("subject_parent_id" , courseFrontVo.getSubjectParentId()); } if (!StringUtils.isEmpty(courseFrontVo.getSubjectId())) { wrapper.eq("subject_id" ,courseFrontVo.getSubjectId()); } if (!StringUtils.isEmpty(courseFrontVo.getBuyCountSort())) { wrapper.orderByDesc("buy_count" ); } if (!StringUtils.isEmpty(courseFrontVo.getGmtCreateSort())) { wrapper.orderByDesc("gmt_create" ); } if (!StringUtils.isEmpty(courseFrontVo.getPriceSort())) { wrapper.orderByDesc("price" ); } wrapper.eq("status" ,"Normal" ); baseMapper.selectPage(pageCourse,wrapper); List<EduCourse> records = pageCourse.getRecords(); long current = pageCourse.getCurrent(); long pages = pageCourse.getPages(); long size = pageCourse.getSize(); long total = pageCourse.getTotal(); boolean hasNext = pageCourse.hasNext(); boolean hasPrevious = pageCourse.hasPrevious(); Map<String, Object> map = new HashMap <>(); map.put("items" , records); map.put("current" , current); map.put("pages" , pages); map.put("size" , size); map.put("total" , total); map.put("hasNext" , hasNext); map.put("hasPrevious" , hasPrevious); return map; }
课程详情 课程详情页,包含课程基本信息、分类、讲师等内容,课程分为免费和付费,如果是付费课程,那么前端的“立即观看”按钮会变为“立即购买”按钮,并且在该页面用户可以发表对该课程的评论。
视频播放
获取播放地址 参考文档 前面的 03-使用服务端 SDK 介绍了如何获取非加密视频的播放地址。直接使用 03 节的例子获取加密视频播放地址会返回如下错误信息 Currently only the AliyunVoDEncryption stream exists, you must use the Aliyun player to play or set the value of ResultType to Multiple. 目前只有 AliyunVoDEncryption 流存在,您必须使用 Aliyun player 来播放或将 ResultType 的值设置为 Multiple。 因此在 testGetPlayInfo 测试方法中添加 ResultType 参数,并设置为 true
1 privateParams.put("ResultType" , "Multiple" );
此种方式获取的视频文件不能直接播放,必须使用阿里云播放器播放
视频播放器 参考文档 视频播放器介绍 阿里云播放器 SDK(ApsaraVideo Player SDK)是阿里视频服务的重要一环,除了支持点播和直播的基础播放功能外,深度融合视频云业务,如支持视频的加密播放、安全下载、清晰度切换、直播答题等业务场景,为用户提供简单、快速、安全、稳定的视频播放服务。
集成视频播放器 参考文档 参考 【播放器简单使用说明】一节 引入脚本文件和 css 文件
1 2 3 4 5 6 7 8 9 <link rel ="stylesheet" href ="https://g.alicdn.com/de/prismplayer/2.8.1/skins/default/aliplayer-min.css" /> <script charset ="utf-8" type ="text/javascript" src ="https://g.alicdn.com/de/prismplayer/2.8.1/aliplayer-min.js" > </script >
初始化视频播放器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <body > <div class ="prism-player" id ="J_prismPlayer" > </div > <script > var player = new Aliplayer ( { id : "J_prismPlayer" , width : "100%" , autoplay : false , cover : "http://liveroom-img.oss-cn-qingdao.aliyuncs.com/logo.png" , }, function (player ) { console .log ("播放器创建好了。" ); } ); </script > </body >
1. 播放地址播放 在 Aliplayer 的配置参数中添加如下属性
启动浏览器运行,测试视频的播放
2. 播放凭证播放(推荐) 阿里云播放器支持通过播放凭证自动换取播放地址进行播放,接入方式更为简单,且安全性更高。播放凭证默认时效为 100 秒(最大为 3000 秒),只能用于获取指定视频的播放地址,不能混用或重复使用。如果凭证过期则无法获取播放地址,需要重新获取凭证。
1 2 3 encryptType :'1' ,vid : '视频id' , playauth : '视频授权码' ,
注意:播放凭证有过期时间,默认值:100 秒 。取值范围:100~3000。 设置播放凭证的有效期 在获取播放凭证的测试用例中添加如下代码
1 request.setAuthInfoTimeout (200L);
在线配置参考
后端获取播放凭证 播放组件相关文档: 集成文档 在线配置 功能展示
整合阿里云视频播放器 后端 修改 VideoVo
1 2 3 4 5 6 7 8 public class VideoVo { private String id; private String title; private String videoSourceId; }
VodController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @GetMapping("getPlayAuth/{id}") public Result getPlayAuth (@PathVariable String id) { try { DefaultAcsClient client = InitVodClient.initVodClient(ConstantVodUtils.ACCESS_KEY_ID,ConstantVodUtils.ACCESS_KEY_SECRET); GetVideoPlayAuthRequest request = new GetVideoPlayAuthRequest (); request.setVideoId(id); GetVideoPlayAuthResponse response = client.getAcsResponse(request); String playAuth = response.getPlayAuth(); return Result.ok().data("playAuth" ,playAuth); } catch (Exception e) { throw new EduException (20001 ,"获取凭证失败" ); } }
前端 api vod.js
1 2 3 4 5 6 7 8 9 10 import request from '@/utils/request' export default { getPlayAuth (vid ) { return request ({ url : `/eduvod/video/getPlayAuth/${vid} ` , method : 'get' }) } }
创建新的 layouts video.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 <template > <div class ="guli-player" > <div class ="head" > <a href ="#" title ="在线教育" > <img class ="logo" src ="~/assets/img/logo.png" lt ="在线教育" /> </a > </div > <div class ="body" > <div class ="content" > <nuxt /> </div > </div > </div > </template > <script > export default {}; </script > <style > html , body { height : 100% ; } </style > <style scoped > .head { height : 50px ; position : absolute; top : 0 ; left : 0 ; width : 100% ; } .head .logo { height : 50px ; margin-left : 10px ; } .body { position : absolute; top : 50px ; left : 0 ; right : 0 ; bottom : 0 ; overflow : hidden; } </style >
_id.vue 点击小节携带视频 id 跳转
1 <a :href ="'/player/'+video.videoSourceId" title target ="_blank" > </a >
新建 Page/player/_vid.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 <template > <div > <link rel ="stylesheet" href ="https://g.alicdn.com/de/prismplayer/2.8.1/skins/default/aliplayer-min.css" /> <div id ="J_prismPlayer" class ="prism-player" /> </div > </template > <script charset ="utf-8" type ="text/javascript" src ="https://g.alicdn.com/de/prismplayer/2.8.1/aliplayer-min.js" /> <script > import vod from "@/api/vod" ; export default { layout : "video" , asyncData ({ params, error } ) { return vod.getPlayAuth (params.vid ).then ((response ) => { return { playAuth : response.data .playAuth , vid : params.vid , }; }); }, mounted ( ) { new Aliplayer ( { id : "J_prismPlayer" , vid : this .vid , playauth : this .playAuth , width : "100%" , height : "500px" , }, function (player ) { console .log ("播放器创建成功" ); } ); }, }; </script >
排错
先看看播放器的 js 有没有引入 摁下 F12,在网络中(network)查看,如果没有可以尝试在 nuxt.config.js 文件中的 head 中添加。 不要删除原_vid.vue 中的
1 2 3 4 5 6 7 <script charset="utf-8" type="text/javascript" src="https://g.alicdn.com/de/prismplayer/2.8.1/aliplayer-min.js" /> `` `html 把它放到` <template></template>`标签外 ` `` JavaScript head : { script : [{ src : 'https://g.alicdn.com/de/prismplayer/2.8.1/aliplayer-min.js' }], }
名师列表 得到所有讲师信息,显示所有名师的头像、名称、简介内容。
讲师详情 在名师列表页可以选择不同讲师的卡片,通过携带讲师 id 请求后端接口来查询该讲师的信息和所授课程,页面中展示了名师的详细信息和所授课程。
订单模块 课程支付,用户只有登录后才能购买对应课程。购买会生成课程订单和微信支付的二维码,在此支付期间每隔 3 秒会查询支付状态,只有扫码成功后才更新数据库中该订单的支付状态,一旦查询支付状态为“已支付”才能为用户开通课程观看权限。 服务实现代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 @Autowired private OrderService orderService;@Override public Map createNative (String orderNo) { try { QueryWrapper<Order> wrapper = new QueryWrapper <>(); wrapper.eq("order_no" ,orderNo); Order order = orderService.getOne(wrapper); Map m = new HashMap (); m.put("appid" , "wx74862e0dfcf69954" ); m.put("mch_id" , "1558950191" ); m.put("nonce_str" , WXPayUtil.generateNonceStr()); m.put("body" , order.getCourseTitle()); m.put("out_trade_no" , orderNo); m.put("total_fee" , order.getTotalFee().multiply(new BigDecimal ("100" )).longValue()+"" ); m.put("spbill_create_ip" , "127.0.0.1" ); m.put("notify_url" , "http://guli.shop/api/order/weixinPay/weixinNotify\n" ); m.put("trade_type" , "NATIVE" ); HttpClient client = new HttpClient ("https://api.mch.weixin.qq.com/pay/unifiedorder" ); client.setXmlParam(WXPayUtil.generateSignedXml(m,"T6m9iK73b0kn9g5v426MKfHQH7X8rKwb" )); client.setHttps(true ); client.post(); String xml = client.getContent(); Map<String,String> resultMap = WXPayUtil.xmlToMap(xml); Map map = new HashMap (); map.put("out_trade_no" , orderNo); map.put("course_id" , order.getCourseId()); map.put("total_fee" , order.getTotalFee()); map.put("result_code" , resultMap.get("result_code" )); map.put("code_url" , resultMap.get("code_url" )); return map; } catch (Exception e) { throw new EduException (20001 ,"生成二维码失败" ); } }
项目仓库 在线教育平台