8.2 登录注册界面+后端修改,邮箱注册、添加验证码逻辑

This commit is contained in:
zhangsan 2025-08-02 14:48:43 +08:00
parent b9501daf0a
commit 2c8fc752ca
30 changed files with 1081 additions and 270 deletions

View File

@ -1,24 +1,39 @@
# —— builder 阶段 ——
# ---------- builder ----------
FROM maven:3.8.7-eclipse-temurin-17-alpine AS builder
WORKDIR /workspace
# 如没有私服,可删掉这行;否则保留
# 如没有私服,可删掉
COPY .mvn/settings.xml /root/.m2/settings.xml
# 拷 POM 并预拉依赖,提升缓存命中率
# 先拉依赖
COPY pom.xml .
RUN mvn -B dependency:go-offline
# ③ 再拷源码并真正打包,显式执行 spring-boot:repackage
# 打包
COPY src src
RUN mvn -B clean package spring-boot:repackage -DskipTests
# —— runtime 阶段 ——
FROM openjdk:17-jdk-slim
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 指向你在 pom.xml 里定义的 finalName
# ---------- runtime ----------
# 继续用 slim体积小手动装字体
FROM openjdk:17-jdk-slim AS runtime
ENV TZ=Asia/Shanghai \
LANG=C.UTF-8 \
LANGUAGE=C.UTF-8
# 换国内源,解决 timeout
RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list
# 安装 fontconfig + 基础西文 + CJK 字体
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
fontconfig fonts-dejavu-core fonts-noto-cjk; \
rm -rf /var/lib/apt/lists/*
# 设置时区
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone
ARG JAR_FILE=smile-picture-backend.jar
COPY --from=builder /workspace/target/${JAR_FILE} /app.jar

View File

@ -14,6 +14,7 @@ create table if not exists user
user_avatar varchar(1024) null comment '用户头像',
user_profile varchar(512) null comment '用户简介',
user_role varchar(256) default 'user' not null comment '用户角色user/admin',
user_email varchar(256) not null COMMENT '用户邮箱',
edit_time datetime default CURRENT_TIMESTAMP not null comment '编辑时间',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',

View File

@ -1 +1 @@
import{_ as o,c as s,a as t,o as a}from"./index-CrUyCRGJ.js";const n={},c={class:"about"};function r(_,e){return a(),s("div",c,e[0]||(e[0]=[t("h1",null,"This is an about page",-1)]))}const l=o(n,[["render",r]]);export{l as default};
import{_ as o,c as s,a as t,o as a}from"./index-D10z2Ner.js";const n={},c={class:"about"};function r(_,e){return a(),s("div",c,e[0]||(e[0]=[t("h1",null,"This is an about page",-1)]))}const l=o(n,[["render",r]]);export{l as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Smile云图库</title>
<meta name="description" content="Smile云图库海量图片素材免费获取">
<script type="module" crossorigin src="/assets/index-CrUyCRGJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cidcg2WD.css">
<script type="module" crossorigin src="/assets/index-D10z2Ner.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlRSOPH_.css">
</head>
<body>
<div id="app"></div>

11
pom.xml
View File

@ -132,6 +132,17 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 邮箱服务 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- 图形验证码: https://github.com/ele-admin/EasyCaptcha -->
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>

View File

@ -0,0 +1,144 @@
package edu.whut.smilepicturebackend.common;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 邮箱工具类
*/
public class EmailUtils {
public static void main(String[] args) {
Map<String, Object> map = new HashMap<>();
map.put("code", 1111);
System.out.println(emailContentTemplate("templates/EmailCodeTemplate.html", "BOOT_", "_END", map));
System.out.println(emailContentTemplate("templates/EmailCodeTemplate.html", map));
}
/**
* 发送静态模板邮箱
*
* @param path 模板路径模板需要放在resource下
* @return 内容
*/
public static String emailContentTemplate(String path) {
// 读取邮件模板该模板放在 templates/ 目录下模板名为EmailTemplate.html
Resource resource = new ClassPathResource(path);
InputStream inputStream = null;
BufferedReader fileReader = null;
StringBuilder buffer = new StringBuilder();
String line = "";
try {
inputStream = resource.getInputStream();
fileReader = new BufferedReader(new InputStreamReader(inputStream));
while ((line = fileReader.readLine()) != null) {
buffer.append(line);
}
} catch (Exception e) {
throw new RuntimeException("邮件模板读取失败", e);
} finally {
try {
if (fileReader != null) {
fileReader.close();
}
if (inputStream != null) {
inputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
// 替换html模板中的参数
return buffer.toString();
}
/**
* 发送动态模板邮箱
*
* @param path 模板路径模板需要放在resource下
* @param paramMap 参数列表按照Map中的Key进行替换
* @return 内容
*/
public static String emailContentTemplate(String path, Map<String, Object> paramMap) {
return emailContentTemplate(path, "BOOT_", "_END", paramMap);
}
/**
* 发送动态模板邮箱
*
* @param path 模板路径模板需要放在resource下
* @param prefix 前缀需要替换的占位符
* @param suffix 后缀需要替换的占位符
* @param paramMap 参数列表按照Map中的Key进行替换
* @return 内容
*/
public static String emailContentTemplate(String path, String prefix, String suffix, Map<String, Object> paramMap) {
String str = emailContentTemplate(path);
List<String> targetList = getTargetString(str, prefix, suffix);
if (targetList != null) {
for (String tl : targetList) {
Object o = paramMap.get(tl);
if (o != null) {
String s = prefix + tl + suffix;
str = str.replaceAll(s, o.toString());
} else {
throw new RuntimeException("邮箱模板中不存在占位字符!");
}
}
}
return str;
}
/**
* 获取占位符字符
*
* @param str 原字符串
* @param startStr 开始字符串
* @param endStr 结束字符串
* @return String[]
*/
private static List<String> getTargetString(String str, String startStr, String endStr) {
// 获取头占位符
List<Integer> startStrIndex = getTargetIndex(str, 0, startStr);
// 获取尾占位符
List<Integer> endStrIndex = getTargetIndex(str, 0, endStr);
if (!startStrIndex.isEmpty() && !endStrIndex.isEmpty() && startStrIndex.size() != endStrIndex.size()) {
return null;
}
List<String> strList = new ArrayList<>();
for (int i = 0, num = startStrIndex.size(); i < num; i++) {
strList.add(str.substring((startStrIndex.get(i) + startStr.length()), (endStrIndex.get(i))));
}
return strList;
}
/**
* 获取占位字符的下标
*
* @param string 字符串
* @param index 下标
* @param findStr 指定字符串
* @return List<Integer>
*/
private static List<Integer> getTargetIndex(String string, int index, String findStr) {
List<Integer> list = new ArrayList<>();
if (index != -1) {
int num = string.indexOf(findStr, index);
if (num == -1) {
return list;
}
list.add(num);
// 递归进行查找
list.addAll(getTargetIndex(string, string.indexOf(findStr, num + 1), findStr));
}
return list;
}
}

View File

@ -6,6 +6,15 @@ import edu.whut.smilepicturebackend.exception.ErrorCode;
*/
public class ResultUtils {
/**
* 成功
*
* @return 响应
*/
public static BaseResponse<Boolean> success() {
return new BaseResponse<>(0, true, "ok");
}
/**
* 成功
*

View File

@ -0,0 +1,68 @@
package edu.whut.smilepicturebackend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 线程池配置类
*/
@Configuration
// @EnableAsync // 开启异步任务, 启动类已开启
public class ThreadPoolConfig {
/**
* 邮件发送线程池
*
* @return 线程池任务执行器
*/
@Bean(name = "emailThreadPool")
public ThreadPoolTaskExecutor emailThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 设置核心线程数,它是可以同时被执行的线程数量
executor.setCorePoolSize(2);
// 设置最大线程数,缓冲队列满了之后会申请超过核心线程数的线程
executor.setMaxPoolSize(10);
// 设置缓冲队列容量,在执行任务之前用于保存任务
executor.setQueueCapacity(50);
// 设置线程生存时间,当超过了核心线程出之外的线程在生存时间到达之后会被销毁
executor.setKeepAliveSeconds(60);
// 设置线程名称前缀
executor.setThreadNamePrefix("emailPool-");
// 设置拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
// 初始化
executor.initialize();
return executor;
}
/**
* 消息发送线程池
*
* @return 线程池任务执行器
*/
@Bean(name = "messageThreadPool")
public ThreadPoolTaskExecutor messageThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 设置核心线程数,它是可以同时被执行的线程数量
executor.setCorePoolSize(2);
// 设置最大线程数,缓冲队列满了之后会申请超过核心线程数的线程
executor.setMaxPoolSize(10);
// 设置缓冲队列容量,在执行任务之前用于保存任务
executor.setQueueCapacity(50);
// 设置线程生存时间,当超过了核心线程出之外的线程在生存时间到达之后会被销毁
executor.setKeepAliveSeconds(60);
// 设置线程名称前缀
executor.setThreadNamePrefix("messagePool-");
// 设置拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
// 初始化
executor.initialize();
return executor;
}
}

View File

@ -0,0 +1,13 @@
package edu.whut.smilepicturebackend.constant;
public interface CacheConstant {
/**
* 邮箱验证码缓存 KEY 前缀
*/
String EMAIL_CODE_KEY = "EMAIL_CODE_KEY:%s:%s";
/**
* 图形验证码缓存 KEY 前缀
*/
String CAPTCHA_CODE_KEY = "CAPTCHA_CODE_KEY:%s";
}

View File

@ -1,14 +1,19 @@
package edu.whut.smilepicturebackend.controller;
import edu.whut.smilepicturebackend.common.BaseResponse;
import edu.whut.smilepicturebackend.common.ResultUtils;
import edu.whut.smilepicturebackend.model.vo.CaptchaVO;
import edu.whut.smilepicturebackend.service.MainApplicationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/")
@RequiredArgsConstructor
public class MainController {
private final MainApplicationService mainApplicationService;
/**
* 健康检查
*/
@ -23,4 +28,12 @@ public class MainController {
// }).start();
return ResultUtils.success("ok");
}
/**
* 获取图形验证码
*/
@GetMapping("/captcha")
public BaseResponse<CaptchaVO> captcha() {
return ResultUtils.success(mainApplicationService.getCaptcha());
}
}

View File

@ -1,6 +1,8 @@
package edu.whut.smilepicturebackend.controller;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.RegexPool;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import edu.whut.smilepicturebackend.annotation.AuthCheck;
import edu.whut.smilepicturebackend.common.BaseResponse;
@ -27,19 +29,47 @@ import java.util.List;
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 用户注册
*/
@PostMapping("/register")
public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
public BaseResponse<Boolean> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR);
String userAccount = userRegisterRequest.getUserAccount();
String userPassword = userRegisterRequest.getUserPassword();
String checkPassword = userRegisterRequest.getCheckPassword();
long result = userService.userRegister(userAccount, userPassword, checkPassword);
return ResultUtils.success(result);
String userEmail = userRegisterRequest.getUserEmail();
String codeKey = userRegisterRequest.getCodeKey();
String codeValue = userRegisterRequest.getCodeValue();
if (StrUtil.hasBlank(userEmail, codeKey, codeValue)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
// 校验邮箱格式
if (StrUtil.isEmpty(userEmail) || !ReUtil.isMatch(RegexPool.EMAIL, userEmail)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "邮箱格式错误");
}
userService.userRegister(userEmail, codeKey, codeValue);
return ResultUtils.success();
}
/**
* 发送邮箱验证码
*
* @param userRegisterRequest 用户注册请求
* @return 验证码 key
*/
@PostMapping("/send/email/code")
public BaseResponse<String> sendEmailCode(@RequestBody UserRegisterRequest userRegisterRequest) {
ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR);
String userEmail = userRegisterRequest.getUserEmail();
if (StrUtil.isEmpty(userEmail)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "邮箱不能为空");
}
// 校验邮箱格式
if (StrUtil.isEmpty(userEmail) || !ReUtil.isMatch(RegexPool.EMAIL, userEmail)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "邮箱格式错误");
}
return ResultUtils.success(userService.sendEmailCode(userEmail));
}
/**
@ -51,7 +81,15 @@ public class UserController {
ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);
String userAccount = userLoginRequest.getUserAccount();
String userPassword = userLoginRequest.getUserPassword();
LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword, request);
String captchaKey = userLoginRequest.getCaptchaKey();
String captchaCode = userLoginRequest.getCaptchaCode();
if (StrUtil.hasBlank(userAccount, userPassword, captchaKey, captchaCode)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数为空");
}
if (userAccount.length() < 4 || userPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号或密码输入错误");
}
LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword, captchaKey, captchaCode,request);
return ResultUtils.success(loginUserVO);
}
@ -74,18 +112,19 @@ public class UserController {
return ResultUtils.success(result);
}
/**
* 创建用户
*/
@PostMapping("/add")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Long> createUser(@RequestBody UserAddRequest userAddRequest) {
// 参数非空校验
ThrowUtils.throwIf(userAddRequest == null, ErrorCode.PARAMS_ERROR);
// 调用 Service 完成业务逻辑
long userId = userService.createUser(userAddRequest);
return ResultUtils.success(userId);
}
// /**
// * 创建用户
// */
// @PostMapping("/add")
// @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
// public BaseResponse<Boolean> createUser(@RequestBody UserAddRequest userAddRequest) {
// // 参数非空校验
// ThrowUtils.throwIf(userAddRequest == null, ErrorCode.PARAMS_ERROR);
//
// // 调用 Service 完成业务逻辑
// boolean result = userService.createUser(userAddRequest);
// return ResultUtils.success(result);
// }
/**
* 根据 id 获取用户仅管理员

View File

@ -12,6 +12,7 @@ public enum ErrorCode {
FORBIDDEN_ERROR(40300, "禁止访问"),
NOT_FOUND_ERROR(40400, "请求数据不存在"),
SYSTEM_ERROR(50000, "系统内部异常"),
DATA_ERROR(404, "数据错误"),
OPERATION_ERROR(50001, "操作失败");
/**

View File

@ -0,0 +1,151 @@
package edu.whut.smilepicturebackend.manager.email;
import edu.whut.smilepicturebackend.common.EmailUtils;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 邮箱服务
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailManager {
private final JavaMailSender javaMailSender;
@Value("${spring.mail.nickname}")
private String nickname;
@Value("${spring.mail.username}")
private String from;
@Async(value = "emailThreadPool")
public void sendEmailCode(String to, String subject, String code) {
log.info("发送邮件验证码[{}]到[{}]", to, code);
Map<String, Object> map = new HashMap<>();
map.put("code", code);
sendEmailAsCode(to, subject, map);
}
/**
* 发送验证码邮件
*/
public void sendEmailAsCode(String to, String subject, Map<String, Object> contentMap) {
try {
MimeMessage message = javaMailSender.createMimeMessage();
// 组合邮箱发送的内容
MimeMessageHelper messageHelper = new MimeMessageHelper(message, true);
// 设置邮件发送者
messageHelper.setFrom(nickname + "<" + from + ">");
// 设置邮件接收者
messageHelper.setTo(to);
// 设置邮件标题
messageHelper.setSubject(subject);
// 设置邮件内容
messageHelper.setText(EmailUtils.emailContentTemplate("templates/EmailCodeTemplate.html", contentMap), true);
javaMailSender.send(message);
} catch (MessagingException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "发送邮件失败");
}
}
/**
* 发送注册成功邮件
*/
@Async(value = "emailThreadPool")
public void sendEmailAsRegisterSuccess(String to, String subject) {
try {
// 创建MIME消息
MimeMessage message = javaMailSender.createMimeMessage();
// 组合邮箱发送的内容
MimeMessageHelper messageHelper = new MimeMessageHelper(message, true);
// 设置邮件发送者
messageHelper.setFrom(nickname + "<" + from + ">");
// 设置邮件接收者
messageHelper.setTo(to);
// 设置邮件主题
messageHelper.setSubject(subject);
// 设置邮件内容
messageHelper.setText(EmailUtils.emailContentTemplate("templates/EmailRegisterSuccessTemplate.html"), true);
// 发送邮件
javaMailSender.send(message);
} catch (Exception e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "发送邮件失败");
}
}
/**
* 发送文本邮件
*/
@Async(value = "emailThreadPool")
public void sendEmailAsText(String title, String text) {
try {
// 创建邮件对象
SimpleMailMessage smm = new SimpleMailMessage();
// 设置邮件发送者
smm.setFrom(nickname + "<" + from + ">");
// 设置邮件接收者
smm.setTo("646228430@qq.com");
// 设置邮件主题
smm.setSubject(title);
// 设置邮件内容
smm.setText(text);
// 发送邮件
javaMailSender.send(smm);
} catch (Exception e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "发送邮件失败");
}
}
/**
* 发送审核邮件
*/
@Async(value = "emailThreadPool")
public void sendEmailAsReview(List<String> tos, String subject, String reviewStatus) {
tos.forEach(to->{
sendEmailAsReview(to, subject, reviewStatus);
});
}
/**
* 发送审核邮件
*/
public void sendEmailAsReview(String to, String subject, String reviewStatus) {
try {
// 创建MIME消息
MimeMessage message = javaMailSender.createMimeMessage();
// 组合邮箱发送的内容
MimeMessageHelper messageHelper = new MimeMessageHelper(message, true);
// 设置邮件发送者
messageHelper.setFrom(nickname + "<" + from + ">");
// 设置邮件接收者
messageHelper.setTo(to);
// 设置邮件主题
messageHelper.setSubject(subject);
// 设置邮件内容
Map<String, Object> contentMap = new HashMap<>();
contentMap.put("reviewStatus", reviewStatus);
messageHelper.setText(EmailUtils.emailContentTemplate("templates/EmailReviewTemplate.html", contentMap), true);
// 发送邮件
javaMailSender.send(message);
} catch (Exception e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "发送邮件失败");
}
}
}

