项目简介
该项目是一个外卖点餐系统,它分为后台管理端和用户移动端两方面开发,后台管理端为商家提供管理菜品套餐的服务,移动端为用户提供点菜下单功能。最终通过git管理项目,并用nginx部署前端,tomcat部署后端,使用mysql主从复制,从库读取,主库写入,再用shell脚本部署到服务器上。
技术栈
涉及的技术包括 Spring,Springboot,Mybatis-plus,MySQL,Redis,Linux,Git,Spring Cache,Sharding-JDBC,Nginx,Swagger。
架构搭建
公共字段填充
公共字段,多个表存在的共有字段,为简化操作,进行同一管理
自动填充方法
- 自动填充是mybatis-plus提供的功能,首先在pom中导入mybatis-plus坐标
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
- 在各个实体类中,对属于公共字段的属性添加注解
fill = FieldFill.INSERT 是一种填充策略,表示插入时自动填充该字
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
- 创建一个公共字段类,实现MetaObjectHandler接口, 重写两个方法insertFill和updateFill,注意添加@Component注解,声明其成为一个bean对象,注入容器
@Component
@Slf4j
public class MyMetaObjecthandler implements MetaObjectHandler {
/**
* 插入操作,自动填充
* */
@Override
public void insertFill(MetaObject metaObject) {
log.info("公共字段自动填充[insert]...");
log.info(metaObject.toString());
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime",LocalDateTime.now());
metaObject.setValue("createUser",BaseContext.getCurrentId());
metaObject.setValue("updateUser",BaseContext.getCurrentId());
}
@Override
public void updateFill(MetaObject metaObject) {
log.info("公共字段自动填充[update]...");
log.info(metaObject.toString());
metaObject.setValue("updateTime",LocalDateTime.now());
metaObject.setValue("updateUser",BaseContext.getCurrentId());
}
}
公共字段设置id
数据库中不少表需要用户id填充,但是在公共字段类中无法获取request对象和session对象,这里考虑使用ThreadLocal来处理
实现步骤
- 编写一个基于ThreadLocal的BaseContext工具类,用于存取用户id
/**
* 基于ThreadLocal封装工具类,用户保存和获取当前登录用户id
* **/
public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
}
- 在过滤器的放行方法中调用BaseContext来获取当前登录用户id
- 在公共字段填充时,将用户id自动填充即可
全局异常处理类
public class CustomException extends RuntimeException{
public CustomException(String message){
super(message);
}
}
定义全局异常处理器,对Controller中的异常进行捕获
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
/**
* SQL异常处理方法
* 使用@ExceptionHandler注解的异常处理方法,用于处理特定类型的异常
* SQLIntegrityConstraintViolationException 数据库约束冲突异常
*/
@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){
log.error(ex.getMessage());
if(ex.getMessage().contains("Duplicate entry")){
String[] split = ex.getMessage().split(" ");
String msg = split[2] + "已存在";
return R.error(msg);
}
return R.error("未知错误");
}
@ExceptionHandler(CustomException.class)
public R<String> exceptionHandler(CustomException ex){
log.error(ex.getMessage());
return R.error(ex.getMessage());
}
}
返回结果封装的实体类
为了便于前后端数据传递,使用对象的形式封装数据
@Data
public class R<T> {
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;
}
}
管理端业务开发
员工管理相关业务
员工登录
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
//1、将页面提交的密码password进行md5加密处理
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
//2、根据页面提交的用户名username查询数据库
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername, employee.getUsername());
//getOne 返回的是一个代理对象,而不是实际的实体对象
Employee emp = employeeService.getOne(queryWrapper);
//3、如果没有查询到则返回登录失败结果
if (emp == null) {
return R.error("登录失败");
}
//4、密码比对,如果不一致则返回登录失败结果
if (!emp.getPassword().equals(password)) {
return R.error("登录失败");
}
//5、查看员工状态,如果为已禁用状态,则返回员工已禁用结果
if (emp.getStatus() == 0) {
return R.error("账号已禁用");
}
//6、登录成功,将员工id存入Session并返回登录成功结果
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
员工退出
点击退出时,清除session中存储的用户id即可
@PostMapping("/logout")
public R<String> logout(HttpServletRequest request) {
//清楚session存储的数据
request.getSession().removeAttribute("employee");
return R.success("退出成功");
}
过滤器拦截
防止用户在未登录情况下访问过多的资源
处理逻辑
- 获取本次请求的URI
- 判断本次请求是否需要处理
- 如果不需要处理则放行
- 判断登录状态,如果登录,则放行
- 如果未登录,则返回未登录
在启动类加上@ServletComponentScan注解
过滤器配置类注解@WebFilter(filterName = "拦截器类名(首字母小写)",urlPatterns = "要拦截的路径")
@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/**",
"/front/**",
"/common/**",
"/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"));
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;
}
log.info("用户未登录");
//5、如果未登录则返回未登录结果,
// 通过输出流方式向客户端页面响应数据
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;
}
}
员工信息修改
员工状态修改
注意:js对于Long型数据处理时会丢失精度,只能保证前16位,
解决措施:服务端给页面响应json数据时,将Long型数据统一转为String字符串
员工信息修改
修改逻辑:
- 数据回显:通过用户id查询数据,显示到界面
- 数据保存:将修改后的用户数据,保存至数据库
@GetMapping("/{id}")
public R<Employee>getById(@PathVariable Long id){
log.info("id:{}",id);
Employee employee = employeeService.getById(id);
return R.success(employee);
}
@PutMapping
public R<String> update(@RequestBody Employee employee){
log.info(employee.toString());
//id status
employeeService.updateById(employee);
return R.success("修改员工信息成功");
}
员工信息分页查询
分页查询逻辑
- 前端页面发送ajax请求,将分页查询参数(page,pageSize,name)传给服务端
- 服务端Controller接受页面提交的数据,调用Service查询数据
- Service调用Mapper操作数据库,查询分页数据
- Controller层将查询到的分页数据响应给页面
- 页面接收到分页数据并通过ElementUI的Table组件展示到页面上
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return mybatisPlusInterceptor;
}
}
新增员工
@PostMapping
public R<String> save(HttpServletRequest request, @RequestBody Employee employee) {
log.info("新增员工,员工信息:{}", employee.toString());
//得到session中存储的用户id
Long empId = (Long) request.getSession().getAttribute("employee");
//设置初始密码
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
employee.setCreateUser(empId);
employee.setUpdateUser(empId);
employeeService.save(employee);
return R.success("新增员工成功");
}
用户id使用雪花自增算法进行生成
分类管理相关业务
分类的分页查询
和上面员工的分页查询类似,执行过程如下:
- 页面发送ajax请求,将分页查询参数(page,pageSize)提交到服务端
- 服务端Controller接受页面提交的数据并调用Service查询数据
- Service调用Mapper操作数据库,查询分页数据
- Controller将查询到的分页数据响应给页面
- 页面显示数据
代码开发:
- 创建分页构造器 Page<Category> pageInfo = new Page<>(page,pageSize);
- 创建条件构造器LambaQueryWrapper
- 注入的CategoryService调用Mapper对象进行分页数据查询 categoryService.page(pageInfo,queryWrapper)
- 返回给页面即可 return R.success(pageInfo)
具体代码如下:
@GetMapping("/page")
public R<Page> page(int page, int pageSize) {
log.info("page : {},pagesize : {}", page, pageSize);
//初始化分页构造器
Page pageInfo = new Page(page, pageSize);
//创建条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
//添加排序条件
queryWrapper.orderByAsc(Category::getSort);
//执行查询
categoryService.page(pageInfo, queryWrapper);
return R.success(pageInfo);
}
新增分类
新增分类包括新增菜品分类和新增套餐分类
根据前端传回的数据直接插入数据库即可
@PostMapping
public R<String> save(@RequestBody Category category) {
log.info("category : {}", category);
categoryService.save(category);
return R.success("新增分类成功");
}
菜品(套餐)分类修改
简单的数据库修改操作
@PutMapping
public R<String> update(@RequestBody Category category){
log.info("修改分类信息 : {}",category);
categoryService.updateById(category);
return R.success("修改分类信息成功");
}
菜品(套餐)的分类删除
在进行删除时,需要注意如果该分类关联了菜品或者套餐时,不允许进行删除
菜品管理业务相关业务
菜品分页查询
通过观察Dish表,数据库中并没有存储菜品分类这个数据,只存储了菜品分类id,这里返回页面的数据不能简单实用Dish进行传输,考虑使用新建的Dto数据传输对象进行数据传输
前端的ajax请求包含(page,pageSize,name)
图片上传下载
文件上传
将本地图片等文件上传到服务器上,可以供其他用户浏览或下载的过程。
具体代码如下:
此时我们上传图片后,是存放在临时位置,关闭浏览器,图片文件就不存在了,无法再次浏览,我们需要将上传的图片下载到本地磁盘存储,这样浏览器上就可以进行图片回显,访问的时候才能看到图片
文件下载
指文件从服务器传输到本地计算机的过程
新增菜品
- 首先将菜品(套餐)分类的名称返回到页面上
@GetMapping("/list")
public R<List<Category>> list(Category category){
//条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
//添加条件
queryWrapper.eq(category.getType()!=null, Category::getType, category.getType());
//添加排序条件
queryWrapper.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);
List<Category> list = categoryService.list(queryWrapper);
return R.success(list);
}
- 多表存入,在进行插入时,需要进行事务管理,防止多表操作崩溃和数据库安全, @Transactional 开启事务; @EnableTransactionManagement 在启动类加入,支持事务开启
修改菜品
与之前的处理一样,第一步回显数据,第二步更新数据
数据查询涉及到dish表和dish_flavor表的查询,查询结果封装到dishDto返回页面进行数据显示
根据菜品id进行数据查询,包括菜品信息与菜品口味的查询
菜品信息的存储
删除菜品
删除可以是批量删除菜品,前端传回ids,后端考虑使用List<Long>类型进行数据接受
批量删除时,传回的ids为多个菜品的id
菜品停售与起售
在进行菜品停售与起售操作时,考虑该菜品是否在套餐当中,如果套餐含有该菜品,则不能修改此菜品的状态
套餐管理相关业务
分页查询
和菜品分页查询类似,将套餐信息分类查询出来,通过stream流方式,将套餐信息拷贝到SetmealDto中,再根据套餐id查询套餐分类对象,将套餐分类信息也拷贝到SetmealDto中,最后返回到页面
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name) {
//分页构造器
Page<Setmeal> pageinfo = new Page<>(page, pageSize);
Page<SetmealDto> setmealDtoPage = new Page<>();
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
//添加模糊查询条件
queryWrapper.like(name != null, Setmeal::getName, name);
queryWrapper.orderByDesc(Setmeal::getUpdateTime);
setmealService.page(pageinfo, queryWrapper);
//对象拷贝
BeanUtils.copyProperties(pageinfo, setmealDtoPage, "records");
List<Setmeal> records = pageinfo.getRecords();
List<SetmealDto> setmealDtoList = records.stream().map((item) -> {
SetmealDto setmealDto = new SetmealDto();
//对象拷贝
BeanUtils.copyProperties(item, setmealDto);
Long categoryId = item.getCategoryId();
//根据分类id查询
Category category = categoryService.getById(categoryId);
if (category != null) {
String categoryName = category.getName();
setmealDto.setCategoryName(categoryName);
}
return setmealDto;
}).collect(Collectors.toList());
setmealDtoPage.setRecords(setmealDtoList);
return R.success(setmealDtoPage);
}
新增套餐
新增套餐操作含有多表处理,分别操作setmeal和setmeal_dish
修改套餐
首先查询数据,回显到页面上,之后依次将setmeal和setmeal_dish插入到数据库中
删除套餐
和删除菜品一样,也是需要先判断套餐状态,删除时考虑将套餐下关联的菜品
套餐状态修改
订单明细
移动端业务开发
用户登录与退出
用户登录
点击登录按钮,发送ajax请求
通过map的key-value形式进行数据接收
@PostMapping("/login")
public R<User> login(@RequestBody Map map, HttpSession session){
log.info(map.toString());
//获取手机号
String phone = map.get("phone").toString();
//获取验证码
String code = map.get("code").toString();
//从Session中获取保存的验证码
Object codeInSession = session.getAttribute(phone);
//进行验证码的比对(页面提交的验证码和Session中保存的验证码比对)
if(codeInSession != null && codeInSession.equals(code)){
//如果能够比对成功,说明登录成功
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone,phone);
User user = userService.getOne(queryWrapper);
if(user == null){
//判断当前手机号对应的用户是否为新用户,如果是新用户就自动完成注册
user = new User();
user.setPhone(phone);
user.setStatus(1);
userService.save(user);
}
session.setAttribute("user",user.getId());
return R.success(user);
}
return R.error("登录失败");
}
用户退出
用户执行退出操作时,删除session中存储的用户id
@PostMapping("/loginout")
public R<String> loginout(HttpServletRequest request){
request.getSession().removeAttribute("user");
return R.success("退出成功");
}
阿里云短信验证码
收货地址
@GetMapping("/list")
public R<List<AddressBook>> list(AddressBook addressBook) {
addressBook.setUserId(BaseContext.getCurrentId());
log.info("addressBook:{}", addressBook);
//条件构造器
LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(null != addressBook.getUserId(), AddressBook::getUserId, addressBook.getUserId());
queryWrapper.orderByDesc(AddressBook::getUpdateTime);
//SQL:select * from address_book where user_id = ? order by update_time desc
return R.success(addressBookService.list(queryWrapper));
}
设置默认地址,根据条件修改地址表中的is_default字段,首先把所有的is_default设置为0,之后把页面传输的数据置为1
@PutMapping("default")
public R<AddressBook> setDefault(@RequestBody AddressBook addressBook) {
log.info("addressBook:{}", addressBook);
LambdaUpdateWrapper<AddressBook> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(AddressBook::getUserId, BaseContext.getCurrentId());
wrapper.set(AddressBook::getIsDefault, 0);
//SQL:update address_book set is_default = 0 where user_id = ?
addressBookService.update(wrapper);
addressBook.setIsDefault(1);
//SQL:update address_book set is_default = 1 where id = ?
addressBookService.updateById(addressBook);
return R.success(addressBook);
}
菜品和套餐展示
菜品选口味
将菜品查询接口("/list")改写为返回DishDto对象,方便前端信息传输
套餐点击展示
通过前端的请求,可以看到页面传递Get请求,传递的是套餐id
@GetMapping("/dish/{id}")
public R<List<DishDto>> dish(@PathVariable("id") Long setmealId){
log.info("SetmealId :{}",setmealId);
LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SetmealDish::getSetmealId,setmealId);
//获取套餐里所有菜品
List<SetmealDish> list = setmealDishService.list(queryWrapper);
List<DishDto> dishDtos = list.stream().map((item->{
DishDto dishDto = new DishDto();
BeanUtils.copyProperties(item,dishDto);
Long dishId = item.getDishId();
Dish dish = dishService.getById(dishId);
BeanUtils.copyProperties(dish,dishDto);
return dishDto;
})).collect(Collectors.toList());
return R.success(dishDtos);
}
购物车
商品数量减少,商品减少时,需要判断该菜品的数量,如果数量大于1,则让数量减1即可,如果为1,则直接将该菜品从数据库中删除
@PostMapping("/sub")
public R<String> remove(@RequestBody ShoppingCart shoppingCart) {
Long currentId = BaseContext.getCurrentId();
shoppingCart.setUserId(currentId);
//查询当前菜品或套餐是否在购物车中
Long dishId = shoppingCart.getDishId();
LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ShoppingCart::getUserId, currentId);
if (dishId != null) {
queryWrapper.eq(ShoppingCart::getDishId, dishId);
} else {
queryWrapper.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
}
ShoppingCart cartServiceOne = shoppingCartService.getOne(queryWrapper);
//若菜品数量大于1
if (cartServiceOne.getNumber()>1){
Integer number = cartServiceOne.getNumber();
cartServiceOne.setNumber(number-1);
shoppingCartService.updateById(cartServiceOne);
}else {
shoppingCartService.remove(queryWrapper);
}
return R.success("商品减去成功");
}
下单操作
在完成菜品选择后,点击去结算按钮,进行订单结算处理
收货地址删除
从页面请求可以看出,前端页面传递用户id
项目优化
使用Redis缓存
缓存验证码
首先引入pom依赖
之前项目将发送的验证码存入到浏览器的session中,这里将验证码改为存储到redis里,设置key为phone,value为code
在后续的登录验证时,从redis里根据phone作为key来查询验证码是否匹配,
如果登录成功,那么则删除掉redis中存储的验证码
缓存菜品查询数据
每次点击菜品分类都要进行一次数据库的查询,这样会造成很大的资源开销,考虑将查询到的数据按照菜品分类存入redis中,设置一个数据生存时间,这样操作后,在第二次点击同一分类后,便不会再查询数据库,直接从redis中获取数据,降低服务器压力
缓存逻辑
- 首先构造唯一的key值,然后判断redis中是否存在,
- 如果存在则直接返回,若为空则执行数据库查询操作,查询完后将数据存入redis中。
- 使用redis缓存菜品数据,在菜品信息修改和删除时,同样需要修改redis中存储的数据,为方便操作,直接对该信息进行删除。
Spring Cache缓存套餐数据
- pom中导入Spring Cache依赖
- 启动类上加入注解,开启缓存功能
- 使用Spring Cache 注解的方式开启缓存
- 缓存查询的套餐信息
- 清除缓存