苍穹外卖 订单与支付功能实践

7 阅读7分钟

先讲「商户系统 + 支付渠道」协作的通用思路,再结合苍穹外卖项目代码。

零基础入门

如果你刚接触支付对接,先记住下面 4 句话:

  1. 预支付(统一下单)是什么:你的服务端向微信申请一笔「待用户确认支付」的订单,拿到 prepay_id 等参数,交给小程序/APP 调起收银台。
  2. 支付回调是什么:用户付完款后,微信服务器异步 POST 到你的 notify_url,通知支付结果;你的系统据此改订单状态
  3. 商户订单号(out_trade_no)是什么:你自己业务库里的订单号,贯穿「下单 → 预支付 → 回调」,用来把渠道通知和本地订单对上。
  4. 为什么强调幂等与验签:网络会重试,回调可能来多次;通知必须能验真,业务更新必须重复执行结果不变

先看一个最小例子(不依赖本项目)

// 1)用户端:拿到调起支付所需的参数(由服务端向渠道下单后返回)
OrderPaymentVO params = orderService.payment(dto);

// 2)渠道:支付成功后 POST 到你的回调地址
// 3)回调里:解析订单号 → 查单 → 若未支付则改为已支付
void onPaySuccess(String outTradeNo) {
    // update order set pay_status = paid where number = ? and ...
}

预支付负责「拉起收银台」,回调负责「把钱的结果写回你自己的订单」。

一、对接支付的简洁实现思路

目标:把「渠道交互」和「业务状态」拆开

我们不希望 Controller 里堆满签名、HTTP 客户端细节,而是:

  1. 工具层:封装调用微信 V3 接口(下单、签名、证书)。
  2. 业务层:决定金额口径、订单号、何时视为已支付、如何更新状态与副作用(如来单提醒)。
  3. 回调层:读 body、解密(若使用加密资源)、取 out_trade_no、调业务、按协议应答微信。
sequenceDiagram
    title 运行流程:从预下单到支付成功
    participant App as 小程序
    participant API as 商户服务端
    participant WX as 微信支付
    participant DB as 数据库

    App->>API: PUT /user/order/payment(订单号)
    API->>WX: JSAPI 统一下单
    WX-->>API: prepay_id 等
    API-->>App: 调起支付参数(timeStamp、paySign 等)
    App->>WX: 用户确认支付
    WX->>API: POST /notify/paySuccess(加密通知)
    API->>API: 解密、取 out_trade_no
    API->>DB: 更新订单为已支付、待接单
    API-->>WX: 200 + SUCCESS

最小代码骨架

public OrderPaymentVO payment(OrdersPaymentDTO dto) {
    JSONObject wx = weChatPayUtil.pay(dto.getOrderNumber(), amount, desc, openid);
    // 已支付等业务分支处理
    return mapToPaymentVO(wx);
}

public void paySuccess(String outTradeNo) {
    Orders order = orderMapper.getByNumber(...);
    if (alreadyPaid(order)) return; // 生产强烈建议幂等
    orderMapper.update(paidState(order));
}

核心思想:支付是外部系统,不可靠、可重复;本地订单状态机要扛住重复通知与超时。


二、再看苍穹外卖项目是怎么实现的

先看业务场景

场景谁发起系统做什么
用户点击支付用户端接口向微信下单,返回小程序调起参数
微信通知支付成功微信服务器解密回调体,取商户订单号,更新订单并推送来单提醒
订单已支付仍发起支付用户端接口微信返回 ORDERPAID,抛业务异常提示

用户端支付接口(Controller)

    @PutMapping("/payment")
    @ApiOperation("订单支付")
    public Result<OrderPaymentVO> payment(@RequestBody OrdersPaymentDTO ordersPaymentDTO) throws Exception {
        log.info("订单支付:{}", ordersPaymentDTO);
        OrderPaymentVO orderPaymentVO = orderService.payment(ordersPaymentDTO);
        log.info("生成预支付交易单:{}", orderPaymentVO);
        return Result.success(orderPaymentVO);
    }

预支付与重复支付分支(Service)

    public OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception {
        // 当前登录用户id
        Long userId = BaseContext.getCurrentId();
        User user = userMapper.getById(userId);

        //调用微信支付接口,生成预支付交易单
        JSONObject jsonObject = weChatPayUtil.pay(
                ordersPaymentDTO.getOrderNumber(), //商户订单号
                new BigDecimal(0.01), //支付金额,单位 元
                "苍穹外卖订单", //商品描述
                user.getOpenid() //微信用户的openid
        );

        if (jsonObject.getString("code") != null && jsonObject.getString("code").equals("ORDERPAID")) {
            throw new OrderBusinessException("该订单已支付");
        }

        OrderPaymentVO vo = jsonObject.toJavaObject(OrderPaymentVO.class);
        vo.setPackageStr(jsonObject.getString("package"));

        return vo;
    }

执行顺序可以概括为:

  1. BaseContext 取当前用户,查 openid
  2. 调用 WeChatPayUtil.pay,用商户订单号向微信下单并组装二次签名参数。
  3. 若返回 ORDERPAID,说明已支付,抛 OrderBusinessException 走全局异常统一返回。
  4. 否则把 JSON 映射为 OrderPaymentVO 返回前端。

支付成功回写与来单提醒(Service)

    public void paySuccess(String outTradeNo) {
        // 当前登录用户id
        Long userId = BaseContext.getCurrentId();

        // 根据订单号查询当前用户的订单
        Orders ordersDB = orderMapper.getByNumberAndUserId(outTradeNo, userId);

        // 根据订单id更新订单的状态、支付方式、支付状态、结账时间
        Orders orders = Orders.builder()
                .id(ordersDB.getId())
                .status(Orders.TO_BE_CONFIRMED)
                .payStatus(Orders.PAID)
                .checkoutTime(LocalDateTime.now())
                .build();

        orderMapper.update(orders);

        //通过websocket向客户端浏览器推送消息 type orderId content
        Map map = new HashMap();
        map.put("type",1); // 1表示来单提醒 2表示客户催单
        map.put("orderId",ordersDB.getId());
        map.put("content","订单号:" + outTradeNo);

        String json = JSON.toJSONString(map);
        webSocketServer.sendToAllClient(json);
    }

支付回调:解密与转调业务(PayNotifyController)

    @RequestMapping("/paySuccess")
    public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
        //读取数据
        String body = readData(request);
        log.info("支付成功回调:{}", body);

        //数据解密
        String plainText = decryptData(body);
        log.info("解密后的文本:{}", plainText);

        JSONObject jsonObject = JSON.parseObject(plainText);
        String outTradeNo = jsonObject.getString("out_trade_no");//商户平台订单号
        String transactionId = jsonObject.getString("transaction_id");//微信支付交易号

        log.info("商户平台订单号:{}", outTradeNo);
        log.info("微信支付交易号:{}", transactionId);

        //业务处理,修改订单状态、来单提醒
        orderService.paySuccess(outTradeNo);

        //给微信响应
        responseToWeixin(response);
    }

解密使用微信提供的 AesUtil 与商户 apiV3Key

    private String decryptData(String body) throws Exception {
        JSONObject resultObject = JSON.parseObject(body);
        JSONObject resource = resultObject.getJSONObject("resource");
        String ciphertext = resource.getString("ciphertext");
        String nonce = resource.getString("nonce");
        String associatedData = resource.getString("associated_data");

        AesUtil aesUtil = new AesUtil(weChatProperties.getApiV3Key().getBytes(StandardCharsets.UTF_8));
        //密文解密
        String plainText = aesUtil.decryptToString(associatedData.getBytes(StandardCharsets.UTF_8),
                nonce.getBytes(StandardCharsets.UTF_8),
                ciphertext);

        return plainText;
    }

微信侧下单与调起参数(WeChatPayUtil)

