// Package ogwebhook 提供 OG 通知接收边界:验签、事件分流、事务提交后回签。 // 用 NewHandler 将它接入你们的 HTTP 服务;数据库和账务由 commit 实现。 package ogwebhook import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "math/big" "net/http" "regexp" "strconv" "time" "unicode/utf8" "walletprotocol/quota" ) const maxBody = 1 << 20 // 本示例的接收端上限:1 MiB。 // Event 保留原始 JSON 字段;金额、链 ID、订单号等不能转为浮点数。 // Family 是 deposit 或 withdrawal;提现所属链和玩家从本地原订单获取。 type Event struct { Family string Type string ID string Fields map[string]json.RawMessage } // String 只读取 JSON 字符串,数字/null/缺失字段均返回 false。 func (e Event) String(name string) (string, bool) { var value string err := json.Unmarshal(e.Fields[name], &value) return value, err == nil && value != "" } // NewHandler 必须传入独立的通知凭据和真实的事务处理函数,不能使用空实现。 // commit 的职责(详见配套接入文档): // - 按当前 OpenAPI 验证完整字段、玩家/链/合约及本地原订单金额。 // - v2 rejected 独立核对可信 environment/accessNamespace、原请求全部身份与 eventId, // 三渠道共用事务;仅无受理/付款证据的原 held 可转 released 一次。保存原 bytes/摘要/决定, // 同 ID 异文、不同决定、受理或付款冲突须持久隔离,不覆盖原证据;不能放进通用 RELEASE。 // - 同一事务持久化通知、按 eventId 去重、按 txRef/withdrawOrderId 幂等改账。 // - 进度不改余额;充值确认按 txRef 入账;提现成功确认预扣,失败/退款释放预扣。 // - 拒绝旧进度覆盖终态;异常和终态冲突持久化核查;失效充值恢复先查询复核。 // - 事务 COMMIT 成功后才返回 nil;合法重复也须核对并确认已持久化,再返回 nil。 // - 无法自动结账的异常可持久化到待核查表,保持余额不变,提交后返回 nil。 // 此时 ACK 仅代表可靠接收,不代表充值已入账或提现已结账。 // // 不要启动 goroutine 后立即返回 nil,不要只写内存;失败返回 error,接收端不会回签。 // 建议服务端设置超时,并保证事务和响应在发送方的 5 秒超时内完成。 func NewHandler(accessKey, secret string, commit func(context.Context, Event, []byte) error) (http.Handler, error) { if accessKey == "" || secret == "" || commit == nil { return nil, errors.New("通知 access key、secret 和事务 commit 均不能为空") } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "POST required", http.StatusMethodNotAllowed) return } key, keyOK := singleHeader(r, "X-Access-Key") timestamp, timeOK := singleHeader(r, "X-Timestamp") signature, sigOK := singleHeader(r, "X-Signature") unix, err := strconv.ParseInt(timestamp, 10, 64) now := time.Now().Unix() if !keyOK || !timeOK || !sigOK || !hmac.Equal([]byte(key), []byte(accessKey)) || !digits.MatchString(timestamp) || err != nil || unix < now-300 || unix > now+300 || !lowerSignature.MatchString(signature) { http.Error(w, "invalid authentication", http.StatusUnauthorized) return } raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody)) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) } else { http.Error(w, "cannot read body", http.StatusBadRequest) } return } // 使用 HTTP 请求的原始路径、查询串与正文;反向代理须保留外部路径前缀。 // 登记的通知 URL 目前不允许 query,因此实际投递 RawQuery 通常为空。 // 仍保留 QUERY 段及分隔符。不要用 URL.Path、解码后的 query 或重序列化 JSON。 mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(r.Method + "|" + r.URL.EscapedPath() + "|" + timestamp + "|" + r.URL.RawQuery + "|")) mac.Write(raw) expected := mac.Sum(nil) provided, _ := hex.DecodeString(signature) // 上面的格式检查已限定 64 个小写 hex。 if !hmac.Equal(expected, provided) { http.Error(w, "invalid authentication", http.StatusUnauthorized) return } event, err := parseEvent(raw) if err != nil { http.Error(w, "invalid event", http.StatusBadRequest) return } // 重复通知也执行 commit,由数据库唯一约束和事务完成幂等处理。 if err := commit(r.Context(), event, raw); err != nil { http.Error(w, "transaction not committed", http.StatusServiceUnavailable) return } // 签原通知请求,绝不签下面的响应正文;恰好返回一份签名。 w.Header().Set("X-Signature", hex.EncodeToString(expected)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, "{}") }), nil } func singleHeader(r *http.Request, name string) (string, bool) { values := r.Header.Values(name) if len(values) != 1 || values[0] == "" { return "", false } return values[0], true } var ( digits = regexp.MustCompile(`^[0-9]+$`) positiveAmount = regexp.MustCompile(`^[1-9][0-9]*$`) orderID = regexp.MustCompile(`^[0-9]{30}$`) lowerSignature = regexp.MustCompile(`^[0-9a-f]{64}$`) ) type eventRule struct { family, status string progress bool } // 充值和提现是独立流程,两类均由同一接收地址接收 Webhook。 var eventRules = map[string]eventRule{ "deposit_detected": {"deposit", "deposit_detected", true}, "deposit_confirming": {"deposit", "deposit_confirming", true}, "deposit_confirmed": {"deposit", "confirmed", false}, "withdrawal_accepted": {"withdrawal", "accepted", true}, "withdrawal_sending": {"withdrawal", "sending", true}, "withdrawal_confirming": {"withdrawal", "confirming", true}, "withdrawal_held": {"withdrawal", "held", true}, "withdrawal_manual_pending": {"withdrawal", "manual_pending", true}, "withdrawal_auto_sending": {"withdrawal", "auto_sending", true}, "withdrawal_voucher_checking": {"withdrawal", "voucher_checking", true}, "withdrawal_voucher_failed": {"withdrawal", "voucher_failed", true}, "withdrawal_succeeded": {"withdrawal", "succeeded", false}, "withdrawal_failed": {"withdrawal", "failed", false}, "withdrawal_refunded": {"withdrawal", "refunded", false}, } // 这里校验分流所需最小字段,不替代完整的 OpenAPI 校验与业务核对。 func parseEvent(raw []byte) (Event, error) { var event Event bad := errors.New("invalid event") // Unmarshal 保留原有的单对象、语法和 10000 层深度边界,再拒绝各层重复键。 if !utf8.Valid(raw) || json.Unmarshal(raw, &event.Fields) != nil || event.Fields == nil || !uniqueJSONKeys(raw) { return event, bad } event.ID, _ = event.String("eventId") event.Type, _ = event.String("type") statusValue, _ := event.String("status") v2 := false for _, key := range []string{"schemaVersion", "decisionId", "quota", "final", "paymentInitiated", "retryMode", "accepted", "broadcast"} { if _, present := event.Fields[key]; present { v2 = true } } if v2 || event.Type == "withdrawal_rejected" || statusValue == "rejected" { // 共享 Go codec 是完整 v2 wire 的唯一权威;原单/scope 仍须 commit 从可信记录绑定。 rejection, err := quota.DecodeRejection(raw) if err != nil { return event, bad } event.Family, event.Type, event.ID = "withdrawal", rejection.Type, rejection.EventID return event, nil } status, _ := event.String("status") amount, _ := event.String("amount") rule, known := eventRules[event.Type] if !known || event.ID == "" || status != rule.status || len(amount) > 78 || !positiveAmount.MatchString(amount) { return event, bad } n, validAmount := new(big.Int).SetString(amount, 10) if !validAmount || n.BitLen() > 256 { return event, bad } event.Family = rule.family phase, _ := event.String("phase") _, hasPhase := event.Fields["phase"] if (rule.progress && phase != "progress") || (!rule.progress && hasPhase) { return event, bad // 终态必须省略 phase;null 也不接受。 } if event.Family == "deposit" { for _, field := range []string{"playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef"} { if _, ok := event.String(field); !ok { return event, bad } } if rule.progress && (!integerAtLeast(event.Fields["confirmations"], 0) || !integerAtLeast(event.Fields["confirmationsRequired"], 1)) { return event, bad } } else { id, _ := event.String("withdrawOrderId") if !orderID.MatchString(id) { return event, bad } if event.Type == "withdrawal_succeeded" { if _, ok := event.String("txHash"); !ok { stage, _ := event.String("verificationStage") var details map[string]json.RawMessage if stage != "payments_succeeded" || json.Unmarshal(event.Fields["paymentDetails"], &details) != nil || details == nil { return event, bad } // Complete arithmetic and frozen-order binding belong to durable commit. } } } return event, nil } // 只检查键的唯一性,不改写参与验签和持久化的原始正文。 func uniqueJSONKeys(raw []byte) bool { decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() var value func() error value = func() error { token, err := decoder.Token() if err != nil { return err } switch token { case json.Delim('{'): seen := map[string]bool{} for decoder.More() { key, err := decoder.Token() if err != nil { return err } name, ok := key.(string) if !ok || seen[name] { return errors.New("重复 JSON 键") } seen[name] = true if err := value(); err != nil { return err } } _, err = decoder.Token() case json.Delim('['): for decoder.More() { if err := value(); err != nil { return err } } _, err = decoder.Token() } return err } if value() != nil { return false } _, err := decoder.Token() return err == io.EOF } func integerAtLeast(raw json.RawMessage, minimum int64) bool { var value int64 return string(raw) != "null" && json.Unmarshal(raw, &value) == nil && value >= minimum }