链接:https://gitee.com/xu3619/community
整合邮件发送功能 发送者邮箱中打开SMTP服务- 首先在自己的邮箱(网易、QQ…均可)设置中开启 SMTP 服务
- 在 pom.xml 文件中引入相关依赖
邮箱参数配置org.springframework.boot spring-boot-starter-mail 2.7.0
- 这里我使用的是网易邮箱的相关配置参数,其他邮箱可以自行百度查看配置参数
spring:
# 发送者邮箱相关配置
mail:
# SMTP服务器域名
host: smtp.163.com
# 编码集
default-encoding: UTF-8
# 邮箱用户名
username: 15814963053@163.com
# 授权码(注意不是邮箱密码!)
password: XDK********ONREJ
# 协议:smtps
protocol: smtps
# 详细配置
properties:
mail:
smtp:
# 设置是否需要认证,如果为true,那么用户名和密码就必须的,
# 如果设置false,可以不设置用户名和密码 (前提要知道对接的平台是否支持无密码进行访问的)
auth: true
# STARTTLS[1] 是对纯文本通信协议的扩展;
# 它提供一种方式将纯文本连接升级为加密连接(TLS或SSL),而不是另外使用一个端口作加密通信。
starttls:
enable: true
required: true
邮件发送工具类
@Component
public class MailClient {
private static final Logger logger = LoggerFactory.getLogger(MailClient.class);
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String from;
public void sendMail(String to, String subject, String content) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message);
helper.setFrom(from); // 发件人
helper.setTo(to); // 收件人
helper.setSubject(subject); // 邮件主题
helper.setText(content, true); // 邮件内容,第二个参数true表示支持html格式
mailSender.send(helper.getMimeMessage());
} catch (MessagingException e) {
logger.error("邮件发送失败:" + e.getMessage());
}
}
}
测试邮件发送
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class MailTests {
@Autowired
private MailClient mailClient;
@Test
public void testTextMail() {
mailClient.sendMail("1193499619@qq.com", "TEST", "测试邮件发送!");
}
}
- 测试发送邮件成功!
- demo.html
邮件示例
欢迎您, !
- 测试类
@Autowired
private TemplateEngine templateEngine;
@Test
public void testHtmlMail() {
// 激活邮件发送
Context context = new Context(); // org.thymeleaf.context.Context 包下
context.setVariable("username", "xxx同学");
String content = templateEngine.process("/mail/demo", context);
System.out.println(content);
mailClient.sendMail("1193499619@qq.com", "HTML", content);
}
开发注册功能
导入相关依赖
参数配置org.apache.commons commons-lang3 3.12.0
# community
community
path
domain: http://localhost:8080
配置工具类
- CommunityUtil.java
public class CommunityUtil {
// 生成随机字符串
public static String generateUUID() {
// 将所有的"-"替换成空字符串
return UUID.randomUUID().toString().replaceAll("-", "");
}
// MD5加密
// hello -> abc123def456
// hello + 3e4a8 -> abc123def456abc
// 这样可以增加安全性
public static String md5(String key) {
if (StringUtils.isBlank(key)) { // 判空操作
return null;
}
return DigestUtils.md5DigestAsHex(key.getBytes());
}
}
- CommunityConstant.java
public interface CommunityConstant {
int ACTIVATION_SUCCESS = 0;
int ACTIVATION_REPEAT = 1;
int ACTIVATION_FAILURE = 2;
int DEFAULT_EXPIRED_SECONDS = 3600 * 12;
int REMEMBER_EXPIRED_SECONDS = 3600 * 24 * 100;
}
业务层
@Service
public class UserService implements CommunityConstant {
@Autowired
private UserMapper userMapper;
@Autowired
private MailClient mailClient;
@Autowired
private TemplateEngine templateEngine;
@Value("${community.path.domain}")
private String domain; // 域名
@Value("${server.servlet.context-path}")
private String contextPath; // 项目名
public User findUserById(int id) {
return userMapper.selectById(id);
}
// 注册方法
public Map register(User user) {
Map map = new HashMap<>();
// 空值处理
if (user == null) {
throw new IllegalArgumentException("参数不能为空!");
}
if (StringUtils.isBlank(user.getUsername())) {
map.put("usernameMsg", "账号不能为空!");
return map;
}
if (StringUtils.isBlank(user.getPassword())) {
map.put("passwordMsg", "密码不能为空!");
return map;
}
if (StringUtils.isBlank(user.getEmail())) {
map.put("emailMsg", "邮箱不能为空!");
return map;
}
// 验证账号
User u = userMapper.selectByName(user.getUsername());
if (u != null) {
map.put("usernameMsg", "该账号已存在!");
return map;
}
// 验证邮箱
u = userMapper.selectByEmail(user.getEmail());
if (u != null) {
map.put("emailMsg", "该邮箱已被注册!");
return map;
}
// 注册用户
// 生成随机的盐值
user.setSalt(CommunityUtil.generateUUID().substring(0, 5));
// 对密码加盐后再进行MD5加密
user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
// 设置用户类型为0-普通用户; 1-超级管理员; 2-版主;
user.setType(0);
// 设置用户状态为0-未激活; 1-已激活;
user.setStatus(0);
// 生成激活码
user.setActivationCode(CommunityUtil.generateUUID());
// 生成随机头像,牛客网有一千个随机头像
user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
// 设置用户的创建时间
user.setCreateTime(new Date());
// 添加用户
// 当调用insert语句时,mybatis会自动回写user,所以执行完这个语句后user就有id了
userMapper.insertUser(user);
// 发送激活邮件
Context context = new Context();
context.setVariable("email", user.getEmail());
String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();
context.setVariable("url", url);
String content = templateEngine.process("/mail/activation", context);
mailClient.sendMail(user.getEmail(), "激活账号", content);
return map;
}
public int activation(int userId, String code) {
User user = userMapper.selectById(userId);
if (user.getStatus() == 1) { // 用户状态已激活:1,未激活:0
return ACTIVATION_REPEAT; // 重复激活
} else if (user.getActivationCode().equals(code)) { // 激活码匹配上了
// 将用户的status状态修改为1
userMapper.updateStatus(userId, 1);
return ACTIVATION_SUCCESS; // 激活成功
} else {
return ACTIVATION_FAILURE; // 激活失败
}
}
}
控制层
@Controller
public class LoginController implements CommunityConstant {
@Autowired
private UserService userService;
@RequestMapping(path = "/register", method = RequestMethod.GET)
public String getRegisterPage() {
// 跳转到注册页面
return "/site/register";
}
@RequestMapping(path = "/login", method = RequestMethod.GET)
public String getLoginPage() {
// 跳转到登录页面
return "/site/login";
}
@RequestMapping(path = "/register", method = RequestMethod.POST)
public String register(Model model, User user) {
Map map = userService.register(user);
// 如果map为空说明注册成功了
if (map == null || map.isEmpty()) {
model.addAttribute("msg", "注册成功,我们已经向您的邮箱发送了一封激活邮件,请尽快激活!");
model.addAttribute("target", "/index");
// 跳转到操作结果页面
return "/site/operate-result";
} else { // 注册失败,返回错误信息并重新跳转到注册页面
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
model.addAttribute("emailMsg", map.get("emailMsg"));
// 跳转到注册页面
return "/site/register";
}
}
// http://localhost:8080/community/activation/101/code
@RequestMapping(path = "/activation/{userId}/{code}", method = RequestMethod.GET)
public String activation(Model model, @PathVariable("userId") int userId, @PathVariable("code") String code) {
int result = userService.activation(userId, code);
if (result == ACTIVATION_SUCCESS) { // 激活成功
model.addAttribute("msg", "激活成功,您的账号已经可以正常使用了!");
model.addAttribute("target", "/login"); // 跳转到登录页面
} else if (result == ACTIVATION_REPEAT) { // 重复激活
model.addAttribute("msg", "无效操作,该账号已经激活过了!");
model.addAttribute("target", "/index"); // 跳转到首页
} else { // 激活失败
model.addAttribute("msg", "激活失败,您提供的激活码不正确!");
model.addAttribute("target", "/index"); // 跳转到首页
}
// 跳转到操作结果页面
return "/site/operate-result";
}
}
会话管理
cookie
session
在分布式系统中 session 存在一定的问题
- 解决方式:采用 redis 保存一些重要的数据,前期先采用 session,后期再升级为 redis
- 使用 Kaptcha 来生成验证码
编写 Kaptcha 配置类com.github.penggle kaptcha 2.3.2
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptchaProducer() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "100");
properties.setProperty("kaptcha.image.height", "40");
properties.setProperty("kaptcha.textproducer.font.size", "32");
properties.setProperty("kaptcha.textproducer.font.color", "0,0,0");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
properties.setProperty("kaptcha.textproducer.char.length", "4");
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);
kaptcha.setConfig(config);
return kaptcha;
}
}
控制层
- LoginController.java
// 生成验证码的方法
@RequestMapping(path = "/kaptcha", method = RequestMethod.GET)
public void getKaptcha(HttpServletResponse response, HttpSession session) { // 因为验证码属于敏感数据,所以存在session比较合适
// 生成验证码文本
String text = kaptchaProducer.createText();
// 通过验证码文本生成验证码图片
BufferedImage image = kaptchaProducer.createImage(text);
// 将验证码存入session
session.setAttribute("kaptcha", text);
// 将验证码图片输出给浏览器
response.setContentType("image/png"); // 设置传输的格式
try {
OutputStream os = response.getOutputStream(); // 获取输出字节流
ImageIO.write(image, "png", os);
} catch (IOException e) {
logger.error("响应验证码失败:" + e.getMessage());
}
}
开发登录、退出功能
实体类
public class LoginTicket {
private int id;
// 用户id
private int userId;
// 凭证,需要发送给客户端
private String ticket;
// 0表示有效,1表示无效
private int status;
// 登录凭证过期时间
private Date expired;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getUserId() {
return userId;
}
public void setUserId(int userId) {
this.userId = userId;
}
public String getTicket() {
return ticket;
}
public void setTicket(String ticket) {
this.ticket = ticket;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public Date getExpired() {
return expired;
}
public void setExpired(Date expired) {
this.expired = expired;
}
@Override
public String toString() {
return "LoginTicket{" + "id=" + id + ", userId=" + userId + ", ticket='" + ticket + ''' + ", status=" + status + ", expired=" + expired + '}';
}
}
数据层
@Mapper
public interface LoginTicketMapper {
// 插入一条凭证数据
@Insert({
"insert into login_ticket(user_id,ticket,status,expired) ",
"values(#{userId},#{ticket},#{status},#{expired})"
})
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertLoginTicket(LoginTicket loginTicket);
// 根据凭证查询用户
@Select({
"select id,user_id,ticket,status,expired ",
"from login_ticket where ticket=#{ticket}"
})
LoginTicket selectByTicket(String ticket);
// 更新凭证的状态
@Update({
"update login_ticket set status=#{status} where ticket=#{ticket} "
})
int updateStatus(String ticket, int status);
}
业务层
public Map login(String username, String password, int expiredSeconds) {
Map 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) { // 等于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); // 0表示该凭证有效
loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
loginTicketMapper.insertLoginTicket(loginTicket);
map.put("ticket", loginTicket.getTicket()); // 将ticket数据存放到map中
return map;
}
public void loginOut(String ticket) {
loginTicketMapper.updateStatus(ticket, 1); // 1表示该凭证无效
}
控制层
// 登录
@RequestMapping(path = "/login", method = RequestMethod.POST)
public String login(String username, String password, String code, boolean rememberme, Model model, HttpSession session, HttpServletResponse response) {
// 检查验证码
String kaptcha = (String) session.getAttribute("kaptcha");
// equalsIgnoreCase表示验证码忽略大小写
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 map = userService.login(username, password, expiredSeconds);
if (map.containsKey("ticket")) {
// cookie的key和value都必须是字符串类型
Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
// 设置cookie在什么范围内有效,这里设置整个项目的访问路径都有效
cookie.setPath(contextPath);
// 设置cookie有效时间
cookie.setMaxAge(expiredSeconds);
// 发送cookie到浏览器
response.addCookie(cookie);
// 拿到ticket,说明登录成功,重定向到首页
return "redirect:/index";
} else {
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
// 登录失败,跳转到登录页面
return "/site/login";
}
}
// 退出登录
@RequestMapping(path = "/logout", method = RequestMethod.GET)
public String logOut(@CookieValue("ticket") String ticket) {
userService.loginOut(ticket);
// 重定向到登录页面,我们有两个/login请求,重定向默认就是GET请求
return "redirect:/login";
}
显示登录信息
工具类客户端通过 cookie 携带登录凭证向服务器换取 user 信息,流程如图:
- CookieUtil.java
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;
}
}
- HostHolder.java
@Component
public class HostHolder {
private ThreadLocal users = new ThreadLocal<>();
public void setUser(User user) {
users.set(user);
}
public User getUser() {
return users.get();
}
public void clear() {
users.remove();
}
}
拦截器类
LoginTicketInterceptor.java 登录凭证拦截器
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
// 请求开始前先获取user信息
@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())) {
// 根据凭证的用户id查询用户信息
User user = userService.findUserById(loginTicket.getUserId());
// 在本次请求中(当前线程)持有该用户信息(要考虑多线程并发的情况,所以借助ThreadLocal)
hostHolder.setUser(user);
}
}
return true;
}
// 调用模板引擎之前执行
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 从ThreadLocal 中得到当前线程持有的user
User user = hostHolder.getUser();
if (user != null && modelAndView != null) {
// 登录的用户信息存入modelAndView
modelAndView.addObject("loginUser", user);
}
}
// 在模板引擎执行之后调用该方法
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 从ThreadLocal中清除数据
hostHolder.clear();
}
}
配置类
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginTicketInterceptor).excludePathPatterns("*.css", "*.js", "*.png", "*.jpg", "*.jpeg"); // 哪些路径不进行拦截
}
}
业务层
public LoginTicket findLoginTicket(String ticket) {
return loginTicketMapper.selectByTicket(ticket);
}
账号设置
修改配置文件
# community
community:
# 配置文件上传路径
upload: /Users/xiexu/community/data
业务层
- UserService.java
public int updateHeader(int userId, String headerUrl) {
return userMapper.updateHeader(userId, headerUrl);
}
public boolean updatePassword(int userId, String oldPassword, String newPassword) {
User user = userMapper.selectById(userId);
// 获取原密码的盐值
String salt = user.getSalt();
// 先校验原密码是否匹配
if (CommunityUtil.md5(oldPassword + salt).equals(user.getPassword())) {
// 设置新密码
newPassword = CommunityUtil.md5(newPassword + salt);
userMapper.updatePassword(userId, newPassword);
return true;
}
return false;
}
控制层
- UserController.java
@Controller
@RequestMapping("/user")
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.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;
@RequestMapping(path = "/setting", method = RequestMethod.GET)
public String getSettingPage() {
// 跳转到账号设置页面
return "/site/setting";
}
@RequestMapping(path = "/upload", method = RequestMethod.POST)
public String uploadHeader(MultipartFile headerImage, Model model) {
if (headerImage == null) {
model.addAttribute("error", "您还没有选择图片!");
return "/site/setting";
}
// 获取上传到服务器的文件名
String filename = headerImage.getOriginalFilename();
// 获取文件名最后一个"."的后缀(比如png,jpeg)
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写入到文件中
headerImage.transferTo(dest);
} catch (IOException e) {
logger.error("上传文件失败:" + e.getMessage());
throw new RuntimeException("上传文件失败,服务器发生异常!", e);
}
// 更新当前用户头像的路径(web访问路径)
// http://localhost:8080/community/user/header/xxx.png
String headerUrl = domain + contextPath + "/user/header/" + filename;
User user = hostHolder.getUser();
userService.updateHeader(user.getId(), headerUrl);
// 更新成功,重定向到首页
return "redirect:/index";
}
@RequestMapping(path = "/header/{fileName}", method = RequestMethod.GET)
public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
// 服务器存放路径
fileName = uploadPath + "/" + fileName;
// 文件后缀
String suffix = fileName.substring(fileName.lastIndexOf("."));
// 响应图片
response.setContentType("image/" + suffix);
ServletOutputStream os = null;
FileInputStream fis = null;
try {
os = response.getOutputStream();
fis = new FileInputStream(fileName);
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());
} finally {
try {
os.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 修改密码
@RequestMapping(path = "/updatePassword", method = RequestMethod.POST)
public String updatePassword(String oldPassword, String newPassword, Model model) {
User user = hostHolder.getUser();
if (!userService.updatePassword(user.getId(), oldPassword, newPassword)) {
model.addAttribute("passwordMsg", "原密码错误,请重新输入!");
// 密码修改失败,跳转到账号设置页面
return "/site/setting";
} else {
// 密码修改成功,重定向到登录页面
return "redirect:/logout";
}
}
}
检查登录状态
注解类对需要登录的接口添加注解,并在拦截器中进行登录状态检查
@Target(ElementType.METHOD) // 该注解可以写在方法上
@Retention(RetentionPolicy.RUNTIME) // 该注解在程序运行时有效
public @interface LoginRequired {
}
拦截器
@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 method = handlerMethod.getMethod();
// 获取该方法上的 @LoginRequired 注解
LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
// 当前方法需要登录,但是该用户没有登录
if (loginRequired != null && hostHolder.getUser() == null) {
// 强制重定向到登录页面
response.sendRedirect(request.getContextPath() + "/login");
// 拒绝访问
return false;
}
}
return true;
}
}
配置类
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private AlphaInterceptor alphaInterceptor;
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
@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"); // 哪些路径不进行拦截
}
}
在需要拦截的方法上加上该注解



