import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.math.BigInteger; import java.nio.ByteBuffer; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; import java.util.HexFormat; import java.util.List; import java.util.Map; /** * 目的:把 ETH / TRON 的充值、提现通知安全交给 OG 的业务事务,提交完成才回签。 * Java 17 + Jackson;框架无关。充值与提现是独立业务,提现不要求先发生充值。 * 旧14事件验证最小包络,v2 拒绝验证完整 wire;业务绑定和账务由必填 Commit 实现。 */ public final class OgWebhook { public record Header(String name, String value) {} public record HttpResult(int status, Map headers, byte[] body) {} @FunctionalInterface public interface Commit { /** * 返回前必须完成持久事务;抛错会得到 503 且不回签。 * 事务须绑定 OG/环境,验证玩家、链、资产、原订单金额及各事件必需字段; * 按 eventId 保存原文摘要、检测同 ID 异文,并按 txRef / withdrawOrderId 幂等改账。 * 充值进度不入账;confirmed 入账一次,失效/冲正后恢复须先查询复核。 * 提现进度(含 voucher_failed)保留预扣;succeeded 确认扣款一次; * failed / refunded 释放原预扣一次;乱序进度不得覆盖终态,冲突终态须核查。 * 重复已提交事件可直接返回;异常事实也可持久化隔离、安排核查后返回,不改余额。 * v2 rejected 必须另绑定可信 scope、原请求全部身份与 eventId;三渠道共用事务, * 仅无 accepted/付款证据的原 held 可转 released 一次。原 bytes/摘要/决定同次保存, * 同 ID 异文/不同决定/付款冲突持久隔离,不覆盖首次证据,不纳入通用 RELEASE。 * 禁止用空实现、内存集合或日志替代持久事务后就返回成功。 */ void save(JsonNode event, byte[] originalBody) throws Exception; } private record Rule(String status, boolean progress) {} private static final Map RULES = Map.ofEntries( Map.entry("deposit_detected", new Rule("deposit_detected", true)), Map.entry("deposit_confirming", new Rule("deposit_confirming", true)), Map.entry("deposit_confirmed", new Rule("confirmed", false)), Map.entry("withdrawal_accepted", new Rule("accepted", true)), Map.entry("withdrawal_sending", new Rule("sending", true)), Map.entry("withdrawal_confirming", new Rule("confirming", true)), Map.entry("withdrawal_held", new Rule("held", true)), Map.entry("withdrawal_manual_pending", new Rule("manual_pending", true)), Map.entry("withdrawal_auto_sending", new Rule("auto_sending", true)), Map.entry("withdrawal_voucher_checking", new Rule("voucher_checking", true)), Map.entry("withdrawal_voucher_failed", new Rule("voucher_failed", true)), Map.entry("withdrawal_succeeded", new Rule("succeeded", false)), Map.entry("withdrawal_failed", new Rule("failed", false)), Map.entry("withdrawal_refunded", new Rule("refunded", false)) ); private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder() .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build()) .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private final byte[] accessKey, secret; private final Clock clock; /** 传入独立 Webhook 凭据;不能传 OG 调用平台 API 的密钥。配置不完整时拒绝启动。 */ public OgWebhook(String webhookAccessKey, String webhookSecret, Clock clock) { if (webhookAccessKey == null || webhookAccessKey.isBlank() || webhookSecret == null || webhookSecret.isEmpty() || webhookAccessKey.startsWith("REPLACE_") || webhookSecret.startsWith("REPLACE_") || clock == null) throw new IllegalArgumentException("Webhook configuration required"); this.accessKey = bytes(webhookAccessKey); this.secret = bytes(webhookSecret); this.clock = clock; } /** * rawTarget = 原始转义路径 + 可选 '?' + 原始 query;不能 URL 解码、排序或重新编码。 * Spring/Servlet 适配时用 getRequestURI() 和 getQueryString() 保留原样, * 不要使用 getParameterMap();代理也必须保留签名时的外部路径与 query。 * headers 必须保留每个头的全部值(getHeaders),不能用单值 Map 隐藏重复头。 * rawBody 从输入流按最多 1 MiB + 1 字节读取;超限立即拒绝,不能先无限读取。 * 先传原字节进来验签,不能让框架解析 JSON 后再序列化。 */ public HttpResult receive(String method, String rawTarget, List
headers, byte[] rawBody, Commit commit) { if (!"POST".equals(method)) return reject(405); if (rawBody == null || headers == null) return reject(400); if (rawBody.length > 1_048_576) return reject(413); if (commit == null) return reject(503); if (rawTarget == null || !rawTarget.startsWith("/") || !rawTarget.matches("[\\x21-\\x7e]+") || rawTarget.contains("#")) return reject(400); String key, timestamp, supplied; try { key = unique(headers, "X-Access-Key"); timestamp = unique(headers, "X-Timestamp"); supplied = unique(headers, "X-Signature"); } catch (IllegalArgumentException e) { return reject(401); } if (!MessageDigest.isEqual(accessKey, bytes(key)) || !timestamp.matches("[0-9]{1,20}") || !supplied.matches("[0-9a-f]{64}")) return reject(401); try { long ts = Long.parseLong(timestamp), now = clock.instant().getEpochSecond(); if (ts < now - 300 || ts > now + 300) return reject(401); } catch (NumberFormatException e) { return reject(401); } int mark = rawTarget.indexOf('?'); String path = mark < 0 ? rawTarget : rawTarget.substring(0, mark); String query = mark < 0 ? "" : rawTarget.substring(mark + 1); byte[] body = rawBody.clone(); byte[] expected; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret, "HmacSHA256")); mac.update(bytes(method + "|" + path + "|" + timestamp + "|" + query + "|")); expected = mac.doFinal(body); } catch (java.security.GeneralSecurityException e) { return reject(503); } if (!MessageDigest.isEqual(expected, HexFormat.of().parseHex(supplied))) return reject(401); JsonNode event; try { // 协议正文为 UTF-8;先严格解码,避免 Jackson 自动识别并接受 UTF-16 等编码。 String utf8 = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT) .decode(ByteBuffer.wrap(body)).toString(); event = JSON.readTree(utf8); if (event != null && event.isObject() && (event.has("schemaVersion") || event.has("decisionId") || event.has("quota") || event.has("final") || event.has("paymentInitiated") || event.has("retryMode") || event.has("accepted") || event.has("broadcast") || "withdrawal_rejected".equals(event.path("type").textValue()) || "rejected".equals(event.path("status").textValue()))) { event = QuotaRejection.decode(body); } else { if (event == null || !event.isObject() || !text(event, "eventId") || !text(event, "type") || !text(event, "status") || !text(event, "amount") || !event.get("amount").textValue().matches("[1-9][0-9]{0,77}") || new BigInteger(event.get("amount").textValue()).bitLength() > 256) return reject(400); String type = event.get("type").textValue(); Rule rule = RULES.get(type); if (rule == null || !rule.status().equals(event.get("status").textValue())) return reject(400); // 终态 phase 必须整个字段缺失;null 或空字符串也不接受。 if (rule.progress() ? !"progress".equals(event.path("phase").textValue()) : event.has("phase")) return reject(400); if (type.startsWith("deposit_")) { for (String field : List.of("playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef")) { if (!text(event, field)) return reject(400); } if (rule.progress() && (!event.path("confirmations").isIntegralNumber() || event.path("confirmations").bigIntegerValue().signum() < 0 || !event.path("confirmationsRequired").isIntegralNumber() || event.path("confirmationsRequired").bigIntegerValue().signum() <= 0)) return reject(400); } else if (!text(event, "withdrawOrderId") || !event.get("withdrawOrderId").textValue().matches("[0-9]{30}")) return reject(400); if (type.equals("withdrawal_succeeded") && !text(event, "txHash") && !("payments_succeeded".equals(event.path("verificationStage").textValue()) && event.path("paymentDetails").isObject())) return reject(400); } } catch (java.io.IOException | IllegalArgumentException e) { return reject(400); } try { commit.save(event, body); } catch (Exception e) { return reject(503); } // 可记录固定错误分类;不要记录密钥或原始敏感正文。 // 回签覆盖「本次原请求」,不签 '{}';只发送一个 X-Signature,用 setHeader 而非 addHeader。 return new HttpResult(200, Map.of("Content-Type", "application/json", "X-Signature", HexFormat.of().formatHex(expected)), bytes("{}")); } private static String unique(List
headers, String name) { String value = null; for (Header h : headers) { if (h == null || h.name() == null) throw new IllegalArgumentException("Invalid header"); if (name.equalsIgnoreCase(h.name())) { if (value != null || h.value() == null || h.value().isEmpty()) throw new IllegalArgumentException("Missing or repeated signature header"); value = h.value(); } } if (value == null) throw new IllegalArgumentException("Missing signature header"); return value; } private static boolean text(JsonNode event, String name) { return event.has(name) && event.get(name).isTextual() && !event.get(name).textValue().isEmpty(); } private static byte[] bytes(String value) { return value.getBytes(StandardCharsets.UTF_8); } private static HttpResult reject(int status) { return new HttpResult(status, Map.of("Content-Type", "application/json"), bytes("{}")); } }