Files
x_gift_bot/internal/checkout/payment_cooldown.go
T
mizorewww 4cb9119dbf feat: cool declined exits before cards and expose payment status
Route X account and eligibility checks directly while regional pricing
and checkout creation share the configured proxy exit, keeping quoted
currency and amounts consistent for the customer.

A decline now cools the card×node pair and the exit's shared IP for 30
minutes first; only a card declined through two distinct nodes enters a
whole-card cooldown, and do_not_try_again blocks just that card. Card
and rotation status stay readable with no cards configured and no longer
report expired cooldowns as blocks. Links-only recovery works without a
payment card, and the recovery status endpoint reports the
payment-protection pause.
2026-10-04 14:27:26 +08:00

127 lines
3.0 KiB
Go

package checkout
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net"
"strings"
"time"
"xgift/internal/vault"
)
const (
paymentNodeCooldown = 6 * time.Hour
paymentNodeDeclineCooldown = 30 * time.Minute
)
var ErrPaymentNodesCooling = errors.New("all payment nodes are cooling down; no alternate route available")
type nodeCooldown struct {
Until int64 `json:"until"`
Reason string `json:"reason"`
}
func routeGroup(node json.RawMessage) string {
var m struct{ Type, Server string }
json.Unmarshal(node, &m)
if m.Type == "direct" {
return "direct"
}
return "server:" + strings.ToLower(m.Server)
}
func cooldownKey(group string) string {
d := sha256.Sum256([]byte(group))
return "payment-node-cooldown:" + hex.EncodeToString(d[:])
}
func nodeCooldownKeys(v *vault.Vault, node json.RawMessage) ([]string, error) {
id := outboundID(node)
keys := []string{cooldownKey("node:" + id), cooldownKey(routeGroup(node))}
b, e := v.Get("payment-egress:" + id)
if errors.Is(e, sql.ErrNoRows) {
return keys, nil
}
if e != nil {
return nil, e
}
defer clear(b)
ip := net.ParseIP(string(b))
if ip == nil {
return nil, errors.New("invalid recorded payment egress IP")
}
return append(keys, cooldownKey("ip:"+ip.String())), nil
}
func nodeCoolingUntil(v *vault.Vault, node json.RawMessage) (int64, error) {
keys, e := nodeCooldownKeys(v, node)
if e != nil {
return 0, e
}
var until int64
for _, key := range keys {
b, e := v.Get(key)
if errors.Is(e, sql.ErrNoRows) {
continue
}
if e != nil {
return 0, e
}
var c nodeCooldown
e = json.Unmarshal(b, &c)
clear(b)
if e != nil {
return 0, errors.New("invalid node cooldown record")
}
if c.Until > until {
until = c.Until
}
}
return until, nil
}
func coolPaymentNode(v *vault.Vault, node json.RawMessage) error {
paymentRouteMu.Lock()
defer paymentRouteMu.Unlock()
return coolPaymentNodeLocked(v, node, "transport_failure", paymentNodeCooldown)
}
// coolPaymentNodeLocked cools the node, its server group and any shared egress
// IP so a flagged exit is skipped for every card. Caller must hold
// paymentRouteMu.
func coolPaymentNodeLocked(v *vault.Vault, node json.RawMessage, reason string, window time.Duration) error {
keys, e := nodeCooldownKeys(v, node)
if e != nil {
return e
}
until := time.Now().Add(window).Unix()
old, e := nodeCoolingUntil(v, node)
if e != nil {
return e
}
if old > until {
until = old
}
b, _ := json.Marshal(nodeCooldown{Until: until, Reason: reason})
defer clear(b)
for _, key := range keys {
if e = v.Put(key, b); e != nil {
return e
}
}
return nil
}
func availablePaymentNodes(v *vault.Vault, nodes []json.RawMessage) ([]json.RawMessage, error) {
available := make([]json.RawMessage, 0, len(nodes))
now := time.Now().Unix()
for _, node := range nodes {
until, e := nodeCoolingUntil(v, node)
if e != nil {
return nil, e
}
if until <= now {
available = append(available, node)
}
}
return available, nil
}