13|(前端转全栈)支付成功不等于结束:回调、幂等、超时关单和状态竞争

GitHub仓库github.com/2530622506/...

01|(前端转全栈)前端人第一次打开 Spring Boot 项目,应该先看哪里?

02|(前端转全栈)从 pnpm dev 到 Spring Boot 启动:后端服务到底怎么跑起来?

03|(前端转全栈)Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?

04|(前端转全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化

05|(前端转全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门

06|(前端转全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路

07|(前端转全栈)为什么后端也要缓存?从前端缓存思维理解 Redis

08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON

09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门

10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新

11|(前端转全栈)购物车不能只存在前端:用户维度数据如何在后端落库

12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?

面向读者:你已经理解第 12 篇中的订单创建、事务和库存锁定。现在订单已经是 PENDING_PAYMENT,库存已经从 available_stock 转入 locked_stock。本篇继续回答:用户支付成功后,后端如何把订单变成已支付?支付平台为什么可能重复回调?如果用户一直不支付,后端如何超时关单并释放库存?取消、支付回调、超时关单同时发生时,系统如何保证最终只有一个赢家?

重要说明:本文中的 Vue / TypeScript 片段都只是"前端侧示意代码",用于帮助前端同学类比理解后端接口,不代表当前仓库中存在真实前端源码。

1. 这篇解决什么问题

上一章讲到,点击"提交订单"后,后端会创建待支付订单,保存订单项快照,并把 SKU 的可售库存转为锁定库存。此时交易还没有结束,只是进入了一个中间状态:用户还没真正支付,库存也还没真正卖出。这个阶段很像前端页面里"正在支付中"的 loading 状态,但后端语义更复杂:订单不能永久停在待支付,支付结果可能来自外部平台回调,回调可能重复到达,用户也可能主动取消订单,定时任务还可能把超时订单关闭。

本项目把这一阶段拆成几个接口和后台任务:用户登录后可以调用 POST /api/orders/{orderId}/payments 创建支付流水,可以调用 GET /api/orders/{orderId}/payment 查询支付流水;模拟外部支付平台调用 POST /api/mock-payments/callback 通知支付成功;系统定时任务 OrderCloseScheduler 周期扫描超过 expires_at 的待支付订单,并调用关单事务释放库存。你会看到,这些入口虽然不同,但最终都围绕同一组核心资源:mall_ordermall_paymentmall_order_itemmall_product_sku

本篇要解决 8 个问题:

  1. 为什么创建支付流水和创建订单是两个接口,而不是订单创建时直接支付;
  2. mall_payment 表保存什么,为什么一张订单只允许一条支付流水;
  3. 支付回调为什么不使用用户 JWT,而使用 X-Mock-Payment-Secret
  4. 支付回调为什么必须幂等,重复回调为什么不能重复确认库存;
  5. 支付成功时 locked_stock 如何真正变成已售库存;
  6. 取消订单和超时关单如何把 locked_stock 释放回 available_stock
  7. 支付回调、取消订单、超时关单之间为什么会竞争,代码如何用锁和条件更新控制状态;
  8. 如何用 curl、数据库字段和测试验证支付链路是否正确。

你是前端转后端,所以本篇不会假设你已经熟悉第三方支付平台、分布式事务或消息队列。我们只基于当前工程已有代码,从最小闭环讲起:本地模拟支付成功回调、支付状态机、订单状态机、库存确认、超时关单。理解这个版本之后,以后你接入支付宝、微信支付、Stripe、PayPal 或公司内部支付平台,本质思路都是类似的。

2. 用前端知识类比

2.1 支付页不是交易终点,只是等待外部结果

前端同学做支付页时,通常会写出这样的交互:

ts 复制代码
// 前端侧示意代码:只用于解释支付页交互,不代表仓库中存在真实前端源码。
async function pay(orderId: number) {
  const payment = await request.post(`/api/orders/${orderId}/payments`)
  openMockPayPage(payment.data.paymentNo)
  startPollingPayment(orderId)
}

async function startPollingPayment(orderId: number) {
  const timer = setInterval(async () => {
    const res = await request.get(`/api/orders/${orderId}/payment`)
    if (res.data.status === 'SUCCESS') {
      clearInterval(timer)
      router.push(`/orders/${orderId}?paid=1`)
    }
    if (res.data.status === 'CLOSED') {
      clearInterval(timer)
      showToast('支付已关闭,请重新下单')
    }
  }, 2000)
}

这段前端代码里,页面只是在等待状态变化。真正把支付状态从 PENDING 改为 SUCCESS 的,不是前端轮询,而是后端收到支付平台回调后的事务处理。前端轮询只是观察结果。这个区别很重要:前端不能自己说"我支付成功了",也不能直接把订单改成已支付。支付成功必须来自可信的服务端回调,且后端要校验回调来源、支付流水、订单金额和当前状态。

2.2 回调像前端事件,但不能只处理一次就算了

前端里有事件回调,例如按钮点击、WebSocket 消息、SDK 回调。你可能会写:

ts 复制代码
// 前端侧示意代码:UI 事件回调通常只影响当前页面状态。
thirdPartySdk.on('paid', () => {
  paymentStatus.value = 'SUCCESS'
})

后端支付回调看起来也像事件:支付平台告诉你某个支付单成功了。但后端必须考虑比前端更复杂的情况:同一个回调事件可能因为网络超时重复发送;第一次回调已经处理成功,但响应丢了,支付平台会再发一次;两个回调请求可能同时到达;回调到达时订单可能刚好被取消或被超时关单处理。后端不能用一个普通布尔变量 handled = true 解决,因为请求是跨进程、跨线程、跨时间的,真正的幂等状态必须落在数据库里。

本项目通过三个手段处理回调幂等:第一,PaymentCallbackTransactionService.handleSuccess 先用 selectByPaymentNoForUpdate 锁定支付流水行,让同一支付单的重复回调排队;第二,如果支付流水已经是 SUCCESS,直接返回原支付流水,不再次确认库存;第三,mall_payment.callback_id 有唯一索引 uk_mall_payment_callback_id,防止同一个回调事件 ID 被记录到不同支付流水上。

2.3 超时关单像前端定时器,但执行在服务端

前端页面也会写倒计时:

ts 复制代码
// 前端侧示意代码:倒计时只是展示,不代表订单真的被关闭。
const remainSeconds = computed(() => expiresAt - Date.now())
if (remainSeconds.value <= 0) {
  showToast('订单已超时')
}

但前端倒计时没有业务权威。用户关掉浏览器、换设备、断网,倒计时都可能不执行。真正的超时关单必须由后端定时任务完成。本项目的 OrderCloseScheduler 使用 @Scheduled 定时触发 orderCloseBatchService.closeExpiredOrders(),后台扫描已经超过 expires_at 且仍为 PENDING_PAYMENT 的订单,然后逐张订单用独立事务关闭并释放库存。

你可以把它类比成服务端版 cron job。前端负责显示剩余时间,后端负责在时间到了之后真正改变订单状态和库存状态。不要把"页面倒计时结束"误认为"订单已经关闭"。

3. 后端核心概念讲解

3.1 支付流水:订单和支付平台之间的桥

订单表示用户买了什么、多少钱、状态是什么;支付流水表示某次支付动作。为什么不直接在订单表里放支付平台交易号?因为支付本身也有状态、金额、创建时间、支付成功时间、关闭时间、回调事件 ID。把这些字段拆到 mall_payment,可以让订单状态机和支付状态机各自清晰。

本项目 mall_payment 表字段包括:payment_noorder_iduser_idamountstatus_codeprovider_transaction_nocallback_idcreated_atupdated_atpaid_atclosed_at。其中 payment_no 是本系统生成的支付流水号,用来给模拟支付平台定位;provider_transaction_no 是支付平台回传的交易号;callback_id 是支付平台本次回调事件的唯一 ID;amount 来自订单金额快照,不能由前端提交。

一张订单一条支付流水由唯一索引 uk_mall_payment_order_id (order_id) 保证。这样前端重复点击"去支付",后端也只会返回同一条支付流水,不会为同一个订单创建多条互相竞争的支付记录。

3.2 订单状态机和支付状态机

本项目订单状态在 OrderStatus 中,支付状态在 PaymentStatus 中。创建订单后,订单是 PENDING_PAYMENT。创建支付流水后,支付是 PENDING。之后有三种核心结局:

stateDiagram-v2 [*] --> PENDING_PAYMENT: 创建订单并锁库存 PENDING_PAYMENT --> PAID: 支付成功回调 PENDING_PAYMENT --> CANCELLED: 用户主动取消 PENDING_PAYMENT --> CLOSED: 系统超时关单 PAID --> [*] CANCELLED --> [*] CLOSED --> [*]

支付流水状态更简单:

stateDiagram-v2 [*] --> PENDING: 创建支付流水 PENDING --> SUCCESS: 支付成功回调 PENDING --> CLOSED: 订单取消或超时关闭 SUCCESS --> [*] CLOSED --> [*]

两个状态机必须保持一致:订单 PAID 时支付流水应该是 SUCCESS;订单 CANCELLEDCLOSED 时,如果支付流水仍是 PENDING,应该关闭为 CLOSED;支付流水已经 SUCCESS 后,超时关单不能再把订单改成 CLOSED。本篇的大部分代码都在保护这个一致性。

3.3 回调鉴权:为什么不用用户 JWT

PaymentController 是用户接口,需要 JWT,因为用户创建和查询的是自己的支付流水。MockPaymentCallbackController 是模拟支付平台回调入口,不使用用户 JWT,而使用请求头 X-Mock-Payment-Secret。原因是回调请求不是用户浏览器发来的,而是支付平台服务器发来的。真实业务中通常会校验签名、公钥证书、时间戳、回调报文摘要等;本项目为了学习简化为共享密钥。

PaymentFacade.requireValidCallbackSecret 会把配置里的 mall.payment.mock-callback-secret 和请求头里的 secret 转成字节数组,并用 MessageDigest.isEqual 做常量时间比较。普通字符串比较可能因为提前结束而暴露匹配长度信息,虽然本地学习项目风险不大,但这种写法体现了安全习惯。密钥错误时返回 PAYMENT_CALLBACK_SECRET_INVALID,HTTP 状态是 401 Unauthorized

3.4 悲观锁和条件更新:让竞争串行化

支付回调、用户取消、超时关单都可能修改同一张订单、同一条支付流水、同一批 SKU 库存。如果两个事务同时改,就会产生竞争。本项目使用两类机制:

  1. SELECT ... FOR UPDATE 悲观锁:先锁定关键行,让并发请求排队;
  2. 条件 UPDATE:只有旧状态符合预期时才更新,影响行数为 1 才算成功。

例如 PaymentMapper.selectByPaymentNoForUpdate 会按支付单号锁定支付流水;OrderMapper.markPaid 只允许 PENDING_PAYMENT 更新为 PAIDPaymentMapper.markSuccess 只允许 PENDING 更新为 SUCCESSOrderMapper.closeExpiredOrder 只允许已过期的 PENDING_PAYMENT 更新为 CLOSEDPaymentMapper.closePendingByOrderId 只关闭仍为 PENDING 的支付流水。

前端类比是:你会在异步请求回来后检查当前页面状态是否还是"可更新",避免旧请求覆盖新状态;后端则用数据库行锁和条件更新保证状态不会被并发请求覆盖。

3.5 锁定库存的三个终点

第 12 篇讲过,创建订单时库存从 available_stock 转到 locked_stock。之后有三条路径:

支付成功时,锁定库存真正被消费掉,所以只减少 locked_stock,不增加 available_stock。取消和超时关单时,交易没有完成,所以锁定库存释放回可售库存。这个三段式库存模型比"下单直接扣库存"更适合待支付场景。

4. 在本项目中对应哪些文件

层次 文件 作用
用户支付入口 backend/service/src/main/java/com/example/fullstackmall/service/payment/PaymentController.java 定义 POST /api/orders/{orderId}/paymentsGET /api/orders/{orderId}/payment
模拟回调入口 MockPaymentCallbackController.java 定义 POST /api/mock-payments/callback,使用 X-Mock-Payment-Secret
支付契约 backend/contract/src/main/java/com/example/fullstackmall/contract/payment/IPaymentFacade.java 声明创建支付、查询支付、处理回调
回调 Request MockPaymentCallbackRequest.java 包含 paymentNocallbackIdproviderTransactionNoresult
支付响应 PaymentResponse.java 返回支付流水号、订单 ID、金额、状态、交易号和时间字段
支付门面 PaymentFacade.java 处理当前用户、回调密钥、并发重复请求、Response 组装
创建支付事务 PaymentCreateTransactionService.java 锁定订单,校验订单待支付且未过期,创建唯一支付流水
回调事务 PaymentCallbackTransactionService.java 锁定支付流水,校验金额和状态,标记订单已支付,确认锁定库存
支付 DB PaymentDbService.javaPaymentMapper.java 封装支付流水查询、行锁、状态更新、关闭支付流水
取消事务 OrderCancelTransactionService.java 取消待支付订单,释放库存,关闭待支付流水
关单任务 OrderCloseScheduler.javaOrderCloseBatchService.javaOrderCloseTransactionService.java 定时扫描过期订单,逐单关闭并释放库存
库存 SQL SkuMapper.java confirmLockedStockreleaseLockedStock
表结构 sql/01_schema.sql 定义 mall_payment 及唯一索引
配置 backend/service/src/main/resources/application.yml 定义回调密钥、订单支付超时时间、关单扫描间隔和批大小
测试 PaymentControllerTest.javaPaymentMySqlContainerTest.javaPaymentTransactionRollbackTest.java 验证权限、幂等、回调、超时关单、并发竞争和回滚

读代码时可以按这张表走:先看用户接口,再看模拟回调接口,再看 Facade,再看事务服务,再看 Mapper 的 SQL,最后看测试如何证明这些规则。

5. Mermaid 图:支付与关单完整链路

5.1 创建支付流水

sequenceDiagram participant FE as 前端支付页 participant C as PaymentController participant F as PaymentFacade participant T as PaymentCreateTransactionService participant O as mall_order participant P as mall_payment FE->>C: POST /api/orders/{orderId}/payments + JWT C->>F: createMyPayment(orderId) F->>F: 从 JWT 获取 userId F->>P: 查询同 orderId + userId 支付流水 alt 已存在 P-->>F: 返回已有流水 else 不存在 F->>T: createPayment(orderId, userId) T->>O: SELECT ... FOR UPDATE 锁订单 T->>O: 校验订单属于当前用户、待支付、未过期 T->>P: 保存 PENDING 支付流水 end F-->>C: PaymentResponse C-->>FE: 201 Created

5.2 支付成功回调

sequenceDiagram participant PSP as 模拟支付平台 participant C as MockPaymentCallbackController participant F as PaymentFacade participant T as PaymentCallbackTransactionService participant P as mall_payment participant O as mall_order participant S as mall_product_sku PSP->>C: POST /api/mock-payments/callback + Secret C->>F: handleMockCallback(secret, request) F->>F: 校验 X-Mock-Payment-Secret F->>T: handleSuccess(request) T->>P: SELECT ... FOR UPDATE 按 paymentNo 锁支付行 alt 已 SUCCESS T-->>F: 直接返回原支付流水 else PENDING T->>O: 校验订单存在、金额一致、仍待支付 T->>O: PENDING_PAYMENT -> PAID T->>P: PENDING -> SUCCESS,保存 callbackId 和平台交易号 T->>S: confirmLockedStock,确认锁定库存售出 end F-->>C: PaymentResponse

5.3 超时关单

flowchart TD A[OrderCloseScheduler 定时触发] --> B[OrderCloseBatchService 扫描过期待支付订单 ID] B --> C{逐个 orderId} C --> D[OrderCloseTransactionService 独立事务] D --> E[锁支付行] E --> F[条件 UPDATE: PENDING_PAYMENT 且 expires_at <= now 才 CLOSED] F --> G{影响行数是否 1} G -->|否| H[已被支付取消或其他任务处理] G -->|是| I[releaseLockedStock 释放库存] I --> J[关闭 PENDING 支付流水] J --> K[提交该订单事务]

5.4 回调和关单竞争

5.5 支付流水表和订单表关系

classDiagram class OrderEntity { id orderNo userId statusCode totalAmount expiresAt } class PaymentEntity { id paymentNo orderId userId amount statusCode callbackId providerTransactionNo } OrderEntity &#34;1&#34; --> &#34;0..1&#34; PaymentEntity : order_id unique

6. 逐段读源码

6.1 PaymentController:用户创建和查询支付流水

PaymentController 位于 backend/service/src/main/java/com/example/fullstackmall/service/payment/PaymentController.java。类级路径是 @RequestMapping("/api/orders/{orderId}"),创建支付路径是 POST /api/orders/{orderId}/payments,查询支付路径是 GET /api/orders/{orderId}/payment

创建方法如下:

java 复制代码
@PostMapping("/payments")
@Operation(summary = "为我的待支付订单创建支付流水")
public ResponseEntity<ApiResponse<PaymentResponse>> createPayment(
        @PathVariable Long orderId,
        HttpServletRequest request
) {
    PaymentResponse response = paymentFacade.createMyPayment(orderId);
    return ResponseEntity.status(HttpStatus.CREATED)
            .body(ApiResponse.success(response, TraceIdContext.get(request)));
}

这里没有 @RequestBody,因为创建支付流水不需要前端提交金额。金额来自订单快照。orderId 来自路径,但后端仍会校验该订单是否属于当前登录用户。成功返回 201 Created,说明这也是一次资源创建。

查询方法返回 ApiResponse<PaymentResponse>。如果当前用户没有这张订单的支付流水,会返回 PAYMENT_NOT_FOUND 或在创建阶段返回 ORDER_NOT_FOUND。前端不能靠隐藏按钮保护权限,后端必须用 orderId + userId 约束。

6.2 MockPaymentCallbackController:模拟外部平台回调

模拟回调入口位于 MockPaymentCallbackController

java 复制代码
@PostMapping("/callback")
@Operation(summary = "接收本地模拟支付成功回调")
public ApiResponse<PaymentResponse> callback(
        @RequestHeader(value = "X-Mock-Payment-Secret", required = false) String callbackSecret,
        @Valid @RequestBody MockPaymentCallbackRequest callbackRequest,
        HttpServletRequest request
) {
    return ApiResponse.success(
            paymentFacade.handleMockCallback(callbackSecret, callbackRequest),
            TraceIdContext.get(request)
    );
}

它不读取用户 JWT,因为这不是用户浏览器发起的请求。它读取 X-Mock-Payment-Secret,并接收 MockPaymentCallbackRequest。请求体里有 paymentNocallbackIdproviderTransactionNoresult。阶段 8 只支持 PaymentCallbackResult.SUCCESS,也就是只模拟支付成功,不模拟支付失败、退款等复杂场景。

6.3 PaymentFacade.createMyPayment:重复创建支付返回同一条

PaymentFacade.createMyPayment 的第一步是拿当前用户:

java 复制代码
Long userId = currentUserService.requireCurrentUserId();
PaymentEntity existing = paymentDbService.getByOrderIdAndUserId(orderId, userId);
if (existing != null) {
    return toResponse(existing);
}

如果同一个用户对同一个订单重复调用创建支付接口,直接返回已有支付流水。这对前端非常友好:用户刷新支付页、重复点击"去支付"、网络超时重试,都不会产生多条支付流水。

如果还不存在,就调用事务服务:

java 复制代码
try {
    Long paymentId = paymentCreateTransactionService.createPayment(orderId, userId);
    return toResponse(paymentDbService.getById(paymentId));
} catch (DataIntegrityViolationException exception) {
    PaymentEntity concurrent = paymentDbService.getByOrderIdAndUserId(orderId, userId);
    if (concurrent != null) {
        return toResponse(concurrent);
    }
    throw exception;
}

这里和订单幂等很像:业务层先查一次,数据库唯一索引 uk_mall_payment_order_id 做并发兜底。两个请求同时创建支付流水时,最多一个插入成功,另一个捕获唯一约束异常后读取已有流水返回。

6.4 PaymentCreateTransactionService:锁订单后创建支付

创建支付流水的事务方法是:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public Long createPayment(Long orderId, Long userId) {
    OrderEntity order = orderDbService.selectByIdForUpdate(orderId);
    if (order == null || !userId.equals(order.getUserId())) {
        throw new BusinessException(ApiCode.ORDER_NOT_FOUND,
                ApiCode.ORDER_NOT_FOUND.defaultMessage(), HttpStatus.NOT_FOUND);
    }
    if (!OrderStatus.PENDING_PAYMENT.name().equals(order.getStatusCode())) {
        throw conflict(ApiCode.PAYMENT_ORDER_NOT_PAYABLE);
    }
    if (order.getExpiresAt() != null && !order.getExpiresAt().isAfter(LocalDateTime.now())) {
        throw conflict(ApiCode.PAYMENT_ORDER_NOT_PAYABLE);
    }
    ...
}

为什么要 selectByIdForUpdate 锁订单?因为创建支付、取消订单、超时关单都可能围绕同一张订单并发执行。锁住订单后,当前事务可以稳定判断订单是否还待支付、是否已过期。只有订单属于当前用户、状态为 PENDING_PAYMENT、且未超过 expires_at,才能创建支付流水。

然后代码再次检查是否已有支付流水:

java 复制代码
PaymentEntity existing = paymentDbService.getByOrderId(orderId);
if (existing != null) {
    return existing.getId();
}

这是事务内的顺序幂等。最后创建 PaymentEntity,设置 paymentNoorderIduserIdamountstatusCode=PENDING、创建时间和更新时间。amount 来自 order.getTotalAmount(),不是前端提交。

6.5 PaymentFacade.handleMockCallback:回调入口外层控制

回调处理先校验密钥:

java 复制代码
requireValidCallbackSecret(callbackSecret);

密钥通过后,调用事务服务:

java 复制代码
try {
    Long paymentId = paymentCallbackTransactionService.handleSuccess(request);
    return toResponse(paymentDbService.getById(paymentId));
} catch (DataIntegrityViolationException exception) {
    PaymentEntity callbackPayment = paymentDbService.getByCallbackId(request.getCallbackId());
    if (callbackPayment != null && request.getPaymentNo().equals(callbackPayment.getPaymentNo())) {
        return toResponse(callbackPayment);
    }
    throw new BusinessException(ApiCode.PAYMENT_CALLBACK_DUPLICATE,
            ApiCode.PAYMENT_CALLBACK_DUPLICATE.defaultMessage(), HttpStatus.CONFLICT);
}

这里处理的是 callback_id 唯一约束冲突。同一个回调事件重复到达同一个支付流水,可以返回已有结果;但如果同一个 callbackId 被拿去绑定另一个 paymentNo,就是高风险异常,返回 PAYMENT_CALLBACK_DUPLICATE

6.6 PaymentCallbackTransactionService:支付成功的事务边界

回调事务是本篇最重要的代码。第一步按 paymentNo 锁定支付流水:

java 复制代码
PaymentEntity payment = paymentDbService.selectByPaymentNoForUpdate(request.getPaymentNo());
if (payment == null) {
    throw new BusinessException(ApiCode.PAYMENT_NOT_FOUND,
            ApiCode.PAYMENT_NOT_FOUND.defaultMessage(), HttpStatus.NOT_FOUND);
}

如果已经成功,直接返回:

java 复制代码
if (PaymentStatus.SUCCESS.name().equals(payment.getStatusCode())) {
    return payment.getId();
}
if (!PaymentStatus.PENDING.name().equals(payment.getStatusCode())) {
    throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}

这就是回调幂等的核心:重复成功回调不再重复确认库存;已经关闭的支付流水不能再改成功。然后读取订单,校验金额和订单状态:

java 复制代码
OrderEntity order = orderDbService.getById(payment.getOrderId());
if (payment.getAmount().compareTo(order.getTotalAmount()) != 0) {
    throw conflict(ApiCode.PAYMENT_AMOUNT_MISMATCH);
}
if (!OrderStatus.PENDING_PAYMENT.name().equals(order.getStatusCode())) {
    throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}

金额校验很关键。支付流水金额来自订单金额快照,订单金额也是后端在创建订单时计算的。如果两者不一致,说明数据异常,不能继续把订单标记为已支付。

6.7 标记订单已支付和支付流水成功

回调事务随后做两个条件更新:

java 复制代码
if (orderDbService.markPaid(order.getId()) != 1) {
    throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}
if (paymentDbService.markSuccess(payment.getId(),
        request.getProviderTransactionNo(), request.getCallbackId()) != 1) {
    throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}

markPaid 只允许订单从 PENDING_PAYMENT 变为 PAIDmarkSuccess 只允许支付流水从 PENDING 变为 SUCCESS,并写入支付平台交易号、回调 ID、支付成功时间。影响行数不是 1,就表示状态已经被其他事务抢先修改,例如超时关单或取消订单已经完成。此时必须抛异常,让当前事务回滚。

6.8 支付成功后确认锁定库存

最后一步确认锁定库存:

java 复制代码
List<OrderItemEntity> items = orderItemDbService.listByOrderId(order.getId())
        .stream().sorted(Comparator.comparing(OrderItemEntity::getSkuId)).toList();
if (items.isEmpty()) {
    throw conflict(ApiCode.PAYMENT_STOCK_CONFIRM_FAILED);
}
for (OrderItemEntity item : items) {
    if (skuDbService.confirmLockedStock(item.getSkuId(), item.getQuantity()) != 1) {
        throw conflict(ApiCode.PAYMENT_STOCK_CONFIRM_FAILED);
    }
}

confirmLockedStock 底层 SQL 是:

sql 复制代码
UPDATE mall_product_sku
SET locked_stock = locked_stock - #{quantity},
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{skuId}
  AND locked_stock >= #{quantity}

支付成功后,锁定库存被真正消费,所以 locked_stock 减少,但 available_stock 不增加。若确认库存失败,抛出 PAYMENT_STOCK_CONFIRM_FAILED。因为整个方法有 @Transactional,订单从 PENDING_PAYMENTPAID、支付从 PENDINGSUCCESS、库存确认这些动作会一起回滚,不能留下"订单已支付但库存没确认"的半成功状态。

6.9 OrderCancelTransactionService:用户主动取消

用户取消待支付订单时,会进入 OrderCancelTransactionService.cancelOrder。它先确认订单属于当前用户,然后锁定支付行,再把订单从 PENDING_PAYMENT 更新为 CANCELLED。随后遍历订单项调用 releaseLockedStock 释放库存,最后关闭仍为 PENDING 的支付流水。

flowchart TD A[用户取消订单] --> B[校验 orderId + userId] B --> C[锁支付行] C --> D[PENDING_PAYMENT -> CANCELLED] D --> E[releaseLockedStock] E --> F[支付流水 PENDING -> CLOSED]

这里释放库存的 SQL 和支付成功不同:取消没有卖出商品,所以要 available_stock + quantitylocked_stock - quantity。如果任何一条 SKU 释放失败,抛 ORDER_STOCK_RELEASE_FAILED,整个取消事务回滚。

6.10 OrderCloseScheduler:后台超时关单

OrderCloseScheduler 使用 @Scheduled

java 复制代码
@Scheduled(
        fixedDelayString = "${mall.order.close-scan-fixed-delay-ms:60000}",
        initialDelayString = "${mall.order.close-scan-fixed-delay-ms:60000}"
)
public void closeExpiredOrders() {
    orderCloseBatchService.closeExpiredOrders();
}

fixedDelay 表示上一次扫描结束后再等待指定时间,避免扫描任务无间隔重叠。默认扫描间隔来自配置 mall.order.close-scan-fixed-delay-ms,默认 60000 毫秒。扫描批大小来自 mall.order.close-scan-batch-size,默认 100。支付超时时间来自 mall.order.payment-timeout-minutes,默认 15 分钟。

6.11 OrderCloseBatchService:批量扫描但不包大事务

批量服务先查询一批过期订单 ID:

java 复制代码
List<Long> orderIds = orderDbService.listExpiredPendingOrderIds(now, batchSize);

然后逐个调用 OrderCloseTransactionService.closeExpiredOrder(orderId, now)。注意注释:批次本身不包大事务,每个订单调用独立 Bean 的事务方法,单笔失败不会回滚整批。这是后端批处理非常重要的设计。假设一次扫描 100 张订单,如果放在一个大事务里,一张订单释放库存失败,其他 99 张也全部回滚,锁持有时间还很长。逐单事务能缩短锁时间,也能让失败隔离。

6.12 OrderCloseTransactionService:关闭过期订单并释放库存

关单事务逻辑如下:

java 复制代码
paymentDbService.selectByOrderIdForUpdate(orderId);
if (orderDbService.closeExpiredOrder(orderId, now) != 1) {
    return false;
}
List<OrderItemEntity> items = orderItemDbService.listByOrderId(orderId)
        .stream().sorted(Comparator.comparing(OrderItemEntity::getSkuId)).toList();
...
paymentDbService.closePendingByOrderId(orderId);
return true;

先锁支付行,是为了和回调、取消保持统一锁顺序,降低死锁风险。closeExpiredOrder 的 SQL 只允许 status_code='PENDING_PAYMENT'expires_at <= now 的订单变成 CLOSED。如果影响行数是 0,说明订单可能已经支付、取消、关闭,或者还没过期。这不是系统异常,返回 false 即可。

如果关闭成功,就释放库存,并关闭仍为 PENDING 的支付流水。若库存释放失败,抛 ORDER_CLOSE_STOCK_RELEASE_FAILED,事务回滚,订单和支付流水都不应该留下关闭状态。

7. 本地运行 / curl 验证

7.1 启动项目

先启动 MySQL:

bash 复制代码
docker compose -f docker-compose.dev.yml up -d mysql

进入 backend/ 目录启动服务:

bash 复制代码
cd backend
mvn -pl service -am spring-boot:run

如果你使用本地 Maven 仓库缓存,可以按自己的环境增加 -Dmaven.repo.local=../.m2-repository

7.2 登录、创建订单、创建支付流水

登录小明:

bash 复制代码
curl -sS -X POST 'http://localhost:8080/api/auth/login' \
  -H 'Content-Type: application/json' \
  -d '{"username":"xiaoming","password":"Mall123456"}'

保存 token:

bash 复制代码
TOKEN='把登录响应里的 token 放这里'

创建订单:

bash 复制代码
curl -sS -X POST 'http://localhost:8080/api/orders' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Idempotency-Key: blog-payment-order-001'

保存订单 ID:

bash 复制代码
ORDER_ID='把订单 id 放这里'

创建支付流水:

bash 复制代码
curl -sS -X POST "http://localhost:8080/api/orders/$ORDER_ID/payments" \
  -H "Authorization: Bearer $TOKEN"

保存响应里的 paymentNo

bash 复制代码
PAYMENT_NO='把 paymentNo 放这里'

7.3 查询支付流水

bash 复制代码
curl -sS "http://localhost:8080/api/orders/$ORDER_ID/payment" \
  -H "Authorization: Bearer $TOKEN"

期望看到 statusPENDINGamount 等于订单金额 9198.00。

7.4 模拟支付成功回调

bash 复制代码
curl -sS -X POST 'http://localhost:8080/api/mock-payments/callback' \
  -H 'Content-Type: application/json' \
  -H 'X-Mock-Payment-Secret: local-mock-payment-secret' \
  -d "{
    \"paymentNo\": \"$PAYMENT_NO\",
    \"callbackId\": \"callback-blog-001\",
    \"providerTransactionNo\": \"provider-blog-001\",
    \"result\": \"SUCCESS\"
  }"

期望返回支付流水 status=SUCCESS,并包含 providerTransactionNopaidAt。随后查询订单,应看到订单状态为 PAID。数据库中 SKU 1 的 locked_stock 应减少到 0,available_stock 保持订单创建后的数值,因为商品已经售出,不应释放回可售库存。

7.5 重复回调验证幂等

再次发送同一个回调:

bash 复制代码
curl -sS -X POST 'http://localhost:8080/api/mock-payments/callback' \
  -H 'Content-Type: application/json' \
  -H 'X-Mock-Payment-Secret: local-mock-payment-secret' \
  -d "{
    \"paymentNo\": \"$PAYMENT_NO\",
    \"callbackId\": \"callback-blog-001\",
    \"providerTransactionNo\": \"provider-blog-001\",
    \"result\": \"SUCCESS\"
  }"

期望仍返回同一条成功支付流水,不应该重复确认库存。这个实验能帮助你理解"回调幂等"不是理论,而是可以通过重复请求直接验证的行为。

7.6 密钥错误验证

bash 复制代码
curl -i -X POST 'http://localhost:8080/api/mock-payments/callback' \
  -H 'Content-Type: application/json' \
  -H 'X-Mock-Payment-Secret: wrong-secret' \
  -d "{
    \"paymentNo\": \"$PAYMENT_NO\",
    \"callbackId\": \"callback-blog-bad\",
    \"providerTransactionNo\": \"provider-blog-bad\",
    \"result\": \"SUCCESS\"
  }"

期望返回 PAYMENT_CALLBACK_SECRET_INVALID,HTTP 状态为 401。真实支付平台会使用更强的签名校验,本项目用共享密钥模拟这个安全入口。

8. 常见错误

8.1 让前端直接把订单改成已支付

前端可以展示"支付成功"页面,但不能决定订单是否已支付。订单状态必须由后端根据支付平台可信回调修改。否则用户可以绕过支付直接调用接口把订单改成已支付。

8.2 支付回调用用户 JWT

支付平台回调不是用户浏览器请求,通常不会携带用户 JWT。真实系统要校验平台签名,本项目使用 X-Mock-Payment-Secret。如果你把回调做成必须用户登录,支付平台就无法调用;如果完全不鉴权,任何人都能伪造支付成功。

8.3 重复回调重复扣库存

支付平台重复通知是常态,不是异常。后端必须让重复成功回调返回已有结果,而不是再次执行 confirmLockedStock。本项目通过支付行锁、状态判断和 callback_id 唯一索引共同保证。

8.4 支付成功后释放库存

支付成功不是释放库存,而是确认售出。释放库存发生在取消订单或超时关单。支付成功应该减少 locked_stock,不增加 available_stock。如果把支付成功写成释放库存,会导致已售商品重新变成可卖。

8.5 超时关单覆盖已支付订单

