点餐系统小结

项目简介

该项目是一个外卖点餐系统,它分为后台管理端和用户移动端两方面开发,后台管理端为商家提供管理菜品套餐的服务,移动端为用户提供点菜下单功能。最终通过git管理项目,并用nginx部署前端,tomcat部署后端,使用mysql主从复制,从库读取,主库写入,再用shell脚本部署到服务器上。

技术栈

涉及的技术包括 Spring,Springboot,Mybatis-plus,MySQL,Redis,Linux,Git,Spring Cache,Sharding-JDBC,Nginx,Swagger。

架构搭建

公共字段填充

公共字段,多个表存在的共有字段,为简化操作,进行同一管理

自动填充方法
  1. 自动填充是mybatis-plus提供的功能,首先在pom中导入mybatis-plus坐标
<dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
        </dependency>
  1. 在各个实体类中,对属于公共字段的属性添加注解

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;
  1. 创建一个公共字段类,实现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来处理

实现步骤
  1. 编写一个基于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();
    }
}
  1. 在过滤器的放行方法中调用BaseContext来获取当前登录用户id

  1. 在公共字段填充时,将用户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("退出成功");
    }
过滤器拦截

防止用户在未登录情况下访问过多的资源

处理逻辑

  1. 获取本次请求的URI
  2. 判断本次请求是否需要处理
  3. 如果不需要处理则放行
  4. 判断登录状态,如果登录,则放行
  5. 如果未登录,则返回未登录

在启动类加上@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字符串

员工信息修改

修改逻辑:

  1. 数据回显:通过用户id查询数据,显示到界面
  2. 数据保存:将修改后的用户数据,保存至数据库

    @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("修改员工信息成功");
    }
员工信息分页查询

分页查询逻辑

  1. 前端页面发送ajax请求,将分页查询参数(page,pageSize,name)传给服务端
  2. 服务端Controller接受页面提交的数据,调用Service查询数据
  3. Service调用Mapper操作数据库,查询分页数据
  4. Controller层将查询到的分页数据响应给页面
  5. 页面接收到分页数据并通过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使用雪花自增算法进行生成

分类管理相关业务

分类的分页查询

和上面员工的分页查询类似,执行过程如下:

  1. 页面发送ajax请求,将分页查询参数(page,pageSize)提交到服务端
  2. 服务端Controller接受页面提交的数据并调用Service查询数据
  3. Service调用Mapper操作数据库,查询分页数据
  4. Controller将查询到的分页数据响应给页面
  5. 页面显示数据

代码开发:

  1. 创建分页构造器 Page<Category> pageInfo = new Page<>(page,pageSize);
  2. 创建条件构造器LambaQueryWrapper
  3. 注入的CategoryService调用Mapper对象进行分页数据查询 categoryService.page(pageInfo,queryWrapper)
  4. 返回给页面即可 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)

图片上传下载
文件上传

将本地图片等文件上传到服务器上,可以供其他用户浏览或下载的过程。

具体代码如下:

此时我们上传图片后,是存放在临时位置,关闭浏览器,图片文件就不存在了,无法再次浏览,我们需要将上传的图片下载到本地磁盘存储,这样浏览器上就可以进行图片回显,访问的时候才能看到图片

文件下载

指文件从服务器传输到本地计算机的过程

新增菜品

  1. 首先将菜品(套餐)分类的名称返回到页面上
    @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);
    }
  1. 多表存入,在进行插入时,需要进行事务管理,防止多表操作崩溃和数据库安全, @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中获取数据,降低服务器压力

缓存逻辑
  1. 首先构造唯一的key值,然后判断redis中是否存在,
  2. 如果存在则直接返回,若为空则执行数据库查询操作,查询完后将数据存入redis中。

  1. 使用redis缓存菜品数据,在菜品信息修改和删除时,同样需要修改redis中存储的数据,为方便操作,直接对该信息进行删除。

Spring Cache缓存套餐数据
  1. pom中导入Spring Cache依赖

  1. 启动类上加入注解,开启缓存功能

  1. 使用Spring Cache 注解的方式开启缓存

  1. 缓存查询的套餐信息

  1. 清除缓存

  • 25
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值