登录、退出功能
功能分析
用户完成注册或者用户登录后,系统会给每个用户一个对应的登录凭证,用于记录用户登录状态和部分用户信息;
退出登录修改登录凭证的状态信息即可;
功能开发
用户完成注册或者用户登录后,系统会给每个用户一个对应的登录凭证,c创建LoginTicket实体类,用户记录用户登录状态信息。
@Data
public class LoginTicket {
/**登陆凭证id*/
private int id;
/**用户id*/
private int userId;
/**登陆凭证*/
private String ticket;
/**登陆状态*/
private int status;
/**登陆凭证有效时间*/
private Date expired;
@Override
public String toString() {
return "LoginTicket{" +
"id=" + id +
", userId=" + userId +
", ticket='" + ticket + '\'' +
", status=" + status +
", expired=" + expired +
'}';
}
}
在 CommunityConstant 类中添加默认状态的登录凭证超时时间和记住状态的登录凭证超时时间,代码如下:
/**
* 默认状态的登录凭证的超时时间
*/
int DEFAULT_EXPIRED_SECONDS = 3600 * 12;
/**
* 记住状态的登录凭证超时时间
*/
int REMEMBER_EXPIRED_SECONDS = 3600 * 24 * 100;
在 dao 包下创建 LoginTicketMapper 类,实现对 login_ticket 表的增删改查
@Mapper
public interface LoginTicketMapper {
/**
* 新增用户登录凭证
* @param loginTicket
* @return
*/
int insertLoginTicket(LoginTicket loginTicket);
/**
* 根据登录ticket查询登录凭证
* @param ticket
* @return
*/
LoginTicket selectByTicket(String ticket);
/**
* 修改登录状态
* @param ticket
* @param status
* @return
*/
int updateStatus(String ticket, int status);
}
在mapper文件夹下新建LoginTicketMapper.xml文件,实现sql文件编写
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ahtoh.community.dao.LoginTicketMapper">
<sql id="selectFields">
id,user_id,user_id, ticket, status, expired
</sql>
<insert id="insertLoginTicket" parameterType="com.ahtoh.community.entity.LoginTicket" keyProperty="id">
insert into login_ticket(user_id, ticket, status, expired)
values(#{userId}, #{ticket}, #{status}, #{expired})
</insert>
<select id="selectByTicket" resultType="com.ahtoh.community.entity.LoginTicket">
select <include refid="selectFields"></include>
from login_ticket
where ticket=#{ticket}
</select>
<update id="updateStatus">
update login_ticket set status = #{status} where ticket = #{ticket}
</update>
</mapper>
在 UserService 类中添加 login、logout方法来分别表示提供登录和退出登录的功能;
@Autowired
private LoginTicketMapper loginTicketMapper;
/**
* 用户登录
* @param username 用户名
* @param password 密码
* @param expiredSeconds 有效时间
* @return
*/
public Map<String, Object> login(String username, String password, int expiredSeconds) {
Map<String, Object> map = new HashMap<>();
// 空值处理
if (StringUtils.isBlank(username)) {
map.put("usernameMsg", "账号不能为空!");
return map;
}
if (StringUtils.isBlank(password)) {
map.put("passwordMsg", "密码不能为空!");
return map;
}
// 验证账号
User user = userMapper.selectByName(username);
if (user == null) {
map.put("usernameMsg", "该账号不存在!");
return map;
}
// 验证状态
if (user.getStatus() == 0) {
map.put("usernameMsg", "该账号未激活!");
return map;
}
// 验证密码
password = CommunityUtil.MD5(password + user.getSalt());
if (!user.getPassword().equals(password)) {
map.put("passwordMsg", "密码不正确!");
return map;
}
// 生成登录凭证
LoginTicket loginTicket = new LoginTicket();
loginTicket.setUserId(user.getId());
loginTicket.setTicket(CommunityUtil.generateUUID());
loginTicket.setStatus(0);
loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
loginTicketMapper.insertLoginTicket(loginTicket);
map.put("ticket", loginTicket.getTicket());
return map;
}
/**
* 退出登录
* @param ticket
*/
public void logout(String ticket) {
loginTicketMapper.updateStatus(ticket, 1);
}
在 LoginController 类中添加 login、logout 方法来捕获用户的发出的登录和退出登录的请求
@Value("${server.servlet.context-path}")
private String contextPath;
/**
* 用户登录
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param rememberme 是否记住用户
* @param model 前端模型
* @param session session
* @param response 响应
* @return
*/
@PostMapping("/login")
public String login(String username, String password, String code, boolean rememberme,
Model model, HttpSession session, HttpServletResponse response) {
// 从服务端session中获取验证码,判断验证码是否正确
String kaptcha = (String) session.getAttribute("kaptcha");
if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
model.addAttribute("codeMsg", "验证码不正确!");
return "/site/login";
}
// 根据登录信息查找登录凭证
int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
Map<String, Object> map = userService.login(username, password, expiredSeconds);
if (map.containsKey("ticket")) {
Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
//在此路径上生效
cookie.setPath(contextPath);
//cookie生效时间
cookie.setMaxAge(expiredSeconds);
//将cookie响应给前端
response.addCookie(cookie);
return "redirect:/index";
} else {
//模型中加入错误信息,用于thymeleaf动态显示
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
return "/site/login";
}
}
/**
* 用户退出登录
* @param ticket 登录凭证信息
* @return
*/
@GetMapping("/logout")
public String logout(@CookieValue("ticket") String ticket) {
userService.logout(ticket);
return "redirect:/login";
}
前端需要对index.html和login.html作对应修改,详情见源代码内容;
功能测试
启动项目后,点击进入登录页面,输入注册完成的账号、密码以及验证码进行登录;
点击立即登录后将会跳转到首页:
登录拦截
功能分析
登录拦截,主要用于把不想要的或不想显示的内容给过滤掉;
拦截器是全局的,可以对多个Controller做拦截;
自定义拦截器实现用户登录验证拦截,检查用户登录凭证信息,对符合的登入者才跳转到正确页面。这样如果有新增权限的话,不用在其他位置修改任何代码,直接在interceptor里修改就行了,方面后期扩展开发;
用户在一次请求过程中,很多地方都需要用到用户的信息,为了方便获取用户对象,需要根据一次请求访问属于一个线程的特点,利用ThreadLocal类存储用户信息;
功能开发
在 util 包下创建 CookieUtil 类用于获取 Cookie 的方法,代码如下:
public class CookieUtil {
public static String getValue(HttpServletRequest request, String name) {
if (request == null || name == null) {
throw new IllegalArgumentException("参数为空");
}
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
return cookie.getValue();
}
}
}
return null;
}
}
用户在一次请求过程中,很多地方都需要用到用户的信息,为了方便获取用户对象,需要根据一次请求访问属于一个线程的特点,利用ThreadLocal类存储用户信息;
在 util 包下创建 HostHolder 类,该方法持有用户信息,用户代替 session 对象,代码如下:
@Component
public class HostHolder {
// 利用ThreadLocal线程隔离特点存储用户对象
private ThreadLocal<User> users = new ThreadLocal<>();
public void setUser(User user){
users.set(user);
}
public User getUser(){
return users.get();
}
public void clear(){
users.remove();
}
}
在controller包下创建 interceptor 包,在该包下创建 LoginTicketInterceptor 拦截器。
在 LoginTicketInterceptor 拦截器作用的影响下,浏览器端每发送一次请求,LoginTicketInterceptor都会拦截请求,先执行 preHandler 方法,再执行 postHandler 方法,再执行请求对应的控制器方法,最后执行 afterCompletion 方法。
- preHandle 方法:从 cookie 中获取凭证 ticket,再去查验凭证,如果凭证合法的话就根据 userId在数据库中查到 user 实体类,将 user实体类存到 hostHolder 中。
- postHandle方法: 从 hostHolder 获取 user 实体类,再将 user 实体类添加到 modelAndview中,这样整个请求都持有用户信息。
- afterCompletion 方法:浏览器端一次请求结束后,将 hostHolder 中的用户信息清除。
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从 cookie 中获取凭证
String ticket = CookieUtil.getValue(request, "ticket");
if (ticket != null) {
// 查询凭证
LoginTicket loginTicket = userService.findLoginTicket(ticket);
// 检查凭证是否 有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
// 根据凭证查询用户
User user = userService.findUserById(loginTicket.getUserId());
// 在本次请求中持有用户
hostHolder.setUser(user);
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
User user = hostHolder.getUser();
if (user != null && modelAndView != null) {
modelAndView.addObject("loginUser", user);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
}
}
在UserService中添加findUserById方法
配置拦截器
在interceptor包,创建AlphaInterceptor拦截器;
在 config 包,在该报下创建 WebMvcConfig 类,代码如下:
@Component
public class AlphaInterceptor implements HandlerInterceptor {
// 生成日志
private static final Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class);
// 在Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.debug("preHandle: " + handler.toString());
return true;
}
// 在Controller之后执行
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
logger.debug("postHandle: " + handler.toString());
}
// 在模版引擎执行完后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
logger.debug("afterCompletion: " + handler.toString());
}
}
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private AlphaInterceptor alphaInterceptor;
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(alphaInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg")
.addPathPatterns("/register", "/login");
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
功能测试
启动项目后,点击进入登录页面,输入注册完成的账号、密码以及验证码进行登录;
点击立即登录后将会跳转到首页,检查登录前后页面变化;
点击退出登录,返回登录界面;
账号设置功能
功能分析
账号设置,主要包括对用户头像、用户名称、用户密码等信息进行修改;
用户头像修改,需要用户上传图片信息,图片显示需要下载图片信息;
功能开发
1、上传文件配置
# 修改为自己电脑上文件夹对应的路经
community.path.upload=/Users/ahtohlove/Desktop/picture
2、UserService添加修改头像方法
/**
* 修改头像信息
* @param id
* @param headerUrl
*/
public void updateHeader(int id, String headerUrl) {
userMapper.updateHeader(id,headerUrl);
}
3、创建UserController
实现文件上传、下载、账号设置界面展示;
@Controller
@RequestMapping("/user")
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class);
@Value("${community.path.upload}")
private String uploadPath;
@Value("${community.path.domain}")
private String domain;
@Value("${server.servlet.context-path}")
private String contextPath;
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
/**
* 账号设置界面展示
* @return
*/
@GetMapping("/setting")
public String getSettingPage() {
return "/site/setting";
}
/**
* 头像文件上传
* @param headerImage
* @param model
* @return
*/
@PostMapping("/upload")
public String uploadHeader(MultipartFile headerImage, Model model) {
if (headerImage == null) {
model.addAttribute("error", "您还没有选择图片!");
return "/site/setting";
}
//原始文件名获取
String fileName = headerImage.getOriginalFilename();
//原始文件后缀格式
String suffix = fileName.substring(fileName.lastIndexOf("."));
if (StringUtils.isBlank(suffix)) {
model.addAttribute("error", "文件的格式不正确!");
return "/site/setting";
}
// 生成随机文件名
fileName = CommunityUtil.generateUUID() + suffix;
// 确定文件存放的路径
File dest = new File(uploadPath + "/" + fileName);
// 存储文件
try {
headerImage.transferTo(dest);
} catch (IOException e) {
logger.error("上传文件失败:" + e.getMessage());
throw new RuntimeException("上传文件失败,服务器发生异常!", e);
}
// 更新当前用户头像的路径
User user = hostHolder.getUser();
String headerUrl = domain + contextPath + "/user/header/" + fileName;
userService.updateHeader(user.getId(), headerUrl);
return "redirect:/index";
}
/**
* 头像下载获取
* @param fileName
* @param response
*/
@GetMapping("/header/{fileName}")
public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
// 服务器存放路径
fileName = uploadPath + "/" + fileName;
// 文件后缀
String suffix = fileName.substring(fileName.lastIndexOf("."));
// 响应图片
response.setContentType("image/" + suffix);
try (
FileInputStream fis = new FileInputStream(fileName);
OutputStream os = response.getOutputStream();
) {
byte[] buffer = new byte[1024];
int b = 0;
while ((b = fis.read(buffer)) != -1) {
os.write(buffer, 0, b);
}
} catch (IOException e) {
logger.error("读取头像失败: " + e.getMessage());
}
}
}
自定义注解拦截
解决问题:
用户未登录状态,可以访问账号设置页面;
1、自定义注解
- 常用的元注解:
@Target、@Retention、@Document、@Inherited - 如何读取注解:
- Method.getDeclaredAnnotations()
- Method.getAnnotation(Class annotationClass)
在controller包下创建 annoatation 包,在此包下创建 LoginRequired注解,代码如下:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
2、编写拦截器
在 interceptor 包中创建LoginRequiredInterceptor 类,代码如下:
@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
if (loginRequired != null && hostHolder.getUser() == null) {
response.sendRedirect(request.getContextPath() + "/login");
return false;
}
}
return true;
}
}
3、配置拦截器
在WebMvcConfig配置类中添加一下代码:
@Autowired
private LoginRequiredInterceptor loginRequiredInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(alphaInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg")
.addPathPatterns("/register", "/login");
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
registry.addInterceptor(loginRequiredInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
4、在controller中添加注解
在 UserContoller 中的 getSettingPage和uploadHeader添加LoginRequired注解;
添加注释后效果,当直接访问/user/setting 页面时会跳转至用户登录界面。
功能测试
账号设置
上传头像
修改成功
敏感词过滤
为维护网站环境,需要对敏感词进行过滤处理;
构建敏感词过滤器,对指定内容的敏感词进行过滤处理;
功能分析
敏感词过滤,本质是对文本进行检索,本项目通过构建前缀树,建立敏感词过滤器。
- 前缀树
名称:Trie、字典树、查找树
特点:查找效率高,消耗内存大
应用:字符串检索、词频统计、字符串排序等 - 敏感词过滤器
定义前缀树
根据敏感词,初始化前缀树
编写过滤敏感词的方法
功能开发
在 resource 包和target包下添加敏感词字典 sensitive-words.txt,内容如下:
赌博
嫖娼
吸毒
开票
在 util 包下创建 SentisiveFile 类,即敏感词的过滤算法,代码如下:
@Component
public class SensitiveFilter {
private static final Logger logger= LoggerFactory.getLogger(SensitiveFilter.class);
// 替换符
private static final String REPLACEMENT="***";
// 根节点
private TrieNode rootNode = new TrieNode();
@PostConstruct
public void init() {
try (
InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
) {
String keyword;
while ((keyword = reader.readLine()) != null) {
// 添加到前缀树
this.addKeyword(keyword);
}
} catch (Exception e) {
logger.error("加载敏感词文件失败:" + e.getMessage());
}
}
// 将一个敏感词添加到前缀树中
private void addKeyword(String keyword) {
TrieNode tempNode = rootNode;
for (int i = 0; i < keyword.length(); i++) {
char c = keyword.charAt(i);
TrieNode subNode = tempNode.getSubNode(c);
if (subNode == null) {
// 初始化子节点
subNode = new TrieNode();
tempNode.addSunNode(c, subNode);
}
// 指向子节点,进入下下一轮循环
tempNode = subNode;
// 设置结束标识
if (i == keyword.length() - 1) {
tempNode.setKeywordEnd(true);
}
}
}
/**
* 过滤敏感词
* @param text 待过滤的文本
* @return 过滤后的文本
*/
public String filter(String text){
if(StringUtils.isBlank(text)){
return null;
}
// 指针1
TrieNode tempNode = rootNode;
// 指针2
int begin = 0;
// 指针3
int position = 0;
// 结果
StringBuilder sb = new StringBuilder();
while(begin < text.length()){
if(position < text.length()) {
Character c = text.charAt(position);
// 跳过符号
if (isSymbol(c)) {
if (tempNode == rootNode) {
begin++;
sb.append(c);
}
position++;
continue;
}
// 检查下级节点
tempNode = tempNode.getSubNode(c);
if (tempNode == null) {
// 以begin开头的字符串不是敏感词
sb.append(text.charAt(begin));
// 进入下一个位置
position = ++begin;
// 重新指向根节点
tempNode = rootNode;
}
// 发现敏感词
else if (tempNode.isKeywordEnd()) {
sb.append(REPLACEMENT);
begin = ++position;
}
// 检查下一个字符
else {
position++;
}
}
// position遍历越界仍未匹配到敏感词
else{
sb.append(text.charAt(begin));
position = ++begin;
tempNode = rootNode;
}
}
return sb.toString();
}
// 判断是否为符号
private boolean isSymbol(Character c) {
// 0x2E80~0x9FFF 是东亚文字范围
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
// 前缀树
private class TrieNode {
// 关键词结束标识
private boolean isKeywordEnd = false;
// 子节点(key是下级字符,value是下级节点)
private Map<Character, TrieNode> sunNodes = new HashMap<>();
public boolean isKeywordEnd() {
return isKeywordEnd;
}
public void setKeywordEnd(boolean keywordEnd) {
isKeywordEnd = keywordEnd;
}
// 添加子节点
public void addSunNode(Character c, TrieNode node) {
sunNodes.put(c, node);
}
// 获取子节点
public TrieNode getSubNode(Character c) {
return sunNodes.get(c);
}
}
}
功能测试
创建 SensitiveTests 测试方法,代码如下:
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class SensitiveTests {
@Autowired
private SensitiveFilter sensitiveFilter;
@Test
public void testSensitiveFilter() {
String text = "这里可以赌博,可以吸毒,可以开票,哈哈哈!";
text = sensitiveFilter.filter(text);
System.out.println(text);
String text1 = "这里可@以@赌@博,可以@吸毒@,可以开票@,哈哈哈!";
text = sensitiveFilter.filter(text1);
System.out.println(text1);
}
}
运行结果如下: