Java
兼容性
JDK 11+(原生 HttpClient)。无第三方依赖。
客户端实现
java
package com.example.coinepay;
import java.net.URI;
import java.net.http.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.UUID;
public class coinepayClient {
private static final String APP_ID = "cp_a1b2c3d4...";
private static final String SECRET = "f1e2d3c4...";
private static final String BASE = "https://api.coinepay.net";
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public static String call(String path, String body, String idempotencyKey) throws Exception {
if (body == null) body = "{}";
String ts = String.valueOf(System.currentTimeMillis() / 1000);
byte[] nonceBytes = new byte[16];
new SecureRandom().nextBytes(nonceBytes);
String nonce = bytesToHex(nonceBytes);
String bodyHash = sha256Hex(body.getBytes("UTF-8"));
String signInput = "POST\n" + path + "\n\n" + ts + "\n" + nonce + "\n" + bodyHash;
String sig = hmacHex(SECRET, signInput);
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.timeout(Duration.ofSeconds(30))
.header("X-App-Id", APP_ID)
.header("X-Timestamp", ts)
.header("X-Nonce", nonce)
.header("X-Signature", sig)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);
HttpResponse<String> resp = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return resp.body();
}
/** Webhook 验签 */
public static boolean verifyWebhook(String sigHeader, String tsHeader,
byte[] rawBody, String webhookSecret) throws Exception {
if (sigHeader == null || !sigHeader.startsWith("v1,")) return false;
String sig = sigHeader.substring(3);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes("UTF-8"), "HmacSHA256"));
mac.update((tsHeader + ".").getBytes("UTF-8"));
mac.update(rawBody);
String expected = bytesToHex(mac.doFinal());
return constantTimeEquals(sig, expected);
}
private static String hmacHex(String key, String data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"));
return bytesToHex(mac.doFinal(data.getBytes("UTF-8")));
}
private static String sha256Hex(byte[] b) throws Exception {
return bytesToHex(MessageDigest.getInstance("SHA-256").digest(b));
}
private static String bytesToHex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
private static boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) return false;
int diff = 0;
for (int i = 0; i < a.length(); i++) diff |= a.charAt(i) ^ b.charAt(i);
return diff == 0;
}
}使用示例
java
public class Demo {
public static void main(String[] args) throws Exception {
// 1. 列卡头
String resp = coinepayClient.call(
"/api/v1/openapi/card_headers/list",
"{\"page\":1,\"page_size\":20}",
null);
System.out.println(resp);
// 2. 申请虚拟卡(带幂等键)
String idempotencyKey = UUID.randomUUID().toString();
String body = """
{"header_id":"hdr_5","package_id":"pkg_12",
"first_name":"John","last_name":"Doe"}
""";
String applyResp = coinepayClient.call(
"/api/v1/openapi/card/apply", body, idempotencyKey);
System.out.println(applyResp);
}
}Webhook 接收(Spring Boot)
java
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import javax.servlet.http.HttpServletRequest;
@RestController
public class WebhookController {
private static final String WEBHOOK_SECRET = System.getenv("COINEPAY_WEBHOOK_SECRET");
@PostMapping(value = "/webhook", consumes = "application/json")
public ResponseEntity<String> handleWebhook(
@RequestBody byte[] rawBody,
@RequestHeader(value = "Webhook-Signature", required = false) String sig,
@RequestHeader(value = "Webhook-Timestamp", required = false) String ts,
@RequestHeader(value = "Webhook-Id", required = false) String eventId,
@RequestHeader(value = "Webhook-Type", required = false) String eventType
) throws Exception {
if (!coinepayClient.verifyWebhook(sig, ts, rawBody, WEBHOOK_SECRET)) {
return ResponseEntity.status(401).body("invalid signature");
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - Long.parseLong(ts)) > 300) {
return ResponseEntity.status(401).body("timestamp expired");
}
if (alreadyProcessed(eventId)) return ResponseEntity.ok("");
String payload = new String(rawBody, "UTF-8");
enqueue(eventType, payload); // 重活丢队列
markProcessed(eventId);
return ResponseEntity.ok("");
}
}Spring Boot 接收 raw body
默认 Spring 会基于 @RequestBody 反序列化。用 byte[] 类型才能拿到原始字节,避免被 JSON parse + re-serialize。
注意事项
secretSpec用getBytes("UTF-8")—— 不要用平台默认编码- Java 中字符串拼接
\n在 source code 里就是0x0A,无需特殊处理 - 不要用
MessageDigest.isEqual之外的常量时间比较;Java 字符串equals不是常量时间