小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对象到前端,我们再看看前端。
class="fly-list">@listing>
#list>
找找中间的内容部分,然后写成了上面那样,因为我把记录列表封装成了宏(macro) 
具体的内容就是这样
href="${base}/user/${post.authorId}" class="fly-avatar">
src="${post.authorAvatar}" alt="${post.authorName}">
class="layui-badge">${post.categoryName}
href="${base}/post/${post.id}">${post.title}
class="fly-list-info">href="${base}/user/${post.authorId}" link>
${post.authorName}
class="layui-badge fly-badge-vip">VIP${post.authorVip}
${post.created?string('yyyy-MM-dd')}
class="fly-list-nums">
class="iconfont icon-pinglun1" title="回答"> ${post.commentCount}
class="fly-list-badge">class="layui-badge layui-bg-black">置顶#if>
class="layui-badge layui-bg-red">精帖#if>
#macro>
关于freemarker标签macro的用法,不懂的就去百度一下啦,貌似之前我们说过是不是?忘了~
...#macro>
代表定义了一个macro名词叫listing,参数是post,标签内容就是宏的内容。需要调用这个宏的地方直接使用标签就可以,所以你就看到了我刚才的写法。

好了,列表是循环出来了,但是有个问题还没解决,就是分页问题,前端中我们需要一个分页的导航给我们点击页数。二期作业中我们使用的是一个其他插件,这次我们直接用layui的分页插件,还是挺简单的。因为分页的这个页码还是很多页码需要用到的,所以我又把分页的内容搞了一个宏,然后参考一下layui的分页写法https://www.layui.com/demo/laypage.html
id="laypage-main">#macro>
上面的js还是比较简单的,就调用了layui的一个laypage.render就可以吧页码给渲染出来了,我们在需要的地方调用一下代码
style="text-align: center">@page>
pageData是controller传过来的数据,渲染效果如下:

简直完美,我真是个天才,人见人爱,花见花开~
导航分类
接下来我们来完善一下导航分类信息,这个比较简单,我们有个表专门存储分类信息的,只需要把列表获取出来就是了(id,name)不需要关联其他表,那么mybatis plus可以直接帮我搞定,不用我写service了,那我来想应该再那里传送数据过去呢,首页index?导航分类是所有的地方都用到的,所以不合适,这时候定义一个freemarker标签是个好办法。
但这里我们没用采用标签方式,我是吧数据放在了全局应用上下文Context中了,这样初始化项目时候我们就加载数据,和我们之前初始化本周热议有点类似,所以我们直接在那个启动类中添加我们的代码

ok,2行代码,绝不写多,有些人可能有个status控制分类的展示,可以做个条件。
currentCategoryId是为了回显当前选择的分类,默认为0(首页)
再看前端,就是展示数据:
class="${(0 == currentCategoryId)?string('layui-hide-xs layui-this', '')}">
href="/">首页
class="${(category.id == currentCategoryId)?string('layui-hide-xs layui-this', '')}">
href="${base}/category/${category.id}">${category.name}
#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
class="fly-list">@listing>
#list>
style="text-align: center">@page>
博客详情
好了,接下来我们看博客详情,点击列表之后跳转到的页面,展示博客内容和评论列表等信息。
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对象。
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中添加一下关联的信息,比如用户名等
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}
前端的话就简单了,就list循环展示数据就行了
...
#list>
style="text-align: center">@page>
具体看我们的代码了,这里就没必要贴出来了。好了,数据的展示就先到这里~
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包
com.github.axet
kaptcha
0.0.9
然后配置一下验证码的图片生成规则:(边框、颜色、字体大小、长、高等)
@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);
}
所以访问这个接口就能得到验证码图片流,页面中:
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包
net.mingsoft
shiro-freemarker-tags
0.1
第二步,需要把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的标签之后,那么我们就可以用了
class="layui-nav fly-nav-user">class="layui-nav-item">
class="iconfont icon-touxiang layui-hide-xs" href="/login">
class="layui-nav-item">
href="/login">登入
class="layui-nav-item">
href="/register">注册
@shiro.guest>
class="layui-nav-item">
class="fly-nav-avatar" href="javascript:;">
class="layui-hide-xs">
class="iconfont icon-renzheng layui-hide-xs" title="认证信息:layui 作者">
class="layui-badge fly-badge-vip layui-hide-xs">VIP
">
class="layui-nav-child">href="user/set.html">class="layui-icon">基本设置
href="user/message.html">class="iconfont icon-tongzhi" style="top: 4px;">我的消息
href="user/home.html">class="layui-icon" style="margin-left: 2px; font-size: 22px;">我的主页
style="margin: 5px 0;">
href="/logout" style="text-align: center;">退出
@shiro.user>
上面就是通过和两个标签来辨别用户是否已经登录了。这样登录前,我们看到的是登录注册按钮,登录之后看到的是用户的用户名称,头像等~
好了,上面shiro的标签我们已经搞定~
今天的作业就先到这里哈,大家先做好登录注册功能。

给eblog一个star,感谢支持哈