Skip to content

苍穹外卖 SkyTakeOut 技术亮点 ​

苍穹外卖是一个基于 Spring Boot 的外卖点餐系统,采用三模块架构(sky-common、sky-pojo、sky-server)。项目虽然定位为学习项目,但其中有不少值得学习的技术实现。这篇文章从源码出发,聊聊其中几个有意思的技术点。

1. WebSocket 实现实时订单推送 ​

在外卖场景中,商家后台需要实时感知新订单的到来。项目使用了 JSR 356 标准的 WebSocket(javax.websocket)来实现这一功能。

WebSocket 服务端实现 ​

核心类是 WebSocketServer,通过 @ServerEndpoint 注解暴露 WebSocket 端点:

java
@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:

java
@Configuration
public class WebSocketConfiguration {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

实际推送场景 ​

在订单支付成功时,OrderServiceImpl 会构造一条 JSON 消息推送给所有客户端:

java
// 订单支付成功后
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 不同:

java
// 用户催单
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 自动处理签名和验签:

java
@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 等信息重新签名:

java
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 解密:

java
@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);
}

伪支付的处理方式 ​

在实际开发环境中,项目跳过了真实的微信支付调用,直接在代码中模拟支付成功:

java
@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 来缓存这些数据。

启用缓存 ​

在启动类上一行注解搞定:

java
@SpringBootApplication
@EnableCaching
@EnableScheduling
@EnableTransactionManagement
public class SkyApplication { ... }

Redis 配置 ​

自定义 RedisTemplate,设置 Key 的序列化器为 StringRedisSerializer(默认的 JDK 序列化存进去是乱码):

java
@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:

java
// /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);
}

套餐同理:

java
// /user/setmeal/list
@GetMapping("/list")
@Cacheable(cacheNames = "setmealCache", key = "#categoryId")
public Result<List<Setmeal>> list(Long categoryId) { ... }

管理端:缓存清除 ​

管理员新增/修改/删除菜品时,通过 @CacheEvict 清除对应的缓存,保证数据一致性:

java
// 新增菜品 —— 只清除对应分类的缓存
@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 分钟仍未支付的订单,自动取消:

java
@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 点执行,将长时间处于派送中的订单自动标记为完成:

java
@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 名称:

java
@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:

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(); }
}

Service 层可以直接获取当前用户 ID,无需从 Controller 层层层传递:

java
Long userId = BaseContext.getCurrentId();

拦截器注册与路径匹配 ​

在 WebMvcConfiguration 中注册拦截器,通过路径前缀区分管理端和用户端:

java
@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。

自定义注解 ​

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
    OperationType value();  // INSERT 或 UPDATE
}

切面实现 ​

拦截所有 Mapper 层标注了 @AutoFill 的方法,通过反射自动填充字段:

java
@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,这个项目是一个不错的参考。