关单 SQL 必须带旧状态条件:只有 PENDING_PAYMENT 且已过期才能变成 CLOSED。如果不检查旧状态,支付成功后的订单可能被定时任务错误关闭。本项目的 OrderMapper.closeExpiredOrder 用条件更新防止这种覆盖。

8.6 批量关单放进一个大事务

一次扫描 100 张过期订单,如果放在一个大事务里,锁持有时间长,一张失败会影响整批。正确做法是批量扫描 ID,然后每张订单独立事务关闭。本项目的 OrderCloseBatchService 就是这样设计的。

8.7 忽略取消、回调、关单的竞争

真实用户行为不会按你想象的顺序发生。用户可能刚点击取消,支付平台回调也到了;定时任务可能刚要关单,支付回调也到了。后端必须用行锁、统一锁顺序和条件更新让最终状态一致,而不是相信"这种情况应该很少"。

8.8 查询支付流水不带 userId

支付流水也是用户私有资源。查询接口必须通过 orderId + userId 限制,否则用户可能猜订单 ID 查询别人的支付信息。本项目 PaymentDbService.getByOrderIdAndUserId 就是在保护这个边界。

8.9 用户支付接口和平台回调接口不是同一种权限模型

前端同学最容易把"创建支付"和"支付回调"想成同一个东西:反正都是 HTTP POST,带一个 token 不就行了吗?后端一定要把这两个入口分开看。POST /api/orders/{orderId}/paymentsGET /api/orders/{orderId}/payment 是用户接口,调用者是登录用户本人,所以它们必须经过 JWT。JWT 解决的问题是"这个用户是谁",再配合 orderId + userId 查询,保证用户只能创建或查看自己的支付流水。换成前端类比,这相当于一个订单详情页只能读取当前登录账号的订单状态,不能只凭 URL 上的 id 就信任请求。

