(eblog)6、博客分类填充、登录注册逻辑
小Hub领读:
继续我们的eblog,今天来完成博客分类的展示,还有登录注册!
项目名称:eblog
项目 Git 仓库:https://github.com/MarkerHub/eblog(给个 star 支持哈)
项目演示地址:https://markerhub.com:8082
前几篇项目讲解文章:
1、(eblog)Github 上最值得学习的 Springboot 开源博客项目!
2、(eblog)小 Hub 手把手教你如何从 0 搭建一个开源项目架构
3、(eblog)整合Redis,以及项目优雅的异常处理与返回结果封装
4、(eblog)用Redis的zset有序集合实现一个本周热议功能
5、(eblog)自定义Freemaker标签实现博客首页数据填充
这一次作业我们来完善一下首页中的内容,比如我们的首页文章列表,首页导航分类,分类列表,文章详情。
同时,上一期的作业中我写了不少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>
<script type="application/javascript">
$(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;
}
}
});
});
});
</script>
</#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
<script>
$(function () {
$("#capthca").click(function () {
this.src="/capthca.jpg";
});
});
</script>
登录页面中我们也不需要写啥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的标签如何用呢?具体的用法,大家看看这篇文章科普一下
https://www.cnblogs.com/Jimc/p/10031094.html
<@*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="javascript:;">
<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的标签我们已经搞定~
今天的作业就先到这里哈,大家先做好登录注册功能。