Part01-博客页面划分

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      └─resources
│          │  application.yml
│          │
│          ├─templates
│          │  └─inc
│          │        common.ftl
│          │        footer.ftl
│          │        header.ftl
│          │        header-panel.ftl
│          │        layout.ftl
│          │        left.ftl
│          │        right.ftl

1 导航栏(header.ftl)

  • 图标
  • 登录/注册

2 分类(header-panel.ftl)

  • 首页
  • 提问、分享、讨论、建议

3 左侧md8(left.ftl)

4 右侧md4(right.ftl)

5 宏(common.ftl)

  • 分页:<@paging XXX></@paging>
  • 一条数据 posting:<@plisting XXX></@plisting>

6 布局(layout.ftl)

  • 宏:macro 定义脚本,名为 layout,参数为 title
  • 划分:header.ftl、<#nested/>、footer.ftl

7 项目环境

  • application.yml :配置文件,【识别 Mapper 层】
spring:
  datasource:
    #    driver-class-name: com.mysql.cj.jdbc.Driver
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver
    url: jdbc:p6spy:mysql://127.0.0.1:3306/xblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=UTC
    username: root
    password: 123456
 
  freemarker:
    settings:
      classic_compatible: true
      datetime_format: yyyy-MM-dd HH:mm
      number_format: 0.##
    cache: false  # 清除缓存实现热部署,部署环境,建议开启 true(默认)
  • pom.xml :项目依赖
<dependencies>
  <!--SpringMVC-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
 
  <!--Lombok-->
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
 
  <!--mp、druid、mysql、mp-generator(MyBatis-Plus 从 3.0.3后移除了代码生成器与模板引擎的默认依赖)、MP支持的SQL分析器-->
  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.2.0</version>
  </dependency>
  <!--        <dependency>-->
  <!--            <groupId>com.alibaba</groupId>-->
  <!--            <artifactId>druid-spring-boot-starter</artifactId>-->
  <!--            <version>1.1.10</version>-->
  <!--        </dependency>-->
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.2.0</version>
  </dependency>
  <dependency>
    <groupId>p6spy</groupId>
    <artifactId>p6spy</artifactId>
    <version>3.8.6</version>
  </dependency>
 
  <!--Freemarker-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-freemarker</artifactId>
  </dependency>
 
  <!-- commons-lang3 -->
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.9</version>
  </dependency>
 
  <!--Devtools-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
  </dependency>
 
  <!--test-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
      <exclusion>
        <groupId>org.junit.vintage</groupId>
        <artifactId>junit-vintage-engine</artifactId>
      </exclusion>
    </exclusions>
  </dependency>
</dependencies>

Part02-MyBatis-Plus的使用

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          │  CodeGenerator
│      │          │
│      │          ├─config
│      │          │      MyBatisPlusConfig.java
│      │          │
│      │          ├─service
│      │          │  │  PostService.java
│      │          │  │
│      │          │  └─impl
│      │          │         PostServiceImpl.java
│      │          │
│      │          ├─mapper
│      │          │  │  PostMapper.java
│      │          │  │
│      │          │  └─impl
│      │          │         PostMapper.xml

8 MP 环境

  • pom.xml :项目依赖,【mybatis-plus-boot-starter、p6spy】
<dependencies>
  <!--mp、druid、mysql、mp-generator(MyBatis-Plus 从 3.0.3后移除了代码生成器与模板引擎的默认依赖)、MP支持的SQL分析器-->
  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.2.0</version>
  </dependency>
  <!--        <dependency>-->
  <!--            <groupId>com.alibaba</groupId>-->
  <!--            <artifactId>druid-spring-boot-starter</artifactId>-->
  <!--            <version>1.1.10</version>-->
  <!--        </dependency>-->
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.2.0</version>
  </dependency>
  <dependency>
    <groupId>p6spy</groupId>
    <artifactId>p6spy</artifactId>
    <version>3.8.6</version>
  </dependency>
</dependencies>
  • application.yml :配置文件,【识别 Mapper 层】
mybatis-plus:
  mapper-locations: classpath*:/mapper/**Mapper.xml

9 代码生成器

  • CodeGenerator.java:项目依赖,【mybatis-plus-boot-starter、mysql-connector-java、mybatis-plus-generator、druid-spring-boot-starter、spring-boot-starter-freemarker】
// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
public class CodeGenerator {
 
    /**
     * <p>
     * 读取控制台内容
     * </p>
     */
    public static String scanner(String tip) {
        Scanner scanner = new Scanner(System.in);
        StringBuilder help = new StringBuilder();
        help.append("请输入" + tip + ":");
        System.out.println(help.toString());
        if (scanner.hasNext()) {
            String ipt = scanner.next();
            if (StringUtils.isNotEmpty(ipt)) {
                return ipt;
            }
        }
        throw new MybatisPlusException("请输入正确的" + tip + "!");
    }
 
    public static void main(String[] args) {
        // 代码生成器
        AutoGenerator mpg = new AutoGenerator();
 
        // 全局配置
        GlobalConfig gc = new GlobalConfig();
        String projectPath = System.getProperty("user.dir");
        gc.setOutputDir(projectPath + "/src/main/java");
        gc.setAuthor("myslayers");
        gc.setOpen(false);
        // gc.setSwagger2(true); 实体属性 Swagger2 注解
        gc.setServiceName("%sService");
        mpg.setGlobalConfig(gc);
 
        // 数据源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl(
            "jdbc:mysql://localhost:3306/xblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=UTC");
        // dsc.setSchemaName("public");
        dsc.setDriverName("com.mysql.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("4023615");
        mpg.setDataSource(dsc);
 
        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setModuleName(null);
        pc.setParent("org.myslayers");
        mpg.setPackageInfo(pc);
 
        // 自定义配置
        InjectionConfig cfg = new InjectionConfig() {
            @Override
            public void initMap() {
                // to do nothing
            }
        };
 
        // 如果模板引擎是 freemarker
        String templatePath = "/templates/mapper.xml.ftl";
        // 如果模板引擎是 velocity
        // String templatePath = "/templates/mapper.xml.vm";
 
        // 自定义输出配置
        List<FileOutConfig> focList = new ArrayList<>();
        // 自定义配置会被优先输出
        focList.add(new FileOutConfig(templatePath) {
            @Override
            public String outputFile(TableInfo tableInfo) {
                // 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
                return projectPath + "/src/main/resources/mapper/"
                    + "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
            }
        });
 
        cfg.setFileOutConfigList(focList);
        mpg.setCfg(cfg);
 
        // 配置模板
        TemplateConfig templateConfig = new TemplateConfig();
 
        // 配置自定义输出模板
        //指定自定义模板路径,注意不要带上.ftl/.vm, 会根据使用的模板引擎自动识别
        // templateConfig.setEntity("templates/entity2.java");
        // templateConfig.setService();
        // templateConfig.setController();
 
        templateConfig.setXml(null);
        mpg.setTemplate(templateConfig);
 
        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        strategy.setNaming(NamingStrategy.underline_to_camel);
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        // 你自己的父类实体,没有就不用设置!
        strategy.setSuperEntityClass("org.myslayers.entity.BaseEntity");
        strategy.setEntityLombokModel(true);
        strategy.setRestControllerStyle(true);
        // 你自己的父类控制器,没有就不用设置!
        strategy.setSuperControllerClass("org.myslayers.controller.BaseController");
        strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
        strategy.setSuperEntityColumns("id", "created", "modified", "status");
        strategy.setControllerMappingHyphenStyle(true);
        strategy.setTablePrefix(pc.getModuleName() + "_");
        mpg.setStrategy(strategy);
        mpg.setTemplateEngine(new FreemarkerTemplateEngine());
        mpg.execute();
    }
}

10 分页插件

  • MyBatisPlusConfig.java :配置类,【SpringBoot 的使用方式】
@Configuration
@EnableTransactionManagement
@MapperScan("org.myslayers.mapper")
public class MyBatisPlusConfig {
 
    /**
     * 分页插件
     */
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
        return paginationInterceptor;
    }
}

11 执行 SQL 分析打印

  • spy.properties :配置文件,【该功能依赖 p6spy 组件,其中 datasource、freemarker、mybatis-plus 的配置】
spring:
  datasource:
    #    driver-class-name: com.mysql.cj.jdbc.Driver
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver
    url: jdbc:p6spy:mysql://127.0.0.1:3306/xblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=UTC
    username: root
    password: 123456
  freemarker:
    settings:
      classic_compatible: true
      datetime_format: yyyy-MM-dd HH:mm
      number_format: 0.##
  • spy.properties :配置文件,【p6spy 组件对应的 spy.properties 配置】
#3.2.1以下使用或者不配置
module.log=com.p6spy.engine.logging.P6LogFactory,com.p6spy.engine.outage.P6OutageFactory
# 自定义日志打印
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
#日志输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 使用日志系统记录 sql
#appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 设置 p6spy driver 代理
deregisterdrivers=true
# 取消JDBC URL前缀
useprefix=true
# 配置记录 Log 例外,可去掉的结果集有error,info,batch,debug,statement,commit,rollback,result,resultset.
excludecategories=info,debug,result,batch,resultset
# 日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# 实际驱动可多个
#driverlist=org.h2.Driver
# 是否开启慢SQL记录
outagedetection=true
# 慢SQL记录标准 2 秒
outagedetectioninterval=2

12 条件构造器

  • PostService.java :业务层接口
public interface PostService extends IService<Post> {
 
    IPage<PostVo> selectPosts(Page page, Long categoryId, Long userId, Integer level, Boolean recommend, String order);
}
  • PostServiceImpl.java:业务层实现
@Service
public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements PostService {
    @Autowired
    PostMapper postMapper;
 
    @Override
    public IPage<PostVo> selectPosts(Page page, Long categoryId, Long userId, Integer level, Boolean recommend, String order) {
        if (level == null) level = -1;
        QueryWrapper wrapper = new QueryWrapper<Post>()
                .eq(categoryId != null, "category_id", categoryId)
                .eq(userId != null, "user_id", userId)
                .eq(level == 0, "level", 0)
                .gt(level > 0, "level", 0)
                .orderByDesc(order != null, order);
        return postMapper.selectPosts(page, wrapper);
    }
 
    @Override
    public PostVo selectOnePost(QueryWrapper<Post> warapper) {
        return postMapper.selectOnePost(warapper);
    }
}
  • PostMapper.java :数据层接口
public interface PostMapper extends BaseMapper<Post> {
 
    IPage<PostVo> selectPosts(Page page, @Param(Constants.WRAPPER) QueryWrapper<Post> wrapper);
}
  • PostMapper.xml :数据层实现
<select id="selectPosts" resultType="org.myslayers.vo.PostVo">
SELECT p.id,
       p.title,
       p.content,
       p.edit_mode,
       p.category_id,
       p.user_id,
       p.vote_up,
       p.vote_down,
       p.view_count,
       p.comment_count,
       p.recommend,
       p.level,
       p.status,
       p.created,
       p.modified,
 
       u.id       AS authorId,
       u.username AS authorName,
       u.avatar   AS authorAvatar,
 
       c.id       AS categoryId,
       c.name     AS categoryName
FROM m_post p
       LEFT JOIN m_user u ON p.user_id = u.id
       LEFT JOIN m_category c ON p.category_id = c.id
  ${ew.customSqlSegment}
</select>

Part03-Controller控制层接口

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─config
│      │          │      ContextStartup.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      IndexController.java
│      │          │      PostController.java
│      │          │
│      │          ├─service
│      │          │  │  postService.java
│      │          │  │
│      │          │  └─impl
│      │          │         postServiceImpl.java
│      │
│      └─resources
│          ├─templates
│          │  │  index.ftl
│          │  │
│          │  ├─inc
│          │  │     header-panel.ftl
│          │  │
│          │  └─post
│          │        category.ftl
│          │        detail.ftl

13 首页

  • IndexController.java :控制层
@Controller
public class IndexController extends BaseController {
 
    /**
     * 首页index
     */
    @GetMapping({"", "/", "/index", "/index.html"})
    public String index() {
        /**
         * 多条(post实体类、PostVo实体类):分页集合results
         */
        //多条:selectPosts(分页信息、分类id、用户id、置顶、精选、排序)
        IPage<PostVo> results = postService.selectPosts(getPage(), null, null, null, null, "created");
        req.setAttribute("postVoDatas", results);
 
        /**
         * 分类(传入id) -> 渲染分类
         */
        //req:根据传入category表中当前页的id -> 【渲染】分类
        req.setAttribute("currentCategoryId", 0);
 
        return "index";
    }
}
  • index.ftl :模板引擎
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl"/>
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "首页">
 
  <#--【二、分类】-->
  <#include "/inc/header-panel.ftl"/>
 
  <#--【三、左侧md8 + 右侧md8】-->
  <div class="layui-container">
    <div class="layui-row layui-col-space15">
 
      <#--1.左侧md8-->
      <div class="layui-col-md8">
 
        <#--1.1 fly-panel-->
        <div class="fly-panel">
          <#--1.1.1 fly-panel-title-->
          <div class="fly-panel-title fly-filter">
            <a>置顶</a>
          </div>
          <#--1.1.2 消息列表-->
          <ul class="fly-list">
              <@details size=2 level=1>
              <#--1.1.2.1 消息列表-->
                  <#list results.records as post>
                      <@plisting post></@plisting>
                  </#list>
              </@details>
          </ul>
        </div>
 
        <#--1.2 fly-panel-->
        <div class="fly-panel" style="margin-bottom: 0;">
          <#--1.2.1 fly-panel-title-->
          <div class="fly-panel-title fly-filter">
            <a href="" class="layui-this">综合</a>
            <span class="fly-mid"></span>
            <a href="">未结</a>
            <span class="fly-mid"></span>
            <a href="">已结</a>
            <span class="fly-mid"></span>
            <a href="">精华</a>
            <span class="fly-filter-right layui-hide-xs">
              <a href="" class="layui-this">按最新</a>
              <span class="fly-mid"></span>
              <a href="">按热议</a>
            </span>
          </div>
 
          <#--1.2.2 消息列表-->
          <div class="fly-list">
              <#list postVoDatas.records as post>
                  <@plisting post></@plisting>
              </#list>
          </div>
 
          <#--1.2.3 分页条-->
          <@paging postVoDatas></@paging>
        </div>
      </div>
 
        <#--2.右侧md4-->
        <#include "/inc/right.ftl"/>
    </div>
  </div>
</@layout>

14 导航栏、文章分类

  • ContextStartup.java :配置类,【向 header-panel.ftl 传入 categorys
@Component
public class ContextStartup implements ApplicationRunner, ServletContextAware {
 
    @Autowired
    CategoryService categoryService;
 
    ServletContext servletContext;
 
    /**
     * 项目启动时,会同时调用该run方法:提前加载导航栏中的“提问、分享、讨论、建议”,并将其list放入servletContext上下文对象
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<Category> categories = categoryService.list(new QueryWrapper<Category>()
                .eq("status", 0)
        );
        servletContext.setAttribute("categorys", categories);
    }
 
    /**
     * servletContext上下文对象
     */
    @Override
    public void setServletContext(ServletContext servletContext) {
        this.servletContext = servletContext;
    }
}
  • PostController.java :控制类,【向 header-panel.ftl 传入 currentCategoryId
@Controller
public class PostController extends BaseController {
    /**
     * 分类category
     */
    @GetMapping("/category/{id:\\d*}")
    public String category(@PathVariable(name = "id") long id) {
        /**
         * 分类(传入id)-> 渲染分类
         */
        //req:根据传入category表中当前页的id -> 【渲染】分类
        req.setAttribute("currentCategoryId", id);
 
        //req:解决使用<@details categoryId=currentCategoryId pn=pn size=2>时,无法传入参数pn的方法:让pn直接从req请求中获取 -> 作为传入posts方法的参数
        req.setAttribute("pn", ServletRequestUtils.getIntParameter(req, "pn", 1));
 
        return "post/category";
    }
}
  • header-panel.ftl :模板引擎
<#--【二、分类】-->
<div class="fly-panel fly-column">
  <div class="layui-container">
    <ul class="layui-clear">
      <!-- 首页 -->
      <li class="${(0 == currentCategoryId)?string('layui-hide-xs layui-this', '')}"><a href="/">首页</a></li>
        <#--提问、分享、讨论、建议-->
        <#list categorys as item>
          <li class="${(item.id == currentCategoryId)?string('layui-hide-xs layui-this', '')}">
            <a href="/category/${item.id}">${item.name}</a>
          </li>
        </#list>
      <li class="layui-hide-xs layui-hide-sm layui-show-md-inline-block"><span class="fly-mid"></span></li>
      <!-- 用户登入后显示 -->
      <li class="layui-hide-xs layui-hide-sm layui-show-md-inline-block"><a href="/user/index#index">我发表的贴</a></li>
      <li class="layui-hide-xs layui-hide-sm layui-show-md-inline-block"><a href="/user/index#collection">我收藏的贴</a></li>
    </ul>
 
    <div class="fly-column-right layui-hide-xs">
      <span class="fly-search"><i class="layui-icon"></i></span>
      <a href="post/edit" class="layui-btn">发表新帖</a>
    </div>
    <div class="layui-hide-sm layui-show-xs-block" style="margin-top: -10px; padding-bottom: 10px; text-align: center;">
      <a href="post/edit" class="layui-btn">发表新帖</a>
    </div>
  </div>
</div>
  • category.ftl :模板引擎
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl"/>
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "博客分类">
 
  <#--【二、分类】-->
  <#include "/inc/header-panel.ftl"/>
 
  <#--【三、左侧md8 + 右侧md8】-->
  <div class="layui-container">
    <div class="layui-row layui-col-space15">
 
      <#--1.左侧md8-->
      <div class="layui-col-md8">
        <#--1.2 fly-panel-->
        <div class="fly-panel" style="margin-bottom: 0;">
          <#--1.2.1 fly-panel-title-->
          <div class="fly-panel-title fly-filter">
            <a href="" class="layui-this">综合</a>
            <span class="fly-mid"></span>
            <a href="">未结</a>
            <span class="fly-mid"></span>
            <a href="">已结</a>
            <span class="fly-mid"></span>
            <a href="">精华</a>
            <span class="fly-filter-right layui-hide-xs">
              <a href="" class="layui-this">按最新</a>
              <span class="fly-mid"></span>
              <a href="">按热议</a>
            </span>
          </div>
            <#--1.2.2 消息列表-->
            <@details categoryId=currentCategoryId pn=pn size=2>
              <ul class="fly-list">
                <#list results.records as post>
                  <@plisting post></@plisting>
                </#list>
              </ul>
                <#--1.2.3 分页条-->
                <@paging results></@paging>
            </@details>
        </div>
      </div>
 
      <#--2.右侧md4-->
      <#include "/inc/right.ftl"/>
    </div>
  </div>
 
  <script>
    layui.cache.page = 'jie';
  </script>
</@layout>

15 文章详情

  • PostController.java :控制层,【查看】文章、【查看】评论
@Controller
public class PostController extends BaseController {
 
    /**
     * 详情detail:【查看】文章、【查看】评论
     */
    @GetMapping("/post/{id:\\d*}")
    public String detail(@PathVariable(name = "id") long id) {
        /**
         * 一条(post实体类、PostVo实体类)
         */
        //一条:selectOnePost(表 文章id = 传 文章id),因为Mapper中select信息中,id过多引起歧义,故采用p.id
        PostVo postVo = postService.selectOnePost(new QueryWrapper<Post>().eq("p.id", id));
        //req:PostVo实体类 -> CategoryId属性
        req.setAttribute("currentCategoryId", postVo.getCategoryId());
        //req:PostVo实体类(回调)
        req.setAttribute("post", postVo);
 
        /**
         * 评论(comment实体类)
         */
        //评论:page(分页信息、文章id、用户id、排序)
        IPage<CommentVo> results = commentService.selectComments(getPage(), postVo.getId(), null, "created");
        //req:CommentVo分页集合
        req.setAttribute("pageData", results);
 
        /**
         * 文章阅读【缓存实现访问量】:减少访问数据库的次数,存在一个BUG,只与点击链接的次数相关,没有与用户的id进行绑定
         */
        postService.putViewCount(postVo);
 
        return "post/detail";
    }
}
  • detail.ftl :模板引擎
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl" />
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "博客详情">
 
  <#--【二、分类】-->
  <#include "/inc/header-panel.ftl" />
 
  <#--【三、左侧md8 + 右侧md8】-->
  <div class="layui-container">
    <div class="layui-row layui-col-space15">
 
      <#--1.左侧md8-->
      <div class="layui-col-md8 content detail">
        <#--1.1文章-->
        <div class="fly-panel detail-box">
          <#--1.1.1 文章标题-->
          <h1>${post.title}</h1>
 
          <#--1.1.2 文章标签-->
          <div class="fly-detail-info">
            <span class="layui-badge layui-bg-green fly-detail-column">${post.categoryName}</span>
 
            <#if post.level gt 0><span class="layui-badge layui-bg-black">置顶</span></#if>
            <#if post.recommend><span class="layui-badge layui-bg-red">精帖</span></#if>
 
            <div class="fly-admin-box" data-id="${post.id}">
                <#--发布者删除-->
                <#if post.userId == profile.id  &&  profile.id != 1>
                  <span class="layui-btn layui-btn-xs jie-admin" type="del">删除</span>
                </#if>
 
                <#--管理员操作-->
                <@shiro.hasRole name="admin">
                  <span class="layui-btn layui-btn-xs jie-admin" type="set" field="delete" rank="1" reload="true" >删除</span>
                    <#if post.level == 0><span class="layui-btn layui-btn-xs jie-admin" type="set" field="stick" rank="1">置顶</span></#if>
                    <#if post.level gt 0><span class="layui-btn layui-btn-xs jie-admin" type="set" field="stick" rank="0" style="background-color:#ccc;">取消置顶</span></#if>
                    <#if !post.recommend><span class="layui-btn layui-btn-xs jie-admin" type="set" field="status" rank="1">加精</span></#if>
                    <#if post.recommend><span class="layui-btn layui-btn-xs jie-admin" type="set" field="status" rank="0" style="background-color:#ccc;">取消加精</span></#if>
                </@shiro.hasRole>
            </div>
 
            <span class="fly-list-nums">
              <a href="#comment"><i class="iconfont" title="回答">&#xe60c;</i>${post.commentCount}</a>
              <i class="iconfont" title="人气">&#xe60b;</i>${post.viewCount}
            </span>
          </div>
 
          <#--1.1.3 文章作者信息-->
          <div class="detail-about">
            <a class="fly-avatar" href="/user/${post.authorId}">
              <img src="${post.authorAvatar}" alt="${post.authorName}">
            </a>
            <div class="fly-detail-user">
              <a href="/user/${post.authorId}" class="fly-link">
                <cite>${post.authorName}</cite>
              </a>
              <span>${timeAgo(post.created)}</span>
            </div>
 
            <div class="detail-hits" id="LAY_jieAdmin" data-id="${post.id}">
              <#--登录状态下,用户id = 作者id,才能进行【编辑文章】-->
              <#if profile.id == post.userId>
                <span class="layui-btn layui-btn-xs jie-admin" type="edit">
                  <a href="/post/edit?id=${post.id}">编辑此贴</a>
                </span>
              </#if>
              <#--未登录状态下,【缺少span块引起的显示问题】,作用:空占位,美化样式-->
              <span class="jie-admin" type=""></span>
            </div>
          </div>
 
          <#--1.1.4 文章内容-->
          <div class="detail-body photos">
              ${post.content}
          </div>
        </div>
 
        <#--1.2 评论-->
        <div class="fly-panel detail-box" id="flyReply">
          <#--1.2.1 回帖线-->
          <fieldset class="layui-elem-field layui-field-title" style="text-align: center;">
            <legend>回帖</legend>
          </fieldset>
 
          <#--1.2.2 评论区-->
          <ul class="jieda" id="jieda">
            <#list pageData.records as comment>
              <li data-id="${comment.id}" class="jieda-daan">
                <a name="item-${comment.id}"></a>
                <div class="detail-about detail-about-reply">
                  <a class="fly-avatar" href="/user/${post.authorId}">
                    <img src="${comment.authorAvatar}" alt="${comment.authorName}">
                  </a>
                  <div class="fly-detail-user">
                    <a href="/user/${comment.authorId}" class="fly-link">
                      <cite>${comment.authorName}</cite>
                    </a>
                    <#if comment.user_id == post.user_id>
                      <span>(楼主)</span>
                    </#if>
                  </div>
                  <div class="detail-hits">
                    <span>${timeAgo(comment.created)}</span>
                  </div>
                </div>
                <div class="detail-body jieda-body photos">
                  ${comment.content}
                </div>
                <div class="jieda-reply">
                    <span class="jieda-zan zanok" type="zan">
                      <i class="iconfont icon-zan"></i>
                      <em>${comment.voteUp}</em>
                    </span>
                  <span type="reply">
                      <i class="iconfont icon-svgmoban53"></i>
                      回复
                    </span>
                  <div class="jieda-admin">
                    <span type="del">删除</span>
                  </div>
                </div>
              </li>
            </#list>
          </ul>
 
          <#--1.2.3 评论分页-->
          <@paging pageData></@paging>
 
          <#--1.2.4 回复区-->
          <div class="layui-form layui-form-pane">
            <form action="/post/reply/" method="post">
              <div class="layui-form-item layui-form-text">
                <a name="comment"></a>
                <div class="layui-input-block">
                  <textarea id="L_content" name="content" required lay-verify="required"
                            placeholder="请输入内容" class="layui-textarea fly-editor"
                            style="height: 150px;"></textarea>
                </div>
              </div>
              <div class="layui-form-item">
                <input type="hidden" name="jid" value="${post.id}">
                <button class="layui-btn" lay-filter="*" lay-submit>提交回复</button>
              </div>
            </form>
          </div>
        </div>
      </div>
 
      <#--2.右侧md4-->
      <#include "/inc/right.ftl"/>
    </div>
  </div>
  <script>
    layui.cache.page = 'jie';
  </script>
 