POST /api/mock-payments/callback 的调用者不是用户浏览器,而是支付平台。支付平台不会拿某个用户的 JWT,因为它代表的是外部系统,不代表某个登录用户。真实项目里通常会使用平台证书、签名串、时间戳、防重放参数和回调 IP 白名单。本项目为了学习,把它简化成 X-Mock-Payment-Secret 共享密钥,并且在 PaymentFacade.requireValidCallbackSecret 中用 MessageDigest.isEqual 做恒定时间比较。这个设计要表达的思想是:用户入口验证用户身份,平台回调验证平台身份,二者不能混用。

flowchart LR FE[前端支付页] -->|JWT| USER_API[用户支付接口] USER_API --> USER_CHECK[校验当前用户和订单归属] PSP[模拟支付平台] -->|X-Mock-Payment-Secret| CALLBACK[支付回调接口] CALLBACK --> SECRET_CHECK[校验平台共享密钥] USER_CHECK --> PAYMENT[mall_payment] SECRET_CHECK --> PAYMENT

如果让前端直接调用回调接口,就等于把"宣布支付成功"的权力交给浏览器,这是严重安全漏洞。前端只能发起创建支付、展示支付页、轮询查询支付状态;订单最终是否支付成功,必须由后端根据可信回调改变状态。

