信工外卖项目总结
Github仓库:https://github.com/dongzhengru/xg-take-out
项目原作者:黑马程序员
信工外卖是一个前后端分离的外卖点餐系统,项目后端采用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
就是数据库的操作类型,比如我们需要在insert
和update
操作时自动填充
@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
那一套
代码非常长,可以看一下commit
记录(反正我记不住 C端微信登录、令牌校验
SpringCache实现套餐菜品缓存
常用的注解如下
注解 | 说明 |
---|---|
@EnableCaching | 开启缓存注解功能,通常加在启动类上 |
@Cacheable | 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中 |
@CachePut | 将方法的返回值放到缓存中 |
@CacheEvict | 将一条或多条数据从缓存中删除 |
实际使用就依照上面的说明,已经非常清楚,注意cacheNames
和key
@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.MIN
和LocalTime.MAX
代表无限接近0
和60
的秒数
还有字符串拼接可以用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);
最后
这篇拖了很久很久的文章终于写完了