</@layout>

Part04-自定义Freemaker标签

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─common
│      │          │  └─templates
│      │          │         DirectiveHandler.java
│      │          │         TemplateDirective.java
│      │          │         TemplateModelUtils.java
│      │          │
│      │          ├─config
│      │          │      FreemarkerConfig.java
│      │          │
│      │          ├─template
│      │          │      PostsTemplate.java
│      │          │      TimeAgoMethod.java

16 方式一:实现TemplateDirectiveModel接口,重写 excute 方法

  • TemplateDirectiveModel.java :配置类
public interface TemplateDirectiveModel extends TemplateModel {
  public void execute(Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body) throws TemplateException, IOException;
}

上述方法的参数说明:

  • env:系统环境变量,通常用它来输出相关内容,如 Writer out = env.getOut()。
  • params:自定义标签传过来的对象,其 key = 自定义标签的参数名,value 值是 TemplateModel 类型,而 TemplateModel 是一个接口类型,通常我们都使用 TemplateScalarModel 接口来替代它获取一个 String 值,如 TemplateScalarModel.getAsString(); 当然还有其它常用的替代接口,如 TemplateNumberModel 获取 number,TemplateHashModel 等。
  • loopVars 循环替代变量。
  • body 用于处理自定义标签中的内容,如 @myDirective 将要被处理的内容;当标签是<@myDirective /> 格式时,body=null。

17 方式二:采用 mblog 项目对该 TemplateDirectiveModel 接口进行封装

  • 实现逻辑:
    • 实现 TemplateDirectiveModel 接口较为复杂,故我们可以直接使用 mblog 项目中已经封装好的类:org.myslayers.common.templates.DirectiveHandler、TemplateDirective、TemplateModelUtils;
    • 其中,我们只需要重写 TemplateDirective 类中的 getName()和 excute(DirectiveHandler handler),本次使用 PostsTemplateTimeAgoMethod 进行开发使用;
    • 最后,使用 FreemarkerConfig 类在 Springboot 中对 PostsTemplate、TimeAgoMethod 进行标签的声明<timeAgo></timeAgo><details></details>
  • DirectiveHandler.java:配置类,【配置标签】
/**
 * mblog:开发标签
 */
public class DirectiveHandler {
 
    private Environment env;
    private Map<String, TemplateModel> parameters;
    private TemplateModel[] loopVars;
    private TemplateDirectiveBody body;
    private Environment.Namespace namespace;
 
    /**
     * 构建 DirectiveHandler
     *
     * @param env        系统环境变量,通常用它来输出相关内容,如Writer out = env.getOut()。
     * @param parameters 自定义标签传过来的对象
     * @param loopVars   循环替代变量
     * @param body       用于处理自定义标签中的内容,如<@myDirective>将要被处理的内容</@myDirective>;当标签是<@myDirective
     *                   />格式时,body=null。
     */
    public DirectiveHandler(Environment env, Map<String, TemplateModel> parameters,
        TemplateModel[] loopVars,
        TemplateDirectiveBody body) {
        this.env = env;
        this.loopVars = loopVars;
        this.parameters = parameters;
        this.body = body;
        this.namespace = env.getCurrentNamespace();
    }
 
    public void render() throws IOException, TemplateException {
        Assert.notNull(body, "must have template directive body");
        body.render(env.getOut());
    }
 
    public void renderString(String text) throws Exception {
        StringWriter writer = new StringWriter();
        writer.append(text);
        env.getOut().write(text);
    }
 
    public DirectiveHandler put(String key, Object value) throws TemplateModelException {
        namespace.put(key, wrap(value));
        return this;
    }
 
    public String getString(String name) throws TemplateModelException {
        return TemplateModelUtils.converString(getModel(name));
    }
 
    public Integer getInteger(String name) throws TemplateModelException {
        return TemplateModelUtils.converInteger(getModel(name));
    }
 
    public Short getShort(String name) throws TemplateModelException {
        return TemplateModelUtils.converShort(getModel(name));
    }
 
    public Long getLong(String name) throws TemplateModelException {
        return TemplateModelUtils.converLong(getModel(name));
    }
 
    public Double getDouble(String name) throws TemplateModelException {
        return TemplateModelUtils.converDouble(getModel(name));
    }
 
    public String[] getStringArray(String name) throws TemplateModelException {
        return TemplateModelUtils.converStringArray(getModel(name));
    }
 
    public Boolean getBoolean(String name) throws TemplateModelException {
        return TemplateModelUtils.converBoolean(getModel(name));
    }
 
    public Date getDate(String name) throws TemplateModelException {
        return TemplateModelUtils.converDate(getModel(name));
    }
 
    public String getString(String name, String defaultValue) throws Exception {
        String result = getString(name);
        return null == result ? defaultValue : result;
    }
 
    public Integer getInteger(String name, int defaultValue) throws Exception {
        Integer result = getInteger(name);
        return null == result ? defaultValue : result;
    }
 
    public Long getLong(String name, long defaultValue) throws Exception {
        Long result = getLong(name);
        return null == result ? defaultValue : result;
    }
 
 
    public String getContextPath() {
        String ret = null;
        try {
            ret = TemplateModelUtils.converString(getEnvModel("base"));
        } catch (TemplateModelException e) {
        }
        return ret;
    }
 
    /**
     * 包装对象
     */
    public TemplateModel wrap(Object object) throws TemplateModelException {
        return env.getObjectWrapper().wrap(object);
    }
 
    /**
     * 获取局部变量
     */
    public TemplateModel getEnvModel(String name) throws TemplateModelException {
        return env.getVariable(name);
    }
 
    public void write(String text) throws IOException {
        env.getOut().write(text);
    }
 
    private TemplateModel getModel(String name) {
        return parameters.get(name);
    }
 
    public abstract static class BaseMethod implements TemplateMethodModelEx {
 
        public String getString(List<TemplateModel> arguments, int index)
            throws TemplateModelException {
            return TemplateModelUtils.converString(getModel(arguments, index));
        }
 
        public Integer getInteger(List<TemplateModel> arguments, int index)
            throws TemplateModelException {
            return TemplateModelUtils.converInteger(getModel(arguments, index));
        }
 
        public Long getLong(List<TemplateModel> arguments, int index)
            throws TemplateModelException {
            return TemplateModelUtils.converLong(getModel(arguments, index));
        }
 
        public Date getDate(List<TemplateModel> arguments, int index)
            throws TemplateModelException {
            return TemplateModelUtils.converDate(getModel(arguments, index));
        }
 
        public TemplateModel getModel(List<TemplateModel> arguments, int index) {
            if (index < arguments.size()) {
                return arguments.get(index);
            }
            return null;
        }
    }
}
  • TemplateDirective.java :配置类,【配置标签】
/**
 * mblog:开发标签
 */
public abstract class TemplateDirective implements TemplateDirectiveModel {
 
    protected static String RESULT = "result";
    protected static String RESULTS = "results";
 
    @Override
    public void execute(Environment env, Map parameters,
        TemplateModel[] loopVars, TemplateDirectiveBody body)
        throws TemplateException, IOException {
        try {
            execute(new DirectiveHandler(env, parameters, loopVars, body));
        } catch (IOException e) {
            throw e;
        } catch (Exception e) {
            throw new TemplateException(e, env);
        }
    }
 
    abstract public String getName();
 
    abstract public void execute(DirectiveHandler handler) throws Exception;
 
}
  • TemplateModelUtils.java :配置类,【配置标签】
/**
 * mblog:开发标签(Freemarker 模型工具类)
 */
public class TemplateModelUtils {
 
    public static final DateFormat FULL_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    public static final int FULL_DATE_LENGTH = 19;
 
    public static final DateFormat SHORT_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
    public static final int SHORT_DATE_LENGTH = 10;
 
    public static String converString(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateScalarModel) {
                return ((TemplateScalarModel) model).getAsString();
            } else if ((model instanceof TemplateNumberModel)) {
                return ((TemplateNumberModel) model).getAsNumber().toString();
            }
        }
        return null;
    }
 
    public static TemplateHashModel converMap(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateHashModelEx) {
                return (TemplateHashModelEx) model;
            } else if (model instanceof TemplateHashModel) {
                return (TemplateHashModel) model;
            }
        }
        return null;
    }
 
    public static Integer converInteger(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateNumberModel) {
                return ((TemplateNumberModel) model).getAsNumber().intValue();
            } else if (model instanceof TemplateScalarModel) {
                String s = ((TemplateScalarModel) model).getAsString();
                if (isNotBlank(s)) {
                    try {
                        return Integer.parseInt(s);
                    } catch (NumberFormatException e) {
                    }
                }
            }
        }
        return null;
    }
 
    public static Short converShort(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateNumberModel) {
                return ((TemplateNumberModel) model).getAsNumber().shortValue();
            } else if (model instanceof TemplateScalarModel) {
                String s = ((TemplateScalarModel) model).getAsString();
                if (isNotBlank(s)) {
                    try {
                        return Short.parseShort(s);
                    } catch (NumberFormatException e) {
                    }
                }
            }
        }
        return null;
    }
 
    public static Long converLong(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateNumberModel) {
                return ((TemplateNumberModel) model).getAsNumber().longValue();
            } else if (model instanceof TemplateScalarModel) {
                String s = ((TemplateScalarModel) model).getAsString();
                if (isNotBlank(s)) {
                    try {
                        return Long.parseLong(s);
                    } catch (NumberFormatException e) {
                    }
                }
            }
        }
        return null;
    }
 
    public static Double converDouble(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateNumberModel) {
                return ((TemplateNumberModel) model).getAsNumber().doubleValue();
            } else if (model instanceof TemplateScalarModel) {
                String s = ((TemplateScalarModel) model).getAsString();
                if (isNotBlank(s)) {
                    try {
                        return Double.parseDouble(s);
                    } catch (NumberFormatException ignored) {
                    }
                }
            }
        }
        return null;
    }
 
    public static String[] converStringArray(TemplateModel model) throws TemplateModelException {
        if (model instanceof TemplateSequenceModel) {
            TemplateSequenceModel smodel = (TemplateSequenceModel) model;
            String[] values = new String[smodel.size()];
            for (int i = 0; i < smodel.size(); i++) {
                values[i] = converString(smodel.get(i));
            }
            return values;
        } else {
            String str = converString(model);
            if (isNotBlank(str)) {
                return split(str, ',');
            }
        }
        return null;
    }
 
    public static Boolean converBoolean(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateBooleanModel) {
                return ((TemplateBooleanModel) model).getAsBoolean();
            } else if (model instanceof TemplateNumberModel) {
                return !(0 == ((TemplateNumberModel) model).getAsNumber().intValue());
            } else if (model instanceof TemplateScalarModel) {
                String temp = ((TemplateScalarModel) model).getAsString();
                if (isNotBlank(temp)) {
                    return Boolean.valueOf(temp);
                }
            }
        }
        return null;
    }
 
    public static Date converDate(TemplateModel model) throws TemplateModelException {
        if (null != model) {
            if (model instanceof TemplateDateModel) {
                return ((TemplateDateModel) model).getAsDate();
            } else if (model instanceof TemplateScalarModel) {
                String temp = trimToEmpty(((TemplateScalarModel) model).getAsString());
                return parseDate(temp);
            }
        }
        return null;
    }
 
    public static Date parseDate(String date) {
 
        Date ret = null;
        try {
            if (FULL_DATE_LENGTH == date.length()) {
                ret = FULL_DATE_FORMAT.parse(date);
            } else if (SHORT_DATE_LENGTH == date.length()) {
                ret = SHORT_DATE_FORMAT.parse(date);
            }
        } catch (ParseException e) {
        }
        return ret;
    }
}
  • TimeAgoMethod.java :工具类,【开发标签】
@Component
public class TimeAgoMethod extends DirectiveHandler.BaseMethod {
 
    private static final long ONE_MINUTE = 60000L;
    private static final long ONE_HOUR = 3600000L;
    private static final long ONE_DAY = 86400000L;
    private static final long ONE_WEEK = 604800000L;
 
    private static final String ONE_SECOND_AGO = "秒前";
    private static final String ONE_MINUTE_AGO = "分钟前";
    private static final String ONE_HOUR_AGO = "小时前";
    private static final String ONE_DAY_AGO = "天前";
    private static final String ONE_MONTH_AGO = "月前";
    private static final String ONE_YEAR_AGO = "年前";
    private static final String ONE_UNKNOWN = "未知";
 
    @Override
    public Object exec(List arguments) throws TemplateModelException {
        Date time = getDate(arguments, 0);
        return format(time);
    }
 
    public static String format(Date date) {
        if (null == date) {
            return ONE_UNKNOWN;
        }
        long delta = new Date().getTime() - date.getTime();
        if (delta < 1L * ONE_MINUTE) {
            long seconds = toSeconds(delta);
            return (seconds <= 0 ? 1 : seconds) + ONE_SECOND_AGO;
        }
        if (delta < 45L * ONE_MINUTE) {
            long minutes = toMinutes(delta);
            return (minutes <= 0 ? 1 : minutes) + ONE_MINUTE_AGO;
        }
        if (delta < 24L * ONE_HOUR) {
            long hours = toHours(delta);
            return (hours <= 0 ? 1 : hours) + ONE_HOUR_AGO;
        }
        if (delta < 48L * ONE_HOUR) {
            return "昨天";
        }
        if (delta < 30L * ONE_DAY) {
            long days = toDays(delta);
            return (days <= 0 ? 1 : days) + ONE_DAY_AGO;
        }
        if (delta < 12L * 4L * ONE_WEEK) {
            long months = toMonths(delta);
            return (months <= 0 ? 1 : months) + ONE_MONTH_AGO;
        } else {
            long years = toYears(delta);
            return (years <= 0 ? 1 : years) + ONE_YEAR_AGO;
        }
    }
 
    private static long toSeconds(long date) {
        return date / 1000L;
    }
 
    private static long toMinutes(long date) {
        return toSeconds(date) / 60L;
    }
 
    private static long toHours(long date) {
        return toMinutes(date) / 60L;
    }
 
    private static long toDays(long date) {
        return toHours(date) / 24L;
    }
 
    private static long toMonths(long date) {
        return toDays(date) / 30L;
    }
 
    private static long toYears(long date) {
        return toMonths(date) / 365L;
    }
}
  • PostsTemplate.java :工具类,【开发标签】
/**
 * 文章具体详情
 */
@Component
public class PostsTemplate extends TemplateDirective {
 
    @Autowired
    PostService postService;
 
    @Override
    public String getName() {
        return "details";
    }
 
    /**
     * 分页外(置顶) -> 默认分页的基本信息
     */
    @Override
    public void execute(DirectiveHandler handler) throws Exception {
        // 置顶等级level
        Integer level = handler.getInteger("level", 1);
        // 起始页码pn
        Integer pn = handler.getInteger("pn", 1);
        // 页面大小size
        Integer size = handler.getInteger("size", 2);
        // 分类信息categoryId
        Long categoryId = handler.getLong("categoryId");
 
        /**
         * 多条(post实体类、PostVo实体类):分页集合results:
         *   1.封装level、pn、size、categoryId
         *   2.注册为“posts”函数:默认调用该函数时,自动查询 -> 分页集合results
         */
        IPage<PostVo> page = postService
            .selectPosts(new Page(pn, size), categoryId, null, level, null, "created");
        handler.put(RESULTS, page).render();
    }
}
  • FreemarkerConfig.java :配置类,【注册标签】
/**
 * Freemarker配置类
 */
@Configuration
public class FreemarkerConfig {
 
    @Autowired
    private freemarker.template.Configuration configuration;
 
    @Autowired
    TimeAgoMethod timeAgoMethod;
 
    @Autowired
    PostsTemplate postsTemplate;
 
    @Autowired
    HotsTemplate hotsTemplate;
 
    /**
     * 注册为“timeAgo”函数:快速实现日期转换
     * 注册为“posts”函数:快速实现分页
     */
    @PostConstruct
    public void setUp() {
        configuration.setSharedVariable("timeAgo", timeAgoMethod);
        configuration.setSharedVariable("details", postsTemplate);
    }
}

Part05-项目启动前加载导航栏

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─config
│      │          │      ContextStartup.java
│      │
│      └─resources
│          ├─templates
│          │  └─inc
│          │        header-panel.ftl

18 ContextStartup 配置类

  • ContextStartup.java :配置类,【提前加载导航栏中的“提问、分享、讨论、建议”】
/**
 * Context配置类
 */
@Component
public class ContextStartup implements ApplicationRunner, ServletContextAware {
 
    @Autowired
    CategoryService categoryService;
 
    ServletContext servletContext;
 
    /**
     * 项目启动时,会同时调用该run方法:提前加载导航栏中的“提问、分享、讨论、建议”,并将其list放入servletContext上下文对象
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<Category> categories = categoryService.list(new QueryWrapper<Category>()
                .eq("status", 0)
        );
        servletContext.setAttribute("categorys", categories);
    }
 
    /**
     * servletContext上下文对象
     */
    @Override
    public void setServletContext(ServletContext servletContext) {
        this.servletContext = servletContext;
    }
 
}

19 使用

  • header-panel.ftl :模板引擎,【根据 currentCategoryId、categorys 对数据进行渲染】
<#--【二、分类】-->
<div class="fly-panel fly-column">
    <div class="layui-container">
        <ul class="layui-clear">
 
            <#--首页-->
            <li class="${(0 == currentCategoryId)?string('layui-hide-xs layui-this', '')}"><a href="/">首页</a></li>
            <#--提问、分享、讨论、建议-->
            <#list categorys as item>
                <li class="${(item.id == currentCategoryId)?string('layui-hide-xs layui-this', '')}"><a href="/category/${item.id}">${item.name}</a></li>
            </#list>
 
            <li class="layui-hide-xs layui-hide-sm layui-show-md-inline-block"><span class="fly-mid"></span></li>
            <!-- 用户登入后显示 -->
            <li class="layui-hide-xs layui-hide-sm layui-show-md-inline-block"><a href="user/index.html">我发表的贴</a></li>
            <li class="layui-hide-xs layui-hide-sm layui-show-md-inline-block"><a href="user/index.html#collection">我收藏的贴</a>
            </li>
        </ul>
 
        <div class="fly-column-right layui-hide-xs">
            <span class="fly-search"><i class="layui-icon"></i></span>
            <a href="jie/add.html" class="layui-btn">发表新帖</a>
        </div>
        <div class="layui-hide-sm layui-show-xs-block"
             style="margin-top: -10px; padding-bottom: 10px; text-align: center;">
            <a href="jie/add.html" class="layui-btn">发表新帖</a>
        </div>
    </div>
</div>

Part06-侧边栏本周热议

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─config
│      │          │      RedisConfig.java
│      │          │      ContextStartup.java
│      │          │      FreemarkerConfig.java
│      │          │
│      │          ├─service
│      │          │  │  PostService.java
│      │          │  │
│      │          │  └─impl
│      │          │         PostServiceImpl.java
│      │          │
│      │          ├─template
│      │          │      HotsTemplate.java
│      │          │
│      │          ├─utils
│      │          │      RedisUtil.java
│      │
│      └─resources
│          ├─templates
│          │  └─inc
│          │        right.ftl

20 Redis环境搭建

  • pom.xml :项目依赖,【添加 redis 依赖,添加 hutool 依赖】
<dependencies>
    <!--Redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
 
    <!--hutool:工具包,例如DateUtils工具类...-->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>4.1.17</version>
    </dependency>
</dependencies>
  • RedisConfig.java :配置类,【考虑到 redis 序列化后出现乱码问题,使用 RedisConfig 配置类进行编码的处理】
/**
 * 指定Redis的序列化后的格式
 */
@Configuration
public class RedisConfig {
 
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
 
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        jackson2JsonRedisSerializer.setObjectMapper(new ObjectMapper());
 
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(jackson2JsonRedisSerializer);
 
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
 
        return template;
    }
 
}
  • RedisUtil.java :工具类
/**
 * RedisUtil 工具类
 */
@Component
public class RedisUtil {
 
    @Autowired
    private RedisTemplate redisTemplate;
 
