# 😎 知识点概览
> 为了方便后续回顾该项目时能够清晰的知道本章节讲了哪些内容,并且能够从该章节的笔记中得到一些帮助,所以在完成本章节的学习后在此对本章节所涉及到的知识点进行总结概述。
本章节为【学成在线】项目的 `day15` 的内容
- [x] 根据 `课程ID` 搜索该课程已发布的课程信息,并返回该课程的所有课程计划信息。
- [x] 将指定课程 `发布时` 所的课程计划的媒资信息保存到 `teachplan_media_publish` 表中,
- [x] 根据 `课程计划id` 搜索该课程计划所对应的媒资信息,需要用到的是该课程计划对应的 `m3u8` 地址,用于在线播放视频,该接口在课程管理服务中开发,供学习服务进行远程调用。
- [x] 在学习服务中远程调用 课程计划媒资信息查询接口,获取该课程计划的视频播放的 `m3u8` url地址,并返回给前端,前端使用该 `url` 进行视频的在线播放。
- [x] 在线学习完整的测试流程:媒资信息的上传、选择、发布到前端门户、搜索门户测试,在线学习的播放视频。
# 目录
内容会比较多,小伙伴门可以根据目录进行按需查阅。
[TOC]
# 一、学习页面:查询课程计划
## 0x01 需求分析
到目前为止,我们已可以编辑课程计划信息并上传课程视频,下一步我们要实现在线学习页面动态读取章节对应的视频并进行播放。在线学习页面所需要的信息有两类:
- 课程计划信息
- 课程学习信息(视频地址、学习进度等)
如下图:

在线学习集成媒资管理的需求如下:
1、在线学习页面显示课程计划
2、点击课程计划播放该课程计划对应的视频
本章节实现学习页面动态显示课程计划,进入不同课程的学习页面右侧动态显示当前课程的课程计划。
## 0x02 Api接口
课程计划信息从哪里获取?
在课程发布完成后会自动发布到一个 `course_pub` 的表中,`logstash` 会自动将课程发布后的信息自动采集到 `ES` 索引库中,这些信息也包含课程计划信息。
所以考虑性能要求,课程发布后对课程的查询统一从 `ES` 索引库中查询。
前端通过请求 `搜索服务` 获取课程信息,需要单独在 `搜索服务` 中定义课程信息查询接口。
本接口接收课程id,查询课程所有信息返回给前端。
我们在搜素服务 `API` 下添加以下方法
```java
@ApiOperation("根据id搜索课程发布信息")
public Map<String,CoursePub> getdetail(String id);
```
返回的课程信息为 `json` 结构:`key` 为课程id,`value` 为课程内容。
## 0x03 服务端开发
在搜索服务中开发查询课程信息接口。
### Controller
在搜素服务下添加以下方法
```java
/**
* 根据id搜索课程发布信息
* @param id 课程id
* @return JSON数据
*/
@Override
@GetMapping("/getdetail/{id}")
public Map<String, CoursePub> getdetail(@PathVariable("id")String id) {
return esCourseService.getdetail(id);
}
```
### Service
```java
/**
* 根据id搜索课程发布信息
* @param id 课程id
* @return JSON数据
*/
public Map<String, CoursePub> getdetail(String id) {
//设置索引
SearchRequest searchRequest = new SearchRequest(es_index);
//设置类型
searchRequest.types(es_type);
//创建搜索源对象
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
//设置查询条件,根据id进行查询
searchSourceBuilder.query(QueryBuilders.termQuery("id",id));
//这里不使用source的原字段过滤,查询所有字段
// searchSourceBuilder.fetchSource(new String[]{"name", "grade", "charge","pic"}, newString[]{});
//设置搜索源对象
searchRequest.source(searchSourceBuilder);
//执行搜索
SearchResponse searchResponse = null;
try {
searchResponse = restHighLevelClient.search(searchRequest);
} catch (IOException e) {
e.printStackTrace();
}
//获取搜索结果
SearchHits hits = searchResponse.getHits();
SearchHit[] searchHits = hits.getHits(); //获取最优结果
Map<String,CoursePub> map = new HashMap<>();
for (SearchHit hit: searchHits) {
//从搜索结果中取值并添加到coursePub对象
Map<String, Object> sourceAsMap = hit.getSourceAsMap();
String courseId = (String) sourceAsMap.get("id");
String name = (String) sourceAsMap.get("name");
String grade = (String) sourceAsMap.get("grade");
String charge = (String) sourceAsMap.get("charge");
String pic = (String) sourceAsMap.get("pic");
String description = (String) sourceAsMap.get("description");
String teachplan = (String) sourceAsMap.get("teachplan");
CoursePub coursePub = new CoursePub();
coursePub.setId(courseId);
coursePub.setName(name);
coursePub.setPic(pic);
coursePub.setGrade(grade);
coursePub.setTeachplan(teachplan);
coursePub.setDescription(description);
//设置map对象
map.put(courseId,coursePub);
}
return map;
}
```
### 测试
使用 `swagger-ui` 或 `postman` 测试查询课程信息接口。

## 0x04 前端开发
### 配置NGINX虚拟主机
学习中心的二级域名为 `ucenter.xuecheng.com` ,我们在 `nginx` 中配置 `ucenter` 虚拟主机。
```c
#学成网用户中心
server {
listen 80;
server_name ucenter.xuecheng.com;
#个人中心
location / {
proxy_pass http://ucenter_server_pool;
}
}
#前端ucenter
upstream ucenter_server_pool{
#server 127.0.0.1:7081 weight=10;
server 127.0.0.1:13000 weight=10;
}
```
在学习中心要调用搜索的 `API`,使用 `Nginx` 解决代理,如下图:

在 `ucenter` 虚拟主机下配置搜索 `Api` 代理路径
```java
#后台搜索(公开api)
upstream search_server_pool{
server 127.0.0.1:40100 weight=10;
}
#学成网用户中心
server {
listen 80;
server_name ucenter.xuecheng.com;
#个人中心
location / {
proxy_pass http://ucenter_server_pool;
}
#后端搜索服务
location /openapi/search/ {
proxy_pass http://search_server_pool/search/;
}
}
```
### 前端 API 方法
在学习中心 `xc-ui-pc-leanring` 对课程信息的查询属于基础常用功能,所以我们将课程查询的 `api` 方法定义在`base` 模块下,如下图:

在`system.js` 中定义课程查询方法:
```js
import http from './public'
export const course_view = id => {
return http.requestGet('/openapi/search/course/getdetail/'+id);
}
```
### 前端 API 方法调用
在 `learning_video.vue` 页面中调用课程信息查询接口得到课程计划,将课程计划`json` 串转成对象。
> xc-ui-pc-leanring/src/module/course/page/learning_video.vue
1、定义视图
课程计划
```html
<!--课程计划部分代码-->
<div class="navCont">
<div class="course-weeklist">
<div class="nav nav-stacked" v-for="(teachplan_first, index) in teachplanList">
<div class="tit nav-justified text-center"><i class="pull-left glyphicon glyphicon-th-list"></i>{{teachplan_first.pname}}<i class="pull-right"></i></div>
<li v-if="teachplan_first.children!=null" v-for="(teachplan_second, index) in teachplan_first.children"><i class="glyphicon glyphicon-check"></i>
<a :href="url" @click="study(teachplan_second.id)">
{{teachplan_second.pname}}
</a>
</li>
<!-- <div class="tit nav-justified text-center"><i class="pull-left glyphicon glyphicon-th-list"></i>第一章<i class="pull-right"></i></div>
<li ><i class="glyphicon glyphicon-check"></i>
<a :href="url" >
第一节
</a>
</li>-->
<!--<li><i class="glyphicon glyphicon-unchecked"></i>为什么分为A、B、C部分</li>-->
</div>
</div>
</div>
```
课程名称
```html
<div class="top text-center">
{{coursename}}
</div>
```
定义数据对象
```js
data() {
return {
url:'',//当前url
courseId:'',//课程id
chapter:'',//章节Id
coursename:'',//课程名称
coursepic:'',//课程图片
teachplanList:[],//课程计划
playerOptions: {//播放参数
autoplay: false,
controls: true,
sources: [{
type: "application/x-mpegURL",
src: ''
}]
},
}
}
```
在 `created` 钩子方法中获取课程信息
```js
created(){
//当前请求的url
this.url = window.location
//课程id
this.courseId = this.$route.params.courseId
//章节id
this.chapter = this.$route.params.chapter
//查询课程信息
systemApi.course_view(this.courseId).then((view_course)=>{
if(!view_course || !view_course[this.courseId]){
this.$message.error("获取课程信息失败,请重新进入此页面!")
return ;
}
let courseInfo = view_course[this.courseId]
console.log(courseInfo)
this.coursename = courseInfo.name
if(courseInfo.teachplan){
let teachplan = JSON.parse(courseInfo.teachplan);
this.teachplanList = teachplan.children;
}
})
},
```
### 测试
在浏览器请求:http://ucenter.xuecheng.com/#/learning/4028e581617f945f01617f9dabc40000/0
- `4028e581617f945f01617f9dabc40000`:第一个参数为课程 `id`,测试时从 `ES`索引库找一个课程 `id`
- 0:第二个参数为课程计划 `id`,此参数用于点击课程计划播放视频。

> 如果出现跨域问题,但是确定已经配置了跨域,请尝试结束所以 nginx.exe 的进程 和 清空浏览器缓存。
>
> 如果还没有解决?重启电脑试试。
# 二、学习页面:获取视频播放地址
## 0x01 需求分析
用户进入在线学习页面,点击课程计划将播放该课程计划对应的教学视频。
业务流程如下:

业务流程说明:
1、用户进入在线学习页面,页面请求搜索服务获取课程信息(包括课程计划信息)并且在页面展示。
2、在线学习请求学习服务获取视频播放地址。
3、学习服务校验当前用户是否有权限学习,如果没有权限学习则提示用户。
4、学习服务校验通过,请求搜索服务获取课程媒资信息。
5、搜索服务请求ElasticSearch获取课程媒资信息。
为什么要请求 `ElasticSearch` 查询课程媒资信息?
出于性能的考虑,公开查询课程信息从搜索服务查询,分摊 `mysql` 数据库的访问压力。
什么时候将课程媒资信息存储到 `ElasticSearch` 中?
课程媒资信息是在课程发布的时候存入 `ElasticSearch`,因为课程发布后课程信息将基本不再修改。
## 0x02 课程发布:储存媒资信息
### 需求分析
课程媒资信息是在课程发布的时候存入 `ElasticSearch` 索引库,因为课程发布后课程信息将基本不再修改,具体的业务流程如下。
**1、课程发布,向课程媒资信息表写入数据。**
1)根据课程 `id` 删除 `teachplanMediaPub` 中的数据
2)根据课程 `id` 查询 `teachplanMedia` 数据
3)将查询到的 `teachplanMedia` 数据插入到 `teachplanMediaPub` 中
**2、Logstash 定时扫描课程媒资信息表,并将课程媒资信息写入索引库。**
### 数据模型
在 `xc_course` 数据库创建课程计划媒资发布表:
```sql
CREATE TABLE `teachplan_media_pub` (
`teachplan_id` varchar(32) NOT NULL COMMENT '课程计划id',
`media_id` varchar(32) NOT NULL COMMENT '媒资文件id',
`media_fileoriginalname` varchar(128) NOT NULL COMMENT '媒资文件的原始名称',
`media_url` varchar(256) NOT NULL COMMENT '媒资文件访问地址',
`courseid` varchar(32) NOT NULL COMMENT '课程Id',
`timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT
'logstash使用',
PRIMARY KEY (`teachplan_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
```
数据模型类如下:
```java
package com.xuecheng.framework.domain.course;
import lombok.Data;
import lombok.ToString;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
@Data
@ToString
@Entity
@Table(name="teachplan_media_pub")
@GenericGenerator(name = "jpa-assigned", strategy = "assigned")
public class TeachplanMediaPub implements Serializable {
private static final long serialVersionUID = -916357110051689485L;
@Id
@GeneratedValue(generator = "jpa-assigned")
@Column(name="teachplan_id")
private String teachplanId;
@Column(name="media_id")
private String mediaId;
@Column(name="media_fileoriginalname")
private String mediaFileOriginalName;
@Column(name="media_url")
private String mediaUrl;
@Column(name="courseid")
private String courseId;
@Column(name="timestamp")
private Date timestamp;//时间戳
}
```
### Dao
创建 `TeachplanMediaPub` 表的 `Dao`,向 `TeachplanMediaPub` 存储信息采用先删除该课程的媒资信息,再添加该课程的媒资信息,所以这里定义根据课程 `id` 删除课程计划媒资方法:
```java
public interface TeachplanMediaPubRepository extends JpaRepository<TeachplanMediaPub, String> {
//根据课程id删除课程计划媒资信息
long deleteByCourseId(String courseId);
}
```
从TeachplanMedia查询课程计划媒资信息
```java
//从TeachplanMedia查询课程计划媒资信息
public interface TeachplanMediaRepository extends JpaRepository<TeachplanMedia, String> {
List<TeachplanMedia> findByCourseId(String courseId);
}
```
### Service
编写保存课程计划媒资信息方法,并在课程发布时调用此方法。
1、保存课程计划媒资信息方法
本方法采用先删除该课程的媒资信息,再添加该课程的媒资信息,在 `CourseService` 下定义该方法
```java
//保存课程计划媒资信息
private void saveTeachplanMediaPub(String courseId){
//查询课程媒资信息
List<TeachplanMedia> byCourseId = teachplanMediaRepository.findByCourseId(courseId);
if(byCourseId == null) return; //没有查询到媒资数据则直接结束该方法
//将课程计划媒资信息储存到待索引表
//删除原有的索引信息
teachplanMediaPubRepository.deleteByCourseId(courseId);
//一个课程可能会有多个媒资信息,遍历并使用list进行储存
List<TeachplanMediaPub> teachplanMediaPubList = new ArrayList<>();
for (TeachplanMedia teachplanMedia: byCourseId) {
TeachplanMediaPub teachplanMediaPub = new TeachplanMediaPub();
BeanUtils.copyProperties(teachplanMedia, teachplanMediaPub);
teachplanMediaPubList.add(teachplanMediaPub);
}
//保存所有信息
teachplanMediaPubRepository.saveAll(teachplanMediaPubList);
}
```
2、课程发布时调用此方法
修改课程发布的 `coursePublish` 方法:
```java
....
//保存课程计划媒资信息到待索引表
saveTeachplanMediaPub(courseId);
//页面url
String pageUrl = cmsPostPageResult.getPageUrl();
return new CoursePublishResult(CommonCode.SUCCESS,pageUrl);
.....
```
### 测试
测试课程发布后是否成功将课程媒资信息存储到 `teachplan_media_pub` 中,测试流程如下:
1、指定一个课程
2、为课程计划添加课程媒资
3、执行课程发布
4、观察课程计划媒资信息是否存储至 `teachplan_media_pub` 中
注意:由于此测试仅用于测试发布课程计划媒资信息的功能,可暂时将 `cms`页面发布的功能暂时屏蔽,提高测试效率。
测试结果如下

## 0x03 Logstash:扫描课程计划媒资
`Logstash` 定时扫描课程媒资信息表,并将课程媒资信息写入索引库。
### 创建索引
1、创建 `xc_course_media` 索引

2、并向此索引创建如下映射
POST: http://localhost:9200/xc_course_media/doc/_mapping
```json
{
"properties" : {
"courseid" : {
"type" : "keyword"
},
"teachplan_id" : {
"type" : "keyword"
},
"media_id" : {
"type" : "keyword"
},
"media_url" : {
"index" : false,
"type" : "text"
},
"media_fileoriginalname" : {
"index" : false,
"type" : "text"
}
}
}
```
索引创建成功

### 创建模板文件
在 `logstach` 的 `config` 目录文件 `xc_course_media_template.json`
文件路径为 `%ES_ROOT_DIR%/logstash6.8.8/config/xc_course_media_template.json`
> %ES_ROOT_DIR% 为 ElasticSearch 和 logstash 的安装目录
内容如下:
```json
{
"mappings" : {
"doc" : {
"properties" : {
"courseid" : {
"type" : "keyword"
},
"teachplan_id" : {
"type" : "keyword"
},
"media_id" : {
"type" : "keyword"
},
"media_url" : {
"index" : false,
"type" : "text"
},
"media_fileoriginalname" : {
"index" : false,
"type" : "text"
}
}
},
"template" : "xc_course_media"
}
}
```
### 配置 mysql.conf
在logstash的 `config` 目录下配置 `mysql_course_media.conf` 文件供 `logstash` 使用,`logstash` 会根据
`mysql_course_media.conf` 文件的配置的地址从 `MySQL` 中读取数据向 `ES` 中写入索引。
参考https://www.elastic.co/guide/en/logstash/current/plugins-inputs-jdbc.html
配置输入数据源和输出数据源。
```json
input {
stdin {}
jdbc {
jdbc_connection_string => "jdbc:mysql://localhost:3306/xc_course?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC"
# 数据库信息
jdbc_user => "root"
jdbc_password => "123123"
# MYSQL 驱动地址,修改为maven仓库对应的位置
jdbc_driver_library => "D:/soft/apache-maven-3.5.4/repository/mysql/mysql-connector-java/5.1.40/mysql-connector-java-5.1.40.jar"
# the name of the driver class for mysql
jdbc_driver_class => "com.mysql.jdbc.Driver"
jdbc_paging_enabled => "true"
jdbc_page_size => "50000"
#要执行的sql文件
#statement_filepath => "/conf/course.sql"
statement => "select * from teachplan_media_pub where timestamp > date_add(:sql_last_value,INTERVAL 8 HOUR)"
#定时配置
schedule => "* * * * *"
record_last_run => true
last_run_metadata_path => "D:/soft/elasticsearch/logstash-6.8.8/config/xc_course_media_metadata"
}
}
output {
elasticsearch {
#ES的ip地址和端口
hosts => "localhost:9200"
#hosts => ["localhost:9200","localhost:9202","localhost:9203"]
#ES索引库名称
index => "xc_course_media"
document_id => "%{teachplan_id}"
document_type => "doc"
template => "D:/soft/elasticsearch/logstash-6.8.8/config/xc_course_media_template.json"
template_name =>"xc_course_media"
template_overwrite =>"true"
}
stdout {
#日志输出
codec => json_lines
}
}
```
### 启动 logstash.bat
启动 `logstash.bat` 采集 `teachplan_media_pub` 中的数据,向 `ES` 写入索引。
```
logstash.bat -f ../config/mysql_course_media.conf
```
课程发布成功后,Logstash 会自动参加 `teachplan_media_pub` 表中新增的数据,效果如下


### Logstash多实例运行
由于之前我们还启动了一个 `Logstash` 对课程的发布信息进行采集,所以如果想两个 `logstash` 实例同时运行,因为每个实例都有一个.lock文件,所以不能使用同一个目录来存放数据,所以我们需要使用 `--path.data=` 为每个实例指定单独的数据目录,具体的代码如下:
> 该配置是在windows下进行的
**课程发布实例**
`logstash_start_course_pub.bat`
```cmd
@title logstash in course_pub
logstash.bat -f ..\config\mysql.conf --path.data=../data/course_pub
```
**课程计划媒体发布实例**
`logstash_start_teachplan_media.bat`
```cmd
@title logstash i n teachplan_media_pub
logstash.bat -f ../config/mysql_course_media.conf --path.data=../data/teachplan_media/
```
同时运行效果如下

## 0x04 搜素服务:查询课程媒资接口
### 需求分析
`搜索服务` 提供查询课程媒资接口,此接口供学习服务调用。
### Api接口定义
```java
@ApiOperation("根据课程计划查询媒资信息")
public TeachplanMediaPub getmedia(String teachplanId);
```
### Service
1、配置课程计划媒资索引库等信息
在 `application.yml` 中配置
```yml
xuecheng:
elasticsearch:
hostlist: ${eshostlist:127.0.0.1:9200} #多个结点中间用逗号分隔
course:
index: xc_course
type: doc
source_field: id,name,grade,mt,st,charge,valid,pic,qq,price,price_old,status,studymodel,teachmode,expires,pub_time,start_time,end_time
media:
index: xc_course_media
type: doc
source_field: courseid,media_id,media_url,teachplan_id,media_fileoriginalname
```
2、service 方法开发
在 `课程搜索服务` 中定义课程媒资查询接口,为了适应后续需求,`service` 参数定义为数组,可一次查询多个课程计划的媒资信息。
```java
/**
* 根据一个或者多个课程计划id查询媒资信息
* @param teachplanIds 课程id
* @return QueryResponseResult
*/
public QueryResponseResult<TeachplanMediaPub> getmedia(String [] teachplanIds){
//设置索引
SearchRequest searchRequest = new SearchRequest(media_index);
//设置类型
searchRequest.types(media_type);
//创建搜索源对象
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
//源字段过滤
String[] media_index_arr = media_field.split(",");
searchSourceBuilder.fetchSource(media_index_arr, new String[]{});
//查询条件,根据课程计划id查询(可以传入多个课程计划id)
searchSourceBuilder.query(QueryBuilders.termsQuery("teachplan_id", teachplanIds));
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = null;
try {
searchResponse = restHighLevelClient.search(searchRequest);
} catch (IOException e) {
e.printStackTrace();
}
//获取结果
SearchHits hits = searchResponse.getHits();
long totalHits = hits.getTotalHits();
SearchHit[] searchHits = hits.getHits();
//数据列表
List<TeachplanMediaPub> teachplanMediaPubList = new ArrayList<>();
for(SearchHit hit:searchHits){
TeachplanMediaPub teachplanMediaPub =new TeachplanMediaPub();
Map<String, Object> sourceAsMap = hit.getSourceAsMap();
//取出课程计划媒资信息
String courseid = (String) sourceAsMap.get("courseid");
String media_id = (String) sourceAsMap.get("media_id");
String media_url = (String) sourceAsMap.get("media_url");
String teachplan_id = (String) sourceAsMap.get("teachplan_id");
String media_fileoriginalname = (String) sourceAsMap.get("media_fileoriginalname");
teachplanMediaPub.setCourseId(courseid);
teachplanMediaPub.setMediaUrl(media_url);
teachplanMediaPub.setMediaFileOriginalName(media_fileoriginalname);
teachplanMediaPub.setMediaId(media_id);
teachplanMediaPub.setTeachplanId(teachplan_id);
//将对象加入到列表中
teachplanMediaPubList.add(teachplanMediaPub);
}
//构建返回课程媒资信息对象
QueryResult<TeachplanMediaPub> queryResult = new QueryResult<>();
queryResult.setList(teachplanMediaPubList);
queryResult.setTotal(totalHits);
return new QueryResponseResult<TeachplanMediaPub>(CommonCode.SUCCESS,queryResult);
}
```
### Controller
```java
/**
* 根据课程计划id搜索发布后的媒资信息
* @param teachplanId
* @return
*/
@GetMapping(value="/getmedia/{teachplanId}")
@Override
public TeachplanMediaPub getmedia(@PathVariable("teachplanId") String teachplanId) {
//为了service的拓展性,所以我们service接收的是数组作为参数,以便后续开发查询多个ID的接口
String[] teachplanIds = new String[]{teachplanId};
//通过service查询ES获取课程媒资信息
QueryResponseResult<TeachplanMediaPub> mediaPubQueryResponseResult = esCourseService.getmedia(teachplanIds);
QueryResult<TeachplanMediaPub> queryResult = mediaPubQueryResponseResult.getQueryResult();
if(queryResult!=null&& queryResult.getList()!=null
&& queryResult.getList().size()>0){
//返回课程计划对应课程媒资
return queryResult.getList().get(0);
} return new TeachplanMediaPub();
}
```
### 测试
使用 `swagger-ui` 和 `postman` 测试课程媒资查询接口。

# 三、在线学习:接口开发
## 0x01 需求分析
根据下边的业务流程,本章节完成前端学习页面请求学习服务获取课程视频地址,并自动播放视频。

## 0x02 搭建开发环境
1、创建数据库
创建 `xc_learning` 数据库,学习数据库将记录学生的选课信息、学习信息。
导入:`资料/xc_learning.sql`
2、创建学习服务工程
参考课程管理服务工程结构,创建学习服务工程:
导入:`资料/xc-service-learning.zip`
项目工程结构如下

## 0x03 Api接口
此 `api` 接口是课程学习页面请求学习服务获取课程学习地址。
定义返回值类型:
```java
package com.xuecheng.framework.domain.learning.response;
import com.xuecheng.framework.model.response.ResponseResult;
import com.xuecheng.framework.model.response.ResultCode;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@ToString
@NoArgsConstructor
public class GetMediaResult extends ResponseResult {
public GetMediaResult(ResultCode resultCode, String fileUrl) {
super(resultCode);
this.fileUrl = fileUrl;
}
//媒资文件播放地址
private String fileUrl;
}
```
定义接口,学习服务根据传入课程 `ID`、章节 `Id`(课程计划 `ID`)来取学习地址。
```java
@Api(value = "录播课程学习管理",description = "录播课程学习管理")
public interface CourseLearningControllerApi {
@ApiOperation("获取课程学习地址")
public GetMediaResult getMediaPlayUrl(String courseId,String teachplanId);
}
```
## 0x04 服务端开发
### 需求分析
学习服务根据传入课程ID、章节Id(课程计划ID)请求搜索服务获取学习地址。
### 搜索服务注册Eureka
学习服务要调用搜索服务查询课程媒资信息,所以需要将搜索服务注册到 `eureka` 中。
1、查看服务名称是否为 `xc-service-search`
```yml
# 注意修改application.xml中的服务名称:
spring:
application:
name: xc‐service‐search
```
2、配置搜索服务的配置文件 `application.yml`,加入 `Eureka` 配置 如下:
```yml
eureka:
client:
registerWithEureka: true #服务注册开关
fetchRegistry: true #服务发现开关
serviceUrl: #Eureka客户端与Eureka服务端进行交互的地址,多个中间用逗号分隔
defaultZone: ${EUREKA_SERVER:http://localhost:50101/eureka/,http://localhost:50102/eureka/}
instance:
prefer-ip-address: true #将自己的ip地址注册到Eureka服务中
ip-address: ${IP_ADDRESS:127.0.0.1}
instance-id: ${spring.application.name}:${server.port} #指定实例id
ribbon:
MaxAutoRetries: 2 #最大重试次数,当Eureka中可以找到服务,但是服务连不上时将会重试,如果eureka中找不到服务则直接走断路器
MaxAutoRetriesNextServer: 3 #切换实例的重试次数
OkToRetryOnAllOperations: false #对所有操作请求都进行重试,如果是get则可以,如果是post,put等操作没有实现幂等的情况下是很危险的,所以设置为false
ConnectTimeout: 5000 #请求连接的超时时间
ReadTimeout: 6000 #请求处理的超时时间
```
3、添加 `eureka` 依赖
```xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring‐cloud‐starter‐netflix‐eureka‐client</artifactId>
</dependency>
```
4、修改启动类,在class上添加如下注解:
```java
@EnableDiscoveryClient
```
### 搜索服务客户端
在 `学习服务` 创建搜索服务的客户端接口,此接口会生成代理对象,调用搜索服务:
```java
package com.xuecheng.learning.client;
import com.xuecheng.framework.domain.course.TeachplanMediaPub;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(value = "xc‐service‐search")
public interface CourseSearchClient {
@GetMapping(value="/getmedia/{teachplanId}")
public TeachplanMediaPub getmedia(@PathVariable("teachplanId") String teachplanId);
}
```
### 自定义错误代码
我们在 `com.xuecheng.framework.domain.learning.response` 包下自定义一个错误消息模型
```java
package com.xuecheng.framework.domain.learning.response;
import com.xuecheng.framework.model.response.ResultCode;
import lombok.ToString;
@ToString
public enum LearningCode implements ResultCode {
LEARNING_GET_MEDIA_ERROR(false,23001,"学习中心获取媒资信息错误!");
//操作代码
boolean success;
//操作代码
int code;
//提示信息
String message;
private LearningCode(boolean success, int code, String message){
this.success = success;
this.code = code;
this.message = message;
}
@Override
public boolean success() {
return success;
}
@Override
public int code() {
return code;
}
@Override
public String message() {
return message;
}
}
```
该消息模型基于 `ResultCode` 来实现,代码如下
```java
package com.xuecheng.framework.model.response;
/**
* Created by mrt on 2018/3/5.
* 10000-- 通用错误代码
* 22000-- 媒资错误代码
* 23000-- 用户中心错误代码
* 24000-- cms错误代码
* 25000-- 文件系统
*/
public interface ResultCode {
//操作是否成功,true为成功,false操作失败
boolean success();
//操作代码
int code();
//提示信息
String message();
```
从 `ResultCode` 中我们可以看出,我们约定了用户中心的错误代码使用 `23000`,所以我们定义的一些错误信息的代码就从 23000 开始计数。
### Service
在学习服务中定义 `service` 方法,此方法远程请求课程管理服务、媒资管理服务获取课程学习地址。
```java
package com.xuecheng.learning.service.impl;
import com.netflix.discovery.converters.Auto;
import com.xuecheng.framework.domain.course.TeachplanMediaPub;
import com.xuecheng.framework.domain.learning.response.GetMediaResult;
import com.xuecheng.framework.exception.ExceptionCast;
import com.xuecheng.framework.model.response.CommonCode;
import com.xuecheng.learning.client.CourseSearchClient;
import com.xuecheng.learning.service.LearningService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class LearningServiceImpl implements LearningService {
@Autowired
CourseSearchClient courseSearchClient;
/**
* 远程调用搜索服务获取已发布媒体信息中的url
* @param courseId 课程id
* @param teachplanId 媒体信息id
* @return
*/
@Override
public GetMediaResult getMediaPlayUrl(String courseId, String teachplanId) {
//校验学生权限,是否已付费等
//远程调用搜索服务进行查询媒体信息
TeachplanMediaPub mediaPub = courseSearchClient.getmedia(teachplanId);
if(mediaPub == null) ExceptionCast.cast(CommonCode.FAIL);
return new GetMediaResult(CommonCode.SUCCESS, mediaPub.getMediaUrl());
}
}
```
### Controller
调用 `service` 根据课程计划 `id` 查询视频播放地址:
```java
@RestController
@RequestMapping("/learning/course")
public class CourseLearningController implements CourseLearningControllerApi {
@Autowired
LearningService learningService;
@Override
@GetMapping("/getmedia/{courseId}/{teachplanId}")
public GetMediaResult getMediaPlayUrl(@PathVariable String courseId, @PathVariable String teachplanId) {
//获取课程学习地址
return learningService.getMedia(courseId, teachplanId);
}
}
```
### 测试
使用 `swagger-ui` 或`postman` 测试学习服务查询课程视频地址接口。

## 0x05 前端开发
### 需求分析
需要在学习中心前端页面需要完成如下功能:
1、进入课程学习页面需要带上 `课程 Id `参数及课程计划Id的参数,其中 `课程 Id` 参数必带,`课程计划 Id` 可以为空。
2、进入页面根据 `课程 Id` 取出该课程的课程计划显示在右侧。
3、进入页面后判断如果请求参数中有`课程计划 Id` 则播放该章节的视频。
4、进入页面后判断如果 `课程计划id` 为0则需要取出本课程第一个 `课程计划的Id`,并播放第一个课程计划的视频。
进入到模块 `xc-ui-pc-leanring/src/module/course`
### api方法
```js
let sysConfig = require('@/../config/sysConfig')
let apiUrl = sysConfig.xcApiUrlPre;
/*获取播放地址*/
export const get_media = (courseId,chapter) => {
return http.requestGet(apiUrl+'/api/learning/course/getmedia/'+courseId+'/'+chapter);
}
```
### 配置代理
在 `Nginx` 中的 `ucenter.xuecheng.com` 虚拟主机中配置 `/api/learning/` 的路径转发,此`url` 请转发到学习服务。
```js
#学习服务
upstream learning_server_pool{
server 127.0.0.1:40600 weight=10;
}
#学成网用户中心
server {
listen 80;
server_name ucenter.xuecheng.com;
#个人中心
location / {
proxy_pass http://ucenter_server_pool;
}
#后端搜索服务
location /openapi/search/ {
proxy_pass http://search_server_pool/search/;
}
#学习服务
location ^~ /api/learning/ {
proxy_pass http://learning_server_pool/learning/;
}
}
```
### 视频播放页面
1、如果传入的课程计划id为0则取出第一个课程计划id
在 `created` 钩子方法中完成
```js
created(){
//当前请求的url
this.url = window.location
//课程id
this.courseId = this.$route.params.courseId
//章节id
this.chapter = this.$route.params.chapter
//查询课程信息
systemApi.course_view(this.courseId).then((view_course)=>{
if(!view_course || !view_course[this.courseId]){
this.$message.error("获取课程信息失败,请重新进入此页面!")
return ;
}
let courseInfo = view_course[this.courseId]
console.log(courseInfo)
this.coursename = courseInfo.name
if(courseInfo.teachplan){
console.log("准备开始播放视频")
let teachplan = JSON.parse(courseInfo.teachplan);
this.teachplanList = teachplan.children;
//开始学习
if(this.chapter == "0" || !this.chapter){
//取出第一个教学计划
this.chapter = this.getFirstTeachplan();
console.log("第一个教学计划id为 ",this.chapter);
this.study(this.chapter);
}else{
this.study(this.chapter);
}
}
})
},
```
取出第一个章节 `id`,用户未输入课程计划 `id ` 或者输入为 `0` 时,播放第一个。
```js
//取出第一个章节
getFirstTeachplan(){
for(var i=0;i<this.teachplanList.length;i++){
let firstTeachplan = this.teachplanList[i];
//如果当前children存在,则取出第一个返回
if(firstTeachplan.children && firstTeachplan.children.length>0){
let secondTeachplan = firstTeachplan.children[0];
return secondTeachplan.id;
}
}
return ;
},
```
开始学习:
```js
//开始学习
study(chapter){
// 获取播放地址
courseApi.get_media(this.courseId,chapter).then((res)=>{
if(res.success){
let fileUrl = sysConfig.videoUrl + res.fileUrl
//播放视频
this.playvideo(fileUrl)
}else if(res.message){
this.$message.error(res.message)
}else{
this.$message.error("播放视频失败,请刷新页面重试")
}
}).catch(res=>{
this.$message.error("播放视频失败,请刷新页面重试")
});
},
```
2、点击右侧课程章节切换播放
在原有代码基础上添加 `click` 事件,点击调用开始学习方法(`study`)。
```html
<li v‐if="teachplan_first.children!=null" v‐for="(teachplan_second, index) in
teachplan_first.children"><i class="glyphicon glyphicon‐check"></i>
<a :href="url" @click="study(teachplan_second.id)">
{{teachplan_second.pname}}
</a>
</li>
```
3、地址栏路由url变更
这里需要注意一个问题,在用户点击课程章节切换播放时,地址栏的 `url` 也应该同步改变为当前所选择的课程计划 `id`
```
```
4、在线学习按钮
将 `learnstatus` 默认更改为 `1`,这样就能显示出马上学习的按钮,方便我们后续的集成测试。
文件路径为 `xc-ui-pc-static-portal/include/course_detail_dynamic.html` 部分代码块如下
```js
<script>
var body= new Vue({ //创建一个Vue的实例
el: "#body", //挂载点是id="app"的地方
data: {
editLoading: false,
title:'测试',
courseId:'',
charge:'',//203001免费,203002收费
learnstatus: 1 ,//课程状态,1:马上学习,2:立即报名、3:立即购买
course:{},
companyId:'template',
company_stat:[],
course_stat:{"s601001":"","s601002":"","s601003":""}
},
```
### 简单的测试
访问在线学习页面:`http://ucenter.xuecheng.com/#/learning/课程id/课程计划id`
通过 `url` 传入两个参数:`课程id` 和 `课程计划id`
如果没有课程计划则传入0
测试项目如下:
1、传入正确的课程id、课程计划id,自动播放本章节的视频
2、传入正确的课程id、课程计划id传入0,自动播放第一个视频
3、传入错误的课程id 或 课程计划id,提示错误信息。
4、通过右侧章节目录切换章节及播放视频。
访问: http://ucenter.xuecheng.com/#/learning/4028e58161bcf7f40161bcf8b77c0000/4028e58161bd18ea0161bd1f73190008
传入正确的课程id、课程计划id,自动播放本章节的视频

传入正确的课程id、课程计划id传入0,自动播放第一个视频
访问 http://ucenter.xuecheng.com/#/learning/4028e58161bcf7f40161bcf8b77c0000/0
识别出第一个课程计划的 `id`

需要注意的是这里的 `chapter` 参数是我自己在 `study` 函数里加上去的,可以忽略。
传入错误的课程id或课程计划id,提示错误信息。

通过右侧章节目录切换章节及播放视频。
点击章节即可播放,但是点击制定章节后 `url` 没有发生改变,这个问题暂时还没有解决,关注笔记后面的内容。

### 完整的测试
准备工作
- 启动 `RabbitMQ`,启动 `Logstash`、`ElasticSearch`
- 建议把所有后端服务都开起来
- 启动 前端静态门户、启动 `nginx` 、启动课程管理前端
我们整理一下测试的流程
1. 上传两个媒资视频文件,用于测试
2. 进入到课程管理,为课程计划选择媒资信息
3. 发布课程,等待 `logstash` 将数据采集到 `ElasticSearch` 的索引库中
4. 进入学成网主页,点击课程,进入到搜索门户页面
5. 搜索课程,进入到课程详情页面
6. 点击开始学习,进入到课程学习页面,选择课程计划中的一个章节进行学习。
#### 1、上传文件
首先我们使用之前开发的媒资管理模块,上传两个视频文件用于测试。
第一个文件上传成功

##### 一些问题
在上传第二个文件时,发生了错误,我们来检查一下问题出在了哪里

在媒体服务的控制台中可以看到,在 `mergeChunks` 方法在校验文件 `md5` 时候抛出了异常
我们在 `MD5` 校验这里打个断点,重新上传文件,分析一下问题所在。

单步调试后发现,合并文件后的MD5值与用户上传的源文件值不相等

##### ~~方案1:删除本地分块文件重新尝试上传~~
考虑到可能是在用户上传完 视频的分块文件时发生了一些问题,导致合并文件后与源文件的大小不等,导致MD5也不相同,这里我们把这个视频上传到本地的文件全部删除,在媒资上传页面重新上传文件。
对比所有分块文件的字节大小和本地源文件的大小,完全是相等的

> 删除所有文件后重新上传,md5值还是不等,考虑从调试一下文件合并的代码。
##### 方案2:检查前端提交的MD5值是否正确
在查阅是否有其他的MD5值获取方案时,发现了一个使用 `windows` 本地命令获取文件MD5值的方法
```
certutil -hashfile .\19-在线学习接口-集成测试.avi md5
```
惊奇的发现,TM的原来是前端那边转换的MD5值不正确,后端这边是没有问题的。

从前面的图可以看出,本地和后端转换的都是以一个 `f6f0` 开头的MD5值

那么问题就出现在前端了,还需要花一些时间去分析一下,这里暂时就先告一段落,因为上传了几个文件测试中只有这一个文件出现了问题。
#### 2、为课程计划选择媒资信息
进入到一个课程的管理页面
http://localhost:12000/#/course/manage/baseinfo/4028e58161bcf7f40161bcf8b77c0000
将刚才我们上传的媒资文件的信息和课程计划绑定

选择效果如下

2、发布课程,等待 `logstash` 从 `course_pub` 以及 `teachplan_media_pub` 表中采集数据到 `ElasticSearch` 当中

发布成功后,我们可以从 `teachplan_media_pub` 表中看到刚才我们发布的媒资信息

再观察 Logstash 的控制台,发现两个 Logstash 的实例都对更新的课程发布信息进行了采集

#### 3、前端门户测试
打开我们的门户主站 `http://www.xuecheng.com/`

点击导航栏的课程,进入到我们的搜索门户页面
> 如果无法进入到搜索门户,请检查你的 xc-ui-pc-portal 前端工程是否已经启动
进入到搜索门户后,可以看到一些初始化时搜索的课程数据,默认是搜索第一页的数据,每页2个课程。

我们可以测试搜索一下前面我们选择媒资信息时所用的课程

点击课程,进入到课程详情页面,然后再点击开始学习。

点击马上学习后,会进入到该课程的在线学习页面,默认自动播放我们第一个课程计划中的视频。

我们可以在右侧的目录中选择第二个课程计划,会自动播放所选的课程计划所对应的媒资视频播放地址,该 播放地址正是我们刚才通过 `Logstash` 自动采集到 `ElasticSearch` 的索引信息,效果图如下

# 四、待完善的一些功能
- [ ] 课程发布前,校验课程计划里面是否包含二级课程计划
- [ ] 课程发布前,校验课程计划信息里面是否全部包含媒资信息
- [ ] 删除媒资信息,并且同步删除ES中的索引
- [ ] 在获取该课程的播放地址时校验用户的合法、
- [ ] 在线学习页面,点击右侧目录中的课程计划同时改变url中的课程计划地址
- [ ] 视频文件 `19-在线学习接口-集成测试.avi` 前端上传时提交的MD5值不正确
# 😁 认识作者
作者:👦 LCyee ,一个向往体面生活的代码🐕
自建博客:[https://www.codeyee.com](https://www.codeyee.com)
> 记录学习以及项目开发过程中的笔记与心得,记录认知迭代的过程,分享想法与观点。
CSDN 博客:[https://blog.csdn.net/codeyee](https://blog.csdn.net/codeyee)
> 记录和分享一些开发过程中遇到的问题以及解决的思路。
欢迎加入微服务练习生的队伍,一起交流项目学习过程中的一些问题、分享学习心得等,不定期组织一起刷题、刷项目,共同见证成长。

![微服务[学成在线] day15:媒资管理系统集成](https://qnoss.codeyee.com/TIM%E5%9B%BE%E7%89%8720200721182350_1595327150222.jpg)
微服务[学成在线] day15:媒资管理系统集成