8.10 前端轮询只是观察,后端回调才是事实来源

支付页通常会有一个倒计时和一个"支付中"的状态。前端可以每隔几秒请求 GET /api/orders/{orderId}/payment,根据后端返回的支付流水状态更新页面。但请记住:轮询只是观察后端状态,不负责改变后端状态。用户关闭浏览器、切换网络、刷新页面,都不应该影响支付平台回调和超时关单。后端状态机必须在没有前端页面在线的情况下仍然能完成最终流转。

前端侧示意代码如下,注意它只读状态,不写状态:

ts 复制代码
// 前端侧示意代码:轮询只是观察后端状态,不负责把订单改成已支付或已关闭。
async function pollPayment(orderId: number) {
  const res = await request.get(`/api/orders/${orderId}/payment`)
  const status = res.data.data.status
  if (status === 'SUCCESS') {
    stopPolling()
    router.push(`/orders/${orderId}/success`)
    return
  }
  if (status === 'CLOSED') {
    stopPolling()
    showToast('支付已关闭,请重新下单')
  }
}

这个分工很像前端的 server state 管理:页面状态可以缓存、刷新、重试,但真正的事实来源仍然是服务端 API。区别在于支付场景更严格,后端还有外部平台回调和定时任务两个"页面看不见的写入口"。所以前端倒计时到 0 只能提示"可能已超时",不能代替 OrderCloseScheduler 真正关闭订单;前端看到成功页也只是展示结果,不能代替 PaymentCallbackTransactionService 标记订单已支付。

8.11 为什么创建支付流水也要幂等

很多人只知道支付回调要幂等,却忽略创建支付流水也要幂等。真实页面里,用户可能连续点击"去支付",浏览器可能因为网络慢重试请求,前端路由刷新也可能重新初始化支付页。如果每次 POST /api/orders/{orderId}/payments 都插入一条新流水,就会出现一张订单对应多个支付单号,后续回调、查询和关单都会变复杂。

本项目用两层保护解决这个问题。第一层是在业务代码中先按 orderId + userId 查已有流水,存在就直接返回。第二层是在数据库里给 mall_payment.order_id 建唯一索引 uk_mall_payment_order_id,即使两个请求并发通过了"先查不存在"的窗口,也只有一个插入能成功。PaymentFacade.createMyPayment 捕获 DataIntegrityViolationException 后会再查询已有流水并返回。前端可以把它理解成"按钮防重复点击"的后端版本,但后端不能只相信按钮禁用,必须在数据库层拥有最后防线。

9. 本章小练习

练习 1:画出支付成功链路

POST /api/mock-payments/callback 开始,写出经过的类和方法:MockPaymentCallbackController.callbackPaymentFacade.handleMockCallbackPaymentCallbackTransactionService.handleSuccessPaymentDbService.selectByPaymentNoForUpdateOrderDbService.markPaidPaymentDbService.markSuccessSkuDbService.confirmLockedStock