View File

@ -21,4 +21,20 @@ public class UserLoginRequest implements Serializable {
* 密码
*/
private String userPassword;
/**
* 用户邮箱
*/
private String userEmail;
/**
* 图形验证码 key
*/
private String captchaKey;
/**
* 图形验证码 验证码
*/
private String captchaCode;
}

View File

@ -17,13 +17,19 @@ public class UserRegisterRequest implements Serializable {
private String userAccount;
/**
* 密码
* 用户邮箱
*/
private String userPassword;
private String userEmail;
/**
* 确认密码
* 验证码 key
*/
private String checkPassword;
private String codeKey;
/**
* 验证码 value
*/
private String codeValue;
}

View File

@ -29,6 +29,11 @@ public class User implements Serializable {
*/
private String userPassword;
/**
* 用户邮箱
*/
private String userEmail;
/**
* 用户昵称
*/

View File

@ -0,0 +1,24 @@
package edu.whut.smilepicturebackend.model.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 图形验证码VO
*/
@Data
public class CaptchaVO implements Serializable {
/**
* 图形验证码 key
*/
private String captchaKey;
/**
* 图形验证码 base64 图片
*/
private String captchaImage;
private static final long serialVersionUID = 1L;
}

View File

@ -0,0 +1,15 @@
package edu.whut.smilepicturebackend.service;
import edu.whut.smilepicturebackend.model.vo.CaptchaVO;
/**
* 公共应用服务接口
*/
public interface MainApplicationService {
/**
* 获取图形验证码
*
* @return 图形验证码VO
*/
CaptchaVO getCaptcha();
}

View File

@ -25,7 +25,7 @@ public interface UserService extends IService<User> {
* @param checkPassword 校验密码
* @return 新用户 id
*/
long userRegister(String userAccount, String userPassword, String checkPassword);
void userRegister(String userAccount, String userPassword, String checkPassword);
/**
* 获取加密后的密码
*
@ -41,14 +41,11 @@ public interface UserService extends IService<User> {
*/
String getEncryptPassword(String userPassword);
/**
* 用户登录
*
* @param userAccount 用户账户
* @param userPassword 用户密码
* @return 脱敏后的用户信息
*/
LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request);
LoginUserVO userLogin(String userAccount, String userPassword, String captchaKey, String captchaCode,HttpServletRequest request);
/**
* 获得脱敏后的登录用户信息
@ -105,5 +102,12 @@ public interface UserService extends IService<User> {
*/
boolean isAdmin(User user);
long createUser(UserAddRequest userAddRequest);
/**
* 发送邮箱验证码
*
* @param userEmail 用户邮箱
* @return 验证码 key
*/
String sendEmailCode(String userEmail);
}

