谷粒学苑项目前台界面 (二)

由于字数限制,谷粒学苑前台用户界面分为俩部分,此篇为 第二部分,第一部分

用户前台页面

    • 十一、阿里云播放器
      • 1.demo案例演示
      • 2.项目整合阿里云视频播放
        • (1) 后端
        • (2) 前端
    • 十二、课程评论功能
      • 1.思路分析
      • 2.显示课程评论列表
        • (1) 后端
        • (2) 前端
      • 3.发表评论
        • (1) 后端
        • (2) 前端
    • 十三、课程支付
      • 1.支付流程分析
      • 2.环境搭建
      • 3.后端接口设计
        • (1) 生成订单
        • (2) 根据 订单号 查询订单
        • (3) 生成支付二维码
        • (4) 查询订单支付状态
      • 4.整合前端订单页面
        • (1) 生成订单
        • (2) 生成二维码
        • (3) 支付
        • (4) 课程详情页面
    • 十四、后台统计分析模块
      • 1.需求分析
      • 2.环境搭建
      • 3.后端接口
      • 4.前端页面
      • 5.添加定时任务
      • 6.ECharts
        • (1) 页面静态整合 ECharts
        • (2) 后端接口
        • (3) 前端
    • 十五、GateWay网关
      • 1.简单介绍
      • 2.项目整合Gateway
    • 十六、权限管理
      • 1.项目需求
      • 2.数据库表分析
      • 3.后端接口
        • (1) 拷贝工作...
        • (2) 查询所有菜单
        • (3) 删除菜单
        • (4) 为角色分配菜单
    • 十七、 SpringSecurity 框架
      • 1.简单介绍
      • 2.前台 整合 SpringSecurity
    • 十八、Nacos 配置中心
      • 1.案例演示
      • 2.加载多配置文件

十一、阿里云播放器

1.demo案例演示

阿里云视频播放有俩种方式:

  1. 根据阿里云视频地址
  2. 根据阿里云视频的播放凭证

目前大多视频都进行了加密,使用视频地址是播放不了加密视频的。

官方帮助文档:快速接入 (aliyun.com)

  1. 新建 html 文件,引入 js 文件
    <link rel="stylesheet" href="https://g.alicdn.com/de/prismplayer/2.11.0/skins/default/aliplayer-min.css" />
    <script charset="utf-8" type="text/javascript"
        src="https://g.alicdn.com/de/prismplayer/2.11.0/aliplayer-h5-min.js"></script>
  1. 引入以下固定代码
<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', // 视频封面
            //播放配置
            // 第一种方式
            //source: '视频播放地址' 
            // 第二种方式
            encryptType:'1',//如果播放加密视频,则需设置encryptType=1,非加密视频无需设置此项
            vid : '视频id',
            playauth : '视频播放凭证',
        }, function (player) {
            console.log('播放器创建好了。')
        });
    </script>
</body>

播放配置有俩种:

  1. 第一种根据视频地址,此种方式不能播放加密视频
source : '你的视频播放地址',
  1. 第二种根据视频播放凭证
encryptType:'1',//如果播放加密视频,则需设置encryptType=1,非加密视频无需设置此项
vid : '视频id',
playauth : '视频播放凭证',

播放地址可通过 阿里云点播控制台获取:

谷粒学苑项目前台界面 (二)_第1张图片

获取视频播放凭证的代码之前有写过,详细代码请看:谷粒学苑项目后台管理系统第八章

只需要设置视频ID 就可以获取播放凭证,通过 request.setAuthInfoTimeout(2000L); 可以设置视频播放凭证的 过期时间。 设置范围:100L~3000L

谷粒学苑项目前台界面 (二)_第2张图片

遇到的问题:

播放视频的时候只有声音没有画面

问题原因:

视频的转码格式错误

可以下载以下软件查看视频的转发格式:

MediaInfo (mediaarea.net)

谷粒学苑项目前台界面 (二)_第3张图片

如果你的视频转码格式也是:MPEG 或 HEVC 等H.265编码就说明也是这种问题。

解决方式:

第一种方式:手动设置

在阿里云点播控制台中,设置默认的转码方式:

谷粒学苑项目前台界面 (二)_第4张图片

谷粒学苑项目前台界面 (二)_第5张图片

也可以自定义转码模板组,只要编码格式是 H.264 的就可以

谷粒学苑项目前台界面 (二)_第6张图片

第二种方式:代码方式

在上传视频的时候指定模板组

request.setTemplateGroupId("模板组ID");

阿里云提供的文档中还有其他的解决方法:存储在OSS中的视频在线播放时出现异常 (aliyun.com)

但是目前这个解决方法我认为是最符合该项目的。】

2.项目整合阿里云视频播放

(1) 后端

通过视频 id 获取视频播放凭证

VodController

    /**
     * @description 根据视频id获取视频播放凭证
     * @date 2022/9/7 11:03
     * @param id
     * @return com.atguigu.commonutils.R
     */
    @ApiOperation("获取视频播放凭证")
    @GetMapping("getPlayAuth/{id}")
    private R getPlayAuth(@PathVariable String id) {
        try {
            // 初始化对象
            DefaultAcsClient client = InitVodClient.initVodClient(ConstantUtil.ACCESS_KEY_ID,ConstantUtil.ACCESS_KEY_SECRET);

            // 获取 request 对象
            GetVideoPlayAuthRequest request = new GetVideoPlayAuthRequest();
            // 设置 id
            request.setVideoId(id);
            GetVideoPlayAuthResponse response = client.getAcsResponse(request);
            // 获取视频播放凭证
            String playAuth  = response.getPlayAuth();
            return R.ok().data("playAuth" ,playAuth);
        } catch (ClientException e) {
            e.printStackTrace();
            throw new GuliException(20001,"获取视频播放凭证失败");
        }
    }
}

(2) 前端

  1. 在 api 目录下创建 vod.js 定义 api

    import request from '@/utils/request'
    
    export default {
    
        // 1. 获取视频播放凭证
      getPlayAuth(vid) {
        return request({
          url: `/vodservice/vod/getPlayAuth/${vid}`,
          method: 'get'
        })
      }
    
    }
    
  2. 创建新的布局,在 layouts 目录下创建 video.vue

<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>
  1. 修改课程的跳转链接
    • target=“_blank” : 表示打开一个新的浏览器窗口

谷粒学苑项目前台界面 (二)_第7张图片

在后端 VideoVo类中是没有 VideoSourceId 属性的,给加上。

谷粒学苑项目前台界面 (二)_第8张图片

由于我们在获取所有小节时使用了 copyProperties 方法,多了个属性也会自动赋值

  1. 在 /pages/ 下创建 player 文件夹,文件夹下创建 _vid.vue 【动态路由】

播放器容器:

<template>
  <div>
    
    <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"
    />

    
    <div id="J_prismPlayer" class="prism-player" />
  div>
template>
  1. Js 代码
    • asyncData : 异步调用,只调用一次。
    • params : 相当于之前使用:this.$route.params
    • params.vid: 会获取路径中参数,参数名 要和 你的文件名 _ 后边的内容保持一致。文件名是 _vid.vue, 参数 : params.vid
      • 比如:我的文件名叫 _ids.vue , 那么获取参数: params.ids
    • return vod.getPlayAuth() : 调用 vod.js 中方法
    • return{} : 就相当于之前在 data 中定义数据
<script>
import vod from "@/api/vod";
export default {
  layout: "video", //应用video布局
  asyncData({ params, error }) {
    return vod.getPlayAuth(params.vid).then((response) => {
      // console.log(response.data.data)
      return {
        vid: params.vid,
        playAuth: response.data.data.playAuth,
      };
    });
  },
  /**
   * 页面渲染完成时:此时js脚本已加载,Aliplayer已定义,可以使用
   * 如果在created生命周期函数中使用,Aliplayer is not defined错误
   */
  mounted() {
    new Aliplayer(
      {
        id: "J_prismPlayer",
        vid: this.vid, // 视频id
        playauth: this.playAuth, // 播放凭证
        encryptType: "1", // 如果播放加密视频,则需设置encryptType=1,非加密视频无需设置此项
        width: "100%",
        height: "500px",
      },
      function (player) {
        console.log("播放器创建成功");
      }
    );
  },
};
</script>
  1. 其他一些关于播放器的参数
// 以下可选设置
cover: 'http://guli.shop/photo/banner/1525939573202.jpg', // 封面
qualitySort: 'asc', // 清晰度排序

mediaType: 'video', // 返回音频还是视频
autoplay: false, // 自动播放
isLive: false, // 直播
rePlay: false, // 循环播放
preload: true,
controlBarVisibility: 'hover', // 控制条的显示方式:鼠标悬停
useH5Prism: true, // 播放器类型:html5
  1. 阿里云视频组件

阿里云Aliplayer播放器 (alicdn.com)

十二、课程评论功能

1.思路分析

需求分析:

谷粒学苑项目前台界面 (二)_第9张图片

整体来说,需要实现的功能有俩个:

  1. 分页显示评论列表
  2. 发布评论

分页显示评论好说,根据课程 id 查询评论 并且 根据发布时间进行降序。

发布评论需要了解的细节:

  • 用户登录之后才允许发表评论,否则跳转到登录界面登录
  • 发表的评论需要和 用户,课程,讲师 关联起来。也就是说在保存评论到数据库的同时需要将:用户id,课程id,讲师 id,也增加到数据库中。

数据库表

谷粒学苑项目前台界面 (二)_第10张图片

使用 代码生成器 或者 MyBatisX 插件在 edu_service 中生成代码

下面着重分析一下 发布评论 功能:

首先在发布评论时 需要用户登录,并且将用户登录的用户信息保存到数据库中,那么如何判断用户是否登录呢 ?

其实有俩个方法:

  • 第一个方法在前端进行实现
    • 在 login.js 中,我们写过根据 token 获取用户信息的方法。如果用户登录了,拦截器会把 token 保存到 header 中,因此每次请求都会带上 token
    • 谷粒学苑项目前台界面 (二)_第11张图片
    • 我们可以利用这个方法在 课程详情页面 获取用户信息,判断该用户信息是否存在,存在即是登录,不存在即是没有登录
      • 如果登录了,那么 cookie 中肯定就有 用户信息,取出来保存在一个对象中就行了。而 课程 id,讲师 id 和 评论信息 在 课程详情页面都能获取到
      • image-20220908105343327
      • 如果没有登录,就 路由跳转到 登录页面,提示登录。
  • 第二个方法在后端进行实现
    • 判断请求的 header 中是否有 token,可以利用 JwtUtils 工具类进行判断。
    • 如果有 token,就是登录用户,使用 OpenFeign + Nacos 远程调用 UCenter 中的方法查询用户信息即可
    • 如果没有 token,就是未登录,throw个异常或给个提示信息都行。

2.显示课程评论列表

(1) 后端

  1. 利用 MyBatisX 插件在 edu_service 中自动生成代码

  2. CommentController ,查询是根据课程 id 进行查询。

@RestController
@RequestMapping("/eduservice/comment")
@CrossOrigin
public class CommentController {


    @Autowired
    private EduCommentService commentService;

    @ApiOperation("分页显示评论列表")
    @GetMapping("pageCommentList/{courseId}/{page}/{limit}")
    private R pageCommentList(@PathVariable String courseId,@PathVariable long page, @PathVariable long limit) {
        // 分页查询 评论列表,封装成一个 map
        Map<String,Object> commentMap = commentService.pageCommentList(courseId,page, limit);

        return R.ok().data(commentMap);
    }
    }
  1. service 层

接口:

Map pageCommentList(String courseId ,long page, long limit);

实现类:

将查询出来的数据和分页数据封装成 map 集合

    @Override
    public Map<String,Object> pageCommentList(long page, long limit) {
        Page<EduComment> eduCommentPage = new Page<>(page, limit);
        QueryWrapper<EduComment> queryWrapper = new QueryWrapper<>();
        // 根据评论时间排序
        queryWrapper.orderByDesc("gmt_create");
       // 根据课程 id 查询评论
        queryWrapper.eq("course_id",courseId);
        this.page(eduCommentPage, queryWrapper);

        // 封装成 map
        List<EduComment> commentList = eduCommentPage.getRecords();

        Map<String, Object> map = new HashMap<>();
        map.put("items", commentList);
        map.put("current", eduCommentPage.getCurrent());
        map.put("pages", eduCommentPage.getPages());
        map.put("size", eduCommentPage.getSize());
        map.put("total", eduCommentPage.getTotal());
        map.put("hasNext", eduCommentPage.hasNext());
        map.put("hasPrevious", eduCommentPage.hasPrevious());
        return map;
    }

(2) 前端

  1. 在 api 目录下创建 comment.js 文件,定义 api
import request from '@/utils/request'

export default {

    // 1. 获取评论列表
  getCommentList(courseid,current,limit) {
    return request({
      url: `/eduservice/comment/pageCommentList/${courseid}/${current}/${limit}`,
      method: 'get'
    })
  },

}
  1. 将评论页面模板放到课程详情下面

image-20220907183229985


    <div class="mt50 commentHtml"><div>
      <h6 class="c-c-content c-infor-title" id="i-art-comment">
        <span class="commentTitle">课程评论span>
      h6>
      <section class="lh-bj-list pr mt20 replyhtml">
        <ul>
          <li class="unBr">
            <aside class="noter-pic">
              
              <span v-if="loginInfo == null">
                  <img width="50" height="50" class="picImg" src="~/assets/img/avatar-boy.gif">
              span>
              <span v-else>
                  <img width="50" height="50" class="picImg" :src="loginInfo.avatar">
              span>
              aside>
            <div class="of">
              <section class="n-reply-wrap">
                 
                <fieldset>
                  <span v-if="loginInfo == null">
                    <textarea name="" v-model="comment.content" placeholder="登录之后才能进行评论哦~" id="commentContent">textarea>
                  span>
                  <span v-else>
                    <textarea name="" v-model="comment.content" placeholder="您现在可以评论此课程了哦~" id="commentContent">textarea>
                  span>
                fieldset>
                <p class="of mt5 tar pl10 pr10">
                  <span class="fl "><tt class="c-red commentContentmeg" style="display: none;">tt>span>
                  <input type="button" @click="addComment()" value="回复" class="lh-reply-btn">
                p>
              section>
            div>
          li>
        ul>
      section>
      
      <section class="">
          <section class="question-list lh-bj-list pr">
            <ul class="pr10">
              <li v-for="(comment,index) in data.items" v-bind:key="index">
                  <aside class="noter-pic">
                    <img width="50" height="50" class="picImg" :src="comment.avatar">
                    aside>
                  <div class="of">
                    <span class="fl"> 
                    <font class="fsize12 c-blue"> 
                      {{comment.nickname}}font>
                    <font class="fsize12 c-999 ml5">评论:font>span>
                  div>
                  <div class="noter-txt mt5">
                    <p>{{comment.content}}p>
                  div>
                  <div class="of mt5">
                    <span class="fr"><font class="fsize12 c-999 ml5">{{comment.gmtCreate}}font>span>
                  div>
                li>
              
              ul>
          section>
        section>
        
        
        <div class="paging">
            
            <a
            :class="{undisable: !data.hasPrevious}"
            href="#"
            title="首页"
            @click.prevent="gotoPage(1)">a>
            <a
            :class="{undisable: !data.hasPrevious}"
            href="#"
            title="前一页"
            @click.prevent="gotoPage(data.current-1)"><a>
            <a
            v-for="page in data.pages"
            :key="page"
            :class="{current: data.current == page, undisable: data.current == page}"
            :title="''+page+''"
            href="#"
            @click.prevent="gotoPage(page)">{{ page }}a>
            <a
            :class="{undisable: !data.hasNext}"
            href="#"
            title="后一页"
            @click.prevent="gotoPage(data.current+1)">>a>
            <a
            :class="{undisable: !data.hasNext}"
            href="#"
            title="末页"
            @click.prevent="gotoPage(data.pages)">a>
            <div class="clear"/>
        div>
        
      div>
    div>
    
  1. data 中定义需要的数据
      page: 1, //当前页
      limit: 5, // 每页显示数量
      comment: {}, // 保存发布评论的信息
      data: {} // 保存分页数据
      loginInfo: { // 保存用户信息
          id: "",
          age: "",
          avatar: "",
          mobile: "",
          nickname: "",
          sex: "",
      },
  1. methods 中创建查询评论的方法,并在 created 中调用该方法
    // 3. 获取所有的评论
    getAllComment() {
      commentApi.getCommentList(this.courseId,this.page,this.limit).then(response => {
        this.data = response.data.data
      })
    },
this.getAllComment()
  1. methods 中定义 页码跳转的方法
    // 4. 页码跳转
    gotoPage(page) {
      this.page = page
        commentApi.getCommentList(page,this.limit).then(response => {
        this.data = response.data.data
      })
    }

3.发表评论

在前端使用 cookie 判断用户登录,这样会比较方便一点。。。但是也有不安全的问题,如果是上线的网站不建议这种做法,还是老老实实的后端用 token 把

效果:

谷粒学苑项目前台界面 (二)_第12张图片

(1) 后端

  1. 将实体类中的 gmtCreate gmtModified字段设置自动填充

谷粒学苑项目前台界面 (二)_第13张图片

  1. CommentController
    @ApiOperation("增加评论")
    @PostMapping("saveComment")
    private R saveComment(@RequestBody EduComment eduComment) {
        boolean result = commentService.save(eduComment);
        return  result ? R.ok().message("发布评论成功") : R.ok().message("发布评论失败");
    }

(2) 前端

  1. 在 comment.js 中定义 Api
      // 2.发表评论
      publishComment(comment) {
        return request({
          url: `/eduservice/comment/saveComment/`,
          method: 'post',
          data: comment
        })
      }
  1. 在课程详情页面【 _id.vue】, 先从 cookie 中获取用户信息,用来做判断用户是否登录。只有登录 cookie 中才有用户信息
//引入 login.js 
import loginApi from "~/api/login";

methods 中定义方法:获取用户信息,保存到 loginInfo 对象中

    // 5. 从 cookie 中获取用户信息
    getUserInfo() {
      loginApi.getUser().then(response => {
        this.loginInfo = response.data.data.userInfo
      })
    },

created 中做调用:

    // 获取用户信息
    this.getUserInfo()
  1. 接下来判断用户是否登录,条件就是 loginIngo 是否为空 ,不为空就是登录则发表评论,为空就是未登录跳转回登录界面
    • 如果登录了,那么 loginInfo 肯定有值,直接将用户信息保存到 comment 对象中就行。
    // 6.发表评论
    addComment() {
      // 先判断用户是否登录
      if(!this.loginInfo) {
        // 没有登录,跳转到登录页面
        this.$router.push({path:'/login'})
        this.$message({
          type: "error",
          message: "请先登录",
        });
      }else{
        // 赋值
        this.comment.courseId = this.courseId
        this.comment.teacherId = this.courseInfo.teacherId
        this.comment.memberId = this.loginInfo.id
        this.comment.nickname = this.loginInfo.nickname
        this.comment.avatar = this.loginInfo.avatar
        commentApi.publishComment(this.comment).then(response => {
          // 发表成功
          // 刷新评论列表
          this.getAllComment()
          //  清空输入框
          this.comment.content = ''
        })
      }
    }

十三、课程支付

1.支付流程分析

课程分为免费课程和付费课程,如果是免费课程可以直接观看,如果是付费观看的课程,用户需下单支付后才可以观看

第一种情况:如果是免费课程,在用户选择课程,进入到课程详情页面时候,直接显示 “立即观看”,用户点击立即观看,可以切换到播放列表进行视频播放

谷粒学苑项目前台界面 (二)_第14张图片

第二种情况:如果是付费课程,在用户选择课程,进入到课程详情页面时候,会显示 “立即购买”

谷粒学苑项目前台界面 (二)_第15张图片

(1)点击 “立即购买” 跳转到该课程的订单页面

谷粒学苑项目前台界面 (二)_第16张图片

(2) 点击 “去支付” 会跳转到支付界面,生成该课程的支付二维码

谷粒学苑项目前台界面 (二)_第17张图片

(3)支付完成后,跳转回课程详情界面,显示 “立即观看”

谷粒学苑项目前台界面 (二)_第18张图片

2.环境搭建

  1. 导入数据库表 guli_order.sql

谷粒学苑项目前台界面 (二)_第19张图片

字段说明:

当扫描二维码完成支付后,将订单的信息保存到 订单表,并且将支付信息保存到 t_Pay_Log 表中。

谷粒学苑项目前台界面 (二)_第20张图片

  1. 创建模块,service_order, 使用 MyBatisX 插件自动生成代码,并为实体类增加 自动填充,逻辑删除注解

谷粒学苑项目前台界面 (二)_第21张图片

  1. 目录结构

谷粒学苑项目前台界面 (二)_第22张图片

  1. 主启动类
@SpringBootApplication
@ComponentScan("com.atguigu")
@MapperScan("com.atguigu.order.mapper")
@EnableDiscoveryClient
@EnableFeignClients
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class,args);
    }
}
  1. 配置文件
# 服务端口
server.port=8007
# 服务名
spring.application.name=service-order

# mysql数据库连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/guli?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=1234

#返回json的全局时间格式
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8


#mybatis日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

# Nacos 服务地址
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848

#开启熔断机制
feign.hystrix.enabled=true
# 设置hystrix超时时间,默认1000ms
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=3000
  1. Nginx 配置文件增加请求转发
        location  ~ /orderService/ {
             proxy_pass  http://192.168.149.1:8007;
        }

3.后端接口设计

Order模块需要设计的接口方法:

  1. 生成订单方法
  2. 根据 订单号【不是订单ID】 查询订单
  3. 生成微信支付二维码
  4. 根据订单 号 查询订单状态

(1) 生成订单

