购物车 → 生成订单 → 支付宝沙箱支付 笔记

购物车 → 生成订单 → 支付宝沙箱支付 笔记

〇、全链路总览

【阶段一】商品详情页点"加入购物车"
  ProductDetail.addCart() ──POST /api/cart──► 网关(验JWT,透传用户头) ──► mall-cart CartController.add
                                                              └► CartServiceImpl.add:有则数量累加,无则新插,user_id=当前登录人

【阶段二】购物车勾选商品,点"去结算"
  Cart.vue ──router.push──► OrderConfirm.vue:加载购物车勾选项 + 收货地址

【阶段三】确认订单页点"提交订单"
  OrderConfirm.submitOrder() ──POST /api/order {shippingId}──► 网关 ──► mall-order OrderController.add
        └► OrderServiceImpl.add:
           ① 从ThreadLocal取userId
           ② Feign调cart-service查"已勾选"的购物车项(含商品信息)
           ③ 算总价 → 插orders表 → 逐条插order_item表 → Feign调cart-service删掉已下单的购物车项

【阶段四】我的订单页,待付款订单点"去支付"
  OrderList.pay() ──GET /api/order/pay/{orderNo}──► 网关 ──► OrderController.pay
        └► OrderServiceImpl.pay:校验归属+状态 → AlipayServiceImpl.createPayForm:调支付宝沙箱生成表单HTML
  前端把form插入页面自动submit ──► 跳转支付宝沙箱收银台 ──► 沙箱买家账号付款

【阶段五】付款完成后
  浏览器跳回 returnUrl(localhost:5174/order) → 页面自动主动查单
  OrderList.checkPayingOrder() ──GET /api/order/payStatus/{orderNo}──► syncPayStatus:查支付宝真实状态→已支付则流转状态
  (生产环境另有支付宝服务器POST /order/notify 异步通知,验签后流转状态;本地收不到,见"联调限制")

贯穿全程的两条暗线:

  1. 用户身份链路:JWT 存在前端 localStorage → 每次请求带 Authorization 头 → 网关 AuthGlobalFilter 验签后把用户 id/name 写进 X-Login-Id/X-Login-Name 请求头 → 各服务的 LoginInterceptor 读出头存入 ThreadLocal(LoginContext)→ 业务代码随时 LoginContext.getLoginInfo().get("id") 取当前登录人。
  2. 微服务协作:mall-order 不直连购物车/商品的数据库,全部通过 OpenFeign(CartClient、ProductClient)跨服务调用——这也是整个链路里最容易出现”服务没启动就报错”的地方。

一、阶段一:加入购物车

1. 前端入口(ProductDetail.vue)

//加入购物车
const addCart = () => {
  if (soldOut.value) {
    ElMessage.warning('该商品已售罄')
    return
  }
  //只传商品id和数量——用户身份在token里,前端不传userId(防伪造)
  cartApi.add({productId: product.value.id, count: count.value}).then(result => {
    if (result.code === 1) {
      ElMessage.success('已加入购物车')
      cartCountStore.refreshCount()   //刷新底部TabBar的角标
    } else {
      ElMessage.error(result.msg || '加入购物车失败')
    }
  })
}
// api/cart/cart.js
add(cart) {
    return request.post("/cart", cart)   // {productId, count}
}

要点:前端只传 productId + count,绝不传 userId——身份由后端从 token 解析,用户无法伪造”替别人加购物车”。

2. 后端落库(CartController.java + CartServiceImpl.java)

@PostMapping
public Result add(@RequestBody Cart cart) {
    cartService.add(cart);
    return Result.ok("添加成功");
}
@Override
public void add(Cart cart) {
    Long id = (Long) LoginContext.getLoginInfo().get("id");

    //归属当前登录用户(之前误写成setId,导致user_id为null)
    cart.setUserId(id);
    LambdaQueryWrapper<Cart> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(Cart::getUserId,id);
    queryWrapper.eq(Cart::getProductId,cart.getProductId());

    Cart dbcart = cartMapper.selectOne(queryWrapper);
    if (dbcart != null) {
        //同一商品已在购物车:数量累加,而不是插入重复行
        dbcart.setCount((dbcart.getCount() + cart.getCount()));
        cartMapper.updateById(dbcart);
    }else  {
        cartMapper.insert(cart);   //主键ASSIGN_ID雪花生成
    }
}

