Github仓库:https://github.com/dongzhengru/xg-take-out

演示地址:http://xgto.zhengru.top

博客:https://blog.zhengru.top

项目原作者:黑马程序员

信工外卖是一个前后端分离的外卖点餐系统,项目后端采用SpringBoot开发。本文是对信工外卖项目后端各个功能实现的简单回顾,功能大致可以分为管理端和用户端。

项目概览

功能模块

  • 基础数据模块
    • 分类管理
    • 员工管理
    • 套餐管理
  • 点餐业务模块
    • 店铺营业状态
    • 微信登录
    • 缓存商品
    • 购物车
    • 用户下单
    • 订单支付和管理
    • 历史订单
    • 订单状态定时处理
    • 来单提醒和客户催单
  • 统计报表模块
    • 图形报表统计
    • Excel报表统计

项目技术栈

  • 用户层
    • Node.js
    • Vue.js
    • ElementUI
    • 微信小程序
    • Apache Echarts
  • 网关层
    • Nginx
  • 应用层
    • Spring Boot
    • Spring MVC
    • Spring Task
    • HttpClient
    • Spring Cache
    • JWT
    • 阿里云OSS
    • Swagger
    • POI
    • WebSocket
  • 数据层
    • MySQL
    • Redis
    • MyBatis
    • PageHelper
    • Spring Data Redis

功能实现

为了节约篇幅和时间,下面只对几个特殊的功能进行列举

PageHelper实现分页查询

本项目基于PageHelper实现了所有的分页查询功能,包括员工、套餐、菜品、菜品分类等等,除了普通的结果返回类Result以外,还定义了用于封装分页查询结果的类PageResult

/**
 * 封装分页查询结果
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {

    private long total; //总记录数

    private List records; //当前页数据集合

}

具体的使用方法也非常简单,在sql语句前使用startPage方法来设置分页条件

PageHelper.startPage((int)分页的页数, (int)每个分页的内容数量);

下面是一段套餐分页查询的代码

/**
 * 套餐分页查询
 * @param setmealPageQueryDTO
 * @return
 */
@Override
public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) {
    Setmeal setmeal = new Setmeal();
    BeanUtils.copyProperties(setmealPageQueryDTO,setmeal);
    PageHelper.startPage(setmealPageQueryDTO.getPage(),setmealPageQueryDTO.getPageSize());
    Page<SetmealVO> page = setmealMapper.pageQuery(setmeal);
    return new PageResult(page.getTotal(),page);
}

记得当时还遇到了一个问题,在PageHelper给含有like模糊查询的sql分页时,会出现只执行COUNT计算总数(结果永远为0),但不执行之后的具体查询逻辑,网上能找到的信息也非常少,最后用了很粗暴的方法解决了,先查询一遍获得total,再进行分页查询。写这篇博客的时候,我又再一次还原了当时的代码,但是问题已经无法重现了…

AOP实现公共字段自动填充

一般来说重要的记录都会存在下面几个字段,创建时间、创建用户、修改时间、修改用户等等,但是每一次都需要在业务层去获取的话就会造成代码冗余,更不便于后期维护,所以本项目唯一用到aop的地方,就是实现了公共字段自动填充

自定义注解 AutoFill,用于标识需要进行公共字段自动填充的方法,这里的OperationType就是数据库的操作类型,比如我们需要在insertupdate操作时自动填充

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
    OperationType value();
}

自定义切面类 AutoFillAspect

@Aspect
@Component
@Slf4j
public class AutoFillAspect {
}

定义切入点,即满足:是mapper包下所有类的方法与存在AutoFill注解的方法

@Pointcut("execution(* top.zhengru.mapper.*.*(..))" +
        " && @annotation(top.zhengru.annotation.AutoFill)")
public void autoFillCutPoint(){}

定义前置通知,统一拦截加入了 AutoFill 注解的方法,并通过反射为公共字段赋值

@Before("autoFillCutPoint()")
public void autoFill(JoinPoint joinPoint){
    //获取数据库操作类型
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
    AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//方法注解对象
    OperationType operationType = autoFill.value();//数据库操作类型

    //获取被拦截方法的参数-对象
    Object[] args = joinPoint.getArgs();
    if (args == null || args.length == 0) {
        return;
    }
    Object entity = args[0];

    //获取赋值数据
    LocalDateTime now = LocalDateTime.now();
    Long currentId = BaseContext.getCurrentId();

    //通过反射赋值
    if (operationType == OperationType.INSERT) {
        try {
            Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
            Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
            Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
            Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
            setCreateTime.invoke(entity, now);
            setUpdateTime.invoke(entity, now);
            setCreateUser.invoke(entity, currentId);
            setUpdateUser.invoke(entity, currentId);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }else if (operationType == OperationType.UPDATE) {
        try {
            Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
            Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
            setUpdateTime.invoke(entity, now);
            setUpdateUser.invoke(entity, currentId);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Mapper 的方法上加入 AutoFill 注解

@AutoFill(OperationType.INSERT)
void addDish(Dish dish);

文件上传

上传文件至对象存储服务,已经单独写了一篇博客关于MinIO和阿里OSS的使用

可以移步至 总结SpringBoot文件上传的几种方式

Redis实现缓存营业状态

将营业状态作为字符串存入Redis缓存,避免给数据库造成过大的压力

@RestController("adminShopController")
@RequestMapping("/admin/shop")
@Api(tags = "店铺操作相关接口")
@Slf4j
public class ShopController {
    public static final String KEY = "SHOP_STATUS";
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 设置营业状态
     * @param status
     * @return
     */
    @PutMapping("/{status}")
    @ApiOperation("设置营业状态")
    public Result<String> setStatus(@PathVariable Integer status) {
        redisTemplate.opsForValue().set(KEY, status);
        return Result.success();
    }

    /**
     * 查询店铺营业状态
     * @return
     */
    @GetMapping("/status")
    @ApiOperation("查询店铺营业状态")
    public Result<Integer> getStatus() {
        Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
        return Result.success(status);
    }
}

微信登录令牌校验

可以看一下微信官方文档的登录流程时序图,主要就是拿到用户发来的code再发送给微信,会得到用户独一无二的openid,再进行自动注册,然后就是jwt那一套

api-login.2fcc9f35.jpg

代码非常长,可以看一下commit记录(反正我记不住 C端微信登录、令牌校验

SpringCache实现套餐菜品缓存

常用的注解如下

注解 说明
@EnableCaching 开启缓存注解功能,通常加在启动类上
@Cacheable 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
@CachePut 将方法的返回值放到缓存中
@CacheEvict 将一条或多条数据从缓存中删除

实际使用就依照上面的说明,已经非常清楚,注意cacheNameskey

@GetMapping("/list")
@Cacheable(cacheNames = "setmealCache",key = "#categoryId")
@ApiOperation("根据分类id查询套餐")
public Result<List<Setmeal>> list(Long categoryId) {
    Setmeal setmeal = new Setmeal();
    setmeal.setCategoryId(categoryId);
    setmeal.setStatus(StatusConstant.ENABLE);

    List<Setmeal> list = setMealService.list(setmeal);
    return Result.success(list);
}
@PostMapping
@CacheEvict(cacheNames = "setmealCache",key = "#setmealDTO.categoryId")
@ApiOperation("新增套餐")
public Result<String> addSetMeal(@RequestBody SetmealDTO setmealDTO) {
    setMealService.addSetMeal(setmealDTO);
    return Result.success();
}

@DeleteMapping
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
@ApiOperation("批量删除套餐")
public Result<String> delete(@RequestParam List<Long> ids) {
    setMealService.deleteBatch(ids);
    return Result.success();
}

SpringTask实现订单处理

使用了springtask来定时处理订单状态,解决超时未支付等等问题

而通过cron表达式可以定义任务触发的时间,每个域的含义分别是秒、分钟、小时、日、月、周、年(可选)

启动类添加注解@EnableScheduling开启任务调度

@SpringBootApplication
@EnableTransactionManagement
@EnableCaching
@EnableScheduling //开启任务调度
@Slf4j
public class XgApplication {
    public static void main(String[] args) {
        SpringApplication.run(XgApplication.class, args);
        log.info("server started");
    }
}

自定义定时任务类OrderTask

@Component
@Slf4j
public class OrderTask {

    @Autowired
    OrderMapper orderMapper;

    /**
     * 处理超时订单
     */
    @Scheduled(cron = "0 * * * * ?")
    public void processTimeoutOrder() {
        List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT,
                LocalDateTime.now().plusMinutes(-15));
        if (ordersList != null && ordersList.size() > 0) {
            for (Orders orders : ordersList) {
                orders.setStatus(Orders.CANCELLED);
                orders.setCancelTime(LocalDateTime.now());
                orders.setCancelReason("订单超时,自动取消");
                orderMapper.update(orders);
            }
        }
    }

    /**
     * 处理一直处于派送中的订单
     */
    @Scheduled(cron = "0 0 1 * * ?")
    public void processDeliveryOrder() {
        List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS,
                LocalDateTime.now().plusMinutes(-60));
        if (ordersList != null && ordersList.size() > 0) {
            for (Orders orders : ordersList) {
                orders.setStatus(Orders.COMPLETED);
                orders.setCancelTime(LocalDateTime.now());
                orders.setCancelReason("订单超时,自动取消");
                orderMapper.update(orders);
            }
        }
    }
}

WebSocket实现来单提醒

主要用于来单提醒和客户催单,但是这里websocket的写法与我以前写的有所不同

首先注册Websocket

/**
 * WebSocket配置类,用于注册WebSocket的Bean
 */
@Configuration
public class WebSocketConfiguration {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

创建WebSocketServer类,类似于工具类

/**
 * WebSocket服务
 */
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {

    //存放会话对象
    private static Map<String, Session> sessionMap = new HashMap();

    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("sid") String sid) {
        System.out.println("客户端:" + sid + "建立连接");
        sessionMap.put(sid, session);
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message, @PathParam("sid") String sid) {
        System.out.println("收到来自客户端:" + sid + "的信息:" + message);
    }

    /**
     * 连接关闭调用的方法
     *
     * @param sid
     */
    @OnClose
    public void onClose(@PathParam("sid") String sid) {
        System.out.println("连接断开:" + sid);
        sessionMap.remove(sid);
    }

    /**
     * 群发
     *
     * @param message
     */
    public void sendToAllClient(String message) {
        Collection<Session> sessions = sessionMap.values();
        for (Session session : sessions) {
            try {
                //服务器向客户端发送消息
                session.getBasicRemote().sendText(message);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

}

接着在业务层注入WebSocketServer并调用群发方法即可

Map map = new HashMap();
map.put("type", 2);
map.put("order", id);
map.put("content", "订单号" + orderDB.getNumber());
webSocketServer.sendToAllClient(JSON.toJSONString(map)); //群发

ECharts实现数据报表

ECharts是一个基于JS的数据可视化图表库,上个月我在关于页面加了两张统计图也是ECharts实现的 点击跳转

其实后端要做的并没有什么不同,还是查询->数据拼接->封装返回

/**
 * 统计指定时间区间内的营业额数据
 * @param begin
 * @param end
 * @return
 */
@Override
public TurnoverReportVO getTurnoverStatistics(LocalDate begin, LocalDate end) {
    List<LocalDate> dateList = new ArrayList<>();
    dateList.add(begin);
    while (!begin.equals(end)) {
        begin = begin.plusDays(1);
        dateList.add(begin);
    }

    List<Double> turnoverList = new ArrayList<>();
    for (LocalDate localDate : dateList) {
        LocalDateTime beginTime = LocalDateTime.of(localDate, LocalTime.MIN);
        LocalDateTime endTime = LocalDateTime.of(localDate, LocalTime.MAX);
        Map map = new HashMap();
        map.put("begin", beginTime);
        map.put("end", endTime);
        map.put("status", Orders.COMPLETED);
        Double turnover = orderMapper.sumByMap(map);
        turnover = turnover == null ? 0.0 : turnover;
        turnoverList.add(turnover);
    }

    return TurnoverReportVO
            .builder().
            dateList(StringUtils.join(dateList, ","))
            .turnoverList(StringUtils.join(turnoverList, ","))
            .build();
}

首先查询区间,前端传来的区间是LocalDate,不包含秒,所以需要使用LocalTime.MINLocalTime.MAX代表无限接近060的秒数

还有字符串拼接可以用StringUtils.join(),用法如上

POI实现导出运营报表

与微人事不同的是,这次使用了准备好的Excel模板文件,使用类加载器读取文件并创建一个新的excel文档

InputStream in = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
XSSFWorkbook excel = new XSSFWorkbook(in);

接下来就是获取sheet表对象以及row和cell获取单元格对象并输入数据,

最后通过输出流将文件下载到客户端浏览器中

ServletOutputStream out = response.getOutputStream();
excel.write(out);

最后

这篇拖了很久很久的文章终于写完了

image-20230809132617906