Skip to content

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。

注意事項

  • secretSpecgetBytes("UTF-8") —— 不要用平臺預設編碼
  • Java 中字串拼接 \n 在 source code 裡就是 0x0A,無需特殊處理
  • 不要用 MessageDigest.isEqual 之外的常量時間比較;Java 字串 equals 不是常量時間

採用 MIT 等價條款釋出