要点:

  • user_id 来自 ThreadLocal,即网关透传的 JWT 中的用户 id。
  • “加购”是 upsert 语义:同一用户 + 同一商品只有一行,重复加购只累加数量。
  • 曾踩的坑:cart.setId(id) 把登录用户 id 塞给了主键,插入时 user_id 为 null 报 Field 'user_id' doesn't have a default value,且主键被污染成用户 id 会冲突。一字之差(setId/setUserId),这是本次联调真实踩过的 bug。

二、阶段二:购物车结算页

Cart.vue 列出当前用户购物车(GET /cart),每项可勾选(selected 0/1)、改数量、删除;底部结算栏显示合计,点击跳转 /order/confirm。

CartServiceImpl.listAll() 返回的每项带商品信息(批量 Feign 查商品回填,见《Redis缓存笔记》同款防 N+1 手法):

//一次性批量查回所有关联商品,按id建索引(避免循环内逐条Feign调用的N+1问题)
List<Long> productIds = cartList.stream().map(Cart::getProductId).filter(Objects::nonNull).distinct().toList();
Map<Long, Product> productMap = new HashMap<>();
if (!productIds.isEmpty()) {
    Result<List<Product>> result = productClient.listByIds(productIds);
    if (result.getCode() == Result.OK && result.getData() != null) {
        productMap = result.getData().stream().collect(Collectors.toMap(Product::getId, p -> p));
    }
}

前端 isValid(item) = !!item.product——商品已被删除时 product 为 null,显示”商品已失效”,不参与结算。

三、阶段三:生成订单

1. 前端确认订单(OrderConfirm.vue)

//提交订单:只传收货地址id,商品/价格/用户全由后端核定
const submitOrder = () => {
  if (!currentAddress.value) {
    ElMessage.warning('请先选择收货地址')
    return
  }
  submitting.value = true
  orderApi.create({shippingId: currentAddress.value.id}).then(result => {
    submitting.value = false
    if (result.code === 1) {
      ElMessage.success('下单成功')
      cartCountStore.refreshCount()   //下单后购物车勾选项被清除,刷新角标
      router.replace('/order')        //跳到我的订单
    } else {
      ElMessage.error(result.msg || '下单失败')
    }
  })
}

要点:金额绝不由前端传——前端传的价格可以随意篡改,订单金额一律由后端按数据库里的商品价格重新计算。

2. 后端核心(OrderServiceImpl.add,整段是链路最重的一段)

@Override
public void add(Order order) {
    //① 当前登录人(ThreadLocal ← 网关透传)
    Long userId = (Long) LoginContext.getLoginInfo().get("id");
    order.setUserId(userId);

    //② Feign调cart-service,拿当前用户购物车里"已勾选"的商品(含商品信息)
    Result<List<CartVO>> result = cartClient.list();
    if (result.getCode() == Result.ERROR) {
        throw new ServiceException("生成订单失败");
    }
    //保存订单基础字段
    order.setStatus(OrderStatus.UNPAID.getCode());  //1:未支付
    order.setPaymentType(1);
    order.setPostage(0);

    List<CartVO> cartVOList = result.getData();
    List<CartVO> selectedCartVOList = cartVOList.stream()
            .filter(cartVO -> cartVO.getSelected() == 1)
            .toList();

    //③ 用数据库里的商品价格计算总价(前端传的钱数一概不信)
    BigDecimal payment = BigDecimal.ZERO;
    for (CartVO cartVO : selectedCartVOList) {
        payment = payment.add(cartVO.getProduct().getPrice()
                .multiply(BigDecimal.valueOf(cartVO.getCount())));
        // TODO 判断库存是否充足,如果库存不足,抛出异常
    }
    order.setPayment(payment);

    //④ 插订单表(主键orderNo为雪花ID,插入后自动回填)
    orderMapper.insert(order);

    //⑤ 逐条插订单明细表:下单瞬间的商品名/图片/单价快照
    for (CartVO cartVO : selectedCartVOList) {
        OrderItem orderItem = new OrderItem();
        orderItem.setOrderNo(order.getOrderNo());
        orderItem.setUserId(userId);
        orderItem.setProductId(cartVO.getProductId());
        orderItem.setProductName(cartVO.getProduct().getName());
        orderItem.setProductImage(cartVO.getProduct().getMainImage());
        orderItem.setCurrentUnitPrice(cartVO.getProduct().getPrice());
        orderItem.setQuantity(cartVO.getCount());
        orderItem.setTotalPrice(cartVO.getProduct().getPrice()
                .multiply(BigDecimal.valueOf(cartVO.getCount())));
        orderItemMapper.insert(orderItem);
    }

    //⑥ 清掉已下单的购物车项(Feign逐条删除)
    selectedCartVOList.forEach(cartVO -> {
        cartClient.deletedById(cartVO.getId());
    });
}