View File

@ -0,0 +1,41 @@
package edu.whut.smilepicturebackend.service.impl;
import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import edu.whut.smilepicturebackend.constant.CacheConstant;
import edu.whut.smilepicturebackend.model.vo.CaptchaVO;
import edu.whut.smilepicturebackend.service.MainApplicationService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 公共应用服务实现
*/
@Service
@RequiredArgsConstructor
public class MainApplicationServiceImpl implements MainApplicationService {
private final StringRedisTemplate redisCache;
/**
* 获取图形验证码
*
* @return 图形验证码VO
*/
@Override
public CaptchaVO getCaptcha() {
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
specCaptcha.setCharType(Captcha.TYPE_ONLY_NUMBER);
String captchaCode = specCaptcha.text().toLowerCase();
String captchaImage = specCaptcha.toBase64();
String captchaKey = UUID.randomUUID().toString();
// 把验证码存入 Redis 并且设置 1 分钟过期
redisCache.opsForValue().set(String.format(CacheConstant.CAPTCHA_CODE_KEY, captchaKey), captchaCode, 1, TimeUnit.MINUTES);
CaptchaVO captchaVO = new CaptchaVO();
captchaVO.setCaptchaKey(captchaKey);
captchaVO.setCaptchaImage(captchaImage);
return captchaVO;
}
}