    /**
     * 指定缓存失效时间
     *
     * @param key
     * @param time 时间(秒)
     * @return
     */
    public boolean expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 根据key 获取过期时间
     *
     * @param key 键 不能为null
     * @return 时间(秒) 返回0代表为永久有效
     */
    public long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }
 
    /**
     * 判断key是否存在
     *
     * @param key
     * @return true 存在 false不存在
     */
    public boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 删除缓存
     *
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("unchecked")
    public void del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }
 
    //============================String=============================
 
    /**
     * 普通缓存获取
     *
     * @param key
     * @return
     */
    public Object get(String key) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }
 
    /**
     * 普通缓存放入
     *
     * @param key
     * @param value
     * @return true成功 false失败
     */
    public boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
 
    }
 
    /**
     * 普通缓存放入并设置时间
     *
     * @param key
     * @param value
     * @param time  时间(秒) time要大于0 如果time小于等于0 将设置无限期
     * @return true成功 false 失败
     */
    public boolean set(String key, Object value, long time) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 递增
     *
     * @param key
     * @param delta  要增加几(大于0)
     * @return
     */
    public long incr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递增因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, delta);
    }
 
    /**
     * 递减
     *
     * @param key
     * @param delta  要减少几(小于0)
     * @return
     */
    public long decr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递减因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, -delta);
    }
 
    //================================Map=================================
 
    /**
     * HashGet
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return
     */
    public Object hget(String key, String item) {
        return redisTemplate.opsForHash().get(key, item);
    }
 
    /**
     * 获取hashKey对应的所有键值
     *
     * @param key
     * @return 对应的多个键值
     */
    public Map<Object, Object> hmget(String key) {
        return redisTemplate.opsForHash().entries(key);
    }
 
    /**
     * HashSet
     *
     * @param key
     * @param map 对应多个键值
     * @return true 成功 false 失败
     */
    public boolean hmset(String key, Map<String, Object> map) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * HashSet 并设置时间
     *
     * @param key
     * @param map  对应多个键值
     * @param time 时间(秒)
     * @return true成功 false失败
     */
    public boolean hmset(String key, Map<String, Object> map, long time) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key
     * @param item
     * @param value
     * @return true 成功 false失败
     */
    public boolean hset(String key, String item, Object value) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key
     * @param item
     * @param value
     * @param time  时间(秒)  注意:如果已存在的hash表有时间,这里将会替换原有的时间
     * @return true 成功 false失败
     */
    public boolean hset(String key, String item, Object value, long time) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 删除hash表中的值
     *
     * @param key  键 不能为null
     * @param item 项 可以使多个 不能为null
     */
    public void hdel(String key, Object... item) {
        redisTemplate.opsForHash().delete(key, item);
    }
 
    /**
     * 判断hash表中是否有该项的值
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return true 存在 false不存在
     */
    public boolean hHasKey(String key, String item) {
        return redisTemplate.opsForHash().hasKey(key, item);
    }
 
    /**
     * hash递增 如果不存在,就会创建一个 并把新增后的值返回
     *
     * @param key
     * @param item
     * @param by   要增加几(大于0)
     * @return
     */
    public double hincr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, by);
    }
 
    /**
     * hash递减
     *
     * @param key
     * @param item
     * @param by   要减少记(小于0)
     * @return
     */
    public double hdecr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, -by);
    }
 
    //============================set=============================
 
    /**
     * 根据key获取Set中的所有值
     *
     * @param key
     * @return
     */
    public Set<Object> sGet(String key) {
        try {
            return redisTemplate.opsForSet().members(key);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
 
    /**
     * 根据value从一个set中查询,是否存在
     *
     * @param key
     * @param value
     * @return true 存在 false不存在
     */
    public boolean sHasKey(String key, Object value) {
        try {
            return redisTemplate.opsForSet().isMember(key, value);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 将数据放入set缓存
     *
     * @param key
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public long sSet(String key, Object... values) {
        try {
            return redisTemplate.opsForSet().add(key, values);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
 
    /**
     * 将set数据放入缓存
     *
     * @param key
     * @param time   时间(秒)
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public long sSetAndTime(String key, long time, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().add(key, values);
            if (time > 0) expire(key, time);
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
 
    /**
     * 获取set缓存的长度
     *
     * @param key
     * @return
     */
    public long sGetSetSize(String key) {
        try {
            return redisTemplate.opsForSet().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
 
    /**
     * 移除值为value的
     *
     * @param key
     * @param values 值 可以是多个
     * @return 移除的个数
     */
    public long setRemove(String key, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().remove(key, values);
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
    //===============================list=================================
 
    /**
     * 获取list缓存的内容
     *
     * @param key
     * @param start 开始
     * @param end   结束  0 到 -1代表所有值
     * @return
     */
    public List<Object> lGet(String key, long start, long end) {
        try {
            return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
 
    /**
     * 获取list缓存的长度
     *
     * @param key
     * @return
     */
    public long lGetListSize(String key) {
        try {
            return redisTemplate.opsForList().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
 
    /**
     * 通过索引 获取list中的值
     *
     * @param key
     * @param index 索引  index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推
     * @return
     */
    public Object lGetIndex(String key, long index) {
        try {
            return redisTemplate.opsForList().index(key, index);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
 
    /**
     * 将list放入缓存
     *
     * @param key
     * @param value
     * @return
     */
    public boolean lSet(String key, Object value) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 将list放入缓存
     *
     * @param key
     * @param value
     * @param time  时间(秒)
     * @return
     */
    public boolean lSet(String key, Object value, long time) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            if (time > 0) expire(key, time);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 将list放入缓存
     *
     * @param key
     * @param value
     * @return
     */
    public boolean lSet(String key, List<Object> value) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 将list放入缓存
     *
     * @param key
     * @param value
     * @param time  时间(秒)
     * @return
     */
    public boolean lSet(String key, List<Object> value, long time) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            if (time > 0) expire(key, time);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 根据索引修改list中的某条数据
     *
     * @param key
     * @param index 索引
     * @param value
     * @return
     */
    public boolean lUpdateIndex(String key, long index, Object value) {
        try {
            redisTemplate.opsForList().set(key, index, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
 
    /**
     * 移除N个值为value
     *
     * @param key
     * @param count 移除多少个
     * @param value
     * @return 移除的个数
     */
    public long lRemove(String key, long count, Object value) {
        try {
            Long remove = redisTemplate.opsForList().remove(key, count, value);
            return remove;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
 
    //================有序集合 sort set===================
    /**
     * 有序set添加元素
     *
     * @param key
     * @param value
     * @param score
     * @return
     */
    public boolean zSet(String key, Object value, double score) {
        return redisTemplate.opsForZSet().add(key, value, score);
    }
 
    public long batchZSet(String key, Set<ZSetOperations.TypedTuple> typles) {
        return redisTemplate.opsForZSet().add(key, typles);
    }
 
    public void zIncrementScore(String key, Object value, long delta) {
        redisTemplate.opsForZSet().incrementScore(key, value, delta);
    }
 
    /**
     * ZUNIONSTORE destination numkeys key [key ...]中,无numkeys,因为numkeys = key + otherKeys
     *
     * @param key 【第7天的key,即今天的key】
     * @param otherKeys 【前6天的keys,用Collection集合来保存】
     * @param destKey 描述key
     */
    public void zUnionAndStore(String key, Collection otherKeys, String destKey) {
        redisTemplate.opsForZSet().unionAndStore(key, otherKeys, destKey);
    }
 
    /**
     * 获取zset数量
     * @param key
     * @param value
     * @return
     */
    public long getZsetScore(String key, Object value) {
        Double score = redisTemplate.opsForZSet().score(key, value);
        if(score==null){
            return 0;
        }else{
            return score.longValue();
        }
    }
 
    /**
     * 获取有序集 key 中成员 member 的排名 。
     * 其中有序集成员按 score 值递减 (从大到小) 排序。
     * @param key
     * @param start
     * @param end
     * @return
     */
    public Set<ZSetOperations.TypedTuple> getZSetRank(String key, long start, long end) {
        return redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
    }
 
}

21 本周热议的【基本原理】

  • 缓存热评文章——哈希表 Hash
  • 评论数量排行——有序列表 sortedSet:ZADD(添加)、ZREVRANGE(展示)、ZUNIONSTORE(并集)
  • ZADD key score member [[score member] [score member] …]
127.0.0.1:6379> ZADD day:18 10 post:1 6 post:2 4 post:3
(integer) 3
127.0.0.1:6379> ZADD day:19 10 post:1 6 post:2 4 post:3
(integer) 3
127.0.0.1:6379> ZADD day:20 10 post:1 6 post:2 4 post:3
(integer) 3
127.0.0.1:6379> ZADD day:21 10 post:1 6 post:2 4 post:3
(integer) 3
127.0.0.1:6379> ZADD day:22 10 post:1 6 post:2 4 post:3
(integer) 3
127.0.0.1:6379> ZADD day:23 10 post:1 6 post:2 4 post:3
(integer) 3
127.0.0.1:6379> ZADD day:24 10 post:1 6 post:2 4 post:3
(integer) 3
  • ZREVRANGE key start stop [WITHSCORES]
127.0.0.1:6379> ZREVRANGE day:18 0 -1 withscores
1) "post:1"
2) "10"
3) "post:2"
4) "6"
5) "post:3"
6) "4"
  • ZUNIONSTORE destination numkeys key [key …] [WEIGHTS weight [weight …]] [AGGREGATE SUM|MIN|MAX]
127.0.0.1:6379> ZUNIONSTORE week:rank 7 day:18 day:19 day:20 day:21 day:22 day:23 day:24
1) "post:1"
2) "post:2"
  • 查看排行榜
127.0.0.1:6379> ZREVRANGE week:rank 0 -1 withscores
1) "post:1"
2) "70"
3) "post:2"
4) "42"
5) "post:3"
6) "28"
  • 添加/删除评论
127.0.0.1:6379> ZINCRBY day:18 10 post:1
"20"
127.0.0.1:6379> ZREVRANGE  day:18 0 -1 withscores
1) "post:1"
2) "20"
3) "post:2"
4) "6"
5) "post:3"
6) "4"
127.0.0.1:6379> ZINCRBY day:18 -10 post:1
"10"

22 本周热议的【初始化操作】

  • 实现逻辑:
    • 项目启动前,获取【近 7 天文章】
    • 初始化【近 7 天文章】的总评论量(先使用 SortedSet 集合对【排行榜 7 天内全部文章】进行 zadd 操作,并设置它们 expire 为 7 天;再使用 Hash 哈希表对【排行榜 7 天内全部文章】进行 hexists 判断,再 hset 缓存操作)
      • 添加 add——将【近 7 天文章】创建日期时间作为 key 值,每篇文章对应的 id 作为它的 value 值,每篇文章对应的评论 comment 作为它的 score 值,并使用 redis 的工具类(RedisUtil),对文章的具体属性进行 zSet()缓存操作
      • 过期 expire——让【近 7 天文章】的 key 过期: 7-(当前时间-创建时间)= 过期时间
      • 缓存——缓存【近 7 天文章】的一些基本信息,例如文章 id,标题 title,评论数量,作者信息…方便访问【近 7 天文章】时,直接 redis,而非 MySQL
        • 先对文章进行 EXISTS 判断其缓存是否存在
        • 如果 false 不存在,则再 hset 缓存操作
    • 对【近 7 天文章】做并集运算(zUnionAndStore), 并使用根据评论量的数量从大到小进行展示(zrevrange)
  • ContextStartup.java :配置类
/**
 * Context配置类
 */
@Component
public class ContextStartup implements ApplicationRunner, ServletContextAware {
 
    @Autowired
    CategoryService categoryService;
 
    ServletContext servletContext;
 
    @Autowired
    PostService postService;
 
    /**
     * 项目启动时,会同时调用该run方法:
     *
     * 加载导航栏中的“提问、分享、讨论、建议”,并将其list放入servletContext上下文对象
     * 加载本周热议
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<Category> categories = categoryService.list(new QueryWrapper<Category>()
                .eq("status", 0)
        );
        servletContext.setAttribute("categorys", categories);
 
        postService.initWeekRank();
    }
 
    /**
     * servletContext上下文对象
     */
    @Override
    public void setServletContext(ServletContext servletContext) {
        this.servletContext = servletContext;
    }
}
  • PostServiceImpl.java :业务层实现
@Service
public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements PostService {
 
    @Autowired
    RedisUtil redisUtil;
 
    /**
     * 项目启动前,初始化本周热议(近7天全部文章评论量的排行榜)
     */
    @Override
    public void initWeekRank() {
        //1.获取【近7天文章】
        List<Post> posts = this.list(new QueryWrapper<Post>()
                .gt("created", DateUtil.offsetDay(new Date(), -6))  //根据created时间,对最近7天内的文章进行筛选
                .select("id, title, user_id, comment_count, view_count, created") //对文章的属性进行筛选,加快查询速率
        );
 
        //2.初始化【近7天文章】的总评论量(先使用SortedSet集合对【排行榜7天内全部文章】进行zadd操作,并设置它们expire为7天;再使用Hash哈希表对【排行榜7天内全部文章】进行hexists判断,再hset缓存操作)
        for (Post post : posts) {
            //1.添加add——|day:rank:20210202--0208|,将【近7天文章】创建日期时间作为key值,每篇文章对应的id作为它的value值,每篇文章对应的评论comment作为它的score值,并使用redis的工具类(RedisUtil),对文章的具体属性进行zSet()缓存操作
            String zKey = "day:rank:" + DateUtil.format(post.getCreated(), DatePattern.PURE_DATE_FORMAT);
            redisUtil.zSet(zKey, post.getId(), post.getCommentCount());//阅读redisUtil工具类,可知zSet等同于zadd
 
            //2.过期expire——|day:rank:20210202--0208|,让【近7天文章】的key过期: 7-(当前时间-创建时间)= 过期时间
            long expireTime = (7 - DateUtil.between(new Date(), post.getCreated(), DateUnit.DAY)) * 24 * 60 * 60;
            redisUtil.expire(zKey, expireTime);
 
            //3.缓存——|day:rank:post:1~16|,缓存【近7天文章】的一些基本信息,例如文章id,标题title,评论数量,作者信息...方便访问【近7天文章】时,直接redis,而非MySQL
            //3.1先对文章进行EXISTS判断其缓存是否存在
            String hKey = "day:rank:post:" + post.getId();
            if (!redisUtil.hasKey(hKey)) {
                //3.2如果false不存在,则再hset缓存操作
                redisUtil.hset(hKey, "post-id", post.getId(), expireTime);
                redisUtil.hset(hKey, "post-title", post.getTitle(), expireTime);
                redisUtil.hset(hKey, "post-commentCount", post.getCommentCount(), expireTime);
                redisUtil.hset(hKey, "post-viewCount", post.getViewCount(), expireTime);
            }
        }
 
        //3.对【近7天文章】做并集运算(zUnionAndStore), 并使用根据评论量的数量从大到小进行展示(zrevrange)
        String currentKey = "day:rank:" + DateUtil.format(new Date(), DatePattern.PURE_DATE_FORMAT);
        List<String> otherKeys = new ArrayList<>();
        for (int i = -6; i < 0; i++) {
            String temp = "day:rank:" + DateUtil.format(DateUtil.offsetDay(new Date(), i), DatePattern.PURE_DATE_FORMAT);
            otherKeys.add(temp);
        }
        String destKey = "week:rank";
        redisUtil.zUnionAndStore(currentKey, otherKeys, destKey);
    }
}

23 本周热议的【更新操作】

  • 实现逻辑:
    • 自增/自减评论数
    • 更新这篇文章的缓存时间,并更新这篇文章的基本信息
    • 对【近 7 天文章】重新做并集运算(zUnionAndStore), 并使用根据评论量的数量从大到小进行展示(zrevrange)
  • PostServiceImpl.java :业务层实现
@Service
public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements PostService {
 
    @Autowired
    RedisUtil redisUtil;
 
    /**
     * 本周热议:增加评论后,通过自增/自减评论数、再对排行榜做并集运算
     */
    @Override
    public void incrCommentCountAndUnionForWeekRank(Post post, boolean isIncr) {
        //1.自增/自减评论数
        String currentKey = "day:rank:" + DateUtil.format(new Date(), DatePattern.PURE_DATE_FORMAT);
        redisUtil.zIncrementScore(currentKey, post.getId(), isIncr ? 1 : -1);
 
        //2.更新这篇文章的缓存时间,并更新这篇文章的基本信息
        String zKey = "day:rank:" + DateUtil.format(post.getCreated(), DatePattern.PURE_DATE_FORMAT);
        long expireTime = (7 - DateUtil.between(new Date(), post.getCreated(), DateUnit.DAY)) * 24 * 60 * 60;
        redisUtil.expire(zKey, expireTime);
        String hKey = "day:rank:post:" + post.getId();
        if (!redisUtil.hasKey(hKey)) {
            //3.2如果false不存在,则再hset缓存操作
            redisUtil.hset(hKey, "post-id", post.getId(), expireTime);
            redisUtil.hset(hKey, "post-title", post.getTitle(), expireTime);
            redisUtil.hset(hKey, "post-commentCount", post.getCommentCount(), expireTime);
            redisUtil.hset(hKey, "post-viewCount", post.getViewCount(), expireTime);
        }
 
        //3.对【近7天文章】重新做并集运算(zUnionAndStore)
        List<String> otherKeys = new ArrayList<>();
        for (int i = -6; i < 0; i++) {
            String temp = "day:rank:" + DateUtil.format(DateUtil.offsetDay(new Date(), i), DatePattern.PURE_DATE_FORMAT);
            otherKeys.add(temp);
        }
        String destKey = "week:rank";
        redisUtil.zUnionAndStore(currentKey, otherKeys, destKey);
    }
}

24 本周热议的【标签】

  • HotsTemplate.java :标签类,【开发标签】
/**
 * 本周热议文章【标签】
 */
@Component
public class HotsTemplate extends TemplateDirective {
 
    @Autowired
    RedisUtil redisUtil;
 
    @Override
    public String getName() {
        return "hots";
    }
 
    @Override
    public void execute(DirectiveHandler handler) throws Exception {
        List<Map> hostPost = new ArrayList<>();
 
        // 获取有序集 key 中成员 member 的排名,其中有序集成员按 score 值递减 (从大到小) 排序
        Set<ZSetOperations.TypedTuple> typedTuples = redisUtil.getZSetRank("week:rank", 0, 6);
        for (ZSetOperations.TypedTuple typedTuple : typedTuples) {
            Map<String, Object> map = new HashMap<>();
 
            //zSet(key, value, score)  -> zSet(文章日期, 文章id, 文章评论数commentCount),此处取出zSet中的value,即文章id
            String postHashKey = "day:rank:post:" + typedTuple.getValue();
 
            map.put("id", redisUtil.hget(postHashKey, "post-id"));
            map.put("title", redisUtil.hget(postHashKey, "post-title"));
            map.put("commentCount", redisUtil.hget(postHashKey, "post-commentCount"));
            map.put("viewCount", redisUtil.hget(postHashKey, "post-viewCount"));
 
            hostPost.add(map);
        }
 
        handler.put(RESULTS, hostPost).render();
    }
}
  • FreemarkerConfig.java :配置类,【注册标签】
/**
 * Freemarker配置类
 */
@Configuration
public class FreemarkerConfig {
 
    @Autowired
    private freemarker.template.Configuration configuration;
 
    @Autowired
    TimeAgoMethod timeAgoMethod;
 
    @Autowired
    PostsTemplate postsTemplate;
 
    @Autowired
    HotsTemplate hotsTemplate;
 
    /**
     * 注册为“timeAgo”函数:快速实现日期转换 ;注册为“posts”函数:快速实现分页
     */
    @PostConstruct
    public void setUp() {
        configuration.setSharedVariable("timeAgo", timeAgoMethod);
        configuration.setSharedVariable("details", postsTemplate);
        configuration.setSharedVariable("hots", hotsTemplate);
    }
}
  • right.ftl :模板引擎
<#--【三(2)、右侧md4】-->
<div class="layui-col-md4">
 
  <dl class="fly-panel fly-list-one">
    <dt class="fly-panel-title">本周热议</dt>
      <@hots>
          <#list results as post>
            <dd>
              <a href="/post/${post.id}">${post.title}</a>
              <span><i class="iconfont icon-pinglun1"></i> ${post.commentCount}</span>
            </dd>
          </#list>
      </@hots>
  </dl>
 
  <div class="fly-panel">
    <div class="fly-panel-title">
      站点信息
    </div>
    <div class="fly-panel-main">
      <a href="https://github.com/" target="_blank" class="fly-zanzhu"
         time-limit="2017.09.25-2099.01.01" style="background-color: #5FB878;">Don't let joy take
        you down !</a>
    </div>
  </div>
 
  <div class="fly-panel fly-link">
    <h3 class="fly-panel-title">友情链接</h3>
    <dl class="fly-panel-main">
      <dd>
        <a href="https://www.youtube.com/" target="_blank">YouTube</a>
      <dd>
      <dd>
        <a href="https://www.facebook.com/" target="_blank">Facebook</a>
      <dd>
      <dd>
        <a href="https://www.twitter.com/" target="_blank">Twitter</a>
      <dd>
      <dd>
        <a href="https://www.instagram.com/" target="_blank">Instagram</a>
      <dd>
          <#--
          <dd>
              <a href="mailto:xianxin@layui-inc.com?subject=%E7%94%B3%E8%AF%B7Fly%E7%A4%BE%E5%8C%BA%E5%8F%8B%E9%93%BE" class="fly-link">申请友链</a>
          <dd>
          -->
    </dl>
  </div>
 
</div>

Part07-文章阅读缓存访问量

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          │  Application.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      PostController.java
│      │          │
│      │          ├─service
│      │          │  │  PostService.java
│      │          │  │
│      │          │  └─impl
│      │          │         PostServiceImpl.java
│      │          ├
│      │          ├─schedules
│      │          │      ViewCountSyncTask.java

25 数据一致性

  • PostController.java :控制层,【文章阅读【缓存实现访问量】,减少访问数据库的次数,存在一个 BUG,只与点击链接的次数相关,没有与用户的 id 进行绑定】
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail
     */
    @RequestMapping("/post/{id:\\d*}")
    public String detail(@PathVariable(name = "id") long id) {
        /**
         * 一条(post实体类、PostVo实体类)
         */
        //一条:selectOnePost(表 文章id = 传 文章id),因为Mapper中select信息中,id过多引起歧义,故采用p.id
        PostVo postVo = postService.selectOnePost(new QueryWrapper<Post>().eq("p.id", id));
        //req:PostVo实体类 -> CategoryId属性
        req.setAttribute("currentCategoryId", postVo.getCategoryId());
        //req:PostVo实体类(回调)
        req.setAttribute("postVoData", postVo);
 
        /**
         * 评论(comment实体类)
         */
        //评论:page(分页信息、文章id、用户id、排序)
        IPage<CommentVo> results = commentService.selectComments(getPage(), postVo.getId(), null, "created");
        //req:CommentVo分页集合
        req.setAttribute("commentVoDatas", results);
 
        /**
         * 文章阅读【缓存实现访问量】:减少访问数据库的次数,存在一个BUG,只与点击链接的次数相关,没有与用户的id进行绑定
         */
        postService.putViewCount(postVo);
 
        return "post/detail";
    }
}
  • PostServiceImpl.java :业务层实现
@Service
public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements PostService {
    @Autowired
    RedisUtil redisUtil;
 
    /**
     * 文章阅读【缓存实现访问量】:减少访问数据库的次数,存在一个BUG,只与点击链接的次数相关,没有与用户的id进行绑定
     */
    @Override
    public void putViewCount(PostVo postVo) {
        //1.从缓存中获取当前访问量viewCount
        String hKey = "day:rank:post:" + postVo.getId();
        Integer viewCount = (Integer)redisUtil.hget(hKey, "post-viewCount");
 
        //2.若缓存中存在viewCount,则viewCount+1;若不存在,则postVo.getViewCount()+1
        //  注意一点,项目启动前会对【7天内的文章】进行缓存,因此,还会存在【7天前的文章】未进行缓存
        if (viewCount != null) {
            postVo.setViewCount(viewCount + 1);
        } else {
            postVo.setViewCount(postVo.getViewCount() + 1);
        }
 
        //3.将viewCount同步到缓存中
        redisUtil.hset(hKey, "post-viewCount", postVo.getViewCount());
    }
}

26 定时器定时更新

  • Application.java:项目启动,【每分钟同步一次(缓存 同步到数据库)】
@EnableScheduling//开启定时器
@SpringBootApplication
public class Application {
 
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
        System.out.println("http://localhost:8080");
    }
}
  • ViewCountSyncTask.java :定时器
/**
 * 定时器定时更新
 */
@Component
public class ViewCountSyncTask {
 
    @Autowired
    RedisUtil redisUtil;
 
    @Autowired
    RedisTemplate redisTemplate;
 
    @Autowired
    PostService postService;
 
    //每分钟同步一次(缓存 -> 同步到数据库)
    @Scheduled(cron = "0/5 * * * * *")
    public void task() {
        //1.查询缓存中"day:rank:post:"的全部key
        Set<String> keys = redisTemplate.keys("day:rank:post:" + "*");
 
        //2.遍历全部key,如果某个key中含有“post-viewCount”,则通过ArrayList数组依次将【带有post-viewCount的文章postId】存放
        List<String> ids = new ArrayList<>();
        for (String key : keys) {
            if (redisUtil.hHasKey(key, "post-viewCount")) {
                String postId = key.substring("day:rank:post:".length());
                ids.add(postId);
            }
        }
 
        //3.将【全部缓存中的postId】同步到数据库
        if (ids.isEmpty()) {
            //3.1 如果[没有需要更新阅读量的文章】,则直接返回
            return;
        } else {
            //3.2 如果[存在需要更新阅读量的文章】,则先【根据ids查询全部的文章】,再【从缓存中获取该postId对应的访问量】,然后【给Post重新赋值viewCount】
            List<Post> posts = postService.list(new QueryWrapper<Post>().in("id", ids));
            for (Post post : posts) {
                Integer viewCount = (Integer) redisUtil.hget("day:rank:post:" + post.getId(), "post-viewCount");
                post.setViewCount(viewCount);
            }
 
            //3.3 同步操作
            if (posts.isEmpty()) {
                //如果【数据库中刚好删除完全部文章,即不存在文章】,则直接返回
                return;
            } else {
                //同步数据,并删除缓存
                if (postService.updateBatchById(posts)) {
                    for (String id : ids) {
                        redisUtil.hdel("day:rank:post:" + id, "post-viewCount");
                        System.out.println(id + "---------------------->同步成功");
                    }
                }
            }
        }
    }
}

Part08-集成Kaptcha实现用户注册

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─common
│      │          │  └─lang
│      │          │         Result.java
│      │          │
│      │          ├─config
│      │          │      kaptchaConfig.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      AuthController.java
│      │          │
│      │          ├─service
│      │          │  │  UserService.java
│      │          │  │
│      │          │  └─impl
│      │          │         UserServiceImpl.java
│      │          ├
│      │          ├─utils
│      │          │      ValidationUtil.java
│      │
│      └─resources
│          ├─templates
│          │  ├─auth
│          │  │     reg.ftl
│          │  │     login.ftl
│          │  └─inc
│          │        header.ftl

27 集成 Kaptcha 环境

  • pom.xml :项目依赖,【Hutool-captcha、Google Kaptcha(本次选用)】
<dependencies>
    <!--图片验证码:Hutool-captcha、Google Kaptcha(本次选用)-->
    <dependency>
        <groupId>com.github.axet</groupId>
        <artifactId>kaptcha</artifactId>
        <version>0.0.9</version>
    </dependency>
</dependencies>

28 个人用户的【注册】:简易页面搭建

  • AuthController.java :控制层
@Controller
public class AuthController extends BaseController {
    /**
     * 登录
     */
    @GetMapping("/login")
    public String login() {
        return "/auth/login";
    }
 
    /**
     * 注册
     */
    @GetMapping("/register")
    public String register() {
        return "/auth/reg";
    }
}
  • header.ftl :模板引擎
<#--【一、导航栏】-->
<div class="fly-header layui-bg-black">
    <div class="layui-container">
        <#--1.图标-->
        <a class="fly-logo" href="/">
            <img src="/res/images/logo.png" alt="layui">
        </a>
 
        <#--2.登录/注册-->
        <ul class="layui-nav fly-nav-user">
            <li class="layui-nav-item">
                <a class="iconfont icon-touxiang layui-hide-xs" href="user/login.html"></a>
            </li>
            <li class="layui-nav-item">
                <a href="/login">登入</a>
            </li>
            <li class="layui-nav-item">
                <a href="/register">注册</a>
            </li>
        </ul>
    </div>
</div>
  • login.ftl :模板引擎
<#--超链接:登入、注册-->
<ul class="layui-tab-title">
    <li><a href="/login">登入</a></li>
    <li class="layui-this">注册</li>
</ul>
  • reg.ftl :模板引擎
<#--超链接:登入、注册-->
<ul class="layui-tab-title">
    <li class="layui-this">登入</li>
    <li><a href="/register">注册</a></li>
</ul>

29 个人用户的【注册】:Kaptcha 图片验证码

  • kaptchaConfig.java :配置类,【配置验证码】
/**
 * kaptcha 图片验证码配置类
 */
@Configuration
public class kaptchaConfig {
 
    @Bean
    public DefaultKaptcha producer () {
        Properties propertis = new Properties();
        //无边框
        propertis.put("kaptcha.border", "no");
        //高度
        propertis.put("kaptcha.image.height", "38");
        //长度
        propertis.put("kaptcha.image.width", "150");
        //字体颜色
        propertis.put("kaptcha.textproducer.font.color", "black");
        //字体大小
        propertis.put("kaptcha.textproducer.font.size", "32");
        Config config = new Config(propertis);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}
  • AuthController.java :控制层,【生成验证码】
@Controller
public class AuthController extends BaseController {
 
    private static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY";
 
    @Autowired
    Producer producer;
 