要点解读:

  • 订单快照设计:order_item 存下单瞬间的 productName / productImage / currentUnitPrice——之后商品改名、调价、甚至删除,历史订单展示不受影响。这是电商订单表的经典设计。
  • 订单号是雪花 ID(IdType.ASSIGN_ID):插入后从实体上拿回,后面支付宝支付的 out_trade_no 用的就是它。
  • 跨服务事务问题:第⑥步清购物车如果失败,会出现”已下单但购物车还在”的脏数据——当前没有分布式事务(Seata),属于已知权衡;TODO 里还有库存校验、扣库存,同样是分布式一致性的练习点。
  • Feign 调用链:CartClient、ProductClient 定义在 mall-api 公共模块,各服务共享;目标服务没启动时调用直接抛 UnknownHostException(Nacos 里无实例)——联调时按服务名排查。

四、阶段四:支付宝沙箱支付

1. 配置(mall-order/application.yml + AlipayProperties)

# 支付宝沙箱配置(凭证建议通过环境变量注入:ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY / ALIPAY_PUBLIC_KEY)
alipay:
  appId: ${ALIPAY_APP_ID:9021000165698953}
  privateKey: ${ALIPAY_PRIVATE_KEY:MIIEvA...}      # 应用私钥(RSA2)
  publicKey: ${ALIPAY_PUBLIC_KEY:MIIBIjAN...}      # 支付宝公钥(验签用,注意不是应用公钥!)
  gateway: ${ALIPAY_GATEWAY:https://openapi-sandbox.dl.alipaydev.com/gateway.do}
  signType: RSA2
  returnUrl: ${ALIPAY_RETURN_URL:http://localhost:5174/order}        # 付款完浏览器跳回前台订单页
  notifyUrl: ${ALIPAY_NOTIFY_URL:http://localhost:8088/order/notify} # 本地收不到外网回调(见联调限制)
@Data
@Configuration
@ConfigurationProperties(prefix = "alipay")
public class AlipayProperties {
    private String appId;
    private String privateKey;
    private String publicKey;
    private String gateway;
    private String signType;
    private String returnUrl;
    private String notifyUrl;
}

密钥关系图(最容易搞混的点):

应用私钥(privateKey)  ──签名──►  请求发给支付宝(证明"我是这个应用")
支付宝公钥(publicKey) ──验签──►  支付宝的异步通知/查单响应(证明"回调真的来自支付宝")

2. 支付宝适配层(AlipayServiceImpl)

@Slf4j
@Service
public class AlipayServiceImpl implements AlipayService {

    @Autowired
    private AlipayProperties alipayProperties;

    //AlipayClient创建开销大且线程安全,懒加载单例复用
    private volatile AlipayClient alipayClient;

    private AlipayClient client() throws AlipayApiException {
        if (alipayClient == null) {
            synchronized (this) {
                if (alipayClient == null) {
                    AlipayConfig cfg = new AlipayConfig();
                    cfg.setServerUrl(alipayProperties.getGateway());   //沙箱网关
                    cfg.setAppId(alipayProperties.getAppId());
                    cfg.setPrivateKey(alipayProperties.getPrivateKey());
                    cfg.setSignType(alipayProperties.getSignType());
                    cfg.setCharset("utf-8");
                    cfg.setFormat("json");
                    alipayClient = new DefaultAlipayClient(cfg);
                }
            }
        }
        return alipayClient;
    }

    @Override
    public String createPayForm(Order order, List<OrderItem> itemList) {
        if (itemList == null || itemList.isEmpty()) {
            throw new RuntimeException("订单缺少商品明细,无法支付");
        }
        try {
            //支付金额以订单表的payment为准(下单时已核算),金额格式必须是两位小数的字符串
            BigDecimal totalAmount = order.getPayment().setScale(2, RoundingMode.HALF_UP);

            //商品标题:取第一个商品名 + "等",超长截断
            StringBuilder subject = new StringBuilder(itemList.get(0).getProductName());
            if (itemList.size() > 1) {
                subject.append("等").append(itemList.size()).append("件商品");
            }
            if (subject.length() > 90) {
                subject.setLength(90);
            }

            AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
            request.setNotifyUrl(alipayProperties.getNotifyUrl());   //异步回调:服务器到服务器
            request.setReturnUrl(alipayProperties.getReturnUrl());   //同步回跳:支付完跳回前端页面

            Map<String, Object> biz = new HashMap<>();
            biz.put("out_trade_no", String.valueOf(order.getOrderNo()));  //商户订单号:本项目为雪花orderNo
            biz.put("total_amount", totalAmount.toPlainString());
            biz.put("subject", subject.toString());
            biz.put("product_code", "FAST_INSTANT_TRADE_PAY");            //电脑网站支付固定值

            request.setBizContent(JSONUtil.toJsonStr(biz));

            AlipayTradePagePayResponse response = client().pageExecute(request);
            if (!response.isSuccess()) {
                log.error("支付宝下单失败: code={}, msg={}, subMsg={}",
                        response.getCode(), response.getMsg(), response.getSubMsg());
                throw new RuntimeException("支付宝下单失败:" + response.getSubMsg());
            }
            //返回的是一段自动提交的form表单HTML,前端插入页面后自动跳转沙箱收银台
            return response.getBody();
        } catch (AlipayApiException e) {
            log.error("支付宝下单异常", e);
            throw new RuntimeException("支付宝下单异常");
        }
    }

    @Override
    public String verifyNotify(Map<String, String> params) {
        try {
            //1.验签:必须用支付宝公钥验证,防止伪造回调
            boolean signVerified = AlipaySignature.rsaCheckV1(params,
                    alipayProperties.getPublicKey(), "utf-8", alipayProperties.getSignType());
            if (!signVerified) {
                log.warn("支付宝异步通知验签失败, params={}", params);
                return null;
            }
            //2.交易状态:TRADE_SUCCESS(支付成功)/TRADE_FINISHED(交易结束,不可退款)
            String tradeStatus = params.get("trade_status");
            if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
                String outTradeNo = params.get("out_trade_no");
                if (StringUtils.hasText(outTradeNo)) {
                    return outTradeNo;   //支付成功,把订单号交给调用方流转状态
                }
            }
            //验签通过但交易未成功(如买家未付款),回复success让支付宝停止重发
            return "";
        } catch (Exception e) {
            log.error("处理支付宝异步通知异常", e);
            return null;
        }
    }

    @Override
    public String queryTradeStatus(Long orderNo) {
        try {
            AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
            Map<String, Object> biz = new HashMap<>();
            biz.put("out_trade_no", String.valueOf(orderNo));
            request.setBizContent(JSONUtil.toJsonStr(biz));

            AlipayTradeQueryResponse response = client().execute(request);
            if (response.isSuccess()) {
                return response.getTradeStatus();
            }
            //交易不存在:用户还没跳转或没付款(错误码ACQ.TRADE_NOT_EXIST),按待付款处理
            log.info("支付宝查单: 订单{}无交易记录, subCode={}, subMsg={}",
                    orderNo, response.getSubCode(), response.getSubMsg());
            return "WAIT_BUYER_PAY";
        } catch (AlipayApiException e) {
            log.error("支付宝查单异常, orderNo={}", orderNo, e);
            throw new RuntimeException("查询支付状态异常");
        }
    }
}

设计要点:

  • 职责边界:AlipayServiceImpl 是纯粹的”支付宝协议适配器”——验签、组装参数、调网关,不掺订单业务(不注入 IOrderService)。订单状态流转由 Controller 编排。这样依赖方向是单向的,不会出现 OrderService ⇄ AlipayService 循环依赖(曾因 handleNotify 内直接调 orderService 触发 BeanCreationNotAllowedException 循环依赖启动失败,重构为此方案)。
  • out_trade_no 用雪花 orderNo:商户侧唯一订单号,支付宝凭它对账、查单、回调。
  • product_code: FAST_INSTANT_TRADE_PAY:电脑网站支付(page pay)的固定值。
  • 金额是字符串且两位小数:toPlainString() 避免科学计数法,setScale(2, HALF_UP) 满足支付宝对金额格式的要求。

3. 订单服务:校验 + 状态流转(OrderServiceImpl)

@Override
public String pay(Long orderNo, Long userId) {
    Order order = orderMapper.selectById(orderNo);
    if (order == null) {
        throw new ServiceException("订单不存在");
    }
    //只能支付自己的订单
    if (!userId.equals(order.getUserId())) {
        throw new ServiceException("无权操作该订单");
    }
    //只有未支付订单可以发起支付
    if (order.getStatus() == null || order.getStatus() != OrderStatus.UNPAID.getCode()) {
        throw new ServiceException("订单当前状态不支持支付");
    }
    List<OrderItem> itemList = orderItemMapper.selectList(
            new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderNo, orderNo));
    return alipayService.createPayForm(order, itemList);
}

@Override
public void paySuccess(Long orderNo) {
    Order order = orderMapper.selectById(orderNo);
    //幂等保护:订单不存在、或已经不是"未支付"状态(重复通知),直接忽略
    if (order == null || order.getStatus() == null || order.getStatus() != OrderStatus.UNPAID.getCode()) {
        return;
    }
    Order update = new Order();
    update.setOrderNo(orderNo);
    update.setStatus(OrderStatus.PAID.getCode());   //1未支付 → 2已支付
    update.setPaymentTime(new Date());
    orderMapper.updateById(update);
}

@Override
public Integer syncPayStatus(Long orderNo, Long userId) {
    Order order = orderMapper.selectById(orderNo);
    if (order == null) {
        throw new ServiceException("订单不存在");
    }
    //只能查询自己的订单
    if (!userId.equals(order.getUserId())) {
        throw new ServiceException("无权操作该订单");
    }
    //已经是终态的订单直接返回,不去打扰支付宝
    if (order.getStatus() == null || order.getStatus() != OrderStatus.UNPAID.getCode()) {
        return order.getStatus();
    }
    //未支付订单:向支付宝查询真实交易状态(异步通知收不到时的兜底)
    String tradeStatus = alipayService.queryTradeStatus(orderNo);
    if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
        paySuccess(orderNo);   //内部幂等,把状态流转为已支付
        return OrderStatus.PAID.getCode();
    }
    //WAIT_BUYER_PAY(还没付)/TRADE_CLOSED(交易关闭):保持未支付
    return OrderStatus.UNPAID.getCode();
}

