(eblog)3、博客分类填充、登录注册逻辑

小助手 1年前 ⋅ 2191 阅读

这一次作业我们来完善一下首页中的内容,比如我们的首页文章列表,首页导航分类,分类列表,文章详情。

同时,上一期的作业中我写了不少bug,然后我又偷偷改了很多,都是比较细的,我可能不会全部都写出来,大家如果不知道我改了哪里,有两个办法:

  • 1、看git的提交记录,点击一下文件就有对比出来

图片

  • 2、运行我的项目和你的项目,链接统一数据库,判断页面的内容显示和功能是否一致,不一致说明我已经偷偷改了一些不为人知的bug了。

1、首页内容填充

列表分页

图片

这里说的列表分页讲得是首页的内容列表,可以看到列表内容每一行的内容其实和置顶的列表是一致的,所以原来的sql我们是可以再应用。包括后面我们点击具体导航分类的列表也是一致的。内容一致,我们就可以想到,首先前端的列表我们可以单独提出来作为一个模板,这样所有的地方都只修改一次,可以控制所有的地方了。

然后后端的处理方式有两种:

  • 1、延用我们freemarker标签的方式
  • 2、使用controller中传送数据到前端

标签的方式我们之前已经学习过了,那么我们这次在controller中再提交数据到前端。

首先来看下首页的controller

@RequestMapping({"", "/", "/index"})
public String index () {

    IPage results = postService.paging(getPage(), null, null, null, null, "created");
    req.setAttribute("pageData", results);
    return "index";
}

上面的postService.paging就是我们之前写过的,只不过参数又多了几个,我偷偷改的。给你看看最新的版本吧

@Override
@Cacheable(cacheNames = "cache_post", key = "'page_' + #page.current + '_' + #page.size " +
        "+ '_query_' +#userId  + '_' + #categoryId + '_' + #level  + '_' + #recommend  + '_' + #order")
public IPage paging(Page page, Long userId, Long categoryId, Integer level, Boolean recommend, String order) {

    if(level == null) level = -1;

    QueryWrapper wrapper = new QueryWrapper<Post>()
            .eq(userId != null, "user_id", userId)
            .eq(categoryId != null && categoryId != 0, "category_id", categoryId)
            .gt(level > 0, "level", 0)
            .eq(level == 0, "level", 0)
            .eq(recommend != null, "recommend", recommend)
            .orderByDesc(order);

    IPage<PostVo> pageData = postMapper.selectPosts(page, wrapper);

    return pageData;
}

其实就是加多了几个参数,为了应付更多的场景。 回到刚才说的index方法,有个getPage()我写在了BaseController,这是分页数据的获取封装,获取前端的分页信息,然后封装成poge对象。然后给一下参数默认值。

public Page getPage() {
    int pn =  ServletRequestUtils.getIntParameter(req, "pn", 1);
    int size =  ServletRequestUtils.getIntParameter(req, "size", 10);
    Page page = new Page(pn, size);
    return page;
}

然后可以看到index中,我传了个pageData对象到前端,我们再看看前端。

<ul class="fly-list">
    <#list pageData.records as post>
        <@listing post></@listing>
    </#list>
</ul>

找找中间的内容部分,然后写成了上面那样,因为我把记录列表封装成了宏(macro) 图片

具体的内容就是这样

<#macro listing post>
<li>
    <a href="${base}/user/${post.authorId}" class="fly-avatar">
        <img src="${post.authorAvatar}" alt="${post.authorName}">
    </a>
    <h2>
        <a class="layui-badge">${post.categoryName}</a>
        <a href="${base}/post/${post.id}">${post.title}</a>
    </h2>
    <div class="fly-list-info">
        <a href="${base}/user/${post.authorId}" link>
            <cite>${post.authorName}</cite>
            <i class="layui-badge fly-badge-vip">VIP${post.authorVip}</i>
        </a>
        <span>${post.created?string('yyyy-MM-dd')}</span>
        <span class="fly-list-nums">
                <i class="iconfont icon-pinglun1" title="回答"></i> ${post.commentCount}
              </span>
    </div>
    <div class="fly-list-badge">
         <#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>
</li>
</#macro>

关于freemarker标签macro的用法,不懂的就去百度一下啦,貌似之前我们说过是不是?忘了~

<#macro listing post>...</#macro>

代表定义了一个macro名词叫listing,参数是post,标签内容就是宏的内容。需要调用这个宏的地方直接使用标签就可以,所以你就看到了我刚才的写法。

图片

好了,列表是循环出来了,但是有个问题还没解决,就是分页问题,前端中我们需要一个分页的导航给我们点击页数。二期作业中我们使用的是一个其他插件,这次我们直接用layui的分页插件,还是挺简单的。因为分页的这个页码还是很多页码需要用到的,所以我又把分页的内容搞了一个宏,然后参考一下layui的分页写法https://www.layui.com/demo/laypage.html

<#--分页模板-->
<#macro page data>

<div id="laypage-main"></div>


    $(function () {

        layui.use(['laypage', 'layer'], function(){
            var laypage = layui.laypage
                    ,layer = layui.layer;

            //总页数大于页码总数
            laypage.render({
                elem: 'laypage-main'
                ,count: ${data.total} //数据总数
                ,curr: ${data.current}
                ,limit: ${data.size}
                ,jump: function(obj, first){
                    console.log(obj)

                    //首次不执行
                    if(!first){
                        var url = window.location.href;
                        location.href = "?pn=" + obj.curr;
                    }
                }
            });
        });
    });


</#macro>

上面的js还是比较简单的,就调用了layui的一个laypage.render就可以吧页码给渲染出来了,我们在需要的地方调用一下代码

<div style="text-align: center">
    <@page pageData></@page>
</div>

pageData是controller传过来的数据,渲染效果如下:

图片

简直完美,我真是个天才,人见人爱,花见花开~

导航分类

接下来我们来完善一下导航分类信息,这个比较简单,我们有个表专门存储分类信息的,只需要把列表获取出来就是了(id,name)不需要关联其他表,那么mybatis plus可以直接帮我搞定,不用我写service了,那我来想应该再那里传送数据过去呢,首页index?导航分类是所有的地方都用到的,所以不合适,这时候定义一个freemarker标签是个好办法。

但这里我们没用采用标签方式,我是吧数据放在了全局应用上下文Context中了,这样初始化项目时候我们就加载数据,和我们之前初始化本周热议有点类似,所以我们直接在那个启动类中添加我们的代码

图片

ok,2行代码,绝不写多,有些人可能有个status控制分类的展示,可以做个条件。

currentCategoryId是为了回显当前选择的分类,默认为0(首页)

再看前端,就是展示数据:

<li class="${(0 == currentCategoryId)?string('layui-hide-xs layui-this', '')}">
    <a href="/">首页</a>
</li>

<#list categorys as category>
    <li class="${(category.id == currentCategoryId)?string('layui-hide-xs layui-this', '')}">
        <a href="${base}/category/${category.id}">${category.name}</a>
    </li>
</#list>

emm~,注意freemarker的二元写法,其他的简单~

分类详情

点击导航分类之后,我们跳转到http://localhost:8080/category/1,内容又和我们的首页列表有点像了,com.example.controller.PostController中

@RequestMapping("/category/{id:\\d*}")
public String category(@PathVariable Long id) {
    Page page = getPage();

    IPage<PostVo> pageData = postService.paging(page, null, id, null, null, "created");
    req.setAttribute("pageData", pageData);
    req.setAttribute("currentCategoryId", id);

    return "post/category";
}

currentCategoryId是为了回显我当前选择的栏目。

  • templates/post/category.ftl
<ul class="fly-list">
  <#list pageData.records as post>
    <@listing post></@listing>
  </#list>
</ul>

<!-- <div class="fly-none">没有相关数据</div> -->

<div style="text-align: center">
  <@page pageData></@page>
</div>

博客详情

好了,接下来我们看博客详情,点击列表之后跳转到的页面,展示博客内容和评论列表等信息。

  • com.example.controller.PostController