    /**
     * 图片验证码
     */
    @GetMapping("/capthca.jpg")
    public void kaptcha(HttpServletResponse resp) throws IOException {
        // 1.生成text、image
        String text = producer.createText();
        BufferedImage image = producer.createImage(text);
        // 2.校验操作,利用session机制对text进行校验(经过测试,ImageIO输出前,必须完成req、resp请求)
        req.getSession().setAttribute("KAPTCHA_SESSION_KEY", text);
        // 3.通过resp设置Header、ContextType(经过测试,图片ContextType必须为"image/jpeg",而非"image/jpg")
        resp.setHeader("Cache-Control", "no-store, no-cache");
        resp.setContentType("text/html; charset=UTF-8");
        resp.setContentType("image/jpeg");
        // 4.通过ImageIO输出image
        ServletOutputStream outputStream = resp.getOutputStream();
        ImageIO.write(image, "jpg", outputStream);
    }
}
  • reg.ftl :模板引擎,【使用验证码】
<#--5.图片验证码-->
<div class="layui-form-item">
    <label for="L_vercode" class="layui-form-label">验证码</label>
    <div class="layui-input-inline">
        <input type="text" id="L_vercode" name="vercode" required lay-verify="required"
               placeholder="请回答后面的问题" autocomplete="off" class="layui-input">
    </div>
    <#--图片验证码-->
    <div class="">
        <img id="capthca" src="/capthca.jpg">
    </div>
</div>

30 个人用户的【注册】:提交表单后,自己跳转【/login】登录页面

  • /res/mods/index.js :源码可知,【lay-submit】此处默认【表单跳转】alert=“true”,则会跳转【action 属性中的值】
//表单提交
  form.on('submit(*)', function(data){
    var action = $(data.form).attr('action'), button = $(data.elem);
    fly.json(action, data.field, function(res){
      var end = function(){
        //action属性:跳转路径
        if(res.action){
          location.href = res.action;
        }
 
        // else {
        //   fly.form[action||button.attr('key')](data.field, data.form);
        // }
      };
      if(res.status == 0){
        button.attr('alert') ? layer.alert(res.msg, {
          icon: 1,
          time: 10*1000,
          end: end
        }) : end();
      }
    });
    return false;
  });
  • reg.ftl :模板引擎
<#--6.注册-->
<div class="layui-form-item">
    <#--通过阅读/res/mods/index.js源码可知,【lay-submit】此处默认【表单提交】对应的链接为”文件名“,即【/register】-->
    <#--通过阅读/res/mods/index.js源码可知,【lay-submit】此处默认【表单跳转】alert="true",则会跳转【action属性中的值】-->
    <button class="layui-btn" lay-filter="*" lay-submit alert="true">立即注册</button>
</div>
  • Result.java :实体类
@Data
public class Result implements Serializable {
    // 操作状态:0成功,-1失败
    private int status;
 
    // 携带msg
    private String msg;
 
    // 携带data
    private Object data;
 
    // 跳转页面:【lay-submit】默认提交时,通过阅读/res/mods/index.js源码可知,默认跳转【location.href = res.action;】,即action对应的位置
    private String action;
 
    /**
     * 操作状态:0成功,-1失败
     */
    public static Result success(String msg, Object data) {
        Result result = new Result();
        result.status = 0;
        result.msg = msg;
        result.data = data;
        return result;
    }
 
    public static Result success() {
        return Result.success("操作成功", null);
    }
 
    public static Result success(Object data) {
        return Result.success("操作成功", data);
    }
 
    /**
     * 操作状态:0成功,-1失败
     */
    public static Result fail(String msg) {
        Result result = new Result();
        result.status = -1;
        result.data = null;
        result.msg = msg;
        return result;
    }
 
    /**
     * 跳转页面
     */
    public Result action(String action){
        this.action = action;
        return this;
    }
}
  • ValidationUtil.java :工具类
/**
 * ValidationUtil 工具类
 */
@Component
public class ValidationUtil {
 
    /**
     * 开启快速结束模式 failFast (true)
     */
    private static Validator validator = Validation.byProvider(HibernateValidator.class).configure().failFast(false).buildValidatorFactory().getValidator();
    /**
     * 校验对象
     *
     * @param t bean
     * @param groups 校验组
     * @return ValidResult
     */
    public static <T> ValidResult validateBean(T t,Class<?>...groups) {
        ValidResult result = new ValidationUtil().new ValidResult();
        Set<ConstraintViolation<T>> violationSet = validator.validate(t,groups);
        boolean hasError = violationSet != null && violationSet.size() > 0;
        result.setHasErrors(hasError);
        if (hasError) {
            for (ConstraintViolation<T> violation : violationSet) {
                result.addError(violation.getPropertyPath().toString(), violation.getMessage());
            }
        }
        return result;
    }
    /**
     * 校验bean的某一个属性
     *
     * @param obj          bean
     * @param propertyName 属性名称
     * @return ValidResult
     */
    public static <T> ValidResult validateProperty(T obj, String propertyName) {
        ValidResult result = new ValidationUtil().new ValidResult();
        Set<ConstraintViolation<T>> violationSet = validator.validateProperty(obj, propertyName);
        boolean hasError = violationSet != null && violationSet.size() > 0;
        result.setHasErrors(hasError);
        if (hasError) {
            for (ConstraintViolation<T> violation : violationSet) {
                result.addError(propertyName, violation.getMessage());
            }
        }
        return result;
    }
    /**
     * 校验结果类
     */
    @Data
    public class ValidResult {
 
        /**
         * 是否有错误
         */
        private boolean hasErrors;
 
        /**
         * 错误信息
         */
        private List<ErrorMessage> errors;
 
        public ValidResult() {
            this.errors = new ArrayList<>();
        }
        public boolean hasErrors() {
            return hasErrors;
        }
 
        public void setHasErrors(boolean hasErrors) {
            this.hasErrors = hasErrors;
        }
 
        /**
         * 获取所有验证信息
         * @return 集合形式
         */
        public List<ErrorMessage> getAllErrors() {
            return errors;
        }
        /**
         * 获取所有验证信息
         * @return 字符串形式
         */
        public String getErrors(){
            StringBuilder sb = new StringBuilder();
            for (ErrorMessage error : errors) {
                sb.append(error.getPropertyPath()).append(":").append(error.getMessage()).append(" ");
            }
            return sb.toString();
        }
 
        public void addError(String propertyName, String message) {
            this.errors.add(new ErrorMessage(propertyName, message));
        }
    }
 
    @Data
    public class ErrorMessage {
 
        private String propertyPath;
 
        private String message;
 
        public ErrorMessage() {
        }
 
        public ErrorMessage(String propertyPath, String message) {
            this.propertyPath = propertyPath;
            this.message = message;
        }
    }
 
}
  • AuthController.java :控制层
@Controller
public class AuthController extends BaseController {
 
    /**
     * 注册:校验
     */
    @ResponseBody
    @PostMapping("/register")
    public Result doRegister(User user, String repass, String vercode) {
        // 使用ValidationUtil工具类,校验【输入是否错误】
        ValidationUtil.ValidResult validResult = ValidationUtil.validateBean(user);
        if(validResult.hasErrors()) {
            return Result.fail(validResult.getErrors());
        }
 
        // 校验【密码是否一致】
        if(!user.getPassword().equals(repass)) {
            return Result.fail("两次输入密码不相同");
        }
 
        // 校验【验证码是否正确】:从session中获取KAPTCHA_SESSION_KEY,即正确的验证码【text】
        String kaptcha_session_key = (String) req.getSession().getAttribute(KAPTCHA_SESSION_KEY);
        System.out.println(kaptcha_session_key);
        if(vercode == null || !vercode.equalsIgnoreCase(kaptcha_session_key)) {
            return Result.fail("验证码输入不正确");
        }
 
        // 完成注册
        Result result = userService.register(user);
        // 如果校验成功,则完成注册,跳转/login页面
        return result.action("/login");
    }
}
  • UserServiceImpl.java :业务层实现
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
 
    @Override
    public Result register(User user) {
        /**
         * 查询【用户名或邮箱】是否被占用
         */
        int count = this.count(new QueryWrapper<User>()
                .eq("email", user.getEmail())
                .or()
                .eq("username", user.getUsername())
        );
        if (count > 0) {
            return Result.fail("用户名或邮箱已被占用");
        }
 
        /**
         * 设置【新注册用户】中属性:
         *
         * 1.防止前端对传来数据进行伪造,因此,重新获取name、password、email重要属性
         * 2.对其他属性进行默认额外处理,比如,Avatar、Created、Point、VipLevel、CommentCount、PostCount、Gender
         */
        User temp = new User();
        temp.setUsername(user.getUsername());
        temp.setPassword(SecureUtil.md5(user.getPassword()));//SecureUtil使用md5对password加密
        temp.setEmail(user.getEmail());
        temp.setAvatar("/res/images/avatar/default.jpgjpg");
        temp.setCreated(new Date());
        temp.setPoint(0);
        temp.setVipLevel(0);
        temp.setCommentCount(0);
        temp.setPostCount(0);
        temp.setGender("0");
        this.save(temp);
        return Result.success();
    }
}

Part09-集成Shiro实现用户登录

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─config
│      │          │      ShiroConfig.java
│      │          │      FreemarkerConfig
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      AuthController.java
│      │          │
│      │          ├─service
│      │          │  │  UserService.java
│      │          │  │
│      │          │  └─impl
│      │          │         UserServiceImpl.java
│      │          ├
│      │          ├─shiro
│      │          │      AccountProfile.java
│      │          │      AccountRealm.java
│      │
│      └─resources
│          ├─templates
│          │  ├─auth
│          │  │     reg.ftl
│          │  │
│          │  └─inc
│          │        header.ftl

31 集成 Shiro 环境

  • pom.xml :项目依赖,【shiro-spring 权限、shiro-freemarker-tags 标签】
<dependencies>
    <!--shiro权限框架-->
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.4.0</version>
    </dependency>
    <!--shiro-freemarker-tags标签-->
    <dependency>
        <groupId>net.mingsoft</groupId>
        <artifactId>shiro-freemarker-tags</artifactId>
        <version>0.1</version>
    </dependency>
</dependencies>
  • ShiroConfig.java :配置类,【安全管理器、拦截器链】
/**
 * Shiro配置类:安全管理器、拦截器链
 */
@Slf4j
@Configuration
public class ShiroConfig {
    /**
     * 安全管理器
     */
    @Bean
    public SecurityManager securityManager(AccountRealm accountRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(accountRealm);
        log.info("------------------>securityManager注入成功");
        return securityManager;
    }
 
    /**
     * 拦截器链
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
        // 配置安全管理器
        filterFactoryBean.setSecurityManager(securityManager);
 
        // 配置登录的url
        filterFactoryBean.setLoginUrl("/login");
 
        // 配置登录成功的url
        filterFactoryBean.setSuccessUrl("/user/center");
 
        // 配置未授权跳转页面
        filterFactoryBean.setUnauthorizedUrl("/error/403");
 
        // 配置过滤链定义图
        Map<String, String> hashMap = new LinkedHashMap<>();
        hashMap.put("/login", "anon");
        filterFactoryBean.setFilterChainDefinitionMap(hashMap);
 
        return filterFactoryBean;
    }
}

32 个人用户的【登录】:使用 Shiro 进行 /login 操作

  • AuthController.java :控制层,【用户登录】
@Controller
public class AuthController extends BaseController {
    /**
     * 登录:Shiro校验
     */
    @ResponseBody
    @PostMapping("/login")
    public Result doLogin(String email, String password) {
        /**
         * 使用hutool的StrUtil工具类,【isEmpty】字符串是否为空、【isBlank】字符串是否为空白
         */
        if (StrUtil.isEmpty(email) || StrUtil.isBlank(password)) {
            return Result.fail("邮箱或密码不能为空");
        }
 
        /**
         * 使用Shiro框架,生成token后进行登录
         */
        try {
            // 生成Token:根据UsernamePasswordToken参数可知,会对username、password进行token生成
            UsernamePasswordToken token = new UsernamePasswordToken(email, SecureUtil.md5(password));
            // 使用Token:使用该token进行登录
            SecurityUtils.getSubject().login(token);
        } catch (AuthenticationException e) {
            // 使用Shiro框架中封装好的常见错误进行【异常处理】
            if (e instanceof UnknownAccountException) {
                return Result.fail("用户不存在");
            } else if (e instanceof LockedAccountException) {
                return Result.fail("用户被禁用");
            } else if (e instanceof IncorrectCredentialsException) {
                return Result.fail("密码错误");
            } else {
                return Result.fail("用户认证失败");
            }
        }
 
        /**
         * 如果登录成功,跳转/根页面
         */
        return Result.success().action("/");
    }
}
  • AccountProfile.java :实体类
/**
 * 用户在login后,将查询后的user结果,复制一份给AccountProfile【用户信息】
 */
@Data
public class AccountProfile implements Serializable {
 
    private Long id;
 
    private String username;
    private String email;
    private String sign;
 
    private String avatar;
    private String gender;
    private Date created;
}
  • AccountRealm.java :过滤器,【重写父类 AuthorizingRealm 方法】
/**
 * AccountRealm:重写父类AuthorizingRealm方法
 */
@Component
public class AccountRealm extends AuthorizingRealm {
 
    @Autowired
    UserService userService;
 
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }
 
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // 1.获取Token
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
        // 2.根据token获取username、password,并进行login登录,返回AccountProfile账户信息
        AccountProfile profile = userService.login(usernamePasswordToken.getUsername(), String.valueOf(usernamePasswordToken.getPassword()));
        // 3.通过profile、token.getCredentials()、getName(),获取AuthenticationInfo子接口对象(SimpleAuthenticationInfo)
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(profile, token.getCredentials(), getName());
        return info;
    }
}
  • UserServiceImpl.java :业务层实现,【AccountRealm 根据 token 获取 username、password,并进行 login 登录,返回 AccountProfile 账户信息】
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
 
    @Override
    public AccountProfile login(String email, String password) {
        /**
         * 查询【用户名或邮箱】是否正确:通过【数据库中获取的username、password】与【token中获取的username、password】进行对比
         */
        User user = this.getOne(new QueryWrapper<User>().eq("email", email));
        //用户名不存在,抛出异常
        if (user == null) {
            throw new UnknownAccountException();
        }
        //用户密码不正确,抛出异常
        if (!user.getPassword().equals(password)) {
            throw new IncorrectCredentialsException();
        }
        //更新用户最后登录时间,并updateById将user写入到数据库
        user.setLasted(new Date());
        this.updateById(user);
 
        /**
         * 将查询后的user结果,复制一份给AccountProfile
         *
         * copyProperties(Object source, Object target) :将source复制给target目标对象
         */
        AccountProfile profile = new AccountProfile();
        BeanUtil.copyProperties(user, profile);
        return profile;
    }
}

33 个人用户的【登录】:shiro-freemarker-tags 标签

  • FreemarkerConfig.java :配置类,【注册标签,将 shiro-freemarker-tags 注册到 Freemarker 配置类】
/**
 * Freemarker配置类
 */
@Configuration
public class FreemarkerConfig {
 
    @Autowired
    private freemarker.template.Configuration configuration;
 
    @Autowired
    TimeAgoMethod timeAgoMethod;
 
    @Autowired
    PostsTemplate postsTemplate;
 
    @Autowired
    HotsTemplate hotsTemplate;
 
    /**
     * 注册为“timeAgo”函数:快速实现日期转换
     * 注册为“posts”函数:快速实现分页
     */
    @PostConstruct
    public void setUp() {
        configuration.setSharedVariable("timeAgo", timeAgoMethod);
        configuration.setSharedVariable("details", postsTemplate);
        configuration.setSharedVariable("hots", hotsTemplate);
        configuration.setSharedVariable("shiro", new ShiroTags()); //shiro-freemarker-tags标签 -> 声明为shiro标签
    }
}
  • header.ftl :模板引擎,【未登录的状态、登录后的状态】
<#--【一、导航栏】-->
<div class="fly-header layui-bg-black">
    <div class="layui-container">
        <#--1.图标-->
        <a class="fly-logo" href="/">
            <img src="/res/images/logo.png" alt="layui">
        </a>
 
        <#--2.登录/注册-->
        <ul class="layui-nav fly-nav-user">
 
            <#--未登录的状态-->
            <#--【shiro.guest】:验证当前用户是否为 “访客”,即未认证(包含未记住)的用户-->
            <@shiro.guest>
                <li class="layui-nav-item">
                    <a class="iconfont icon-touxiang layui-hide-xs" href="user/login.html"></a>
                </li>
                <li class="layui-nav-item">
                    <a href="/login">登入</a>
                </li>
                <li class="layui-nav-item">
                    <a href="/register">注册</a>
                </li>
            </@shiro.guest>
 
            <#--登录后的状态-->
            <#--【shiro.user】:认证通过或已记住的用户-->
            <@shiro.user>
                <li class="layui-nav-item">
                    <a class="fly-nav-avatar" href="javascript:;">
                    <#--当前用户【username】-->
                        <cite class="layui-hide-xs">
                            <@shiro.principal property="username"/>
                        </cite>
                        <i class="iconfont icon-renzheng layui-hide-xs" title="认证信息:layui 作者"></i>
                        <i class="layui-badge fly-badge-vip layui-hide-xs">SVIP</i>
                        <#--当前用户【avatar】-->
                        <img src="<@shiro.principal property="avatar" />">
                    </a>
                    <dl class="layui-nav-child">
                        <#--基本设置-->
                        <dd>
                            <a href="user/set.html">
                                <i class="layui-icon">&#xe620;</i>基本设置
                            </a>
                        </dd>
                        <#--我的消息-->
                        <dd>
                            <a href="user/message.html">
                                <i class="iconfont icon-tongzhi" style="top: 4px;"></i>我的消息
                            </a>
                        </dd>
                        <#--我的主页-->
                        <dd>
                            <a href="user/home.html">
                                <i class="layui-icon" style="margin-left: 2px; font-size: 22px;">&#xe68e;</i>我的主页
                            </a>
                        </dd>
                        <hr style="margin: 5px 0;">
                        <#--退出登录-->
                        <dd>
                            <a href="/user/logout/" style="text-align: center;">
                                退出
                            </a>
                        </dd>
                    </dl>
                </li>
            </@shiro.user>
 
        </ul>
    </div>
</div>

34 个人用户的【登录】:使用 Shiro 进行【登出】操作

  • AuthController.java :控制层,【用户登出】
@Controller
public class AuthController extends BaseController {
    /**
     * 登出:Shiro校验
     */
    @RequestMapping("/user/logout")
    public String logout() {
        // Shiro将【当前用户】登出
        SecurityUtils.getSubject().logout();
        // 页面重定向至【根目录/】
        return "redirect:/";
    }
}
  • header.ftl :模板引擎
<#--退出登录-->
<dd>
    <a href="/user/logout/" style="text-align: center;">
        退出
    </a>
</dd>

Part10-集成Shiro实现个人账户-我的主页、基本设置

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─common
│      │          │  └─lang
│      │          │         Consts.java
│      │          │
│      │          ├─config
│      │          │      SpringMvcConfig.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      UserController.java
│      │          │
│      │          ├─service
│      │          │  │  UserService.java
│      │          │  │
│      │          │  └─impl
│      │          │         UserServiceImpl.java
│      │          ├
│      │          ├─utils
│      │          │      UploadUtil.java
│      │
│      └─resources
│          │  application.yml
│          │
│          ├─templates
│          │  └─user
│          │        home.ftl
│          │        set.ftl

35 个人账户:我的主页

  • UserController.java :控制层
@Controller
public class UserController extends BaseController {
 
    /**
     * 我的主页
     */
    @GetMapping("/user/home")
    public String home() {
        //用户:从Shiro中获取用户
        User user = userService.getById(getProfileId());
        req.setAttribute("user", user);
 
        //文章:用户近期【30天】的文章
        List<Post> posts = postService.list(new QueryWrapper<Post>()
                .eq("user_id", getProfileId())
                .orderByDesc("created")
        );
        req.setAttribute("posts", posts);
 
        return "/user/home";
    }
}
  • home.ftl :模板引擎
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl"/>
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "我的主页">
 
    <#--1.用户基本信息-->
    <div class="fly-home fly-panel" >
        <#--头像-->
        <img src="${user.avatar}" alt="贤心">
        <i class="iconfont icon-renzheng" title="Fly社区认证"></i>
 
        <#--作者信息-->
        <h1>
            ${user.username}
            <i class="iconfont icon-nan"></i>
            <i class="layui-badge fly-badge-vip">SVIP</i>
        </h1>
 
        <#--创建时间-->
        <p class="fly-home-info">
            <i class="iconfont icon-kiss" title="飞吻"></i><span style="color: #FF7200;">66666 飞吻</span>
            <i class="iconfont icon-shijian"></i><span>${timeAgo(user.created)} 加入</span>
        </p>
 
        <#--个性签名-->
        <p class="fly-home-sign">
            ${user.sign!'这个人好懒,什么都没留下!'}
        </p>
    </div>
 
    <#--2.最近的提问 + 最近的回答-->
    <div class="layui-container">
        <div class="layui-row layui-col-space15">
            <#--用户近期【30天】的文章-->
            <div class="layui-col-md6 fly-home-jie">
                <div class="fly-panel">
                    <h3 class="fly-panel-title">${user.username} 最近的提问</h3>
                    <ul class="jie-row">
                        <#list posts as post>
                            <li>
                                <#if post.recommend>
                                    <span class="fly-jing">精</span>
                                </#if>
                                <a href="/post/${post.id}" class="jie-title">
                                    ${post.title}
                                </a>
                                <i>${timeAgo(post.created)}</i>
                                <em class="layui-hide-xs">
                                    ${post.viewCount}阅/${post.commentCount}答
                                </em>
                            </li>
                        </#list>
                        <#if !posts>
                            <div class="fly-none" style="min-height: 50px; padding:30px 0; height:auto;">
                                <i style="font-size:14px;">没有发表任何求解</i>
                            </div>
                        </#if>
                    </ul>
                </div>
            </div>
            <#--最近的回答-->
            <div class="layui-col-md6 fly-home-da">
                <div class="fly-panel">
                    <h3 class="fly-panel-title">${user.username} 最近的回答</h3>
                    <ul class="home-jieda">
                        <div class="fly-none" style="min-height: 50px; padding:30px 0; height:auto;"><span>没有回答任何问题</span></div>
                    </ul>
                </div>
            </div>
        </div>
    </div>
 
    <script>
        layui.cache.page = 'user';
    </script>
</@layout>

36 个人账户:基本设置-更新资料

  • /res/mods/index.js :源码可知,【lay-submit】此处默认【表单跳转】reload=“true”,则会【重新加载当前页面】
//表单提交
form.on('submit(*)', function (data) {
    var action = $(data.form).attr('action'), button = $(data.elem);
    fly.json(action, data.field, function (res) {
        var end = function () {
            /*action属性:跳转路径*/
            if (res.action) {
                location.href = res.action;
            }
 
            /*解决:基本设置 中 修改个人资料后,无法【重新加载】*/
            if (button.attr('reload')) {
                location.reload();
            }
 
        };
        if (res.status == 0) {
            button.attr('alert') ? layer.alert(res.msg, {
                icon: 1,
                time: 10 * 1000,
                end: end
            }) : end();
        }
    });
    return false;
});
  • UserController.java :控制层
@Controller
public class UserController extends BaseController {
    /**
     * 基本设置
     */
    @GetMapping("/user/set")
    public String set() {
        //用户:从Shiro中获取用户
        User user = userService.getById(getProfileId());
        req.setAttribute("user", user);
 
        return "/user/set";
    }
 
    /**
     * 基本设置:更新资料
     */
    @ResponseBody
    @PostMapping("/user/set")
    public Result doSet(User user) {
        //校验:昵称不能为空
        if (StrUtil.isBlank(user.getUsername())) {
            return Result.fail("昵称不能为空");
        }
 
        //校验:从数据库中查询【username是否存在】、【id并非当前用户】,如果count > 0,则代表“该昵称已被占用”
        int count = userService.count(new QueryWrapper<User>()
                .eq("username", getProfile().getUsername())
                .ne("id", getProfileId()));
        if (count > 0) {
            return Result.fail("该昵称已被占用");
        }
 
        //更新显示【数据库】:username、gender、sign -> 数据库 -> 刷新页面
        User temp = userService.getById(getProfileId());
        temp.setUsername(user.getUsername());
        temp.setGender(user.getGender());
        temp.setSign(user.getSign());
        userService.updateById(temp);
 
        //更新显示【Shiro】:更新 “AccountRealm类中,返回的AccountProfile对象” -> 【header.ftl】
        AccountProfile profile = getProfile();
        profile.setUsername(temp.getUsername());
        profile.setSign(temp.getSign());
 
        return Result.success().action("/user/set#info");
    }
}
  • set.ftl :模板引擎
<#--1.更新资料-->
<div class="layui-form layui-form-pane layui-tab-item layui-show">
    <form method="post">
        <#--1.1 邮箱-->
        <div class="layui-form-item">
            <label for="L_email" class="layui-form-label">邮箱</label>
            <div class="layui-input-inline">
                <input type="text" id="L_email" name="email" required lay-verify="email"
                       autocomplete="off" value="${user.email}" class="layui-input" readonly>
            </div>
        </div>
 
        <#--1.2 昵称-->
        <div class="layui-form-item">
            <label for="L_username" class="layui-form-label">昵称</label>
            <div class="layui-input-inline">
                <input type="text" id="L_username" name="username" required lay-verify="required"
                       autocomplete="off" value="${user.username}" class="layui-input">
            </div>
            <div class="layui-inline">
                <div class="layui-input-inline">
                    <input type="radio" name="gender" value="0" <#if user.gender =='0'>checked</#if>
                           title="男">
                    <input type="radio" name="gender" value="1" <#if user.gender =='1'>checked</#if>
                           title="女">
                </div>
            </div>
        </div>
 
        <#--1.3 签名-->
        <div class="layui-form-item layui-form-text">
            <label for="L_sign" class="layui-form-label">签名</label>
            <div class="layui-input-block">
                <textarea placeholder="" id="L_sign" name="sign" autocomplete="off"
                          class="layui-textarea" style="height: 80px;">${user.sign}</textarea>
            </div>
        </div>
        <#--1.4 确定修改-->
        <div class="layui-form-item">
            <#--通过阅读/res/mods/index.js源码可知,【lay-submit】此处默认【表单跳转】reload="true",则会【重新加载当前页面】-->
            <button class="layui-btn" key="set-mine" lay-filter="*" lay-submit reload="true"
                    alert="true">确认修改
            </button>
        </div>
    </form>