练习 2:解释为什么回调不用 JWT

用自己的话说明:用户创建支付流水为什么需要 JWT?支付平台回调为什么不使用 JWT?本项目用哪个请求头模拟支付平台鉴权?

练习 3:读懂 mall_payment 唯一索引

找到 sql/01_schema.sqluk_mall_payment_order_iduk_mall_payment_callback_id。解释它们分别防止什么问题。再思考:为什么 callback_id 可以为空,但一旦有值就应该唯一?

练习 4:解释支付成功时库存变化

假设订单创建后 SKU 1 的 available_stock=8locked_stock=2。支付成功后应该变成多少?取消订单后又应该变成多少?请分别写出原因。

练习 5:阅读测试当业务文档

PaymentControllerTest 中这些测试名翻译成中文业务规则:createsQueriesAndRetriesSamePaymentcallbackRequiresSecretAndIsIdempotentcancelledOrderRejectsLateCallbackAndKeepsReleasedStockclosesExpiredOrderAndReleasesStockpaidOrderIsNotClosedByBatch

练习 6:分析回滚测试

阅读 PaymentTransactionRollbackTest.callbackRollsBackOrderAndPaymentWhenStockConfirmationFails。为什么它把 locked_stock 改成 0 后,回调应该失败?失败后订单和支付流水为什么仍然保持待支付 / 待处理?

练习 7:设计前端轮询停止条件

写一段前端侧示意代码,轮询 GET /api/orders/{orderId}/payment,当状态为 SUCCESS 时跳转成功页,当状态为 CLOSED 时停止轮询并提示订单已关闭。注意标注"前端侧示意代码"。

练习 8:解释三个最终状态

用三句话解释:PAIDCANCELLEDCLOSED 分别由什么触发?它们对支付流水和库存分别有什么影响?

10. 再深入一点:一致性不是靠一个 if 判断实现的

很多前端转后端同学刚开始写业务状态流转时,会习惯在代码里写几个 if:如果订单是待支付,就改成已支付;如果支付是待处理,就改成成功。单线程演示时这看起来没问题,但后端服务是多线程处理请求的。两个请求可能同时读到同一个旧状态,然后分别写入不同新状态。只靠 Java 里的 if,不能保证数据库最终一致。

本项目的做法是把判断推进到数据库层。比如 markPaid 不是先查订单再无条件更新,而是:

sql 复制代码
UPDATE mall_order
SET status_code = 'PAID',
    paid_at = CURRENT_TIMESTAMP(3),
    updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{orderId}
  AND status_code = 'PENDING_PAYMENT'

status_code = 'PENDING_PAYMENT' 是关键。它让"检查旧状态"和"修改新状态"合并成一个原子数据库动作。更新后影响行数是 1,说明你赢得了状态流转;影响行数是 0,说明状态已经被其他事务改变,你不能继续执行后续库存操作。