状态机(OrderStatus 枚举):0已取消 → 1未支付 → 2已支付 → 3已发货 → 4交易成功 / 5已关闭。支付环节只负责 1 → 2 这一步,且两个入口(异步通知、主动查单)汇合到同一个 paySuccess,靠”只有未支付才更新”实现幂等——重复通知、通知与查单赛跑,都不会重复更新。

4. Controller 端点(OrderController)

//发起支付宝支付:返回自动提交的支付表单HTML
@GetMapping("/pay/{orderNo}")
public Result<String> pay(@PathVariable Long orderNo) {
    Long userId = (Long) LoginContext.getLoginInfo().get("id");
    //注意:必须用ok(msg,data)重载。pay返回String,直接ok(form)会命中ok(String msg)把表单塞进msg
    return Result.ok("", orderService.pay(orderNo, userId));
}

//支付宝异步通知(服务器到服务器的POST,不带token)
@PostMapping(value = "/notify", produces = "text/plain;charset=UTF-8")
public String notify(@RequestParam Map<String, String> params) {
    //验签并解析交易结果:null=验签失败;""=验签通过但交易未成功;有值=支付成功的订单号
    String orderNo = alipayService.verifyNotify(params);
    if (orderNo == null) {
        return "failure";
    }
    if (!orderNo.isEmpty()) {
        orderService.paySuccess(Long.parseLong(orderNo));   //内部幂等,重复通知不会重复更新
    }
    return "success";
}

