微服务[学成在线] day06:页面发布以及课程管理

时间:2022-07-22
本文章向大家介绍微服务[学成在线] day06:页面发布以及课程管理,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

? 知识点概览

为了方便后续回顾该项目时能够清晰的知道本章节讲了哪些内容,并且能够从该章节的笔记中得到一些帮助,所以在完成本章节的学习后在此对本章节所涉及到的知识点进行总结概述。

本章节为【学成在线】项目的 day06 的内容

  • 使用 Spring boot 集成 RabbitMQGridFS 实现基于生产者和消费者模型的页面静态化发布的流程。 在本章节的知识点中,再次复习了基于 GridFSRabbitMQ 的分布式静态页面发布的知识点,深化了记忆。
  • 使用三级菜单实现课程计划的查询和添加 这里的技术点不是很多,用到了 Mysql 的表内自连接查询,以及在添加课程的时候,需要考虑一些意外情况的发生,例如再添加课程时,如果该课程的根节点(一级菜单)不存在,则需要为该课程添加一个根节点后再进行该二级节点的添加。一、页面发布

0x01 技术方案

本项目使用MQ实现页面发布的技术方案如下:

**技术方案说明 **

1、平台包括多个站点,页面归属不同的站点。

2、发布一个页面应将该页面发布到所属站点的服务器上。

3、每个站点服务部署 cms client 程序,并与交换机绑定,绑定时指定站点Id为routingKey。

指定站点id为 routingKey 就可以实现 cms client 只能接收到所属站点的页面发布消息。

4、页面发布程序向MQ发布消息时指定页面所属站点 IdroutingKey,将该页面发布到它所在服务器上的cms client

路由模式分析如下

发布一个页面,需发布到该页面所属的每个站点服务器,其它站点服务器不发布。

比如 发布一个门户的页面,需要发布到每个门户服务器上,而用户中心服务器则不需要发布。

所以本项目采用 routing 模式,用站点 id 作为 routingKey,这样就可以匹配页面只发布到所属的站点服务器上。

页面发布流程图如下

1、前端请求 cms 执行页面发布。

2、cms 执行静态化程序生成 html文件。

3、cmshtml 文件存储到 GridFS 中。

4、cmsMQ 发送页面发布消息

5、MQ 将页面发布消息通知给 Cms Client

6、Cms ClientGridFS 中下载 html 文件

7、Cms Clienthtml 保存到所在服务器指定目录

0x02 页面发布消费方

需求分析

功能分析

创建 Cms Client 工程作为页面发布消费方,将 Cms Client 部署在多个服务器上,它负责接收到页面发布 的消息后从 GridFS 中下载文件在本地保存。

需求如下

1、将 cms Client 部署在服务器,配置队列名称和站点 ID

2、cms Client 连接 RabbitMQ 并监听各自的“页面发布队列”

3、cms Client 接收页面发布队列的消息

4、根据消息中的页面 idmongodb 数据库下载页面到本地

创建Cms Client工程

1、POM配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <artifactId>xc-framework-parent</artifactId>
        <groupId>com.xuecheng</groupId>
        <version>1.0-SNAPSHOT</version>
        <relativePath>../xc-framework-parent/pom.xml</relativePath>
    </parent>

    <artifactId>xc-service-manage-cms-client</artifactId>

    <!--项目依赖-->
    <dependencies>
        <dependency>
            <groupId>com.xuecheng</groupId>
            <artifactId>xc-framework-model</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-io</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
    </dependencies>
</project>

2、配置文件

resources下配置 application.yml

server:
  port: 31000
spring:
  application:
    name: xc-service-manage-cms-client
  data:
    mongodb:
      uri: mongodb://root:123123@localhost:27017
      database: xc_cms
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    username: guest
    password: guest
    virtual-host: /
  freemarker:
    cache: false #关闭模板缓存,方便测试
    settings:
      template_update_delay: 0
xuecheng:
  mq:
  #cms客户端监控的队列名称(不同的客户端监控的队列不能重复)
    queue: queue_cms_postpage_01
    routingKey: 5a751fab6abb5044e0d19ea1 #此routingKey为门户站点ID3

说明 在配置文件中配置队列的名称,每个 cms client在部署时注意队列名称不要重复

3、启动类

package com.xuecheng.manage_cms_client;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan(basePackages={"com.xuecheng.framework"})//扫描common下的所有类
@ComponentScan(basePackages={"com.xuecheng.manage_cms_client"})
public class ManageCmsClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(ManageCmsClientApplication.class,args);
    }
}

RabbitmqConfig 配置类

消息队列设置如下

1、创建 ex_cms_postpage 交换机

2、每个 Cms Client 创建一个队列与交换机绑定

3、每个 Cms Client 程序配置队列名称和 routingKey,将站点ID作为routingKey

package com.xuecheng.manage_cms_client.config;

import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitmqConfig {
    //队列bean的名称
    public static final String QUEUE_CMS_POSTPAGE = "queue_cms_postpage";
    //交换机的名称
    public static final String EX_ROUTING_CMS_POSTPAGE = "ex_routing_cms_postpage";

    //队列的名称
    @Value("${xuecheng.mq.queue}")
    public String queue_cms_postpage_name;
    //站点id作为routing key
    @Value("${xuecheng.mq.routingKey}")
    public String routingKey;

    /**
     * 配置direct交换机
     * @return
     */
    @Bean(EX_ROUTING_CMS_POSTPAGE)
    public Exchange EXCHANGE_DIRECT_INFORM(){
        return ExchangeBuilder.directExchange(EX_ROUTING_CMS_POSTPAGE).durable(true).build();
    }

    /**
     * 声明队列
     */
    @Bean(QUEUE_CMS_POSTPAGE)
    public Queue QUEUE_CMS_POSTPAGE(){
        Queue queue = new Queue(queue_cms_postpage_name);
        return queue;
    }

    /**
     * 绑定队列到交换机
     * @param queue
     * @param exchange
     * @return
     */
    @Bean
    public Binding BINDING_QUEUE_INFORM_SMS(@Qualifier(QUEUE_CMS_POSTPAGE) Queue queue,
                                            @Qualifier(EX_ROUTING_CMS_POSTPAGE) Exchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with(routingKey).noargs();
    }

    
}

配置 MongoConfig

后续的代码中需要将GridFSBucket注入成bean

package com.xuecheng.manage_cms_client.config;


import com.mongodb.MongoClient;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.gridfs.GridFSBucket;
import com.mongodb.client.gridfs.GridFSBuckets;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MongoConfig {
    @Value("${spring.data.mongodb.database}")
    String db;

    @Bean
    public GridFSBucket getGridFSBucket(MongoClient mongoClient){
        MongoDatabase database = mongoClient.getDatabase(db);
        GridFSBucket gridFSBucket = GridFSBuckets.create(database);
        return gridFSBucket;
    }
}

定义消息格式

消息内容采用 json 格式存储数据,如下

页面id 发布页面的id

{
    "pageId":""
}

PageDao

1、使用 CmsPageRepository 查询页面信息

public interface CmsPageRepository extends MongoRepository<CmsPage,String> {}

2、使用 CmsSiteRepository 查询站点信息,主要获取站点物理路径

public interface CmsSiteRepository extends MongoRepository<CmsSite,String> {}

PageService

package com.xuecheng.manage_cms_client.service;

import com.mongodb.client.gridfs.GridFSBucket;

import com.mongodb.client.gridfs.GridFSDownloadStream;
import com.mongodb.client.gridfs.model.GridFSFile;
import com.xuecheng.framework.domain.cms.CmsPage;
import com.xuecheng.framework.domain.cms.CmsSite;
import com.xuecheng.framework.exception.ExceptionCast;
import com.xuecheng.framework.model.response.CmsCode;
import com.xuecheng.manage_cms_client.dao.CmsPageRepository;
import com.xuecheng.manage_cms_client.dao.CmsSiteRepository;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.gridfs.GridFsResource;
import org.springframework.data.mongodb.gridfs.GridFsTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;

@Service
public class PageService {
    @Autowired
    CmsPageRepository cmsPageRepository;

    @Autowired
    CmsSiteRepository cmsSiteRepository;

    @Autowired
    GridFsTemplate gridFsTemplate;

    @Autowired
    GridFSBucket gridFSBucket;

    /**
     * 将页面html保存到页面物理路径
     * @param pageId
     */
    public void savePageToServerPath(String pageId){
        Optional<CmsPage> optional = cmsPageRepository.findById(pageId);
        //页面不存在则抛出异常
        if(!optional.isPresent()){
            ExceptionCast.cast(CmsCode.CMS_PAGE_NOT_EXISTS);
        }

        CmsPage cmsPage = optional.get();
        CmsSite cmsSite = this.getCmsSiteById(cmsPage.getSiteId());
        if(cmsSite == null){
            ExceptionCast.cast(CmsCode.CMS_SIZE_NOT_EXISTS);
        }

        //原讲义和视频中使用的是sizePathysicaiPath,但是SizePage中没有这个字段,使用PageCms中的PathysicaiPath代替
        String pagePath = cmsPage.getPagePhysicalPath() + cmsPage.getPageWebPath() + cmsPage.getPageName();

        //获取页面文件id
        String htmlFileId = cmsPage.getHtmlFileId();
        if(StringUtils.isEmpty(htmlFileId)){
            ExceptionCast.cast(CmsCode.CMS_PAGE_FILEID_NOT_EXISTS);
        }
        //获取文件流
        InputStream inputStream = this.getFileInputStreamFileById(htmlFileId);
        if(inputStream == null){
            ExceptionCast.cast(CmsCode.CMS_GENRATEHTML_HTML_IS_NULL);
        }

        FileOutputStream fileOutputStream = null;
        try {
            fileOutputStream = new FileOutputStream(new File(pagePath));
            //将文件保存至物理路径
            IOUtils.copy(inputStream,fileOutputStream);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            //关闭流
            try {
                inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            } try {
                fileOutputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    //根据文件id获取文件的gridFS输入流
    private InputStream getFileInputStreamFileById(String fileId){
        GridFSFile gridFSFile = gridFsTemplate.findOne(Query.query(Criteria.where("_id").is(fileId)));
        //获取gridFs下载流
        GridFSDownloadStream gridFSDownloadStream = gridFSBucket.openDownloadStream(gridFSFile.getObjectId());
        try {
            //获取文件输入流
            GridFsResource gridFsResource = new GridFsResource(gridFSFile, gridFSDownloadStream);
            return gridFsResource.getInputStream();
        }catch (IOException e){
            e.printStackTrace();
        }
        return null;
    }

    //根据站点id得到站点信息
    private CmsSite getCmsSiteById(String siteId){
        Optional<CmsSite> optional = cmsSiteRepository.findById(siteId);
        if(!optional.isPresent()){
            return null;
        }
        CmsSite cmsSite = optional.get();
        return cmsSite;
    }
}

ConsumerPostPage

cms client 工程的 mq 包下创建 ConsumerPostPage 类,ConsumerPostPage 作为发布页面的消费客户端,监听页面发布队列的消息,收到消息后从 mongodb 下载文件,保存在本地。

package com.xuecheng.manage_cms_client.mq;


import com.alibaba.fastjson.JSON;
import com.xuecheng.framework.domain.cms.CmsPage;
import com.xuecheng.manage_cms_client.dao.CmsPageRepository;
import com.xuecheng.manage_cms_client.service.PageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.Optional;

@Component
public class ConsumerPostPage {
    private static final Logger LOGGER = LoggerFactory.getLogger(ConsumerPostPage.class);

    @Autowired
    CmsPageRepository cmsPageRepository;
    @Autowired
    PageService pageService;

    @RabbitListener(queues = {"${xuecheng.mq.queue}"}) //监听队列
    public void postPage(String msg){
        //解析消息
        Map map = JSON.parseObject(msg, Map.class);
        LOGGER.info("receive cms post page:{}", msg.toString());
        //取出页面id
        String pageId = (String) map.get("pageId");

        //查询页面信息
        Optional<CmsPage> optional = cmsPageRepository.findById(pageId);
        if(!optional.isPresent()){
            LOGGER.error("receive cms post page,cmsPage is null: {}",msg.toString());
        }

        //将页面保存到服务器物理路径
        pageService.savePageToServerPath(pageId);
    }
}

0x03 页面发布生产方

需求分析

管理员通过 cms 系统发布 “页面发布” 的消费,cms 系统作为页面发布的生产方。 需求如下

1、管理员进入管理界面点击“页面发布”,前端请求cms页面发布接口。

2、cms页面发布接口执行页面静态化,并将静态化页面存储至GridFS中。

3、静态化成功后,向消息队列发送页面发布的消息。

​ 1) 获取页面的信息及页面所属站点ID。

​ 2) 设置消息内容为页面ID。(采用json格式,方便日后扩展)

​ 3) 发送消息给 ex_cms_postpage 交换机,并将站点ID作为 routingKey

RabbitMQ配置

1、配置 Rabbitmq 的连接参数

application.yml 添加如下配置

spring:
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    username: guest
    password: guest
    virtualHost: /

2、在 pom.xml 添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring‐boot‐starter‐amqp</artifactId>
</dependency>

3、RabbitMQConfig 配置

由于 cms 作为页面发布方要面对很多不同站点的服务器,面对很多页面发布队列,所以这里不再配置队列,只需要配置交换机名称即可。

package com.xuecheng.manage_cms.config;

import com.xuecheng.framework.exception.ExceptionCast;
import org.springframework.amqp.core.Exchange;
import org.springframework.amqp.core.ExchangeBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitmqConfig {
    //交换机的名称
    public static final String EX_ROUTING_CMS_POSTPAGE="ex_routing_cms_postpage";

    /**
     * 交换配置使用direct类型
     */
    @Bean(EX_ROUTING_CMS_POSTPAGE)
    public Exchange EXCHANGE_TOPICS_INFORM(){
        return ExchangeBuilder.directExchange(EX_ROUTING_CMS_POSTPAGE).durable(true).build();
    }
}

Api 接口

在api工程定义页面发布接口

@ApiOperation("发布页面")
public ResponseResult post(String pageId);

PageService

package com.xuecheng.manage_cms.service;

import com.alibaba.fastjson.JSON;
import com.mongodb.client.gridfs.GridFSBucket;
import com.mongodb.client.gridfs.GridFSDownloadStream;
import com.mongodb.client.gridfs.model.GridFSFile;
import com.xuecheng.framework.domain.cms.CmsPage;
import com.xuecheng.framework.domain.cms.CmsTemplate;
import com.xuecheng.framework.domain.cms.request.QueryPageRequest;
import com.xuecheng.framework.domain.cms.response.CmsPageResult;
import com.xuecheng.framework.exception.ExceptionCast;
import com.xuecheng.framework.model.response.*;
import com.xuecheng.manage_cms.config.RabbitmqConfig;
import com.xuecheng.manage_cms.dao.CmsPageRepository;
import com.xuecheng.manage_cms.dao.CmsTemplateRepository;
import freemarker.cache.StringTemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import io.netty.util.internal.StringUtil;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.bson.types.ObjectId;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.*;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.gridfs.GridFsResource;
import org.springframework.data.mongodb.gridfs.GridFsTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Service
public class PageService {

    @Autowired
    CmsPageRepository cmsPageRepository;

    @Autowired
    RestTemplate restTemplate;

    @Autowired
    CmsTemplateRepository templateRepository;

    @Autowired
    GridFsTemplate gridFsTemplate;

    @Autowired
    GridFSBucket gridFSBucket;

    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 页面发布
     * @param pageId
     * @return
     */
    public ResponseResult postPage(String pageId) {
        //执行静态化
        String pageHtml = null;
        try {
            pageHtml = this.getPageHtml(pageId);
        } catch (IOException e) {
            e.printStackTrace();
        } catch (TemplateException e) {
            e.printStackTrace();
        }
        if(StringUtils.isEmpty(pageHtml)){
            ExceptionCast.cast(CmsCode.CMS_GENRATEHTML_HTML_IS_NULL);
        }

        //保存静态化文件
        CmsPage cmsPage = this.saveHtml(pageId, pageHtml);
        //发送消息
        this.sendPostPage(pageId);
        return new ResponseResult(CommonCode.SUCCESS);
    }

    //发送页面发布消息
    private void sendPostPage(String pageId){
        CmsPageResult cmsPageResult = this.cmsPageQueryById(pageId);
        CmsPage cmsPage = cmsPageResult.getCmsPage();
        if(cmsPage == null){
            ExceptionCast.cast(CmsCode.CMS_PAGE_NOT_EXISTS);
        }
        Map<String, String> msgMap = new HashMap<>();
        msgMap.put("pageId",pageId);
        //消息内容
        String msg = JSON.toJSONString(msgMap);
        //获取站点id作为routing key
        String siteId = cmsPage.getSiteId();
        //发送消息到指定交换机、routingKey、以及要发送的消息
        rabbitTemplate.convertAndSend(RabbitmqConfig.EX_ROUTING_CMS_POSTPAGE,siteId,msg);
    }

    //保存静态化页面内容
    private CmsPage saveHtml(String pageId,String content){
        //查询页面是否存在
        Optional<CmsPage> optional = cmsPageRepository.findById(pageId);
        if(!optional.isPresent()){
            ExceptionCast.cast(CmsCode.CMS_PAGE_NOT_EXISTS);
        }

        CmsPage cmsPage = optional.get();
        //储存之前先删除
        String htmlFileId = cmsPage.getHtmlFileId();
        if(StringUtils.isNotEmpty(htmlFileId)){
            gridFsTemplate.delete(Query.query(Criteria.where("_id").is(htmlFileId)));
        }
        //保存html文件到GridFS
        try {
            InputStream inputStream = IOUtils.toInputStream(content,"utf-8");
            ObjectId objectId = gridFsTemplate.store(inputStream, cmsPage.getPageName());
            //文件id
            String fileId = objectId.toString();
            //将文件id储存到cmspage中
            cmsPage.setHtmlFileId(fileId);
            cmsPageRepository.save(cmsPage);
        } catch (IOException e) {
            e.printStackTrace();
        }

        return cmsPage;
    }


    /**
     * 页面静态化
     * @param pageId
     * @return
     * @throws IOException
     * @throws TemplateException
     */
    public String getPageHtml(String pageId) throws IOException, TemplateException {
        //获取页面模型数据
        Map modelByPageId = this.getModelByPageId(pageId);
        if(modelByPageId == null){
            //获取页面模型数据为空
            ExceptionCast.cast(CmsCode.CMS_GENRATEHTML_DATA_IS_NULL);
        }
        //获取页面模板
        String templateContent = getTemplateByPageId(pageId);
        if(StringUtils.isEmpty(templateContent)){
            //页面模板为空
            ExceptionCast.cast(CmsCode.CMS_GENRATEHTML_TEMPLATE_IS_NULL);
        }
        //构建页面静态化数据
        String html = generateHtml(templateContent, modelByPageId);
        if(StringUtils.isEmpty(html)){
            ExceptionCast.cast(CmsCode.CMS_GENRATEHTML_HTML_IS_NULL);
        }
        return html;
    }

    //构建静态化页面数据
    private String generateHtml(String template,Map model) throws IOException, TemplateException {
        //生成配置类
        Configuration configuration = new Configuration(Configuration.getVersion());
        //模板加载器
        StringTemplateLoader stringTemplateLoader = new StringTemplateLoader();
        stringTemplateLoader.putTemplate("template",template);
        //配置模板加载器
        configuration.setTemplateLoader(stringTemplateLoader);

        //获取模板
        Template template1 = configuration.getTemplate("template");
        String html = FreeMarkerTemplateUtils.processTemplateIntoString(template1, model);
        return html;
    }

    //获取页面模板文件数据
    private String getTemplateByPageId(String pageId){
        //查询页面信息
        CmsPageResult cmsPageResult = this.cmsPageQueryById(pageId);
        CmsPage cmsPage = cmsPageResult.getCmsPage();
        //页面不存在
        if(cmsPage == null){
            ExceptionCast.cast(CmsCode.CMS_PAGE_NOT_EXISTS);
        }
        //获取页面模板数据
        String templateId = cmsPage.getTemplateId();
        if(StringUtils.isEmpty(templateId)){
            ExceptionCast.cast(CmsCode.CMS_GENRATEHTML_TEMPLATE_IS_NULL);
        }
        Optional<CmsTemplate> optional = templateRepository.findById(templateId);
        if(optional.isPresent()){
            CmsTemplate cmsTemplate = optional.get();
            //获取模板文件id
            String templateFileId = cmsTemplate.getTemplateFileId();
            //取出模板文件内容
            GridFSFile gridFSFile = gridFsTemplate.findOne(Query.query(Criteria.where("_id").is(templateFileId)));
            //打开下载流对象
            GridFSDownloadStream gridFSDownloadStream = gridFSBucket.openDownloadStream(gridFSFile.getObjectId());
            //创建GridResource
            GridFsResource gridFsResource = new GridFsResource(gridFSFile, gridFSDownloadStream);

            try {
                String content = IOUtils.toString(gridFsResource.getInputStream(), "utf-8");
                return content;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    //从dataUrl中获取页面模型数据
    private Map getModelByPageId(String pageId){
        //查询页面信息
        CmsPageResult cmsPageResult = this.cmsPageQueryById(pageId);
        CmsPage cmsPage = cmsPageResult.getCmsPage();
        //页面不存在
        if(cmsPage == null){
            ExceptionCast.cast(CmsCode.CMS_PAGE_NOT_EXISTS);
        }
        //取出dataUrl
        String dataUrl = cmsPage.getDataUrl();
        if(StringUtils.isEmpty(dataUrl)){
            ExceptionCast.cast(CmsCode.CMS_GENRATEHTML_DATAURL_IS_NULL);
        }
        //发送请求获取模型数据
        ResponseEntity<Map> forEntity = restTemplate.getForEntity(dataUrl,Map.class);
        Map body = forEntity.getBody();
        return body;
    }


    /**
     * 分页查询、页面CURL 部分代码略过,需要请参考前面的章节
     */
 	
    /**
     * 根据id获取页面数据
     */
    public CmsPageResult cmsPageQueryById(String id) {
        Optional<CmsPage> optional = cmsPageRepository.findById(id);
        if (optional.isPresent()) {
            CmsPage cmsPage = optional.get();
            return new CmsPageResult(CommonCode.SUCCESS, cmsPage);
        }
        return new CmsPageResult(CommonCode.FAIL, null);
    }
	
}

0x04 页面发布前端

用户操作流程

1、用户进入 cms 页面列表。

2、点击 “发布” 请求服务端接口,发布页面。

3、提示 “发布成功”,或发布失败。

API方法

在 cms前端添加 api方法。

/*发布页面*/
export const page_postPage= id => {
  return http.requestPost(apiUrl+'/cms/page/postPage/'+id)
}

页面开发

修改 page_list.vue,添加发布按钮

<el‐table‐column label="发布" width="80">
  <template slot‐scope="scope">
    <el‐button
      size="small" type="primary" plain @click="postPage(scope.row.pageId)">发布
    </el‐button>
  </template>
</el‐table‐column>

添加页面发布事件

postPage (id) {
  this.$confirm('确认发布该页面吗?', '提示', {
  }).then(() => {
    cmsApi.page_postPage(id).then((res) => {
      if(res.success){
        console.log('发布页面id='+id);
        this.$message.success('发布成功,请稍后查看结果');
      }else{
        this.$message.error('发布失败');
      }
    });
  }).catch(() => {
  });
},

0x05 测试

这里测试轮播图页面修改、发布的流程

1、修改轮播图页面模板或修改轮播图地址

注意 先修改页面原型,页面原型调试正常后再修改页面模板。

2、执行页面预览

3、执行页面发布,查看页面是否写到网站目录

4、刷新门户首页并观察轮播图是否变化。

0x06 思考

1、如果发布到服务器的页面内容不正确怎么办?

2、一个页面需要发布很多服务器,点击“发布”后如何知道详细的发布结果?

3、一个页面发布到多个服务器,其中有一个服务器发布失败时怎么办?

二、课程管理

0x01 需求分析

在线教育平台的课程信息相当于电商平台的商品。课程管理是后台管理功能中最重要的模块。本项目为教学机构提供课程管理功能,教学机构可以添加属于自己的课程,供学生在线学习。

课程管理包括如下功能需求

1、分类管理 2、新增课程 3、修改课程 4、预览课程 5、发布课程

用户的操作流程如下

1、进入我的课程

2、点击“添加课程”,进入添加课程界面

3、输入课程基本信息,点击提交

4、课程基本信息提交成功,自动进入“管理课程”界面,点击“管理课程”也可以进入“管理课程”界面

5、编辑图片上传课程图片。

6、编辑课程营销信息

营销信息主要是设置课程的收费方式及价格。

7、编辑课程计划

0x02 教学方法

本模块对课程信息管理功能的教学方法采用实战教学方法,旨在通过实战提高接口编写的能力,具体教学方法如下:

1、前后端工程导入

教学管理前端工程采用与系统管理工程相同的技术,直接导入后在此基础上开发。 课程管理服务端工程采用Spring Boot技术构建,技术层技术使用Spring data Jpa(与Spring data Mongodb类 似)、Mybatis,直接导入后在此基础上开发。

2、课程计划功能

课程计划功能采用全程教学。

3、我的课程、新增课程、修改课程、课程营销

我的课程、新增课程、修改课程、课程营销四个功能采用实战方式,课堂上会讲解每个功能的需求及技术点,讲解完成学生开始实战,由导师进行技术指导。

4、参考文档

实战结束提供每个功能的开发文档,学生参考文档并修正功能缺陷。

0x03 环境搭建

1、搭建数据库环境

  1. 创建数据库

课程管理使用 MySQL 数据库,创建课程管理数据库:xc_course

导入 xc_course.sql 脚本

  1. 数据表介绍

课程信息内容繁多,将课程信息分类保存在如下表中:

分类储存既可以提高解耦合度,也可以保证数据的完整性。

数据表结构如下:

2、导入课程管理服务工程

1)持久层技术介绍:

课程管理服务使用MySQL数据库存储课程信息,持久层技术如下:

1、spring data jpa:用于表的基本CRUD。

2、mybatis:用于复杂的查询操作。

3、druid:使用阿里巴巴提供的spring boot 整合druid包druid-spring-boot-starter管理连接池。

druid-spring-boot-starter地址:https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter

为什么持久层用到了 JPA 还要用 Mybatis ?

因为 JPA 是面向对象进行开发的,对于一些复杂的sql操作,优化起来会比较麻烦:

而 Mybatis 是面向sql的,对一些复杂的多表操作比较友好。

导入资料下的 “xc-service-manage-course.zip”。

3、导入课程管理前端工程

课程管理属于教学管理子系统的功能,使用用户为教学机构的管理人员和老师,为保证系统的可维护性,单独创建一个教学管理前端工程。 教学管理前端工程与系统管理前端的工程结构一样,也采用 vue.js 框架来实现。

从课程资料目录拷贝 xc-ui-pc-teach.zip 到工程,使用 webstorm 打开,启动工程

效果图如下:

三、课程计划

0x01 需求分析

什么是课程计划?

课程计划定义了课程的章节内容,学生通过课程计划进行在线学习,下图中右侧显示的就是课程计划。

课程计划包括两级,第一级是课程的大章节、第二级是大章节下属的小章节,每个小章节通常是一段视频,学生点击小章节在线学习。

教学管理人员对课程计划如何管理?

功能包括:添加课程计划、删除课程计划、修改课程计划等。例如:

0x02 课程计划查询

课程计划查询是将某个课程的课程计划内容完整的显示出来,如下图所示:

左侧显示的就是课程计划,课程计划是一个树型结构,方便扩展课程计划的级别。

在上边页面中,点击“添加课程计划”即可对课程计划进行添加操作。

点击修改可对某个章节内容进行修改。

点击删除可删除某个章节。

页面原型

tree组件介绍

本功能使用 element-uitree 组件来完成

course_plan.vue 文件中添加 tree 组件的代码,进行测试:

1、组件标签

<el‐tree
      :data="data"
      show‐checkbox
      node‐key="id"
      default‐expand‐all
      :expand‐on‐click‐node="false"
      :render‐content="renderContent">
</el‐tree>

2、数据对象

let id = 1000;
  export default {
    data() {
      return {
        data : [{
          id: 1,
          label: '一级 1',
          children: [{
            id: 4,
            label: '二级 1‐1',
            children: [{
              id: 9,
              label: '三级 1‐1‐1'
            }, {
              id: 10,
              label: '三级 1‐1‐2'
            }]
          }]
        }]
      }
     }
 }

webstorm 配置JSX

本组件用到了JSX语法,如下所示:

JSXJavascriptXML 结合的一种格式,它是 React 的核心组成部分,JSXXML 语法类似,可以定义属性以及子元素。唯一特殊的是可以用大括号来加入JavaScript 表达式。遇到 HTML 标签(以 < 开头),就用 HTML 规则解析; 遇到代码块(以 { 开头),就用 JavaScript 规则解析。

下面是官方的一个例子:

设置方法 如下:

1 、Javascript version 选择 React JSX (如果没有就选择JSX Harmony)

2、HTML 类型文件中增加vue

preferences -> Editor -> File Types 中找到上边框中HTML 在下边加一个 *.vue

如果已经在 vue template 中已存在.vue 则把它改为.vue2(因为要在Html中添加.vue)

API接口

数据模型

1、表结构

2、模型类

课程计划为树型结构,由树根(课程)和树枝(章节)组成,为了保证系统的可扩展性,在系统设计时将课程计划设置为树型结构。

@Data
@ToString
@Entity
@Table(name="teachplan")
@GenericGenerator(name = "jpa‐uuid", strategy = "uuid")
public class Teachplan implements Serializable {
    private static final long serialVersionUID = ‐916357110051689485L;
    @Id
    @GeneratedValue(generator = "jpa‐uuid")
    @Column(length = 32)
    private String id;
    private String pname;
    private String parentid;
    private String grade;
    private String ptype;
    private String description;
    private String courseid;
    private String status;
    private Integer orderby;
    private Double timelength;
    private String trylearn;
}

自定义模型类

前端页面需要树型结构的数据来展示Tree组件,如下:

[{
      id: 1,
      label: '一级 1',
      children: [{
        id: 4,
        label: '二级 1‐1'
       }]
     }]

自定义课程计划结点类如下:

@Data
@ToString
public class TeachplanNode extends Teachplan {
    List<TeachplanNode> children;
}

接口定义

根据课程id查询课程的计划接口如下,在api工程创建 course 包,创建CourseControllerApi 接口类并定义接口方法如下:

public interface CourseControllerApi {
    @ApiOperation("课程计划查询")
    public TeachplanNode findTeachplanList(String courseId);
}

课程管理服务

SQL语句

SELECT
  a.id one_id,
  a.pname one_pname,
  b.id two_id,
  b.pname two_pname,
  c.id three_id,
  c.pname three_pname
FROM
  teachplan a
  LEFT JOIN teachplan b
    ON a.id = b.parentid
  LEFT JOIN teachplan c
    ON b.id = c.parentid
WHERE a.parentid = '0'
  AND a.courseid = '402885816243d2dd016243f24c030002'
ORDER BY a.orderby,
  b.orderby,
  c.orderby

DAO

  1. mapper接口
@Mapper
public interface TeachplanMapper {
    public TeachplanNode selectList(String courseId);
}

2)mapper映射文件

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.xuecheng.manage_course.dao.TeachplanMapper">
    <resultMap id="teachplanMap" type="com.xuecheng.framework.domain.course.ext.TeachplanNode">
        <!--一级节点-->
        <id property="id" column="one_id"/>
        <result property="pname" column="one_pname"/>
        <collection property="children" ofType="com.xuecheng.framework.domain.course.ext.TeachplanNode">
            <!--二级节点-->
            <id property="id" column="two_id"/>
            <result property="pname" column="two_pname"/>
            <collection property="children" ofType="com.xuecheng.framework.domain.course.ext.TeachplanNode">
                <!--三级节点-->
                <id property="id" column="three_id"/>
                <result property="pname" column="three_pname"/>
            </collection>
        </collection>
    </resultMap>

    <!--三级菜单查询-->
    <select id="selectList" resultMap="teachplanMap" parameterType="java.lang.String">
        SELECT
            a.id one_id,
            a.pname one_pname,
            a.courseid one_course,
            b.id two_id,
            b.pname two_pname,
            c.id three_id,
            c.pname three_pname
        FROM
            teachplan a
        LEFT JOIN teachplan b
            ON b.parentid = a.id
        LEFT JOIN teachplan c
            ON c.parentid = b.id
        WHERE
            a.parentid = '0'
        <!--判断参数不为空时才进行参数的匹配-->
        <if test="_parameter!=null and _parameter!=''">
            and a.courseid = #{courseId}
        </if>
        ORDER BY a.orderby,
            b.orderby,
            c.orderby
    </select>
</mapper>

说明:针对输入参数为简单类型 #{}中可以是任意类型,判断参数是否为空要用 _parameter(它属于mybatis的内置参数)

Service

@Service
public class CourseService {
    @Autowired
    TeachplanMapper teachplanMapper;
    //查询课程计划
    public TeachplanNode findTeachplanList(String courseId){
        TeachplanNode teachplanNode = teachplanMapper.selectList(courseId);
        return teachplanNode;
    }
}

Controller

@RestController
@RequestMapping("/course")
public class CourseController implements CourseControllerApi {
    @Autowired
    CourseService courseService;
    //查询课程计划
    @Override
    @GetMapping("/teachplan/list/{courseId}")
    public TeachplanNode findTeachplanList(String courseId) {
        return courseService.findTeachplanList(courseId);
    }
}

测试

使用postman或swagger-ui测试查询接口。

Get 请求:http://localhost:31200/course/teachplan/list/402885816243d2dd016243f24c030002

前端页面

API方法

定义课程计划查询的api方法:

/*查询课程计划*/
export const findTeachplanList = courseid => {
  return http.requestQuickGet(apiUrl+'/course/teachplan/list/'+courseid)
}

Api调用

  1. mounted 钩子方法 中查询 课程计划 定义查询课程计划的方法,赋值给数据对象 teachplanList
 findTeachplan(){
     courseApi.findTeachplanList(this.courseid).then((res) =>{
     this.teachplanList = [];//清空树
     if(res.children){
     this.teachplanList = res.children;
     }
 });
 }

2)在mounted钩子中查询课程计划

mounted(){ 
  //课程id
  this.courseid = this.$route.params.courseid;
  //课程计划
  this.findTeachplan();
}

3)修改树结点的标签属性

课程计划信息中 pname 为结点的名称,需要修改树结点的标签属性方可正常显示课程计划名称,如下:

defaultProps: { 
  children: 'children',
  label: 'pname'
}

测试

0x03 添加课程计划

需求分析

用户操作流程

1 、进入课程计划页面,点击“添加课程计划”

2、打开添加课程计划页面,输入课程计划信息

上级结点说明

如果不选择上级节点的话,表示当前添加的课程计划的父节点作为该课程的根节点,也就是说,添加的是二级菜单,如果选择了上级节点,表示添加的是三级菜单,如果添加该课程时,在课程计划中没有该课程的根节点时,要自动将该课程设置为根节点。

页面原型说明

添加课程计划采用弹出窗口组件Dialog。

1、视图部分

在course_plan.vue页面添加添加课程计划的弹出窗口代码:

<el‐dialog title=" 添加课程计划" :visible.sync="teachplayFormVisible" >
      <el‐form ref="teachplayForm"  :model="teachplanActive" label‐width="140px"
style="width:600px;" :rules="teachplanRules" >
        <el‐form‐item label="上级结点" >
          <el‐select v‐model="teachplanActive.parentid" placeholder="不填表示根结点">
            <el‐option
              v‐for="item in teachplanList"
              :key="item.id"
              :label="item.pname"
              :value="item.id">
            </el‐option>
          </el‐select>
        </el‐form‐item>
        <el‐form‐item label="章节/课时名称" prop="pname">
                  <el‐input v‐model="teachplanActive.pname" auto‐complete="off"></el‐input> 
        </el‐form‐item>
        <el‐form‐item label="课程类型" >
          <el‐radio‐group v‐model="teachplanActive.ptype">
            <el‐radio class="radio" label='1'>视频</el‐radio>
            <el‐radio class="radio" label='2'>文档</el‐radio>
          </el‐radio‐group>
        </el‐form‐item>
        <el‐form‐item label="学习时长(分钟)  请输入数字" >
          <el‐input type="number" v‐model="teachplanActive.timelength" auto‐complete="off" ></el‐
input>
        </el‐form‐item>
        <el‐form‐item label="排序字段" >
          <el‐input v‐model="teachplanActive.orderby" auto‐complete="off" ></el‐input>
        </el‐form‐item>
        <el‐form‐item label="章节/课时介绍" prop="description">
          <el‐input type="textarea" v‐model="teachplanActive.description" ></el‐input>
        </el‐form‐item>
        <el‐form‐item label="状态" prop="status">
          <el‐radio‐group v‐model="teachplanActive.status" >
            <el‐radio class="radio" label="0" >未发布</el‐radio>
            <el‐radio class="radio" label='1'>已发布</el‐radio>
          </el‐radio‐group>
        </el‐form‐item>
        <el‐form‐item  >
          <el‐button type="primary" v‐on:click="addTeachplan">提交</el‐button>
          <el‐button type="primary" v‐on:click="resetForm">重置</el‐button>
        </el‐form‐item>
      </el‐form>
    </el‐dialog>

2、数据模型

在数据模型中添加如下变量:

teachplayFormVisible:false,
teachplanRules: {
        pname: [
            {required: true, message: '请输入课程计划名称', trigger: 'blur'}
        ],
            status: [
                {required: true, message: '请选择状态', trigger: 'blur'}
            ]
},
teachplanActive:{},

3、 添加按钮

通过变量 teachplayFormVisible 控制弹出窗口是否显示。

<el‐button type="primary" @click="teachplayFormVisible = true"> 添加课程计划</el‐button>

4、定义表单提交方法和重置方法

// 提交课程计划
addTeachplan(){
    alert()
},
//重置表单
resetForm(){
  this.teachplanActive = {}
},

API接口

1)添加课程计划

ApiOperation("添加课程计划")
public ResponseResult addTeachplan(Teachplan teachplan);

课程管理服务

DAO层

public interface TeachplanRepository extends JpaRepository<Teachplan, String> { 
   //定义方法根据课程id和父结点id查询出结点列表,可以使用此方法实现查询根结点
   public List<Teachplan> findByCourseidAndParentid(String courseId,String parentId);
}

Service层

package com.xuecheng.manage_course.service;

import com.xuecheng.framework.domain.course.CourseBase;
import com.xuecheng.framework.domain.course.Teachplan;
import com.xuecheng.framework.domain.course.ext.TeachplanNode;
import com.xuecheng.framework.exception.ExceptionCast;
import com.xuecheng.framework.model.response.CommonCode;
import com.xuecheng.framework.model.response.ResponseResult;
import com.xuecheng.manage_course.dao.CourseBaseRepository;
import com.xuecheng.manage_course.dao.TeachplanMapper;
import com.xuecheng.manage_course.dao.TeachplanRepository;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
public class CourseService {
    @Autowired
    TeachplanMapper teachplanMapper;
    @Autowired
    CourseBaseRepository courseBaseRepository;
    @Autowired
    TeachplanRepository teachplanRepository;

    /**
     * 添加课程计划
     * @param teachplan
     * @return
     */
    @Transactional  //增删改操作都需要加spring事务
    public ResponseResult addTeachplan(Teachplan teachplan) {
        //校验课程id和课程计划名称
        if(teachplan == null|| StringUtils.isEmpty(teachplan.getCourseid()) || StringUtils.isEmpty(teachplan.getPname())){
            ExceptionCast.cast(CommonCode.INVALID_PARAM);
        }

        //取出课程id
        String courseId = teachplan.getCourseid();
        //取出父节点id
        String parentId = teachplan.getParentid();
        String newTeachplanGrade = "3";  //设置新节点的菜单等级

        // 如果用户未在添加时候未选择根节点,表示需要添加的是一个二级菜单,并且默认添加到当前课程的根节点下
        if(StringUtils.isEmpty(parentId)){
            newTeachplanGrade = "2";
            //根据课程id取根节点的id
            parentId = this.getTeachplanRoot(courseId);
            if(StringUtils.isEmpty(parentId)){
                ExceptionCast.cast(CommonCode.INVALID_PARAM);
            }
        }

        //创建新节点
        Teachplan teachplanNew = new Teachplan();
        //将传入的节点信息赋值到新节点内
        BeanUtils.copyProperties(teachplan,teachplanNew);
        teachplanNew.setParentid(parentId);
        teachplanNew.setCourseid(courseId);

        /* 设置新节点的级别
            方法1:根据父节点的级别进行设置,父节点级别为1,当前则为2,为2则当前为3
            方法2:在判断前端传入的父节点是否为空时进行设置,如果为空,表示需求为添加二级节点,设置2
                  如果不为空,则表示要添加的是三级节点,设置为3,与方法1相比可以减少一次查询。
         */
        teachplanNew.setGrade(newTeachplanGrade); //节点级别,根据父节点的级别进行设置
        teachplanRepository.save(teachplanNew);
        return new ResponseResult(CommonCode.SUCCESS);
    }

    //根据课程id获取课程根节点id,如果根节点不存在 则创建一个根节点,并作为该课程的根节点
    private String getTeachplanRoot(String courseId){
        //校验课程id
        Optional<CourseBase> optional = courseBaseRepository.findById(courseId);
        if(!optional.isPresent()){
            return null;
        }
        CourseBase courseBase = optional.get();
        //取出一级根节点菜单
        List<Teachplan> teachplanList = teachplanRepository.findByCourseidAndParentid(courseId, "0");

        //如果根节点不存在,则新增一个节点,并且作为该课程的根节点
        if(teachplanList == null || teachplanList.size() == 0){
            //新增一个节点
            Teachplan teachplanNewRoot = new Teachplan();
            teachplanNewRoot.setCourseid(courseId);
            teachplanNewRoot.setPname(courseBase.getName());
            teachplanNewRoot.setCourseid(courseId);
            teachplanNewRoot.setGrade("1"); //1级菜单
            teachplanNewRoot.setStatus("0"); //未发布
            teachplanRepository.save(teachplanNewRoot);
            return teachplanNewRoot.getId();
        }
        Teachplan teachplan = teachplanList.get(0);
        return teachplan.getId();
    }

    /**
     * 查询课程计划
     * @param courseId
     * @return
     */
    public TeachplanNode findTeachplanList(String courseId){
        TeachplanNode teachplanNode = teachplanMapper.selectList(courseId);
        return teachplanNode;
    }
}

Controller层

package com.xuecheng.manage_course.controller;

import com.xuecheng.api.course.CourseControllerApi;
import com.xuecheng.framework.domain.course.Teachplan;
import com.xuecheng.framework.domain.course.ext.TeachplanNode;
import com.xuecheng.framework.model.response.ResponseResult;
import com.xuecheng.manage_course.service.CourseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/course")
public class CourseController implements CourseControllerApi {
    @Autowired
    CourseService courseService;

    @GetMapping("/teachplan/list/{courseId}")
    @Override
    public TeachplanNode findTeachplanList(@PathVariable("courseId") String courseId) {
        return courseService.findTeachplanList(courseId);
    }

    @Override
    @PostMapping("/teachplan/add")
    public ResponseResult addTeachplan(@RequestBody Teachplan teachplan) {
        return courseService.addTeachplan(teachplan);
    }
}

测试

复杂一些的业务逻辑建议写完服务端代码就进行单元测试。

使用swagger-uipostman测试上边的课程计划添加接口。

前端Api调用

1、定义 api方法

/* 添加课程计划*/
export const addTeachplan = teachplah => {
  return http.requestPost(apiUrl+'/course/teachplan/add',teachplah)
}

2、调用 api

addTeachplan(){
  this.$refs.teachplayForm.validate((valid) => {
    if (valid) {
      //添加课程计划时带上课程id
      this.teachplanActive.courseid = this.courseid;
      courseApi.addTeachplan(this.teachplanActive).then((res) => {
        if(res.success){
          this.$message.success('提交成功');
          //清空表单
          this.teachplanActive = {}
          //刷新整个树
          this.findTeachplan();
          }else{
          this.$message.error('提交失败');
        }
      });
    }
  })
},

测试

测试流程:

1、新建一个课程

2、向新建课程中添加课程计划

​ 添加一级结点

​ 添加二级结点

进度复盘

一些数据

开始时间:2020/03/16

当前时间:2020/04/01

时 长:17 天

当前进度:完成至day 06

调整

进度太慢,在这之前平均每天投入的时间只有3~4个小时,再加上专注率不高,导致 1day的进度平均需要学习2~3天,根据今天统计的数据

投入的时间需要在10个小时左右,才能完成一个day的进度,但是专注率还有提升的空间。

期望在本周能完成至day10的进度。