统一下单请求体要点:out_trade_nonotify_urlamount(分)、payer.openid

    private String jsapi(String orderNum, BigDecimal total, String description, String openid) throws Exception {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("appid", weChatProperties.getAppid());
        jsonObject.put("mchid", weChatProperties.getMchid());
        jsonObject.put("description", description);
        jsonObject.put("out_trade_no", orderNum);
        jsonObject.put("notify_url", weChatProperties.getNotifyUrl());

        JSONObject amount = new JSONObject();
        amount.put("total", total.multiply(new BigDecimal(100)).setScale(2, BigDecimal.ROUND_HALF_UP).intValue());
        amount.put("currency", "CNY");

        jsonObject.put("amount", amount);

        JSONObject payer = new JSONObject();
        payer.put("openid", openid);

        jsonObject.put("payer", payer);

        String body = jsonObject.toJSONString();
        return post(JSAPI, body);
    }

拿到 prepay_id 后做二次签名,返回小程序调起支付所需字段:

    public JSONObject pay(String orderNum, BigDecimal total, String description, String openid) throws Exception {
        //统一下单,生成预支付交易单
        String bodyAsString = jsapi(orderNum, total, description, openid);
        //解析返回结果
        JSONObject jsonObject = JSON.parseObject(bodyAsString);
        System.out.println(jsonObject);

        String prepayId = jsonObject.getString("prepay_id");
        if (prepayId != null) {
            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();
            byte[] message = signMessage.getBytes();

            Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initSign(PemUtil.loadPrivateKey(new FileInputStream(new File(weChatProperties.getPrivateKeyFilePath()))));
            signature.update(message);
            String packageSign = Base64.getEncoder().encodeToString(signature.sign());

            //构造数据给微信小程序,用于调起微信支付
            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;
        }
        return jsonObject;
    }

三、预支付接口与支付回调的区别(容易混淆)

类型典型路径是否带用户 JWT关注点
预支付/user/order/payment通常有(用户端拦截器)金额、订单号、openid、返回调起参数
支付回调/notify/paySuccess微信服务器调用,无用户登录态解密、验签(生产需按官方指引补全)、幂等更新、正确应答

当前项目里 paySuccess 使用 BaseContext.getCurrentId()getByNumberAndUserId 联查订单。回调入口不在 /user/ 下,ThreadLocal 中往往没有当前用户;这是教学代码与生产实践之间的典型差异点。生产上更稳妥的做法是:仅用 out_trade_no(或再加渠道侧约束)定位订单,并在更新前判断「若已支付则直接返回」,避免重复回调重复推消息。


四、注意事项与可选增强

  1. 密钥与证书:私钥路径、apiV3Key、平台证书勿提交仓库,用环境变量或配置中心。
  2. 幂等paySuccess 入口建议「已是已支付则 return」,避免 WebSocket 重复推送。
  3. 金额:示例里固定 0.01 元便于联调;上线应使用订单实付金额并校验与微信通知一致。
  4. 回调安全:除解密外,应按微信支付 V3 要求完成签名验证与重放防护。
  5. 补偿:回调丢失时可定时「按商户订单号查单」对齐状态,与定时关单任务配合。

总结

支付链路可以记三句话:预下单把收银台拉起来,回调把支付结果写进订单,工具类把微信协议细节藏起来。
苍穹外卖里,OrderController.paymentWeChatPayUtil.pay 完成预支付;PayNotifyController 解密后调用 OrderServiceImpl.paySuccess 推进状态并推送来单提醒。


附录:相关源码路径

sky-server/src/main/java/com/sky/controller/user/OrderController.java
sky-server/src/main/java/com/sky/controller/nofity/PayNotifyController.java
sky-server/src/main/java/com/sky/service/impl/OrderServiceImpl.java
sky-common/src/main/java/com/sky/utils/WeChatPayUtil.java
sky-common/src/main/java/com/sky/properties/WeChatProperties.java

参考

苍穹外卖www.bilibili.com/video/BV1TP…

deekseek-v4