//主动查单:向支付宝查询真实支付状态并同步订单(异步通知收不到时的兜底)
@GetMapping("/payStatus/{orderNo}")
public Result<Integer> payStatus(@PathVariable Long orderNo) {
    Long userId = (Long) LoginContext.getLoginInfo().get("id");
    return Result.ok(orderService.syncPayStatus(orderNo, userId));
}

//支付宝同步回跳(浏览器支付完成后跳回,仅承载跳转,支付结果以异步通知为准)
@GetMapping("/return")
public Map<String, String> returnUrl(@RequestParam Map<String, String> params) {
    return params;
}

三个必须知道的协议细节:

  1. /notify 的返回值必须是纯文本 success——支付宝收到 success 才停止重发通知,否则按衰减间隔最多重发 8 次。所以不能包 Result JSON。
  2. /notify 不带任何 token——必须在网关 AuthGlobalFilter 白名单精确放行 path.startsWith("/order/notify"),同时接口内必须验签(这是防伪造”支付成功”的唯一防线,验签不过直接 failure)。
  3. /notify 用 @RequestParam 收参——支付宝回调是 application/x-www-form-urlencoded 表单,Spring 自动把每个表单字段收进 Map,供 rsaCheckV1 原样验签(多参少参都会导致验签失败)。

