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-commonsky-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
// EmployeeServiceImpl.java - 管理员登录
public Employee login(EmployeeLoginDTO dto) {
// 1. 根据用户名查询
Employee employee = employeeMapper.getByUsername(dto.getUsername());
// 2. 密码比对(MD5加密)
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(employee.getPassword())) {
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}
// 3. 账号状态校验
if (employee.getStatus() == StatusConstant.DISABLE) {
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
}
return employee;
}

// UserServiceImpl.java - 微信登录
public User wxLogin(UserLoginDTO dto) {
// 1. 调用微信接口获取 openid
String openid = getOpenid(dto.getCode());
if (openid == null) {
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}
// 2. 判断新用户,自动注册
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
// JwtTokenAdminInterceptor.java
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
// 从请求头获取token
String token = request.getHeader(jwtProperties.getAdminTokenName());

try {
// 解析JWT
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());

// 存入ThreadLocal,同线程共享
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
// BaseContext.java
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,天然支持水平扩展
  • 双端隔离adminuser 使用不同的 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(); // INSERT 或 UPDATE
}

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) {
// 反射为4个公共字段赋值
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) {
// 反射为2个公共字段赋值
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) {
// 1. 业务校验:地址簿不能为空
AddressBook address = addressBookMapper.getById(dto.getAddressBookId());
if (address == null) {
throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
}

// 2. 业务校验:购物车不能为空
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);
}

// 3. 构造订单数据
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);

// 4. 插入订单表
orderMapper.insert(orders);

// 5. 插入订单明细
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);

// 6. 清空购物车
shoppingCartMapper.deleteByUserId(userId);

// 7. 封装返回结果
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 {
// 1. 统一下单
String body = jsapi(orderNum, total, description, openid);
JSONObject json = JSON.parseObject(body);
String prepayId = json.getString("prepay_id");

// 2. 组装调起支付参数
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);

// 1. 插入菜品表
dishMapper.insert(dish);

// 2. 获取生成的主键值(MyBatis useGeneratedKeys)
Long dishId = dish.getId();

// 3. 插入口味数据
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) {
// 1. 校验:是否存在起售中的菜品
for (Long id : ids) {
Dish dish = dishMapper.getById(id);
if (Objects.equals(dish.getStatus(), StatusConstant.ENABLE)) {
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}

// 2. 校验:是否被套餐关联
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && !setmealIds.isEmpty()) {
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}

// 3. 批量删除菜品 + 关联口味
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);

// 1. 查询是否已存在相同商品
List<ShoppingCart> list = shoppingCartMapper.list(cart);

if (list != null && !list.isEmpty()) {
// 2. 已存在:数量 +1
ShoppingCart existing = list.get(0);
existing.setNumber(existing.getNumber() + 1);
shoppingCartMapper.updateNumberById(existing);
} else {
// 3. 不存在:插入新记录
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(),确保数据隔离
  • 幂等添加:同一商品重复添加时自动累加数量,而非重复插入
  • 冗余字段设计:购物车表冗余存储 nameimageamount,减少联表查询

四、数据库设计

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 技术亮点总结

  1. 模块化架构设计:Maven 多模块拆分,sky-common / sky-pojo / sky-server 职责清晰,具备微服务拆分基础
  2. JWT + ThreadLocal 认证方案:无状态设计支持水平扩展,双拦截器实现 admin/user 隔离
  3. AOP 公共字段自动填充:通过自定义注解 + 反射消灭样板代码,提升开发效率
  4. 事务一致性保障:订单提交使用 @Transactional 包裹多表操作,确保数据一致性
  5. 微信支付完整集成:V3版本JSAPI支付,包含下单、调起、回调、幂等处理全流程
  6. 业务规则前置校验:菜品删除前检查起售状态和套餐关联,维护数据完整性

6.2 面试高频问题与回答策略

Q1: 为什么选择 JWT 而不是 Session?

答:首先,项目采用前后端分离架构,微信小程序和Vue管理后台都是独立部署的,Session 需要服务端存储且存在跨域问题。JWT 是无状态的,服务端只负责签发和校验 Token,天然支持水平扩展。其次,我们同时服务 admin 和 user 两类用户,通过双拦截器 + 不同 SecretKey 实现权限隔离,比 Session 方案更灵活。

Q2: AOP 自动填充的实现原理是什么?

答:核心是三个部分:① 自定义 @AutoFill 注解,标注在 Mapper 方法上区分 INSERT/UPDATE;② 定义 AOP 切面 AutoFillAspect,通过 @Pointcut 拦截所有带注解的方法;③ 在 @Before 通知中通过反射获取方法参数实体,调用 setCreateTimesetUpdateUser 等 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 或邮件交流讨论。