目录
完整项目链接
介绍
”从众美食外卖平台“专门为餐饮企业(餐厅、饭店)定制的一款软件产品,包括系统管理后台和移动端应用两部分。其中系统管理后台主要提供给餐饮企业内部员工使用,可以对餐厅的菜品、套餐、订单等进行管理维护。移动端应用主要提供给消费者使用,可以在线浏览菜品、添加购物车、下单等.
技术栈
SpringBoot,MybatisPlus,MySQL,Redis,SpringCache,ElementUI,Vue,Axios,Nignx,Git;
项目核心技术复盘
1.后端 Controller 层返回结果统一封装的对象
后端的controller层接收完前端的请求后,要返回什么样的结果是需要按情况变化的,但如果每一个controller返回的结果不一样,前端也要用不同的数据类型进行接收。为了避免麻烦,制定统一的controller层返回对象是很有必要的。
public class R<T> implements Serializable {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
2.捕获异常返回的配置
请求到controller之后,调用service进行业务操作,一旦报错,一般我们会在controller中使用try-catch进行异常捕获,但是这个方法有一定的弊端,try-catch和业务代码混杂在一起,耦合度高,不易阅读。
我们可以配置全局异常处理器,通过SpringAop切面编程的技术,将全局异常处理器织入到所有被RestController或者Controller注解所注解的类。这样我们就可以把所有controller层中需要写的try-catch全部写到一个类中,代码更简洁,复用性更高。
自定义异常
/*
* 自定义业务异常
* 继承了运行时异常
* 1.查询当前分类是否关联了套餐或者菜品,如果已经关联不允许删除,抛出这个业务异常
* 该异常将在全局异常处理器中被捕获,并获取它携带的的信息
* */
public class CustomException extends RuntimeException{
public CustomException(String message){
super(message);
}
}
全局异常处理
/**
*
* 全局异常处理
*/
@ControllerAdvice(annotations = {RestController.class, Controller.class}) //表示拦截哪些类型的controller注解
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
/**
* 处理SQLIntegrityConstraintViolationException异常的方法
* @return
*/
@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public R<String> exceptionHandle(SQLIntegrityConstraintViolationException exception){
log.error(exception.getMessage()); //报错记得打日志
if (exception.getMessage().contains("Duplicate entry")){
//获取已经存在的用户名,这里是从报错的异常信息中获取的
String[] split = exception.getMessage().split(" ");
String msg = split[2] + "这个用户名已经存在";
return R.error(msg);
}
return R.error("未知错误");
}
/**
* 异常处理方法
* 这是自己写的异常,捕获这个异常,并收到它携带的信息
* @return
*/
@ExceptionHandler(CustomException.class)
public R<String> exceptionHandler(CustomException ex){
log.error(ex.getMessage());
return R.error(ex.getMessage());
}
}
3.配置消息资源转换器(解决雪花算法到前端的精度缺失)
数据库的主键大都是由mybatis-plus的主键自动生成策略之雪花算法生成的,雪花算法生成的是一个Long类型的数字,而雪花算法生成的主键传输到前端的时候会出现精度丢失现象导致前端拿到的id和数据库中的id不一致。那么前端再发出请求无论是通过id查找数据还是修改数据都会因为id不一致而修改失败
@Configuration
public class MyWebMvcConfig extends WebMvcConfigurationSupport {
/*
* 拓展消息资源转换器
* */
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter messageConverter=new MappingJackson2HttpMessageConverter();
messageConverter.setObjectMapper(new JacksonObjectMapper());
//将我们自定义的消息转换器,添加进行集合中,并把优先级设置为最高
converters.add(0,messageConverter);
}
}
/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
*/
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
public JacksonObjectMapper() {
super();
//收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
//反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
.addSerializer(BigInteger.class, ToStringSerializer.instance)
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));
//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(simpleModule);
}
}
4.判断用户是否已经完成登录
登录添加一个过滤器或拦截器,判断用户是否已经完成登录,如果没有登录则返回提示信息,跳转到登录页面。
过滤器LoginCheckFilter处理逻辑如下
①. 获取本次请求的URI
②. 判断本次请求, 是否需要登录, 才可以访问
③. 如果不需要,则直接放行
④. 判断登录状态,如果已登录,则直接放行
⑤. 如果未登录, 则返回未登录结果
/**
* 检查用户是否已经完成登录
*/
@WebFilter(filterName = "loginCheckFilter",urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter {
//路径匹配器,支持通配符
public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//1、获取本次请求的URI
String requestURI = request.getRequestURI();// /backend/index.html
log.info("拦截到请求:{}",requestURI);
//定义不需要处理的请求路径
String[] urls = new String[]{
"/employee/login",//登录不拦截
"/employee/logout",//退出登录不拦截
"/backend/**",//backend的静态资源不拦截
"/front/**",//front的静态资源不拦截
"/user/sendMsg",//移动端发送短信不拦截
"/user/login"//移动端登录不拦截
};
//2、判断本次请求是否需要处理
boolean check = check(urls, requestURI);
//3、如果不需要处理,则直接放行
if(check){
log.info("本次请求{}不需要处理",requestURI);
filterChain.doFilter(request,response);
return;
}
//4-1、判断后端登录状态,如果已登录,则直接放行
if(request.getSession().getAttribute("employee") != null){
log.info("用户后端已登录,用户id为:{}",request.getSession().getAttribute("employee"));
//将用户id放入线程
long empId = (long) request.getSession().getAttribute("employee");
BaseContext.setCurrentId(empId);
filterChain.doFilter(request,response);
return;
}
//4-2、判断移动端登录状态,如果已登录,则直接放行
if(request.getSession().getAttribute("user") != null){
log.info("用户移动端已登录,用户id为:{}",request.getSession().getAttribute("user"));
Long userId = (Long) request.getSession().getAttribute("user");
BaseContext.setCurrentId(userId);
filterChain.doFilter(request,response);
return;
}
//5、如果未登录则返回未登录结果,通过输出流方式向客户端页面响应数据
//未登录的跳转页面在前端的request.js中实现,将R对象转成JSON后传给这个js文件
log.info("用户未登录");
response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
return;
}
/**
* 路径匹配,检查本次请求是否需要放行
* @param urls
* @param requestURI
* @return
*/
public boolean check(String[] urls,String requestURI){
for (String url : urls) {
boolean match = PATH_MATCHER.match(url, requestURI);
if(match){
return true;
}
}
return false;
}
}
5.获取当前登录用户的id
客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个线程:
1). LoginCheckFilter的doFilter方法
2). EmployeeController的update方法
3). MyMetaObjectHandler的updateFill方法
我们可以在上述类的方法中加入如下代码(获取当前线程ID,并输出):
long id = Thread.currentThread().getId();
log.info("线程id为:{}",id);
结论:执行编辑员工功能进行验证,通过观察控制台输出可以发现,一次请求对应的线程id是相同的:
经过上述的分析之后,发现可以使用JDK提供的ThreadLocal类, 来解决此问题
/**
* 基于ThreadLocal封装工具类,用户保存和获取当前登录用户id
*/
public class BaseContext {
//用来存储用户id
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
/**
* 设置值
* @param id
*/
public static void setCurrentId(Long id){
threadLocal.set(id);
}
/**
* 获取值
* @return
*/
public static Long getCurrentId(){
return threadLocal.get();
}
}
6.邮箱发送验证码
移动端的用户通过接受邮箱来进行登录
首先记得开启邮箱授权服务
UserServiceImpl
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService{
//把yml配置的邮箱号赋值到from
@Value("${spring.mail.username}")
private String from;
//发送邮件需要的对象
@Resource
private JavaMailSender javaMailSender;
//邮件发送人
@Override
public void sendMsg(String to, String subject, String text) {
//发送简单邮件,简单邮件不包括附件等别的
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(text);
//发送邮件
javaMailSender.send(message);
}
}
pom文件加入
<!-- 邮件服务 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Thymeleaf 模版,用于发送模版邮件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
yml也要记得配置
# 邮箱配置
mail:
host: smtp.qq.com # 发送邮件的服务器地址
username: 146887001000@qq.com # 开启 IMAP/SMTP服务 的qq邮箱的账号
password: xxxxxxxxxxxx # 开启 IMAP/SMTP服务 获得的授权码,而不是qq邮箱的登录密码
default-encoding: UTF-8
# QQ邮箱(mail.qq.com)
# POP3服务器地址:pop.qq.com(端口:110)
# SMTP服务器地址:smtp.qq.com(端口:25
7.Redis 缓存邮箱验证码
前面我们已经实现了移动端邮箱验证码登录,随机生成的验证码我们是保存在HttpSession中的。但是在我们实际的业务场景中,一般验证码都是需要设置过期时间的,如果存在HttpSession中就无法设置过期时间,此时我们就需要对这一块的功能进行优化。
现在需要改造为将验证码缓存在Redis中,具体的实现思路如下:
1. 在服务端UserController中注入RedisTemplate对象,用于操作Redis;
2. 在服务端UserController的sendMsg方法中,将随机生成的验证码缓存到Redis中,并设置有效期为5分钟;
3. 在服务端UserController的login方法中,从Redis中获取缓存的验证码,如果登录成功则删除Redis中的验证码;
编写Redis 配置类RedisConfig
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
//默认的Key序列化器为:JdkSerializationRedisSerializer
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
}
在SendMsg 方法中,加上
//需要将生成的验证码保存到Redis,设置过期时间
redisTemplate.opsForValue().set(phone, code, 5, TimeUnit.MINUTES);
在login 方法中
//从Redis中获取缓存的验证码
Object codeInSession = redisTemplate.opsForValue().get(phone);
//从Redis中删除缓存的验证码
redisTemplate.delete(phone);