</div>

37 个人账户:基本设置-更新头像(上传图片)

  • application.yml :配置文件,自定义上传路径
file:
  upload:
    dir: ${user.dir}/upload
  • Consts.java :实体类,上传图片(基本设置)
/**
 * 上传图片(基本设置):封装类
 */
@Data
@Component
public class Consts {
 
    @Value("${file.upload.dir}")
    private String uploadDir;
 
    public static final Long IM_DEFAULT_USER_ID = 999L;
 
    public final static Long IM_GROUP_ID = 999L;
    public final static String IM_GROUP_NAME = "e-group-study";
 
    //消息类型
    public final static String IM_MESS_TYPE_PING = "pingMessage";
    public final static String IM_MESS_TYPE_CHAT = "chatMessage";
 
    public static final String IM_ONLINE_MEMBERS_KEY = "online_members_key";
    public static final String IM_GROUP_HISTROY_MSG_KEY = "group_histroy_msg_key";
 
}
  • SpringMvcConfig.java :配置类,重写父类 addResourceHandlers 方法(识别非静态资源目录:/upload/avatar/**)
/**
 * SpringMvc配置类
 */
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
 
    @Autowired
    Consts consts;
 
    /**
     * 重写父类addResourceHandlers方法(识别非静态资源目录:/upload/avatar/**)
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/upload/avatar/**")
                .addResourceLocations("file:///" + consts.getUploadDir() + "/avatar/");
    }
}
  • UploadUtil.java :工具类,上传图片(基本设置)
/**
 * 上传图片(基本设置):工具类
 */
@Slf4j
@Component
public class UploadUtil {
 
    @Autowired
    Consts consts;
 
    public final static String type_avatar = "avatar";
 
    public Result upload(String type, MultipartFile file) throws IOException {
 
        if(StrUtil.isBlank(type) || file.isEmpty()) {
            return Result.fail("上传失败");
        }
 
        // 获取文件名
        String fileName = file.getOriginalFilename();
        log.info("上传的文件名为:" + fileName);
        // 获取文件的后缀名
        String suffixName = fileName.substring(fileName.lastIndexOf("."));
        log.info("上传的后缀名为:" + suffixName);
        // 文件上传后的路径
        String filePath = consts.getUploadDir();
 
        if ("avatar".equalsIgnoreCase(type)) {
            AccountProfile profile = (AccountProfile) SecurityUtils.getSubject().getPrincipal();
            fileName = "/avatar/avatar_" + profile.getId() + suffixName;
 
        } else if ("post".equalsIgnoreCase(type)) {
            fileName = "/post/post_" + DateUtil.format(new Date(), DatePattern.PURE_DATETIME_MS_PATTERN) + suffixName;
        }
 
        File dest = new File(filePath + fileName);
        // 检测是否存在目录
        if (!dest.getParentFile().exists()) {
            dest.getParentFile().mkdirs();
        }
        try {
            file.transferTo(dest);
            log.info("上传成功后的文件路径为:" + filePath + fileName);
 
            String path = filePath + fileName;
            String url = "/upload" + fileName;
 
            log.info("url ---> {}", url);
 
            return Result.success(url);
        } catch (IllegalStateException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
 
        return Result.success(null);
 
    }
 
}
  • UserController.java :控制层,上传头像(Post 请求)
@Controller
public class UserController extends BaseController {
    /**
     * 基本设置:上传头像
     */
    @ResponseBody
    @PostMapping("/user/upload")
    public Result uploadAvatar(@RequestParam(value = "file") MultipartFile file) throws IOException {
        return uploadUtil.upload(UploadUtil.type_avatar, file);
    }
}

38 个人账户:基本设置-更新头像(更新图片)

  • /res/mods/user.js :源码可知,修改默认 Post请求 更新图片路径,从 /user/set 更换为 /user/setAvatar
//上传图片
if ($('.upload-img')[0]) {
    layui.use('upload', function (upload) {
        var avatarAdd = $('.avatar-add');
 
        upload.render({
            elem: '.upload-img'
            , url: '/user/upload'
            , size: 50
            , before: function () {
                avatarAdd.find('.loading').show();
            }
            , done: function (res) {
                if (res.status == 0) {
                    /*修改默认post更新图片路径,从/user/set更换为/user/setAvatar*/
                    $.post('/user/setAvatar', {
                        avatar: res.data
                    }, function (res) {
                        location.reload();
                    });
                } else {
                    layer.msg(res.msg, {icon: 5});
                }
                avatarAdd.find('.loading').hide();
            }
            , error: function () {
                avatarAdd.find('.loading').hide();
            }
        });
    });
}
  • UserController.java :控制层,更新头像(Post 请求)
@Controller
public class UserController extends BaseController {
    /**
     * 基本设置:更新头像
     */
    @ResponseBody
    @PostMapping("/user/setAvatar")
    public Result doAvatar(User user) {
        if (StrUtil.isNotBlank(user.getAvatar())) {
            //更新显示【数据库】:avatar -> 数据库 -> 刷新页面
            User temp = userService.getById(getProfileId());
            temp.setAvatar(user.getAvatar());
            userService.updateById(temp);
 
            //更新显示【Shiro】:更新 “AccountRealm类中,返回的AccountProfile对象” -> 【header.ftl】
            AccountProfile profile = getProfile();
            profile.setAvatar(user.getAvatar());
 
            return Result.success().action("/user/set#avatar");
        }
        return Result.success().action("/user/set#avatar");
    }
}
  • set.ftl :模板引擎
<#--2.更新头像-->
<div class="layui-form layui-form-pane layui-tab-item">
    <div class="layui-form-item">
        <div class="avatar-add">
            <p>建议尺寸168*168,支持jpg、png、gif,最大不能超过2048KB</p>
            <button type="button" class="layui-btn upload-img">
                <i class="layui-icon">&#xe67c;</i>上传头像
            </button>
            <#--默认头像-->
            <img src="<@shiro.principal property="avatar" />">
            <span class="loading"></span>
        </div>
    </div>
</div>

39 个人账户:基本设置-更新密码

  • UserController.java :控制层,更新密码
@Controller
public class UserController extends BaseController {
    /**
     * 基本设置:更新密码
     */
    @ResponseBody
    @PostMapping("/user/repass")
    public Result repass(String nowpass, String pass, String repass) {
        //判断nowpass与pass两次输入是否一致
        if (!pass.equals(repass)) {
            return Result.fail("两次密码不相同");
        }
 
        //判断nowpass是否正确
        User user = userService.getById(getProfileId());
        String nowPassMd5 = SecureUtil.md5(nowpass);
        if (!nowPassMd5.equals(user.getPassword())) {
            return Result.fail("密码不正确");
        }
 
        //如果nowpass正确,则更新密码
        user.setPassword(SecureUtil.md5(pass));
        userService.updateById(user);
 
        return Result.success().action("/user/set#pass");
    }
}
  • set.ftl :模板引擎
<#--3.更新密码-->
<div class="layui-form layui-form-pane layui-tab-item">
    <form action="/user/repass" method="post">
        <#--3.1 当前密码-->
        <div class="layui-form-item">
            <label for="L_nowpass" class="layui-form-label">当前密码</label>
            <div class="layui-input-inline">
                <input type="password" id="L_nowpass" name="nowpass" required lay-verify="required"
                       autocomplete="off" class="layui-input">
            </div>
        </div>
        <#--3.2 新密码-->
        <div class="layui-form-item">
            <label for="L_pass" class="layui-form-label">新密码</label>
            <div class="layui-input-inline">
                <input type="password" id="L_pass" name="pass" required lay-verify="required"
                       autocomplete="off" class="layui-input">
            </div>
            <div class="layui-form-mid layui-word-aux">6到16个字符</div>
        </div>
        <#--3.3 确定密码-->
        <div class="layui-form-item">
            <label for="L_repass" class="layui-form-label">确认密码</label>
            <div class="layui-input-inline">
                <input type="password" id="L_repass" name="repass" required lay-verify="required"
                       autocomplete="off" class="layui-input">
            </div>
        </div>
        <#--3.4 确认修改-->
        <div class="layui-form-item">
            <#--通过阅读/res/mods/index.js源码可知,【lay-submit】此处默认【表单跳转】reload="true",则会【重新加载当前页面】-->
            <button class="layui-btn" key="set-mine" lay-filter="*" lay-submit reload="true"
                    alert="true">确认修改
            </button>
        </div>
    </form>
</div>

Part11-集成Shiro实现个人账户-用户中心

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      UserController.java
│      │          │
│      │          ├─service
│      │          │  │  UserService.java
│      │          │  │
│      │          │  └─impl
│      │          │         UserServiceImpl.java
│      │
│      └─resources
│          ├─templates
│          │  ├─inc
│          │  │     common.ftl
│          │  │
│          │  └─user
│          │        index.ftl

40 个人账户:用户中心

  • UserController.java :控制层,【跳转页面】、【发布的贴】、【收藏的贴】
@Controller
public class UserController extends BaseController {
    /**
     * 用户中心:跳转页面
     */
    @GetMapping("/user/index")
    public String index() {
        return "/user/index";
    }
 
    /**
     * 用户中心:发布的贴
     */
    @ResponseBody
    @GetMapping("/user/publish")
    public Result userPublic() {
        IPage page = postService.page(getPage(), new QueryWrapper<Post>()
                .eq("user_id", getProfileId())
                .orderByDesc("created"));
        long total = page.getTotal();
        req.setAttribute("publishCount", total);
        return Result.success(page);
    }
 
    /**
     * 用户中心:收藏的贴
     */
    @ResponseBody
    @GetMapping("/user/collection")
    public Result userCollection() {
        IPage page = postService.page(getPage(), new QueryWrapper<Post>()
                .inSql("id", "SELECT post_id FROM m_user_collection where user_id = " + getProfileId())
        );
        req.setAttribute("collectionCount", page.getTotal());
        return Result.success(page);
    }
}
  • index.ftl :模板引擎,参考【layui 社区中的 flow 流加载、laytpl 模板引擎、util 工具文档】
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl"/>
<#--宏common.ftl(用户中心-左侧链接(我的主页、用户中心、基本设置、我的消息))-->
<#include "/inc/common.ftl"/>
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "用户中心">
 
    <div class="layui-container fly-marginTop fly-user-main">
    <#--用户中心-左侧链接(我的主页、用户中心、基本设置、我的消息)-->
        <@centerLeft level=1></@centerLeft>
 
        <div class="site-tree-mobile layui-hide">
            <i class="layui-icon">&#xe602;</i>
        </div>
        <div class="site-mobile-shade"></div>
 
        <div class="site-tree-mobile layui-hide">
            <i class="layui-icon">&#xe602;</i>
        </div>
        <div class="site-mobile-shade"></div>
 
        <div class="fly-panel fly-panel-user" pad20>
            <div class="layui-tab layui-tab-brief" lay-filter="user">
                <ul class="layui-tab-title" id="LAY_mine">
                    <li data-type="mine-jie" lay-id="index" class="layui-this">我发的帖</li>
                    <li data-type="collection" data-url="/collection/find/" lay-id="collection">我收藏的帖</li>
                </ul>
 
                <div class="layui-tab-content" style="padding: 20px 0;">
                    <div class="layui-tab-item layui-show">
 
                    <#-----------------------1.发布的贴----------------------->
                    <#--第二步:建立视图,用于呈现渲染结果-->
                        <ul class="mine-view jie-row" id="publish">
                        <#--第一步,编写模版(laytpl),使用一个script标签存放模板:https://www.layui.com/doc/modules/laytpl.html-->
                            <script id="tpl-publish" type="text/html">
                                <li>
                                    <a class="jie-title" href="/post/{{d.id}}" target="_blank">
                                        {{d.title}}
                                    </a>
                                    <i>
                                        {{layui.util.toDateString(d.created, 'yyyy-MM-dd HH:mm:ss')}}
                                    </i>
                                    <a class="mine-edit" href="/post/edit?id={{d.id}}">编辑</a>
                                    <em>
                                        {{d.viewCount }}阅/{{d.commentCount}}答
                                    </em>
                                </li>
                            </script>
                        </ul>
 
                        <div id="LAY_page"></div>
                    </div>
 
                    <div class="layui-tab-item">
 
                    <#-----------------------2.收藏的贴----------------------->
                    <#--第二步:建立视图,用于呈现渲染结果-->
                        <ul class="mine-view jie-row" id="collection">
                        <#--第一步,编写模版(laytpl),使用一个script标签存放模板:https://www.layui.com/doc/modules/laytpl.html-->
                            <script id="tpl-collection" type="text/html">
                                <li>
 
                                    <a class="jie-title" href="/post/{{d.id}}" target="_blank">{{d.title}}</a>
                                    <i>收藏于{{layui.util.timeAgo(d.created, true)}}</i>
                                </li>
                            </script>
                        </ul>
 
                        <div id="LAY_page1"></div>
                    </div>
                </div>
            </div>
        </div>
    </div>
 
    <script>
        layui.cache.page = 'user';
 
        layui.use(['laytpl', 'flow', 'util'], function () {
            var $ = layui.jquery;
            var laytpl = layui.laytpl;
            var flow = layui.flow;
            var util = layui.util;
 
            /*流加载(flow)*/
            flow.load({
                elem: '#publish'                    //elem:指定列表容器
                , isAuto: false                     //isAuto:是否自动加载。默认 true。如果设为 false,点会在列表底部生成一个 “加载更多” 的 button,则只能点击它才会加载下一页数据。
                , done: function (page, next) {     //done:到达临界点触发加载的回调(默认滚动触发),触发下一页
                    var lis = [];
 
                    //以jQuery的Ajax请求为例,请求下一页数据(注意:page是从2开始返回)
                    $.get('/user/publish?pn=' + page, function (res) {
 
                        //假设你的列表返回在data集合中
                        layui.each(res.data.records, function (index, item) {
 
                        <#--第三步:渲染模版-->
                            var tpl = $("#tpl-publish").html();                //获取html内容:选择tpl-publish【第一步中的模版】
                            laytpl(tpl).render(item, function (html) {         //使用render进行渲染:使用【集合item】对【模板tpl】渲染为html
                                $("#publish .layui-flow-more").before(html);
                            });
                        });
 
                        //执行下一页渲染,第二参数为:满足“加载更多”的条件,即后面仍有分页
                        //pages为Ajax返回的总页数,只有当前页小于总页数的情况下,才会继续出现加载更多
                        next(lis.join(''), page < res.data.pages);
                    });
                }
            });
 
            flow.load({
                elem: '#collection'
                ,isAuto: false
                ,done: function(page, next){
                    var lis = [];
 
                    $.get('/user/collection?pn='+page, function(res){
                        layui.each(res.data.records, function(index, item){
 
                        <#--第三步:渲染模版-->
                            var tpl = $("#tpl-collection").html();          //获取html内容:选择tpl-collection【第一步中的模版】
                            laytpl(tpl).render(item, function (html) {      //使用render进行渲染:使用【集合item】对【模板tpl】渲染为html
                                $("#collection .layui-flow-more").before(html);
                            });
                        });
 
                        next(lis.join(''), page < res.data.pages);
                    });
                }
            });
 
        });
    </script>
 
</@layout>

41 宏:个人账户-左侧链接(我的主页、用户中心、基本设置、我的消息)

  • common.ftl :模板引擎,【公共部分】
<#--宏:个人账户-左侧链接(我的主页、用户中心、基本设置、我的消息)-->
<#macro centerLeft level>
    <ul class="layui-nav layui-nav-tree layui-inline" lay-filter="user">
        <li class="layui-nav-item <#if level == 0> layui-this</#if>">
            <a href="/user/home">
                <i class="layui-icon">&#xe609;</i>
                我的主页
            </a>
        </li>
        <li class="layui-nav-item <#if level == 1> layui-this</#if>">
            <a href="/user/index">
                <i class="layui-icon">&#xe612;</i>
                用户中心
            </a>
        </li>
        <li class="layui-nav-item <#if level == 2> layui-this</#if>">
            <a href="/user/set">
                <i class="layui-icon">&#xe620;</i>
                基本设置
            </a>
        </li>
        <li class="layui-nav-item <#if level == 3> layui-this</#if>">
            <a href="/user/mess">
                <i class="layui-icon">&#xe611;</i>
                我的消息
            </a>
        </li>
    </ul>
</#macro>

Part12-集成Shiro实现个人账户-我的消息

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      UserController.java
│      │          │
│      │          ├─service
│      │          │  │  UserService.java
│      │          │  │
│      │          │  └─impl
│      │          │         UserServiceImpl.java
│      │          │
│      │          ├─vo
│      │          │      UserMessageVo.java
│      │          │
│      │          ├─shiro
│      │          │      AccountRealm.java
│      │
│      └─resources
│          ├─templates
│          │  ├─inc
│          │  │     layout.ftl
│          │  │
│          │  └─user
│          │        index.ftl

42 个人账户:我的消息【查询消息】

  • UserController.java :控制层,【查询消息】
@Controller
public class UserController extends BaseController {
    /**
     * 我的消息:查询消息
     */
    @GetMapping("/user/mess")
    public String mess() {
        IPage<UserMessageVo> page = messageService.paging(getPage(), new QueryWrapper<UserMessage>()
                .eq("to_user_id", getProfileId())
                .orderByDesc("created")
        );
        req.setAttribute("pageData", page);
        return "/user/mess";
    }
}
  • UserMessageVo.java :实体类
@Data
public class UserMessageVo extends UserMessage {
    /**
     * 我的消息的【接收消息的用户ID】-用户名name     未使用
     */
    private String toUserName;
 
    /**
     * 我的消息的【发送消息的用户ID】-用户名name
     */
    private String fromUserName;
 
    /**
     * 我的消息的【关联的文章ID】-文章标题title
     */
    private String postTitle;
 
    /**
     * 我的消息的【关联的文章-对应的评论ID】-评论内容content
     */
    private String commentContent;
}
  • UserMessageMapper.xml :数据层实现
<select id="selectMessages" resultType="org.myslayers.vo.UserMessageVo">
    SELECT m.*,
        (
            SELECT username
            FROM `m_user`
            WHERE id = m.from_user_id
        ) AS fromUserName,
        (
            SELECT title
            FROM `m_post`
            WHERE id = m.post_id
        ) AS postTitle,
        (
            SELECT content
            FROM `m_comment`
            WHERE id = m.comment_id
        ) AS commentContent
    FROM `m_user_message` m
        ${ew.customSqlSegment}
</select>
  • index.ftl :模板引擎
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl"/>
<#--宏common.ftl(个人账户-左侧链接(我的主页、用户中心、基本设置、我的消息),分页)-->
<#include "/inc/common.ftl"/>
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "用户中心">
 
    <div class="layui-container fly-marginTop fly-user-main">
        <#--用户中心-左侧链接(我的主页、用户中心、基本设置、我的消息)-->
        <@centerLeft level=3></@centerLeft>
 
        <div class="site-tree-mobile layui-hide">
            <i class="layui-icon">&#xe602;</i>
        </div>
        <div class="site-mobile-shade"></div>
 
        <div class="site-tree-mobile layui-hide">
            <i class="layui-icon">&#xe602;</i>
        </div>
        <div class="site-mobile-shade"></div>
 
        <div class="fly-panel fly-panel-user" pad20>
            <div class="layui-tab layui-tab-brief" lay-filter="user" id="LAY_msg" style="margin-top: 15px;">
                <button class="layui-btn layui-btn-danger" id="LAY_delallmsg">清空全部消息</button>
                <div id="LAY_minemsg" style="margin-top: 10px;">
                    <ul class="mine-msg">
 
                        <#--我的消息的【消息的类型】:0代表系统消息、1代表评论的文章、2代表评论的评论-->
                        <#list pageData.records as mess>
                            <li data-id="${mess.id}">
                                <blockquote class="layui-elem-quote">
                                    <#if mess.type == 0>
                                        系统消息:${mess.content}
                                    </#if>
                                    <#if mess.type == 1>
                                        ${mess.fromUserName} 评论了你的文章 <${mess.postTitle}>,内容是 (${mess.commentContent})
                                    </#if>
                                    <#if mess.type == 2>
                                        ${mess.fromUserName} 回复了你的评论 (${mess.commentContent}),文章是 <${mess.postTitle}>
                                    </#if>
                                </blockquote>
                                <p>
                                    <span>
                                        ${timeAgo(mess.created)}
                                    </span>
                                    <a class="layui-btn layui-btn-small layui-btn-danger fly-delete" href="javascript:;">
                                        删除
                                    </a>
                                </p>
                            </li>
                        </#list>
 
                    </ul>
                </div>
            </div>
        </div>
    </div>
 
    <script>
        layui.cache.page = 'user';
    </script>
 
</@layout>

43 个人账户:我的消息【删除单个消息 或 删除全部消息】

  • UserController.java :控制层,【删除单个消息 或 删除全部消息】
@Controller
public class UserController extends BaseController {
    /**
     * 我的消息:删除单个消息 或 删除全部消息(前端参数包含“all=true”,如果为ture时,使用【.eq(!all, "id", id))】 删除全部消息)
     */
    @ResponseBody
    @PostMapping("/message/remove/")
    public Result msgRemove(Long id, @RequestParam(defaultValue = "false") Boolean all) {
        boolean remove = messageService.remove(new QueryWrapper<UserMessage>()
                .eq("to_user_id", getProfileId())
                .eq(!all, "id", id));
        return remove ? Result.success(null) : Result.fail("删除失败");
    }
 
    /**
     * 我的消息:使用layout.ftl中 利用session来实现【登录状态】 后,发现【接口异常】,
     *         查看后发现,【/res/mods/index.js】源码,【新消息通知 -> layui.cache.user.uid !== -1】 -> 因此补充 status、count 数据接口
     */
    @ResponseBody
    @PostMapping("/message/nums/")
    public Map msgNums() {
        int count = messageService.count(new QueryWrapper<UserMessage>()
                .eq("to_user_id", getProfileId())//全部数量的消息
                .eq("status", "0")           //未读的消息  未读0 已读1
        );
        return MapUtil.builder("status", 0).put("count", count).build();
    }
}

44 个人账户:我的消息【消息弹窗】

  • UserController.java :控制层,【消息弹窗】
@Controller
public class UserController extends BaseController {
    /**
     * 我的消息:使用layout.ftl中 利用session来实现【登录状态】 后,发现【接口异常】,
     *         查看后发现,【/res/mods/index.js】源码,【新消息通知 -> layui.cache.user.uid !== -1】 -> 因此补充 status、count 数据接口
     */
    @ResponseBody
    @PostMapping("/message/nums/")
    public Map msgNums() {
        int count = messageService.count(new QueryWrapper<UserMessage>()
                .eq("to_user_id", getProfileId())//全部数量的消息
                .eq("status", "0")           //未读的消息  未读0 已读1
        );
        return MapUtil.builder("status", 0).put("count", count).build();
    }
}
  • /res/mods/index.js :源码可知,如果 res.status === 0 && res.count > 0,会出现弹窗【你有 X 条未读消息】
//新消息通知
newmsg: function () {
    var elemUser = $('.fly-nav-user');
    if (layui.cache.user.uid !== -1 && elemUser[0]) {
        fly.json('/message/nums/', {
            _: new Date().getTime()
        }, function (res) {
            if (res.status === 0 && res.count > 0) {
                var msg = $('<a class="fly-nav-msg" href="javascript:;">' + res.count + '</a>');
                elemUser.append(msg);
                msg.on('click', function () {
                    fly.json('/message/read', {}, function (res) {
                        if (res.status === 0) {
                            location.href = '/user/message/';
                        }
                    });
                });
                layer.tips('你有 ' + res.count + ' 条未读消息', msg, {
                    tips: 3
                    , tipsMore: true
                    , fixed: true
                });
                msg.on('mouseenter', function () {
                    layer.closeAll('tips');
                })
            }
        });
    }
    return arguments.callee;
}

45 其他:layout.ftlscript 设置用户登录状态

  1. 方式一:利用 shiro 来实现【登录状态】
  • layout.ftl :模板引擎
<#--宏:1.macro定义脚本,名为layout,参数为title-->
<#macro layout title>
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>${title}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
        <meta name="keywords" content="fly,layui,前端社区">
        <meta name="description" content="Fly社区是模块化前端UI框架Layui的官网社区,致力于为web开发提供强劲动力">
        <link rel="stylesheet" href="/res/layui/css/layui.css">
        <link rel="stylesheet" href="/res/css/global.css">
 
        <#--导入script-->
        <script src="/res/layui/layui.js"></script>
        <script src="/res/js/jquery.min.js"></script>
    </head>
    <body>
 
    <#--【一、导航栏】-->
    <#include "/inc/header.ftl"/>
 
    <#--【三、所有引用该“带有宏的标签layout.ftl”都会执行该操作:<@layout "首页"></@layout>中的数据 -> 填充到<#nested/>标签中】-->
    <#nested>
 
    <#--【四、页脚】-->
    <#include "/inc/footer.ftl"/>
 
    <script>
        <#-----------------方式一:利用shiro来实现【登录状态】---------------------->
        <#--未登录的状态-->
        <#--【shiro.guest】:验证当前用户是否为 “访客”,即未认证(包含未记住)的用户-->
        <@shiro.guest>
            // layui.cache.page = '';
            layui.cache.user = {
                username: '游客'
                , uid: -1
                , avatar: '/res/images/avatar/00.jpg'
                , experience: 83
                , sex: '男'
            };
            layui.config({
                version: "3.0.0"
                ,base: '/res/mods/' //这里实际使用时,建议改成绝对路径
            }).extend({
                fly: 'index'
            }).use('fly');
        </@shiro.guest>
 
        <#--登录后的状态-->
        <#--【shiro.user】:认证通过或已记住的用户-->
        <@shiro.user>
            // layui.cache.page = '';
            layui.cache.user = {
                username: <@shiro.principal property="username"/>
                , uid: <@shiro.principal property="id"/>
                , avatar: <@shiro.principal property="avatar"/>
                , experience: 83
                , sex: <@shiro.principal property="gender"/>
            };
            layui.config({
                version: "3.0.0"
                , base: '/res/mods/' //这里实际使用时,建议改成绝对路径
            }).extend({
                fly: 'index'
            }).use('fly');
        </@shiro.user>
    </script>
 
    </body>
    </html>
</#macro>

2.方式二:利用 session 来实现【登录状态】,修改【更新资料/更新头像】后,需要【手动更新 shiro/session 数据】

  • layout.ftl :模板引擎
<#--宏:1.macro定义脚本,名为layout,参数为title-->
<#macro layout title>
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>${title}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
        <meta name="keywords" content="fly,layui,前端社区">
        <meta name="description" content="Fly社区是模块化前端UI框架Layui的官网社区,致力于为web开发提供强劲动力">
        <link rel="stylesheet" href="/res/layui/css/layui.css">
        <link rel="stylesheet" href="/res/css/global.css">
 
        <#--导入script-->
        <script src="/res/layui/layui.js"></script>
        <script src="/res/js/jquery.min.js"></script>
    </head>
    <body>
 
    <#--【一、导航栏】-->
    <#include "/inc/header.ftl"/>
 
    <#--【三、所有引用该“带有宏的标签layout.ftl”都会执行该操作:<@layout "首页"></@layout>中的数据 -> 填充到<#nested/>标签中】-->
    <#nested>
 
    <#--【四、页脚】-->
    <#include "/inc/footer.ftl"/>
 
    <script>
        <#-----------------方式二:利用session来实现【登录状态】---------------------->
        // layui.cache.page = '';
        layui.cache.user = {
            username: '${profile.username!"游客"}'
            , uid: ${profile.id!"-1"}
            , avatar: '${profile.avatar!"/res/images/avatar/00.jpg"}'
            , experience: 83
            , sex: '${profile.gender!"男"}'
        };
 
        layui.config({
            version: "3.0.0"
            , base: '/res/mods/' //这里实际使用时,建议改成绝对路径
        }).extend({
            fly: 'index'
        }).use('fly');
    </script>
 
    </body>
    </html>
</#macro>
  • AccountRealm.java :配置类,【手动更新 shiro/session 数据】
/**
 * AccountRealm:重写父类AuthorizingRealm方法
 */
@Component
public class AccountRealm extends AuthorizingRealm {
 
    @Autowired
    UserService userService;
 
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }
 
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // 1.获取Token
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
        // 2.根据token获取username、password,并进行login登录,返回AccountProfile账户信息
        AccountProfile profile = userService.login(usernamePasswordToken.getUsername(), String.valueOf(usernamePasswordToken.getPassword()));
        // 3.通过profile、token.getCredentials()、getName(),获取AuthenticationInfo子接口对象(SimpleAuthenticationInfo)
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(profile, token.getCredentials(), getName());
 
        // 方式二:利用session来实现【登录状态】,修改【更新资料/更新头像】后,需要【手动更新shiro/session数据】
        SecurityUtils.getSubject().getSession().setAttribute("profile", profile);
        return info;
    }
}
  • UserController.java :控制层,【手动更新 shiro/session 数据】
@Controller
public class UserController extends BaseController {
    /**
     * 基本设置:更新资料
     */
    @ResponseBody
    @PostMapping("/user/set")
    public Result doSet(User user) {
        //校验:昵称不能为空
        if (StrUtil.isBlank(user.getUsername())) {
            return Result.fail("昵称不能为空");
        }
 
        //校验:从数据库中查询【username是否存在】、【id并非当前用户】,如果count > 0,则代表“该昵称已被占用”
        int count = userService.count(new QueryWrapper<User>()
                .eq("username", getProfile().getUsername())
                .ne("id", getProfileId()));
        if (count > 0) {
            return Result.fail("该昵称已被占用");
        }
 
        //更新显示【数据库】:username、gender、sign -> 数据库 -> 刷新页面
        User temp = userService.getById(getProfileId());
        temp.setUsername(user.getUsername());
        temp.setGender(user.getGender());
        temp.setSign(user.getSign());
        userService.updateById(temp);
 
        //更新显示【Shiro】:更新 “AccountRealm类中,返回的AccountProfile对象” -> 【header.ftl】
        AccountProfile profile = getProfile();
        profile.setUsername(temp.getUsername());
        profile.setSign(temp.getSign());
 
        //方式二:利用session来实现【登录状态】,修改【更新资料/更新头像】后,需要【手动更新shiro/session数据】
        SecurityUtils.getSubject().getSession().setAttribute("profile", profile);
 
        return Result.success().action("/user/set#info");
    }
 
    /**
     * 基本设置:更新头像
     */
    @ResponseBody
    @PostMapping("/user/setAvatar")
    public Result doAvatar(User user) {
        if (StrUtil.isNotBlank(user.getAvatar())) {
            //更新显示【数据库】:avatar -> 数据库 -> 刷新页面
            User temp = userService.getById(getProfileId());
            temp.setAvatar(user.getAvatar());
            userService.updateById(temp);
 
            //更新显示【Shiro】:更新 “AccountRealm类中,返回的AccountProfile对象” -> 【header.ftl】
            AccountProfile profile = getProfile();
            profile.setAvatar(user.getAvatar());
 
            //方式二:利用session来实现【登录状态】,修改【更新资料/更新头像】后,需要【手动更新shiro/session数据】
            SecurityUtils.getSubject().getSession().setAttribute("profile", profile);
 
            return Result.success().action("/user/set#avatar");
        }
        return Result.success().action("/user/set#avatar");
    }
}

Part13-集成Shiro实现博客详情-收藏文章

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─shiro
│      │          │      ShiroConfig.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      PostController.java
│      │          │
│      │          ├─shiro
│      │          │      AuthFilter.java

46 博客详情:收藏文章【判断用户是否收藏了文章】

  • PostController.java :控制层,【判断用户是否收藏了文章】
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail:判断用户是否收藏了文章
     */
    @ResponseBody
    @PostMapping("/collection/find/")
    public Result collectionFind(Long pid) {
        int count = collectionService.count(new QueryWrapper<UserCollection>()
                .eq("user_id", getProfileId())
                .eq("post_id", pid)
        );
        //【/res/mods/jie.js】源码可知,异步渲染(layui.cache.user.uid != -1时,会调用/collection/find/接口)
        //【/res/mods/jie.js】源码可知,异步渲染(res.data.collection ? '取消收藏' : '收藏'),count > 0 为true时,则res.data.collection也为true
        return Result.success(MapUtil.of("collection", count > 0));
    }
}

47 博客详情:收藏文章【加入收藏】

  • PostController.java :控制层,【加入收藏】
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail:【加入收藏】文章
     */
    @ResponseBody
    @PostMapping("/collection/add/")
    public Result collectionAdd(Long pid) {
        Post post = postService.getById(pid);
 
        //文章是否被删除
        //如果【post != null】为true,则直接跳过该条语句;否则为false,则报异常【java.lang.IllegalArgumentException: 改帖子已被删除】
        //等价写法【if (post == null) return Result.fail("该帖子已被删除");】
        Assert.isTrue(post != null, "该文章已被删除");
 
        //文章是否被收藏
        int count = collectionService.count(new QueryWrapper<UserCollection>()
                .eq("user_id", getProfileId())
                .eq("post_id", pid)
        );
        if (count > 0) {
            return Result.fail("你已经收藏");
        }
 
        //将该文章进行收藏
        UserCollection collection = new UserCollection();
        collection.setUserId(getProfileId());
        collection.setPostId(pid);
        collection.setCreated(new Date());
        collection.setModified(new Date());
        collection.setPostUserId(post.getUserId());
        collectionService.save(collection);
 
        return Result.success();
    }
}

48 博客详情:收藏文章【取消收藏】

  • PostController.java :控制层,【取消收藏】
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail:【取消收藏】文章
     */
    @ResponseBody
    @PostMapping("/collection/remove/")
    public Result collectionRemove(Long pid) {
        Post post = postService.getById(pid);
 
        //文章是否被删除
        Assert.isTrue(post != null, "该文章已被删除");
 
        //将该文章进行删除
        collectionService.remove(new QueryWrapper<UserCollection>()
                .eq("user_id", getProfileId())
                .eq("post_id", pid));
 
        return Result.success();
    }
}

49 其他:Shiro自定义过滤器【判断请求是否是Ajax请求,还是Web请求】

  • 场景:如果用户退出登录后,点击【收藏文章】,报错【请求异常,请重试】,如何弹窗提示【请先登录!】
  • 解决:Shiro 自定义过滤器,重写 UserFilter 父类中的 redirectToLogin() 方法
  • AuthFilter.java :过滤器,判断请求是否是 Ajax 请求,还是 Web 请求
public class AuthFilter extends UserFilter {
 
    @Override
    protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
 
        /**
         * Ajax请求:通过对request字段的处理,来判断如果为Ajax请求,则设置response返回一段Json请求,并弹窗提示【请先登录!】
         */
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        //通过“X-Requested-With”来确定“该请求是一个Ajax请求”
        String header = httpServletRequest.getHeader("X-Requested-With");
        if(header != null  && "XMLHttpRequest".equals(header)) {
            //isAuthenticated():如果此主题/用户在当前会话期间通过提供与系统已知的凭据匹配的有效凭据来证明了自己的身份,则返回true否则返回false
            boolean authenticated = SecurityUtils.getSubject().isAuthenticated();
            //如果!authenticated = true,则将resp设置为“返回一段Json数据”,Result.fail("请先登录!")会触发【弹窗】显示【请先登录!】
            if(!authenticated) {
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().print(JSONUtil.toJsonStr(Result.fail("请先登录!")));
            }
        } else {
 
            /**
             * Web请求,则重定向到【登录页面】,直接super.父类方法,无需对request、response进行处理
             */
            super.redirectToLogin(request, response);
        }
    }
}
  • ShiroConfig.java :配置类,安全管理器、拦截器链、自定义过滤器
/**
 * Shiro配置类:安全管理器、拦截器链、自定义过滤器
 */
@Slf4j
@Configuration
public class ShiroConfig {
 
    /**
     * 安全管理器
     */
    @Bean
    public SecurityManager securityManager(AccountRealm accountRealm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(accountRealm);
        log.info("------------------>securityManager注入成功");
        return securityManager;
    }
 
    /**
     * 拦截器链
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
        // 配置安全管理器
        filterFactoryBean.setSecurityManager(securityManager);
 
        // 配置登录的url
        filterFactoryBean.setLoginUrl("/login");
 
        // 配置登录成功的url
        filterFactoryBean.setSuccessUrl("/user/center");
 
        // 配置未授权跳转页面
        filterFactoryBean.setUnauthorizedUrl("/error/403");
 
        // 配置Shiro自定义过滤器:判断请求是否是Ajax请求,还是Web请求
        filterFactoryBean.setFilters(MapUtil.of("auth", authFilter()));
 
        // 配置过滤链定义图:未登录的情况下,访问/login、/user/home页面,自动跳转登录页面进行认证
        Map<String, String> hashMap = new LinkedHashMap<>();
        hashMap.put("/res/**", "anon");
 
        hashMap.put("/user/home", "auth");
        hashMap.put("/user/set", "auth");
        hashMap.put("/user/upload", "auth");
 
        hashMap.put("/user/index", "auth");
        hashMap.put("/user/public", "auth");
        hashMap.put("/user/collection", "auth");
        hashMap.put("/user/mess", "auth");
        hashMap.put("/msg/remove/", "auth");
        hashMap.put("/message/nums/", "auth");
 
        hashMap.put("/collection/remove/", "auth");
        hashMap.put("/collection/find/", "auth");
        hashMap.put("/collection/add/", "auth");
 
        hashMap.put("/post/edit", "auth");
        hashMap.put("/post/submit", "auth");
        hashMap.put("/post/delete", "auth");
        hashMap.put("/post/reply/", "auth");
 
        hashMap.put("/websocket", "anon");
        hashMap.put("/login", "anon");
        filterFactoryBean.setFilterChainDefinitionMap(hashMap);
 
        return filterFactoryBean;
    }
 
    /**
     * Shiro自定义过滤器:判断请求是否是Ajax请求,还是Web请求
     */
    @Bean
    public AuthFilter authFilter() {
        return new AuthFilter();
    }
}
  • static/res/mods/index.js :源码可知,如果 status 为 0 ,则代表登录成功;status 为 -1,则代表登录失败,并弹窗显示 msg 内容
//Ajax
json: function (url, data, success, options) {
    var that = this, type = typeof data === 'function';
 
    if (type) {
        options = success
        success = data;
        data = {};
    }
 
    options = options || {};
 
    return $.ajax({
        type: options.type || 'post',
        dataType: options.dataType || 'json',
        data: data,
        url: url,
        success: function (res) {
            if (res.status === 0) {
                success && success(res);
            } else {
                layer.msg(res.msg || res.code, {shift: 6});     //弹窗显示【返回的"msg"内容】
                options.error && options.error();
            }
        }, error: function (e) {
            layer.msg('请求异常,请重试', {shift: 6});
            options.error && options.error(e);
        }
    });
}

Part14-集成Shiro实现博客详情-添加文章、编辑文章、提交文章

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      PostController.java
│      │
│      └─resources
│          ├─templates
│          │  └─post
│          │         edit.ftl

50 博客详情:添加文章/编辑文章、提交文章

  • PostController.java :控制层,【添加】、【编辑】、【提交】
@Controller
public class PostController extends BaseController {
    /**
     * 添加/编辑edit:【添加/编辑】文章
     */
    @GetMapping("/post/edit")
    public String edit() {
        //getParameter:http://localhost:8080/post/edit?id=1
        String id = req.getParameter("id");
        //如果id不为空
        if (!StringUtils.isEmpty(id)) {
            Post post = postService.getById(id);
            Assert.isTrue(post != null, "该文章已被删除!");
            Assert.isTrue(post.getUserId().longValue() == getProfileId().longValue(), "没权限操作此文章!");
            //向request域存放【post文章信息】
            req.setAttribute("post", post);
        }
 
        //向request域存放【categories分类信息】
        req.setAttribute("categories", categoryService.list());
        return "/post/edit";
    }
 
    /**
     * 添加/编辑edit:【提交】文章
     */
    @ResponseBody
    @PostMapping("/post/submit")
    public Result submit(Post post) {
        // 使用ValidationUtil工具类,校验【输入是否错误】
        ValidationUtil.ValidResult validResult = ValidationUtil.validateBean(post);
        if (validResult.hasErrors()) {
            return Result.fail(validResult.getErrors());
        }
 
        // 在传入【req.setAttribute("post", post);】后,同一页面请求的数据,可以通过post.getId()查询到【id】
        // 如果id不存在,则为【添加-文章】
        if (post.getId() == null) {
            post.setUserId(getProfileId());
            post.setModified(new Date());
            post.setCreated(new Date());
            post.setCommentCount(0);
            post.setEditMode(null);
            post.setLevel(0);
            post.setRecommend(false);
            post.setViewCount(0);
            post.setVoteDown(0);
            post.setVoteUp(0);
            postService.save(post);
        } else {
            // 如果id存在,则为【更新-文章】
            Post tempPost = postService.getById(post.getId());
            Assert.isTrue(tempPost.getUserId().longValue() == getProfileId().longValue(), "无权限编辑此文章!");
            tempPost.setTitle(post.getTitle());
            tempPost.setContent(post.getContent());
            tempPost.setCategoryId(post.getCategoryId());
            postService.updateById(tempPost);
        }
 
        // 无论id是否存在,两类情况都会 retern 跳转到 /post/${id}
        return Result.success().action("/post/" + post.getId());
    }
}
  • edit.ftl :模板引擎,【添加】、【编辑】、【提交】
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl" />
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "添加或编辑文章">
 
  <div class="layui-container fly-marginTop">
    <div class="fly-panel" pad20 style="padding-top: 5px;">
      <!--<div class="fly-none">没有权限</div>-->
      <div class="layui-form layui-form-pane">
        <div class="layui-tab layui-tab-brief" lay-filter="user">
 
          <#--1.类型:发表文章/编辑文章-->
          <ul class="layui-tab-title">
            <li class="layui-this">
              <#--通过post是否为null,来判断该页面是【发表文章 还是 编辑文章】-->
              <#if post == null>
                发表文章<#else>编辑文章
              </#if>
            </li>
          </ul>
 
          <div class="layui-form layui-tab-content" id="LAY_ucm" style="padding: 20px 0;">
            <div class="layui-tab-item layui-show">
              <#--2.表单-->
              <form action="/post/submit" method="post">
                <div class="layui-row layui-col-space15 layui-form-item">
                  <#--2.1 所在专栏-->
                  <div class="layui-col-md3">
                    <label class="layui-form-label">所在专栏</label>
                    <div class="layui-input-block">
                      <select lay-verify="required" name="categoryId" lay-filter="column">
                        <option></option>
                        <#--下拉列表:分类信息-->
                        <#list categories as category>
                          <option value="${category.id}" <#if category.id == post.categoryId>selected</#if> >${category.name}</option>
                        </#list>
                      </select>
                    </div>
                  </div>
 
                  <#--2.2 文章标题-->
                  <div class="layui-col-md9">
                    <label for="L_title" class="layui-form-label">标题</label>
                    <div class="layui-input-block">
                      <input type="text" id="L_title" name="title" required lay-verify="required" value="${post.title}"
                             autocomplete="off" class="layui-input">
                      <input type="hidden" name="id" value="${post.id}">
                    </div>
                  </div>
                </div>
 
                <#--2.3 文章内容-->
                <div class="layui-form-item layui-form-text">
                  <div class="layui-input-block">
                    <textarea id="L_content" name="content" required lay-verify="required"
                              placeholder="详细描述" class="layui-textarea fly-editor" style="height: 260px;">${post.content}</textarea>
                  </div>
                </div>
 
                <#--2.4 提交表单-->
                <div class="layui-form-item">
                  <button class="layui-btn" lay-filter="*" lay-submit alert="true" >立即发布</button>
                </div>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <script>
    layui.cache.page = 'jie';
  </script>
 
</@layout>

51 博客详情:添加文章/编辑文章-使用表情

  • edit.ftl :模板引擎,默认表情无法被识别,需要引入 fly、face
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl" />
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "博客详情">
 
  <#--【二、分类】-->
  <#include "/inc/header-panel.ftl" />
  <script>
      layui.cache.page = 'jie';
 
      //如果你是采用模版自带的编辑器,你需要开启以下语句来解析
      $(function () {
          layui.use(['fly', 'face'], function() { //引入fly、face
              var fly = layui.fly;
              $('.detail-body').each(function(){
                  var othis = $(this), html = othis.html();
                  othis.html(fly.content(html));
              });
          });
      });
  </script>
</@layout>

Part15-集成Shiro实现博客详情-超级用户、删除、置顶、精华

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─exception
│      │          │      GlobalException.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      AdminController.java
│      │          │
│      │          ├─shiro
│      │          │      AccountRealm.java
│      │
│      └─resources
│          ├─templates
│          │  │  error.ftl
│          │  │
│          │  └─post
│          │         detail.ftl

52 博客详情-超级用户

  • AccountRealm.java :过滤器,授权 id=1 的用户 admin 为 超级用户
/**
 * Shiro过滤器:授权 / 认证
 */
@Component
public class AccountRealm extends AuthorizingRealm {
 
    @Autowired
    UserService userService;
 
    /**
     * doGetAuthorizationInfo(授权):
     * <p>
     * 需要判断是否有访问某个资源的权限
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        AccountProfile profile = (AccountProfile) principals.getPrimaryPrincipal();
        // 给id=1的admin用户,赋予admin角色
        if(profile.getId() == 1) {
            SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
            info.addRole("admin");
            return info;
        }
        return null;
    }
}

53 博客详情-删除、置顶、精华

  • detail.ftl :模板引擎,使用<@shiro.hasRole name=“admin”>/@shiro标签对【删除】、【置顶】、【加精】进行处理,因此,该功能只能【登录 admin 超级用户】
<#--1.1.2 文章标签-->
<div class="fly-detail-info">
<span class="layui-badge layui-bg-green fly-detail-column">${post.categoryName}</span>
 
<#if post.level gt 0><span class="layui-badge layui-bg-black">置顶</span></#if>
<#if post.recommend><span class="layui-badge layui-bg-red">精帖</span></#if>
 
<div class="fly-admin-box" data-id="${post.id}">
    <#--发布者删除-->
    <#if post.userId == profile.id>
      <span class="layui-btn layui-btn-xs jie-admin" type="del">删除</span>
    </#if>
 
    <#--管理员操作-->
    <@shiro.hasRole name="admin">
      <span class="layui-btn layui-btn-xs jie-admin" type="set" field="delete" rank="1">删除</span>
        <#if post.level == 0><span class="layui-btn layui-btn-xs jie-admin" type="set" field="stick" rank="1">置顶</span></#if>
        <#if post.level gt 0><span class="layui-btn layui-btn-xs jie-admin" type="set" field="stick" rank="0" style="background-color:#ccc;">取消置顶</span></#if>
        <#if !post.recommend><span class="layui-btn layui-btn-xs jie-admin" type="set" field="status" rank="1">加精</span></#if>
        <#if post.recommend><span class="layui-btn layui-btn-xs jie-admin" type="set" field="status" rank="0" style="background-color:#ccc;">取消加精</span></#if>
    </@shiro.hasRole>
</div>
 
<span class="fly-list-nums">
  <a href="#comment"><i class="iconfont" title="回答">&#xe60c;</i>${post.commentCount}</a>
  <i class="iconfont" title="人气">&#xe60b;</i>${post.viewCount}
</span>
</div>

54 博客详情-数据接口

  • AdminController.java :控制层,根据前端传来的 3 个参数:id、rank、field,对功能进行实现
@Controller
public class AdminController extends BaseController {
 
    /**
     * 访问 /post/{id} 的文章时,如果为 admin 超级管理员,则可以管理该文章,例如【删除】、【置顶】、【加精】
     *
     * 实现思路:
     * 1.AccountRealm.java 中的 doGetAuthorizationInfo() 方法 -> 授权 id = 1 的用户 admin 为 超级管理员
     * 2.detail.ftl 页面,使用<@shiro.hasRole name="admin"></@shiro>标签对【删除】、【置顶】、【加精】进行处理,因此,该功能只能【登录admin超级管理员账户】
     * 3.根据前端传来的 3 个参数:id、rank、field,对功能进行实现
     *
     * @param id    post.id
     * @param rank  0表示取消(取消置顶、取消加精),1表示操作(删除、置顶、加精)
     * @param field 操作类型:删除(field:delete)、置顶(field:stick)、加精(field:status)
     */
    @ResponseBody
    @PostMapping("/admin/jie-set")
    public Result jetSet(Long id, Integer rank, String field) {
        //根据id判断该文章是否被删除
        Post post = postService.getById(id);
        Assert.notNull(post, "该文章已被删除");
 
        //删除
        if ("delete".equals(field)) {
            postService.removeById(id);
            return Result.success();
        } else if ("status".equals(field)) {
            //置顶
            post.setRecommend(rank == 1);
        } else if ("stick".equals(field)) {
            //加精
            post.setLevel(rank);
        }
 
        postService.updateById(post);
        return Result.success();
    }
}

55 其他-全局异常

  • GlobalException.java :全局异常,分别对 Ajax 异常请求、Web 异常请求进行处理
/**
 * 全局异常
 */
@Slf4j
@ControllerAdvice
public class GlobalException {
 
    @ExceptionHandler(value = Exception.class)
    public ModelAndView handler(HttpServletRequest req, HttpServletResponse resp, Exception e) throws IOException {
        //Ajax异常请求
        String header = req.getHeader("X-Requested-With");
        if (header != null && "XMLHttpRequest".equals(header)) {
            resp.setContentType("application/json;charset=UTF-8");
            resp.getWriter().print(JSONUtil.toJsonStr(Result.fail(e.getMessage())));
            return null;
        }
        //Web异常请求
        ModelAndView modelAndView = new ModelAndView("error");
        modelAndView.addObject("message", e.getMessage());
        return modelAndView;
    }
}
  • error.ftl :模板引擎,将 message 错误信息进行显示
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl" />
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "错误页面">
 
  <#--【二、分类】-->
  <#include "/inc/header-panel.ftl" />
 
  <div class="layui-container fly-marginTop">
    <div class="fly-panel">
      <div class="fly-none">
        <h2><i class="iconfont icon-tishilian"></i></h2>
        <p>${message}</p>
      </div>
    </div>
  </div>
 
  <script>
    layui.cache.page = '';
  </script>
 
