Contexto
Tras varios retrasos por la revisión de la cuenta comercial y la certificación del miniprograma, finalmente tenemos lista la integración de WeChat Pay en modalidad Native. Este artículo forma parte de una serie que cubre desde cero la implementación de pagos con WeChat Pay, centrándose ahora en el manejo del callback de notificación desde el servidor de WeChat hacia nuestro sistema.
Tecnologías utilizadas:
- Backend: Spring Boot 3.1.x, MySQL 8.0, MyBatis Plus
- Frontend (PC): Vue 3, Vite, Element Plus
- Mini programa: Uniapp, Uview
Callback del modo Native
¿Cuándo se recibe?
Una vez que el usuario completa el pago, WeChat envía una notificación POST a la URL configurada en el parámetro notify_url de la soliictud de creación de pedido. Es obligatorio que dicha URL sea HTTPS y accesible desde el exterior, sin parámetros adicionales. Ejemplo: https://tu-dominio.com/api/wx-pay/native/notify.
Reglas de notificación
WeChat intentará reenviar la notificación en caso de fallo o timeout siguiendo este patrón: 15s, 15s, 30s, 3m, 10m, 20m, 30m, 30m, 30m, 60m, 3h, 3h, 3h, 6h, 6h (total 24h 4m).
Entorno de desarrollo local
Para pruebas locales necesitamos una dirección HTTPS. Podemos usar herramientas de túneles como ngrok (Sunny-Ngrok) o 花生壳 (Oray). En mi caso uso Oray con un coste de 6¥ y dos dominios SSL. Configuramos la redirección hacia 127.0.0.1:9080 y actualizamos la propiedad wxpay.notify-domain en wxpay.properties con la URL del túnel.
Procesamiento del callback
Validación de firma
Para garantizar que la notificación proviene de WeChat, debemos verificar la firma incluida en los headers HTTP: Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature y Wechatpay-Serial. Construimos el string a firmar de la siguiente forma:
timestamp + "\n" + nonce + "\n" + body + "\n"
Comprobamos que el timestamp no tenga más de 5 minutos de antigüedad. Si la verificación falla, devolvemos un error 500.
Clase personalizada para validación
Creamos WechatPay2ValidatorForRequest que recibe el verificador, el requestId y el body de la notificación.
package com.ejemplo.wechat;
import com.wechat.pay.contrib.apache.httpclient.auth.Verifier;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import static com.wechat.pay.contrib.apache.httpclient.constant.WechatPayHttpHeaders.*;
public class WechatPay2ValidatorForRequest {
protected static final Logger log = LoggerFactory.getLogger(WechatPay2ValidatorForRequest.class);
protected static final long RESPONSE_EXPIRED_MINUTES = 5;
protected final Verifier verifier;
protected final String body;
protected final String requestId;
public WechatPay2ValidatorForRequest(Verifier verifier, String requestId, String body) {
this.verifier = verifier;
this.requestId = requestId;
this.body = body;
}
public final boolean validate(HttpServletRequest request) throws IOException {
try {
validateParameters(request);
String message = buildMessage(request);
String serial = request.getHeader(WECHAT_PAY_SERIAL);
String signature = request.getHeader(WECHAT_PAY_SIGNATURE);
if (!verifier.verify(serial, message.getBytes(StandardCharsets.UTF_8), signature)) {
throw verifyFail("serial=[%s] message=[%s] sign=[%s], request-id=[%s]",
serial, message, signature, request.getHeader(REQUEST_ID));
}
} catch (IllegalArgumentException e) {
log.warn(e.getMessage());
return false;
}
return true;
}
protected final void validateParameters(HttpServletRequest request) {
String[] headers = {WECHAT_PAY_SERIAL, WECHAT_PAY_SIGNATURE, WECHAT_PAY_NONCE, WECHAT_PAY_TIMESTAMP};
String header = null;
for (String h : headers) {
header = request.getHeader(h);
if (header == null) {
throw parameterError("empty [%s], request-id=[%s]", h, requestId);
}
}
String timestampStr = header;
try {
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestampStr));
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= RESPONSE_EXPIRED_MINUTES) {
throw parameterError("timestamp=[%s] expires, request-id=[%s]", timestampStr, requestId);
}
} catch (DateTimeException | NumberFormatException e) {
throw parameterError("invalid timestamp=[%s], request-id=[%s]", timestampStr, requestId);
}
}
protected final String buildMessage(HttpServletRequest request) throws IOException {
String timestamp = request.getHeader(WECHAT_PAY_TIMESTAMP);
String nonce = request.getHeader(WECHAT_PAY_NONCE);
return timestamp + "\n" + nonce + "\n" + body + "\n";
}
protected static IllegalArgumentException parameterError(String message, Object... args) {
return new IllegalArgumentException("parameter error: " + String.format(message, args));
}
protected static IllegalArgumentException verifyFail(String message, Object... args) {
return new IllegalArgumentException("signature verify fail: " + String.format(message, args));
}
}
Desencriptación del payload
El cuerpo de la notificación contiene un objeto resource con los campos ciphertext, nonce y associated_data. Usamos AesUtil del SDK de WeChat con la clave APIv3 para desencriptra.
import com.wechat.pay.contrib.apache.httpclient.util.AesUtil;
import java.nio.charset.StandardCharsets;
private String decryptResource(Map<String, Object> bodyMap) throws GeneralSecurityException {
Map<String, String> resource = (Map) bodyMap.get("resource");
String ciphertext = resource.get("ciphertext");
String nonce = resource.get("nonce");
String associatedData = resource.get("associated_data");
AesUtil aesUtil = new AesUtil(apiV3Key.getBytes(StandardCharsets.UTF_8));
return aesUtil.decryptToString(
associatedData.getBytes(StandardCharsets.UTF_8),
nonce.getBytes(StandardCharsets.UTF_8),
ciphertext
);
}
Lectura del cuerpo de la petición
Necesitamos leer el body completo respetando los saltos de línea. Creamos un utilitario.
package com.ejemplo.wechat;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class HttpUtils {
public static String readBody(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
try (BufferedReader br = request.getReader()) {
String line;
while ((line = br.readLine()) != null) {
if (sb.length() > 0) sb.append("\n");
sb.append(line);
}
} catch (IOException e) {
throw new RuntimeException("Error reading request body", e);
}
return sb.toString();
}
public static Map<String, Object> parseBodyToMap(HttpServletRequest request) {
String body = readBody(request);
return JSONUtil.toBean(body, HashMap.class);
}
}
Endpoint del callback
Implementamos el método POST para recibir la notificación, verificar firma y procesar el pedido.
@PostMapping("/notify")
public Map<String, String> nativeNotify(HttpServletRequest request, HttpServletResponse response) {
try {
String body = HttpUtils.readBody(request);
Map<String, Object> bodyMap = JSONUtil.toBean(body, HashMap.class);
String requestId = (String) bodyMap.get("id");
WechatPay2ValidatorForRequest validator = new WechatPay2ValidatorForRequest(verifier, requestId, body);
if (!validator.validate(request)) {
log.error("Firma inválida en la notificación");
response.setStatus(500);
return Map.of("code", "FAIL");
}
// Procesar el pedido
wxPayService.processOrder(bodyMap);
response.setStatus(200);
return Map.of("code", "SUCCESS");
} catch (Exception e) {
log.error("Error al procesar callback de WeChat", e);
response.setStatus(500);
return Map.of("code", "FAIL");
}
}
Lógica de negocio: actualizar pedido y registrar pago
Usamos un ReentrantLock para evitar concurrencia. Primero desencriptamos, luego obtenemos el número de pedido y actualizamos su estado a SUCCESS. Después registramos el pago en la tabla payment_info.
private final ReentrantLock lock = new ReentrantLock();
@Transactional(rollbackFor = Exception.class)
public void processOrder(Map<String, Object> bodyMap) {
try {
String plainText = decryptResource(bodyMap);
Map<String, Object> data = JSONUtil.toBean(plainText, Map.class);
String orderNo = (String) data.get("out_trade_no");
if (lock.tryLock()) {
try {
OrderInfo order = orderInfoService.lambdaQuery()
.eq(OrderInfo::getOrderNo, orderNo)
.one();
if (order != null && !"NOTPAY".equals(order.getOrderStatus())) {
log.info("Notificación duplicada, pedido ya pagado");
return;
}
orderInfoService.lambdaUpdate()
.eq(OrderInfo::getOrderNo, orderNo)
.set(OrderInfo::getOrderStatus, "SUCCESS")
.update();
paymentInfoService.createPaymentInfo(plainText);
} finally {
lock.unlock();
}
}
} catch (Exception e) {
log.error("Error en processOrder", e);
throw new RuntimeException(e);
}
}
Registro del pago
@Service
public class PaymentInfoService extends ServiceImpl<PaymentInfoMapper, PaymentInfo> {
@Transactional
public void createPaymentInfo(String plainText) {
Map<String, Object> map = JSONUtil.toBean(plainText, Map.class);
String orderNo = (String) map.get("out_trade_no");
String transactionId = (String) map.get("transaction_id");
String tradeType = (String) map.get("trade_type");
String tradeState = (String) map.get("trade_state");
Map<String, Object> amount = (Map<String, Object>) map.get("amount");
Integer payerTotal = MapUtil.getInt(amount, "payer_total");
PaymentInfo info = new PaymentInfo();
info.setOrderNo(orderNo);
info.setPaymentType("WXPAY");
info.setTransactionId(transactionId);
info.setTradeType(tradeType);
info.setTradeState(tradeState);
info.setPayerTotal(payerTotal);
info.setContent(plainText);
baseMapper.insert(info);
}
}
Prueba del flujo completo
- Inicia el túnel HTTPS apuntando al puerto de tu aplicación.
- Arranca el proyecto Spring Boot.
- Llama al endpoint de creación de pedido:
POST /api/wx-pay/native/native/{productId}utilizando un ID de producto existente. - Recibe el código QR de la respuesta.
- Genera un QR escaneable (por ejemplo con cli.im) y escanéalo con WeChat.
- Espera el callback y verifica en la consola que se procesa correctamente y que el pedido cambia a estado SUCCESS.