5. 网关白名单(AuthGlobalFilter.java)

//不需要进行验证的路径
if (path.startsWith("/service/captcha")
        || path.startsWith("/admin/login")
        || path.startsWith("/user/login")
        || path.startsWith("/order/notify")) {   //支付宝异步通知是服务器回调,不带token
    return chain.filter(exchange);
}

五、阶段五:前端支付交互(OrderList.vue)

//去支付:后端返回支付宝的form表单HTML,插入页面后自动提交跳转沙箱收银台
const pay = (order) => {
  orderApi.pay(order.orderNo).then(result => {
    if (result.code === 1) {
      //记下这笔订单,付款跳回来后自动查单核对状态
      localStorage.setItem('payingOrderNo', String(order.orderNo))
      const div = document.createElement('div')
      div.style.display = 'none'
      div.innerHTML = result.data        //后端返回的表单HTML
      document.body.appendChild(div)
      const form = div.querySelector('form')
      if (form) {
        form.submit()                    //自动提交 → 浏览器带着签名参数跳到支付宝收银台
      } else {
        ElMessage.error('支付表单异常')
      }
    } else {
      ElMessage.error(result.msg || '发起支付失败')
    }
  })
}

//主动查单兜底:本地收不到支付宝的异步通知,跳回来后向支付宝核对一次真实支付状态
const checkPayingOrder = () => {
  const payingOrderNo = localStorage.getItem('payingOrderNo')
  if (!payingOrderNo) return
  localStorage.removeItem('payingOrderNo')
  const order = list.value.find(o => String(o.orderNo) === payingOrderNo)
  //订单不在列表里或已不是待付款,就不用查了
  if (!order || order.status !== 1) return
  orderApi.payStatus(payingOrderNo).then(result => {
    if (result.code === 1) {
      order.status = result.data
      if (result.data === 2) {
        ElMessage.success('支付成功')
      } else {
        ElMessage.info('订单尚未完成支付')
      }
    }
  }).catch(() => {})
}

模板上:order.status === 1(待付款)的订单显示”去支付”按钮。

交互设计:为什么用 localStorage 记订单号?付款是在支付宝页面完成的,回来时是整页重新加载,内存状态全丢,只能靠 localStorage 这种跨页面刷新持久的存储把”我正在付哪单”带回来;查完立即 remove,防止下次进页面误查。

六、本地联调的已知限制与踩坑记录

1. 异步通知到不了本机(限制,非 bug)

notifyUrl 若是 localhost/192.168.x.x,支付宝服务器无法发起回调——localhost 只在本机有效,私网地址不在公网路由。本地跑闭环靠主动查单兜底(阶段五);生产部署到公网服务器后,异步通知成为主力(实时、不依赖用户回跳),查单退居兜底。参考项目 order_service 之所以通知正常,是因为它整套部署在公网 mall.dxkua.cn。备选方案是内网穿透(注意只穿透必要端口、用完即关)。

2. 实际踩过的四个坑

坑 现象 原因与修复
Result.ok(String) 重载陷阱 支付表单 HTML 被塞进 msg,data 为 null,前端报”支付表单异常” pay() 返回 String,命中 ok(String msg) 而非 ok(Object data);改用 ok("", form) 显式走双参重载
循环依赖启动失败 OrderServiceImpl ⇄ AlipayServiceImpl 互相注入,BeanCreationNotAllowedException 重构职责:AlipayService 只做支付宝协议,返回解析结果,订单状态流转由 Controller 编排,依赖变单向
Field 'user_id' doesn't have a default value 加购物车插入失败 cart.setId(id) 误把用户 id 塞给主键,应为 cart.setUserId(id)
收银台控制台一堆红字 ERR_NAME_NOT_RESOLVED、Mixed Content、wss://127.0.0.1 连接失败 全是支付宝沙箱页面自己的埋点/指纹采集/安全控件探测噪音,不影响支付功能,可无视

3. 验证清单

  1. 详情页加购 → TabBar 角标 +1;重复加购数量累加
  2. 购物车勾选 → 去结算 → 确认订单页金额 = 数据库价格 × 数量
  3. 提交订单 → 我的订单出现”待付款”,对应购物车项被清除
  4. 去支付 → 跳沙箱收银台(控制台红字无视)→ 沙箱买家账号付款
  5. 跳回订单页 → 自动弹”支付成功” → 订单状态变”已付款”、数据库 payment_time 有值
上一篇
下一篇