View File

@ -1,21 +1,22 @@
package edu.whut.smilepicturebackend.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.RegexPool;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import edu.whut.smilepicturebackend.annotation.AuthCheck;
import edu.whut.smilepicturebackend.common.BaseResponse;
import edu.whut.smilepicturebackend.common.ResultUtils;
import edu.whut.smilepicturebackend.constant.CacheConstant;
import edu.whut.smilepicturebackend.constant.UserConstant;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import edu.whut.smilepicturebackend.exception.ThrowUtils;
import edu.whut.smilepicturebackend.manager.auth.StpKit;
import edu.whut.smilepicturebackend.model.dto.user.UserAddRequest;
import edu.whut.smilepicturebackend.manager.email.EmailManager;
import edu.whut.smilepicturebackend.model.dto.user.UserQueryRequest;
import edu.whut.smilepicturebackend.model.entity.User;
import edu.whut.smilepicturebackend.model.enums.UserRoleEnum;
@ -25,13 +26,15 @@ import edu.whut.smilepicturebackend.service.UserService;
import edu.whut.smilepicturebackend.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@ -45,49 +48,40 @@ import java.util.stream.Collectors;
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService{
private final BCryptPasswordEncoder passwordEncoder;
private final EmailManager emailManager;
private final StringRedisTemplate stringRedisTemplate;
/**
* 用户注册
*
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param checkPassword 校验密码
* @return
*/
@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
// 1. 校验参数
if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
public void userRegister(String userEmail, String codeKey, String codeValue) {
String KEY = String.format(CacheConstant.EMAIL_CODE_KEY, codeKey, userEmail);
// 获取 Redis 中的验证码
String code = stringRedisTemplate.opsForValue().get(KEY);
// 删除验证码
if (StrUtil.isEmpty(code) || !code.equals(codeValue)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "验证码错误");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
boolean existed = existedUserByEmail(userEmail);
if (existed) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号已存在, 请直接登录!");
}
if (userPassword.length() < 8 || checkPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
}
if (!userPassword.equals(checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}
// 2. 检查用户账号是否和数据库中已有的重复
LambdaQueryWrapper<User> lambda = Wrappers.lambdaQuery();
lambda.eq(User::getUserAccount, userAccount);
long count = this.baseMapper.selectCount(lambda);
if (count > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
}
// 3. 密码一定要加密
String encryptPassword = getEncryptPassword(userPassword);
// 4. 插入数据到数据库中
// 构建参数
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptPassword);
user.setUserName("无名");
user.setUserRole(UserRoleEnum.USER.getValue());
boolean saveResult = this.save(user);
if (!saveResult) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
}
return user.getId();
user.setUserEmail(userEmail);
// 默认值填充
fillDefaultValue(user);
boolean result = this.save(user);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR,"注册失败!");
stringRedisTemplate.delete(KEY);
emailManager.sendEmailAsRegisterSuccess(userEmail, "Smile图库 - 注册成功通知");
}
/**
* 获取加密后的密码
@ -103,30 +97,34 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
// return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
}
@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {
// 1. 校验
if (StrUtil.hasBlank(userAccount, userPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
public LoginUserVO userLogin(String userAccount, String userPassword, String captchaKey, String captchaCode,HttpServletRequest request) {
String KEY = String.format(CacheConstant.CAPTCHA_CODE_KEY, captchaKey);
// 获取 Redis 中的验证码
String code = stringRedisTemplate.opsForValue().get(KEY);
// 删除验证码
stringRedisTemplate.delete(KEY);
if (StrUtil.isEmpty(code) || !code.equals(captchaCode)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "验证码错误");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号错误");
User user;
if (ReUtil.isMatch(RegexPool.EMAIL, userAccount)) {
// 根据 userEmail 获取用户信息
user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getUserEmail, userAccount));
} else {
// 根据 userAccount 获取用户信息
user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getUserAccount, userAccount));
}
if (userPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码错误");
}
// 2. 根据账号查询用户不带密码
LambdaQueryWrapper<User> lambda = Wrappers.lambdaQuery();
lambda.eq(User::getUserAccount, userAccount);
User user = this.baseMapper.selectOne(lambda);
if (user == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");
throw new BusinessException(ErrorCode.DATA_ERROR, "用户不存在或密码错误");
}
// 3. BCryptPasswordEncoder 校验明文密码 vs 数据库里存的加密密码
// 校验密码
if (!passwordEncoder.matches(userPassword, user.getUserPassword())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或者密码错误");
}
// 4. 存用户登录态
// 用户登录态
request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE, user);
// 记录用户登录态到 Sa-token便于空间鉴权时使用注意保证该用户信息与 SpringSession 中的信息过期时间一致
StpKit.SPACE.login(user.getId());
@ -257,23 +255,39 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
return user != null && UserRoleEnum.ADMIN.getValue().equals(user.getUserRole());
}
/**
* 创建用户
*/
@Override
public long createUser(UserAddRequest req) {
// 1. DTO -> entity
User user = new User();
BeanUtil.copyProperties(req, user);
public String sendEmailCode(String userEmail) {
boolean existed = existedUserByEmail(userEmail);
if (existed) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号已存在, 请直接登录!");
}
// 发送邮箱验证码
String code = RandomUtil.randomNumbers(4);
emailManager.sendEmailCode(userEmail, "Smile图库 - 注册验证码", code);
// 生成一个唯一 ID, 后面注册前端需要带过来
String key = UUID.randomUUID().toString();
// 存入 Redis, 5 分钟过期
stringRedisTemplate.opsForValue().set(String.format(CacheConstant.EMAIL_CODE_KEY, key, userEmail), code, 5, TimeUnit.MINUTES);
return key;
}
// 2. 设定默认密码并加密
String encoded = passwordEncoder.encode(UserConstant.USER_DEFAULT);
user.setUserPassword(encoded);
// 3.插入数据库
boolean result = this.save(user);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
return user.getId();
public boolean existedUserByEmail(String userEmail) {
return this.getBaseMapper()
.exists(new QueryWrapper<User>()
.eq("user_email", userEmail)
);
}
/**
* 填充默认值
*/
public void fillDefaultValue(User user) {
String random = RandomUtil.randomString(6);
user.setUserAccount("user_" + random);
user.setUserName("用户_" + random);
user.setUserRole(UserRoleEnum.USER.getText());
user.setUserPassword(getEncryptPassword("12345678"));
}
}

View File

@ -1,5 +1,5 @@
server:
port: 8123
port: 8096
servlet:
context-path: /api
# cookie 30 天过期
@ -8,21 +8,19 @@ server:
max-age: 2592000
spring:
profiles:
active: local
application:
name: smile-picture-backend
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/smile-picture
url: jdbc:mysql://localhost:13308/smile-picture
username: root
password: 123456
# Redis 配置
redis:
database: 1
host: localhost
port: 6379
port: 36380
password: 123456
timeout: 5000
# Session 配置
@ -41,7 +39,7 @@ spring:
smile-picture:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/smile-picture
url: jdbc:mysql://localhost:13308/smile-picture
username: root
password: 123456
rules:
@ -61,6 +59,29 @@ spring:
algorithmClassName: edu.whut.smilepicturebackend.manager.sharding.PictureShardingAlgorithm
props:
sql-show: true #打印实际执行的sql
# 发送邮件配置QQ邮箱
mail:
host: ${smile-picture.email.host}
port: ${smile-picture.email.port}
nickname: Smile图库
username: ${smile-picture.email.username}
password: ${smile-picture.email.password}
protocol: ${smile-picture.email.protocol}
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
ssl:
enable: true
socketFactory:
port: ${smile-picture.email.port}
class: javax.net.ssl.SSLSocketFactory
mybatis-plus:
type-aliases-package: edu.whut.smilepicturebackend.model.entity
configuration:

View File

@ -60,6 +60,29 @@ spring:
algorithmClassName: edu.whut.smilepicturebackend.manager.sharding.PictureShardingAlgorithm
props:
sql-show: false # 生产建议关闭;需要排查时可临时改 true
# 发送邮件配置QQ邮箱
mail:
host: ${smile-picture.email.host}
port: ${smile-picture.email.port}
nickname: Smile图库
username: ${smile-picture.email.username}
password: ${smile-picture.email.password}
protocol: ${smile-picture.email.protocol}
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
ssl:
enable: true
socketFactory:
port: ${smile-picture.email.port}
class: javax.net.ssl.SSLSocketFactory
mybatis-plus:
type-aliases-package: edu.whut.smilepicturebackend.model.entity
configuration:

View File

@ -9,6 +9,7 @@
<result property="userAccount" column="user_account" jdbcType="VARCHAR"/>
<result property="userPassword" column="user_password" jdbcType="VARCHAR"/>
<result property="userName" column="user_name" jdbcType="VARCHAR"/>
<result property="userEmail" column="user_email" jdbcType="VARCHAR"/>
<result property="userAvatar" column="user_avatar" jdbcType="VARCHAR"/>
<result property="userProfile" column="user_profile" jdbcType="VARCHAR"/>
<result property="userRole" column="user_role" jdbcType="VARCHAR"/>
@ -20,7 +21,7 @@
<sql id="Base_Column_List">
id,user_account,user_password,
user_name,user_avatar,user_profile,
user_name,user_emial,user_avatar,user_profile,
user_role,edit_time,create_time,
update_time,is_delete
</sql>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="email code">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<!--邮箱验证码模板-->
<body>
<div style="background-color:#ECECEC; padding: 35px;">
<table cellpadding="0" align="center"
style="width: 800px;height: 100%; margin: 0px auto; text-align: left; position: relative; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; font-size: 14px; font-family:微软雅黑, 黑体; line-height: 1.5; box-shadow: rgb(153, 153, 153) 0px 0px 5px; border-collapse: collapse; background-position: initial initial; background-repeat: initial initial;background:#fff;">
<tbody>
<tr>
<th valign="middle"
style="height: 25px; line-height: 25px; padding: 15px 35px; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: #409EFF; background-color: #409EFF; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 0px; border-bottom-left-radius: 0px;">
<font face="微软雅黑" size="5" style="color: rgb(255, 255, 255); ">Smile图库</font>
</th>
</tr>
<tr>
<td style="word-break:break-all">
<div style="padding:25px 35px 40px; background-color:#fff;opacity:0.8;">
<h2 style="margin: 5px 0px; ">
<font color="#333333" style="line-height: 20px; ">
<font style="line-height: 22px; " size="4">
注册操作:</font>
</font>
</h2>
<!-- 中文 -->
<p>
你好!你正在注册 <a href="https://picture.baolong.icu">Smile图库</a> ,你的验证码为
<span style="font-size: 20px">BOOT_code_END</span>,验证码有效时间<span style="color: red">5分钟</span>
</p>
<br>
<div style="width:100%;margin:0 auto;">
<div
style="padding:10px 10px 0;border-top:1px solid #ccc;color:#747474;margin-bottom:20px;line-height:1.3em;font-size:12px;">
<p>Smile</p>
<p>Q Q: 646228430</p>
<p>微信: This_isZhangsan</p>
<p>联系邮箱646228430@qq.com</p>
<br>
<p>此为系统邮件,请勿回复<br>
Please do not reply to this system email
</p>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="email code">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<!--邮箱验证码模板-->
<body>
<div style="background-color:#ECECEC; padding: 35px;">
<table cellpadding="0" align="center"
style="width: 800px;height: 100%; margin: 0px auto; text-align: left; position: relative; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; font-size: 14px; font-family:微软雅黑, 黑体; line-height: 1.5; box-shadow: rgb(153, 153, 153) 0px 0px 5px; border-collapse: collapse; background-position: initial initial; background-repeat: initial initial;background:#fff;">
<tbody>
<tr>
<th valign="middle"
style="height: 25px; line-height: 25px; padding: 15px 35px; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: #409EFF; background-color: #409EFF; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 0px; border-bottom-left-radius: 0px;">
<font face="微软雅黑" size="5" style="color: rgb(255, 255, 255); ">Smile图库</font>
</th>
</tr>
<tr>
<td style="word-break:break-all">
<div style="padding:25px 35px 40px; background-color:#fff;opacity:0.8;">
<h2 style="margin: 5px 0px; ">
<font color="#333333" style="line-height: 20px; ">
<font style="line-height: 22px; " size="4">
注册成功:</font>
</font>
</h2>
<!-- 中文 -->
<p>
你好!感谢你注册 <a href="https://picture.baolong.icu">Smile图库</a> ,您的初始密码为<span
style="color: red; font-weight: bold">12345678</span>,为了确保你的账号安全, 请尽快登录修改密码!
</p>
<br>
<div style="width:100%;margin:0 auto;">
<div
style="padding:10px 10px 0;border-top:1px solid #ccc;color:#747474;margin-bottom:20px;line-height:1.3em;font-size:12px;">
<p>Smile</p>
<p>Q Q: 646228430</p>
<p>微信: This_isZhangsan</p>
<p>联系邮箱646228430@qq.com</p>
<br>
<p>此为系统邮件,请勿回复<br>
Please do not reply to this system email
</p>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="email code">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<!--邮箱验证码模板-->
<body>
<div style="background-color:#ECECEC; padding: 35px;">
<table cellpadding="0" align="center"
style="width: 800px;height: 100%; margin: 0px auto; text-align: left; position: relative; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; font-size: 14px; font-family:微软雅黑, 黑体; line-height: 1.5; box-shadow: rgb(153, 153, 153) 0px 0px 5px; border-collapse: collapse; background-position: initial initial; background-repeat: initial initial;background:#fff;">
<tbody>
<tr>
<th valign="middle"
style="height: 25px; line-height: 25px; padding: 15px 35px; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: #409EFF; background-color: #409EFF; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 0px; border-bottom-left-radius: 0px;">
<font face="微软雅黑" size="5" style="color: rgb(255, 255, 255); ">Smile图库</font>
</th>
</tr>
<tr>
<td style="word-break:break-all">
<div style="padding:25px 35px 40px; background-color:#fff;opacity:0.8;">
<h2 style="margin: 5px 0px; ">
<font color="#333333" style="line-height: 20px; ">
<font style="line-height: 22px; " size="4">
审核通知:</font>
</font>
</h2>
<!-- 中文 -->
<p>
你好!您上传的图片,<span style="color: red; font-weight: bold">BOOT_reviewStatus_END</span>
</p>
<br>
<div style="width:100%;margin:0 auto;">
<div
style="padding:10px 10px 0;border-top:1px solid #ccc;color:#747474;margin-bottom:20px;line-height:1.3em;font-size:12px;">
<p>Smile</p>
<p>Q Q: 646228430</p>
<p>微信: This_isZhangsan</p>
<p>联系邮箱646228430@qq.com</p>
<br>
<p>此为系统邮件,请勿回复<br>
Please do not reply to this system email
</p>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>