订单表主要由三部分信息组成:

  1. 订单相关的信息
  2. 用户相关的信息
  3. 课程相关的信息

因此在 保存订单 时需要将这三部分的信息查询出来,用户相关信息在 UCenter 模块,课程相关信息在 edu 模块,使用 OpenFeign + Nacos 实现远程调用

谷粒学苑项目前台界面 (二)_第23张图片

在 edu 中返回的是 课程对象,在 UCenter 中返回的是 用户对象。在 order 模块都没有,因此将俩个对象复制到 common_utils 模块中。

谷粒学苑项目前台界面 (二)_第24张图片

  1. service_edu 模块中,根据课程 id 查询课程信息,封装到 公共VO类中
    /**
     * @description 根据课程 id 查询课程信息i,供 order模块调用
     * @date 2022/9/8 15:53
     * @param courseId
     * @return
     */
    @ApiOperation("根据课程 id 查询课程")
    @GetMapping("getCourseById/{courseId}")
    private CourseOrderVo getCourseById(@PathVariable("courseId") String courseId) {
        EduCourse course = courseService.getById(courseId);
        CourseOrderVo courseOrderVo = new CourseOrderVo();
        // 拷贝对象
        BeanUtils.copyProperties(course,courseOrderVo);
        return courseOrderVo;
    }
  1. service_ucenter 模块中,根据 用户id 查询课程信息,封装到 公共Vo类中
    /**
     * @description 供 service_order 模块远程调用
     * @date 2022/9/7 17:40
     * @param userId
     * @return com.atguigu.ucenter.entity.UcenterMember
     */
    @GetMapping("getUserInfoById/{userId}")
    @ApiOperation("根据用户id获取用户信息")
    private UcenterOrderVo getUserInfoById(@PathVariable("userId") String userId) {
        UcenterMember ucenterMember = memberService.getById(userId);
        UcenterOrderVo ucenterOrderVo = new UcenterOrderVo();
        BeanUtils.copyProperties(ucenterMember,ucenterOrderVo);

        return ucenterOrderVo;
    }
  1. 在 service_edu、service_ucenter 俩个模块 中,增加配置注册到 nacos 中,并在启动类上增加 @EnableDiscoveryClient 注解
# 注册进 nacos
spring.cloud.nacos.server-addr=localhost:8848
  1. 在 service_order 中创建 OpenFeign 远程调用的接口。

谷粒学苑项目前台界面 (二)_第25张图片

EduCourseFeignService:

@FeignClient("service-edu")
@Component
public interface EduCourseFeignService {

    @ApiOperation("根据课程 id 查询课程")
    @GetMapping("/eduservice/courseFront/getCourseById/{courseId}")
    // 如果是路径参数,PathVariable 里必须加上参数名
    CourseOrderVo getCourseById(@PathVariable("courseId") String courseId);
}

UcenterFeignService:

@FeignClient("service-ucenter")
@Component
public interface UcenterFeignService {

    @GetMapping("ucenterService/ucenter/getUserInfoById/{userId}")
    @ApiOperation("根据用户id获取用户信息")
     UcenterOrderVo getUserInfoById(@PathVariable("userId") String userId);
}
  1. 将生成订单号的工具类放入 common_utils 模块下
public class OrderNoUtil {

    /**
     * 获取订单号
     * @return
     */
    public static String getOrderNo() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
        String newDate = sdf.format(new Date());
        String result = "";
        Random random = new Random();
        for (int i = 0; i < 3; i++) {
            result += random.nextInt(10);
        }
        return newDate + result;
    }

}
  1. service_order 中实现 生成订单业务

OrderController :

@RequestMapping("orderService/order")
@CrossOrigin
@RestController
public class OrderController {

    @Autowired
    private OrderService orderService;

    @PostMapping("createOrder/{courseId}")
    private R createOrder(@PathVariable String courseId, HttpServletRequest request) {
        // 返回订单号
        String orderNo = orderService.saveOrder(courseId,request);
        return R.ok().data("orderNo",orderNo);
    }
}

service 层:

接口:

String saveOrder(String courseId, HttpServletRequest request);

实现类:

@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order>
        implements OrderService {

    @Autowired
    private EduCourseFeignService courseFeignService;
    @Autowired
    private UcenterFeignService ucenterFeignService;
    /**
     * @description 生成订单
     * @date 2022/9/8 16:14
     * @param courseId
     * @param request
     * @return java.lang.String
     */
    @Override
    public String saveOrder(String courseId, HttpServletRequest request) {
        // 根据 request 中的token 获取用户 id
        String userId = JwtUtils.getMemberIdByJwtToken(request);
        if (StringUtils.isEmpty(userId)) {
            throw new GuliException(20001,"用户未登录");
        }
        // 根据课程 id 查询课程信息
        CourseOrderVo courseOrderVo = courseFeignService.getCourseById(courseId);
        // 根据用户 id 查询用户信息
        UcenterOrderVo ucenterOrderVo = ucenterFeignService.getUserInfoById(userId);
        // 创建订单对象
        Order order = new Order();
        //创建订单
        order.setOrderNo(OrderNoUtil.getOrderNo()); // 使用 OrderNoUtils 生成订单号
        order.setCourseId(courseId);
        order.setCourseTitle(courseOrderVo.getTitle());
        order.setCourseCover(courseOrderVo.getCover());
        order.setTeacherName("test");
        order.setTotalFee(courseOrderVo.getPrice());
        order.setMemberId(userId);
        order.setMobile(ucenterOrderVo.getMobile());
        order.setNickname(ucenterOrderVo.getNickname());

        order.setStatus(0); // 支付状态 -- 0:未支付
        order.setPayType(1); // 支付类型 -- 1:微信支付
        baseMapper.insert(order);


        return order.getOrderNo();
    }
}

(2) 根据 订单号 查询订单

OrderController

    @ApiOperation("根据订单号查询订单信息")
    @GetMapping("getOderByOrderNo/{orderNo}")
    private R getOrderByOderNo(@PathVariable String orderNo) {
        QueryWrapper<Order> orderQueryWrapper = new QueryWrapper<>();
        orderQueryWrapper.eq("order_no",orderNo);
        Order order = orderService.getOne(orderQueryWrapper);

        return R.ok().data("order",order);
    }

(3) 生成支付二维码

点击 ‘立即支付’ 显示微信二维码

微信支付同样也需要开通资质认证的。资料里已经给出测试用的 appid 账号

谷粒学苑项目前台界面 (二)_第26张图片

  1. 引入依赖
    <dependencies>
        
        <dependency>
            <groupId>com.github.wxpaygroupId>
            <artifactId>wxpay-sdkartifactId>
            <version>0.0.3version>
        dependency>

        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>fastjsonartifactId>
        dependency>
    dependencies>
  1. 引入工具类

谷粒学苑项目前台界面 (二)_第27张图片

  1. PayLogController
@RequestMapping("orderService/payLog")
@CrossOrigin
@RestController
public class PayLogController {

    @Autowired
    private PayLogService payLogService;

    @GetMapping("createNative/{orderNo}")
    @ApiOperation("生成微信支付二维码")
    private R createNative(@PathVariable String orderNo) {
        // 生成支付二维码,并将信息保存到 map 集合中
        Map map = payLogService.createNative(orderNo);
        return  R.ok().data(map);

    }
}
  1. service 层

接口:

 Map<String, String> createNative(String orderNo);

实现类:

@Service
public class PayLogServiceImpl extends ServiceImpl<PayLogMapper, PayLog>
        implements PayLogService {

    @Autowired
    private OrderService orderService;
    /**
     * @description 生成微信支付二维码
     * @date 2022/9/10 19:02
     * @param orderNo
     * @return java.util.Map
     */
    @Override
    public Map<String, String> createNative(String orderNo) {
        try {
            // 1.查询出订单信息
            QueryWrapper<Order> queryWrapper = new QueryWrapper<>();
            queryWrapper.eq("order_no",orderNo);
            Order order = orderService.getOne(queryWrapper);

            Map<String,String> m = new HashMap();
            //2、设置支付参数
            m.put("appid", "wx74862e0dfcf69954"); // 设置 appid
            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");


            //3、HTTPClient来根据URL访问第三方接口并且传递参数
            HttpClient client = new HttpClient("https://api.mch.weixin.qq.com/pay/unifiedorder");


            //client设置参数,参数类型是 xml 格式
            client.setXmlParam(WXPayUtil.generateSignedXml(m, "T6m9iK73b0kn9g5v426MKfHQH7X8rKwb"));
            client.setHttps(true);
            client.post();
            //4、返回第三方的数据,getContent(): 获取响应中的数据。返回类型是 xml 格式
            String xml = client.getContent();
            // 将 xml 转换成 map 集合
            Map<String, String> resultMap = WXPayUtil.xmlToMap(xml);


            //5、封装返回结果集
            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")); // 二维码地址

            //微信支付二维码2小时过期,可采取2小时未支付取消订单
            //redisTemplate.opsForValue().set(orderNo, map, 120, TimeUnit.MINUTES);
            return map;
        } catch (Exception e) {
            e.printStackTrace();
            return  null;
        }
    }
}

生成二维码返回的数据:

{
    course_id=1559756643711848449, 
    out_trade_no=20220910204323770, 
    code_url=weixin://wxpay/bizpayurl?pr=dlihR6gzz, 
    total_fee=999.00, result_code=SUCCESS
}

(4) 查询订单支付状态

  • 查询订单支付状态:
    • 支付成功
      • 支付成功后向 t_pay_log 表中插入数据
      • 修改 order 表中的支付状态 字段image-20220910192655563
    • 支付失败
  1. PayLogController
    @GetMapping("queryPayStatus/{orderNo}")
    @ApiOperation("查询订单支付状态")
    private R queryPayStatus(@PathVariable String orderNo) {
        // 请求微信提供的固定地址,查看支付状态
        Map<String,String> map = payLogService.queryPayStatus(orderNo);
        System.out.println("********" + map);
        if (map.isEmpty()) {
            // 支付失败
            return  R.error().message("支付失败");
        }else if (map.get("trade_state").equals("SUCCESS")){
            // 支付成功
            // 向 pay_log 表中插入数据,并修改 order 表中的支付状态
            payLogService.UpdateOrderStatus(map);
            return R.ok().message("支付成功");
        }else{
            return R.error().message("支付中....");
        }

    }
  1. PayLogService 层

接口:

    // 查询支付状态
    Map<String, String> queryPayStatus(String orderNo);

    // 修改支付状态,插入数据
    void UpdateOrderStatus(Map<String, String> map);

实现类:

    /**
     * @description 查询订单支付状态
     * @date 2022/9/10 19:42
     * @param orderNo
     * @return java.util.Map
     */
    @Override
    public Map<String, String> queryPayStatus(String orderNo) {
        try {
            //1、封装参数
            Map m = new HashMap<>();
            m.put("appid", "wx74862e0dfcf69954");
            m.put("mch_id", "1558950191");
            m.put("out_trade_no", orderNo);
            m.put("nonce_str", WXPayUtil.generateNonceStr());

            //2、设置请求。固定地址
            HttpClient client = new HttpClient("https://api.mch.weixin.qq.com/pay/orderquery");
            client.setXmlParam(WXPayUtil.generateSignedXml(m, "T6m9iK73b0kn9g5v426MKfHQH7X8rKwb"));
            client.setHttps(true);
            client.post();
            //3、返回第三方的数据
            String xml = client.getContent();
            //4、转成Map
            Map<String, String> resultMap = WXPayUtil.xmlToMap(xml);
            //5、返回
            return resultMap;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * @description 插入数据 并且修改支付状态
     * @date 2022/9/10 19:44
     * @param map
     * @return void
     */
    @Override
    public void UpdateOrderStatus(Map<String, String> map) {
        // 获取订单号
        String orderNo = map.get("out_trade_no");

        // 查询订单信息
        QueryWrapper<Order> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("order_no",orderNo);
        Order order = orderService.getOne(queryWrapper);

        // 修改支付状态
        order.setStatus(1);
        orderService.updateById(order);

        // 记录支付日志
        PayLog payLog=new PayLog();
        payLog.setOrderNo(order.getOrderNo());// 支付订单号
        payLog.setPayTime(new Date());
        payLog.setPayType(1);// 支付类型
        payLog.setTotalFee(order.getTotalFee());// 总金额(分)
        payLog.setTradeState(map.get("trade_state"));// 支付状态
        payLog.setTransactionId(map.get("transaction_id")); // 流水号
        payLog.setAttr(JSONObject.toJSONString(map)); // 其余属性

        baseMapper.insert(payLog);// 插入到支付日志表
    }

查询订单状态返回的信息:

{
    nonce_str=Oy3ELEPYNLRJAJM7, 
    device_info=, trade_state=NOTPAY, 
    out_trade_no=20220910204323770,
     appid=wx74862e0dfcf69954,
    total_fee=99900,
     trade_state_desc=订单未支付, 
    sign=E52F7D06EA40023F9157060D1B7F9D0C,
     return_msg=OK, 
    result_code=SUCCESS, 
    mch_id=1558950191, 
    return_code=SUCCESS
}

4.整合前端订单页面

(1) 生成订单

点击 ‘立即购买’ 按钮,跳转到订单页面生成订单,并显示订单信息。前提是: 用户登录

  1. 将样式 assets 拷贝目录中,替换原来的 assets

谷粒学苑项目前台界面 (二)_第28张图片

  1. 在 layouts 目录中引入该样式
import '~/assets/css/reset.css'
import '~/assets/css/theme.css'
import '~/assets/css/global.css'
import '~/assets/css/web.css'
import '~/assets/css/base.css'
import '~/assets/css/activity_tab.css'
import '~/assets/css/bottom_rec.css'
import '~/assets/css/nice_select.css'
import '~/assets/css/order.css'
import '~/assets/css/swiper-3.3.1.min.css'
import "~/assets/css/pages-weixinpay.css"
  1. Api 目录下创建order.js 文件,定义 Api
import request from '@/utils/request'

export default {
    // 1.生成订单
  saveOrder(courseId) {
    return request({
      url: `orderService/orderr/createOrder/` + courseId ,
      method: 'post',
    })
  },
    // 2.根据订单号查询订单
    getOrderInfo(orderNo) {
        return request({
          url: `orderService/orderr/getOderByOrderNo/` + orderNo,
          method: 'get',
        })
      },

}
  1. 课程详情页面引入order.js 文件,并修改课程详情页面 ‘立即购买’ 按钮,点击 立即购买跳转到 订单 页面
import orderApi from "~/api/order";

image-20220910180226091

methods 中调用生成订单的方法,在生成订单之前判断是否登录,登录才允许生成订单,并跳转到 订单页面:

使用动态路由的方式,因为订单号是不相同的。

      // ========================================================================== 生成订单
      addOrder() {
        //  登录之后,允许生成订单
        if (this.loginInfo) {
          orderApi.saveOrder(this.courseId).then(response => {
          //  跳转到订单页面,使用动态路由的方式。
          this.$router.push({path: '/orders/' + response.data.data.orderNo})
        })
        }else{
                  // 没有登录,跳转到登录页面
        this.$router.push({path:'/login'})
         this.$message({
          type: "error",
          message: "请先登录",
        });
        }

      }
  1. pages 下创建 order 目录,在 order 目录下创建 _oid.vue 页面 ——订单页面

页面模板:

<template>
  <div class="Page Confirm">
    <div class="Title">
      <h1 class="fl f18">订单确认h1>
      <img src="~/assets/img/cart_setp2.png" class="fr">
      <div class="clear">div>
    div>
    <form name="flowForm" id="flowForm" method="post" action="">
      <table class="GoodList">
        <tbody>
        <tr>
          <th class="name">商品th>
          <th class="price">原价th>
          <th class="priceNew">价格th>
        tr>
        tbody>
        <tbody>
        
        <tr>
          <td colspan="3" class="teacher">讲师:{{order.teacherName}}td>
        tr>
        <tr class="good">
          <td class="name First">
            <a target="_blank" :href="'https://localhost:3000/course/'+order.courseId">
              <img :src="order.courseCover">a>
            <div class="goodInfo">
              <input type="hidden" class="ids ids_14502" value="14502">
              <a target="_blank" :href="'https://localhost:3000/course/'+ order.courseId">{{order.courseTitle}}a>
            div>
          td>
          <td class="price">
            <p><strong>{{order.totalFee}}strong>p>
            
          td>
          <td class="red priceNew Last"><strong>{{order.totalFee}}strong>td>
        tr>
        <tr>
          <td class="Billing tr" colspan="3">
            <div class="tr">
              <p><strong class="red">1strong> 件商品,合计<span
                class="red f20"><strong>{{order.totalFee}}strong>span>p>
            div>
          td>
        tr>
        tbody>
      table>
      <div class="Finish">
        <div class="fr" id="AgreeDiv">
          
          <label for="Agree"><p class="on"><input type="checkbox" checked="checked">我已阅读并同意<a href="javascript:" target="_blank">《谷粒学院购买协议》a>p>label>
        div>
        <div class="clear">div>
        <div class="Main fl">
          <div class="fl">
            <a :href="'/course/'+order.courseId">返回课程详情页a>
          div>
          <div class="fr">
            <p><strong class="red">1strong> 件商品,合计<span class="red f20"><strong
              id="AllPrice">{{order.totalFee}}strong>span>p>
          div>
        div>
        <input name="score" value="0" type="hidden" id="usedScore">
        <button class="fr redb" type="button" id="submitPay" @click="toPay()">去支付button>
        <div class="clear">div>
      div>
    form>
  div>
template>
  1. 引入 order.js 文件,根据 订单号查询订单信息,在页面中进行显示
<script>
import orderApi from '~/api/order'
export default {
    data() {
        return {
            orderNo: '',
            order: {}
        }
    },
    created() {
        //  从路径中取值
        if(this.$route.params.oid) {
            this.orderNo = this.$route.params.oid
        }
        // 查询订单信息
        this.getOrder()
    },
    methods: {
        // 查询订单信息
        getOrder() {
            orderApi.getOrderInfo(this.orderNo).then((response) => {
                this.order = response.data.data.order
            })
        }
    },
    
}
</script>

(2) 生成二维码

image-20220910201815239

点击 ‘去支付’ 跳转到 二维码界面,扫码完成支付

  1. 跳转二维码界面
    toPay(){
        //  跳转到支付二维码界面
        this.$router.push({path:'/pay/' + this.orderNo})
    }
  1. 创建 pay 文件夹,下面创建 _pid.vue 页面,用于显示二维码

image-20220910202103991

  1. 二维码页面模板

需要使用 qriously 插件,用来下载二维码。 npm install qriously

<template>
  <div class="cart py-container">
    
    <div class="checkout py-container  pay">
      <div class="checkout-tit">
        <h4 class="fl tit-txt"><span class="success-icon">span><span class="success-info">订单提交成功,请您及时付款!订单号:{{payObj.out_trade_no}}span>
        h4>
        <span class="fr"><em class="sui-lead">应付金额:em><em class="orange money">¥{{payObj.total_fee}}em>span>
        <div class="clearfix">div>
      div>
      <div class="checkout-steps">
        <div class="fl weixin">微信支付div>
        <div class="fl sao">
          <p class="red">请使用微信扫一扫。p>
          <div class="fl code">
            
            
            <qriously :value="payObj.code_url" :size="338"/>
            <div class="saosao">
              <p>请使用微信扫一扫p>
              <p>扫描二维码支付p>
            div>

          div>

        div>
        <div class="clearfix">div>
        
        
      div>
    div>
  div>
template>
  1. order.js 中定义 Api
    // 3.生成微信支付二维码
    createNative(orderNo) {
        return request({
            url: `orderService/payLog/createNative/` + orderNo,
            method: 'get',
        })
    },

    // 4.查询支付状态
    getPayStatus(orderNo) {
        return request({
            url: `orderService/payLog/queryPayStatus/` + orderNo,
            method: 'get',
        })
    },
  1. script 中 引入 order.js 文件,并生成二维码
<script>
import orderApi from '~/api/order'
export default {
    data() {
        return {
            orderNo: '',
            payObj: {}  // 保存
        }
    },
    created() {
                //  从路径中取值
        if(this.$route.params.pid) {
            this.orderNo = this.$route.params.pid
            console.log("****" + this.orderNo)
        }
        this.createPayCode()
    },
    methods: {
        createPayCode(){
            orderApi.createNative(this.orderNo).then(response => {
                 console.log(response)
                //  保存支付的一些信息
                this.payObj = response.data.data
               
            })
        }
        
    },
}
</script>

效果:

谷粒学苑项目前台界面 (二)_第29张图片

(3) 支付

支付时,使用一个定时器,不断查询是否支付成功,支付成功跳转到课程详情页面。

  • 支付成功:跳转课程详情页面,并提示支付成功
  • 支付中: 超过 30s 未支付 也跳转回课程详情页面,并提示支付失败
  1. data中定义数据
            timer1: '' ,// 定时器
            timeCount: 0 // 用来计数,超过30s未支付,支付失败
  1. 设置定时器,在页面渲染之后完成
    mounted() {
      //在页面渲染之后执行
      //每隔三秒,去查询一次支付状态
      this.timer1 = setInterval(() => {
        //  每次查询 timeCount+3
        this.timeCount += 3
        this.queryPayStatus(this.payObj.out_trade_no)
      }, 3000);
    },
  1. queryPayStatus方法:查询支付状态
 //  查询支付状态
        // out_trade_no: 订单号
        queryPayStatus(out_trade_no) {
            console.log(this.timeCount)
            orderApi.getPayStatus(out_trade_no).then(response => {
                // 判断是否支付成功
                if (response.data.success) {
                    this.timeCount = 0
                    // 支付成功,清除定时器
                    clearInterval(this.timer1)
                    this.$message({
                        type: 'success',
                        message: '支付成功!'
                    })
                    //跳转到课程详情页面观看视频
                this.$router.push({path: '/course/' + this.payObj.course_id})
                }else{
                    // 支付中
                    if(this.timeCount == 30) {
                    // 超时未支付,清空计时器
                    clearInterval(this.timer1)
                    // 超过 30s未支付,跳转课程详情界面
                    this.$message({
                        type: 'error',
                        message: '超时未支付'
                    })
                    //跳转到课程详情页面观看视频
                    this.$router.push({path: '/course/' + this.payObj.course_id})
                    this.timeCount = 0

                    }
                }
            })
        }

(4) 课程详情页面

在课程详情页面 :

  1. 该课程是免费的的,显示 ‘立即观看’
  2. 该课程不是免费的,并且已经支付过,显示 '立即观看
  3. 该课程 不是免费的,没有支付过,显示 ‘立即购买’

image-20220913130942495

  1. 修改课程详情页面 '立即购买 ’ 按钮

判断课程的金额

            <section class="c-attr-mt"  v-if="Number(courseInfo.price) === 0">
              <a
                href="#"
                title="立即观看"
                class="comm-btn c-btn-3"
                @click="addOrder"
                >立即观看a
              >
            section>

            <section class="c-attr-mt"  v-else>
              <a
                href="#"
                title="立即观看"
                class="comm-btn c-btn-3"
                @click="addOrder"
                >立即购买a
              >
            section>
  1. 接口中根据 课程id 和 用户 id 查询该订单的支付状况——status 字段:0 未支付,1 已支付

OrderController:

    @ApiOperation("查询订单支付状态")
    @GetMapping("isBuy/{courseId}/{memberId}")
    private boolean isBuy(@PathVariable String courseId,@PathVariable String memberId) {
        // true : 已支付  false : 未支付
        return orderService.isBuy(courseId,memberId);
    }

接口:

    // 根据课程id、用户id 查询订单支付状态
    boolean isBuy(String courseId, String orderNo);

实现类:

    @Override
    public boolean isBuy(String courseId, String memberId) {
        QueryWrapper<Order> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("course_id",courseId);
        queryWrapper.eq("member_id",memberId);
        queryWrapper.eq("status",1);
        Order order = this.getOne(queryWrapper);

        return order != null;
    }
  1. 使用 远程调用,在查询课程信息的时候,判断该课程是否已经购买过

    • 在 service-edu 中创建远程调用接口:
    @Component
    @FeignClient("service-order")
    public interface OrderFeignService {
    
        @ApiOperation("查询订单支付状态")
        @GetMapping("orderService/order/isBuy/{courseId}/{memberId}")
        boolean isBuy(@PathVariable("courseId") String courseId, @PathVariable("memberId") String memberId);
    }
    
    • CourseFrontController 中调用 并返回结果

    谷粒学苑项目前台界面 (二)_第30张图片

    1. 在前端 课程详情页面中,data中 增加 ‘isBuy’ 属性,在查询课程信息时获取到

    image-20220913204008567

    1. 这里有一个小 bug ,如果没有登录的情况下,memberId 是空的,因此会报空指针异常,所以在查询课程支付状态时,需要增加一个判断 memberId 是否为空。

    谷粒学苑项目前台界面 (二)_第31张图片

十四、后台统计分析模块

1.需求分析

使用图表的形式统计 网站的 注册人数,在 service_ucenter 模块中统计在一定时间范围内的 用户注册 数量,在 统计模块中进行调用

谷粒学苑项目前台界面 (二)_第32张图片

SQL 语句 :

SELECT COUNT(*) FROM ucenter_member WHERE DATE(gmt_create) = 'xxxxx'

DATE 函数: 截取字段中的日期部分,不算时间部分【时分秒】

2.环境搭建

  1. 引入 统计分析表

谷粒学苑项目前台界面 (二)_第33张图片

  1. 创建 service_statistics 模块

  2. 创建 application 配置文件

# 服务端口
server.port=8008
# 服务名
spring.application.name=service-statistics

# mysql数据库连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/guli?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=1234

#返回json的全局时间格式
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8


#mybatis日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

# nacos服务地址
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848

#开启熔断机制
#feign.hystrix.enabled=true
# 设置hystrix超时时间,默认1000ms
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=3000
  1. 启动类
@SpringBootApplication
@ComponentScan(basePackages = {"com.atguigu"})
@MapperScan("com.atguigu.statistics.entity")
@EnableDiscoveryClient
@EnableFeignClients
public class StatisticsApplication {
    public static void main(String[] args) {
        SpringApplication.run(StatisticsApplication.class,args);
    }
}

  1. 使用 MyBatis 插件自动生成代码

  2. 实体类中增加自动填充注解

  3. Nginx 中修改配置文件

        location  ~ /staService/ {
             proxy_pass  http://192.168.149.1:8008;
        }

3.后端接口

UCenter 模块中 统计用户的注册数量:

  1. UcenterController:
    @ApiOperation("统计某天的注册人数")
    @GetMapping("countRegister/{date}")
    private R countRegister(@PathVariable String date) {
        Integer count = memberService.countRegister(date);
        return R.ok().data("count",count);
    }
  1. mapper 接口
    // 统计某天的注册人数
    Integer countRegister(String date);
  1. mapper 映射文件
    <select id="countRegister" resultType="java.lang.Integer">
        SELECT COUNT(*) FROM ucenter_member WHERE DATE(gmt_create) = #{date}
    select>

Statistics 中调用 UCenter 模块:

  1. 创建 OpenFeign 接口
@Component
@FeignClient("service-ucenter")
public interface UcenterFeignService {

    @GetMapping("ucenterService/ucenter/countRegister/{date}")
    R countRegister(@PathVariable String date);
}
  1. statisticsControlelr
@RestController
@RequestMapping("staService/statistics")
@CrossOrigin
public class StatisticsController {

    @Autowired
    private StatisticsDailyService statisticsDailyService;
    /**
     * @description 将统计数据表村到 统计表中
     * @date 2022/9/13 22:34
     * @param
     * @return com.atguigu.commonutils.R
     */
    @PostMapping("registerCount/{date}")
    private R registerCount(@PathVariable String date) {
        // 保存统计数据
        statisticsDailyService.registerCount(date);
        return  R.ok();

    }
}

  1. service层:将统计出来的数据存放到 数据库表中,保存到数据库之前删除相同日期的统计数据,

接口:

    // 将统计的数据保存到数据库表中
    void registerCount(String date);

实现类:

@Service
public class StatisticsDailyServiceImpl extends ServiceImpl<StatisticsDailyMapper, StatisticsDaily>
    implements StatisticsDailyService{

    @Autowired
    private UcenterFeignService ucenterFeignService;

    @Override
    public void registerCount(String date) {
       // 保存数据库之前,删除相同日期的统计数据
        QueryWrapper<StatisticsDaily> statisticsDailyQueryWrapper = new QueryWrapper<>();
        statisticsDailyQueryWrapper.eq("date_calculated",date);
        this.remove(statisticsDailyQueryWrapper);
        R r = ucenterFeignService.countRegister(date);
        // 获取到注册的人数
        Integer count =(Integer) r.getData().get("count");

        StatisticsDaily statisticsDaily = new StatisticsDaily();

        statisticsDaily.setDateCalculated(date);
        statisticsDaily.setRegisterNum(count);
        // 以下数据 随机生成,只演示一个 注册人数
        statisticsDaily.setLoginNum(RandomUtils.nextInt(100,200));
        statisticsDaily.setVideoViewNum(RandomUtils.nextInt(100,200));
        statisticsDaily.setCourseNum(RandomUtils.nextInt(100,200));
        // 保存数据库
        this.save(statisticsDaily);
    }
}

4.前端页面

  1. 在 src/router/index.js 中 增加 统计分析 路由

谷粒学苑项目前台界面 (二)_第34张图片

  // 统计分析路由
  {
    path: '/sta',
    component: Layout,
    redirect: '/sta/table',
    name: '统计分析',
    meta: { title: '统计分析', icon: 'example' },
    children: [
      {
        path: 'create',
        name: '生成数据',
        component: () => import('@/views/edu/sta/create'),
        meta: { title: '生成数据', icon: 'table' }
      },
      {
        path: 'show',
        name: '显示图表',
        component: () => import('@/views/edu/sta/show'),
        meta: { title: '显示图表', icon: 'tree' }
      }
    ]
  },
  1. 在 views 下创建页面

image-20220914162651389

流程:

谷粒学苑项目前台界面 (二)_第35张图片

  1. 在 api 目录下创建 sta.js ,定义访问接口的 Api
// request 封装了axios
import request from '@/utils/request'

// ES6 模块化
export default {
    // 1. 生成统计数据
    createStaData(date) {
        return request({
            url: `staService/statistics/registerCount/` + date,
            method: 'post',
        })
    },
}
  1. create.vue 组件模板
<template>
  <div class="app-container">
    
    <el-form :inline="true" class="demo-form-inline">

      <el-form-item label="日期">
        <el-date-picker
          v-model="day"
          type="date"
          placeholder="选择要统计的日期"
          value-format="yyyy-MM-dd" />
      el-form-item>

      <el-button
        :disabled="btnDisabled"
        type="primary"
        @click="create()">生成el-button>
    el-form>

  div>
template>
  1. JS 代码
<script>
import staApi from '@/api/edu/sta'
export default {
    data() {
        return {
            day: '',
             btnDisabled: false
        }
    },
    created() {

    },
    methods: {
        create() {
            staApi.createStaData(this.day).then(response => {
                this.$message({
                    type: 'success',
                    message: '成功生成数据'
                })
                //  跳转到 显示数据 页面
                this.$router.push({path: '/sta/show'})
            })
        }
    }
}
</script>

5.添加定时任务

使用 cron 表达式 定时 统计数据保存到数据库中 ,cron 又称 ‘七子表达式’,image-20220914171741043

七子: 秒,分钟,小时,日,月,周,年

但是在 SpringBoot 中默认没有年,如果使用 七位 会报错:

image-20220914173343188

自动生成 cron 表达式网站 :https://www.pppet.net/

  1. 在 service_statistics 的主启动类上 增加 @EnableScheduling 注解,开启定时任务
  2. 创建定时任务类
@Component
public class ScheduledTask {

    @Autowired
    private StatisticsDailyService statisticsDailyService;
    /**
     * @description 定时任务, 每日的凌晨一点执行,统计前一天的数据
     * @date 2022/9/14 17:21
     * @param
     * @return void
     */
    @Scheduled(cron = "0 0 1 * * ?")
    public void task01() {
        // 使用 DateUtil 获取前一天的日期
        statisticsDailyService.registerCount(DateUtil.formatDate(DateUtil.addDays(new Date(),-1)));
    }
}
  1. 引入 DateUtils 工具类,获取前一天或者下一天的日期
package com.atguigu.commonutils;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;

/**
 * 日期操作工具类
 *
 * @author qy
 * @since 1.0
 */
public class DateUtil {

    private static final String dateFormat = "yyyy-MM-dd";

    /**
     * 格式化日期
     *
     * @param date
     * @return
     */
    public static String formatDate(Date date) {
        SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
        return sdf.format(date);

    }

    /**
     * 在日期date上增加amount天 。
     *
     * @param date   处理的日期,非null
     * @param amount 要加的天数,可能为负数
     */
    public static Date addDays(Date date, int amount) {
        Calendar now =Calendar.getInstance();
        now.setTime(date);
        now.set(Calendar.DATE,now.get(Calendar.DATE)+amount);
        return now.getTime();
    }

    public static void main(String[] args) {
        System.out.println(DateUtil.formatDate(new Date()));
        System.out.println(DateUtil.formatDate(DateUtil.addDays(new Date(), -1)));
    }
}

6.ECharts

ECharts是百度的一个项目,后来百度把Echart捐给apache,用于图表展示,提供了常规的折线图、柱状图、散点图、饼图、K线图,用于统计的盒形图,用于地理数据可视化的地图、热力图、线图,用于关系数据可视化的关系图、treemap、旭日图,多维数据可视化的平行坐标,还有用于 BI 的漏斗图,仪表盘,并且支持图与图之间的混搭。

官方网站:Apache ECharts

(1) 页面静态整合 ECharts

谷粒学苑项目前台界面 (二)_第36张图片

在点击 ‘图表显示’ 时,有三个条件框: 第一个是根据哪个字段来生成图表

谷粒学苑项目前台界面 (二)_第37张图片

后俩个条件框: 根据统计日期的范围查询

点击 ‘显示’ 显示图表

  1. 项目中安装 echarts 插件
npm install --save [email protected]

报错的使用 cnpm install --save [email protected]

  1. 页面模板
<template>
  <div class="app-container">
    
    <el-form :inline="true" class="demo-form-inline">

      <el-form-item>
        <el-select v-model="searchObj.type" clearable placeholder="请选择">
          <el-option label="学员登录数统计" value="login_num"/>
          <el-option label="学员注册数统计" value="register_num"/>
          <el-option label="课程播放数统计" value="video_view_num"/>
          <el-option label="每日课程数统计" value="course_num"/>
        el-select>
      el-form-item>

      <el-form-item>
        <el-date-picker
          v-model="searchObj.begin"
          type="date"
          placeholder="选择开始日期"
          value-format="yyyy-MM-dd" />
      el-form-item>
      <el-form-item>
        <el-date-picker
          v-model="searchObj.end"
          type="date"
          placeholder="选择截止日期"
          value-format="yyyy-MM-dd" />
      el-form-item>
      <el-button
        :disabled="btnDisabled"
        type="primary"
        icon="el-icon-search"
        @click="showChart()">查询el-button>
    el-form>

    <div class="chart-container">
      <div id="chart" class="chart" style="height:500px;width:100%" />
    div>
  div>
template>
  1. Js 代码
<script>
import echarts from "echarts";
export default {
  data() {
    return {
      searchObj: {}, // 封装查询条件
      btnDisabled: false,
    };
  },
  created() {},
  methods: {
    showChart() {
      // 基于准备好的dom,初始化echarts实例
      this.chart = echarts.init(document.getElementById("chart"));
      // console.log(this.chart)

      // 指定图表的配置项和数据
      var option = {
        // x轴是类目轴(离散数据),必须通过data设置类目数据
        xAxis: {
          type: "category",
          data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
        },
        // y轴是数据轴(连续数据)
        yAxis: {
          type: "value",
        },
        // 系列列表。每个系列通过 type 决定自己的图表类型
        series: [
          {
            // 系列中的数据内容数组
            data: [820, 932, 901, 934, 1290, 1330, 1320],
            // 折线图
            type: "line",
          },
        ],
      };

      this.chart.setOption(option);
    },
  },
};
</script>

页面效果:

谷粒学苑项目前台界面 (二)_第38张图片

X 轴 和 Y 轴 中的数据是需要我们从后端中返回的,并且都是数组形式,因此在后端接口中返回的数据也必须是数组形式的。。

(2) 后端接口

前端和后端中 JSON 的对应关系:

谷粒学苑项目前台界面 (二)_第39张图片

由于前端图表中需要俩部分数据,第一部分:需要统计的日期范围,第二部分:统计字段的数据,并且传到前端都是数组形式。因此可以利用将查询出来的数据保存到 list 集合中,再用 map 集合封装

  1. controller 层
    @GetMapping("showData/{type}/{begin}/{end}")
    @ApiOperation("获取展示的数据")
    private R getShowData(@PathVariable String type,
                          @PathVariable String begin,
                          @PathVariable String end) {
        Map map = statisticsDailyService.getShowData(type,begin,end);
        return R.ok().data(map);
    }
  1. service 层

接口:

    // 获取统计的数据
    Map getShowData(String type, String begin, String end);

实现类:

 @Override
    public Map getShowData(String type, String begin, String end) {
        // 查询出统计的数据
        QueryWrapper<StatisticsDaily> wrapper = new QueryWrapper<>();
        wrapper.between(!StringUtils.isAllEmpty(begin, end), "date_calculated", begin, end);
        // 指定查询的字段
        wrapper.select(type,"date_calculated");
        List<StatisticsDaily> statisticsDailies = this.list(wrapper);

        // 保存 日期 的集合
        List<Object> dateList = new ArrayList<>();
        // 保存 统计数据 的集合
        List<Object> countList = new ArrayList<>();


        for (StatisticsDaily daily : statisticsDailies) {
            dateList.add(daily.getDateCalculated());
            // 统计哪个字段,就保存哪个字段的值
            switch (type) {
                case "register_num":
                    countList.add(daily.getRegisterNum());
                    break;
                case "login_num":
                    countList.add(daily.getLoginNum());
                    break;
                case "video_view_num":
                    countList.add(daily.getVideoViewNum());
                    break;
                case "course_num":
                    countList.add(daily.getCourseNum());
                    break;
                default:
                    break;
            }
        }
        // 将 list 集合保存到 map 集合中
        HashMap<String, Object> map = new HashMap<>();
        map.put("dateList", dateList);
        map.put("countList", countList);


        return map;
    }

测试效果:

谷粒学苑项目前台界面 (二)_第40张图片

(3) 前端

  1. sta.js 中定义Api
        // 2. 获取统计数据
        getData(searchObj) {
            return request({
                url: `staService/statistics/showData/${searchObj.type}/${searchObj.begin}/${searchObj.end}`,
                method: 'get',
            })
        },
  1. show.vue 中,data 中增加属性
      xData: [], // x轴数据
      yData: [] // y 轴数据
  1. methods 中调用 api
    //  获取数据
    showChart() {
        staApi.getData(this.searchObj).then(response => {
            this.xData = response.data.dateList
            this.yData = response.data.countList

            this.setChart()
        })
    },

将之前 showChart 方法改名: setChart , 在这个 showChart 中调用

设置 x轴,y 轴 的值:

谷粒学苑项目前台界面 (二)_第41张图片

十五、GateWay网关

GateWay 网关介绍以及应用:https://blog.csdn.net/aetawt/article/details/126568999

1.简单介绍

GateWay 网关 是基于目前微服务架构而出现的,负责拦截所有的请求,并分发到服务上去。前提是:服务已经注册到 Nacos 中

谷粒学苑项目前台界面 (二)_第42张图片

GateWay 和 Nginx 都可以对 请求 API 进行拦截,并实现负载均衡,反向代理,请求转发…

区别就是: GateWay 使用 Java 编写的,Nginx 使用 Go 语言,GateWay属于本地负载均衡器,Nginx 属于服务端负载均衡器

GateWay 基本由三部分组成

Route 路由: 路由是构建网关的基本模块,它由ID,目标URI,一系列的断言和过滤器组成,如果断言为true则匹配该路由

Predicate(断言): 开发人员可以匹配HTTP请求中的所有内容(例如请求头或请求参数),如果请求与断言相匹配则进行路由。简单来说就是请求的匹配规则。

Filter (过滤) : 指的是Spring框架中GatewayFilter的实例,使用过滤器,可以在请求被路由前或者之后对请求进行修改。

GateWay 的基本流程

谷粒学苑项目前台界面 (二)_第43张图片

  1. 客户端发送请求,由 GateWHandlerMapping 对路由进行映射,请求和断言能够匹配上,就交给对应的 GateWayWebHandler 处理
  2. GateWayWebHandler 再通过指定的过滤器链来将请求发送到我们实际的服务执行业务逻辑,然后返回。
  3. 过滤器之间用虚线分开是因为过滤器可能会在发送代理请求之前(“pre”)或之后(“post”)执行业务逻辑。
  4. Filter在“pre”类型的过滤器可以做参数校验、权限校验、流量监控、日志输出、协议转换等,
  5. 在“post”类型的过滤器中可以做响应内容、响应头的修改,日志的输出,流量监控等有着非常重要的作用。

2.项目整合Gateway

  1. 在 guli_parent 下,创建 infrastructure 模块,在下面创建 api_gateway 子模块

image-20220915165657904

  1. 依赖

注意: spring-cloud-starter-gateway 依赖与 web 依赖有冲突,如果依赖里还有 web 依赖,请把 web 依赖排除

        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-gatewayartifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.bootgroupId>
                    <artifactId>spring-boot-starter-webartifactId>
                exclusion>
                <exclusion>
                    <groupId>org.springframework.bootgroupId>
                    <artifactId>spring-boot-starter-webfluxartifactId>
                exclusion>
            exclusions>
        dependency>
    <dependencies>
        <dependency>
            <groupId>com.atguigugroupId>
            <artifactId>common_utilsartifactId>
            <version>0.0.1-SNAPSHOTversion>
        dependency>
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
            <version>0.2.2.RELEASEversion>
        dependency>

        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-gatewayartifactId>
        dependency>

        
        <dependency>
            <groupId>com.google.code.gsongroupId>
            <artifactId>gsonartifactId>
        dependency>

        
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-openfeignartifactId>
        dependency>
    dependencies>
  1. 启动类
@SpringBootApplication
@EnableDiscoveryClient
public class ApiGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(ApiGatewayApplication.class,args);
    }
}

  1. application 配置文件
  • 一组路由由:id,uri,一组断言 组成。一般来说一个服务对应一个路由
# 服务端口
server.port=8222
# 服务名
spring.application.name=service-gateway

# nacos服务地址
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848

#使用服务发现路由
spring.cloud.gateway.discovery.locator.enabled=true
#服务路由名小写
#spring.cloud.gateway.discovery.locator.lower-case-service-id=true

#设置路由id, 要求唯一,通常使用服务名
spring.cloud.gateway.routes[0].id=service-acl
#设置路由的uri。动态路由方式,lb 负载均衡
spring.cloud.gateway.routes[0].uri=lb://service-acl
#设置路由断言,代理servicerId为auth-service的/auth/路径
spring.cloud.gateway.routes[0].predicates= Path=/*/acl/**

#配置service-edu服务
spring.cloud.gateway.routes[1].id=service-edu
spring.cloud.gateway.routes[1].uri=lb://service-edu
spring.cloud.gateway.routes[1].predicates= Path=/eduservice/**

#配置service-ucenter服务
spring.cloud.gateway.routes[2].id=service-ucenter
spring.cloud.gateway.routes[2].uri=lb://service-ucenter
spring.cloud.gateway.routes[2].predicates= Path=/ucenterService/**

#配置service-ucenter服务
spring.cloud.gateway.routes[3].id=service-cms
spring.cloud.gateway.routes[3].uri=lb://service-cms
spring.cloud.gateway.routes[3].predicates= Path=/cmsService/**

spring.cloud.gateway.routes[4].id=service-msm
spring.cloud.gateway.routes[4].uri=lb://service-msm
spring.cloud.gateway.routes[4].predicates= Path=/msmService/**

spring.cloud.gateway.routes[5].id=service-order
spring.cloud.gateway.routes[5].uri=lb://service-order
spring.cloud.gateway.routes[5].predicates= Path=/orderService/**

spring.cloud.gateway.routes[6].id=service-order
spring.cloud.gateway.routes[6].uri=lb://service-order
spring.cloud.gateway.routes[6].predicates= Path=/orderservice/**

spring.cloud.gateway.routes[7].id=service-oss
spring.cloud.gateway.routes[7].uri=lb://service-oss
spring.cloud.gateway.routes[7].predicates= Path=/oss/**

spring.cloud.gateway.routes[8].id=service-statistic
spring.cloud.gateway.routes[8].uri=lb://service-statistic
spring.cloud.gateway.routes[8].predicates= Path=/staService/**

spring.cloud.gateway.routes[9].id=service-vod
spring.cloud.gateway.routes[9].uri=lb://service-vod
spring.cloud.gateway.routes[9].predicates= Path=/vodservice/**

  1. 解决跨域问题配置类
@Configuration
public class CorsConfig {
    @Bean
    public CorsWebFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedMethod("*");
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        source.registerCorsConfiguration("/**", config);

        return new CorsWebFilter(source);
    }
}
  1. filter 过滤器,过滤外部不允许访问的服务
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();
        //谷粒学院api接口,校验用户必须登录
        if(antPathMatcher.match("/api/**/auth/**", path)) {
            List<String> tokenList = request.getHeaders().get("token");
            if(null == tokenList) {
                ServerHttpResponse response = exchange.getResponse();
                return out(response);
            } else {
//                Boolean isCheck = JwtUtils.checkToken(tokenList.get(0));
//                if(!isCheck) {
                    ServerHttpResponse response = exchange.getResponse();
                    return out(response);
//                }
            }
        }
        //内部服务接口,不允许外部访问
        if(antPathMatcher.match("/**/inner/**", path)) {
            ServerHttpResponse response = exchange.getResponse();
            return out(response);
        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }

    private Mono<Void> out(ServerHttpResponse response) {
        JsonObject message = new JsonObject();
        message.addProperty("success", false);
        message.addProperty("code", 28004);
        message.addProperty("data", "鉴权失败");
        byte[] bits = message.toString().getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = response.bufferFactory().wrap(bits);
        //response.setStatusCode(HttpStatus.UNAUTHORIZED);
        //指定编码,否则在浏览器中会中文乱码
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        return response.writeWith(Mono.just(buffer));
    }
}
  1. 异常处理器
@Configuration
@EnableConfigurationProperties({ServerProperties.class, ResourceProperties.class})
public class ErrorHandlerConfig {

    private final ServerProperties serverProperties;

    private final ApplicationContext applicationContext;

    private final ResourceProperties resourceProperties;

    private final List<ViewResolver> viewResolvers;

    private final ServerCodecConfigurer serverCodecConfigurer;

    public ErrorHandlerConfig(ServerProperties serverProperties,
                                     ResourceProperties resourceProperties,
                                     ObjectProvider<List<ViewResolver>> viewResolversProvider,
                                        ServerCodecConfigurer serverCodecConfigurer,
                                     ApplicationContext applicationContext) {
        this.serverProperties = serverProperties;
        this.applicationContext = applicationContext;
        this.resourceProperties = resourceProperties;
        this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
        this.serverCodecConfigurer = serverCodecConfigurer;
    }

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes) {
        JsonExceptionHandler exceptionHandler = new JsonExceptionHandler(
                errorAttributes,
                this.resourceProperties,
                this.serverProperties.getError(),
                this.applicationContext);
        exceptionHandler.setViewResolvers(this.viewResolvers);
        exceptionHandler.setMessageWriters(this.serverCodecConfigurer.getWriters());
        exceptionHandler.setMessageReaders(this.serverCodecConfigurer.getReaders());
        return exceptionHandler;
    }
}

  1. 异常处理

    public class JsonExceptionHandler extends DefaultErrorWebExceptionHandler {
    
        public JsonExceptionHandler(ErrorAttributes errorAttributes, ResourceProperties resourceProperties,
                                    ErrorProperties errorProperties, ApplicationContext applicationContext) {
            super(errorAttributes, resourceProperties, errorProperties, applicationContext);
        }
    
        /**
         * 获取异常属性
         */
        @Override
        protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
            Map<String, Object> map = new HashMap<>();
            map.put("success", false);
            map.put("code", 20005);
            map.put("message", "网关失败");
            map.put("data", null);
            return map;
        }
    
        /**
         * 指定响应处理方法为JSON处理的方法
         * @param errorAttributes
         */
        @Override
        protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
            return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
        }
    
        /**
         * 根据code获取对应的HttpStatus
         * @param errorAttributes
         */
        @Override
        protected int getHttpStatus(Map<String, Object> errorAttributes) {
            return 200;
        }
    }
    

最终的目录结构:

谷粒学苑项目前台界面 (二)_第44张图片

最终使用 GateWay网关完成调用:localhost:8222/eduservice/teacher/findAll

使用配置类解决跨域问题,将之前 controller 层 的跨域注解删掉,否则还会跨回去。。。

有的兄弟跨域配置类不起作用:看一下你的 target 目录是否将类都编译了。

谷粒学苑项目前台界面 (二)_第45张图片

如果出现上面这种情况,直接使用 maven 工具,clean,compile ,或者 IDEA 上面 build - rebuild project

十六、权限管理

1.项目需求

谷粒学苑项目前台界面 (二)_第46张图片

菜单管理:对菜单的增删改操作

角色管理:根据不同角色的权限,可以操作不同的 菜单。比如: 讲师管理员只能操作讲师管理模块

用户管理:为用户分配角色,比如:将某一个用户设置为讲师管理员

image-20220915174632141

2.数据库表分析

导入 acl 表

谷粒学苑项目前台界面 (二)_第47张图片

谷粒学苑项目前台界面 (二)_第48张图片

谷粒学苑项目前台界面 (二)_第49张图片

菜单表和角色表、角色表和用户表 是多对多的关系,额外的俩张表保存了对应关系。

菜单角色关系表: 保存菜单和角色对应的 id

角色用户关系表: 保存用户和角色对应的 id

3.后端接口

(1) 拷贝工作…

所有 资料都在 谷粒学院 的文件夹里面

拷贝 service_acl 到 service 模块下,拷贝 spring_security 模块到 common 模块下。 源码都在资料文件中。 拷贝完记着将 实体类中增加自动填充注解

image-20220916171546811

注意: 一定要拷贝 第18天的源码,17天的有问题。

谷粒学苑项目前台界面 (二)_第50张图片目录介绍:

谷粒学苑项目前台界面 (二)_第51张图片

并将 ResponseUtils 工具类拷贝到 common_utils 模块下:

谷粒学苑项目前台界面 (二)_第52张图片

package com.atguigu.commonutils;

import com.atguigu.commonutils.R;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class ResponseUtil {

    public static void out(HttpServletResponse response, R r) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        try {
            mapper.writeValue(response.getWriter(), r);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

(2) 查询所有菜单

首先清除菜单的结构, 整体是一个 JSON 格式,level 表示菜单的级别,children 是一个数组,保存下一级菜单。

谷粒学苑项目前台界面 (二)_第53张图片

acl_permission 中的 pid ,就描述了 菜单的级别, pid : 保存了菜单的上级菜单

比如

全部数据 的 pid 是 0,它就是顶级菜单,没有上级,它就是一级菜单

权限管理的 pid 是 1,他的上级 就是 id 为 1 的全部数据,那么权限管理就是二级菜单

用户管理 的 pid 等于 权限管理的 id,用户管理的上级就是 权限管理,那么用户管理就是三级菜单,以此类推。。。

谷粒学苑项目前台界面 (二)_第54张图片

代码逻辑分析

实现方式和之前课程分类有些类似,都是先将数据查询出来然后封装。但是封装的过程有些区别:

  • 课程分类,是一个固定的级别,只有一级,二级分类,只需要创建俩个实体类封装即可

  • 而菜单,并不确定有多少级菜单,可能有一级,二级,三级,等等…因此创建实体类的方式并不是很稳妥。

    因此我们可以选择使用递归的方式来封装菜单:

  1. 使用递归首先需要一个入口和出口,否则进不去也出不来
  2. 入口就选择,数据库表中的 全部数据作为一级分类,递归查询子菜单。
  3. 出口则是 遍历完所有的菜单即可。

在 Permission 实体类中额外增加的俩个属性

level : 保存菜单的级别。 children:保存子菜单

谷粒学苑项目前台界面 (二)_第55张图片

  1. PermissionController
    /**
     * @description 获取全部菜单
     * @date 2022/9/15 21:24
     * @param
     * @return com.atguigu.commonutils.R
     */
    @ApiOperation(value = "查询所有菜单")
    @GetMapping
    public R indexAllPermission() {
        List<Permission> list = permissionService.queryAllMenuGuli();
        return R.ok().data("children", list);
    }
  1. service 层

接口:

    //获取全部菜单
    List<Permission> queryAllMenuGuli();

实现类;

    /**
     * @description 获取所有的菜单,并封装
     * @date 2022/9/15 22:33
     * @param
     * @return java.util.List
     */
    @Override
    public List<Permission> queryAllMenuGuli() {
        //1 查询菜单表所有数据
        QueryWrapper<Permission> wrapper = new QueryWrapper<>();
        wrapper.orderByDesc("id");
        List<Permission> permissionList = baseMapper.selectList(wrapper);
        //2 把查询所有菜单list集合按照要求进行封装
        return BuildPermission.build(permissionList);
    }
    
  1. 创建 utils 工具类,将封装 菜单 的功能写在工具包内
    • 首先先查询出所有菜单中的顶级菜单作为一级菜单。这个一级菜单就是递归的入口。
    • 在递归中 比较 父级菜单 的 id 和 子级菜单的 pid 是否相等,相等则是父子关系。将子级菜单保存到 父级菜单的 children 集合中。
package com.atguigu.aclservice.utils;

import com.atguigu.aclservice.entity.Permission;

import java.util.ArrayList;
import java.util.List;

/**
 * 封装菜单
 * Author: YZG
 * Date: 2022/9/15 23:04
 * Description: 
 */
public class MenuUtil {

    /**
     * @description 获取顶级菜单作为递归的入口
     * @date 2022/9/15 23:13
     * @param allPermissionList 所有菜单
     * @return java.util.List
     */
    public static List<Permission> build(List<Permission> allPermissionList) {
        // 用于最终的封装集合
        ArrayList<Permission> finalList = new ArrayList<>();
        // 获取顶级菜单作为一级菜单
        for (Permission permission : allPermissionList) {
            if ("0".equals(permission.getPid())) {
                permission.setLevel(1);

                // 查询一级菜单的所有子菜单
                finalList.add(findChildrenPermission(permission, allPermissionList));
            }
        }
        return finalList;
    }

    /**
     * @description
     * @date 2022/9/15 23:13
     * @param permission 父级菜单
     * @param allPermissionList 所有菜单
     * @return com.atguigu.aclservice.entity.Permission
     */
    private static Permission findChildrenPermission(Permission permission, List<Permission> allPermissionList) {
        // 初始化子级菜单: 由于在Permission 实体类中的 children属性并未初始化。 不初始化的话可能报空指针
        permission.setChildren(new ArrayList<Permission>());
        // 遍历所有的菜单
        for (Permission node : allPermissionList) {
            // 判断父级菜单id 和 子级菜单pid 是否相等,相等则是父子关系。
            if (permission.getId().equals(node.getPid())) {
                // 子级菜单的level = 父级菜单的level+1
                node.setLevel(permission.getLevel() + 1);
                //如果children为空,进行初始化操作
                if (permission.getChildren() == null) {
                    permission.setChildren(new ArrayList<Permission>());
                }
                // 将子级菜单保存到父级菜单的 children属性中. 并递归查找子级菜单的子级菜单
                permission.getChildren().add(findChildrenPermission(node, allPermissionList));
            }
        }
        return permission;
    }

}

使用 Swagger 测试,需要注释掉以下一行代码,路径中带 admin 是不允许测试 :

谷粒学苑项目前台界面 (二)_第56张图片

(3) 删除菜单

删除菜单时,需要删除该菜单下的所有子级菜单。

过程分析

  1. 前端传入菜单 id ,但是该菜单下可能还会有多个子菜单
  2. 那么就需要根据 该 菜单 id 查询 出所有的子级菜单
  3. 也是使用递归的过程,利用: 子级菜单pid = 父级菜单id 这一条件。查询出所有的子级菜单
  1. PermissionController
    @ApiOperation(value = "递归删除菜单")
    @DeleteMapping("remove/{id}")
    public R remove(@PathVariable String id) {
        permissionService.removeChildByIdGuli(id);
        return R.ok();
    }
  1. service 层

接口:

    //递归删除菜单
    void removeChildByIdGuli(String id);

实现类:

    @Override
    public void removeChildByIdGuli(String id) {
        // 保存删除菜单的 id 集合
        ArrayList<String> idsList = new ArrayList<>();
        // 根据子级菜单递归查找下一个子级菜单,封装到 idsList中
        MenuUtil.selectChildrenById(id, idsList);
        idsList.add(id);
        // 删除 所有菜单
        this.removeBatchByIds(idsList);
    }
  1. 将实现的方法写在 MenuUtil 工具类里面。

有一个小问题: 该方法是 static 方法,引用 permissionService 就需要在 变量上也加上 static 变量:

@Autowired
private static PermissionServiceImpl permissionService;

那么问题就出现了,由于静态变量是属于 类的属性,会在编译字节码文件时就给静态变量分配了内存空间,导致 Spring 在注入的时候就会忽略,最后导致 空指针。

解决办法: 可以利用 set 方法将对象注入

 private static PermissionServiceImpl permissionService;

 @Autowired
 public void setPermissionService(PermissionServiceImpl permissionService) {
     MenuUtil.permissionService = permissionService;
 /**
     * @description 根据id查找所有子级菜单,将所有子级菜单的id封装到 idsList 中
     * @date 2022/9/16 14:11
     * @param id
     * @param idsList
     * @return void
     */
    public static void selectChildrenById(String id, ArrayList<String> idsList) {
        QueryWrapper<Permission> queryWrapper = new QueryWrapper<>();
        // 1.首先找出该 id 对应的子级菜单
        queryWrapper.eq("pid",id);
        queryWrapper.select("id");
        List<Permission> list = permissionService.list(queryWrapper);

        // 2. 遍历所有的子级菜单,将子级菜单的 id 增加到集合中
        for (Permission permission : list) {
            idsList.add(permission.getId());
            // 3. 继续递归找下一个子级菜单
            selectChildrenById(permission.getId(),idsList);
        }
    }

(4) 为角色分配菜单

角色与菜单为多对多的关系,一个角色可以有多个菜单管理,而每个菜单管理也可以对应多个角色

我们的目的就是:将 角色 id 和 菜单管理的 id 对应关系保存到 角色菜单关系表中。

image-20220916145818128

  1. PermissionController
    @ApiOperation(value = "给角色分配菜单")
    @PostMapping("/doAssign")
    public R doAssign(String roleId, String[] permissionId) {
        permissionService.saveRolePermissionRealtionShipGuli(roleId, permissionId);
        return R.ok();
    }
  1. service 层

接口:

    //给角色分配菜单
    void saveRolePermissionRealtionShipGuli(String roleId, String[] permissionId);

实现类:

    @Override
    public void saveRolePermissionRealtionShipGuli(String roleId, String[] permissionIds) {

        // 创建集合保存 角色和菜单 的对应关系
        ArrayList<RolePermission> rolePermissionList = new ArrayList<>();
        for (String permissionId : permissionIds) {
            RolePermission rolePermission = new RolePermission();
            rolePermission.setRoleId(roleId);
            rolePermission.setPermissionId(permissionId);

            // 保存到集合中
            rolePermissionList.add(rolePermission);
        }
        rolePermissionService.saveBatch(rolePermissionList);
    }

十七、 SpringSecurity 框架

1.简单介绍

Spring 是一个非常流行和成功的 Java 应用开发框架。Spring Security 基于 Spring 框架,提供了一套 Web 应用安全性的完整解决方案。一般来说,Web 应用的安全性包括用户认证Authentication)和 用户授权(Authorization)两个部分。

(1)用户认证指的是:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。

(2)用户授权指的是 : 验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。

Spring Security其实就是用filter,多请求的路径进行过滤。

(1)如果是基于Session,那么Spring-security会对cookie里的sessionid进行解析,找到服务器存储的sesion信息,然后判断当前用户是否符合请求的要求。

(2)如果是token,则是解析出token,然后将当前请求加入到Spring-security管理的权限信息中去

SpringSecurity 实现认证和授权原理

如果系统的模块众多,每个模块都需要就行授权与认证,所以我们选择基于 token 的形式进行授权与认证,用户根据用户名密码认证成功,然后获取当前用户角色的一系列权限值,并以用户名为key,权限列表为value的形式存入redis缓存中,根据用户名相关信息生成token返回,浏览器将token记录到cookie中,每次调用api接口都默认将token携带到header请求头中,Spring-security解析header头获取token信息,解析token获取当前用户名,根据用户名就可以从redis中获取权限列表,这样Spring-security就能够判断当前请求是否有权限访问

谷粒学苑项目前台界面 (二)_第57张图片

2.前台 整合 SpringSecurity

做到这里,不得不吐槽俩句,可能是老师为了赶进度~~~~ 一路的 copy 整的全都是错…

下面我将我遇到的错误一一都整理了出来。

在这里提醒各位兄弟一句: 在进行拷贝之前,提前将原来的项目拷贝一份,重中之重,否则改毁了没地哭。。。。

  1. 在 node_modules 目录中替换 element-ui 组件依赖

谷粒学苑项目前台界面 (二)_第58张图片

  1. 将资料里面的内容,全部替换掉原来的内容。

谷粒学苑项目前台界面 (二)_第59张图片

在拷贝 /router/index.js 文件时一定要将路径提前改成自己项目中所对应的路径。我建议你和之前的项目中的index.js 文件对照着,将上面那个index.js 文件的路径改了。

  1. 拷贝完成后,修改数据库的 acl_permission表,

这里主要修改 component 组件地址。component地址是 你 /router/index.js 里面对应的地址

谷粒学苑项目前台界面 (二)_第60张图片

就照着以下的图片改:

谷粒学苑项目前台界面 (二)_第61张图片

  1. 测试时,如果后台出现 Access is denied 错误

这个错误,只需要使用 GateWay 配置类解决跨域问题就好了。将其他controller 的注解删除掉

整个SpringSecurity的执行流程

谷粒学苑项目前台界面 (二)_第62张图片

十八、Nacos 配置中心

1.案例演示

  1. 在 nacos中创建配置文件

谷粒学苑项目前台界面 (二)_第63张图片

  1. Data ID 的命名规则: ${prefix}-${spring.profiles.active}.${file-extension}

prefix : 默认是服务名称,也就是 spring.application.name, 可以通过 spring.cloud.nacos.config.prefix 再配置文件中配置

spring.profiles.active : 开发环境,分别是: dev、test、prod

file-extension : 配置文件后缀,目前只支持 properties,yaml

谷粒学苑项目前台界面 (二)_第64张图片

  1. 在项目中增加依赖
        
        <dependency>
            <groupId>com.alibaba.cloudgroupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-configartifactId>
        dependency>
  1. 创建 bootstarp.yaml 配置文件
spring:
    application:
        name: service-acl
    cloud:
        nacos:
            config:
                server-addr: localhost:8848 #地址
                file-extension: properties # 指明配置文件类型
  1. SpringBoot 中配置文件加载的优先级: bootstarp - xxx.properties/xxx.yaml - xxx-dev/test/prod.yaml

关于命名空间、Group、Data ID 的区别,去我另外一篇博客看:https://blog.csdn.net/aetawt/article/details/126570750

2.加载多配置文件

加载Nacos中多个配置文件

  1. 在 Nacos 创建配置文件

谷粒学苑项目前台界面 (二)_第65张图片

  1. 在 bootstarp 中配置

    • yaml 版:
    spring:
        application:
            name: service-acl
        cloud:
            nacos:
                config:
                    server-addr: localhost:8848 #地址
                    file-extension: properties # 指明配置文件类型
                    ext-config:
                        - data-id: port.properties # 配置文件名称。 
                          refresh: true # 开启动态刷新配置,否则配置文件修改,工程无法感知
    
    
    • properties 版:
    spring.cloud.nacos.config.server-addr=localhost:8848
    spring.application.name= service-acl
    
    
    spring.cloud.nacos.config.ext-config[0].data-id=port.properties
    # 开启动态刷新配置,否则配置文件修改,工程无法感知
    spring.cloud.nacos.config.ext-config[0].refresh=true
    
  2. 结果

image-20220917215624193

你可能感兴趣的:(谷粒学苑项目,阿里云,java,spring,cloud,spring,boot,java-ee)