@RequestMapping("/post/{id:\\d*}")
public String view(@PathVariable Long id) {
    QueryWrapper wrapper = new QueryWrapper<Post>()
            .eq(id != null, "p.id", id);
    PostVo vo = postService.selectOne(wrapper);
    IPage commentPage = commentService.paging(getPage(), null, id, "id");

    req.setAttribute("post", vo);
    req.setAttribute("pageData", commentPage);
    return "post/view";
}

上面我写了两个service方法

  • postService.selectOne

其中selectOne的方法的sql其实元原来post的selectPosts是一样的,只是返回的一个是分页,一个bean,参数没有page对象。

<select id="selectOne" resultType="com.example.vo.PostVo">
    select p.*
    , c.id as categoryId, c.name as categoryName
    , u.id as authorId, u.username as authorName, u.avatar as authorAvatar, u.vip_level as authorVip
    from post p
    left join user u on p.user_id = u.id
    left join category c on p.category_id = c.id

    ${ew.customSqlSegment}

commentService.paging

这个方法我写了一个commentVo用于传输数据,vo中添加一下关联的信息,比如用户名等

<select id="selectComments" resultType="com.example.vo.CommentVo">
    select c.*
    , u.id as authorId, u.username as authorName, u.avatar as authorAvatar, u.vip_level as authorVip
    from comment c
    left join user u on c.user_id = u.id

    ${ew.customSqlSegment}
</select>

前端的话就简单了,就list循环展示数据就行了

<#list pageData.records as comment>
...
</#list>
<!--分页-->
<div style="text-align: center">
    <@page pageData></@page>
</div>

具体看我们的代码了,这里就没必要贴出来了。 好了,数据的展示就先到这里~

2、用户状态

上面我们完成了数据的展示,数据的编辑我们需要用到登录用户的权限才行,所以在编辑之前我们先来做下用户的登录认证问题,这里我们使用shiro框架来完成。

关于登录模块,我们先来梳理一下逻辑,首先是把登录注册的页面复制进来,然后改成模板形式(头和尾,侧边栏等),再然后集成shiro框架,写登录注册接口,login -> realm(认证)-> 写登录注册逻辑->页面的shiro标签->分布式session的相关配置,然后

登录逻辑

  • com.example.controller.IndexController
@GetMapping("/login")
public String login() {
    return "auth/login";
}

@ResponseBody
@PostMapping("/login")
public Result doLogin(String email, String password) {

    if(StringUtils.isEmpty(email) || StringUtils.isEmpty(password)) {
        return Result.fail("用户名或密码不能为空!");
    }
    AuthenticationToken token = new UsernamePasswordToken(email, SecureUtil.md5(password));

    try {

        //尝试登陆,将会调用realm的认证方法
        SecurityUtils.getSubject().login(token);

    }catch (AuthenticationException e) {
        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.succ("登录成功", null, "/");
}

@GetMapping("/register")
public String register() {
    return "auth/register";
}

@ResponseBody
@PostMapping("/register")
public Result doRegister(User user, String captcha, String repass) {

    String kaptcha = (String) SecurityUtils.getSubject().getSession().getAttribute(KAPTCHA_SESSION_KEY);
    if(!kaptcha.equalsIgnoreCase(captcha)) {
        return Result.fail("验证码不正确");
    }

    if(repass == null || !repass.equals(user.getPassword())) {
        return Result.fail("两次输入密码不一致");
    }

    Result result = userService.register(user);
    result.setAction("/login"); // 注册成功之后跳转的页面
    return result;
}

@GetMapping("/logout")
public String logout() throws IOException {
    SecurityUtils.getSubject().logout();
    return "redirect:/";
}

上面的代码,首先分别写了一下login和register的get和post的方式,一个是跳转到login,然后我们通过异步的post方式来提交form表单数据,login的主要逻辑很简单,主要就一行代码:

SecurityUtils.getSubject().login(token);
根据我们对shiro的理解,login之后会最终委托给realm完成登录逻辑的认证,那么我们先来看看realm的内容(doGetAuthenticationInfo)
@Slf4j
@Component
public class AccountRealm extends AuthorizingRealm {

    @Autowired
    UserService userService;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {

        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken token = (UsernamePasswordToken)authenticationToken;

        //注意token.getUsername()是指email!!
        AccountProfile profile = userService.login(token.getUsername(), String.valueOf(token.getPassword()));

        log.info("---------------->进入认证步骤");

        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(profile, token.getCredentials(), getName());
        return info;
    }
}

doGetAuthenticationInfo就是我们认证的方法,authenticationToken就是我们的传过来的UsernamePasswordToken ,包含着邮箱和密码。然后userService.login的内容就是校验一下账户的合法性,不合法就抛出对应的异常,合法最终就返回封装对象AccountProfile

@Override
public AccountProfile login(String username, String password) {
    log.info("------------>进入用户登录判断,获取用户信息步骤");

    User user = this.getOne(new QueryWrapper<User>().eq("email", username));
    if(user == null) {
        throw new UnknownAccountException("账户不存在");
    }

    if(!user.getPassword().equals(password)) {
        throw new IncorrectCredentialsException("密码错误");
    }

    //更新最后登录时间
    user.setLasted(new Date());
    this.updateById(user);

    AccountProfile profile = new AccountProfile();

    BeanUtil.copyProperties(user, profile);

    return profile;
}

ok,登录逻辑已经梳理完毕,等下页面我们再弄,再来弄下注册逻辑。

注册逻辑

注册过程设计到一个验证码校验的插件,这里我们使用google的验证码生成器kaptcha。

先来整合一下,首先导入jar包

<!--验证码-->
<dependency>
    <groupId>com.github.axet</groupId>
    <artifactId>kaptcha</artifactId>
    <version>0.0.9</version>
</dependency>

然后配置一下验证码的图片生成规则:(边框、颜色、字体大小、长、高等)

@Configuration
public class WebMvcConfig {

    @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;
    }
}

好了,插件我们已经集成完毕,接下来我们提供一个访问的接口用于生成验证码图片 首先注入插件

@Autowired
private Producer producer;
然后com.example.controller.IndexController中
@GetMapping("/capthca.jpg")
public void captcha(HttpServletResponse response) throws IOException {
    response.setHeader("Cache-Control", "no-store, no-cache");
    response.setContentType("image/jpeg");

    //生成文字验证码
    String text = producer.createText();
    //生成图片验证码
    BufferedImage image = producer.createImage(text);
    //把验证码存到shrio的session中
    SecurityUtils.getSubject().getSession().setAttribute(KAPTCHA_SESSION_KEY, text);

    ServletOutputStream outputStream = response.getOutputStream();
    ImageIO.write(image, "jpg", outputStream);
}

所以访问这个接口就能得到验证码图片流,页面中:

<img id="capthca" src="/capthca.jpg">

那么流是接通前端后端的,到后端还需要验证验证码的正确性,所以生成验证码的时候我们需要把验证码先存到session中,然后注册接口中再从session中获取出来然后比较是否正确。

@ResponseBody
@PostMapping("/register")
public Result doRegister(User user, String captcha, String repass) {

    String kaptcha = (String) SecurityUtils.getSubject().getSession().getAttribute(KAPTCHA_SESSION_KEY);
    if(!kaptcha.equalsIgnoreCase(captcha)) {
        return Result.fail("验证码不正确");
    }
    ...
    
    return result;
}

所以注册接口的第一件事就是校验验证码是否正确。

Result result = userService.register(user);

我们看下里面的逻辑

@Override
public Result register(User user) {
    if(StringUtils.isEmpty(user.getEmail()) || StringUtils.isEmpty(user.getPassword())
            || StringUtils.isEmpty(user.getUsername())) {
        return Result.fail("必要字段不能为空");
    }

    User po = this.getOne(new QueryWrapper<User>().eq("email", user.getEmail()));
    if(po != null) {
        return Result.fail("邮箱已被注册");
    }

    String passMd5 = SecureUtil.md5(user.getPassword());

    po = new User();
    po.setEmail(user.getEmail());
    po.setPassword(passMd5);
    po.setCreated(new Date());
    po.setUsername(user.getUsername());
    po.setAvatar("/res/images/avatar/default.png");
    po.setPoint(0);

    return this.save(po)? Result.succ("") : Result.fail("注册失败");
}