flowchart TD A[Java if 判断] --> B[只能说明刚才读到的状态] B --> C[并发下可能过期] D[条件 UPDATE] --> E[数据库在更新瞬间检查旧状态] E --> F[影响行数告诉你是否抢到状态流转权]

这就是后端并发业务的基本思路:Java 判断用于可读性和提前失败,数据库条件更新用于最终保证。行锁用于让高冲突资源串行化,唯一索引用于让重复请求有唯一胜者,事务用于让多张表一起成功或回滚。支付链路把这些手段都串起来了。

11. 排查支付和关单问题的顺序

支付问题常见现象包括:创建支付失败、重复创建多条支付、回调 401、回调后订单没变已支付、库存没有变化、订单超时后没关闭、已支付订单被错误关闭。建议按下面顺序排查:

flowchart TD A[支付/关单异常] --> B{用户接口还是回调接口} B -->|用户接口| C[检查 JWT 和 orderId + userId] B -->|回调接口| D[检查 X-Mock-Payment-Secret] C --> E{订单是否 PENDING_PAYMENT 且未过期} E -->|否| E1[PAYMENT_ORDER_NOT_PAYABLE] E -->|是| F[检查 mall_payment 是否已有 order_id] D --> G{paymentNo 是否存在} G -->|否| G1[PAYMENT_NOT_FOUND] G -->|是| H{支付流水是否 PENDING} H -->|SUCCESS| H1[重复回调直接返回] H -->|CLOSED| H2[PAYMENT_STATUS_CONFLICT] H -->|PENDING| I[检查订单金额和状态] I --> J[检查 confirmLockedStock / releaseLockedStock 影响行数] J --> K[对照测试和数据库字段]

数据库字段优先看这些:mall_order.status_codemall_order.expires_atmall_payment.status_codemall_payment.callback_idmall_payment.provider_transaction_nomall_product_sku.available_stockmall_product_sku.locked_stock。如果错误码是 PAYMENT_CALLBACK_SECRET_INVALID,先看请求头;如果是 PAYMENT_STATUS_CONFLICT,看订单和支付流水是否已经被其他流程处理;如果是 PAYMENT_STOCK_CONFIRM_FAILEDORDER_CLOSE_STOCK_RELEASE_FAILED,看锁定库存是否足够。

测试也是排查入口。PaymentMySqlContainerTest.concurrentSuccessCallbacksConfirmStockOnlyOnce 验证重复成功回调只确认一次库存;callbackAndTimeoutCloseHaveOneConsistentWinner 验证支付回调和超时关单并发时最终只能得到一致状态;PaymentTransactionRollbackTest 验证库存确认或释放失败时订单和支付流水一起回滚。这些测试覆盖的是生产中最容易出事故的边界。

12. 下一章预告:Redis Cache Aside 商品详情实战

到这里,商品、SKU、购物车、订单、支付的主交易链路已经基本串起来了。下一篇我们会回到读多写少的商品详情场景,深入讲 Redis Cache Aside 商品详情实战。你会看到商品详情缓存 key 如何设计,缓存命中和未命中如何处理,为什么要缓存空值,Redis 异常时为什么要降级查 MySQL,管理端修改商品后为什么要删除缓存,以及如何用 curl 和日志观察缓存命中。

这对前端同学也很重要。前端常见缓存有 memory cache、HTTP cache、localStorage、SWR;后端 Redis 缓存和它们思想相似,但多了跨用户共享、TTL、缓存穿透、缓存一致性和服务降级等问题。下一篇会用当前工程的 ProductDetailCacheServiceProductFacade 继续讲。

13. 本篇总结

本篇你需要带走 13 个结论:

  1. 订单创建后只是待支付,库存处于锁定状态,交易还没最终完成;
  2. 支付流水是订单和支付平台之间的桥,一张订单在本阶段只有一条支付流水;
  3. 创建支付和查询支付是用户接口,需要 JWT,并且必须按 orderId + userId 隔离;
  4. 支付回调不是用户请求,不使用用户 JWT,本项目用 X-Mock-Payment-Secret 模拟平台鉴权;
  5. 支付回调必须幂等,因为支付平台可能重复通知;
  6. 重复成功回调应该返回已有成功结果,不能重复确认库存;
  7. callback_id 唯一索引用于防止同一个回调事件被串用到不同支付流水;
  8. 支付成功时订单从 PENDING_PAYMENT 变为 PAID,支付流水从 PENDING 变为 SUCCESS
  9. 支付成功会调用 confirmLockedStock,只减少 locked_stock,不增加 available_stock
  10. 用户取消和超时关单会调用 releaseLockedStock,把锁定库存释放回可售库存;
  11. 超时关单由后端定时任务执行,前端倒计时只负责展示;
  12. 批量关单不应该放在一个大事务里,每张订单独立事务更安全;
  13. 支付回调、取消、关单之间会竞争,后端用行锁、条件更新、唯一索引和事务保证最终一致。

如果你能解释"支付成功为什么不是释放库存,而是确认锁定库存",并能说清楚"重复回调为什么不会重复扣库存",说明你已经理解了支付链路最核心的后端思维。下一篇我们会从交易链路切回性能优化,学习 Redis Cache Aside 在商品详情中的完整实战。

相关推荐
thesky1234561 小时前
智能体面试准备(六):Agent 记忆系统设计——短期、长期与向量记忆的架构与实现
人工智能·面试·agent·智能体·记忆系统
swipe1 小时前
12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?
前端·后端·面试
小林ixn1 小时前
React + TypeScript 实战:从“类型体操”到“数据持久化”,一次讲透组件通信与副作用管理
前端·react.js·typescript
何时梦醒1 小时前
React + TypeScript 企业级开发实战:从零搭建到组件架构演进
前端·react.js·全栈
Coffeeee1 小时前
AGP9.0的主要变更项,给Gradle来一次大变样
android·前端·gradle
用户8181870627461 小时前
第2章 JVM 异常处理机制源码级剖析
后端
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 7 篇:副作用与生命周期 —— watch/watchEffect 如何翻译为 useEffect
前端·vue.js·react.js
数聚天成DeepSData1 小时前
外贸海关进出口数据去哪免费下载?从统计到明细的查找指南
linux·服务器·开发语言·前端·网络·人工智能·自然语言处理
Alan_751 小时前
文件上传接口设计-从单文件到分片上传
后端