</@layout>

Part16-集成Shiro实现博客详情-用户文章、用户评论

blog
├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      PostController.java

56 博客详情-用户文章

  • PostController.java :控制层,【查看】文章、【查看】评论、【删除】文章、【评论】文章、
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail:【查看】文章、【查看】评论
     */
    @GetMapping("/post/{id:\\d*}")
    public String detail(@PathVariable(name = "id") long id) {
        /**
         * 一条(post实体类、PostVo实体类)
         */
        //一条:selectOnePost(表 文章id = 传 文章id),因为Mapper中select信息中,id过多引起歧义,故采用p.id
        PostVo postVo = postService.selectOnePost(new QueryWrapper<Post>().eq("p.id", id));
        //req:PostVo实体类 -> CategoryId属性
        req.setAttribute("currentCategoryId", postVo.getCategoryId());
        //req:PostVo实体类(回调)
        req.setAttribute("post", postVo);
 
        /**
         * 评论(comment实体类)
         */
        //评论:page(分页信息、文章id、用户id、排序)
        IPage<CommentVo> results = commentService.selectComments(getPage(), postVo.getId(), null, "created");
        //req:CommentVo分页集合
        req.setAttribute("pageData", results);
 
        /**
         * 文章阅读【缓存实现访问量】:减少访问数据库的次数,存在一个BUG,只与点击链接的次数相关,没有与用户的id进行绑定
         */
        postService.putViewCount(postVo);
 
        return "post/detail";
    }
 
    /**
     * 详情detail:【删除】文章
     */
    @ResponseBody
    @Transactional
    @PostMapping("/post/delete")
    public Result delete(Long id) {
        Post post = postService.getById(id);
        Assert.notNull(post, "该帖子已被删除");
        Assert.isTrue(post.getUserId().longValue() == getProfileId().longValue(), "无权限删除此文章!");
 
        // 删除-该篇文章【该篇文章】-Post
        postService.removeById(id);
        // 删除-我的消息【收到消息】-UserMessage中的post_id
        messageService.removeByMap(MapUtil.of("post_id", id));
        // 删除-用户中心【收藏的帖】-UserCollection中的post_id
        collectionService.removeByMap(MapUtil.of("post_id", id));
 
        return Result.success().action("/user/index");
    }
}

57 博客详情-用户评论

  • PostController.java :控制层,【评论】文章、【删除】评论
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail:【评论】文章
     */
    @ResponseBody
    @Transactional
    @PostMapping("/post/reply/")
    public Result reply(Long jid, String content) {
        Assert.notNull(jid, "找不到对应的文章");
        Assert.hasLength(content, "评论内容不能为空");
 
        Post post = postService.getById(jid);
        Assert.isTrue(post != null, "该文章已被删除");
 
        // 新增评论
        Comment comment = new Comment();
        comment.setPostId(jid);
        comment.setContent(content);
        comment.setUserId(getProfileId());
        comment.setCreated(new Date());
        comment.setModified(new Date());
        comment.setLevel(0);
        comment.setVoteDown(0);
        comment.setVoteUp(0);
        commentService.save(comment);
 
        // 评论数量+1
        post.setCommentCount(post.getCommentCount() + 1);
        postService.updateById(post);
 
        // 本周热议数量+1
        postService.incrCommentCountAndUnionForWeekRank(post, true);
 
        // 通知作者,有人评论了你的文章
        // 作者自己评论自己文章,不需要通知
        if (comment.getUserId() != post.getUserId()) {
            UserMessage message = new UserMessage();
            message.setFromUserId(getProfileId());
            message.setToUserId(post.getUserId());
            message.setPostId(jid);
            message.setCommentId(comment.getId());
            message.setType(1); //我的消息的【消息的类型】:0代表系统消息、1代表评论的文章、2代表回复的评论
            message.setStatus(0); //我的消息的【状态】:0代表未读、1代表已读
            message.setCreated(new Date());
            messageService.save(message);
        }
 
        // 通知被@的人,有人回复了你的文章
        if (content.startsWith("@")) {
            String username = content.substring(1, content.indexOf(" "));
            User user = userService.getOne(new QueryWrapper<User>().eq("username", username));
            if (user != null) {
                UserMessage message = new UserMessage();
                message.setFromUserId(getProfileId());
                message.setToUserId(post.getUserId());
                message.setPostId(jid);
                message.setCommentId(comment.getId());
                message.setType(2); //我的消息的【消息的类型】:0代表系统消息、1代表评论的文章、2代表回复的评论
                message.setStatus(0); //我的消息的【状态】:0代表未读、1代表已读
                message.setCreated(new Date());
                messageService.save(message);
            }
        }
 
        return Result.success().action("/post/" + post.getId());
    }
 
    /**
     * 详情detail:【删除】评论
     */
    @ResponseBody
    @Transactional
    @PostMapping("/post/jieda-delete/")
    public Result reply(Long id) {
        Assert.notNull(id, "评论id不能为空!");
        Comment comment = commentService.getById(id);
        Assert.notNull(comment, "找不到对应评论!");
 
        // 删除评论
        if (comment.getUserId().longValue() != getProfileId().longValue()) {
            return Result.fail("不是你发表的评论!");
        }
        commentService.removeById(id);
 
        // 评论数量-1
        Post post = postService.getById(comment.getPostId());
        post.setCommentCount(post.getCommentCount() - 1);
        postService.saveOrUpdate(post);
 
        // 本周热议数量-1
        postService.incrCommentCountAndUnionForWeekRank(post, false);
 
        return Result.success(null);
    }
}

Part17-集成WeSocket实现用户评论-即时通讯

blog
│  pom.xml

└─src
│  └─main
│      ├─java
│      │   └─org
│      │      └─myslayers
│      │          ├─config
│      │          │      WsConfig.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      PostController.java
│      │          │
│      │          ├─service
│      │          │  │   WsService.java
│      │          │  │
│      │          │  └─impl
│      │          │         WsServiceImpl.java
│      │
│      └─resources
│          ├─templates
│          │  └─inc
│          │         layout.ftl

58 集成 WebSocket 环境

  • pom.xml :项目依赖,【websocket 通讯】
<dependencies>
  <!--websocket-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
  </dependency>
</dependencies>

59 配置 WebSocket 环境

  • WsConfig.java :配置类,【点对点通讯,订阅通道/user/、/topic/,访问地址/websocket】
/**
 * WebSocket 配置类:点对点
 */
@EnableAsync //开启异步消息
@Configuration
@EnableWebSocketMessageBroker //表示开启使用STOMP协议的消息代理
public class WsConfig implements WebSocketMessageBrokerConfigurer {
 
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket") // 注册一个端点:websocket的访问地址
            .withSockJS();
    }
 
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/user/", "/topic/"); //推送消息的前缀
        registry.setApplicationDestinationPrefixes("/app"); //注册代理点
    }
}
  • layout.ftl :模板引擎,【引入 sockjs.js、stomp.js】
<#macro layout title>
  <!DOCTYPE html>
  <html>
  <head>
    <meta charset="utf-8">
    <title>${title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <meta name="keywords" content="fly,layui,前端社区">
    <meta name="description" content="Fly社区是模块化前端UI框架Layui的官网社区,致力于为web开发提供强劲动力">
    <link rel="stylesheet" href="/res/layui/css/layui.css">
    <link rel="stylesheet" href="/res/css/global.css">
 
    <#--导入script-->
    <script src="/res/layui/layui.js"></script>
    <script src="/res/js/jquery.min.js"></script>
    <script src="/res/js/sockjs.js"></script>
    <script src="/res/js/stomp.js"></script>
  </head>
  <body>
</#macro>

60 使用 WebSocket 通讯

  • PostController.java :控制层,【即时通知作者(websocket)】
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail:【评论】文章
     */
    @ResponseBody
    @Transactional
    @PostMapping("/post/reply/")
    public Result reply(Long jid, String content) {
        Assert.notNull(jid, "找不到对应的文章");
        Assert.hasLength(content, "评论内容不能为空");
 
        Post post = postService.getById(jid);
        Assert.isTrue(post != null, "该文章已被删除");
 
        // 新增评论
        Comment comment = new Comment();
        comment.setPostId(jid);
        comment.setContent(content);
        comment.setUserId(getProfileId());
        comment.setCreated(new Date());
        comment.setModified(new Date());
        comment.setLevel(0);
        comment.setVoteDown(0);
        comment.setVoteUp(0);
        commentService.save(comment);
 
        // 评论数量+1
        post.setCommentCount(post.getCommentCount() + 1);
        postService.updateById(post);
 
        // 本周热议数量+1
        postService.incrCommentCountAndUnionForWeekRank(post, true);
 
        // 通知作者,有人评论了你的文章
        // 作者自己评论自己文章,不需要通知
        if (comment.getUserId() != post.getUserId()) {
            UserMessage message = new UserMessage();
            message.setFromUserId(getProfileId());
            message.setToUserId(post.getUserId());
            message.setPostId(jid);
            message.setCommentId(comment.getId());
            message.setType(1); //我的消息的【消息的类型】:0代表系统消息、1代表评论的文章、2代表回复的评论
            message.setStatus(0); //我的消息的【状态】:0代表未读、1代表已读
            message.setCreated(new Date());
            messageService.save(message);
 
            // 即时通知【文章作者】(websocket)
            wsService.sendMessCountToUser(message.getToUserId());
        }
 
        // 通知被@的人,有人回复了你的文章
        if (content.startsWith("@")) {
            String username = content.substring(1, content.indexOf(" "));
            User user = userService.getOne(new QueryWrapper<User>().eq("username", username));
            if (user != null) {
                UserMessage message = new UserMessage();
                message.setFromUserId(getProfileId());
                message.setToUserId(post.getUserId());
                message.setPostId(jid);
                message.setCommentId(comment.getId());
                message.setType(2); //我的消息的【消息的类型】:0代表系统消息、1代表评论的文章、2代表回复的评论
                message.setStatus(0); //我的消息的【状态】:0代表未读、1代表已读
                message.setCreated(new Date());
                messageService.save(message);
 
                // 即时通知【被@的用户】(websocket)
            }
        }
 
        return Result.success().action("/post/" + post.getId());
    }
}
  • WsServiceImpl.java :业务层实现,【使用 Spring 自带的【消息模板】,向 ToUserId 发生消息,url 为 /user/20/messCount/ 】
@Service
public class WsServiceImpl implements WsService {
 
    @Autowired
    UserMessageService messageService;
 
    @Autowired
    SimpMessagingTemplate messagingTemplate;    //Spring自带的【消息模板】
 
    @Async  //异步消息
    @Override
    public void sendMessCountToUser(Long toUserId) {
        int count = messageService.count(new QueryWrapper<UserMessage>()
            .eq("to_user_id", toUserId) //全部数量的消息
            .eq("status", "0")      //未读的消息  未读0 已读1
        );
 
        // websocket 使用 messagingTemplate模板 进行通知,拼凑结果url为:/user/20/messCount/
        // super.convertAndSend(this.destinationPrefix + user + destination, payload, headers, postProcessor);
        messagingTemplate.convertAndSendToUser(toUserId.toString(), "/messCount", count);
    }
}
  • layout.ftl :模板引擎
<#--宏:1.macro定义脚本,名为layout,参数为title-->
<#macro layout title>
  <!DOCTYPE html>
  <html>
  <head>
    <meta charset="utf-8">
    <title>${title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <meta name="keywords" content="fly,layui,前端社区">
    <meta name="description" content="Fly社区是模块化前端UI框架Layui的官网社区,致力于为web开发提供强劲动力">
    <link rel="stylesheet" href="/res/layui/css/layui.css">
    <link rel="stylesheet" href="/res/css/global.css">
 
    <#--导入script-->
    <script src="/res/layui/layui.js"></script>
    <script src="/res/js/jquery.min.js"></script>
    <script src="/res/js/sockjs.js"></script>
    <script src="/res/js/stomp.js"></script>
  </head>
  <body>
 
  <#--宏common.ftl:分页、一条数据posting、个人账户-左侧链接(我的主页、用户中心、基本设置、我的消息)-->
  <#include "/inc/common.ftl" /><#--经过测试,发现common公共包,必须在header.ftl等之前进行“include导入”-->
 
  <#--【一、导航栏】-->
  <#include "/inc/header.ftl"/>
 
  <#--【三、所有引用该“带有宏的标签layout.ftl”都会执行该操作:<@layout "首页"></@layout>中的数据 -> 填充到<#nested/>标签中】-->
  <#nested>
 
  <#--【四、页脚】-->
  <#include "/inc/footer.ftl"/>
 
  <script>
    <#-----------------方式二:利用session来实现【登录状态】---------------------->
    // layui.cache.page = '';
    layui.cache.user = {
      username: '${profile.username!"游客"}'
      , uid: ${profile.id!"-1"}
      , avatar: '${profile.avatar!"/res/images/avatar/00.jpg"}'
      , experience: 83
      , sex: '${profile.gender!"男"}'
    };
 
    layui.config({
      version: "3.0.0"
      , base: '/res/mods/' //这里实际使用时,建议改成绝对路径
    }).extend({
      fly: 'index'
    }).use('fly');
  </script>
 
  <script>
    <#--使用ws实现【评论消息的即时通讯】-->
    $(function () {
      var elemUser = $('.fly-nav-user');
      if (layui.cache.user.uid !== -1 && elemUser[0]) { //根据layui使用,layui.cache.user.uid !== -1 时,表示【用户登录成功】
        var socket = new SockJS("/websocket") //注册一个端点:websocket的访问地址
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function (frame) {
          //subscribe订阅消息
          stompClient.subscribe("/user/" + ${profile.id} + "/messCount", function (res) {
            console.log(res);
            showTips(res.body);   //消息的显示:弹窗
          })
        });
 
      }
    });
 
    //消息的显示:弹窗,【将/res/mods/index.js中的消息弹窗 -> 复制到此处,供ws使用】
    function showTips(count) {
      var msg = $('<a class="fly-nav-msg" href="javascript:;">' + count + '</a>');
      var elemUser = $('.fly-nav-user');
      elemUser.append(msg);
      //click,点击跳转【用户中心 /user/mess】
      msg.on('click', function () {
        location.href = "/user/mess";
      });
      //tips,提示【你有X条未读消息】
      layer.tips('你有 ' + count + ' 条未读消息', msg, {
        tips: 3
        , tipsMore: true
        , fixed: true
      });
      msg.on('mouseenter', function () {
        layer.closeAll('tips');
      })
    }
  </script>
 
  </body>
  </html>
</#macro>

61 其他:用户中心-批量将未读改为已读

  • UserController.java :控制层,【批量处理,将全部消息的【状态:未读 0】改为【状态:已读 1】,并【批量修改 状态为已读 1】】
@Controller
public class UserController extends BaseController {
    /**
     * 我的消息:查询消息
     */
    @GetMapping("/user/mess")
    public String mess() {
        IPage<UserMessageVo> page = messageService.paging(getPage(), new QueryWrapper<UserMessage>()
            .eq("to_user_id", getProfileId())
            .orderByDesc("created")
        );
        req.setAttribute("pageData", page);
 
        //查看消息时,将全部消息的【状态:未读0】改为【状态:已读1】,并【批量修改 状态为已读1】
        List<Long> ids = new ArrayList<>();
        for (UserMessageVo messageVo : page.getRecords()) {
            if (messageVo.getStatus() == 0) {
                ids.add(messageVo.getId());
            }
        }
        messageService.updateToReaded(ids); //批量处理
 
        return "/user/mess";
    }
}
  • UserMessageServiceImpl.java :业务层实现,【批量处理】
@Service
public class UserMessageServiceImpl extends ServiceImpl<UserMessageMapper, UserMessage> implements UserMessageService {
 
    @Override
    public void updateToReaded(List<Long> ids) {
        if (ids.isEmpty()) {
            return;
        }
        messageMapper.updateToReaded(new QueryWrapper<UserMessage>()
            .in("id", ids)
        );
    }
}
  • UserMessageMapper.java :数据层接口,【开启事务】
public interface UserMessageMapper extends BaseMapper<UserMessage> {
 
    @Transactional
    void updateToReaded(@Param(Constants.WRAPPER) QueryWrapper<UserMessage> wrapper);
}
  • UserMessageMapper.xml :数据层实现,【SQL 命令】
<update id="updateToReaded">
  UPDATE m_user_message
  SET status = 1
  ${ew.customSqlSegment}
</update>

Part18-集成Elasticsearch实现文章内容-搜索引擎

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      AdminController.java
│      │          │      IndexController.java
│      │          │
│      │          ├─service
│      │          │  │  SearchService.java
│      │          │  │
│      │          │  └─impl
│      │          │         SearchServiceImpl.java
│      │          ├─search
│      │          │  │
│      │          │  ├─model
│      │          │  │      PostDocment.java
│      │          │  │
│      │          │  └─repository
│      │          │         PostRepository.java
│      │
│      └─resources
│          │  application.yml
│          │
│          ├─templates
│          │  │  search.ftl
│          │  │
│          │  └─user
│          │         set.ftl

62 集成 Elasticsearch 环境

  • pom.xml :项目依赖,【elasticsearch 搜索引擎】
<dependencies>
  <!--elasticsearch-6.4.3:搜索引擎 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    <version>2.1.1.RELEASE</version>
  </dependency>
 
  <!--modelmapper:Model和DTO模型类的转换-->
  <dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>1.1.1</version>
  </dependency>
</dependencies>
  • application.yml :配置文件,【elasticsearch 搜索引擎】
spring:
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 127.0.0.1:9300
      repositories:
        enabled: true
  • Application.java:项目启动,【解决 elasticsearch 启动时,由于底层 netty 版本问题引发的项目启动问题】
@EnableScheduling//开启定时器
@SpringBootApplication
public class Application {
 
    public static void main(String[] args) {
 
        // 解决elasticsearch启动时,由于底层netty版本问题引发的项目启动问题
        System.setProperty("es.set.netty.runtime.available.processors", "false");
 
        SpringApplication.run(Application.class, args);
        System.out.println("http://localhost:8080");
    }
}

63 配置 Elasticsearch 环境

  • PostDocment.java :实体类,【类似 MySQL 表】
/**
 * Elasticsearch:实体类,类似MySQL表
 */
@Data
//indexName代表索引名称,type代表post类型,createIndex代表”启动时,是否创建该文档,默认为true“
@Document(indexName="post", type="post", createIndex=true)
public class PostDocment implements Serializable {
 
    //主键ID
    @Id
    private Long id;
 
    //文章的【标题title】
    //ik分词器:文本Text、最粗粒度的拆分ik_smart、最细粒度的拆分ik_max_word
    @Field(type = FieldType.Text, searchAnalyzer="ik_smart", analyzer = "ik_max_word")
    private String title;
 
    //文章的【作者id】
    @Field(type = FieldType.Long)
    private Long authorId;
 
    //文章的【作者name】
    @Field(type = FieldType.Keyword)
    private String authorName;
 
    //文章的【作者avatar】
    private String authorAvatar;
 
    //文章的【分类id】
    private Long categoryId;
 
    //文章的【分类name】
    @Field(type = FieldType.Keyword)
    private String categoryName;
 
    //文章的【置顶等级】
    private Integer level;
 
    //文章的【精华】
    private Boolean recomment;
 
    //文章的【评论数量】
    private Integer commentCount;
 
    //文章的【访问量】
    private Integer viewCount;
 
    //文章的【创建日期】
    @Field(type = FieldType.Date)
    private Date created;
}
  • PostRepository.java :配置类,【自定义 ElasticsearchRepository】
/**
 * PostRepository:继承ElasticsearchRepository
 */
@Repository
public interface PostRepository extends ElasticsearchRepository<PostDocment, Long> {
    // 符合jpa命名规范的接口
    // ...
}

64 使用 Elasticsearch 搜索引擎-【搜索按钮】

//搜索
$('.fly-search').on('click', function () {
  layer.open({
    type: 1
    ,
    title: false
    ,
    closeBtn: false
    //,shade: [0.1, '#fff']
    ,
    shadeClose: true
    ,
    maxWidth: 10000
    ,
    skin: 'fly-layer-search'
    ,
    content: [
      '<form action="/search">' //将http://cn.bing.com/search 更改为 /search
      ,
      '<input autocomplete="off" placeholder="搜索内容,回车跳转" type="text" name="q">'
      , '</form>'].join('')
    ,
    success: function (layero) {
      var input = layero.find('input');
      input.focus();
 
      layero.find('form').submit(function () {
        var val = input.val();
        if (val.replace(/\s/g, '') === '') {
          return false;
        }
 
        //关闭默认跳转搜索链接,发现跳转接口为“https://cn.bing.com/search?q=xxx”
        // input.val('site:layui.com ' + input.val());
      });
    }
  })
});
  • IndexController.java :控制层,【搜索按钮】
@Controller
public class IndexController extends BaseController {
    /**
     * 搜索 Elasticsearch
     */
    @GetMapping("/search")
    public String search(String q) {
        //使用自定义es的search方法,进行查询
        IPage pageData = searchService.search(getPage(), q);
 
        //关键词:${q}
        req.setAttribute("q", q);
        //搜索结果:${pageData.total}、${pageData.records}
        req.setAttribute("pageData", pageData);
 
        return "search";
    }
}
  • SearchServiceImpl.java :业务层实现,【search 搜索,使用模型映射器进行 MP-page 转换为 JPA-page 转换为 MP-page】
@Service
public class SearchServiceImpl implements SearchService {
 
    @Autowired
    PostRepository postRepository;
 
    /**
     * ES:search 搜索
     */
    @Override
    public IPage search(Page page, String keyword) {
        //1.将MP-page -> 转换为 JPA-page
        Long current = page.getCurrent() - 1;
        Long size = page.getSize();
        Pageable pageable = PageRequest.of(current.intValue(), size.intValue());
 
        //2.使用ES -> 得到 pageData数据
        MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(keyword, "title", "authorName", "categoryName");
        org.springframework.data.domain.Page<PostDocment> docments = postRepository.search(multiMatchQueryBuilder, pageable);
 
        //3.将JPA-page -> 转换为 MP-page
        IPage pageData = new Page(page.getCurrent(), page.getSize(), docments.getTotalElements());
        pageData.setRecords(docments.getContent());
        return pageData;
    }
}
  • search.ftl :目标引擎,【搜索结果后的页面】
<#--宏layout.ftl(导航栏 + 页脚)-->
<#include "/inc/layout.ftl" />
 
<#--【三、填充(导航栏 + 页脚)】-->
<@layout "搜索 - ${q}">
 
<#--【二、分类】-->
    <#include "/inc/header-panel.ftl" />
 
  <div class="layui-container">
      <div class="layui-row layui-col-space15">
 
      <#--1.左侧md8-->
          <div class="layui-col-md8">
              <div class="fly-panel">
 
              <#--1.2.1 共有X条记录-->
                  <div class="fly-panel-title fly-filter">
                      <a>您正在搜索关键字”${q}“,共有 <strong>${pageData.total}</strong> 条记录</a>
                      <a href="#signin" class="layui-hide-sm layui-show-xs-block fly-right" id="LAY_goSignin" style="color: #FF5722;">去签到</a>
                  </div>
 
              <#--1.2.2 消息列表-->
                  <ul class="fly-list">
            <#list pageData.records as post>
              <@plisting post></@plisting>
            </#list>
                  </ul>
 
              <#--1.2.3 分页条-->
                  <div style="text-align: center">
                  <#--待渲染的div块(laypage-main)-->
                      <div id="laypage-main"></div>
 
                  <#--Script渲染div块-->
                      <script src="/res/layui/layui.js"></script>
                      <script>
                          layui.use('laypage', function () {
                              var laypage = layui.laypage;
 
                              //执行一个laypage实例
                              laypage.render({
                                  elem: 'laypage-main'
                                  , count: ${pageData.total}
                                  , curr: ${pageData.current}
                                  , limit: ${pageData.size}
                                  , jump: function (obj, first) {
                                      //首次不执行,之后【跳转curr页面】
                                      if (!first) {
                                          location.href = "?q=" + '${q}' + "&&pn=" + obj.curr;
                                      }
                                  }
                              });
                          });
                      </script>
                  </div>
 
              </div>
          </div>
 
      <#--2.右侧md4-->
      <#include "/inc/right.ftl" />
      </div>
  </div>
</@layout>

65 使用 Elasticsearch 搜索引擎-【管理员-同步ES数据】

  • AdminController.java :超级用户,【只有管理员,才可以同步 ES 数据】
@Controller
public class AdminController extends BaseController {
    /**
     * 管理员操作:同步ES数据
     */
    @ResponseBody
    @PostMapping("admin/initEsData")
    public Result initEsData() {
        //total:索引总记录
        long total = 0;
 
        //从第1页 -> 检索 -> 到第1000页
        for (int i = 1; i < 1000; i++) {
            //current:当前页   size:每页显示1000条数
            Page page = new Page(i, 1000);
 
            //调用【postService层】的selectPosts方法 -> 进行 IPage<PostVo> 查询
            IPage<PostVo> paging = postService.selectPosts(page, null, null, null, null, null);
 
            //调用【searchService层】的initEsData方法 -> 进行 total 统计
            total += searchService.initEsData(paging.getRecords());
 
            //某一次循环的查询过程中,如果【该页查询 小于 1000条】时,说明【该页 已经是 最后一页了】,因此使用break,停止查询
            if (paging.getRecords().size() < 1000) {
                break;
            }
        }
        return Result.success("ES索引初始化成功,共 " + total + " 条记录!", null);
    }
}
  • SearchServiceImpl.java :业务层实现,【initEsData 初始化数据】
@Service
public class SearchServiceImpl implements SearchService {
 
    @Autowired
    PostRepository postRepository;
 
    /**
     * ES:initEsData 初始化数据
     */
    @Override
    public int initEsData(List<PostVo> records) {
        if(records == null || records.isEmpty()) {
            return 0;
        }
 
        //将List<PostVo> -> List<PostDocment>
        List<PostDocment> documents = new ArrayList<>();
        for(PostVo vo : records) {
            //转换操作:将source映射到destinationType的实例,map(Object source, Class<D> destinationType)
            PostDocment postDocment = new ModelMapper().map(vo, PostDocment.class);
            documents.add(postDocment);
        }
        postRepository.saveAll(documents);
        return documents.size();
    }
}
  • set.ftl :模板引擎,【只有管理员,才可以同步 ES 数据】
<#--4.同步ES数据-->
<@shiro.hasRole name="admin">
  <div class="layui-form layui-form-pane layui-tab-item">
   <form action="/admin/initEsData" method="post">
    <button class="layui-btn" key="set-mine" lay-filter="*" lay-submit alert="true">同步ES数据
    </button>
   </form>
  </div>
</@shiro.hasRole>

Part19-集成RabbitMQ保证ES随文章增删改查-实时更新

blog
│  pom.xml

└─src
│  └─main
│      ├─java
│      │   └─org
│      │      └─myslayers
│      │          ├─config
│      │          │      RabbitConfig.java
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      PostController.java
│      │          │
│      │          ├─service
│      │          │  │   SearchService.java
│      │          │  │
│      │          │  └─impl
│      │          │         SearchServiceImpl.java
│      │          │
│      │          └─search
│      │             └─amqp
│      │                    MqMessageHandler.java
│      │                    PostMqIndexMessage.java
│      │
│      └─resources
│          │  application.yml

66 集成 RabbitMQ 环境

  • pom.xml :项目依赖,【RabbitMQ 消息同步】
<dependencies>
  <!--rabbitmq:消息同步-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    <version>2.1.2.RELEASE</version>
  </dependency>
</dependencies>
  • application.yml :配置文件,【RabbitMQ 消息同步】
spring:
  rabbitmq:
    username: guest
    password: guest
    host: 127.0.0.1
    port: 5672

67 配置 RabbitMQ 环境

  • RabbitConfig.java :配置类,【创建队列、交换机,并把它们通过 es_bind_key 进行绑定】
/**
 * RabbitConfig:配置类
 */
@Configuration
public class RabbitConfig {
 
    public final static String es_queue = "es_queue";
    public final static String es_exchage = "es_exchage";
    public final static String es_bind_key = "es_exchage";
 
    //队列
    @Bean
    public Queue exQueue() {
        return new Queue(es_queue);
    }
 
    //交换机
    @Bean
    DirectExchange exchange() {
        return new DirectExchange(es_exchage);
    }
 
    //绑定队列与交换机
    @Bean
    Binding binding(Queue exQueue, DirectExchange exchange) {
        return BindingBuilder.bind(exQueue).to(exchange).with(es_bind_key);
    }
}
  • PostMqIndexMessage.java :实体类,供 【/post/submit、/post/delete】 使用 convertAndSend 【 交换机,路由密钥,发送的消息(操作的文章、操作的类型) 】
/**
 * PostMqIndexMessage:实体类
 * 供 -> 【/post/submit、/post/delete】 -> 使用 convertAndSend 【 交换机,路由密钥,发送的消息(操作的文章、操作的类型) 】
 */
@Data
@AllArgsConstructor
public class PostMqIndexMessage implements Serializable {
 
    // 两种type:一种是create_update、一种是remove
    public final static String CREATE_OR_UPDATE = "create_update";
    public final static String REMOVE = "remove";
 
    // 操作的文章:postId
    private Long postId;
 
    // 操作的类型:增删改查
    private String type;
 
}
  • MqMessageHandler.java :执行类,【执行操作的逻辑】
/**
 * RabbitMQ:执行操作的逻辑
 */
@Slf4j
@Component
@RabbitListener(queues = RabbitConfig.es_queue) //监听的队列es_queue
public class MqMessageHandler {
 
    @Autowired
    SearchService searchService;
 
    @RabbitHandler
    public void handler(org.myslayers.search.amqp.PostMqIndexMessage message) {
 
        log.info("mq 收到一条消息: {}", message.toString());
 
        switch (message.getType()) {
            //类型:创建或更新,【CREATE_OR_UPDATE】
            case PostMqIndexMessage.CREATE_OR_UPDATE:
                searchService.createOrUpdateIndex(message);
                break;
 
            //类型:删除,【REMOVE】
            case PostMqIndexMessage.REMOVE:
                searchService.removeIndex(message);
                break;
 
            //其他类型:输出错误日志
            default:
                log.error("没找到对应的消息类型,请注意!! --》 {}", message.toString());
                break;
        }
    }
}
  • SearchServiceImpl.java :业务层实现,【创建/更新文章】、删除文章
@Slf4j
@Service
public class SearchServiceImpl implements SearchService {
 
    @Autowired
    PostRepository postRepository;
 
    @Autowired
    PostService postService;
 
    /**
     * ES:createOrUpdateIndex 创建/更新文章
     */
    @Override
    public void createOrUpdateIndex(PostMqIndexMessage message) {
        //根据message.getPostId() -> 查询文章
        PostVo postVo = postService.selectOnePost(
            new QueryWrapper<Post>().eq("p.id", message.getPostId())
        );
 
        //将postVo -> PostDocment
        PostDocment postDocment = new ModelMapper().map(postVo, PostDocment.class);
        postRepository.save(postDocment);
 
        log.info("es 索引更新成功! ---> {}", postDocment.toString());
    }
 
    /**
     * ES:removeIndex 删除文章
     */
    @Override
    public void removeIndex(PostMqIndexMessage message) {
        //根据message.getPostId() -> 删除文章
        postRepository.deleteById(message.getPostId());
 
        log.info("es 索引删除成功! ---> {}", message.toString());
    }
}

68 使用 RabbitMQ 保证 ES 随文章增删改查-实时更新

  • PostController.java :控制层,【消息同步,通知消息给 RabbitMQ,告知 ES【更新文章或添加文章】、【删除文章】】
@Controller
public class PostController extends BaseController {
    /**
     * 详情detail:【删除】文章
     */
    @ResponseBody
    @Transactional
    @PostMapping("/post/delete")
    public Result delete(Long id) {
        Post post = postService.getById(id);
        Assert.notNull(post, "该帖子已被删除");
        Assert.isTrue(post.getUserId().longValue() == getProfileId().longValue(), "无权限删除此文章!");
 
        // 删除-该篇文章【该篇文章】-Post
        postService.removeById(id);
        // 删除-我的消息【收到消息】-UserMessage中的post_id
        messageService.removeByMap(MapUtil.of("post_id", id));
        // 删除-用户中心【收藏的帖】-UserCollection中的post_id
        collectionService.removeByMap(MapUtil.of("post_id", id));
 
        // RabbitMQ:消息同步,通知消息给RabbitMQ,告知【更新或添加】
        // convertAndSend 【 交换机,路由密钥,发送的消息(操作的文章、操作的类型) 】
        amqpTemplate.convertAndSend(RabbitConfig.es_exchage, RabbitConfig.es_bind_key, new PostMqIndexMessage(post.getId(), PostMqIndexMessage.REMOVE));
 
        return Result.success().action("/user/index");
    }
 
    /**
     * 添加/编辑edit:【提交】文章
     */
    @ResponseBody
    @PostMapping("/post/submit")
    public Result submit(Post post) {
        // 使用ValidationUtil工具类,校验【输入是否错误】
        ValidationUtil.ValidResult validResult = ValidationUtil.validateBean(post);
        if (validResult.hasErrors()) {
            return Result.fail(validResult.getErrors());
        }
 
        // 在传入【req.setAttribute("post", post);】后,同一页面请求的数据,可以通过post.getId()查询到【id】
        // 如果id不存在,则为【添加-文章】
        if (post.getId() == null) {
            post.setUserId(getProfileId());
            post.setModified(new Date());
            post.setCreated(new Date());
            post.setCommentCount(0);
            post.setEditMode(null);
            post.setLevel(0);
            post.setRecommend(false);
            post.setViewCount(0);
            post.setVoteDown(0);
            post.setVoteUp(0);
            postService.save(post);
        } else {
            // 如果id存在,则为【更新-文章】
            Post tempPost = postService.getById(post.getId());
            Assert.isTrue(tempPost.getUserId().longValue() == getProfileId().longValue(), "无权限编辑此文章!");
            tempPost.setTitle(post.getTitle());
            tempPost.setContent(post.getContent());
            tempPost.setCategoryId(post.getCategoryId());
            postService.updateById(tempPost);
        }
 
        // RabbitMQ:消息同步,通知消息给RabbitMQ,告知【更新或添加】
        // convertAndSend 【 交换机,路由密钥,发送的消息(操作的文章、操作的类型) 】
        amqpTemplate.convertAndSend(RabbitConfig.es_exchage, RabbitConfig.es_bind_key, new PostMqIndexMessage(post.getId(), PostMqIndexMessage.CREATE_OR_UPDATE));
 
        // 无论id是否存在,两类情况都会 retern 跳转到 /post/${id}
        return Result.success().action("/post/" + post.getId());
    }
}

Part20-集成WebSocket-tio实现网络群聊-聊天室

blog
│  pom.xml

├─src
│  └─main
│      ├─java
│      │  └─org
│      │      └─myslayers
│      │          ├─config
│      │          │      ImServerConfig.java  # 执行入口类
│      │          │
│      │          ├─controller
│      │          │      BaseController.java
│      │          │      ChatController.java
│      │          │
│      │          ├─service
│      │          │  │  ChatService.java
│      │          │  │
│      │          │  └─impl
│      │          │          ChatServiceImpl.java
│      │          │
│      │          ├─utils
│      │          │      SpringUtil.java
│      │          │
│      │          └─im
│      │             ├─handler  # 处理【接受字符类型消息:Chat类型、Ping类型】
│      │             │  │  MsgHandler.java
│      │             │  │  MsgHandlerFactory.java
│      │             │  │
│      │             │  ├─filter
│      │             │  │      ExculdeMineChannelContextFilter.java
│      │             │  │
│      │             │  └─impl
│      │             │          ChatMsgHandler.java
│      │             │          PingMsgHandler.java
│      │             │
│      │             ├─message
│      │             │      ChatImMess.java
│      │             │      ChatOutMess.java
│      │             │
│      │             ├─server
│      │             │      ImServerStarter.java  # 1.启动tio服务(绑定端口),并调用-消息处理器
│      │             │      ImWsMsgHandler.java   # 2.判断-消息处理器-类别【接受字符类型消息:Chat类型、Ping类型
│      │             │
│      │             └─vo
│      │                     ImMess.java
│      │                     ImTo.java
│      │                     ImUser.java
│      │
│      └─resources
│          │  application.yml
│          │
│          ├─static
│          │  └─res
│          │      ├─js  # 自己编写 js 文件
│          │      │      chat.js
│          │      │      im.js
│          │      │
│          │      ├─layui   # 引入的 js 文件
│          │         │
│          │         ├─css
│          │         │  │  layui.css
│          │         │  │  layui.mobile.css
│          │         │  │
│          │         │  └─modules
│          │         │      │
│          │         │      └─layim
│          │         │          │  layim.css
│          │         │          │
│          │         │          ├─html
│          │         │          │      chatlog.html
│          │         │          │      find.html
│          │         │          │      getmsg.json
│          │         │          │      msgbox.html
│          │         │          │
│          │         │          ├─mobile
│          │         │          │      layim.css
│          │         │          │
│          │         │          ├─skin
│          │         │          │      1.jpg
│          │         │          │      2.jpg
│          │         │          │      3.jpg
│          │         │          │      4.jpg
│          │         │          │      5.jpg
│          │         │          │      logo.jpg
│          │         │          │
│          │         │          └─voice
│          │         │                  default.mp3
│          │         │                  default.wav
│          │         │
│          │         └─lay
│          │             └─modules
│          │                    │  layim.js
│          │
│          ├─templates
│          │  └─inc
│          │         layout.ftl

69 集成 WebSocket-tio 环境

  • pom.xml :项目依赖,【tio:网络群聊】
<dependencies>
  <!--websocket-tio:网络群聊-->
  <!--参考:https://www.layui.com/layim/-->
  <dependency>
    <groupId>org.t-io</groupId>
    <artifactId>tio-websocket-server</artifactId>
    <version>3.2.5.v20190101-RELEASE</version>
  </dependency>
</dependencies>
  • static/res/layui/lay/modules/layim.js :js文件,【拷贝 layim.js static/res/layui/lay/modules/
  • static/res/layui/css/modules/layim/… :css文件,【拷贝 layim/… static/res/layui/css/modules/

70 配置 WebSocket-tio 环境

  • application.yml :配置文件
im:
  server:
    port: 9326
  • ImServerConfig.java :配置类,【执行入口类】
/**
 * 执行入口类 -> 1.启动tio服务(绑定端口),并调用-消息处理器
 *          -> 2.判断-消息处理器-类别【接受字符类型消息:Chat类型、Ping类型】
 */
@Slf4j
@Configuration
public class ImServerConfig {
 
    @Value("${im.server.port}")
    private int imPort;
 
    @Bean
    ImServerStarter imServerStarter() {
        try {
            // 启动tio服务(绑定端口)
            ImServerStarter serverStarter = new ImServerStarter(imPort);
            serverStarter.start();
 
            // 初始化消息处理器类别
            MsgHandlerFactory.init();
            return serverStarter;
        } catch (IOException e) {
            log.error("tio server 启动失败", e);
        }
 
        return null;
    }
}
  • ImServerStarter.java :配置类,【1.启动tio服务(绑定端口),并调用-消息处理器】
/**
 * 1.启动tio服务(绑定端口),并调用-消息处理器
 */
@Slf4j
public class ImServerStarter {
 
    //返回全局变量:使用websocket-tio包中的ImServerStarter【org.tio.websocket.server.WsServerStarter】
    private WsServerStarter starter;
 
    /**
     * 构造方法:启动tio服务(绑定端口)
     */
    public ImServerStarter(int port) throws IOException {
        //调用【消息处理器】
        IWsMsgHandler handler = new ImWsMsgHandler();
        starter = new WsServerStarter(port, handler);
 
        //可选【在上下文对象中,设置心跳时间】
        ServerGroupContext serverGroupContext = starter.getServerGroupContext();
        serverGroupContext.setHeartbeatTimeout(50000);
    }
 
    /**
     * 初始化消息处理器类别
     */
    public void start() throws IOException {
        starter.start();
        log.info("tio server start !!");
    }
}
  • ImWsMsgHandler.java :配置类,【2.判断-消息处理器-类别【接受字符类型消息:Chat类型、Ping类型】】
/**
 * 2.判断-消息处理器-类别【接受字符类型消息:Chat类型、Ping类型】
 */
@Slf4j
public class ImWsMsgHandler implements IWsMsgHandler {
 
    /**
     * 握手时候走的方法
     */
    @Override
    public HttpResponse handshake(HttpRequest httpRequest, HttpResponse httpResponse, ChannelContext channelContext) throws Exception {
 
        // 绑定个人通道
        String userId = httpRequest.getParam("userId");
        log.info("{} --------------> 正在握手!", userId);
        Tio.bindUser(channelContext, userId);
 
        return httpResponse;
    }
 
    /**
     * 握手完成之后
     */
    @Override
    public void onAfterHandshaked(HttpRequest httpRequest, HttpResponse httpResponse, ChannelContext channelContext) throws Exception {
 
        // 绑定群聊通道,群名称叫做:e-group-study
        Tio.bindGroup(channelContext, Consts.IM_GROUP_NAME);
        log.info("{} --------------> 已绑定群!", channelContext.getId());
 
    }
 
    /**
     * 接受字节类型消息
     */
    @Override
    public Object onBytes(WsRequest wsRequest, byte[] bytes, ChannelContext channelContext) throws Exception {
        return null;
    }
 
    /**
     * 接受字符类型消息
     */
    @Override
    public Object onText(WsRequest wsRequest, String text, ChannelContext channelContext) throws Exception {
        if(text != null && text.indexOf("ping") < 0) {
            log.info("接收到信息——————————————————>{}", text);
        }
 
        Map map = JSONUtil.toBean(text, Map.class);
        String type = MapUtil.getStr(map, "type");
        String data = MapUtil.getStr(map, "data");
 
        //处理消息
        MsgHandler handler = MsgHandlerFactory.getMsgHandler(type);
        handler.handler(data, wsRequest, channelContext);
        return null;
    }
 
    /**
     * 链接关闭时候方法
     */
    @Override
    public Object onClose(WsRequest wsRequest, byte[] bytes, ChannelContext channelContext) throws Exception {
 
        return null;
    }
}

71 使用 WebSocket-tio 环境

  • ChatController.java :控制层
@RestController
@RequestMapping("/chat")
public class ChatController extends BaseController {
 
    @GetMapping("/getMineAndGroupData")
    public Result getMineAndGroupData() {
        //默认群
        Map<String, Object> group = new HashMap<>();
        group.put("name", "社区群聊");
        group.put("type", "group");
        group.put("avatar", "http://tp1.sinaimg.cn/5619439268/180/40030060651/1");
        group.put("id", Consts.IM_GROUP_ID);
        group.put("members", 0);
 
        ImUser user = chatService.getCurrentUser();
        return Result.success(MapUtil.builder()
                .put("group", group)
                .put("mine", user)
                .map());
    }
 
    @GetMapping("/getGroupHistoryMsg")
    public Result getGroupHistoryMsg() {
 
        List<Object> messages = chatService.getGroupHistoryMsg(20);
        return Result.success(messages);
    }
}
  • ChatService.java :业务层接口
public interface ChatService {
    ImUser getCurrentUser();
 
    void setGroupHistoryMsg(ImMess responseMess);
 
    List<Object> getGroupHistoryMsg(int count);
}
  • ChatServiceImpl.java :业务层实现
@Slf4j
@Service("chatService")
public class ChatServiceImpl implements ChatService {
 
    @Autowired
    RedisUtil redisUtil;
 
    @Override
    public ImUser getCurrentUser() {
        AccountProfile profile = (AccountProfile) SecurityUtils.getSubject().getPrincipal();
 
        ImUser user = new ImUser();
 
        if(profile != null) {
            user.setId(profile.getId());
            user.setAvatar(profile.getAvatar());
            user.setUsername(profile.getUsername());
            user.setStatus(ImUser.ONLINE_STATUS);
 
        } else {
            user.setAvatar("http://tp1.sinaimg.cn/5619439268/180/40030060651/1");
 
            // 匿名用户处理
            Long imUserId = (Long) SecurityUtils.getSubject().getSession().getAttribute("imUserId");
            user.setId(imUserId != null ? imUserId : RandomUtil.randomLong());
 
            SecurityUtils.getSubject().getSession().setAttribute("imUserId", user.getId());
 
            user.setSign("never give up!");
            user.setUsername("匿名用户");
            user.setStatus(ImUser.ONLINE_STATUS);
        }
 
        return user;
    }
 
    @Override
    public void setGroupHistoryMsg(ImMess imMess) {
        redisUtil.lSet(Consts.IM_GROUP_HISTROY_MSG_KEY, imMess, 24 * 60 * 60);
    }
 
    @Override
    public List<Object> getGroupHistoryMsg(int count) {
        long length = redisUtil.lGetListSize(Consts.IM_GROUP_HISTROY_MSG_KEY);
        return redisUtil.lGet(Consts.IM_GROUP_HISTROY_MSG_KEY, length - count < 0 ? 0 : length - count, length);
    }
}

72 编写 chat.js、im.js 文件

  • chat.js :js文件,【layim 聊天窗口(chat.js 调用 im.js 方法)】
/**
 * layim 聊天窗口(chat.js 调用 im.js 方法)
 */
layui.use('layim', function (layim) {
 
  var $ = layui.jquery;
  layim.config({
    brief: true //是否简约模式(如果true则不显示主面板)
    , voice: false
    , chatLog: layui.cache.dir + 'css/modules/layim/html/chatlog.html'
  });
 
  var tiows = new tio.ws($, layim);
 
  //1.【获取个人、群聊信息】 + 【打开聊天窗口】
  tiows.openChatWindow();
 
  //2.【查看历史聊天记录 - 回显】
  tiows.initHistoryMess();
 
  //3.【使用websocket建立连接】
  tiows.connect();
 
  //4.【发送消息】
  layim.on('sendMessage', function (res) {
    tiows.sendChatMessage(res);
  });
});
 
  • im.js :js文件,【layim 聊天窗口(chat.js 调用 im.js 方法)】
/**
 * layim 聊天窗口(chat.js 调用 im.js 方法)
 */
if (typeof (tio) == "undefined") {
  tio = {};
}
tio.ws = {};
tio.ws = function ($, layim) {
 
  this.heartbeatTimeout = 5000; // 心跳超时时间,单位:毫秒
  this.heartbeatSendInterval = this.heartbeatTimeout / 2;
  var self = this;
 
  //【使用websocket建立连接】
  this.connect = function () {
    var url = "ws://127.0.0.1:9326?userId=" + self.userId;
    var socket = new WebSocket(url);
 
    self.socket = socket;
 
    socket.onopen = function () {
      console.log("tio ws 启动~");
 
      self.lastInteractionTime(new Date().getTime());
 
      //建立心跳
      self.ping();
    };
 
    socket.onclose = function () {
      console.log("tio ws 关闭~");
 
      //尝试重连
      self.reconn();
    }
    socket.onmessage = function (res) {
      console.log("接收到消息!!")
      console.log(res)
 
      var msgBody = eval('(' + res.data + ')');
      if (msgBody.emit === 'chatMessage') {
        layim.getMessage(msgBody.data);
      }
 
      self.lastInteractionTime(new Date().getTime());
    }
  };
 
  //【获取个人、群聊信息】 + 【打开聊天窗口】
  this.openChatWindow = function () {
    // 获取个人信息
    $.ajax({
      url: "/chat/getMineAndGroupData",
      async: false,
      success: function (res) {
        self.group = res.data.group;
        self.mine = res.data.mine;
        self.userId = self.mine.id;
      }
    });
 
    console.log(self.group);
    console.log(self.mine);
    var cache = layui.layim.cache();
    cache.mine = self.mine;
 
    // 打开窗口
    layim.chat(self.group);
    layim.setChatMin(); //收缩聊天面板
  };
 
  //【发送消息】
  this.sendChatMessage = function (res) {
    self.socket.send(JSON.stringify({
      type: 'chatMessage'
      ,data: res
    }));
  }
 
  //【查看历史聊天记录 - 回显】
  this.initHistoryMess = function () {
    localStorage.clear();
    $.ajax({
      url: '/chat/getGroupHistoryMsg',
      success: function (res) {
        var data = res.data;
        if(data.length < 1) {
          return;
        }
 
        for (var i in data){
          layim.getMessage(data[i]);
        }
      }
    });
  }
 
  //【最后的交互时间】
  this.lastInteractionTime = function () {
    // debugger;
    if (arguments.length == 1) {
      this.lastInteractionTimeValue = arguments[0]
    }
    return this.lastInteractionTimeValue
  }
 
  //【建立心跳】
  this.ping = function () {
    console.log("------------->准备心跳中~");
 
    //建立一个定时器,定时心跳
    self.pingIntervalId = setInterval(function () {
      var iv = new Date().getTime() - self.lastInteractionTime(); // 已经多久没发消息了
 
      // debugger;
 
      // 单位:秒
      if ((self.heartbeatSendInterval + iv) >= self.heartbeatTimeout) {
        self.socket.send(JSON.stringify({
          type: 'pingMessage'
          , data: 'ping'
        }))
        console.log("------------->心跳中~")
      }
    }, self.heartbeatSendInterval)
  };
 
  //【尝试重连:心跳机制、重连机制】
  this.reconn = function () {
    // 先删除心跳定时器
    clearInterval(self.pingIntervalId);
    // 然后尝试重连
    self.connect();
  };
}

73 使用 chat.js、im.js 文件

  • layout.ftl :模板引擎,【引入 im.js、chat.js】
<#macro layout title>
  <!DOCTYPE html>
  <html>
  <head>
    <meta charset="utf-8">
    <title>${title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <meta name="keywords" content="fly,layui,前端社区">
    <meta name="description" content="Fly社区是模块化前端UI框架Layui的官网社区,致力于为web开发提供强劲动力">
    <link rel="stylesheet" href="/res/layui/css/layui.css">
    <link rel="stylesheet" href="/res/css/global.css">
 
    <#--导入顺序:im.js 一定要在 chat.js 前-->
    <script src="/res/layui/layui.js"></script>
    <script src="/res/js/jquery.min.js"></script>
    <script src="/res/js/sockjs.js"></script>
    <script src="/res/js/stomp.js"></script>
    <script src="/res/js/im.js"></script>
    <script src="/res/js/chat.js"></script>
  </head>
  <body>
</#macro>