其实就是校验一下用户是否已经注册了,没注册就插入一条记录,这里的密码我只搞了md5加密,如果觉得密码的加密不够严谨,可以加盐,或者换其他加密方式。 ok,这里后端的注册逻辑我们已经弄完,接下来我们来看下前端。layui已经帮我们封装好了form表单的提交逻辑

图片

所以返回值中属性要有action、status、msg等。所以我们之前封装的Result类现在需要修改一下,以前我们Result只有code、data、msg,现在加多一个action和status。

  • com.example.common.lang.Result
@Data
public class Result implements Serializable {

    private Integer code;
    private Integer status;
    private String msg;
    private Object data;
    private String action;
    ... 
}

上面就是我们最新的返回的封装类,具体还有点封装方法要看看具体代码哈。 所以注册方法的放回值最后是这样的

Result result = userService.register(user);
result.setAction("/login"); // 注册成功之后跳转的页面
return result;

action表示form处理成功之后跳转的链接。图片

上面可以看到,我点击了操作成功的确定按钮之后调到了登录页面,就是我这个action这里设置的。

刚才我们已经完成了业务层面的逻辑,现在我们来看下页面端的。原本layui的后台界面已经帮我们完成页面逻辑。其实也没什么逻辑,form表单对应好字段之后,我们知道js中已经有了监测所有form表单的提交按钮,会触发一下方法:

  • static/res/mods/index.js
//表单提交
form.on('submit(*)', function(data){
  var action = $(data.form).attr('action'), button = $(data.elem);
  fly.json(action, data.field, function(res){
    var end = function(){
      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;
});

所以,在注册页面,我们不需要写啥js,可以给图片验证码一个点击事件,因为有时候看不清楚可以点击换一张

  • templates/auth/register.ftl

    $(function () {
        $("#capthca").click(function () {
            this.src="/capthca.jpg";
        });
    });

登录页面中我们也不需要写啥js。搞定! 以上就是我们的注册逻辑。

shiro页面标签

下面我们在前端用上shiro的一些标签,这样在页面中我们才能控制按钮的权限、用户的登录状态、用户信息等。因为我们页面用的是freemarker,所以我们用一个freemarker-shiro的jar包

<dependency>
    <groupId>net.mingsoft</groupId>
    <artifactId>shiro-freemarker-tags</artifactId>
    <version>0.1</version>
</dependency>

第二步,需要把shiro的标签注入到freemarker的标签配置中:

  • com.example.config.FreemarkerConfig

图片

第三步,我们在页面的右上角中展示用户登录后的信息

图片

依稀记得,我们的的头部的内容是放在

  • templates/inc/header.ftl

那么shiro的标签如何用呢?具体的用法,大家看看这篇文章科普一下

<@shiro.guest>

<@shiro.user>

<@**shiro.principal property="username" **/>

所以学会shiro的标签之后,那么我们就可以用了

<ul class="layui-nav fly-nav-user">
    <@shiro.guest>
    <!-- 未登入的状态 -->
    <li class="layui-nav-item">
        <a class="iconfont icon-touxiang layui-hide-xs" href="/login"></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>
    <!-- 登入后的状态 -->
    <li class="layui-nav-item">
      <a class="fly-nav-avatar" href=";">
        <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">VIP<@shiro.principal property="vipLevel" /></i>
        <img src="<@shiro.principal property="avatar" />">
      </a>
      <dl class="layui-nav-child">
        <dd><a href="user/set.html"><i class="layui-icon"></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;"></i>我的主页</a></dd>
        <hr style="margin: 5px 0;">
        <dd><a href="/logout" style="text-align: center;">退出</a></dd>
      </dl>
    </li>
    </@shiro.user>
</ul>

上面就是通过**<@shiro.guest>和<@shiro.user>**两个标签来辨别用户是否已经登录了。 这样登录前,我们看到的是登录注册按钮,登录之后看到的是用户的用户名称,头像等~

好了,上面shiro的标签我们已经搞定~

今天的作业就先到这里哈,大家先做好登录注册功能。


全部评论: 0

    我有话说: