Sky Take-Out:基于 Spring Boot 的模块化外卖平台设计与实现
项目地址: https://github.com/dasheng5/sky-take-out
技术栈: Spring Boot 2.7.3 | MyBatis | MySQL | Redis | JWT | 微信支付 | 阿里云OSS | 微信小程序
一、项目概述
Sky Take-Out(苍穹外卖)是一个完整的外卖订餐平台,采用前后端分离架构,支持微信小程序用户端(C端)和 Vue 管理后台(B端) 双端交互。项目基于 Spring Boot 2.7.3 构建,采用 Maven 多模块设计,独立完成了从系统架构设计、模块划分到核心功能编码实现的全流程开发。
本项目旨在解决传统餐饮场景中效率低、易漏单、管理混乱的痛点,通过数字化手段实现菜品管理、在线下单、微信支付、订单追踪、数据统计等完整业务闭环。
二、系统架构设计
2.1 整体架构图
2.2 技术架构分层
| 层级 |
技术选型 |
职责说明 |
| 客户端层 |
微信小程序 + Vue 管理后台 |
C端用户下单、B端商家管理 |
| 网关层 |
Nginx 反向代理 |
负载均衡、静态资源分发、跨域处理 |
| 服务层 |
Spring Boot 2.7.3 |
核心业务逻辑处理 |
| 数据层 |
MySQL 8.0 + Redis + MyBatis |
关系型数据持久化 + 缓存加速 + ORM映射 |
| 第三方服务 |
微信支付V3 + 阿里云OSS + 微信登录 |
支付能力、文件存储、身份认证 |
2.3 Maven 多模块设计
项目采用清晰的模块化分层,便于维护和扩展:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| sky-take-out (父工程) ├── sky-common # 通用工具模块 │ ├── constant # 常量定义 │ ├── context # ThreadLocal上下文 │ ├── exception # 全局异常体系 │ ├── json # Jackson序列化配置 │ ├── properties # 配置属性类 │ ├── result # 统一响应封装 │ └── utils # JWT / OSS / 微信支付 / HttpClient ├── sky-pojo # 数据实体模块 │ ├── dto # 数据传输对象 │ ├── entity # 数据库实体 │ └── vo # 视图对象 └── sky-server # 核心服务模块 ├── annotation # 自定义注解 (@AutoFill) ├── aspect # AOP切面 (自动填充) ├── config # 配置类 (WebMvc / Redis / OSS) ├── controller # 控制器 (admin / user 双端) ├── handler # 全局异常处理器 ├── interceptor # JWT拦截器 (admin / user) ├── mapper # 数据访问层 ├── service # 业务逻辑层 └── resources/mapper # MyBatis XML映射文件
|
设计亮点:
sky-common 与 sky-pojo 作为独立模块,可被其他服务复用
sky-server 通过依赖引入公共模块,实现关注点分离
- 未来可基于
sky-pojo 快速拆分出订单服务、支付服务等微服务
三、核心模块深度剖析
3.1 用户认证体系:JWT + ThreadLocal 双端隔离
3.1.1 设计背景
外卖平台需要同时支持两类用户:
- B端管理员:通过账号密码登录管理后台
- C端消费者:通过微信授权登录小程序
两类用户认证方式不同,但都需要在无状态的 HTTP 协议下维持登录态。项目采用 JWT(JSON Web Token) 方案,通过双拦截器实现认证隔离。
3.1.2 实现方案
1. 登录流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| public Employee login(EmployeeLoginDTO dto) { Employee employee = employeeMapper.getByUsername(dto.getUsername()); password = DigestUtils.md5DigestAsHex(password.getBytes()); if (!password.equals(employee.getPassword())) { throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR); } if (employee.getStatus() == StatusConstant.DISABLE) { throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED); } return employee; }
public User wxLogin(UserLoginDTO dto) { String openid = getOpenid(dto.getCode()); if (openid == null) { throw new LoginFailedException(MessageConstant.LOGIN_FAILED); } User user = userMapper.getByOpenid(openid); if (user == null) { user = User.builder().openid(openid).createTime(LocalDateTime.now()).build(); userMapper.insert(user); } return user; }
|
2. JWT 生成与校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader(jwtProperties.getAdminTokenName());
try { Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
BaseContext.setCurrentId(empId); return true; } catch (Exception ex) { response.setStatus(401); return false; } }
|
3. ThreadLocal 上下文
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public class BaseContext { public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void removeCurrentId() { threadLocal.remove(); } }
|
设计价值:
- 无状态服务:服务端不存储 Session,天然支持水平扩展
- 双端隔离:
admin 和 user 使用不同的 SecretKey 和 TokenName,避免权限串用
- 线程安全:ThreadLocal 确保并发场景下用户ID不串线
3.2 AOP 公共字段自动填充:消灭样板代码
3.2.1 问题痛点
在每张表的 INSERT/UPDATE 操作中,都需要重复设置以下字段:
create_time / update_time
create_user / update_user
传统方式需要在每个 Service 方法中手动赋值,代码冗余且易遗漏。
3.2.2 解决方案:自定义注解 + AOP 切面
1. 自定义注解
1 2 3 4 5
| @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AutoFill { OperationType value(); }
|
2. AOP 切面实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| @Aspect @Component @Slf4j public class AutoFillAspect {
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)") public void autoFillPointCut() {}
@Before("autoFillPointCut()") public void autoFill(JoinPoint joinPoint) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class); OperationType operationType = autoFill.value();
Object entity = joinPoint.getArgs()[0]; LocalDateTime now = LocalDateTime.now(); Long currentId = BaseContext.getCurrentId();
if (operationType == OperationType.INSERT) { Method setCreateTime = entity.getClass() .getDeclaredMethod(SET_CREATE_TIME, LocalDateTime.class); Method setCreateUser = entity.getClass() .getDeclaredMethod(SET_CREATE_USER, Long.class); Method setUpdateTime = entity.getClass() .getDeclaredMethod(SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser = entity.getClass() .getDeclaredMethod(SET_UPDATE_USER, Long.class);
setCreateTime.invoke(entity, now); setCreateUser.invoke(entity, currentId); setUpdateTime.invoke(entity, now); setUpdateUser.invoke(entity, currentId); } else if (operationType == OperationType.UPDATE) { Method setUpdateTime = entity.getClass() .getDeclaredMethod(SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser = entity.getClass() .getDeclaredMethod(SET_UPDATE_USER, Long.class);
setUpdateTime.invoke(entity, now); setUpdateUser.invoke(entity, currentId); } } }
|
3. Mapper 层使用
1 2 3 4 5
| @AutoFill(OperationType.INSERT) void insert(Employee employee);
@AutoFill(OperationType.UPDATE) void update(Employee employee);
|
设计价值:
- 消除重复代码:所有实体类的公共字段赋值逻辑统一收敛
- 避免人为遗漏:通过编译期注解检查,确保每个 INSERT/UPDATE 都自动填充
- 可扩展性强:新增字段只需修改切面,无需改动业务代码
3.3 订单与支付:数据一致性设计
3.3.1 用户下单流程
核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| @Service @Slf4j public class OrderServiceImpl implements OrderService {
@Transactional public OrderSubmitVO submitOrder(OrdersSubmitDTO dto) { AddressBook address = addressBookMapper.getById(dto.getAddressBookId()); if (address == null) { throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL); }
Long userId = BaseContext.getCurrentId(); List<ShoppingCart> carts = shoppingCartMapper.list( ShoppingCart.builder().userId(userId).build()); if (carts == null || carts.isEmpty()) { throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL); }
Orders orders = new Orders(); BeanUtils.copyProperties(dto, orders); orders.setOrderTime(LocalDateTime.now()); orders.setPayStatus(Orders.UN_PAID); orders.setStatus(Orders.PENDING_PAYMENT); orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setPhone(address.getPhone()); orders.setConsignee(address.getConsignee()); orders.setUserId(userId);
orderMapper.insert(orders);
List<OrderDetail> details = new ArrayList<>(); for (ShoppingCart cart : carts) { OrderDetail detail = new OrderDetail(); BeanUtils.copyProperties(cart, detail); detail.setOrderId(orders.getId()); details.add(detail); } orderDetailMapper.insertBatch(details);
shoppingCartMapper.deleteByUserId(userId);
return OrderSubmitVO.builder() .id(orders.getId()) .orderTime(orders.getOrderTime()) .orderNumber(orders.getNumber()) .orderAmount(orders.getAmount()) .build(); } }
|
关键设计:
@Transactional 事务包裹:订单表 + 订单明细表 + 购物车清理,三者要么全部成功,要么全部回滚
- 订单号生成策略:使用
System.currentTimeMillis() 保证唯一性,避免高并发冲突
- 业务异常优先校验:在事务开启前完成所有业务校验,减少数据库资源占用
3.3.2 微信支付集成
项目集成 微信支付 V3 版本,采用 JSAPI 支付方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @Component public class WeChatPayUtil {
private CloseableHttpClient getClient() { PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey( new FileInputStream(new File(weChatProperties.getPrivateKeyFilePath()))); X509Certificate certificate = PemUtil.loadCertificate( new FileInputStream(new File(weChatProperties.getWeChatPayCertFilePath())));
return WechatPayHttpClientBuilder.create() .withMerchant(weChatProperties.getMchid(), weChatProperties.getMchSerialNo(), merchantPrivateKey) .withWechatPay(Arrays.asList(certificate)) .build(); }
public JSONObject pay(String orderNum, BigDecimal total, String description, String openid) throws Exception { String body = jsapi(orderNum, total, description, openid); JSONObject json = JSON.parseObject(body); String prepayId = json.getString("prepay_id");
String timeStamp = String.valueOf(System.currentTimeMillis() / 1000); String nonceStr = RandomStringUtils.randomNumeric(32);
return payParams; } }
|
支付回调幂等性设计:
- 微信支付存在重复通知的可能,必须在回调接口中实现幂等性
- 通过数据库订单表的 唯一约束(订单号 + 支付状态)防止重复更新
- 回调处理时先查询订单状态,若已为”已支付”则直接返回成功,避免重复发货
3.4 菜品管理:关联数据的一致性维护
3.4.1 菜品与口味的关联设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Service @Slf4j public class DishServiceImpl implements DishService {
@Transactional public void saveWithFlavor(DishDTO dto) { Dish dish = new Dish(); BeanUtils.copyProperties(dto, dish);
dishMapper.insert(dish);
Long dishId = dish.getId();
List<DishFlavor> flavors = dto.getFlavors(); if (flavors != null && !flavors.isEmpty()) { flavors.forEach(f -> f.setDishId(dishId)); dishFlavorMapper.insertBatch(flavors); } } }
|
3.4.2 批量删除的业务约束
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Transactional public void deleteBatch(List<Long> ids) { for (Long id : ids) { Dish dish = dishMapper.getById(id); if (Objects.equals(dish.getStatus(), StatusConstant.ENABLE)) { throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE); } }
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids); if (setmealIds != null && !setmealIds.isEmpty()) { throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL); }
dishMapper.deleteByIDs(ids); dishFlavorMapper.deleteByDishIds(ids); }
|
设计价值:
- 事务一致性:菜品与口味数据必须同时成功或失败
- 业务规则前置校验:删除前检查起售状态和套餐关联,避免脏数据
- 批量操作优化:使用
deleteByIDs 而非循环单条删除,减少数据库交互次数
3.5 购物车设计:用户隔离与幂等添加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| @Service @Slf4j public class ShoppingCartServiceImpl implements ShoppingCartService {
public void addShoppingCart(ShoppingCartDTO dto) { ShoppingCart cart = new ShoppingCart(); BeanUtils.copyProperties(dto, cart); Long userId = BaseContext.getCurrentId(); cart.setUserId(userId);
List<ShoppingCart> list = shoppingCartMapper.list(cart);
if (list != null && !list.isEmpty()) { ShoppingCart existing = list.get(0); existing.setNumber(existing.getNumber() + 1); shoppingCartMapper.updateNumberById(existing); } else { Long dishId = dto.getDishId(); Long setmealId = dto.getSetmealId();
if (dishId != null) { Dish dish = dishMapper.getById(dishId); cart.setName(dish.getName()); cart.setImage(dish.getImage()); cart.setAmount(dish.getPrice()); } else { Setmeal setmeal = setmealMapper.getById(setmealId); cart.setName(setmeal.getName()); cart.setImage(setmeal.getImage()); cart.setAmount(setmeal.getPrice()); } cart.setNumber(1); cart.setCreateTime(LocalDateTime.now()); shoppingCartMapper.insert(cart); } } }
|
设计亮点:
- 用户隔离:所有购物车操作基于
BaseContext.getCurrentId(),确保数据隔离
- 幂等添加:同一商品重复添加时自动累加数量,而非重复插入
- 冗余字段设计:购物车表冗余存储
name、image、amount,减少联表查询
四、数据库设计
4.1 ER 关系图
4.2 核心表结构说明
| 表名 |
职责 |
关键设计 |
employee |
管理员账号 |
MD5密码加密、状态字段控制启停 |
user |
微信用户 |
openid 唯一索引、自动注册机制 |
category |
菜品分类 |
type字段区分菜品/套餐分类 |
dish |
菜品信息 |
category_id 外键关联分类 |
dish_flavor |
菜品口味 |
dish_id 外键,支持多口味组合 |
setmeal |
套餐信息 |
category_id 外键、status 控制起售 |
setmeal_dish |
套餐菜品关联 |
setmeal_id + dish_id 联合关联 |
orders |
订单主表 |
number 唯一索引、status 状态机 |
order_detail |
订单明细 |
order_id 外键、冗余商品信息 |
shopping_cart |
购物车 |
user_id 索引、支持菜品/套餐混合 |
address_book |
地址簿 |
user_id 外键、is_default 默认地址 |
五、安全与性能设计
5.1 安全设计
| 安全维度 |
实现方案 |
| 认证 |
JWT Token + 双拦截器隔离 |
| 密码 |
MD5 加密存储,禁止明文 |
| 传输 |
HTTPS(生产环境部署) |
| 文件上传 |
阿里云OSS直传,服务端不保存文件 |
| 支付安全 |
微信支付V3签名验证 + 回调幂等处理 |
5.2 性能优化
| 优化点 |
方案 |
| 分页查询 |
PageHelper 插件,避免全表扫描 |
| 图片存储 |
阿里云OSS CDN加速 |
| 缓存 |
Spring Cache + Redis(可扩展) |
| 连接池 |
Druid 监控连接池状态 |
| SQL优化 |
索引覆盖查询、避免N+1问题 |
六、项目亮点与面试谈资
6.1 技术亮点总结
- 模块化架构设计:Maven 多模块拆分,sky-common / sky-pojo / sky-server 职责清晰,具备微服务拆分基础
- JWT + ThreadLocal 认证方案:无状态设计支持水平扩展,双拦截器实现 admin/user 隔离
- AOP 公共字段自动填充:通过自定义注解 + 反射消灭样板代码,提升开发效率
- 事务一致性保障:订单提交使用
@Transactional 包裹多表操作,确保数据一致性
- 微信支付完整集成:V3版本JSAPI支付,包含下单、调起、回调、幂等处理全流程
- 业务规则前置校验:菜品删除前检查起售状态和套餐关联,维护数据完整性
6.2 面试高频问题与回答策略
Q1: 为什么选择 JWT 而不是 Session?
答:首先,项目采用前后端分离架构,微信小程序和Vue管理后台都是独立部署的,Session 需要服务端存储且存在跨域问题。JWT 是无状态的,服务端只负责签发和校验 Token,天然支持水平扩展。其次,我们同时服务 admin 和 user 两类用户,通过双拦截器 + 不同 SecretKey 实现权限隔离,比 Session 方案更灵活。
Q2: AOP 自动填充的实现原理是什么?
答:核心是三个部分:① 自定义 @AutoFill 注解,标注在 Mapper 方法上区分 INSERT/UPDATE;② 定义 AOP 切面 AutoFillAspect,通过 @Pointcut 拦截所有带注解的方法;③ 在 @Before 通知中通过反射获取方法参数实体,调用 setCreateTime、setUpdateUser 等 setter 方法赋值。ThreadLocal 中的用户ID通过 BaseContext.getCurrentId() 获取,确保并发安全。
Q3: 订单提交如何保证数据一致性?
答:使用 Spring 的 @Transactional 注解包裹整个 submitOrder 方法。方法内涉及三个数据库操作:插入订单表、批量插入订单明细、清空购物车。如果任何一个步骤失败,Spring 会自动回滚事务。此外,订单号使用 System.currentTimeMillis() 生成,保证唯一性;业务校验(地址簿、购物车非空)在事务开启前完成,减少数据库资源占用。
Q4: 微信支付回调如何处理幂等性?
答:微信支付存在重复通知的可能,我们在回调接口中做了两层防护:① 数据库层面,订单表的 number 字段有唯一索引,防止重复更新;② 业务层面,回调处理时先查询订单当前状态,如果已经是”已支付”,直接返回成功响应,不再执行后续发货逻辑。这样既保证了资金安全,也避免了重复发货。
Q5: 如果购物车数据量很大,如何优化?
答:当前实现中,购物车数据按 user_id 做了查询条件,数据库层面可以添加 (user_id, dish_id, setmeal_id) 的联合索引加速查询。如果数据量进一步增长,可以考虑:① 引入 Redis 缓存用户购物车,减少数据库压力;② 购物车数据增加过期时间(如30天未操作自动清理);③ 分表策略,按 user_id 取模分片。
七、未来优化方向
| 方向 |
具体方案 |
| 微服务拆分 |
引入 Spring Cloud Gateway,将订单、支付、商品拆分为独立服务 |
| 消息队列 |
引入 RabbitMQ / Kafka 处理订单异步通知、支付回调削峰 |
| 分布式事务 |
使用 Seata 处理跨服务事务 |
| 缓存架构 |
Redis 缓存热点数据(菜品列表、套餐信息),设置合理过期策略 |
| 监控告警 |
接入 Prometheus + Grafana 监控系统指标 |
| 容器化部署 |
Docker + K8s 实现自动化部署和弹性伸缩 |
八、总结
Sky Take-Out 项目从零到一完成了外卖平台的核心功能开发,涵盖了用户认证、商品管理、购物车、订单系统、微信支付、数据统计等完整业务链路。在技术实现上,通过 Maven 模块化 保证了代码的可维护性,通过 JWT + AOP 实现了优雅的认证和自动填充机制,通过 事务控制 + 业务校验 保障了数据一致性。
这个项目不仅展示了 Spring Boot 企业级开发的全栈能力,也为后续微服务架构演进打下了坚实基础。如果你对这个项目的某个技术点感兴趣,欢迎通过 GitHub Issues 或邮件交流讨论。