苍穹外卖 SkyTakeOut 技术亮点
苍穹外卖是一个基于 Spring Boot 的外卖点餐系统,采用三模块架构(sky-common、sky-pojo、sky-server)。项目虽然定位为学习项目,但其中有不少值得学习的技术实现。这篇文章从源码出发,聊聊其中几个有意思的技术点。
1. WebSocket 实现实时订单推送
在外卖场景中,商家后台需要实时感知新订单的到来。项目使用了 JSR 356 标准的 WebSocket(javax.websocket)来实现这一功能。
WebSocket 服务端实现
核心类是 WebSocketServer,通过 @ServerEndpoint 注解暴露 WebSocket 端点:
@Slf4j
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {
// 存放会话对象,用 sid 作为 key
private static Map<String, Session> sessionMap = new HashMap();
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
log.info("客户端:" + sid + "建立连接");
sessionMap.put(sid, session);
}
@OnClose
public void onClose(@PathParam("sid") String sid) {
log.info("连接关闭:" + sid);
sessionMap.remove(sid);
}
// 群发消息给所有已连接的客户端
public void sendToAllClient(String message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}WebSocket 配置
Spring Boot 中使用内嵌 Tomcat 的 WebSocket 需要注册一个 ServerEndpointExporter Bean:
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}实际推送场景
在订单支付成功时,OrderServiceImpl 会构造一条 JSON 消息推送给所有客户端:
// 订单支付成功后
Map map = new HashMap();
map.put("type", 1); // 1=支付成功通知
map.put("orderId", ordersDB.getId());
map.put("content", "订单号:" + ordersDB.getNumber() + "支付成功");
webSocketServer.sendToAllClient(JSON.toJSONString(map));同样,用户催单功能也复用了同一套 WebSocket 通道,只是 type 不同:
// 用户催单
map.put("type", 2); // 2=催单通知
map.put("orderId", id);
map.put("content", "订单号:" + id + ",用户催单了");
webSocketServer.sendToAllClient(JSON.toJSONString(map));设计思路与学习点
type字段区分消息类型:前端可以通过type值判断是支付通知还是催单通知,再做不同的 UI 提示。static Map保存 Session:简单直接,适合单机部署。如果是集群环境,需要改用 Redis Pub/Sub 或消息队列来广播。- 群发 vs 定向发送:当前实现是广播给所有连接的客户端,实际上商家端只需要收到自己店铺的订单。更完善的方案应该用
sid做路由,只推送给对应的商家。
2. 微信支付集成(伪支付模式)
项目完整实现了微信支付 V3 API 的调用链路,包括下单、二次签名、回调通知、退款等。由于是学习项目,实际运行时用了伪支付来跳过真实支付环节,但支付相关的代码结构非常完整,值得参考。
WeChatPayUtil 核心实现
工具类封装了微信支付 V3 的 HTTP 调用,使用官方的 wechatpay-apache-httpclient SDK 自动处理签名和验签:
@Component
public class WeChatPayUtil {
public static final String JSAPI = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi";
public static final String REFUNDS = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds";
@Autowired
private WeChatProperties weChatProperties;
private CloseableHttpClient getClient() {
// 加载商户私钥和平台证书
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new FileInputStream(new File(weChatProperties.getPrivateKeyFilePath())));
X509Certificate x509Certificate = PemUtil.loadCertificate(
new FileInputStream(new File(weChatProperties.getWeChatPayCertFilePath())));
// 使用官方 Builder 自动处理签名和验签
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(weChatProperties.getMchid(), weChatProperties.getMchSerialNo(), merchantPrivateKey)
.withWechatPay(Arrays.asList(x509Certificate));
return builder.build();
}
}二次签名(调起支付的关键)
微信小程序端调起支付时需要二次签名。这里用 SHA256withRSA 对 prepay_id 等信息重新签名:
public JSONObject pay(String orderNum, BigDecimal total, String description, String openid) throws Exception {
// 1. 统一下单,获取 prepay_id
String bodyAsString = jsapi(orderNum, total, description, openid);
JSONObject jsonObject = JSON.parseObject(bodyAsString);
String prepayId = jsonObject.getString("prepay_id");
// 2. 二次签名
String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
String nonceStr = RandomStringUtils.randomNumeric(32);
ArrayList<Object> list = new ArrayList<>();
list.add(weChatProperties.getAppid());
list.add(timeStamp);
list.add(nonceStr);
list.add("prepay_id=" + prepayId);
StringBuilder stringBuilder = new StringBuilder();
for (Object o : list) {
stringBuilder.append(o).append("\n");
}
String signMessage = stringBuilder.toString();
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(PemUtil.loadPrivateKey(...));
signature.update(signMessage.getBytes());
String packageSign = Base64.getEncoder().encodeToString(signature.sign());
// 3. 构造给小程序的支付参数
JSONObject jo = new JSONObject();
jo.put("timeStamp", timeStamp);
jo.put("nonceStr", nonceStr);
jo.put("package", "prepay_id=" + prepayId);
jo.put("signType", "RSA");
jo.put("paySign", packageSign);
return jo;
}支付回调处理
PayNotifyController 负责接收微信支付成功后的回调通知。回调数据是加密的,需要用 AES-256-GCM 解密:
@RequestMapping("/paySuccess")
public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
String body = readData(request);
// AES-GCM 解密
String plainText = decryptData(body);
JSONObject jsonObject = JSON.parseObject(plainText);
String outTradeNo = jsonObject.getString("out_trade_no");
// 更新订单状态 + WebSocket 推送
orderService.paySuccess(outTradeNo);
// 必须给微信返回 200,否则微信会重复通知
responseToWeixin(response);
}伪支付的处理方式
在实际开发环境中,项目跳过了真实的微信支付调用,直接在代码中模拟支付成功:
@Override
public OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception {
// 原本的微信支付调用被注释掉了
// JSONObject jsonObject = weChatPayUtil.pay(...);
// 直接构造"已支付"的结果
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", "ORDERPAID");
OrderPaymentVO vo = jsonObject.toJavaObject(OrderPaymentVO.class);
// 手动更新订单状态(替代回调)
orderMapper.updateStatus(OrderStatus, OrderPaidStatus, check_out_time, orderNumber);
// WebSocket 推送
webSocketServer.sendToAllClient(JSON.toJSONString(map));
return vo;
}设计思路与学习点
- 回调机制:支付回调是异步的,用户端可能还没收到支付结果,服务端已经通过回调更新了状态。WebSocket 推送弥补了这个时间差。
- 幂等处理:微信会重复发送回调通知,实际项目中需要做幂等校验(检查订单是否已经处理过)。
- 配置外部化:商户号、证书路径等敏感信息通过
@ConfigurationProperties(prefix = "sky.wechat")绑定到WeChatProperties,不硬编码在代码中。
3. Spring Cache 缓存策略
菜品和套餐是高频查询、低频变更的数据,非常适合做缓存。项目使用 Spring Cache + Redis 来缓存这些数据。
启用缓存
在启动类上一行注解搞定:
@SpringBootApplication
@EnableCaching
@EnableScheduling
@EnableTransactionManagement
public class SkyApplication { ... }Redis 配置
自定义 RedisTemplate,设置 Key 的序列化器为 StringRedisSerializer(默认的 JDK 序列化存进去是乱码):
@Configuration
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}用户端:缓存读取
用户查询菜品列表时,使用 @Cacheable 将结果缓存到 Redis,key 为分类 ID:
// /user/dish/list
@GetMapping("/list")
@Cacheable(cacheNames = "dishCache", key = "#categoryId")
public Result<List<DishVO>> list(Long categoryId) {
// 首次查询走数据库,后续请求直接从 Redis 返回
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);
List<DishVO> list = dishService.listWithFlavor(dish);
return Result.success(list);
}套餐同理:
// /user/setmeal/list
@GetMapping("/list")
@Cacheable(cacheNames = "setmealCache", key = "#categoryId")
public Result<List<Setmeal>> list(Long categoryId) { ... }管理端:缓存清除
管理员新增/修改/删除菜品时,通过 @CacheEvict 清除对应的缓存,保证数据一致性:
// 新增菜品 —— 只清除对应分类的缓存
@PostMapping
@CacheEvict(cacheNames = "dishCache", key = "#dishDTO.categoryId")
public Result save(@RequestBody DishDTO dishDTO) { ... }
// 修改/删除/起停售 —— 清除所有菜品缓存
@PutMapping
@CacheEvict(cacheNames = "dishCache", allEntries = true)
public Result update(@RequestBody DishDTO dishDTO) { ... }
@DeleteMapping
@CacheEvict(cacheNames = "dishCache", allEntries = true)
public Result delete(@RequestParam List<Long> ids) { ... }
@PostMapping("/status/{status}")
@CacheEvict(cacheNames = "dishCache", allEntries = true)
public Result startOrStop(@PathVariable Integer status, Long id) { ... }设计思路与学习点
- 读写分离的缓存策略:用户端(C 端)只做
@Cacheable读缓存,管理端(B 端)只做@CacheEvict写失效。职责清晰。 - 精细 vs 粗粒度清除:新增菜品时用
key = "#dishDTO.categoryId"只清除相关分类的缓存(精细);修改/删除时用allEntries = true全部清除(保守安全)。这是在精确性和简单性之间的权衡。 - 注意缓存穿透:如果查询一个不存在的分类,缓存不会存储 null 值,每次都会打到数据库。生产环境可以考虑缓存空值或用布隆过滤器。
4. Spring Task 定时任务
外卖系统有很多需要定时处理的场景:超时未支付的订单自动取消、长时间派送中的订单自动完成等。项目用 Spring Task(@Scheduled)来实现。
订单超时自动取消
每分钟执行一次,找出超过 15 分钟仍未支付的订单,自动取消:
@Component
@Slf4j
public class OrderTask {
@Autowired
private OrderMapper orderMapper;
@Scheduled(cron = "0 * * * * ?")
public void processTimeoutOrder() {
log.info("开始处理超时订单");
// 计算截止时间:当前时间减去 15 分钟
LocalDateTime outTime = LocalDateTime.now().plusMinutes(15);
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(
Orders.PENDING_PAYMENT, outTime);
if (ordersList.isEmpty()) return;
for (Orders orders : ordersList) {
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason(MessageConstant.ORDER_PAID_TIME_OUT);
orders.setCancelTime(LocalDateTime.now());
orderMapper.update(orders);
}
}
}派送中订单自动完成
每天凌晨 1 点执行,将长时间处于派送中的订单自动标记为完成:
@Scheduled(cron = "0 0 1 * * ?")
public void processDeliveryTimeOutOrders() {
log.info("定时处理处于派送中的订单:{}", LocalDateTime.now());
// 找出 1 小时前下单但仍处于派送中的订单
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(
Orders.DELIVERY_IN_PROGRESS, LocalDateTime.now().plusMinutes(-60));
if (ordersList.isEmpty()) return;
for (Orders orders : ordersList) {
orders.setStatus(Orders.COMPLETED);
orderMapper.update(orders);
}
}设计思路与学习点
- Cron 表达式的含义:
0 * * * * ?表示每分钟的第 0 秒执行;0 0 1 * * ?表示每天凌晨 1 点执行。掌握 Cron 表达式是定时任务的基础。 - 批量处理:先查询出所有符合条件的订单列表,再逐个更新。注意这里没有做分页,如果数据量大需要分批处理。
- 单机局限:
@Scheduled在单机环境下没问题,但集群部署时多个实例会重复执行。生产环境需要引入分布式锁(如 ShedLock)来保证只有一个实例执行。
5. 拦截器与 JWT 认证
项目的认证授权完全基于 Spring MVC 的 HandlerInterceptor,分别为管理端和用户端实现了 JWT 校验拦截器。
双拦截器设计
管理端和用户端使用不同的拦截器,各自有独立的密钥和 Token 名称:
@Component
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 1. 判断是否为 Controller 方法
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 2. 从请求头获取 Token
String token = request.getHeader(jwtProperties.getAdminTokenName());
// 3. 解析 JWT
try {
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
BaseContext.setCurrentId(empId); // 存入 ThreadLocal
return true;
} catch (Exception ex) {
response.setStatus(401);
return false;
}
}
@Override
public void afterCompletion(...) {
BaseContext.removeCurrentId(); // 请求结束,清理 ThreadLocal
}
}ThreadLocal 传递用户 ID
BaseContext 使用 ThreadLocal 在整个请求链路中传递当前登录用户的 ID:
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(); }
}Service 层可以直接获取当前用户 ID,无需从 Controller 层层层传递:
Long userId = BaseContext.getCurrentId();拦截器注册与路径匹配
在 WebMvcConfiguration 中注册拦截器,通过路径前缀区分管理端和用户端:
@Override
protected void addInterceptors(InterceptorRegistry registry) {
// 管理端:/admin/** 下的所有接口(排除登录接口)
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
// 用户端:/user/** 下的所有接口(排除登录和店铺状态查询)
registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/user/**")
.excludePathPatterns("/user/user/login")
.excludePathPatterns("/user/shop/status");
}设计思路与学习点
- 路径隔离:
/admin/**和/user/**两套路径天然隔离,不同角色使用不同的密钥,互不干扰。 - ThreadLocal 的生命周期管理:在
preHandle中设置,在afterCompletion中清除,避免内存泄漏。 - 静态资源放行:
if (!(handler instanceof HandlerMethod))这个判断确保静态资源请求不会被拦截。 - 全局异常处理:配合
@RestControllerAdvice统一处理异常,拦截器抛 401,业务异常走全局异常处理器返回友好提示。
6. AOP 自动填充公共字段
这是一个很优雅的设计——用自定义注解 + AOP 切面自动填充 createTime、updateTime、createUser、updateUser 等公共字段,避免在每个 Mapper 方法里手动 set。
自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
OperationType value(); // INSERT 或 UPDATE
}切面实现
拦截所有 Mapper 层标注了 @AutoFill 的方法,通过反射自动填充字段:
@Aspect
@Component
public class AutoFillAspect {
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {}
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) throws Exception {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType value = autoFill.value();
Object entity = joinPoint.getArgs()[0];
if (value == OperationType.INSERT) {
// INSERT 操作:填充 createTime, createUser, updateTime, updateUser
entity.getClass().getDeclaredMethod("setCreateTime", LocalDateTime.class)
.invoke(entity, LocalDateTime.now());
entity.getClass().getDeclaredMethod("setCreateUser", Long.class)
.invoke(entity, BaseContext.getCurrentId());
entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class)
.invoke(entity, LocalDateTime.now());
entity.getClass().getDeclaredMethod("setUpdateUser", Long.class)
.invoke(entity, BaseContext.getCurrentId());
} else if (value == OperationType.UPDATE) {
// UPDATE 操作:只填充 updateTime, updateUser
entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class)
.invoke(entity, LocalDateTime.now());
entity.getClass().getDeclaredMethod("setUpdateUser", Long.class)
.invoke(entity, BaseContext.getCurrentId());
}
}
}设计思路与学习点
- 关注点分离:业务代码不需要关心公共字段的填充逻辑,切面统一处理。
- 反射的使用:通过
getDeclaredMethod+invoke动态调用 setter,避免了对实体类的硬编码依赖。 - 结合 ThreadLocal:
BaseContext.getCurrentId()获取当前操作人 ID,与拦截器的 JWT 认证形成闭环。
总结
| 技术点 | 核心价值 |
|---|---|
| WebSocket | 服务端主动推送,解决订单实时通知问题 |
| 微信支付 | 完整的 V3 API 调用链路,二次签名 + 回调解密 |
| Spring Cache | 注解式缓存,读写分离的缓存策略 |
| Spring Task | 定时处理超时/异常订单,维护订单状态机 |
| JWT 拦截器 | 双端认证 + ThreadLocal 传递用户上下文 |
| AOP 自动填充 | 自定义注解 + 切面,消除重复代码 |
这个项目虽然是学习项目,但整体的技术选型和架构设计是比较规范的。特别是 WebSocket 推送、缓存策略、定时任务这几个点,在实际工作中也是高频使用的。如果你正在学习 Spring Boot,这个项目是一个不错的参考。