支付流程時序圖
用戶 商戶系統 易寶網關 銀行
| | | |
|---提交訂單-->| | |
| |---構建請求/簽名-->| |
| |<--返回表單/跳轉---| |
|<--支付頁面---| | |
|---輸入卡號/密碼---------------->| |
| | |---扣款請求---->|
| | |<--扣款結果-----|
| |<--異步通知/同步跳轉---------------|
| |---驗簽/更新訂單---| |
|<--支付結果---| | |
核心參數說明 (易寶文檔標準)
| 參數 | 含義 | 示例 |
| p0_Cmd | 業務類型 | Buy |
| p1_MerId | 商戶號 | 10001123456 |
| p2_Order | 訂單號(唯一) | 202401010001 |
| p3_Amt | 金額(元,兩位小數) | 100.00 |
| p4_Cur | 幣種 | CNY |
| p8_Url | 同步跳轉頁面 | https://shop.com/pay/return |
| p9_SAF | 傳輸類型 | 0 (POST) |
| pd_FrpId | 銀行編碼 | ICBC-NET, CCB-NET |
| hmac | MD5/HMAC 簽名 | a1b2c3d4... |
完整 Java 客戶端實現
public class YeePayClient {
private final String merchantId;
private final String secretKey; // 易寶分配的密鑰
private final String gatewayUrl = "https://www.yeepay.com/app-merchant-proxy/node";
private final String notifyUrl; // 異步通知地址
private final String returnUrl; // 同步跳轉地址
public YeePayClient(String merchantId, String secretKey, String notifyUrl, String returnUrl) {
this.merchantId = merchantId;
this.secretKey = secretKey;
this.notifyUrl = notifyUrl;
this.returnUrl = returnUrl;
}
/** 生成支付表單 HTML (前端直接提交) */
public String buildPayForm(PayRequest req) {
Map params = new LinkedHashMap<>(); // 保持順序
params.put("p0_Cmd", "Buy");
params.put("p1_MerId", merchantId);
params.put("p2_Order", req.getOrderNo());
params.put("p3_Amt", String.format("%.2f", req.getAmount()));
params.put("p4_Cur", "CNY");
params.put("p5_Pid", req.getProductName());
params.put("p6_Pcat", req.getProductCategory());
params.put("p7_Pdesc", req.getProductDesc());
params.put("p8_Url", returnUrl);
params.put("p9_SAF", "0");
params.put("pa_MP", req.getAttach()); // 商戶擴展信息
params.put("pd_FrpId", req.getBankCode());
params.put("pr_NeedResponse", "1");
// 計算簽名
String hmac = sign(params);
params.put("hmac", hmac);
// 生成自動提交表單
StringBuilder html = new StringBuilder();
html.append("");
return html.toString();
}
/** 驗證異步通知/同步返回 */
public boolean verifyCallback(Map params) {
String receivedHmac = params.remove("hmac"); // 移除後驗證
String calculatedHmac = sign(params);
return calculatedHmac.equals(receivedHmac);
}
/** 簽名算法:按 key 排序拼接 + 密鑰 -> MD5 */
private String sign(Map params) {
StringBuilder sb = new StringBuilder();
params.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(e -> sb.append(e.getValue()));
sb.append(secretKey);
return DigestUtils.md5Hex(sb.toString()).toUpperCase();
}
private String escapeHtml(String s) {
return s.replace("&", "&").replace("<", "<").replace(">", ">")
.replace("\"", """).replace("'", "'");
}
// ==================== 內部類 ====================
@Data @Builder @AllArgsConstructor @NoArgsConstructor
public static class PayRequest {
private String orderNo;
private BigDecimal amount;
private String productName;
private String productCategory;
private String productDesc;
private String bankCode; // 為空則易寶選銀行頁
private String attach; // 回調時原樣返回
}
}
Controller 層調用示例
@RestController
@RequestMapping("/pay/yeepay")
public class YeePayController {
@Autowired YeePayClient yeePayClient;
@Autowired OrderService orderService;
@PostMapping("/create")
public Result createPayForm(@RequestBody PayCreateDTO dto) {
Order order = orderService.createOrder(dto); // 生成訂單,狀態 UNPAID
YeePayClient.PayRequest req = YeePayClient.PayRequest.builder()
.orderNo(order.getOrderNo())
.amount(order.getAmount())
.productName(order.getProductName())
.bankCode(dto.getBankCode())
.attach(order.getOrderNo()) // 回調攜帶訂單號
.build();
String formHtml = yeePayClient.buildPayForm(req);
return Result.success(formHtml); // 前端 document.write(formHtml) 或 iframe 載入
}
@PostMapping("/notify") // 異步通知
public String notify(HttpServletRequest request) {
Map params = new HashMap<>();
request.getParameterMap().forEach((k, v) -> params.put(k, v[0]));
if (!yeePayClient.verifyCallback(params)) {
log.warn("易寶通知驗簽失敗: {}", params);
return "FAIL";
}
String orderNo = params.get("r6_Order");
String amount = params.get("r3_Amt");
String resultCode = params.get("r1_Code"); // "1" 成功
if ("1".equals(resultCode)) {
// 幂等處理:只更新 UNPAID -> PAID
boolean updated = orderService.updateStatusIfUnpaid(orderNo, OrderStatus.PAID,
new PaymentRecord(orderNo, amount, "YEEPAY", params.get("r2_TrxId")));
if (updated) {
notificationService.sendOrderConfirmation(orderNo); // 異步發郵件/短信
}
}
return "SUCCESS"; // 必須返回 SUCCESS,否則易寶會重試
}
@GetMapping("/return") // 同步跳轉
public String returnPage(HttpServletRequest request, Model model) {
Map params = new HashMap<>();
request.getParameterMap().forEach((k, v) -> params.put(k, v[0]));
boolean verified = yeePayClient.verifyCallback(params);
model.addAttribute("verified", verified);
model.addAttribute("orderNo", params.get("r6_Order"));
return "pay/result"; // 支付結果頁
}
}
生產環境關鍵加固
- 密鑰管理:密鑰存放於配置中心/密保箱,絕不硬編碼、提交代碼倉庫
- 幂等性:異步通知可能重複,基於
訂單號 + 狀態機 保證冪等
- 金額校驗:回調時核對
r3_Amt 與訂單金額一致,防範竄改
- 異步通知可靠性:處理邏輯拋異常不返回 SUCCESS,易寶按策略重試(間隔遞增)
- 日誌審計:完整記錄請求/回調報文(脫敏),便於對賬排查
- 測試環境隔離:使用易寶測試商戶號,真實銀行卡小額驗證
常見問題排查表
| 現象 | 可能原因 | 解決 |
| 驗簽失敗 | 參數順序錯、編碼不一致、密鑰錯 | 嚴格按文檔排序、UTF-8、核對密鑰 |
| 異步通知收不到 | notifyUrl 不可達、防火牆、返回非 SUCCESS | 公網可訪問、HTTPS、邏輯不拋異常 |
| 金額不符 | 分/元單位混淆、格式化精度丟失 | 統一用 BigDecimal、格式化 %.2f |
| 重複扣款 | 用戶重複提交、無防重機制 | 前端按鈕防抖、後端訂單號唯一索引 |
總結
易寶支付對接核心在於:請求參數嚴格按文檔排序簽名、異步通知驗簽+幂等處理、金額精度用 BigDecimal、密鑰安全管理。封裝通用 YeePayClient,Controller 薄調用,業務邏輯集中在 Service,是穩健接入第三方支付的標準架構。